|
|
|
|
@@ -7,7 +7,7 @@ export const MEMBERSHIP_DAY_MS = 86_400_000
|
|
|
|
|
export interface MembershipGrantExisting {
|
|
|
|
|
readonly remainingTimes: number | null
|
|
|
|
|
readonly totalTimes: number | null
|
|
|
|
|
readonly expireDate: Date | string
|
|
|
|
|
readonly expireDate: Date | string | null
|
|
|
|
|
readonly status: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -18,9 +18,9 @@ export interface MembershipGrantCardType {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface MembershipGrantResult {
|
|
|
|
|
readonly startDate: Date
|
|
|
|
|
readonly startDate: Date | null
|
|
|
|
|
readonly remainingTimes: number | null
|
|
|
|
|
readonly expireDate: Date
|
|
|
|
|
readonly expireDate: Date | null
|
|
|
|
|
readonly totalTimes: number | null
|
|
|
|
|
readonly isRenewal: boolean
|
|
|
|
|
}
|
|
|
|
|
@@ -35,10 +35,10 @@ export interface MembershipRenewalHint {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface RenewalHintMembership {
|
|
|
|
|
readonly startDate?: Date | string
|
|
|
|
|
readonly startDate?: Date | string | null
|
|
|
|
|
readonly cardTypeId: string
|
|
|
|
|
readonly remainingTimes: number | null
|
|
|
|
|
readonly expireDate: Date | string
|
|
|
|
|
readonly expireDate: Date | string | null
|
|
|
|
|
readonly status: MembershipStatus
|
|
|
|
|
readonly cardType: {
|
|
|
|
|
readonly type: CardTypeCategory
|
|
|
|
|
@@ -49,7 +49,8 @@ export function addDurationDays(from: Date, durationDays: number): Date {
|
|
|
|
|
return new Date(from.getTime() + durationDays * MEMBERSHIP_DAY_MS)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getMembershipDaysLeft(expireDate: Date | string, now: Date = new Date()): number {
|
|
|
|
|
export function getMembershipDaysLeft(expireDate: Date | string | null, now: Date = new Date()): number {
|
|
|
|
|
if (expireDate === null) return 0
|
|
|
|
|
return Math.ceil((new Date(expireDate).getTime() - now.getTime()) / MEMBERSHIP_DAY_MS)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -57,18 +58,33 @@ 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()
|
|
|
|
|
export function isMembershipExpired(membership: { readonly status: string; readonly expireDate: Date | string | null }, now: Date = new Date()): boolean {
|
|
|
|
|
return membership.status === MembershipStatus.EXPIRED || (membership.expireDate !== null && new Date(membership.expireDate).getTime() <= now.getTime())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function isMembershipAwaitingCheckIn(membership: {
|
|
|
|
|
readonly startDate?: Date | string | null; readonly expireDate: Date | string | null
|
|
|
|
|
readonly status: string; readonly cardType: { readonly type: string }
|
|
|
|
|
}): boolean {
|
|
|
|
|
return membership.cardType.type === CardTypeCategory.DURATION && membership.startDate === null
|
|
|
|
|
&& membership.expireDate === null && membership.status !== MembershipStatus.EXPIRED
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** The activation day counts as day one, in the studio's China time zone. */
|
|
|
|
|
export function getStudioDayStart(now: Date): Date {
|
|
|
|
|
const day = new Date(now.getTime() + 8 * 3600_000).toISOString().slice(0, 10)
|
|
|
|
|
return new Date(`${day}T00:00:00+08:00`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Effective benefits at read time; retain the stored balance for historical auditing. */
|
|
|
|
|
export function getMembershipEffectiveState(membership: {
|
|
|
|
|
readonly startDate?: Date | string
|
|
|
|
|
readonly startDate?: Date | string | null
|
|
|
|
|
readonly status: string
|
|
|
|
|
readonly expireDate: Date | string
|
|
|
|
|
readonly expireDate: Date | string | null
|
|
|
|
|
readonly remainingTimes: number | null
|
|
|
|
|
readonly cardType: { readonly type: string }
|
|
|
|
|
}, now: Date = new Date()): { status: MembershipStatus; remainingTimes: number | null } {
|
|
|
|
|
if (isMembershipAwaitingCheckIn(membership)) return { status: MembershipStatus.SCHEDULED, remainingTimes: membership.remainingTimes }
|
|
|
|
|
const expired = isMembershipExpired(membership, now)
|
|
|
|
|
const future = membership.startDate != null && new Date(membership.startDate).getTime() > now.getTime()
|
|
|
|
|
return {
|
|
|
|
|
@@ -88,6 +104,10 @@ export function computeMembershipGrant(params: {
|
|
|
|
|
}): MembershipGrantResult {
|
|
|
|
|
const { existing, cardType, now } = params
|
|
|
|
|
|
|
|
|
|
if (cardType.type === CardTypeCategory.DURATION) {
|
|
|
|
|
return { startDate: null, expireDate: null, remainingTimes: cardType.totalTimes, totalTimes: cardType.totalTimes, isRenewal: existing !== null }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!existing || cardType.type === CardTypeCategory.TRIAL) {
|
|
|
|
|
return {
|
|
|
|
|
startDate: now,
|
|
|
|
|
@@ -98,15 +118,11 @@ export function computeMembershipGrant(params: {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const existingExpire = new Date(existing.expireDate)
|
|
|
|
|
const existingExpire = existing.expireDate === null ? now : new Date(existing.expireDate)
|
|
|
|
|
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,
|
|
|
|
|
@@ -143,7 +159,7 @@ export function pickRenewalTarget<T extends RenewalHintMembership>(
|
|
|
|
|
const matches = memberships
|
|
|
|
|
.filter((m) => m.cardTypeId === cardTypeId && canRenewCardType(m.cardType.type))
|
|
|
|
|
.slice()
|
|
|
|
|
.sort((a, b) => new Date(b.expireDate).getTime() - new Date(a.expireDate).getTime())
|
|
|
|
|
.sort((a, b) => (b.expireDate ? new Date(b.expireDate).getTime() : Infinity) - (a.expireDate ? new Date(a.expireDate).getTime() : Infinity))
|
|
|
|
|
|
|
|
|
|
if (matches[0]?.cardType.type === CardTypeCategory.DURATION) {
|
|
|
|
|
return matches.find(m => m.status !== MembershipStatus.EXPIRED) ?? matches[0]
|
|
|
|
|
@@ -227,7 +243,7 @@ export function getMembershipRenewalHint(
|
|
|
|
|
const renewable = memberships
|
|
|
|
|
.filter((m) => canRenewCardType(m.cardType.type))
|
|
|
|
|
.slice()
|
|
|
|
|
.sort((a, b) => new Date(b.expireDate).getTime() - new Date(a.expireDate).getTime())
|
|
|
|
|
.sort((a, b) => (b.expireDate ? new Date(b.expireDate).getTime() : Infinity) - (a.expireDate ? new Date(a.expireDate).getTime() : Infinity))
|
|
|
|
|
|
|
|
|
|
const recommended = renewable[0]
|
|
|
|
|
return {
|
|
|
|
|
@@ -245,14 +261,22 @@ export function getStudioSlotRange(date: Date | string, startTime: string, endTi
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function canUseMembershipForSlot(membership: {
|
|
|
|
|
readonly startDate?: Date | string; readonly expireDate: Date | string; readonly status: string
|
|
|
|
|
readonly remainingTimes: number | null; readonly cardType: { readonly type: string }
|
|
|
|
|
readonly startDate?: Date | string | null; readonly expireDate: Date | string | null; readonly status: string
|
|
|
|
|
readonly activationDurationDays?: number | null
|
|
|
|
|
readonly remainingTimes: number | null; readonly cardType: { readonly type: string; readonly durationDays?: number }
|
|
|
|
|
}, 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)
|
|
|
|
|
if (isMembershipAwaitingCheckIn(membership)) {
|
|
|
|
|
const duration = membership.activationDurationDays ?? membership.cardType.durationDays ?? 0
|
|
|
|
|
const earliestActivation = getStudioDayStart(now)
|
|
|
|
|
// Any reservation must still fit if the first check-in happens today.
|
|
|
|
|
return Number.isFinite(start.getTime()) && end > start && start >= earliestActivation
|
|
|
|
|
&& end <= addDurationDays(earliestActivation, duration)
|
|
|
|
|
}
|
|
|
|
|
return Number.isFinite(start.getTime()) && end > start
|
|
|
|
|
&& (membership.startDate == null || start >= new Date(membership.startDate)) && end <= new Date(membership.expireDate)
|
|
|
|
|
&& membership.expireDate !== null && (membership.startDate == null || start >= new Date(membership.startDate)) && end <= new Date(membership.expireDate)
|
|
|
|
|
}
|
|
|
|
|
|