fix: 修复月卡无法编辑次数的问题

This commit is contained in:
richarjiang
2026-09-07 18:10:39 +08:00
parent 86ad9ee64f
commit 87d946adb5
17 changed files with 564 additions and 91 deletions

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `bookings` ADD COLUMN `membership_times_deducted` BOOLEAN NULL;

View File

@@ -205,17 +205,18 @@ model TimeSlot {
}
model Booking {
id String @id @default(uuid())
userId String @map("user_id")
timeSlotId String @map("time_slot_id")
membershipId String @map("membership_id")
status BookingStatus @default(PENDING_CONFIRMATION)
cancelledAt DateTime? @map("cancelled_at")
confirmedAt DateTime? @map("confirmed_at")
completedAt DateTime? @map("completed_at")
operatorId String? @map("operator_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
id String @id @default(uuid())
userId String @map("user_id")
timeSlotId String @map("time_slot_id")
membershipId String @map("membership_id")
membershipTimesDeducted Boolean? @map("membership_times_deducted")
status BookingStatus @default(PENDING_CONFIRMATION)
cancelledAt DateTime? @map("cancelled_at")
confirmedAt DateTime? @map("confirmed_at")
completedAt DateTime? @map("completed_at")
operatorId String? @map("operator_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id])
timeSlot TimeSlot @relation(fields: [timeSlotId], references: [id])

View File

@@ -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 () => {

View File

@@ -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

View File

@@ -23,6 +23,12 @@ const durationCard = {
durationDays: 30,
}
const limitedDurationCard = {
type: CardTypeCategory.DURATION,
totalTimes: 10,
durationDays: 30,
}
const trialCard = {
type: CardTypeCategory.TRIAL,
totalTimes: 1,
@@ -136,6 +142,27 @@ describe('computeMembershipGrant', () => {
expect(result.isRenewal).toBe(true)
expect(result.expireDate.getTime()).toBe(now.getTime() + 30 * 86_400_000)
})
it('stacks sessions for an active count-limited DURATION card', () => {
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: 13,
totalTimes: 20,
})
expect(result.expireDate.getTime()).toBe(expireDate.getTime() + 30 * 86_400_000)
})
})
describe('getMembershipRenewalHint', () => {
@@ -185,6 +212,26 @@ describe('getMembershipRenewalHint', () => {
})
})
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(
[

View File

@@ -224,7 +224,7 @@ describe('MembershipService', () => {
expect(result.remainingTimes).toBe(0)
})
it('should not change times for a DURATION card', async () => {
it('should leave an unlimited DURATION card unchanged', async () => {
mockPrismaService.membership.findUnique.mockResolvedValue(mockDurationMembership)
const result = await service.deductMembership('mem-duration-001')
@@ -234,6 +234,27 @@ describe('MembershipService', () => {
expect(result.status).toBe(MembershipStatus.ACTIVE)
})
it('should decrement remainingTimes for a count-limited DURATION card', async () => {
const membership = {
...mockDurationMembership,
remainingTimes: 5,
totalTimes: 10,
}
mockPrismaService.membership.findUnique.mockResolvedValue(membership)
mockPrismaService.membership.update.mockResolvedValue({
...membership,
remainingTimes: 4,
})
await service.deductMembership('mem-duration-001')
expect(mockPrismaService.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
data: { remainingTimes: 4, status: MembershipStatus.ACTIVE },
}),
)
})
it('should throw NotFoundException when membership does not exist', async () => {
mockPrismaService.membership.findUnique.mockResolvedValue(null)
@@ -298,6 +319,39 @@ describe('MembershipService', () => {
expect(result.remainingTimes).toBe(4)
})
it('should leave an unlimited DURATION card unchanged', async () => {
mockPrismaService.membership.findUnique.mockResolvedValue(mockDurationMembership)
const result = await service.restoreMembership('mem-duration-001')
expect(mockPrismaService.membership.update).not.toHaveBeenCalled()
expect(result.remainingTimes).toBeNull()
expect(result.status).toBe(MembershipStatus.ACTIVE)
})
it('should restore a count-limited DURATION card from USED_UP', async () => {
const membership = {
...mockDurationMembership,
remainingTimes: 0,
totalTimes: 10,
status: MembershipStatus.USED_UP,
}
mockPrismaService.membership.findUnique.mockResolvedValue(membership)
mockPrismaService.membership.update.mockResolvedValue({
...membership,
remainingTimes: 1,
status: MembershipStatus.ACTIVE,
})
await service.restoreMembership('mem-duration-001')
expect(mockPrismaService.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
data: { remainingTimes: 1, status: MembershipStatus.ACTIVE },
}),
)
})
it('should throw NotFoundException when membership does not exist', async () => {
mockPrismaService.membership.findUnique.mockResolvedValue(null)

View File

@@ -59,12 +59,8 @@ export class MembershipService {
throw new BadRequestException(`Membership ${membershipId} is not active`)
}
const isTimeBased =
membership.cardType.type === CardTypeCategory.TIMES ||
membership.cardType.type === CardTypeCategory.TRIAL
if (!isTimeBased) {
// DURATION card: validate expiry only, no times to deduct
if (membership.remainingTimes === null) {
// Unlimited memberships do not consume a session.
return { ...membership, cardType: { ...membership.cardType } }
}
@@ -93,11 +89,7 @@ export class MembershipService {
throw new NotFoundException(`Membership ${membershipId} not found`)
}
const isTimeBased =
membership.cardType.type === CardTypeCategory.TIMES ||
membership.cardType.type === CardTypeCategory.TRIAL
if (!isTimeBased) {
if (membership.remainingTimes === null) {
return { ...membership, cardType: { ...membership.cardType } }
}

View File

@@ -544,6 +544,123 @@ describe('UserService', () => {
})
})
describe('updateUserMembership', () => {
const startDate = '2026-09-01'
const expireDate = '2099-10-01'
it('snapshots a submitted count when creating a DURATION membership', async () => {
mockPrisma.membership.findFirst.mockResolvedValue(null)
mockPrisma.membership.create.mockResolvedValue({ id: 'mem-duration-001' })
await service.updateUserMembership('user-1', {
cardTypeId: 'card-duration-001',
remainingTimes: 10,
startDate,
expireDate,
})
expect(mockPrisma.membership.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
userId: 'user-1',
cardTypeId: 'card-duration-001',
remainingTimes: 10,
totalTimes: 10,
status: MembershipStatus.ACTIVE,
}),
}),
)
})
it('preserves the existing count snapshot when the submitted remaining count does not exceed it', async () => {
mockPrisma.membership.findFirst.mockResolvedValue({
id: 'mem-duration-001',
userId: 'user-1',
cardTypeId: 'card-duration-001',
remainingTimes: 6,
totalTimes: 10,
})
mockPrisma.membership.update.mockResolvedValue({ id: 'mem-duration-001' })
await service.updateUserMembership('user-1', {
membershipId: 'mem-duration-001',
cardTypeId: 'card-duration-001',
remainingTimes: 8,
startDate,
expireDate,
})
expect(mockPrisma.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'mem-duration-001' },
data: expect.objectContaining({
cardTypeId: 'card-duration-001',
remainingTimes: 8,
totalTimes: 10,
status: MembershipStatus.ACTIVE,
}),
}),
)
})
it('raises the count snapshot when the submitted remaining count exceeds it', async () => {
mockPrisma.membership.findFirst.mockResolvedValue({
id: 'mem-duration-001',
userId: 'user-1',
cardTypeId: 'card-duration-001',
remainingTimes: 6,
totalTimes: 10,
})
mockPrisma.membership.update.mockResolvedValue({ id: 'mem-duration-001' })
await service.updateUserMembership('user-1', {
membershipId: 'mem-duration-001',
cardTypeId: 'card-duration-001',
remainingTimes: 12,
startDate,
expireDate,
})
expect(mockPrisma.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
remainingTimes: 12,
totalTimes: 12,
}),
}),
)
})
it('resets the count snapshot when changing to a different card type', async () => {
mockPrisma.membership.findFirst.mockResolvedValue({
id: 'mem-duration-001',
userId: 'user-1',
cardTypeId: 'card-duration-001',
remainingTimes: 6,
totalTimes: 10,
})
mockPrisma.membership.update.mockResolvedValue({ id: 'mem-duration-001' })
await service.updateUserMembership('user-1', {
membershipId: 'mem-duration-001',
cardTypeId: 'card-duration-002',
remainingTimes: 8,
startDate,
expireDate,
})
expect(mockPrisma.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
cardTypeId: 'card-duration-002',
remainingTimes: 8,
totalTimes: 8,
}),
}),
)
})
})
describe('deleteUserMembership', () => {
it('expires only the selected membership', async () => {
mockPrisma.membership.findFirst.mockResolvedValue({

View File

@@ -581,18 +581,18 @@ export class UserService {
const now = new Date()
const expireDate = new Date(dto.expireDate)
const remainingTimes = dto.remainingTimes ?? null
const isTimeBased = remainingTimes !== null
const isCountLimited = remainingTimes !== null
let status: MembershipStatus = MembershipStatus.ACTIVE
if (expireDate < now) {
status = MembershipStatus.EXPIRED
} else if (isTimeBased && remainingTimes <= 0) {
} else if (isCountLimited && remainingTimes <= 0) {
status = MembershipStatus.USED_UP
}
const data = {
cardTypeId: dto.cardTypeId,
remainingTimes: dto.remainingTimes ?? null,
remainingTimes,
startDate: new Date(dto.startDate),
expireDate: new Date(dto.expireDate),
status,
@@ -609,15 +609,21 @@ export class UserService {
}
if (existing) {
const totalTimes = remainingTimes === null
? null
: existing.cardTypeId === dto.cardTypeId
? Math.max(existing.totalTimes ?? 0, remainingTimes)
: remainingTimes
return this.prisma.membership.update({
where: { id: existing.id },
data,
data: { ...data, totalTimes },
include: { cardType: true },
})
}
return this.prisma.membership.create({
data: { userId, totalTimes: dto.remainingTimes ?? null, ...data },
data: { userId, totalTimes: remainingTimes, ...data },
include: { cardType: true },
})
}