feat: 下线支付功能; 月卡生效时间从第一次约课开始

This commit is contained in:
richarjiang
2026-10-30 20:51:21 +08:00
parent a192bbb186
commit 21558f841d
42 changed files with 490 additions and 196 deletions

View File

@@ -58,6 +58,8 @@ export {
addDurationDays,
getMembershipDaysLeft,
isMembershipExpired,
isMembershipAwaitingCheckIn,
getStudioDayStart,
getMembershipEffectiveState,
getStudioSlotRange,
canUseMembershipForSlot,

View File

@@ -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)
}

View File

@@ -53,8 +53,8 @@ export interface MembershipContractSummary {
readonly cardName: string
readonly kind: ContractKind
readonly amount: number
readonly startDate: string
readonly expireDate: string
readonly startDate: string | null
readonly expireDate: string | null
readonly createdAt: string
readonly signedAt: string | null
readonly voidReason: string | null
@@ -132,8 +132,8 @@ export interface LegacyContractCandidate {
readonly kind: ContractKind
readonly remainingTimes: number | null
readonly totalTimes: number | null
readonly startDate: string
readonly expireDate: string
readonly startDate: string | null
readonly expireDate: string | null
}
export interface LegacyContractSyncResult {

View File

@@ -7,8 +7,9 @@ export interface Membership {
readonly cardTypeId: string
readonly remainingTimes: number | null
readonly totalTimes: number | null
readonly startDate: string
readonly expireDate: string
readonly startDate: string | null
readonly expireDate: string | null
readonly activationDurationDays?: number | null
readonly status: MembershipStatus
readonly createdAt: string
readonly updatedAt: string