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:
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user