|
|
|
|
@@ -18,6 +18,7 @@ export interface MembershipGrantCardType {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface MembershipGrantResult {
|
|
|
|
|
readonly startDate: Date
|
|
|
|
|
readonly remainingTimes: number | null
|
|
|
|
|
readonly expireDate: Date
|
|
|
|
|
readonly totalTimes: number | null
|
|
|
|
|
@@ -34,6 +35,7 @@ export interface MembershipRenewalHint {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface RenewalHintMembership {
|
|
|
|
|
readonly startDate?: Date | string
|
|
|
|
|
readonly cardTypeId: string
|
|
|
|
|
readonly remainingTimes: number | null
|
|
|
|
|
readonly expireDate: Date | string
|
|
|
|
|
@@ -55,6 +57,30 @@ export function canRenewCardType(type: CardTypeCategory): boolean {
|
|
|
|
|
return type !== CardTypeCategory.TRIAL
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function isMembershipExpired(membership: { readonly status: string; readonly expireDate: Date | string }, now: Date = new Date()): boolean {
|
|
|
|
|
return membership.status === MembershipStatus.EXPIRED || new Date(membership.expireDate).getTime() <= now.getTime()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Effective benefits at read time; retain the stored balance for historical auditing. */
|
|
|
|
|
export function getMembershipEffectiveState(membership: {
|
|
|
|
|
readonly startDate?: Date | string
|
|
|
|
|
readonly status: string
|
|
|
|
|
readonly expireDate: Date | string
|
|
|
|
|
readonly remainingTimes: number | null
|
|
|
|
|
readonly cardType: { readonly type: string }
|
|
|
|
|
}, now: Date = new Date()): { status: MembershipStatus; remainingTimes: number | null } {
|
|
|
|
|
const expired = isMembershipExpired(membership, now)
|
|
|
|
|
const future = membership.startDate != null && new Date(membership.startDate).getTime() > now.getTime()
|
|
|
|
|
return {
|
|
|
|
|
status: expired ? MembershipStatus.EXPIRED : future ? MembershipStatus.SCHEDULED
|
|
|
|
|
: membership.status === MembershipStatus.SCHEDULED
|
|
|
|
|
? membership.remainingTimes === 0 ? MembershipStatus.USED_UP : MembershipStatus.ACTIVE
|
|
|
|
|
: membership.status as MembershipStatus,
|
|
|
|
|
remainingTimes: expired && membership.cardType.type === CardTypeCategory.DURATION && membership.remainingTimes !== null
|
|
|
|
|
? 0 : membership.remainingTimes,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function computeMembershipGrant(params: {
|
|
|
|
|
readonly existing: MembershipGrantExisting | null
|
|
|
|
|
readonly cardType: MembershipGrantCardType
|
|
|
|
|
@@ -64,6 +90,7 @@ export function computeMembershipGrant(params: {
|
|
|
|
|
|
|
|
|
|
if (!existing || cardType.type === CardTypeCategory.TRIAL) {
|
|
|
|
|
return {
|
|
|
|
|
startDate: now,
|
|
|
|
|
remainingTimes: cardType.totalTimes ?? null,
|
|
|
|
|
expireDate: addDurationDays(now, cardType.durationDays),
|
|
|
|
|
totalTimes: cardType.totalTimes ?? null,
|
|
|
|
|
@@ -72,13 +99,17 @@ export function computeMembershipGrant(params: {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const existingExpire = new Date(existing.expireDate)
|
|
|
|
|
const isExpired =
|
|
|
|
|
existing.status === MembershipStatus.EXPIRED || existingExpire.getTime() <= now.getTime()
|
|
|
|
|
const baseDate = existingExpire.getTime() > now.getTime() ? existingExpire : now
|
|
|
|
|
const isExpired = isMembershipExpired(existing, now)
|
|
|
|
|
const baseDate = isExpired ? now : existingExpire
|
|
|
|
|
const expireDate = addDurationDays(baseDate, cardType.durationDays)
|
|
|
|
|
|
|
|
|
|
if (cardType.type === CardTypeCategory.DURATION) {
|
|
|
|
|
return { startDate: baseDate, remainingTimes: cardType.totalTimes, totalTimes: cardType.totalTimes, expireDate, isRenewal: true }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (cardType.totalTimes == null) {
|
|
|
|
|
return {
|
|
|
|
|
startDate: now,
|
|
|
|
|
remainingTimes: null,
|
|
|
|
|
expireDate,
|
|
|
|
|
totalTimes: null,
|
|
|
|
|
@@ -88,6 +119,7 @@ export function computeMembershipGrant(params: {
|
|
|
|
|
|
|
|
|
|
if (isExpired) {
|
|
|
|
|
return {
|
|
|
|
|
startDate: now,
|
|
|
|
|
remainingTimes: cardType.totalTimes,
|
|
|
|
|
expireDate: addDurationDays(now, cardType.durationDays),
|
|
|
|
|
totalTimes: cardType.totalTimes,
|
|
|
|
|
@@ -96,6 +128,7 @@ export function computeMembershipGrant(params: {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
startDate: now,
|
|
|
|
|
remainingTimes: (existing.remainingTimes ?? 0) + cardType.totalTimes,
|
|
|
|
|
expireDate,
|
|
|
|
|
totalTimes: (existing.totalTimes ?? existing.remainingTimes ?? 0) + cardType.totalTimes,
|
|
|
|
|
@@ -112,12 +145,14 @@ export function pickRenewalTarget<T extends RenewalHintMembership>(
|
|
|
|
|
.slice()
|
|
|
|
|
.sort((a, b) => new Date(b.expireDate).getTime() - new Date(a.expireDate).getTime())
|
|
|
|
|
|
|
|
|
|
if (matches[0]?.cardType.type === CardTypeCategory.DURATION) {
|
|
|
|
|
return matches.find(m => m.status !== MembershipStatus.EXPIRED) ?? matches[0]
|
|
|
|
|
}
|
|
|
|
|
return matches[0] ?? null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isUsableMembership(membership: RenewalHintMembership, now: Date): boolean {
|
|
|
|
|
if (membership.status !== MembershipStatus.ACTIVE) return false
|
|
|
|
|
if (new Date(membership.expireDate).getTime() <= now.getTime()) return false
|
|
|
|
|
if (getMembershipEffectiveState(membership, now).status !== MembershipStatus.ACTIVE) return false
|
|
|
|
|
return membership.remainingTimes === null || membership.remainingTimes > 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -187,6 +222,7 @@ export function getMembershipRenewalHint(
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (memberships.length === 0) return null
|
|
|
|
|
if (memberships.some(m => getMembershipEffectiveState(m, now).status === MembershipStatus.SCHEDULED)) return null
|
|
|
|
|
|
|
|
|
|
const renewable = memberships
|
|
|
|
|
.filter((m) => canRenewCardType(m.cardType.type))
|
|
|
|
|
@@ -197,7 +233,26 @@ export function getMembershipRenewalHint(
|
|
|
|
|
return {
|
|
|
|
|
kind: 'expired',
|
|
|
|
|
cardTypeId: recommended?.cardTypeId ?? null,
|
|
|
|
|
remainingTimes: recommended?.remainingTimes ?? null,
|
|
|
|
|
remainingTimes: recommended ? getMembershipEffectiveState(recommended, now).remainingTimes : null,
|
|
|
|
|
daysLeft: recommended ? getMembershipDaysLeft(recommended.expireDate, now) : null,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Studio dates are stored as UTC date-only values; clock times are China Standard Time. */
|
|
|
|
|
export function getStudioSlotRange(date: Date | string, startTime: string, endTime: string): { start: Date; end: Date } {
|
|
|
|
|
const day = typeof date === 'string' ? date.slice(0, 10) : date.toISOString().slice(0, 10)
|
|
|
|
|
return { start: new Date(`${day}T${startTime.slice(0, 5)}:00+08:00`), end: new Date(`${day}T${endTime.slice(0, 5)}:00+08:00`) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function canUseMembershipForSlot(membership: {
|
|
|
|
|
readonly startDate?: Date | string; readonly expireDate: Date | string; readonly status: string
|
|
|
|
|
readonly remainingTimes: number | null; readonly cardType: { readonly type: string }
|
|
|
|
|
}, slot: { readonly date: Date | string; readonly startTime: string; readonly endTime: string }, now: Date = new Date()): boolean {
|
|
|
|
|
const state = getMembershipEffectiveState(membership, now)
|
|
|
|
|
if (state.status !== MembershipStatus.ACTIVE && state.status !== MembershipStatus.SCHEDULED) return false
|
|
|
|
|
if (membership.remainingTimes !== null && membership.remainingTimes <= 0) return false
|
|
|
|
|
if (membership.cardType.type !== CardTypeCategory.DURATION) return state.status === MembershipStatus.ACTIVE
|
|
|
|
|
const { start, end } = getStudioSlotRange(slot.date, slot.startTime, slot.endTime)
|
|
|
|
|
return Number.isFinite(start.getTime()) && end > start
|
|
|
|
|
&& (membership.startDate == null || start >= new Date(membership.startDate)) && end <= new Date(membership.expireDate)
|
|
|
|
|
}
|
|
|
|
|
|