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
This commit is contained in:
@@ -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],
|
||||
})
|
||||
|
||||
710
packages/server/src/booking/__tests__/booking.service.spec.ts
Normal file
710
packages/server/src/booking/__tests__/booking.service.spec.ts
Normal file
@@ -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<string, unknown> = {}) {
|
||||
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<PrismaService>
|
||||
let studioService: jest.Mocked<StudioService>
|
||||
|
||||
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>(BookingService)
|
||||
prisma = module.get(PrismaService) as jest.Mocked<PrismaService>
|
||||
studioService = module.get(StudioService) as jest.Mocked<StudioService>
|
||||
})
|
||||
|
||||
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 },
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
81
packages/server/src/booking/booking.controller.ts
Normal file
81
packages/server/src/booking/booking.controller.ts
Normal file
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
13
packages/server/src/booking/booking.module.ts
Normal file
13
packages/server/src/booking/booking.module.ts
Normal file
@@ -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 {}
|
||||
366
packages/server/src/booking/booking.service.ts
Normal file
366
packages/server/src/booking/booking.service.ts
Normal file
@@ -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<T> {
|
||||
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<BookingWithRelations> {
|
||||
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<CancelBookingResult> {
|
||||
// 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<PaginatedResult<BookingWithRelations>> {
|
||||
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<BookingWithRelations[]> {
|
||||
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<PaginatedResult<BookingWithRelations & { user: { id: string; nickname: string; phone: string | null } }>> {
|
||||
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<BookingWithRelations> {
|
||||
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
|
||||
}
|
||||
}
|
||||
9
packages/server/src/booking/dto/create-booking.dto.ts
Normal file
9
packages/server/src/booking/dto/create-booking.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { IsUUID } from 'class-validator'
|
||||
|
||||
export class CreateBookingDto {
|
||||
@IsUUID()
|
||||
timeSlotId!: string
|
||||
|
||||
@IsUUID()
|
||||
membershipId!: string
|
||||
}
|
||||
333
packages/server/src/payment/__tests__/payment.service.spec.ts
Normal file
333
packages/server/src/payment/__tests__/payment.service.spec.ts
Normal file
@@ -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<Record<string, unknown>> = {}) => ({
|
||||
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<typeof buildPrismaMock>
|
||||
let wechat: ReturnType<typeof buildWechatMock>
|
||||
|
||||
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>(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')
|
||||
})
|
||||
})
|
||||
})
|
||||
6
packages/server/src/payment/dto/create-order.dto.ts
Normal file
6
packages/server/src/payment/dto/create-order.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { IsUUID } from 'class-validator'
|
||||
|
||||
export class CreateOrderDto {
|
||||
@IsUUID()
|
||||
cardTypeId!: string
|
||||
}
|
||||
91
packages/server/src/payment/payment.controller.ts
Normal file
91
packages/server/src/payment/payment.controller.ts
Normal file
@@ -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<string, string>,
|
||||
@Body() body: Record<string, unknown>,
|
||||
) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
13
packages/server/src/payment/payment.module.ts
Normal file
13
packages/server/src/payment/payment.module.ts
Normal file
@@ -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 {}
|
||||
215
packages/server/src/payment/payment.service.ts
Normal file
215
packages/server/src/payment/payment.service.ts
Normal file
@@ -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<T> {
|
||||
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<CreateOrderResult> {
|
||||
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<string, string>, body: Record<string, unknown>): Promise<string> {
|
||||
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<PaginatedOrders<Order & { cardType: CardType }>> {
|
||||
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<PaginatedOrders<Order & { cardType: CardType; user: { id: string; nickname: string; phone: string | null } }>> {
|
||||
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 `<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>`
|
||||
}
|
||||
|
||||
private buildFailXml(code: string, msg: string): string {
|
||||
return `<xml><return_code><![CDATA[${code}]]></return_code><return_msg><![CDATA[${msg}]]></return_msg></xml>`
|
||||
}
|
||||
}
|
||||
115
packages/server/src/payment/wechat-pay.service.ts
Normal file
115
packages/server/src/payment/wechat-pay.service.ts
Normal file
@@ -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<string>('WX_APPID') ?? ''
|
||||
this.mchId = this.config.get<string>('WX_MCH_ID') ?? ''
|
||||
this.mchKey = this.config.get<string>('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<WxPaymentParams> {
|
||||
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<string, string>, _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<string, unknown>): 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 }
|
||||
}
|
||||
}
|
||||
@@ -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<Pick<
|
||||
SlotGeneratorService,
|
||||
'generateSlots' | 'cleanupExpiredSlots' | 'checkExpiredMemberships' | 'completeBookings'
|
||||
>> = {
|
||||
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>(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),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
13
packages/server/src/scheduler/scheduler.module.ts
Normal file
13
packages/server/src/scheduler/scheduler.module.ts
Normal file
@@ -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 {}
|
||||
54
packages/server/src/scheduler/scheduler.service.ts
Normal file
54
packages/server/src/scheduler/scheduler.service.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user