393 lines
13 KiB
TypeScript
393 lines
13 KiB
TypeScript
import {
|
|
CardTypeCategory,
|
|
MembershipStatus,
|
|
RENEWAL_DAYS_THRESHOLD,
|
|
RENEWAL_TIMES_THRESHOLD,
|
|
computeMembershipGrant,
|
|
getMembershipRenewalHint,
|
|
pickRenewalTarget,
|
|
getMembershipEffectiveState,
|
|
} from '@mp-pilates/shared'
|
|
import type { RenewalHintMembership } from '@mp-pilates/shared'
|
|
|
|
const now = new Date('2026-06-01T00:00:00Z')
|
|
|
|
const timesCard = {
|
|
type: CardTypeCategory.TIMES,
|
|
totalTimes: 10,
|
|
durationDays: 90,
|
|
}
|
|
|
|
const durationCard = {
|
|
type: CardTypeCategory.DURATION,
|
|
totalTimes: null,
|
|
durationDays: 30,
|
|
}
|
|
|
|
const limitedDurationCard = {
|
|
type: CardTypeCategory.DURATION,
|
|
totalTimes: 10,
|
|
durationDays: 30,
|
|
}
|
|
|
|
const trialCard = {
|
|
type: CardTypeCategory.TRIAL,
|
|
totalTimes: 1,
|
|
durationDays: 7,
|
|
}
|
|
|
|
function membership(overrides: Partial<RenewalHintMembership> & { cardTypeId: string }): RenewalHintMembership {
|
|
return {
|
|
remainingTimes: null,
|
|
expireDate: '2026-12-01T00:00:00.000Z',
|
|
status: MembershipStatus.ACTIVE,
|
|
cardType: { type: CardTypeCategory.TIMES },
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
describe('computeMembershipGrant', () => {
|
|
it('creates a fresh grant when there is no existing membership', () => {
|
|
const result = computeMembershipGrant({ existing: null, cardType: timesCard, now })
|
|
|
|
expect(result.isRenewal).toBe(false)
|
|
expect(result.remainingTimes).toBe(10)
|
|
expect(result.totalTimes).toBe(10)
|
|
expect(result.expireDate.getTime()).toBe(now.getTime() + 90 * 86_400_000)
|
|
})
|
|
|
|
it('always treats TRIAL as a new grant even if an old trial exists', () => {
|
|
const result = computeMembershipGrant({
|
|
existing: {
|
|
remainingTimes: 0,
|
|
totalTimes: 1,
|
|
expireDate: '2026-08-01T00:00:00.000Z',
|
|
status: MembershipStatus.ACTIVE,
|
|
},
|
|
cardType: trialCard,
|
|
now,
|
|
})
|
|
|
|
expect(result.isRenewal).toBe(false)
|
|
expect(result.remainingTimes).toBe(1)
|
|
expect(result.totalTimes).toBe(1)
|
|
})
|
|
|
|
it('stacks times and extends expireDate for an active TIMES card', () => {
|
|
const expireDate = new Date('2026-09-01T00:00:00Z')
|
|
const result = computeMembershipGrant({
|
|
existing: {
|
|
remainingTimes: 3,
|
|
totalTimes: 10,
|
|
expireDate,
|
|
status: MembershipStatus.ACTIVE,
|
|
},
|
|
cardType: timesCard,
|
|
now,
|
|
})
|
|
|
|
expect(result.isRenewal).toBe(true)
|
|
expect(result.remainingTimes).toBe(13)
|
|
expect(result.totalTimes).toBe(20)
|
|
expect(result.expireDate.getTime()).toBe(expireDate.getTime() + 90 * 86_400_000)
|
|
})
|
|
|
|
it('does not inherit leftover times when the TIMES card is expired', () => {
|
|
const result = computeMembershipGrant({
|
|
existing: {
|
|
remainingTimes: 4,
|
|
totalTimes: 10,
|
|
expireDate: '2026-01-01T00:00:00.000Z',
|
|
status: MembershipStatus.EXPIRED,
|
|
},
|
|
cardType: timesCard,
|
|
now,
|
|
})
|
|
|
|
expect(result.isRenewal).toBe(true)
|
|
expect(result.remainingTimes).toBe(10)
|
|
expect(result.totalTimes).toBe(10)
|
|
expect(result.expireDate.getTime()).toBe(now.getTime() + 90 * 86_400_000)
|
|
})
|
|
|
|
it('extends a still-active DURATION card from its current expireDate', () => {
|
|
const expireDate = new Date('2026-07-01T00:00:00Z')
|
|
const result = computeMembershipGrant({
|
|
existing: {
|
|
remainingTimes: null,
|
|
totalTimes: null,
|
|
expireDate,
|
|
status: MembershipStatus.ACTIVE,
|
|
},
|
|
cardType: durationCard,
|
|
now,
|
|
})
|
|
|
|
expect(result.isRenewal).toBe(true)
|
|
expect(result.remainingTimes).toBeNull()
|
|
expect(result.expireDate.getTime()).toBe(expireDate.getTime() + 30 * 86_400_000)
|
|
})
|
|
|
|
it('starts an expired DURATION card from now', () => {
|
|
const result = computeMembershipGrant({
|
|
existing: {
|
|
remainingTimes: null,
|
|
totalTimes: null,
|
|
expireDate: '2026-01-01T00:00:00.000Z',
|
|
status: MembershipStatus.EXPIRED,
|
|
},
|
|
cardType: durationCard,
|
|
now,
|
|
})
|
|
|
|
expect(result.isRenewal).toBe(true)
|
|
expect(result.expireDate.getTime()).toBe(now.getTime() + 30 * 86_400_000)
|
|
})
|
|
|
|
it.each([
|
|
{ expireDate: '2026-05-01T00:00:00.000Z', status: MembershipStatus.EXPIRED },
|
|
{ expireDate: '2026-05-01T00:00:00.000Z', status: MembershipStatus.ACTIVE },
|
|
{ expireDate: now.toISOString(), status: MembershipStatus.ACTIVE },
|
|
{ expireDate: '2026-07-01T00:00:00.000Z', status: MembershipStatus.EXPIRED },
|
|
{ expireDate: '2026-05-01T00:00:00.000Z', status: MembershipStatus.USED_UP },
|
|
])('resets an expired 10-session monthly plan with 6 unused sessions: %j', expiry => {
|
|
const result = computeMembershipGrant({ existing: { remainingTimes: 6, totalTimes: 10, ...expiry }, cardType: limitedDurationCard, now })
|
|
expect(result).toEqual({ startDate: now, isRenewal: true, remainingTimes: 10, totalTimes: 10, expireDate: new Date(now.getTime() + 30 * 86_400_000) })
|
|
})
|
|
|
|
it('does not extend an explicitly expired unlimited monthly plan from a future stored date', () => {
|
|
const result = computeMembershipGrant({ existing: { remainingTimes: null, totalTimes: null, expireDate: '2026-07-01T00:00:00.000Z', status: MembershipStatus.EXPIRED }, cardType: durationCard, now })
|
|
expect(result.expireDate.getTime()).toBe(now.getTime() + 30 * 86_400_000)
|
|
})
|
|
|
|
it('keeps a renewed monthly quota separate and schedules it after the current period', () => {
|
|
const expireDate = new Date('2026-07-01T00:00:00Z')
|
|
const result = computeMembershipGrant({
|
|
existing: {
|
|
remainingTimes: 3,
|
|
totalTimes: 10,
|
|
expireDate,
|
|
status: MembershipStatus.ACTIVE,
|
|
},
|
|
cardType: limitedDurationCard,
|
|
now,
|
|
})
|
|
|
|
expect(result).toMatchObject({
|
|
isRenewal: true,
|
|
remainingTimes: 10,
|
|
totalTimes: 10,
|
|
startDate: expireDate,
|
|
})
|
|
expect(result.expireDate.getTime()).toBe(expireDate.getTime() + 30 * 86_400_000)
|
|
})
|
|
})
|
|
|
|
describe('Effective monthly-card benefits', () => {
|
|
it('expires unused monthly sessions immediately even before the scheduled status update', () => {
|
|
const original = membership({ cardTypeId: 'monthly', remainingTimes: 6, status: MembershipStatus.ACTIVE, expireDate: now.toISOString(), cardType: { type: CardTypeCategory.DURATION } })
|
|
expect(getMembershipEffectiveState(original, now)).toEqual({ status: MembershipStatus.EXPIRED, remainingTimes: 0 })
|
|
expect(original.remainingTimes).toBe(6)
|
|
expect(original.status).toBe(MembershipStatus.ACTIVE)
|
|
})
|
|
it('keeps unused monthly sessions available until the exact expiry instant', () => {
|
|
const original = membership({ cardTypeId: 'monthly', remainingTimes: 6, expireDate: new Date(now.getTime() + 1).toISOString(), cardType: { type: CardTypeCategory.DURATION } })
|
|
expect(getMembershipEffectiveState(original, now)).toEqual({ status: MembershipStatus.ACTIVE, remainingTimes: 6 })
|
|
})
|
|
it('honors explicit EXPIRED status even when an administrator left a future date', () => {
|
|
const original = membership({ cardTypeId: 'monthly', remainingTimes: 6, status: MembershipStatus.EXPIRED, cardType: { type: CardTypeCategory.DURATION } })
|
|
expect(getMembershipEffectiveState(original, now)).toEqual({ status: MembershipStatus.EXPIRED, remainingTimes: 0 })
|
|
})
|
|
it('keeps expired times-card balances for history without marking them usable', () => {
|
|
const original = membership({ cardTypeId: 'times', remainingTimes: 6, expireDate: now.toISOString() })
|
|
expect(getMembershipEffectiveState(original, now)).toEqual({ status: MembershipStatus.EXPIRED, remainingTimes: 6 })
|
|
})
|
|
it('preserves the unlimited-plan representation after expiry', () => {
|
|
const original = membership({ cardTypeId: 'monthly', remainingTimes: null, expireDate: now.toISOString(), cardType: { type: CardTypeCategory.DURATION } })
|
|
expect(getMembershipEffectiveState(original, now)).toEqual({ status: MembershipStatus.EXPIRED, remainingTimes: null })
|
|
})
|
|
})
|
|
|
|
describe('getMembershipRenewalHint', () => {
|
|
it('prefers a TIMES card running low over a DURATION card near expiry', () => {
|
|
const hint = getMembershipRenewalHint(
|
|
[
|
|
membership({
|
|
cardTypeId: 'duration-1',
|
|
remainingTimes: null,
|
|
expireDate: new Date(now.getTime() + 3 * 86_400_000).toISOString(),
|
|
cardType: { type: CardTypeCategory.DURATION },
|
|
}),
|
|
membership({
|
|
cardTypeId: 'times-1',
|
|
remainingTimes: RENEWAL_TIMES_THRESHOLD,
|
|
expireDate: new Date(now.getTime() + 60 * 86_400_000).toISOString(),
|
|
cardType: { type: CardTypeCategory.TIMES },
|
|
}),
|
|
],
|
|
now,
|
|
)
|
|
|
|
expect(hint).toMatchObject({
|
|
kind: 'times_low',
|
|
cardTypeId: 'times-1',
|
|
remainingTimes: RENEWAL_TIMES_THRESHOLD,
|
|
})
|
|
})
|
|
|
|
it('returns days_low when a DURATION card is within the threshold', () => {
|
|
const hint = getMembershipRenewalHint(
|
|
[
|
|
membership({
|
|
cardTypeId: 'duration-1',
|
|
remainingTimes: null,
|
|
expireDate: new Date(now.getTime() + RENEWAL_DAYS_THRESHOLD * 86_400_000).toISOString(),
|
|
cardType: { type: CardTypeCategory.DURATION },
|
|
}),
|
|
],
|
|
now,
|
|
)
|
|
|
|
expect(hint).toMatchObject({
|
|
kind: 'days_low',
|
|
cardTypeId: 'duration-1',
|
|
daysLeft: RENEWAL_DAYS_THRESHOLD,
|
|
})
|
|
})
|
|
|
|
it('returns times_low for a count-limited DURATION card', () => {
|
|
const hint = getMembershipRenewalHint(
|
|
[
|
|
membership({
|
|
cardTypeId: 'limited-duration-1',
|
|
remainingTimes: RENEWAL_TIMES_THRESHOLD,
|
|
expireDate: new Date(now.getTime() + 20 * 86_400_000).toISOString(),
|
|
cardType: { type: CardTypeCategory.DURATION },
|
|
}),
|
|
],
|
|
now,
|
|
)
|
|
|
|
expect(hint).toMatchObject({
|
|
kind: 'times_low',
|
|
cardTypeId: 'limited-duration-1',
|
|
remainingTimes: RENEWAL_TIMES_THRESHOLD,
|
|
})
|
|
})
|
|
|
|
it('does not recommend renewing a TRIAL card when times run low', () => {
|
|
const hint = getMembershipRenewalHint(
|
|
[
|
|
membership({
|
|
cardTypeId: 'trial-1',
|
|
remainingTimes: 1,
|
|
expireDate: new Date(now.getTime() + 5 * 86_400_000).toISOString(),
|
|
cardType: { type: CardTypeCategory.TRIAL },
|
|
}),
|
|
],
|
|
now,
|
|
)
|
|
|
|
expect(hint).toMatchObject({
|
|
kind: 'trial_low',
|
|
cardTypeId: null,
|
|
remainingTimes: 1,
|
|
})
|
|
})
|
|
|
|
it('recommends the most recently expired non-trial card', () => {
|
|
const hint = getMembershipRenewalHint(
|
|
[
|
|
membership({
|
|
cardTypeId: 'times-old',
|
|
remainingTimes: 0,
|
|
expireDate: '2026-01-01T00:00:00.000Z',
|
|
status: MembershipStatus.EXPIRED,
|
|
cardType: { type: CardTypeCategory.TIMES },
|
|
}),
|
|
membership({
|
|
cardTypeId: 'duration-latest',
|
|
remainingTimes: null,
|
|
expireDate: '2026-05-01T00:00:00.000Z',
|
|
status: MembershipStatus.EXPIRED,
|
|
cardType: { type: CardTypeCategory.DURATION },
|
|
}),
|
|
membership({
|
|
cardTypeId: 'trial-1',
|
|
remainingTimes: 0,
|
|
expireDate: '2026-05-15T00:00:00.000Z',
|
|
status: MembershipStatus.USED_UP,
|
|
cardType: { type: CardTypeCategory.TRIAL },
|
|
}),
|
|
],
|
|
now,
|
|
)
|
|
|
|
expect(hint).toMatchObject({
|
|
kind: 'expired',
|
|
cardTypeId: 'duration-latest',
|
|
})
|
|
})
|
|
|
|
it('returns null when active cards are healthy', () => {
|
|
const hint = getMembershipRenewalHint(
|
|
[
|
|
membership({
|
|
cardTypeId: 'times-1',
|
|
remainingTimes: 8,
|
|
expireDate: new Date(now.getTime() + 60 * 86_400_000).toISOString(),
|
|
}),
|
|
],
|
|
now,
|
|
)
|
|
|
|
expect(hint).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('pickRenewalTarget', () => {
|
|
it('queues monthly purchases after the live tail instead of an explicitly expired future-dated record', () => {
|
|
const target = pickRenewalTarget([
|
|
membership({cardTypeId:'monthly',expireDate:'2099-01-01T00:00:00Z',status:MembershipStatus.EXPIRED,cardType:{type:CardTypeCategory.DURATION}}),
|
|
membership({cardTypeId:'monthly',startDate:'2026-07-01T00:00:00Z',expireDate:'2026-07-31T00:00:00Z',status:MembershipStatus.SCHEDULED,cardType:{type:CardTypeCategory.DURATION}}),
|
|
],'monthly')
|
|
expect(target?.expireDate).toBe('2026-07-31T00:00:00Z')
|
|
})
|
|
it('picks the latest expiring membership of the same card type', () => {
|
|
const target = pickRenewalTarget(
|
|
[
|
|
membership({
|
|
cardTypeId: 'times-1',
|
|
expireDate: '2026-01-01T00:00:00.000Z',
|
|
}),
|
|
membership({
|
|
cardTypeId: 'times-1',
|
|
expireDate: '2026-08-01T00:00:00.000Z',
|
|
}),
|
|
membership({
|
|
cardTypeId: 'duration-1',
|
|
expireDate: '2026-12-01T00:00:00.000Z',
|
|
cardType: { type: CardTypeCategory.DURATION },
|
|
}),
|
|
],
|
|
'times-1',
|
|
)
|
|
|
|
expect(target?.expireDate).toBe('2026-08-01T00:00:00.000Z')
|
|
})
|
|
|
|
it('does not pick a TRIAL membership as a renewal target', () => {
|
|
const target = pickRenewalTarget(
|
|
[
|
|
membership({
|
|
cardTypeId: 'trial-1',
|
|
cardType: { type: CardTypeCategory.TRIAL },
|
|
}),
|
|
],
|
|
'trial-1',
|
|
)
|
|
|
|
expect(target).toBeNull()
|
|
})
|
|
})
|