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

@@ -0,0 +1,5 @@
-- Preserve all historical periods. New monthly cards have no dates until first check-in.
ALTER TABLE `memberships`
MODIFY `start_date` DATETIME(3) NULL,
MODIFY `expire_date` DATETIME(3) NULL,
ADD COLUMN `activation_duration_days` INTEGER NULL;

View File

@@ -163,8 +163,9 @@ model Membership {
cardTypeId String @map("card_type_id")
remainingTimes Int? @map("remaining_times")
totalTimes Int? @map("total_times")
startDate DateTime @map("start_date")
expireDate DateTime @map("expire_date")
startDate DateTime? @map("start_date")
expireDate DateTime? @map("expire_date")
activationDurationDays Int? @map("activation_duration_days")
status MembershipStatus @default(ACTIVE)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

View File

@@ -1,7 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing'
import { JwtService } from '@nestjs/jwt'
import { UnauthorizedException } from '@nestjs/common'
import { MembershipStatus, UserRole } from '@mp-pilates/shared'
import { MembershipStatus, UserRole, CardTypeCategory } from '@mp-pilates/shared'
import { ConfigService } from '@nestjs/config'
import { AuthService, RANDOM_FN_TOKEN } from '../auth.service'
import { WechatService } from '../wechat.service'
@@ -231,9 +231,10 @@ describe('AuthService', () => {
where: {
userId: USER_ID,
status: { in: [MembershipStatus.ACTIVE, MembershipStatus.SCHEDULED] },
startDate: { lte: expect.any(Date) },
expireDate: { gt: expect.any(Date) },
OR: [{ remainingTimes: { gt: 0 } }, { remainingTimes: null }],
AND: [
{ OR: [{ startDate: { lte: expect.any(Date) }, expireDate: { gt: expect.any(Date) } }, { startDate: null, expireDate: null, cardType: { type: CardTypeCategory.DURATION } }] },
{ OR: [{ remainingTimes: { gt: 0 } }, { remainingTimes: null }] },
],
},
})
expect(result.user.activeMembershipCount).toBe(2)

View File

@@ -277,6 +277,43 @@ describe('BookingService', () => {
expect(tx.booking.create).toHaveBeenCalledWith(expect.objectContaining({data:expect.objectContaining({membershipId:planned.id})}))
expect(tx.membership.update).not.toHaveBeenCalled()
})
it('keeps a new monthly card undated through reservation and confirmation', async () => {
jest.useFakeTimers().setSystemTime(new Date('2099-12-30T00:00:00Z'))
try {
const pending = { ...mockLimitedDurationMembership, startDate: null, expireDate: null, activationDurationDays: 30, status: MembershipStatus.SCHEDULED }
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(pending)
tx.booking.create.mockResolvedValue({ ...mockConfirmedBooking, status: BookingStatus.PENDING_CONFIRMATION })
;(prisma.$transaction as jest.Mock).mockImplementation(fn => fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({ ...mockConfirmedBooking, timeSlot: mockOpenSlot, membership: pending })
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
await service.createBooking(MOCK_USER_ID, { timeSlotId: MOCK_SLOT_ID, membershipId: pending.id })
expect(tx.membership.update).not.toHaveBeenCalled()
tx.booking.findUnique.mockResolvedValue({ ...mockConfirmedBooking, status: BookingStatus.PENDING_CONFIRMATION, membershipId: pending.id, timeSlot: mockOpenSlot, membership: pending })
tx.booking.update.mockResolvedValue(mockConfirmedBooking)
;(prisma.user.findUnique as jest.Mock).mockResolvedValue(null)
studioService.getInfo.mockResolvedValue(mockStudioConfig as never)
await service.confirmBooking(MOCK_BOOKING_ID, MOCK_ADMIN_ID)
expect(tx.membership.update).toHaveBeenCalledWith({ where: { id: pending.id }, data: { remainingTimes: 4, status: MembershipStatus.SCHEDULED } })
expect(pending.startDate).toBeNull()
expect(pending.expireDate).toBeNull()
} finally { jest.useRealTimers() }
})
it('refunds cancellation to the pending card without setting activation dates', async () => {
const pending = { ...mockLimitedDurationMembership, startDate: null, expireDate: null, activationDurationDays: 30, status: MembershipStatus.SCHEDULED }
const tx = buildTxMock()
const booking = { ...mockConfirmedBooking, membershipId: pending.id, membership: pending, timeSlot: { ...mockOpenSlot, bookedCount: 1 } }
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(booking)
;(prisma.user.findUnique as jest.Mock).mockResolvedValue(null)
;(prisma.$transaction as jest.Mock).mockImplementation(fn => fn(tx))
studioService.getInfo.mockResolvedValue(mockStudioConfig as never)
tx.booking.update.mockResolvedValue({ ...booking, status: BookingStatus.CANCELLED })
const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
expect(result.refunded).toBe(true)
expect(tx.membership.update).toHaveBeenCalledWith({ where: { id: pending.id }, data: { remainingTimes: 6, status: MembershipStatus.SCHEDULED } })
})
it.each(['before','after'])('rejects a course %s the monthly period before writing a booking',async boundary=>{
const tx=buildTxMock()
const membership=boundary==='before'?{...planned,startDate:new Date('2100-01-01T00:00:00Z')}:{...planned,expireDate:new Date('2099-12-31T00:00:00Z')}

View File

@@ -0,0 +1,96 @@
import { BadRequestException } from '@nestjs/common'
import { BookingStatus, CardTypeCategory, MembershipStatus, canUseMembershipForSlot, getMembershipEffectiveState, getMembershipRenewalHint } from '@mp-pilates/shared'
import { BookingService } from '../booking.service'
const checkInTime = new Date('2026-10-30T16:05:00Z') // Oct 31, 00:05 in China
const pending = () => ({
id: 'monthly', userId: 'member', cardTypeId: 'monthly-type',
remainingTimes: 9, totalTimes: 10, startDate: null, expireDate: null,
activationDurationDays: 30, status: MembershipStatus.SCHEDULED,
cardType: { type: CardTypeCategory.DURATION, durationDays: 7 }, // Later card edits must not shorten purchased validity.
})
function setup(overrides: Record<string, unknown> = {}) {
const membership = { ...pending(), ...overrides }
const booking = { id: 'booking', userId: 'member', membershipId: membership.id, status: BookingStatus.CONFIRMED, membership }
const tx = {
$queryRaw: jest.fn(),
booking: { findUnique: jest.fn().mockResolvedValue(booking), update: jest.fn().mockImplementation(async ({ data }) => Object.assign(booking, data)) },
membership: { update: jest.fn().mockImplementation(async ({ data }) => Object.assign(membership, data)) },
bookingStatusHistory: { create: jest.fn() },
}
const db = { $transaction: jest.fn(async (fn) => fn(tx)), booking: { findUnique: jest.fn().mockImplementation(async () => booking) } }
const service = new BookingService(db as never, {} as never, {} as never, {} as never, { recordQualifiedTrialBooking: jest.fn() } as never)
return { tx, membership, booking, service }
}
afterEach(() => jest.useRealTimers())
describe('Monthly activation on the first completed booking', () => {
beforeEach(() => jest.useFakeTimers().setSystemTime(checkInTime))
it.each([9, 0, null])('starts on the China-local check-in day, preserving the quota and purchased 30 days: %s', async remainingTimes => {
const { service, tx, membership } = setup({ remainingTimes })
await service.completeBooking('booking', 'owner')
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(tx.booking.findUnique.mock.invocationCallOrder[0])
expect(membership).toMatchObject({
startDate: new Date('2026-10-30T16:00:00Z'), expireDate: new Date('2026-11-29T16:00:00Z'),
remainingTimes, status: remainingTimes === 0 ? MembershipStatus.USED_UP : MembershipStatus.ACTIVE,
})
expect(tx.booking.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ completedAt: checkInTime }) }))
})
it('does not activate a no-show', async () => {
const { service, tx, membership } = setup()
await service.markNoShow('booking', 'owner')
expect(membership.startDate).toBeNull()
expect(tx.membership.update).not.toHaveBeenCalled()
})
it('does not reset validity on a later completed booking or change a legacy monthly card', async () => {
const dates = { startDate: new Date('2026-10-01T00:00:00Z'), expireDate: new Date('2026-12-01T00:00:00Z'), status: MembershipStatus.ACTIVE }
const { service, tx, membership } = setup(dates)
await service.completeBooking('booking', 'owner')
expect(tx.membership.update).not.toHaveBeenCalled()
expect(membership).toMatchObject(dates)
})
it('rejects a duplicate completion without extending the card again', async () => {
const { service, tx } = setup()
await service.completeBooking('booking', 'owner')
await expect(service.completeBooking('booking', 'owner')).rejects.toBeInstanceOf(BadRequestException)
expect(tx.membership.update).toHaveBeenCalledTimes(1)
})
it('does not activate an unconfirmed booking', async () => {
const { service, booking, tx } = setup()
booking.status = BookingStatus.PENDING_CONFIRMATION
await expect(service.completeBooking('booking', 'owner')).rejects.toBeInstanceOf(BadRequestException)
expect(tx.membership.update).not.toHaveBeenCalled()
})
it('does not mark the booking completed if card activation fails', async () => {
const { service, tx } = setup()
tx.membership.update.mockRejectedValueOnce(new Error('write failed'))
await expect(service.completeBooking('booking', 'owner')).rejects.toThrow('write failed')
expect(tx.booking.update).not.toHaveBeenCalled()
expect(tx.bookingStatusHistory.create).not.toHaveBeenCalled()
})
})
describe('Pending monthly-card benefits and reservations', () => {
it('never expires or auto-activates while waiting, even with all sessions reserved', () => {
for (const remainingTimes of [9, 0, null]) {
expect(getMembershipEffectiveState({ ...pending(), remainingTimes }, new Date('2030-01-01'))).toEqual({ status: MembershipStatus.SCHEDULED, remainingTimes })
}
expect(getMembershipRenewalHint([pending()], checkInTime)).toBeNull()
})
it('allows a pending card to book, but rejects courses beyond its purchased duration or without quota', () => {
const slot = { date: '2026-11-01', startTime: '09:00', endTime: '10:00' }
expect(canUseMembershipForSlot(pending(), slot, checkInTime)).toBe(true)
expect(canUseMembershipForSlot({ ...pending(), remainingTimes: 0 }, slot, checkInTime)).toBe(false)
expect(canUseMembershipForSlot(pending(), { ...slot, date: '2026-11-30' }, checkInTime)).toBe(false)
expect(canUseMembershipForSlot({ ...pending(), remainingTimes: null }, slot, checkInTime)).toBe(true)
})
})

View File

@@ -9,6 +9,9 @@ import {
import { Booking, Membership, TimeSlot, BookingStatusHistory } from '@prisma/client'
import {
BookingStatus,
isMembershipAwaitingCheckIn,
getStudioDayStart,
addDurationDays,
CardTypeCategory,
CONTRACT_UNSIGNED_MESSAGE,
MembershipContractStatus,
@@ -129,7 +132,7 @@ export class BookingService {
}
// A card cannot be used after its validity period ends.
if (membership.expireDate <= new Date()) {
if (membership.expireDate && membership.expireDate <= new Date()) {
throw new BadRequestException('Membership has expired')
}
if (membership.cardType.type === CardTypeCategory.DURATION && !canUseMembershipForSlot(membership, timeSlot)) throw new BadRequestException('课程不在该月卡周期内,或该周期已无可用课时')
@@ -246,7 +249,7 @@ export class BookingService {
}
// 2. Validate membership still has available times
if (existing.membership.expireDate <= new Date()) {
if (existing.membership.expireDate && existing.membership.expireDate <= new Date()) {
throw new BadRequestException('Membership has expired')
}
if (existing.membership.status === MembershipStatus.EXPIRED) throw new BadRequestException('Membership has expired')
@@ -373,7 +376,7 @@ export class BookingService {
`Membership is not active (status: ${membership.status})`,
)
}
if (membership.expireDate <= new Date()) {
if (membership.expireDate && membership.expireDate <= new Date()) {
throw new BadRequestException('Membership has expired')
}
@@ -488,9 +491,11 @@ export class BookingService {
remark: string,
): Promise<BookingWithRelations> {
const booking = await this.prisma.$transaction(async (tx) => {
// Use the same member lock as booking confirmation and purchased-card issuance.
await tx.$queryRaw`SELECT u.id FROM users u INNER JOIN bookings b ON b.user_id = u.id WHERE b.id = ${bookingId} FOR UPDATE`
const existing = await tx.booking.findUnique({
where: { id: bookingId },
include: { timeSlot: true },
include: { timeSlot: true, membership: { include: { cardType: true } } },
})
if (!existing) {
@@ -507,8 +512,22 @@ export class BookingService {
operatorId,
}
if (toStatus === BookingStatus.COMPLETED) {
updateData.completedAt = new Date()
updateData.reviewReminderDueAt = new Date(Date.now() + 24 * 3600000)
const now = new Date()
updateData.completedAt = now
updateData.reviewReminderDueAt = new Date(now.getTime() + 24 * 3600000)
if (existing.membership && isMembershipAwaitingCheckIn(existing.membership)) {
const startDate = getStudioDayStart(now)
const durationDays = existing.membership.activationDurationDays ?? existing.membership.cardType.durationDays
if (durationDays <= 0) throw new BadRequestException('月卡有效天数无效,请联系馆主')
await tx.membership.update({
where: { id: existing.membershipId },
data: {
startDate,
expireDate: addDurationDays(startDate, durationDays),
status: existing.membership.remainingTimes === 0 ? MembershipStatus.USED_UP : MembershipStatus.ACTIVE,
},
})
}
}
const updated = await tx.booking.update({

View File

@@ -77,6 +77,18 @@ describe('contract formatting', () => {
})
describe('buildContractDocument', () => {
it('explains first-check-in activation in both the monthly dates and numbered clauses', () => {
const document = render({}, { kind: 'MONTHLY', durationDays: 30, startDate: null, expireDate: null })
const text = allText(document)
expect(text).toContain('权益生效时间:首次课程核销当天生效(中国时区)')
expect(text).toContain('不从下单、付款或签署合同之日起计时')
expect(text).toContain('取消预约或标记未出席均不触发生效')
expect(text).toContain('该日为第 1 天,有效期共 30 个自然日')
expect(text).not.toContain('自上一期届满时起生效')
expect(text).not.toContain('微信支付确认后立即生效')
expect(document.sections[1].clauses[5].text).toMatch(/^6\./)
})
it('renders a multi-times contract with standard-price refunds and fixed clause numbers', () => {
const document = render()
const refund = document.sections.find(section => section.title.includes('退费'))!

View File

@@ -104,6 +104,19 @@ describe('ContractService.issueForPaidOrder', () => {
expect(prisma.membershipContract.create).not.toHaveBeenCalled()
})
it('issues a deferred monthly contract without inventing dates from the payment time', async () => {
const { service, prisma } = buildService()
prisma.membershipContract.findUnique.mockResolvedValue(null)
await service.issueForPaidOrder(prisma as never, {
order: order as never,
cardType: { ...timesCard, type: 'DURATION', durationDays: 7 } as never,
membership: { id: 'monthly', createdAt: paidAt, startDate: null, expireDate: null, remainingTimes: 10, activationDurationDays: 30 } as never,
paidAt,
})
const terms = prisma.membershipContract.create.mock.calls[0][0].data.terms
expect(terms).toMatchObject({ kind: 'MONTHLY', startDate: null, expireDate: null, durationDays: 30, scheduled: false })
})
it('issues a booking-blocking contract with frozen purchase terms', async () => {
const { service, prisma } = buildService()
prisma.membershipContract.findUnique.mockResolvedValue(null)

View File

@@ -9,7 +9,7 @@ import {
type ContractSection,
} from '@mp-pilates/shared'
export const CONTRACT_TEMPLATE_VERSION = '2026-10-v2'
export const CONTRACT_TEMPLATE_VERSION = '2026-10-v3'
const CHINA_OFFSET_MS = 8 * 3600 * 1000
const PENDING_TEXT = '(待填写)'
@@ -228,7 +228,7 @@ function buildCardRows(input: ContractRenderInput): ContractRow[] {
}
rows.push(
{ label: '权益生效时间', value: terms.startDate ? formatChinaDateTime(terms.startDate) : '微信支付确认后立即生效' },
{ label: '权益生效时间', value: terms.startDate ? formatChinaDateTime(terms.startDate) : isMonthly ? '首次课程核销当天生效(中国时区)' : '微信支付确认后立即生效' },
{
label: '权益截止时间',
value: terms.expireDate ? formatChinaDateTime(terms.expireDate) : `自生效时间起 ${terms.durationDays} 天`,
@@ -280,7 +280,9 @@ function buildSections(input: ContractRenderInput): ContractSection[] {
? `本卡为月卡,每期有效期 ${terms.durationDays} 天,每期课时为${unlimited ? '有效期内不限次数' : ` ${terms.lessonCount} 节`}。每期课时仅限当期使用,未使用的课时不结转至下一期;依本条第 6、7 款办理到期升级的除外。`
: `本卡为多次卡,共含课时 ${terms.lessonCount ?? 0} 节,自付款确认时起生效,有效期 ${terms.durationDays} 天,具体起止时间以「会员卡信息」所载为准。`,
isMonthly
? '上一期月卡尚未到期时购买的,新一期自上一期届满时起生效;当期课时用完后,甲方可在小程序中确认提前启用下一期,下一期自启用时起计算完整期限。各期生效与截止时间以小程序记录为准。'
? terms.startDate === null
? `本期月卡自甲方使用本卡首次预约成功且课程被核销完成的当天起生效,不从下单、付款或签署合同之日起计时;仅预约、确认预约、取消预约或标记未出席均不触发生效。生效日按中国时区的自然日计算,该日为第 1 天,有效期共 ${terms.durationDays} 个自然日,至第 ${terms.durationDays} 天结束时届满。核销完成后,具体起止时间可在小程序「我的会员卡」查询;每张新购月卡独立计算,无需手动提前启用。`
: '上一期月卡尚未到期时购买的,新一期自上一期届满时起生效;当期课时用完后,甲方可在小程序中确认提前启用下一期,下一期自启用时起计算完整期限。各期生效与截止时间以小程序记录为准。'
: '在有效期内续购同一卡种的,新增课时并入原会员卡,有效期按续购时小程序公示的规则相应延长,并以续购时签署的合同为准;已过期会员卡的剩余课时不并入新购卡。',
bold(`有效期届满后,甲方不能直接使用本卡预约课程;甲方已按第七条办理延期或暂停的,按调整后的有效期执行。有效期届满不当然免除乙方依法应承担的退费、结算等义务,甲方可依第六条及法律规定主张相关权利${isMonthly ? ',也可依本条第 6、7 款办理到期升级' : ''}。`),
'乙方在小程序中实时展示剩余课时、有效期及预约、上课记录,甲方可随时查询。',

View File

@@ -118,16 +118,16 @@ export class ContractService {
cardTypeId: cardType.id,
cardName: cardType.name,
lessonCount: cardType.totalTimes,
durationDays: cardType.durationDays,
durationDays: membership.activationDurationDays ?? cardType.durationDays,
listPrice,
amount,
discountNote: amount < listPrice ? (order.inviteInviterId ? '已享好友邀请 95 折' : '已享优惠') : null,
orderNos: [order.orderNo],
paidAt: paidAt.toISOString(),
acceptedAt: order.contractAcceptedAt?.toISOString() ?? null,
startDate: (reusedRecord ? paidAt : membership.startDate).toISOString(),
expireDate: membership.expireDate.toISOString(),
scheduled: membership.startDate > paidAt,
startDate: (reusedRecord ? paidAt : membership.startDate)?.toISOString() ?? null,
expireDate: membership.expireDate?.toISOString() ?? null,
scheduled: membership.startDate !== null && membership.startDate > paidAt,
renewal,
availableTimesAfter: renewal ? membership.remainingTimes : null,
legacyRemainingTimes: null,
@@ -189,7 +189,7 @@ export class ContractService {
where: {
...(userId ? { userId } : {}),
status: { in: [MembershipStatus.ACTIVE, MembershipStatus.SCHEDULED] },
expireDate: { gt: now },
OR: [{ expireDate: { gt: now } }, { startDate: null, expireDate: null, cardType: { type: CardTypeCategory.DURATION } }],
inviteRewardGrants: { none: {} },
},
include: {
@@ -266,16 +266,16 @@ export class ContractService {
cardTypeId: membership.cardTypeId,
cardName: membership.cardType.name,
lessonCount: membership.totalTimes ?? membership.cardType.totalTimes,
durationDays: membership.cardType.durationDays,
durationDays: membership.activationDurationDays ?? membership.cardType.durationDays,
listPrice: Number(membership.cardType.price),
amount,
discountNote: null,
orderNos: orders.map(order => order.orderNo),
paidAt: lastPaid?.toISOString() ?? null,
acceptedAt: null,
startDate: membership.startDate.toISOString(),
expireDate: membership.expireDate.toISOString(),
scheduled: membership.startDate > now,
startDate: membership.startDate?.toISOString() ?? null,
expireDate: membership.expireDate?.toISOString() ?? null,
scheduled: membership.startDate !== null && membership.startDate > now,
renewal: false,
availableTimesAfter: null,
legacyRemainingTimes: state.remainingTimes,
@@ -301,8 +301,8 @@ export class ContractService {
kind: membership.cardType.type === CardTypeCategory.DURATION ? 'MONTHLY' : 'MULTI_TIMES',
remainingTimes,
totalTimes: membership.totalTimes,
startDate: membership.startDate.toISOString(),
expireDate: membership.expireDate.toISOString(),
startDate: membership.startDate?.toISOString() ?? null,
expireDate: membership.expireDate?.toISOString() ?? null,
}
}
@@ -653,8 +653,8 @@ export class ContractService {
cardName: terms.cardName,
kind: terms.kind,
amount: terms.amount,
startDate: terms.startDate ?? contract.createdAt.toISOString(),
expireDate: terms.expireDate ?? contract.createdAt.toISOString(),
startDate: terms.startDate,
expireDate: terms.expireDate,
createdAt: contract.createdAt.toISOString(),
signedAt: contract.signedAt?.toISOString() ?? null,
voidReason: contract.voidReason,

View File

@@ -53,7 +53,7 @@ describe('computeMembershipGrant', () => {
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)
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', () => {
@@ -89,7 +89,7 @@ describe('computeMembershipGrant', () => {
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)
expect(result.expireDate!.getTime()).toBe(expireDate.getTime() + 90 * 86_400_000)
})
it('does not inherit leftover times when the TIMES card is expired', () => {
@@ -107,10 +107,10 @@ describe('computeMembershipGrant', () => {
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)
expect(result.expireDate!.getTime()).toBe(now.getTime() + 90 * 86_400_000)
})
it('extends a still-active DURATION card from its current expireDate', () => {
it('defers activation of a new unlimited monthly card while keeping the old period', () => {
const expireDate = new Date('2026-07-01T00:00:00Z')
const result = computeMembershipGrant({
existing: {
@@ -125,10 +125,11 @@ describe('computeMembershipGrant', () => {
expect(result.isRenewal).toBe(true)
expect(result.remainingTimes).toBeNull()
expect(result.expireDate.getTime()).toBe(expireDate.getTime() + 30 * 86_400_000)
expect(result.expireDate).toBeNull()
expect(result.startDate).toBeNull()
})
it('starts an expired DURATION card from now', () => {
it('defers activation of a new monthly card after the old one expires', () => {
const result = computeMembershipGrant({
existing: {
remainingTimes: null,
@@ -141,7 +142,8 @@ describe('computeMembershipGrant', () => {
})
expect(result.isRenewal).toBe(true)
expect(result.expireDate.getTime()).toBe(now.getTime() + 30 * 86_400_000)
expect(result.expireDate).toBeNull()
expect(result.startDate).toBeNull()
})
it.each([
@@ -152,15 +154,16 @@ describe('computeMembershipGrant', () => {
{ 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) })
expect(result).toEqual({ startDate: null, isRenewal: true, remainingTimes: 10, totalTimes: 10, expireDate: null })
})
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)
expect(result.expireDate).toBeNull()
expect(result.startDate).toBeNull()
})
it('keeps a renewed monthly quota separate and schedules it after the current period', () => {
it('keeps a renewed monthly quota separate until its first check-in', () => {
const expireDate = new Date('2026-07-01T00:00:00Z')
const result = computeMembershipGrant({
existing: {
@@ -177,9 +180,10 @@ describe('computeMembershipGrant', () => {
isRenewal: true,
remainingTimes: 10,
totalTimes: 10,
startDate: expireDate,
startDate: null,
})
expect(result.expireDate.getTime()).toBe(expireDate.getTime() + 30 * 86_400_000)
expect(result.expireDate).toBeNull()
expect(result.startDate).toBeNull()
})
})

View File

@@ -164,7 +164,7 @@ describe('MembershipService', () => {
where: expect.objectContaining({
userId: 'user-001',
status: { in: [MembershipStatus.ACTIVE, MembershipStatus.SCHEDULED] },
startDate: { lte: expect.any(Date) },
AND: expect.any(Array),
}),
orderBy: { expireDate: 'asc' },
}),
@@ -187,7 +187,7 @@ describe('MembershipService', () => {
expect(mockPrismaService.membership.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
OR: [{ remainingTimes: { gt: 0 } }, { remainingTimes: null }],
AND: expect.arrayContaining([{ OR: [{ remainingTimes: { gt: 0 } }, { remainingTimes: null }] }]),
}),
}),
)
@@ -441,7 +441,7 @@ describe('MembershipService', () => {
mockPrismaService.membership.findFirst.mockResolvedValue(existing)
mockPrismaService.membership.create.mockImplementation(async ({data}) => ({id:'next-period',...data}))
const result = await service.grantPurchasedCard(tx, 'user-001', mockDurationCardType, now)
expect(mockPrismaService.membership.create).toHaveBeenCalledWith({data: {userId:'user-001',cardTypeId:mockDurationCardType.id,startDate:existing.expireDate,expireDate:new Date('2026-07-31T00:00:00Z'),remainingTimes:null,totalTimes:null,status:MembershipStatus.SCHEDULED}})
expect(mockPrismaService.membership.create).toHaveBeenCalledWith({data: {userId:'user-001',cardTypeId:mockDurationCardType.id,startDate:null,expireDate:null,activationDurationDays:30,remainingTimes:null,totalTimes:null,status:MembershipStatus.SCHEDULED}})
expect(result.id).toBe('next-period')
expect(mockPrismaService.membership.update).not.toHaveBeenCalled()
})
@@ -485,7 +485,7 @@ describe('MembershipService', () => {
mockPrismaService.membership.findFirst.mockResolvedValue(existing)
mockPrismaService.membership.create.mockResolvedValue({ ...existing, id: 'new-period', remainingTimes: 10 })
await service.grantPurchasedCard(tx, 'user-001', { ...mockDurationCardType, totalTimes: 10 }, now)
expect(mockPrismaService.membership.create).toHaveBeenCalledWith({ data: { userId: 'user-001', cardTypeId: mockDurationCardType.id, startDate: now, remainingTimes: 10, totalTimes: 10, expireDate: new Date('2026-07-01T00:00:00Z'), status: MembershipStatus.ACTIVE } })
expect(mockPrismaService.membership.create).toHaveBeenCalledWith({ data: { userId: 'user-001', cardTypeId: mockDurationCardType.id, startDate: null, remainingTimes: 10, totalTimes: 10, expireDate: null, activationDurationDays: 30, status: MembershipStatus.SCHEDULED } })
expect(mockPrismaService.membership.update).not.toHaveBeenCalled()
})

View File

@@ -18,7 +18,7 @@ describe('Independent monthly periods', () => {
const db = { $transaction: jest.fn(async (callback: (value: typeof tx) => unknown) => callback(tx)) }
return { tx, service: new MembershipService(db as unknown as PrismaService) }
}
it('queues repeated purchases in distinct records and never changes the current 6-session balance', async () => {
it('defers repeated purchases in distinct records and never changes the current 6-session balance', async () => {
const { tx, service } = setup()
const old = { ...current(), remainingTimes: 6, status: 'ACTIVE' }
const periods = [old]
@@ -27,8 +27,14 @@ describe('Independent monthly periods', () => {
await service.grantPurchasedCard(tx as never, 'member', cardType as never, now)
await service.grantPurchasedCard(tx as never, 'member', cardType as never, now)
expect(periods.map(p => p.remainingTimes)).toEqual([6, 10, 10])
expect(periods[1]).toMatchObject({ startDate: old.expireDate, expireDate: new Date('2026-07-31T00:00:00Z'), status: 'SCHEDULED' })
expect(periods[2]).toMatchObject({ startDate: periods[1].expireDate, expireDate: new Date('2026-08-30T00:00:00Z'), status: 'SCHEDULED' })
expect(periods[1]).toMatchObject({ startDate: null, expireDate: null, activationDurationDays: 30, status: 'SCHEDULED' })
expect(periods[2]).toMatchObject({ startDate: null, expireDate: null, activationDurationDays: 30, status: 'SCHEDULED' })
expect(tx.membership.update).not.toHaveBeenCalled()
})
it('does not allow manual activation of a new monthly card awaiting its first check-in', async () => {
const { tx, service } = setup()
tx.membership.findFirst.mockResolvedValue({ ...next(), startDate: null, expireDate: null, activationDurationDays: 30 })
await expect(service.activateNextPeriod('member', 'next', now)).rejects.toThrow('首次课程核销')
expect(tx.membership.update).not.toHaveBeenCalled()
})
it('activates the earliest next period and shifts later periods while preserving their full durations', async () => {

View File

@@ -3,5 +3,14 @@ import { MembershipStatus } from '@mp-pilates/shared'
/** A scheduled period becomes usable at its timestamp without waiting for cron. */
export function currentMembershipWhere(now = new Date()): Prisma.MembershipWhereInput {
return { status: { in: [MembershipStatus.ACTIVE, MembershipStatus.SCHEDULED] }, startDate: { lte: now }, expireDate: { gt: now }, OR: [{ remainingTimes: { gt: 0 } }, { remainingTimes: null }] }
return {
status: { in: [MembershipStatus.ACTIVE, MembershipStatus.SCHEDULED] },
AND: [
{ OR: [
{ startDate: { lte: now }, expireDate: { gt: now } },
{ startDate: null, expireDate: null, cardType: { type: 'DURATION' } },
] },
{ OR: [{ remainingTimes: { gt: 0 } }, { remainingTimes: null }] },
],
}
}

View File

@@ -1,6 +1,6 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'
import { CardType, Membership, Prisma } from '@prisma/client'
import { CardTypeCategory, MembershipStatus, computeMembershipGrant, getMembershipEffectiveState, getStudioSlotRange } from '@mp-pilates/shared'
import { CardTypeCategory, MembershipStatus, computeMembershipGrant, getMembershipEffectiveState, getStudioSlotRange, isMembershipAwaitingCheckIn } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { CreateCardTypeDto } from './dto/create-card-type.dto'
import { UpdateCardTypeDto } from './dto/update-card-type.dto'
@@ -146,7 +146,8 @@ export class MembershipService {
expireDate: grant.expireDate,
remainingTimes: grant.remainingTimes,
totalTimes: grant.totalTimes,
status: grant.startDate > now ? MembershipStatus.SCHEDULED : MembershipStatus.ACTIVE,
...(cardType.type === CardTypeCategory.DURATION ? { activationDurationDays: cardType.durationDays } : {}),
status: grant.startDate === null || grant.startDate > now ? MembershipStatus.SCHEDULED : MembershipStatus.ACTIVE,
},
})
}
@@ -167,11 +168,12 @@ export class MembershipService {
await tx.$queryRaw`SELECT id FROM users WHERE id = ${userId} FOR UPDATE`
const target = await tx.membership.findFirst({ where: { id: membershipId, userId }, include: { cardType: true } })
if (!target) throw new NotFoundException('月卡周期不存在')
if (target.cardType.type !== CardTypeCategory.DURATION || target.startDate <= now || target.status === MembershipStatus.EXPIRED) throw new BadRequestException('只能提前启用待生效的月卡周期')
if (isMembershipAwaitingCheckIn(target)) throw new BadRequestException('该月卡将在首次课程核销当天自动生效,无需提前启用')
if (target.cardType.type !== CardTypeCategory.DURATION || !target.startDate || target.startDate <= now || target.status === MembershipStatus.EXPIRED) throw new BadRequestException('只能提前启用待生效的月卡周期')
if (target.remainingTimes === 0) throw new BadRequestException('该周期课时已全部预约,请先检查预约记录')
const periods = await tx.membership.findMany({ where: { userId, cardTypeId: target.cardTypeId, status: { not: MembershipStatus.EXPIRED }, expireDate: { gt: now } }, orderBy: [{ startDate: 'asc' }, { createdAt: 'asc' }] })
const current = periods.filter(period => period.startDate <= now)
const queued = periods.filter(period => period.startDate > now)
const current = periods.filter(period => period.startDate && period.startDate <= now)
const queued = periods.filter(period => period.startDate && period.startDate > now)
if (queued[0]?.id !== membershipId) throw new BadRequestException('请按顺序启用最早的待生效周期')
if (current.some(period => period.remainingTimes !== 0)) throw new BadRequestException('本期课时尚未用完,请继续使用本期权益')
@@ -181,7 +183,7 @@ export class MembershipService {
let cursor = now
const shifted = queued.map(period => {
const duration = period.expireDate.getTime() - period.startDate.getTime()
const duration = period.expireDate!.getTime() - period.startDate!.getTime()
if (duration <= 0) throw new BadRequestException('月卡周期日期无效,请联系馆主')
const dates = { startDate: cursor, expireDate: new Date(cursor.getTime() + duration) }
cursor = dates.expireDate

View File

@@ -387,8 +387,7 @@ describe('PaymentService', () => {
prisma.membership.create.mockImplementation(async ({data}) => ({ ...existing, ...data, id: 'new-monthly-period' }))
await service.handleWxNotify(headers, successBody)
const data = prisma.membership.create.mock.calls[0][0].data
expect(data).toMatchObject({ remainingTimes: 10, totalTimes: 10, status: MembershipStatus.ACTIVE, startDate: expect.any(Date) })
expect(data.expireDate.getTime() - data.startDate.getTime()).toBe(30 * 86_400_000)
expect(data).toMatchObject({ remainingTimes: 10, totalTimes: 10, status: MembershipStatus.SCHEDULED, startDate: null, expireDate: null, activationDurationDays: 30 })
expect(prisma.membership.update).not.toHaveBeenCalled()
expect(prisma.order.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ membershipId: 'new-monthly-period' }) }))
})

View File

@@ -54,6 +54,7 @@ const makeBooking = (
// ---------------------------------------------------------------------------
const mockPrisma = {
cardType: { findUnique: jest.fn().mockImplementation(async ({ where }) => ({ id: where.id, type: where.id.includes('duration') ? CardTypeCategory.DURATION : CardTypeCategory.TIMES, durationDays: 30 })) },
lessonSupplement: {
aggregate: jest.fn().mockResolvedValue({ _sum: { quantity: 0 } }),
groupBy: jest.fn().mockResolvedValue([]),
@@ -93,7 +94,7 @@ const mockConfigService = {
// Tests
// ---------------------------------------------------------------------------
const usableWhere = { status: { in: [MembershipStatus.ACTIVE, MembershipStatus.SCHEDULED] }, startDate: { lte: expect.any(Date) }, expireDate: { gt: expect.any(Date) }, OR: [{ remainingTimes: { gt: 0 } }, { remainingTimes: null }] }
const usableWhere = { status: { in: [MembershipStatus.ACTIVE, MembershipStatus.SCHEDULED] }, AND: [{ OR: [{ startDate: { lte: expect.any(Date) }, expireDate: { gt: expect.any(Date) } }, { startDate: null, expireDate: null, cardType: { type: CardTypeCategory.DURATION } }] }, { OR: [{ remainingTimes: { gt: 0 } }, { remainingTimes: null }] }] }
describe('UserService', () => {
let service: UserService
@@ -634,7 +635,10 @@ describe('UserService', () => {
cardTypeId: 'card-duration-001',
remainingTimes: 10,
totalTimes: 10,
status: MembershipStatus.ACTIVE,
status: MembershipStatus.SCHEDULED,
startDate: null,
expireDate: null,
activationDurationDays: 30,
}),
}),
)

View File

@@ -67,8 +67,8 @@ export class LessonSupplementService {
where: { id: card.id, userId, remainingTimes: card.remainingTimes, updatedAt: card.updatedAt },
data: {
remainingTimes: { decrement: dto.quantity },
status: card.expireDate <= new Date() || card.status === MembershipStatus.EXPIRED
? MembershipStatus.EXPIRED : remaining === 0 ? MembershipStatus.USED_UP : MembershipStatus.ACTIVE,
status: (card.expireDate !== null && card.expireDate <= new Date()) || card.status === MembershipStatus.EXPIRED
? MembershipStatus.EXPIRED : card.startDate === null ? MembershipStatus.SCHEDULED : remaining === 0 ? MembershipStatus.USED_UP : MembershipStatus.ACTIVE,
},
})
if (changed.count !== 1) throw new ConflictException('会员卡已发生变化,请刷新后重试')
@@ -104,8 +104,8 @@ export class LessonSupplementService {
where: { id: card.id, userId, remainingTimes: card.remainingTimes, updatedAt: card.updatedAt },
data: {
remainingTimes: { increment: row.deductedTimes },
status: card.expireDate <= new Date() || card.status === MembershipStatus.EXPIRED
? MembershipStatus.EXPIRED : MembershipStatus.ACTIVE,
status: (card.expireDate !== null && card.expireDate <= new Date()) || card.status === MembershipStatus.EXPIRED
? MembershipStatus.EXPIRED : card.startDate === null ? MembershipStatus.SCHEDULED : MembershipStatus.ACTIVE,
},
})
if (restored.count !== 1) throw new ConflictException('会员卡已发生变化,请刷新后重试')

View File

@@ -38,8 +38,9 @@ function serializeMembership(membership: {
cardTypeId: string
remainingTimes: number | null
totalTimes: number | null
startDate: Date
expireDate: Date
startDate: Date | null
expireDate: Date | null
activationDurationDays?: number | null
status: string
createdAt: Date
updatedAt: Date
@@ -67,8 +68,9 @@ function serializeMembership(membership: {
cardTypeId: rest.cardTypeId,
remainingTimes: effective.remainingTimes,
totalTimes: rest.totalTimes,
startDate: rest.startDate.toISOString(),
expireDate: rest.expireDate.toISOString(),
startDate: rest.startDate?.toISOString() ?? null,
expireDate: rest.expireDate?.toISOString() ?? null,
activationDurationDays: rest.activationDurationDays ?? null,
status: effective.status,
createdAt: rest.createdAt.toISOString(),
updatedAt: rest.updatedAt.toISOString(),
@@ -689,6 +691,21 @@ export class UserService {
throw new NotFoundException('Membership not found')
}
const cardType = await this.prisma.cardType.findUnique({ where: { id: dto.cardTypeId } })
if (!cardType) throw new NotFoundException('Card type not found')
const awaitingCheckIn = cardType.type === CardTypeCategory.DURATION
&& (!existing || (existing.startDate === null && existing.expireDate === null))
const activationData = awaitingCheckIn
? {
startDate: null,
expireDate: null,
status: MembershipStatus.SCHEDULED,
activationDurationDays: existing?.cardTypeId === cardType.id
? existing.activationDurationDays ?? cardType.durationDays
: cardType.durationDays,
}
: {}
if (existing) {
const totalTimes = remainingTimes === null
? null
@@ -697,14 +714,14 @@ export class UserService {
: remainingTimes
return this.prisma.membership.update({
where: { id: existing.id },
data: { ...data, totalTimes },
where: { id: existing.id, ...(awaitingCheckIn ? { startDate: null, expireDate: null } : {}) },
data: { ...data, ...activationData, totalTimes },
include: { cardType: true },
})
}
return this.prisma.membership.create({
data: { userId, totalTimes: remainingTimes, ...data },
data: { userId, totalTimes: remainingTimes, ...data, ...activationData },
include: { cardType: true },
})
}