feat: 支持同卡种续卡叠加并修复过期登录态

支付成功后将次数或有效期叠加到已有会员卡,首页与详情页引导续卡;接口 401 时同步清掉本地登录态。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
richarjiang
2026-09-07 14:04:11 +08:00
parent 801e2f47da
commit 88cd8419c8
22 changed files with 1370 additions and 123 deletions

View File

@@ -0,0 +1,295 @@
import {
CardTypeCategory,
MembershipStatus,
RENEWAL_DAYS_THRESHOLD,
RENEWAL_TIMES_THRESHOLD,
computeMembershipGrant,
getMembershipRenewalHint,
pickRenewalTarget,
} 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 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)
})
})
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('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('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()
})
})