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