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:
richarjiang
2026-04-02 12:33:50 +08:00
parent 593a6e5453
commit 994d1f75d5
15 changed files with 2183 additions and 0 deletions

View 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 },
}),
)
})
})
})

View 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,
)
}
}

View 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 {}

View 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
}
}

View File

@@ -0,0 +1,9 @@
import { IsUUID } from 'class-validator'
export class CreateBookingDto {
@IsUUID()
timeSlotId!: string
@IsUUID()
membershipId!: string
}