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