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