fix: 修复月卡无法编辑次数的问题
This commit is contained in:
@@ -85,6 +85,13 @@ const mockDurationMembership = {
|
||||
cardType: mockDurationCardType,
|
||||
}
|
||||
|
||||
const mockLimitedDurationMembership = {
|
||||
...mockDurationMembership,
|
||||
id: 'mem-duration-limited-001',
|
||||
remainingTimes: 5,
|
||||
totalTimes: 10,
|
||||
}
|
||||
|
||||
const mockExpiredMembership = {
|
||||
...mockActiveMembership,
|
||||
id: 'mem-expired-001',
|
||||
@@ -102,6 +109,7 @@ const mockConfirmedBooking = {
|
||||
userId: MOCK_USER_ID,
|
||||
timeSlotId: MOCK_SLOT_ID,
|
||||
membershipId: MOCK_MEMBERSHIP_ID,
|
||||
membershipTimesDeducted: true,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
cancelledAt: null,
|
||||
createdAt: new Date(),
|
||||
@@ -274,6 +282,95 @@ describe('BookingService', () => {
|
||||
courseName: 'FocusCore Pilates',
|
||||
bookingEndTime: '2099-12-31 10:00',
|
||||
})
|
||||
expect(tx.booking.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ membershipTimesDeducted: true }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('deducts a count-limited DURATION membership on confirmation', async () => {
|
||||
const tx = buildTxMock()
|
||||
tx.booking.findUnique.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
membershipId: mockLimitedDurationMembership.id,
|
||||
status: BookingStatus.PENDING_CONFIRMATION,
|
||||
timeSlot: mockOpenSlot,
|
||||
membership: mockLimitedDurationMembership,
|
||||
})
|
||||
tx.booking.update.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
membershipId: mockLimitedDurationMembership.id,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
})
|
||||
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 })
|
||||
tx.membership.update.mockResolvedValue({
|
||||
...mockLimitedDurationMembership,
|
||||
remainingTimes: 4,
|
||||
})
|
||||
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
membershipId: mockLimitedDurationMembership.id,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
timeSlot: mockOpenSlot,
|
||||
membership: mockLimitedDurationMembership,
|
||||
})
|
||||
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ openid: 'openid-001' })
|
||||
studioService.getInfo.mockResolvedValue(mockStudioConfig)
|
||||
subscriptionMessageService.sendBookingConfirmedMessage.mockResolvedValue(true)
|
||||
|
||||
await service.confirmBooking(MOCK_BOOKING_ID, 'admin-001')
|
||||
|
||||
expect(tx.membership.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: mockLimitedDurationMembership.id },
|
||||
data: { remainingTimes: 4, status: MembershipStatus.ACTIVE },
|
||||
}),
|
||||
)
|
||||
expect(tx.booking.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ membershipTimesDeducted: true }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('records no deduction for an unlimited DURATION membership on confirmation', async () => {
|
||||
const tx = buildTxMock()
|
||||
tx.booking.findUnique.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
membershipId: mockDurationMembership.id,
|
||||
status: BookingStatus.PENDING_CONFIRMATION,
|
||||
timeSlot: mockOpenSlot,
|
||||
membership: mockDurationMembership,
|
||||
})
|
||||
tx.booking.update.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
membershipId: mockDurationMembership.id,
|
||||
membershipTimesDeducted: false,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
})
|
||||
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 })
|
||||
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
membershipId: mockDurationMembership.id,
|
||||
membershipTimesDeducted: false,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
timeSlot: mockOpenSlot,
|
||||
membership: mockDurationMembership,
|
||||
})
|
||||
|
||||
await service.confirmBooking(MOCK_BOOKING_ID, 'admin-001')
|
||||
|
||||
expect(tx.membership.update).not.toHaveBeenCalled()
|
||||
expect(tx.booking.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ membershipTimesDeducted: false }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -349,6 +446,7 @@ describe('BookingService', () => {
|
||||
userId: MOCK_USER_ID,
|
||||
timeSlotId: MOCK_SLOT_ID,
|
||||
membershipId: MOCK_MEMBERSHIP_ID,
|
||||
membershipTimesDeducted: false,
|
||||
status: BookingStatus.PENDING_CONFIRMATION,
|
||||
}),
|
||||
}),
|
||||
@@ -596,6 +694,7 @@ describe('BookingService', () => {
|
||||
where: { id: MOCK_BOOKING_ID },
|
||||
data: {
|
||||
membershipId: MOCK_MEMBERSHIP_ID,
|
||||
membershipTimesDeducted: false,
|
||||
status: BookingStatus.PENDING_CONFIRMATION,
|
||||
cancelledAt: null,
|
||||
confirmedAt: null,
|
||||
@@ -682,6 +781,100 @@ describe('BookingService', () => {
|
||||
expect(result.refunded).toBe(true)
|
||||
})
|
||||
|
||||
it('restores a count-limited DURATION membership when cancelled within the limit', async () => {
|
||||
const limitedDurationMembership = {
|
||||
...mockLimitedDurationMembership,
|
||||
remainingTimes: 4,
|
||||
}
|
||||
const bookingWithRelations = {
|
||||
...mockConfirmedBooking,
|
||||
membershipId: limitedDurationMembership.id,
|
||||
timeSlot: { ...futureSlot, bookedCount: 1 },
|
||||
membership: limitedDurationMembership,
|
||||
}
|
||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(bookingWithRelations)
|
||||
|
||||
const tx = buildTxMock()
|
||||
tx.booking.update.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
membershipId: limitedDurationMembership.id,
|
||||
status: BookingStatus.CANCELLED,
|
||||
})
|
||||
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
|
||||
tx.membership.update.mockResolvedValue({
|
||||
...limitedDurationMembership,
|
||||
remainingTimes: 5,
|
||||
})
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
|
||||
const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID)
|
||||
|
||||
expect(tx.membership.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: limitedDurationMembership.id },
|
||||
data: expect.objectContaining({
|
||||
remainingTimes: 5,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(result.refunded).toBe(true)
|
||||
})
|
||||
|
||||
it('does not restore an unlimited DURATION membership when cancelled within the limit', async () => {
|
||||
const bookingWithRelations = {
|
||||
...mockConfirmedBooking,
|
||||
membershipId: mockDurationMembership.id,
|
||||
membershipTimesDeducted: false,
|
||||
timeSlot: { ...futureSlot, bookedCount: 1 },
|
||||
membership: mockDurationMembership,
|
||||
}
|
||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(bookingWithRelations)
|
||||
|
||||
const tx = buildTxMock()
|
||||
tx.booking.update.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
membershipId: mockDurationMembership.id,
|
||||
status: BookingStatus.CANCELLED,
|
||||
})
|
||||
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
|
||||
const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID)
|
||||
|
||||
expect(tx.membership.update).not.toHaveBeenCalled()
|
||||
expect(result.refunded).toBe(false)
|
||||
})
|
||||
|
||||
it('does not refund an originally unlimited membership after it is changed to counted', async () => {
|
||||
const bookingWithRelations = {
|
||||
...mockConfirmedBooking,
|
||||
membershipId: mockLimitedDurationMembership.id,
|
||||
membershipTimesDeducted: false,
|
||||
timeSlot: { ...futureSlot, bookedCount: 1 },
|
||||
membership: {
|
||||
...mockLimitedDurationMembership,
|
||||
remainingTimes: 10,
|
||||
},
|
||||
}
|
||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(bookingWithRelations)
|
||||
|
||||
const tx = buildTxMock()
|
||||
tx.booking.update.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
membershipId: mockLimitedDurationMembership.id,
|
||||
membershipTimesDeducted: false,
|
||||
status: BookingStatus.CANCELLED,
|
||||
})
|
||||
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
|
||||
const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID)
|
||||
|
||||
expect(tx.membership.update).not.toHaveBeenCalled()
|
||||
expect(result.refunded).toBe(false)
|
||||
})
|
||||
|
||||
it('cancels booking past limit: does NOT refund membership', async () => {
|
||||
const bookingWithImminent = {
|
||||
...mockConfirmedBooking,
|
||||
@@ -1034,7 +1227,10 @@ describe('BookingService', () => {
|
||||
function stubArrangeSuccess(
|
||||
tx: ReturnType<typeof buildTxMock>,
|
||||
options?: {
|
||||
membership?: typeof mockActiveMembership | typeof mockDurationMembership | typeof mockTrialMembership
|
||||
membership?: typeof mockActiveMembership
|
||||
| typeof mockDurationMembership
|
||||
| typeof mockLimitedDurationMembership
|
||||
| typeof mockTrialMembership
|
||||
slot?: typeof mockOpenSlot
|
||||
existing?: typeof mockConfirmedBooking | null
|
||||
},
|
||||
@@ -1093,6 +1289,7 @@ describe('BookingService', () => {
|
||||
userId: MOCK_USER_ID,
|
||||
timeSlotId: MOCK_SLOT_ID,
|
||||
membershipId: MOCK_MEMBERSHIP_ID,
|
||||
membershipTimesDeducted: true,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
operatorId: MOCK_ADMIN_ID,
|
||||
}),
|
||||
@@ -1127,7 +1324,7 @@ describe('BookingService', () => {
|
||||
expect(result.status).toBe(BookingStatus.CONFIRMED)
|
||||
})
|
||||
|
||||
it('does not deduct remaining times for duration cards', async () => {
|
||||
it('does not deduct remaining times for unlimited duration cards', async () => {
|
||||
const tx = buildTxMock()
|
||||
stubArrangeSuccess(tx, { membership: mockDurationMembership })
|
||||
|
||||
@@ -1137,7 +1334,33 @@ describe('BookingService', () => {
|
||||
})
|
||||
|
||||
expect(tx.membership.update).not.toHaveBeenCalled()
|
||||
expect(tx.booking.create).toHaveBeenCalled()
|
||||
expect(tx.booking.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ membershipTimesDeducted: false }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('deducts remaining times for count-limited duration cards', async () => {
|
||||
const tx = buildTxMock()
|
||||
stubArrangeSuccess(tx, { membership: mockLimitedDurationMembership })
|
||||
|
||||
await service.adminArrangeBooking(MOCK_ADMIN_ID, {
|
||||
...dto,
|
||||
membershipId: mockLimitedDurationMembership.id,
|
||||
})
|
||||
|
||||
expect(tx.membership.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: mockLimitedDurationMembership.id },
|
||||
data: { remainingTimes: 4, status: MembershipStatus.ACTIVE },
|
||||
}),
|
||||
)
|
||||
expect(tx.booking.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ membershipTimesDeducted: true }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('deducts a trial card session', async () => {
|
||||
|
||||
@@ -115,18 +115,9 @@ export class BookingService {
|
||||
)
|
||||
}
|
||||
|
||||
const cardType = membership.cardType
|
||||
const isTimeBased =
|
||||
cardType.type === CardTypeCategory.TIMES ||
|
||||
cardType.type === CardTypeCategory.TRIAL
|
||||
|
||||
if (isTimeBased) {
|
||||
// 4a. TIMES / TRIAL: must have remaining times (check at confirm time, not booking time)
|
||||
} else {
|
||||
// 4b. DURATION: must not be expired
|
||||
if (membership.expireDate <= new Date()) {
|
||||
throw new BadRequestException('Membership has expired')
|
||||
}
|
||||
// A card cannot be used after its validity period ends.
|
||||
if (membership.expireDate <= new Date()) {
|
||||
throw new BadRequestException('Membership has expired')
|
||||
}
|
||||
|
||||
// 5. Create booking or revive a previously cancelled booking.
|
||||
@@ -135,6 +126,7 @@ export class BookingService {
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
membershipId: dto.membershipId,
|
||||
membershipTimesDeducted: false,
|
||||
status: BookingStatus.PENDING_CONFIRMATION,
|
||||
cancelledAt: null,
|
||||
confirmedAt: null,
|
||||
@@ -147,6 +139,7 @@ export class BookingService {
|
||||
userId,
|
||||
timeSlotId: dto.timeSlotId,
|
||||
membershipId: dto.membershipId,
|
||||
membershipTimesDeducted: false,
|
||||
status: BookingStatus.PENDING_CONFIRMATION,
|
||||
},
|
||||
})
|
||||
@@ -202,19 +195,16 @@ export class BookingService {
|
||||
}
|
||||
|
||||
// 2. Validate membership still has available times
|
||||
const cardType = existing.membership.cardType
|
||||
const isTimeBased =
|
||||
cardType.type === CardTypeCategory.TIMES ||
|
||||
cardType.type === CardTypeCategory.TRIAL
|
||||
if (existing.membership.expireDate <= new Date()) {
|
||||
throw new BadRequestException('Membership has expired')
|
||||
}
|
||||
|
||||
if (isTimeBased) {
|
||||
const isCountLimited = existing.membership.remainingTimes !== null
|
||||
|
||||
if (isCountLimited) {
|
||||
if ((existing.membership.remainingTimes ?? 0) <= 0) {
|
||||
throw new BadRequestException('No remaining times on this membership')
|
||||
}
|
||||
} else {
|
||||
if (existing.membership.expireDate <= new Date()) {
|
||||
throw new BadRequestException('Membership has expired')
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Update booking status to CONFIRMED
|
||||
@@ -222,6 +212,7 @@ export class BookingService {
|
||||
where: { id: bookingId },
|
||||
data: {
|
||||
status: BookingStatus.CONFIRMED,
|
||||
membershipTimesDeducted: isCountLimited,
|
||||
confirmedAt: new Date(),
|
||||
operatorId,
|
||||
},
|
||||
@@ -241,7 +232,7 @@ export class BookingService {
|
||||
})
|
||||
|
||||
// 5. Deduct membership times
|
||||
if (isTimeBased) {
|
||||
if (isCountLimited) {
|
||||
const newRemainingTimes = (existing.membership.remainingTimes ?? 0) - 1
|
||||
const newMembershipStatus =
|
||||
newRemainingTimes <= 0 ? MembershipStatus.USED_UP : MembershipStatus.ACTIVE
|
||||
@@ -333,12 +324,9 @@ export class BookingService {
|
||||
throw new BadRequestException('Membership has expired')
|
||||
}
|
||||
|
||||
const cardType = membership.cardType
|
||||
const isTimeBased =
|
||||
cardType.type === CardTypeCategory.TIMES ||
|
||||
cardType.type === CardTypeCategory.TRIAL
|
||||
const isCountLimited = membership.remainingTimes !== null
|
||||
|
||||
if (isTimeBased && (membership.remainingTimes ?? 0) <= 0) {
|
||||
if (isCountLimited && (membership.remainingTimes ?? 0) <= 0) {
|
||||
throw new BadRequestException('No remaining times on this membership')
|
||||
}
|
||||
|
||||
@@ -370,6 +358,7 @@ export class BookingService {
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
membershipId: dto.membershipId,
|
||||
membershipTimesDeducted: isCountLimited,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
cancelledAt: null,
|
||||
confirmedAt: now,
|
||||
@@ -382,13 +371,14 @@ export class BookingService {
|
||||
userId: dto.userId,
|
||||
timeSlotId: timeSlot.id,
|
||||
membershipId: dto.membershipId,
|
||||
membershipTimesDeducted: isCountLimited,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
confirmedAt: now,
|
||||
operatorId,
|
||||
},
|
||||
})
|
||||
|
||||
if (isTimeBased) {
|
||||
if (isCountLimited) {
|
||||
const newRemainingTimes = (membership.remainingTimes ?? 0) - 1
|
||||
await tx.membership.update({
|
||||
where: { id: membership.id },
|
||||
@@ -574,13 +564,18 @@ export class BookingService {
|
||||
})
|
||||
|
||||
// Conditionally restore membership
|
||||
if (withinLimit) {
|
||||
const cardType = booking.membership.cardType
|
||||
const isTimeBased =
|
||||
cardType.type === CardTypeCategory.TIMES ||
|
||||
cardType.type === CardTypeCategory.TRIAL
|
||||
// Legacy bookings predate the snapshot. Keep their former category-based
|
||||
// refund behavior instead of inferring from a count that may be edited.
|
||||
const membershipTimesDeducted = booking.membershipTimesDeducted ?? (
|
||||
booking.membership.cardType.type === CardTypeCategory.TIMES ||
|
||||
booking.membership.cardType.type === CardTypeCategory.TRIAL
|
||||
)
|
||||
|
||||
if (isTimeBased) {
|
||||
if (withinLimit && membershipTimesDeducted) {
|
||||
// The snapshot reflects confirmation-time behavior. The card may have
|
||||
// been edited after confirmation, so its current count must not decide
|
||||
// whether this booking earns a refund.
|
||||
if (booking.membership.remainingTimes !== null) {
|
||||
const newRemainingTimes = (booking.membership.remainingTimes ?? 0) + 1
|
||||
const newStatus =
|
||||
booking.membership.status === MembershipStatus.USED_UP
|
||||
|
||||
Reference in New Issue
Block a user