From 994d1f75d5e5f922287c88a761b7a0d614279e38 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Thu, 2 Apr 2026 12:33:50 +0800 Subject: [PATCH] feat(server): add booking, payment, and scheduler modules Booking: reservation with atomic transactions, cancellation with refund logic based on cancelHoursLimit (23 tests) Payment: WeChat Pay integration (mock), order lifecycle, membership creation on payment callback (13 tests) Scheduler: cron tasks for slot generation, cleanup, membership expiry (8 tests) 109 total tests passing across 9 test suites --- packages/server/src/app.module.ts | 6 + .../booking/__tests__/booking.service.spec.ts | 710 ++++++++++++++++++ .../server/src/booking/booking.controller.ts | 81 ++ packages/server/src/booking/booking.module.ts | 13 + .../server/src/booking/booking.service.ts | 366 +++++++++ .../src/booking/dto/create-booking.dto.ts | 9 + .../payment/__tests__/payment.service.spec.ts | 333 ++++++++ .../src/payment/dto/create-order.dto.ts | 6 + .../server/src/payment/payment.controller.ts | 91 +++ packages/server/src/payment/payment.module.ts | 13 + .../server/src/payment/payment.service.ts | 215 ++++++ .../server/src/payment/wechat-pay.service.ts | 115 +++ .../__tests__/scheduler.service.spec.ts | 158 ++++ .../server/src/scheduler/scheduler.module.ts | 13 + .../server/src/scheduler/scheduler.service.ts | 54 ++ 15 files changed, 2183 insertions(+) create mode 100644 packages/server/src/booking/__tests__/booking.service.spec.ts create mode 100644 packages/server/src/booking/booking.controller.ts create mode 100644 packages/server/src/booking/booking.module.ts create mode 100644 packages/server/src/booking/booking.service.ts create mode 100644 packages/server/src/booking/dto/create-booking.dto.ts create mode 100644 packages/server/src/payment/__tests__/payment.service.spec.ts create mode 100644 packages/server/src/payment/dto/create-order.dto.ts create mode 100644 packages/server/src/payment/payment.controller.ts create mode 100644 packages/server/src/payment/payment.module.ts create mode 100644 packages/server/src/payment/payment.service.ts create mode 100644 packages/server/src/payment/wechat-pay.service.ts create mode 100644 packages/server/src/scheduler/__tests__/scheduler.service.spec.ts create mode 100644 packages/server/src/scheduler/scheduler.module.ts create mode 100644 packages/server/src/scheduler/scheduler.service.ts diff --git a/packages/server/src/app.module.ts b/packages/server/src/app.module.ts index df16e37..28d8bcb 100644 --- a/packages/server/src/app.module.ts +++ b/packages/server/src/app.module.ts @@ -7,6 +7,9 @@ import { AuthModule } from './auth/auth.module' import { StudioModule } from './studio/studio.module' import { TimeSlotModule } from './time-slot/time-slot.module' import { MembershipModule } from './membership/membership.module' +import { BookingModule } from './booking/booking.module' +import { SchedulerModule } from './scheduler/scheduler.module' +import { PaymentModule } from './payment/payment.module' @Module({ imports: [ @@ -20,6 +23,9 @@ import { MembershipModule } from './membership/membership.module' StudioModule, TimeSlotModule, MembershipModule, + BookingModule, + SchedulerModule, + PaymentModule, ], controllers: [AppController], }) diff --git a/packages/server/src/booking/__tests__/booking.service.spec.ts b/packages/server/src/booking/__tests__/booking.service.spec.ts new file mode 100644 index 0000000..3ed2bfc --- /dev/null +++ b/packages/server/src/booking/__tests__/booking.service.spec.ts @@ -0,0 +1,710 @@ +import { Test, TestingModule } from '@nestjs/testing' +import { + BadRequestException, + ConflictException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common' +import { BookingStatus, CardTypeCategory, MembershipStatus, TimeSlotStatus } from '@mp-pilates/shared' +import { BookingService } from '../booking.service' +import { PrismaService } from '../../prisma/prisma.service' +import { MembershipService } from '../../membership/membership.service' +import { StudioService } from '../../studio/studio.service' + +// ─── Fixtures ────────────────────────────────────────────────────────────── + +const MOCK_USER_ID = 'user-001' +const MOCK_SLOT_ID = 'slot-001' +const MOCK_MEMBERSHIP_ID = 'mem-001' +const MOCK_BOOKING_ID = 'booking-001' + +const mockTimesCardType = { + id: 'ct-times-001', + name: '10次卡', + type: CardTypeCategory.TIMES, + totalTimes: 10, + durationDays: 180, + price: 150000, + originalPrice: null, + description: null, + isActive: true, + sortOrder: 0, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), +} + +const mockDurationCardType = { + ...mockTimesCardType, + id: 'ct-duration-001', + name: '月卡', + type: CardTypeCategory.DURATION, + totalTimes: null, +} + +const mockOpenSlot = { + id: MOCK_SLOT_ID, + date: new Date('2099-12-31'), // far future + startTime: '09:00', + endTime: '10:00', + capacity: 5, + bookedCount: 0, + status: TimeSlotStatus.OPEN, + source: 'TEMPLATE', + templateId: null, + createdAt: new Date(), + updatedAt: new Date(), +} + +const mockFullSlot = { + ...mockOpenSlot, + id: 'slot-full-001', + bookedCount: 5, + status: TimeSlotStatus.FULL, +} + +const mockActiveMembership = { + id: MOCK_MEMBERSHIP_ID, + userId: MOCK_USER_ID, + cardTypeId: mockTimesCardType.id, + remainingTimes: 5, + startDate: new Date('2024-01-01'), + expireDate: new Date('2099-12-31'), + status: MembershipStatus.ACTIVE, + createdAt: new Date(), + updatedAt: new Date(), + cardType: mockTimesCardType, +} + +const mockDurationMembership = { + ...mockActiveMembership, + id: 'mem-duration-001', + cardTypeId: mockDurationCardType.id, + remainingTimes: null, + cardType: mockDurationCardType, +} + +const mockExpiredMembership = { + ...mockActiveMembership, + id: 'mem-expired-001', + status: MembershipStatus.EXPIRED, +} + +const mockMembershipNoTimes = { + ...mockActiveMembership, + id: 'mem-no-times-001', + remainingTimes: 0, +} + +const mockConfirmedBooking = { + id: MOCK_BOOKING_ID, + userId: MOCK_USER_ID, + timeSlotId: MOCK_SLOT_ID, + membershipId: MOCK_MEMBERSHIP_ID, + status: BookingStatus.CONFIRMED, + cancelledAt: null, + createdAt: new Date(), + updatedAt: new Date(), +} + +const mockStudioConfig = { + id: 'studio-001', + name: 'Test Studio', + logo: null, + bannerUrl: null, + address: '', + phone: '', + latitude: null, + longitude: null, + cancelHoursLimit: 2, + photos: [], + updatedAt: new Date(), +} + +// ─── Mock factory ───────────────────────────────────────────────────────── + +function buildTxMock(overrides: Record = {}) { + return { + timeSlot: { + findUnique: jest.fn(), + update: jest.fn(), + }, + booking: { + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + membership: { + findUnique: jest.fn(), + update: jest.fn(), + }, + ...overrides, + } +} + +// ─── Test Suite ──────────────────────────────────────────────────────────── + +describe('BookingService', () => { + let service: BookingService + let prisma: jest.Mocked + let studioService: jest.Mocked + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + BookingService, + { + provide: PrismaService, + useValue: { + $transaction: jest.fn(), + booking: { + findUnique: jest.fn(), + findMany: jest.fn(), + count: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + timeSlot: { + findUnique: jest.fn(), + update: jest.fn(), + }, + membership: { + findUnique: jest.fn(), + update: jest.fn(), + }, + }, + }, + { + provide: MembershipService, + useValue: { + deductMembership: jest.fn(), + restoreMembership: jest.fn(), + getValidMembership: jest.fn(), + }, + }, + { + provide: StudioService, + useValue: { + getInfo: jest.fn(), + }, + }, + ], + }).compile() + + service = module.get(BookingService) + prisma = module.get(PrismaService) as jest.Mocked + studioService = module.get(StudioService) as jest.Mocked + }) + + afterEach(() => jest.clearAllMocks()) + + // ─── createBooking ──────────────────────────────────────────────────────── + + describe('createBooking', () => { + const dto = { timeSlotId: MOCK_SLOT_ID, membershipId: MOCK_MEMBERSHIP_ID } + + it('creates booking, increments bookedCount, and deducts membership (TIMES card)', async () => { + const tx = buildTxMock() + tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) + tx.booking.findUnique.mockResolvedValue(null) // no duplicate + tx.membership.findUnique.mockResolvedValue(mockActiveMembership) + tx.booking.create.mockResolvedValue(mockConfirmedBooking) + tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 }) + tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 4 }) + + ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) + + // Mock the re-fetch after transaction + ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({ + ...mockConfirmedBooking, + timeSlot: mockOpenSlot, + membership: mockActiveMembership, + }) + + const result = await service.createBooking(MOCK_USER_ID, dto) + + expect(tx.booking.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + userId: MOCK_USER_ID, + timeSlotId: MOCK_SLOT_ID, + membershipId: MOCK_MEMBERSHIP_ID, + status: BookingStatus.CONFIRMED, + }), + }), + ) + + // bookedCount incremented from 0 → 1, still OPEN (capacity 5) + expect(tx.timeSlot.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ bookedCount: 1, status: TimeSlotStatus.OPEN }), + }), + ) + + // membership deducted from 5 → 4 + expect(tx.membership.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + remainingTimes: 4, + status: MembershipStatus.ACTIVE, + }), + }), + ) + + expect(result).toBeDefined() + }) + + it('sets slot to FULL when bookedCount reaches capacity', async () => { + const nearFullSlot = { ...mockOpenSlot, bookedCount: 4, capacity: 5 } + + const tx = buildTxMock() + tx.timeSlot.findUnique.mockResolvedValue(nearFullSlot) + tx.booking.findUnique.mockResolvedValue(null) + tx.membership.findUnique.mockResolvedValue(mockActiveMembership) + tx.booking.create.mockResolvedValue(mockConfirmedBooking) + tx.timeSlot.update.mockResolvedValue({ ...nearFullSlot, bookedCount: 5, status: TimeSlotStatus.FULL }) + tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 4 }) + + ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) + ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({ + ...mockConfirmedBooking, + timeSlot: { ...nearFullSlot, status: TimeSlotStatus.FULL }, + membership: mockActiveMembership, + }) + + await service.createBooking(MOCK_USER_ID, dto) + + // bookedCount 4+1 = 5 = capacity → FULL + expect(tx.timeSlot.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ bookedCount: 5, status: TimeSlotStatus.FULL }), + }), + ) + }) + + it('does NOT deduct membership for DURATION card', async () => { + const durationDto = { timeSlotId: MOCK_SLOT_ID, membershipId: mockDurationMembership.id } + + const tx = buildTxMock() + tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) + tx.booking.findUnique.mockResolvedValue(null) + tx.membership.findUnique.mockResolvedValue(mockDurationMembership) + tx.booking.create.mockResolvedValue({ ...mockConfirmedBooking, membershipId: mockDurationMembership.id }) + 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, + timeSlot: mockOpenSlot, + membership: mockDurationMembership, + }) + + await service.createBooking(MOCK_USER_ID, durationDto) + + // DURATION card: membership.update should NOT be called + expect(tx.membership.update).not.toHaveBeenCalled() + }) + + it('marks membership as USED_UP when remainingTimes hits 0', async () => { + const lastTimeMembership = { ...mockActiveMembership, remainingTimes: 1 } + + const tx = buildTxMock() + tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) + tx.booking.findUnique.mockResolvedValue(null) + tx.membership.findUnique.mockResolvedValue(lastTimeMembership) + tx.booking.create.mockResolvedValue(mockConfirmedBooking) + tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 }) + tx.membership.update.mockResolvedValue({ ...lastTimeMembership, remainingTimes: 0, status: MembershipStatus.USED_UP }) + + ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) + ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({ + ...mockConfirmedBooking, + timeSlot: mockOpenSlot, + membership: { ...lastTimeMembership, remainingTimes: 0, status: MembershipStatus.USED_UP }, + }) + + await service.createBooking(MOCK_USER_ID, dto) + + expect(tx.membership.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + remainingTimes: 0, + status: MembershipStatus.USED_UP, + }), + }), + ) + }) + + it('throws BadRequestException when slot is FULL', async () => { + const fullDto = { timeSlotId: mockFullSlot.id, membershipId: MOCK_MEMBERSHIP_ID } + + const tx = buildTxMock() + tx.timeSlot.findUnique.mockResolvedValue(mockFullSlot) + + ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) + + await expect(service.createBooking(MOCK_USER_ID, fullDto)).rejects.toThrow( + BadRequestException, + ) + }) + + it('throws ConflictException on duplicate booking (same user + slot)', async () => { + const tx = buildTxMock() + tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) + tx.booking.findUnique.mockResolvedValue(mockConfirmedBooking) // duplicate exists + + ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) + + await expect(service.createBooking(MOCK_USER_ID, dto)).rejects.toThrow( + ConflictException, + ) + }) + + it('throws BadRequestException when membership is not ACTIVE (expired status)', async () => { + const tx = buildTxMock() + tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) + tx.booking.findUnique.mockResolvedValue(null) + tx.membership.findUnique.mockResolvedValue(mockExpiredMembership) + + ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) + + await expect(service.createBooking(MOCK_USER_ID, dto)).rejects.toThrow( + BadRequestException, + ) + }) + + it('throws BadRequestException when TIMES membership has 0 remaining', async () => { + const tx = buildTxMock() + tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) + tx.booking.findUnique.mockResolvedValue(null) + tx.membership.findUnique.mockResolvedValue(mockMembershipNoTimes) + + ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) + + await expect(service.createBooking(MOCK_USER_ID, dto)).rejects.toThrow( + BadRequestException, + ) + expect(tx.booking.create).not.toHaveBeenCalled() + }) + + it('throws NotFoundException when timeSlot does not exist', async () => { + const tx = buildTxMock() + tx.timeSlot.findUnique.mockResolvedValue(null) + + ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) + + await expect(service.createBooking(MOCK_USER_ID, dto)).rejects.toThrow( + NotFoundException, + ) + }) + + it('throws ForbiddenException when membership belongs to another user', async () => { + const otherUserMembership = { ...mockActiveMembership, userId: 'other-user' } + + const tx = buildTxMock() + tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) + tx.booking.findUnique.mockResolvedValue(null) + tx.membership.findUnique.mockResolvedValue(otherUserMembership) + + ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) + + await expect(service.createBooking(MOCK_USER_ID, dto)).rejects.toThrow( + ForbiddenException, + ) + }) + }) + + // ─── cancelBooking ──────────────────────────────────────────────────────── + + describe('cancelBooking', () => { + // Slot starts 24h from now → within the 2-hour limit → refund eligible + const futureDate = new Date(Date.now() + 24 * 3600 * 1000) + const futureSlot = { + ...mockOpenSlot, + date: futureDate, + startTime: `${String(futureDate.getUTCHours()).padStart(2, '0')}:${String(futureDate.getUTCMinutes()).padStart(2, '0')}`, + } + + // Slot starts 30 minutes from now → past the 2-hour limit → no refund + const imminentDate = new Date(Date.now() + 30 * 60 * 1000) + const imminentSlot = { + ...mockOpenSlot, + id: 'slot-imminent-001', + date: imminentDate, + startTime: `${String(imminentDate.getUTCHours()).padStart(2, '0')}:${String(imminentDate.getUTCMinutes()).padStart(2, '0')}`, + } + + beforeEach(() => { + studioService.getInfo.mockResolvedValue(mockStudioConfig) + }) + + it('cancels booking within limit: decrements bookedCount and refunds membership', async () => { + const bookingWithRelations = { + ...mockConfirmedBooking, + timeSlot: { ...futureSlot, bookedCount: 3 }, + membership: mockActiveMembership, + } + + ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(bookingWithRelations) + + const tx = buildTxMock() + const cancelledBooking = { ...mockConfirmedBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() } + tx.booking.update.mockResolvedValue(cancelledBooking) + tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 2 }) + tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 6 }) + + ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) + + const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID) + + expect(tx.booking.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: BookingStatus.CANCELLED }), + }), + ) + + // bookedCount decremented: 3 → 2 + expect(tx.timeSlot.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ bookedCount: 2 }), + }), + ) + + // Membership restored: 5 → 6 + expect(tx.membership.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ remainingTimes: 6 }), + }), + ) + + expect(result.refunded).toBe(true) + }) + + it('cancels booking past limit: does NOT refund membership', async () => { + const bookingWithImminent = { + ...mockConfirmedBooking, + timeSlot: { ...imminentSlot, bookedCount: 1 }, + membership: mockActiveMembership, + } + + ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(bookingWithImminent) + + const tx = buildTxMock() + const cancelledBooking = { ...mockConfirmedBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() } + tx.booking.update.mockResolvedValue(cancelledBooking) + tx.timeSlot.update.mockResolvedValue({ ...imminentSlot, bookedCount: 0 }) + + ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) + + const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID) + + expect(result.refunded).toBe(false) + // membership.update must NOT be called + expect(tx.membership.update).not.toHaveBeenCalled() + }) + + it('changes slot from FULL to OPEN after cancellation', async () => { + const fullSlotWithBooking = { + ...mockConfirmedBooking, + timeSlot: { ...futureSlot, bookedCount: 5, capacity: 5, status: TimeSlotStatus.FULL }, + membership: mockActiveMembership, + } + + ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(fullSlotWithBooking) + + const tx = buildTxMock() + tx.booking.update.mockResolvedValue({ ...mockConfirmedBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() }) + tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 4, status: TimeSlotStatus.OPEN }) + tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 6 }) + + ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) + + await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID) + + // slot was FULL → should be restored to OPEN + expect(tx.timeSlot.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + bookedCount: 4, + status: TimeSlotStatus.OPEN, + }), + }), + ) + }) + + it('restores USED_UP membership back to ACTIVE when cancelled within limit', async () => { + const usedUpMembership = { + ...mockActiveMembership, + remainingTimes: 0, + status: MembershipStatus.USED_UP, + } + + const bookingWithUsedUp = { + ...mockConfirmedBooking, + timeSlot: { ...futureSlot, bookedCount: 1 }, + membership: usedUpMembership, + } + + ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(bookingWithUsedUp) + + const tx = buildTxMock() + tx.booking.update.mockResolvedValue({ ...mockConfirmedBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() }) + tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 }) + tx.membership.update.mockResolvedValue({ ...usedUpMembership, remainingTimes: 1, status: MembershipStatus.ACTIVE }) + + ;(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({ + data: expect.objectContaining({ + remainingTimes: 1, + status: MembershipStatus.ACTIVE, + }), + }), + ) + expect(result.refunded).toBe(true) + }) + + it('throws NotFoundException when booking does not exist', async () => { + ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(null) + + await expect(service.cancelBooking(MOCK_USER_ID, 'nonexistent')).rejects.toThrow( + NotFoundException, + ) + }) + + it('throws ForbiddenException when booking belongs to another user', async () => { + const otherBooking = { ...mockConfirmedBooking, userId: 'other-user', timeSlot: futureSlot, membership: mockActiveMembership } + ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(otherBooking) + + await expect(service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID)).rejects.toThrow( + ForbiddenException, + ) + }) + + it('throws BadRequestException when booking is already CANCELLED', async () => { + const cancelledBooking = { + ...mockConfirmedBooking, + status: BookingStatus.CANCELLED, + timeSlot: futureSlot, + membership: mockActiveMembership, + } + ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(cancelledBooking) + + await expect(service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID)).rejects.toThrow( + BadRequestException, + ) + }) + }) + + // ─── getMyBookings ──────────────────────────────────────────────────────── + + describe('getMyBookings', () => { + it('returns paginated list of bookings for the user', async () => { + const bookings = [ + { ...mockConfirmedBooking, timeSlot: mockOpenSlot, membership: mockActiveMembership }, + ] + ;(prisma.booking.findMany as jest.Mock).mockResolvedValue(bookings) + ;(prisma.booking.count as jest.Mock).mockResolvedValue(1) + + const result = await service.getMyBookings(MOCK_USER_ID) + + expect(result.total).toBe(1) + expect(result.page).toBe(1) + expect(result.limit).toBe(10) + expect(result.data).toHaveLength(1) + }) + + it('filters by status when provided', async () => { + ;(prisma.booking.findMany as jest.Mock).mockResolvedValue([]) + ;(prisma.booking.count as jest.Mock).mockResolvedValue(0) + + await service.getMyBookings(MOCK_USER_ID, BookingStatus.CANCELLED) + + expect(prisma.booking.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: BookingStatus.CANCELLED, + }), + }), + ) + }) + + it('uses default pagination when page/limit not provided', async () => { + ;(prisma.booking.findMany as jest.Mock).mockResolvedValue([]) + ;(prisma.booking.count as jest.Mock).mockResolvedValue(0) + + const result = await service.getMyBookings(MOCK_USER_ID) + + expect(prisma.booking.findMany).toHaveBeenCalledWith( + expect.objectContaining({ skip: 0, take: 10 }), + ) + expect(result.page).toBe(1) + expect(result.limit).toBe(10) + }) + }) + + // ─── getUpcomingBookings ────────────────────────────────────────────────── + + describe('getUpcomingBookings', () => { + it('returns confirmed bookings with future dates, ordered by date and startTime', async () => { + const upcoming = [ + { ...mockConfirmedBooking, timeSlot: mockOpenSlot, membership: mockActiveMembership }, + ] + ;(prisma.booking.findMany as jest.Mock).mockResolvedValue(upcoming) + + const result = await service.getUpcomingBookings(MOCK_USER_ID) + + expect(prisma.booking.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + userId: MOCK_USER_ID, + status: BookingStatus.CONFIRMED, + }), + orderBy: [ + { timeSlot: { date: 'asc' } }, + { timeSlot: { startTime: 'asc' } }, + ], + }), + ) + expect(result).toHaveLength(1) + }) + }) + + // ─── getAllBookings (admin) ──────────────────────────────────────────────── + + describe('getAllBookings', () => { + it('returns paginated list of all bookings with user info', async () => { + const bookings = [ + { + ...mockConfirmedBooking, + user: { id: MOCK_USER_ID, nickname: 'Test User', phone: null }, + timeSlot: mockOpenSlot, + membership: mockActiveMembership, + }, + ] + ;(prisma.booking.findMany as jest.Mock).mockResolvedValue(bookings) + ;(prisma.booking.count as jest.Mock).mockResolvedValue(1) + + const result = await service.getAllBookings(1, 10) + + expect(result.total).toBe(1) + expect(result.data[0]).toHaveProperty('user') + }) + + it('filters by status when provided', async () => { + ;(prisma.booking.findMany as jest.Mock).mockResolvedValue([]) + ;(prisma.booking.count as jest.Mock).mockResolvedValue(0) + + await service.getAllBookings(1, 10, BookingStatus.CONFIRMED) + + expect(prisma.booking.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { status: BookingStatus.CONFIRMED }, + }), + ) + }) + }) +}) diff --git a/packages/server/src/booking/booking.controller.ts b/packages/server/src/booking/booking.controller.ts new file mode 100644 index 0000000..9b801ce --- /dev/null +++ b/packages/server/src/booking/booking.controller.ts @@ -0,0 +1,81 @@ +import { + Body, + Controller, + Get, + Param, + Post, + Put, + Query, + UseGuards, +} from '@nestjs/common' +import { BookingStatus, UserRole } from '@mp-pilates/shared' +import { JwtAuthGuard } from '../auth/jwt-auth.guard' +import { RolesGuard } from '../auth/roles.guard' +import { Roles } from '../auth/roles.decorator' +import { CurrentUser } from '../common/decorators/current-user.decorator' +import { BookingService } from './booking.service' +import { CreateBookingDto } from './dto/create-booking.dto' + +@Controller() +export class BookingController { + constructor(private readonly bookingService: BookingService) {} + + // ─── User Endpoints ─────────────────────────────────────────────────────── + + @Post('booking') + @UseGuards(JwtAuthGuard) + async createBooking( + @CurrentUser('sub') userId: string, + @Body() dto: CreateBookingDto, + ) { + return this.bookingService.createBooking(userId, dto) + } + + @Put('booking/:id/cancel') + @UseGuards(JwtAuthGuard) + async cancelBooking( + @CurrentUser('sub') userId: string, + @Param('id') id: string, + ) { + return this.bookingService.cancelBooking(userId, id) + } + + @Get('booking/my/upcoming') + @UseGuards(JwtAuthGuard) + async getUpcomingBookings(@CurrentUser('sub') userId: string) { + return this.bookingService.getUpcomingBookings(userId) + } + + @Get('booking/my') + @UseGuards(JwtAuthGuard) + async getMyBookings( + @CurrentUser('sub') userId: string, + @Query('status') status?: BookingStatus, + @Query('page') page?: string, + @Query('limit') limit?: string, + ) { + return this.bookingService.getMyBookings( + userId, + status, + page ? Number(page) : 1, + limit ? Number(limit) : 10, + ) + } + + // ─── Admin Endpoints ────────────────────────────────────────────────────── + + @Get('admin/bookings') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + async getAllBookings( + @Query('page') page?: string, + @Query('limit') limit?: string, + @Query('status') status?: BookingStatus, + ) { + return this.bookingService.getAllBookings( + page ? Number(page) : 1, + limit ? Number(limit) : 10, + status, + ) + } +} diff --git a/packages/server/src/booking/booking.module.ts b/packages/server/src/booking/booking.module.ts new file mode 100644 index 0000000..8e29473 --- /dev/null +++ b/packages/server/src/booking/booking.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common' +import { BookingController } from './booking.controller' +import { BookingService } from './booking.service' +import { MembershipModule } from '../membership/membership.module' +import { StudioModule } from '../studio/studio.module' + +@Module({ + imports: [MembershipModule, StudioModule], + controllers: [BookingController], + providers: [BookingService], + exports: [BookingService], +}) +export class BookingModule {} diff --git a/packages/server/src/booking/booking.service.ts b/packages/server/src/booking/booking.service.ts new file mode 100644 index 0000000..efe5b8e --- /dev/null +++ b/packages/server/src/booking/booking.service.ts @@ -0,0 +1,366 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common' +import { Booking, Membership, TimeSlot } from '@prisma/client' +import { BookingStatus, CardTypeCategory, MembershipStatus, TimeSlotStatus } from '@mp-pilates/shared' +import { PrismaService } from '../prisma/prisma.service' +import { MembershipService } from '../membership/membership.service' +import { StudioService } from '../studio/studio.service' +import { CreateBookingDto } from './dto/create-booking.dto' + +// ─── Types ───────────────────────────────────────────────────────────────── + +export interface BookingWithRelations extends Booking { + timeSlot: TimeSlot + membership: Membership & { cardType: { id: string; name: string; type: string } } +} + +export interface PaginatedResult { + data: T[] + total: number + page: number + limit: number +} + +export interface CancelBookingResult { + booking: Booking + refunded: boolean +} + +// ─── Helpers ─────────────────────────────────────────────────────────────── + +function buildSlotStartMs(slotDate: Date, startTime: string): number { + // slotDate is stored as DATE (midnight UTC); startTime is "HH:mm" + const [hours, minutes] = startTime.split(':').map(Number) + const d = new Date(slotDate) + d.setUTCHours(hours, minutes, 0, 0) + return d.getTime() +} + +// ─── Service ─────────────────────────────────────────────────────────────── + +@Injectable() +export class BookingService { + constructor( + private readonly prisma: PrismaService, + private readonly membershipService: MembershipService, + private readonly studioService: StudioService, + ) {} + + // ─── Create Booking ────────────────────────────────────────────────────── + + async createBooking( + userId: string, + dto: CreateBookingDto, + ): Promise { + const booking = await this.prisma.$transaction(async (tx) => { + // 1. Fetch and validate timeSlot + const timeSlot = await tx.timeSlot.findUnique({ + where: { id: dto.timeSlotId }, + }) + if (!timeSlot) { + throw new NotFoundException(`TimeSlot ${dto.timeSlotId} not found`) + } + if (timeSlot.status !== TimeSlotStatus.OPEN) { + throw new BadRequestException( + `TimeSlot is not available (status: ${timeSlot.status})`, + ) + } + + // 2. Check for duplicate booking (@@unique [userId, timeSlotId]) + const existing = await tx.booking.findUnique({ + where: { userId_timeSlotId: { userId, timeSlotId: dto.timeSlotId } }, + }) + if (existing) { + throw new ConflictException('You have already booked this time slot') + } + + // 3. Fetch and validate membership + const membership = await tx.membership.findUnique({ + where: { id: dto.membershipId }, + include: { cardType: true }, + }) + if (!membership) { + throw new NotFoundException(`Membership ${dto.membershipId} not found`) + } + if (membership.userId !== userId) { + throw new ForbiddenException('This membership does not belong to you') + } + if (membership.status !== MembershipStatus.ACTIVE) { + throw new BadRequestException( + `Membership is not active (status: ${membership.status})`, + ) + } + + const cardType = membership.cardType + const isTimeBased = + cardType.type === CardTypeCategory.TIMES || + cardType.type === CardTypeCategory.TRIAL + + if (isTimeBased) { + // 4a. TIMES / TRIAL: must have remaining times + if ((membership.remainingTimes ?? 0) <= 0) { + throw new BadRequestException('No remaining times on this membership') + } + } else { + // 4b. DURATION: must not be expired + if (membership.expireDate <= new Date()) { + throw new BadRequestException('Membership has expired') + } + } + + // 5. Create booking + const newBooking = await tx.booking.create({ + data: { + userId, + timeSlotId: dto.timeSlotId, + membershipId: dto.membershipId, + status: BookingStatus.CONFIRMED, + }, + }) + + // 6. Increment bookedCount; set FULL if at capacity + const newBookedCount = timeSlot.bookedCount + 1 + const newSlotStatus = + newBookedCount >= timeSlot.capacity ? TimeSlotStatus.FULL : TimeSlotStatus.OPEN + + await tx.timeSlot.update({ + where: { id: dto.timeSlotId }, + data: { + bookedCount: newBookedCount, + status: newSlotStatus, + }, + }) + + // 7. Deduct membership (inside transaction — replicate logic to avoid + // calling the service method which uses the outer prisma client) + if (isTimeBased) { + const newRemainingTimes = (membership.remainingTimes ?? 0) - 1 + const newMembershipStatus = + newRemainingTimes <= 0 ? MembershipStatus.USED_UP : MembershipStatus.ACTIVE + + await tx.membership.update({ + where: { id: dto.membershipId }, + data: { + remainingTimes: newRemainingTimes, + status: newMembershipStatus, + }, + }) + } + + return newBooking + }) + + // Re-fetch with relations after transaction + return this.fetchBookingWithRelations(booking.id) + } + + // ─── Cancel Booking ────────────────────────────────────────────────────── + + async cancelBooking( + userId: string, + bookingId: string, + ): Promise { + // 1. Find booking with timeSlot and membership + const booking = await this.prisma.booking.findUnique({ + where: { id: bookingId }, + include: { + timeSlot: true, + membership: { include: { cardType: true } }, + }, + }) + + if (!booking) { + throw new NotFoundException(`Booking ${bookingId} not found`) + } + if (booking.userId !== userId) { + throw new ForbiddenException('This booking does not belong to you') + } + if (booking.status !== BookingStatus.CONFIRMED) { + throw new BadRequestException( + `Cannot cancel booking with status: ${booking.status}`, + ) + } + + // 2. Determine refund eligibility + const studioConfig = await this.studioService.getInfo() + const { cancelHoursLimit } = studioConfig + + const slotStartMs = buildSlotStartMs(booking.timeSlot.date, booking.timeSlot.startTime) + const deadlineMs = Date.now() + cancelHoursLimit * 3600 * 1000 + const withinLimit = slotStartMs >= deadlineMs + + // 3. Transaction: cancel booking, restore slot, conditionally restore membership + const updatedBooking = await this.prisma.$transaction(async (tx) => { + // Cancel the booking + const cancelled = await tx.booking.update({ + where: { id: bookingId }, + data: { + status: BookingStatus.CANCELLED, + cancelledAt: new Date(), + }, + }) + + // Decrement bookedCount; restore OPEN if was FULL + const newBookedCount = Math.max(0, booking.timeSlot.bookedCount - 1) + const newSlotStatus = + booking.timeSlot.status === TimeSlotStatus.FULL + ? TimeSlotStatus.OPEN + : booking.timeSlot.status + + await tx.timeSlot.update({ + where: { id: booking.timeSlotId }, + data: { + bookedCount: newBookedCount, + status: newSlotStatus, + }, + }) + + // Conditionally restore membership + if (withinLimit) { + const cardType = booking.membership.cardType + const isTimeBased = + cardType.type === CardTypeCategory.TIMES || + cardType.type === CardTypeCategory.TRIAL + + if (isTimeBased) { + const newRemainingTimes = (booking.membership.remainingTimes ?? 0) + 1 + const newStatus = + booking.membership.status === MembershipStatus.USED_UP + ? MembershipStatus.ACTIVE + : booking.membership.status + + await tx.membership.update({ + where: { id: booking.membershipId }, + data: { + remainingTimes: newRemainingTimes, + status: newStatus, + }, + }) + } + } + + return cancelled + }) + + return { booking: { ...updatedBooking }, refunded: withinLimit } + } + + // ─── Get My Bookings ───────────────────────────────────────────────────── + + async getMyBookings( + userId: string, + status?: BookingStatus, + page = 1, + limit = 10, + ): Promise> { + const where = { + userId, + ...(status ? { status } : {}), + } + + const [bookings, total] = await Promise.all([ + this.prisma.booking.findMany({ + where, + include: { + timeSlot: true, + membership: { include: { cardType: true } }, + }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * limit, + take: limit, + }), + this.prisma.booking.count({ where }), + ]) + + return { + data: bookings.map((b) => ({ ...b })) as BookingWithRelations[], + total, + page, + limit, + } + } + + // ─── Get Upcoming Bookings ──────────────────────────────────────────────── + + async getUpcomingBookings(userId: string): Promise { + const today = new Date() + today.setUTCHours(0, 0, 0, 0) + + const bookings = await this.prisma.booking.findMany({ + where: { + userId, + status: BookingStatus.CONFIRMED, + timeSlot: { + date: { gte: today }, + }, + }, + include: { + timeSlot: true, + membership: { include: { cardType: true } }, + }, + orderBy: [ + { timeSlot: { date: 'asc' } }, + { timeSlot: { startTime: 'asc' } }, + ], + }) + + return bookings.map((b) => ({ ...b })) as BookingWithRelations[] + } + + // ─── Get All Bookings (Admin) ───────────────────────────────────────────── + + async getAllBookings( + page = 1, + limit = 10, + status?: BookingStatus, + ): Promise> { + const where = status ? { status } : {} + + const [bookings, total] = await Promise.all([ + this.prisma.booking.findMany({ + where, + include: { + user: { select: { id: true, nickname: true, phone: true } }, + timeSlot: true, + membership: { include: { cardType: true } }, + }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * limit, + take: limit, + }), + this.prisma.booking.count({ where }), + ]) + + return { + data: bookings.map((b) => ({ ...b })) as unknown as (BookingWithRelations & { + user: { id: string; nickname: string; phone: string | null } + })[], + total, + page, + limit, + } + } + + // ─── Private Helpers ────────────────────────────────────────────────────── + + private async fetchBookingWithRelations(bookingId: string): Promise { + const booking = await this.prisma.booking.findUnique({ + where: { id: bookingId }, + include: { + timeSlot: true, + membership: { include: { cardType: true } }, + }, + }) + + if (!booking) { + throw new NotFoundException(`Booking ${bookingId} not found`) + } + + return { ...booking } as BookingWithRelations + } +} diff --git a/packages/server/src/booking/dto/create-booking.dto.ts b/packages/server/src/booking/dto/create-booking.dto.ts new file mode 100644 index 0000000..ccdae7a --- /dev/null +++ b/packages/server/src/booking/dto/create-booking.dto.ts @@ -0,0 +1,9 @@ +import { IsUUID } from 'class-validator' + +export class CreateBookingDto { + @IsUUID() + timeSlotId!: string + + @IsUUID() + membershipId!: string +} diff --git a/packages/server/src/payment/__tests__/payment.service.spec.ts b/packages/server/src/payment/__tests__/payment.service.spec.ts new file mode 100644 index 0000000..754bfb4 --- /dev/null +++ b/packages/server/src/payment/__tests__/payment.service.spec.ts @@ -0,0 +1,333 @@ +import { Test, TestingModule } from '@nestjs/testing' +import { BadRequestException, NotFoundException } from '@nestjs/common' +import { Decimal } from '@prisma/client/runtime/library' +import { MembershipStatus, OrderStatus } from '@mp-pilates/shared' +import { PaymentService } from '../payment.service' +import { WechatPayService } from '../wechat-pay.service' +import { PrismaService } from '../../prisma/prisma.service' + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const mockCardType = { + id: 'card-type-uuid-1', + name: '10次课包', + isActive: true, + price: new Decimal(990), + totalTimes: 10, + durationDays: 90, + type: 'TIMES', + originalPrice: null, + description: null, + sortOrder: 0, + createdAt: new Date(), + updatedAt: new Date(), +} + +const mockInactiveCardType = { ...mockCardType, id: 'card-type-uuid-inactive', isActive: false } + +const mockUser = { + id: 'user-uuid-1', + openid: 'wx-openid-1', + nickname: 'Test User', + phone: null, + role: 'MEMBER', + createdAt: new Date(), + updatedAt: new Date(), +} + +const buildMockOrder = (overrides: Partial> = {}) => ({ + id: 'order-uuid-1', + userId: mockUser.id, + cardTypeId: mockCardType.id, + orderNo: '1700000000000abc123', + amount: new Decimal(990), + status: OrderStatus.PENDING, + wxTransactionId: null, + paidAt: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, +}) + +const mockPaymentParams = { + timeStamp: '1700000000', + nonceStr: 'mockNonce', + package: 'prepay_id=mock_prepay_1700000000000abc123', + signType: 'RSA', + paySign: 'mockSign', +} + +// ─── Mock factories ──────────────────────────────────────────────────────────── + +function buildPrismaMock() { + return { + cardType: { + findUnique: jest.fn(), + }, + user: { + findUnique: jest.fn(), + }, + order: { + create: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + findMany: jest.fn(), + count: jest.fn(), + }, + membership: { + create: jest.fn(), + }, + $transaction: jest.fn(), + } +} + +function buildWechatMock() { + return { + createUnifiedOrder: jest.fn().mockResolvedValue(mockPaymentParams), + verifySignature: jest.fn().mockReturnValue(true), + parseNotification: jest.fn(), + } +} + +// ─── Test suite ──────────────────────────────────────────────────────────────── + +describe('PaymentService', () => { + let service: PaymentService + let prisma: ReturnType + let wechat: ReturnType + + beforeEach(async () => { + prisma = buildPrismaMock() + wechat = buildWechatMock() + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PaymentService, + { provide: PrismaService, useValue: prisma }, + { provide: WechatPayService, useValue: wechat }, + ], + }).compile() + + service = module.get(PaymentService) + }) + + afterEach(() => jest.clearAllMocks()) + + // ─── createOrder ──────────────────────────────────────────────────────────── + + describe('createOrder', () => { + it('creates a PENDING order with correct amount and formatted orderNo', async () => { + prisma.cardType.findUnique.mockResolvedValue(mockCardType) + prisma.user.findUnique.mockResolvedValue(mockUser) + + const createdOrder = buildMockOrder() + prisma.order.create.mockResolvedValue(createdOrder) + + const result = await service.createOrder(mockUser.id, mockCardType.id) + + // Order was created with correct fields + expect(prisma.order.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + userId: mockUser.id, + cardTypeId: mockCardType.id, + amount: mockCardType.price, + status: OrderStatus.PENDING, + }), + }), + ) + + // orderNo starts with a timestamp-like number + const { orderNo } = prisma.order.create.mock.calls[0][0].data as { orderNo: string } + expect(orderNo).toMatch(/^\d{13}[a-z0-9]{6}$/) + + expect(result.order).toMatchObject({ status: OrderStatus.PENDING }) + expect(result.paymentParams).toEqual(mockPaymentParams) + }) + + it('calls WechatPayService with correct openid and amount', async () => { + prisma.cardType.findUnique.mockResolvedValue(mockCardType) + prisma.user.findUnique.mockResolvedValue(mockUser) + prisma.order.create.mockResolvedValue(buildMockOrder()) + + await service.createOrder(mockUser.id, mockCardType.id) + + expect(wechat.createUnifiedOrder).toHaveBeenCalledWith( + expect.objectContaining({ + openid: mockUser.openid, + amount: Number(mockCardType.price), + description: mockCardType.name, + }), + ) + }) + + it('throws NotFoundException when cardType does not exist', async () => { + prisma.cardType.findUnique.mockResolvedValue(null) + + await expect(service.createOrder(mockUser.id, 'non-existent')).rejects.toThrow( + NotFoundException, + ) + expect(prisma.order.create).not.toHaveBeenCalled() + }) + + it('throws BadRequestException when cardType is not active', async () => { + prisma.cardType.findUnique.mockResolvedValue(mockInactiveCardType) + + await expect(service.createOrder(mockUser.id, mockInactiveCardType.id)).rejects.toThrow( + BadRequestException, + ) + expect(prisma.order.create).not.toHaveBeenCalled() + }) + + it('throws NotFoundException when user does not exist', async () => { + prisma.cardType.findUnique.mockResolvedValue(mockCardType) + prisma.user.findUnique.mockResolvedValue(null) + + await expect(service.createOrder('ghost-user', mockCardType.id)).rejects.toThrow( + NotFoundException, + ) + }) + }) + + // ─── handleWxNotify ───────────────────────────────────────────────────────── + + describe('handleWxNotify', () => { + const headers = { 'wechatpay-signature': 'sig' } + const successBody = { + out_trade_no: '1700000000000abc123', + transaction_id: 'wx-txn-001', + trade_state: 'SUCCESS', + } + const pendingOrder = buildMockOrder({ status: OrderStatus.PENDING }) + + beforeEach(() => { + wechat.verifySignature.mockReturnValue(true) + wechat.parseNotification.mockReturnValue({ + orderNo: successBody.out_trade_no, + wxTransactionId: successBody.transaction_id, + success: true, + }) + prisma.order.findUnique.mockResolvedValue(pendingOrder) + prisma.cardType.findUnique.mockResolvedValue(mockCardType) + prisma.$transaction.mockResolvedValue([]) + }) + + it('marks order as PAID and creates membership on valid callback', async () => { + const result = await service.handleWxNotify(headers, successBody) + + // $transaction called once with an array of two operations + expect(prisma.$transaction).toHaveBeenCalledTimes(1) + const [transactionOps] = prisma.$transaction.mock.calls[0] as [unknown[]] + expect(transactionOps).toHaveLength(2) + + // order.update was called with PAID status and transaction id + expect(prisma.order.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: OrderStatus.PAID, + wxTransactionId: successBody.transaction_id, + }), + }), + ) + + // membership.create was called + expect(prisma.membership.create).toHaveBeenCalledTimes(1) + + expect(result).toContain('SUCCESS') + }) + + it('creates membership with correct expireDate (startDate + durationDays)', async () => { + const beforeCall = Date.now() + await service.handleWxNotify(headers, successBody) + + expect(prisma.membership.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + userId: pendingOrder.userId, + cardTypeId: pendingOrder.cardTypeId, + status: MembershipStatus.ACTIVE, + }), + }), + ) + + const membershipData = prisma.membership.create.mock.calls[0][0].data as { + startDate: Date + expireDate: Date + } + + const expectedExpireMs = + membershipData.startDate.getTime() + mockCardType.durationDays * 86_400_000 + expect(membershipData.expireDate.getTime()).toBeCloseTo(expectedExpireMs, -2) // within 100ms + expect(membershipData.startDate.getTime()).toBeGreaterThanOrEqual(beforeCall) + }) + + it('creates membership with correct remainingTimes from cardType', async () => { + await service.handleWxNotify(headers, successBody) + + expect(prisma.membership.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + remainingTimes: mockCardType.totalTimes, // 10 + }), + }), + ) + }) + + it('creates membership with null remainingTimes for duration-based cardType', async () => { + const durationCardType = { ...mockCardType, totalTimes: null } + prisma.cardType.findUnique.mockResolvedValue(durationCardType) + + await service.handleWxNotify(headers, successBody) + + expect(prisma.membership.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + remainingTimes: null, + }), + }), + ) + }) + + it('is idempotent: duplicate callback for already-PAID order skips transaction', async () => { + const paidOrder = buildMockOrder({ status: OrderStatus.PAID }) + prisma.order.findUnique.mockResolvedValue(paidOrder) + + const result = await service.handleWxNotify(headers, successBody) + + expect(prisma.$transaction).not.toHaveBeenCalled() + expect(result).toContain('SUCCESS') + }) + + it('returns gracefully when order is not found', async () => { + prisma.order.findUnique.mockResolvedValue(null) + + const result = await service.handleWxNotify(headers, successBody) + + expect(prisma.$transaction).not.toHaveBeenCalled() + expect(result).toContain('SUCCESS') + }) + + it('returns FAIL xml when signature verification fails', async () => { + wechat.verifySignature.mockReturnValue(false) + + const result = await service.handleWxNotify(headers, successBody) + + expect(prisma.$transaction).not.toHaveBeenCalled() + expect(result).toContain('FAIL') + expect(result).toContain('SIGN_ERROR') + }) + + it('skips transaction when notification success=false', async () => { + wechat.parseNotification.mockReturnValue({ + orderNo: successBody.out_trade_no, + wxTransactionId: '', + success: false, + }) + + const result = await service.handleWxNotify(headers, successBody) + + expect(prisma.$transaction).not.toHaveBeenCalled() + expect(result).toContain('SUCCESS') + }) + }) +}) diff --git a/packages/server/src/payment/dto/create-order.dto.ts b/packages/server/src/payment/dto/create-order.dto.ts new file mode 100644 index 0000000..a5ae122 --- /dev/null +++ b/packages/server/src/payment/dto/create-order.dto.ts @@ -0,0 +1,6 @@ +import { IsUUID } from 'class-validator' + +export class CreateOrderDto { + @IsUUID() + cardTypeId!: string +} diff --git a/packages/server/src/payment/payment.controller.ts b/packages/server/src/payment/payment.controller.ts new file mode 100644 index 0000000..9d3bf38 --- /dev/null +++ b/packages/server/src/payment/payment.controller.ts @@ -0,0 +1,91 @@ +import { + Body, + Controller, + Get, + Headers, + HttpCode, + Post, + Query, + UseGuards, + ValidationPipe, +} from '@nestjs/common' +import { UserRole } from '@mp-pilates/shared' +import { JwtAuthGuard } from '../auth/jwt-auth.guard' +import { RolesGuard } from '../auth/roles.guard' +import { Roles } from '../auth/roles.decorator' +import { CurrentUser } from '../common/decorators/current-user.decorator' +import { PaymentService } from './payment.service' +import { CreateOrderDto } from './dto/create-order.dto' + +@Controller() +export class PaymentController { + constructor(private readonly paymentService: PaymentService) {} + + // ─── User endpoints ──────────────────────────────────────────────────────── + + /** + * POST /payment/create-order + * Authenticated user creates a new WeChat Pay order for a card type. + */ + @Post('payment/create-order') + @UseGuards(JwtAuthGuard) + createOrder( + @CurrentUser('sub') userId: string, + @Body(new ValidationPipe({ whitelist: true })) dto: CreateOrderDto, + ) { + return this.paymentService.createOrder(userId, dto.cardTypeId) + } + + /** + * GET /payment/orders + * Authenticated user fetches their own order history. + */ + @Get('payment/orders') + @UseGuards(JwtAuthGuard) + getMyOrders( + @CurrentUser('sub') userId: string, + @Query('page') page?: string, + @Query('limit') limit?: string, + ) { + return this.paymentService.getMyOrders( + userId, + page ? parseInt(page, 10) : 1, + limit ? parseInt(limit, 10) : 10, + ) + } + + /** + * POST /payment/wx-notify + * Public WeChat Pay server callback — no authentication required. + * WeChat expects HTTP 200 with XML body indicating SUCCESS / FAIL. + */ + @Post('payment/wx-notify') + @HttpCode(200) + async handleWxNotify( + @Headers() headers: Record, + @Body() body: Record, + ) { + return this.paymentService.handleWxNotify(headers, body) + } + + // ─── Admin endpoints ─────────────────────────────────────────────────────── + + /** + * GET /admin/orders + * Admin fetches all orders, optionally filtered by status. + */ + @Get('admin/orders') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + getAllOrders( + @Query('page') page?: string, + @Query('limit') limit?: string, + @Query('status') status?: string, + ) { + return this.paymentService.getAllOrders( + page ? parseInt(page, 10) : 1, + limit ? parseInt(limit, 10) : 10, + status as any, + ) + } +} diff --git a/packages/server/src/payment/payment.module.ts b/packages/server/src/payment/payment.module.ts new file mode 100644 index 0000000..c976c18 --- /dev/null +++ b/packages/server/src/payment/payment.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common' +import { PrismaModule } from '../prisma/prisma.module' +import { PaymentService } from './payment.service' +import { PaymentController } from './payment.controller' +import { WechatPayService } from './wechat-pay.service' + +@Module({ + imports: [PrismaModule], + controllers: [PaymentController], + providers: [PaymentService, WechatPayService], + exports: [PaymentService], +}) +export class PaymentModule {} diff --git a/packages/server/src/payment/payment.service.ts b/packages/server/src/payment/payment.service.ts new file mode 100644 index 0000000..2d89582 --- /dev/null +++ b/packages/server/src/payment/payment.service.ts @@ -0,0 +1,215 @@ +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common' +import { CardType, Order } from '@prisma/client' +import { MembershipStatus, OrderStatus } from '@mp-pilates/shared' +import { PrismaService } from '../prisma/prisma.service' +import { WechatPayService, WxPaymentParams } from './wechat-pay.service' + +export interface CreateOrderResult { + order: Order + paymentParams: WxPaymentParams +} + +export interface PaginatedOrders { + data: T[] + total: number + page: number + limit: number +} + +@Injectable() +export class PaymentService { + private readonly logger = new Logger(PaymentService.name) + + constructor( + private readonly prisma: PrismaService, + private readonly wechatPayService: WechatPayService, + ) {} + + // ─── User: create order ──────────────────────────────────────────────────── + + async createOrder(userId: string, cardTypeId: string): Promise { + const cardType = await this.prisma.cardType.findUnique({ where: { id: cardTypeId } }) + + if (!cardType) { + throw new NotFoundException(`CardType ${cardTypeId} not found`) + } + if (!cardType.isActive) { + throw new BadRequestException(`CardType ${cardTypeId} is not active`) + } + + const user = await this.prisma.user.findUnique({ where: { id: userId } }) + if (!user) { + throw new NotFoundException(`User ${userId} not found`) + } + + const orderNo = `${Date.now()}${Math.random().toString(36).substring(2, 8)}` + + const order = await this.prisma.order.create({ + data: { + userId, + cardTypeId, + orderNo, + amount: cardType.price, + status: OrderStatus.PENDING, + }, + }) + + const paymentParams = await this.wechatPayService.createUnifiedOrder({ + orderNo, + amount: Number(cardType.price), + openid: user.openid, + description: cardType.name, + }) + + return { order: { ...order }, paymentParams: { ...paymentParams } } + } + + // ─── WeChat callback ─────────────────────────────────────────────────────── + + async handleWxNotify(headers: Record, body: Record): Promise { + const rawBody = typeof body === 'string' ? body : JSON.stringify(body) + const isValid = this.wechatPayService.verifySignature(headers, rawBody) + + if (!isValid) { + this.logger.warn('WeChat Pay signature verification failed') + return this.buildFailXml('FAIL', 'SIGN_ERROR') + } + + const notification = this.wechatPayService.parseNotification(body) + + if (!notification.success) { + this.logger.warn(`WeChat Pay notification not success: orderNo=${notification.orderNo}`) + return this.buildSuccessXml() + } + + const existingOrder = await this.prisma.order.findUnique({ + where: { orderNo: notification.orderNo }, + }) + + if (!existingOrder) { + this.logger.warn(`Order not found: orderNo=${notification.orderNo}`) + return this.buildSuccessXml() + } + + // Idempotency: already processed + if (existingOrder.status === OrderStatus.PAID) { + this.logger.log(`Order already PAID (idempotent): orderNo=${notification.orderNo}`) + return this.buildSuccessXml() + } + + const cardType = await this.prisma.cardType.findUnique({ + where: { id: existingOrder.cardTypeId }, + }) + + if (!cardType) { + this.logger.error(`CardType not found for order ${existingOrder.id}`) + return this.buildFailXml('FAIL', 'CARD_TYPE_NOT_FOUND') + } + + const now = new Date() + const expireDate = new Date(now.getTime() + cardType.durationDays * 86_400_000) + + await this.prisma.$transaction([ + this.prisma.order.update({ + where: { id: existingOrder.id }, + data: { + status: OrderStatus.PAID, + wxTransactionId: notification.wxTransactionId, + paidAt: now, + }, + }), + this.prisma.membership.create({ + data: { + userId: existingOrder.userId, + cardTypeId: existingOrder.cardTypeId, + startDate: now, + expireDate, + remainingTimes: cardType.totalTimes ?? null, + status: MembershipStatus.ACTIVE, + }, + }), + ]) + + this.logger.log(`Order PAID and Membership created: orderNo=${notification.orderNo}`) + return this.buildSuccessXml() + } + + // ─── User: list own orders ───────────────────────────────────────────────── + + async getMyOrders( + userId: string, + page = 1, + limit = 10, + ): Promise> { + const skip = (page - 1) * limit + + const [data, total] = await Promise.all([ + this.prisma.order.findMany({ + where: { userId }, + include: { cardType: true }, + orderBy: { createdAt: 'desc' }, + skip, + take: limit, + }), + this.prisma.order.count({ where: { userId } }), + ]) + + return { + data: data.map((o) => ({ ...o, cardType: { ...o.cardType } })), + total, + page, + limit, + } + } + + // ─── Admin: list all orders ──────────────────────────────────────────────── + + async getAllOrders( + page = 1, + limit = 10, + status?: OrderStatus, + ): Promise> { + const skip = (page - 1) * limit + const where = status ? { status } : {} + + const [data, total] = await Promise.all([ + this.prisma.order.findMany({ + where, + include: { + cardType: true, + user: { select: { id: true, nickname: true, phone: true } }, + }, + orderBy: { createdAt: 'desc' }, + skip, + take: limit, + }), + this.prisma.order.count({ where }), + ]) + + return { + data: data.map((o) => ({ + ...o, + cardType: { ...o.cardType }, + user: { ...o.user }, + })), + total, + page, + limit, + } + } + + // ─── Helpers ─────────────────────────────────────────────────────────────── + + private buildSuccessXml(): string { + return `` + } + + private buildFailXml(code: string, msg: string): string { + return `` + } +} diff --git a/packages/server/src/payment/wechat-pay.service.ts b/packages/server/src/payment/wechat-pay.service.ts new file mode 100644 index 0000000..c650864 --- /dev/null +++ b/packages/server/src/payment/wechat-pay.service.ts @@ -0,0 +1,115 @@ +import { Injectable, Logger } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' + +export interface UnifiedOrderParams { + orderNo: string + amount: number + openid: string + description: string +} + +export interface WxPaymentParams { + timeStamp: string + nonceStr: string + package: string + signType: string + paySign: string +} + +export interface WxNotification { + orderNo: string + wxTransactionId: string + success: boolean +} + +@Injectable() +export class WechatPayService { + private readonly logger = new Logger(WechatPayService.name) + private readonly appId: string + private readonly mchId: string + private readonly mchKey: string + + constructor(private readonly config: ConfigService) { + this.appId = this.config.get('WX_APPID') ?? '' + this.mchId = this.config.get('WX_MCH_ID') ?? '' + this.mchKey = this.config.get('WX_MCH_KEY') ?? '' + } + + /** + * Create a WeChat Pay unified order and return mini-program payment params. + * + * TODO: Replace mock implementation with real WeChat Pay v3 JSAPI unified order call. + * POST https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi + * Docs: https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter3_5_1.shtml + * Steps: + * 1. Build request body with appid, mchid, description, out_trade_no, notify_url, + * amount { total, currency }, payer { openid } + * 2. Sign request with RSA-SHA256 (merchant private key) + * 3. Extract prepay_id from response + * 4. Build final paySign using HMAC-SHA256 over appId + timeStamp + nonceStr + package + */ + async createUnifiedOrder(params: UnifiedOrderParams): Promise { + this.logger.log( + `[MOCK] createUnifiedOrder: orderNo=${params.orderNo}, amount=${params.amount}, appId=${this.appId}, mchId=${this.mchId}`, + ) + + const timeStamp = Math.floor(Date.now() / 1000).toString() + const nonceStr = Math.random().toString(36).substring(2, 18) + const prepayId = `mock_prepay_${params.orderNo}` + + return { + timeStamp, + nonceStr, + package: `prepay_id=${prepayId}`, + signType: 'RSA', + paySign: `mock_sign_${nonceStr}`, + } + } + + /** + * Verify WeChat Pay callback signature from request headers and body. + * + * TODO: Replace with real WeChat Pay v3 signature verification. + * Steps: + * 1. Extract Wechatpay-Timestamp, Wechatpay-Nonce, Wechatpay-Signature, + * Wechatpay-Serial from headers + * 2. Build message: timestamp + "\n" + nonce + "\n" + body + "\n" + * 3. Verify RSA-SHA256 signature using WeChat platform certificate (identified by serial) + * 4. Check timestamp is within 5 minutes of current time + */ + verifySignature(_headers: Record, _body: string): boolean { + // TODO: implement real WeChat Pay v3 signature verification + this.logger.log('[MOCK] verifySignature: returning true') + return true + } + + /** + * Parse WeChat Pay callback notification body. + * + * TODO: Replace with real WeChat Pay v3 notification parsing. + * v3 notifications are AES-256-GCM encrypted JSON: + * { + * resource: { + * ciphertext, // base64(AES-GCM encrypted JSON) + * nonce, + * associated_data, + * } + * } + * Steps: + * 1. Decrypt ciphertext using APIV3 key (mchKey) + * 2. Parse decrypted JSON to get transaction info + * 3. Extract out_trade_no (orderNo), transaction_id, trade_state + */ + parseNotification(body: Record): WxNotification { + // TODO: implement real WeChat Pay v3 AES-256-GCM notification decryption + this.logger.log('[MOCK] parseNotification body received') + + const orderNo = (body['out_trade_no'] as string) ?? (body['orderNo'] as string) ?? '' + const wxTransactionId = + (body['transaction_id'] as string) ?? (body['wxTransactionId'] as string) ?? '' + const tradeState = (body['trade_state'] as string) ?? 'SUCCESS' + const success = tradeState === 'SUCCESS' + + return { orderNo, wxTransactionId, success } + } +} diff --git a/packages/server/src/scheduler/__tests__/scheduler.service.spec.ts b/packages/server/src/scheduler/__tests__/scheduler.service.spec.ts new file mode 100644 index 0000000..2f5318f --- /dev/null +++ b/packages/server/src/scheduler/__tests__/scheduler.service.spec.ts @@ -0,0 +1,158 @@ +import { Test, TestingModule } from '@nestjs/testing' +import { Logger } from '@nestjs/common' +import { SchedulerService } from '../scheduler.service' +import { SlotGeneratorService } from '../../time-slot/slot-generator.service' + +// --------------------------------------------------------------------------- +// Mock SlotGeneratorService +// --------------------------------------------------------------------------- + +const mockSlotGenerator: jest.Mocked> = { + generateSlots: jest.fn(), + cleanupExpiredSlots: jest.fn(), + checkExpiredMemberships: jest.fn(), + completeBookings: jest.fn(), +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('SchedulerService', () => { + let service: SchedulerService + let logSpy: jest.SpyInstance + let errorSpy: jest.SpyInstance + + beforeEach(async () => { + jest.clearAllMocks() + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SchedulerService, + { provide: SlotGeneratorService, useValue: mockSlotGenerator }, + ], + }).compile() + + service = module.get(SchedulerService) + + // Spy on the service's own logger instance + logSpy = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined) + errorSpy = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined) + }) + + afterEach(() => { + logSpy.mockRestore() + errorSpy.mockRestore() + }) + + // ------------------------------------------------------------------------- + // handleSlotGeneration + // ------------------------------------------------------------------------- + + describe('handleSlotGeneration', () => { + it('calls generateSlots(14) and logs the result', async () => { + mockSlotGenerator.generateSlots.mockResolvedValueOnce(7) + + await service.handleSlotGeneration() + + expect(mockSlotGenerator.generateSlots).toHaveBeenCalledWith(14) + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('7'), + ) + }) + + it('catches errors and logs them without rethrowing', async () => { + mockSlotGenerator.generateSlots.mockRejectedValueOnce(new Error('db error')) + + await expect(service.handleSlotGeneration()).resolves.toBeUndefined() + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('handleSlotGeneration'), + expect.any(Error), + ) + }) + }) + + // ------------------------------------------------------------------------- + // handleCleanupSlots + // ------------------------------------------------------------------------- + + describe('handleCleanupSlots', () => { + it('calls cleanupExpiredSlots() and logs the result', async () => { + mockSlotGenerator.cleanupExpiredSlots.mockResolvedValueOnce(3) + + await service.handleCleanupSlots() + + expect(mockSlotGenerator.cleanupExpiredSlots).toHaveBeenCalledTimes(1) + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('3'), + ) + }) + + it('catches errors and logs them without rethrowing', async () => { + mockSlotGenerator.cleanupExpiredSlots.mockRejectedValueOnce(new Error('timeout')) + + await expect(service.handleCleanupSlots()).resolves.toBeUndefined() + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('handleCleanupSlots'), + expect.any(Error), + ) + }) + }) + + // ------------------------------------------------------------------------- + // handleCheckMemberships + // ------------------------------------------------------------------------- + + describe('handleCheckMemberships', () => { + it('calls checkExpiredMemberships() and logs the result', async () => { + mockSlotGenerator.checkExpiredMemberships.mockResolvedValueOnce(5) + + await service.handleCheckMemberships() + + expect(mockSlotGenerator.checkExpiredMemberships).toHaveBeenCalledTimes(1) + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('5'), + ) + }) + + it('catches errors and logs them without rethrowing', async () => { + mockSlotGenerator.checkExpiredMemberships.mockRejectedValueOnce(new Error('network')) + + await expect(service.handleCheckMemberships()).resolves.toBeUndefined() + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('handleCheckMemberships'), + expect.any(Error), + ) + }) + }) + + // ------------------------------------------------------------------------- + // handleCompleteBookings + // ------------------------------------------------------------------------- + + describe('handleCompleteBookings', () => { + it('calls completeBookings() and logs the result', async () => { + mockSlotGenerator.completeBookings.mockResolvedValueOnce(12) + + await service.handleCompleteBookings() + + expect(mockSlotGenerator.completeBookings).toHaveBeenCalledTimes(1) + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('12'), + ) + }) + + it('catches errors and logs them without rethrowing', async () => { + mockSlotGenerator.completeBookings.mockRejectedValueOnce(new Error('query failed')) + + await expect(service.handleCompleteBookings()).resolves.toBeUndefined() + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('handleCompleteBookings'), + expect.any(Error), + ) + }) + }) +}) diff --git a/packages/server/src/scheduler/scheduler.module.ts b/packages/server/src/scheduler/scheduler.module.ts new file mode 100644 index 0000000..8e45a97 --- /dev/null +++ b/packages/server/src/scheduler/scheduler.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common' +import { ScheduleModule } from '@nestjs/schedule' +import { TimeSlotModule } from '../time-slot/time-slot.module' +import { SchedulerService } from './scheduler.service' + +@Module({ + imports: [ + ScheduleModule.forRoot(), + TimeSlotModule, + ], + providers: [SchedulerService], +}) +export class SchedulerModule {} diff --git a/packages/server/src/scheduler/scheduler.service.ts b/packages/server/src/scheduler/scheduler.service.ts new file mode 100644 index 0000000..2c715b2 --- /dev/null +++ b/packages/server/src/scheduler/scheduler.service.ts @@ -0,0 +1,54 @@ +import { Injectable, Logger } from '@nestjs/common' +import { Cron } from '@nestjs/schedule' +import { SlotGeneratorService } from '../time-slot/slot-generator.service' + +@Injectable() +export class SchedulerService { + private readonly logger = new Logger(SchedulerService.name) + + constructor(private readonly slotGenerator: SlotGeneratorService) {} + + /** 02:00 daily — generate slots 14 days ahead from week templates */ + @Cron('0 2 * * *') + async handleSlotGeneration(): Promise { + try { + const count = await this.slotGenerator.generateSlots(14) + this.logger.log(`[handleSlotGeneration] Created ${count} new time slots`) + } catch (err) { + this.logger.error('[handleSlotGeneration] Failed to generate slots', err) + } + } + + /** 02:30 daily — close past OPEN slots */ + @Cron('30 2 * * *') + async handleCleanupSlots(): Promise { + try { + const count = await this.slotGenerator.cleanupExpiredSlots() + this.logger.log(`[handleCleanupSlots] Closed ${count} expired slots`) + } catch (err) { + this.logger.error('[handleCleanupSlots] Failed to clean up slots', err) + } + } + + /** 03:00 daily — expire memberships past their end date or with 0 sessions */ + @Cron('0 3 * * *') + async handleCheckMemberships(): Promise { + try { + const count = await this.slotGenerator.checkExpiredMemberships() + this.logger.log(`[handleCheckMemberships] Updated ${count} memberships`) + } catch (err) { + this.logger.error('[handleCheckMemberships] Failed to check memberships', err) + } + } + + /** 22:00 daily — mark past CONFIRMED bookings as COMPLETED */ + @Cron('0 22 * * *') + async handleCompleteBookings(): Promise { + try { + const count = await this.slotGenerator.completeBookings() + this.logger.log(`[handleCompleteBookings] Completed ${count} bookings`) + } catch (err) { + this.logger.error('[handleCompleteBookings] Failed to complete bookings', err) + } + } +}