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 & { 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() }) })