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,333 @@
import { Test, TestingModule } from '@nestjs/testing'
import { BadRequestException, NotFoundException } from '@nestjs/common'
import { Decimal } from '@prisma/client/runtime/library'
import { MembershipStatus, OrderStatus } from '@mp-pilates/shared'
import { PaymentService } from '../payment.service'
import { WechatPayService } from '../wechat-pay.service'
import { PrismaService } from '../../prisma/prisma.service'
// ─── Fixtures ─────────────────────────────────────────────────────────────────
const mockCardType = {
id: 'card-type-uuid-1',
name: '10次课包',
isActive: true,
price: new Decimal(990),
totalTimes: 10,
durationDays: 90,
type: 'TIMES',
originalPrice: null,
description: null,
sortOrder: 0,
createdAt: new Date(),
updatedAt: new Date(),
}
const mockInactiveCardType = { ...mockCardType, id: 'card-type-uuid-inactive', isActive: false }
const mockUser = {
id: 'user-uuid-1',
openid: 'wx-openid-1',
nickname: 'Test User',
phone: null,
role: 'MEMBER',
createdAt: new Date(),
updatedAt: new Date(),
}
const buildMockOrder = (overrides: Partial<Record<string, unknown>> = {}) => ({
id: 'order-uuid-1',
userId: mockUser.id,
cardTypeId: mockCardType.id,
orderNo: '1700000000000abc123',
amount: new Decimal(990),
status: OrderStatus.PENDING,
wxTransactionId: null,
paidAt: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
})
const mockPaymentParams = {
timeStamp: '1700000000',
nonceStr: 'mockNonce',
package: 'prepay_id=mock_prepay_1700000000000abc123',
signType: 'RSA',
paySign: 'mockSign',
}
// ─── Mock factories ────────────────────────────────────────────────────────────
function buildPrismaMock() {
return {
cardType: {
findUnique: jest.fn(),
},
user: {
findUnique: jest.fn(),
},
order: {
create: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
findMany: jest.fn(),
count: jest.fn(),
},
membership: {
create: jest.fn(),
},
$transaction: jest.fn(),
}
}
function buildWechatMock() {
return {
createUnifiedOrder: jest.fn().mockResolvedValue(mockPaymentParams),
verifySignature: jest.fn().mockReturnValue(true),
parseNotification: jest.fn(),
}
}
// ─── Test suite ────────────────────────────────────────────────────────────────
describe('PaymentService', () => {
let service: PaymentService
let prisma: ReturnType<typeof buildPrismaMock>
let wechat: ReturnType<typeof buildWechatMock>
beforeEach(async () => {
prisma = buildPrismaMock()
wechat = buildWechatMock()
const module: TestingModule = await Test.createTestingModule({
providers: [
PaymentService,
{ provide: PrismaService, useValue: prisma },
{ provide: WechatPayService, useValue: wechat },
],
}).compile()
service = module.get<PaymentService>(PaymentService)
})
afterEach(() => jest.clearAllMocks())
// ─── createOrder ────────────────────────────────────────────────────────────
describe('createOrder', () => {
it('creates a PENDING order with correct amount and formatted orderNo', async () => {
prisma.cardType.findUnique.mockResolvedValue(mockCardType)
prisma.user.findUnique.mockResolvedValue(mockUser)
const createdOrder = buildMockOrder()
prisma.order.create.mockResolvedValue(createdOrder)
const result = await service.createOrder(mockUser.id, mockCardType.id)
// Order was created with correct fields
expect(prisma.order.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
userId: mockUser.id,
cardTypeId: mockCardType.id,
amount: mockCardType.price,
status: OrderStatus.PENDING,
}),
}),
)
// orderNo starts with a timestamp-like number
const { orderNo } = prisma.order.create.mock.calls[0][0].data as { orderNo: string }
expect(orderNo).toMatch(/^\d{13}[a-z0-9]{6}$/)
expect(result.order).toMatchObject({ status: OrderStatus.PENDING })
expect(result.paymentParams).toEqual(mockPaymentParams)
})
it('calls WechatPayService with correct openid and amount', async () => {
prisma.cardType.findUnique.mockResolvedValue(mockCardType)
prisma.user.findUnique.mockResolvedValue(mockUser)
prisma.order.create.mockResolvedValue(buildMockOrder())
await service.createOrder(mockUser.id, mockCardType.id)
expect(wechat.createUnifiedOrder).toHaveBeenCalledWith(
expect.objectContaining({
openid: mockUser.openid,
amount: Number(mockCardType.price),
description: mockCardType.name,
}),
)
})
it('throws NotFoundException when cardType does not exist', async () => {
prisma.cardType.findUnique.mockResolvedValue(null)
await expect(service.createOrder(mockUser.id, 'non-existent')).rejects.toThrow(
NotFoundException,
)
expect(prisma.order.create).not.toHaveBeenCalled()
})
it('throws BadRequestException when cardType is not active', async () => {
prisma.cardType.findUnique.mockResolvedValue(mockInactiveCardType)
await expect(service.createOrder(mockUser.id, mockInactiveCardType.id)).rejects.toThrow(
BadRequestException,
)
expect(prisma.order.create).not.toHaveBeenCalled()
})
it('throws NotFoundException when user does not exist', async () => {
prisma.cardType.findUnique.mockResolvedValue(mockCardType)
prisma.user.findUnique.mockResolvedValue(null)
await expect(service.createOrder('ghost-user', mockCardType.id)).rejects.toThrow(
NotFoundException,
)
})
})
// ─── handleWxNotify ─────────────────────────────────────────────────────────
describe('handleWxNotify', () => {
const headers = { 'wechatpay-signature': 'sig' }
const successBody = {
out_trade_no: '1700000000000abc123',
transaction_id: 'wx-txn-001',
trade_state: 'SUCCESS',
}
const pendingOrder = buildMockOrder({ status: OrderStatus.PENDING })
beforeEach(() => {
wechat.verifySignature.mockReturnValue(true)
wechat.parseNotification.mockReturnValue({
orderNo: successBody.out_trade_no,
wxTransactionId: successBody.transaction_id,
success: true,
})
prisma.order.findUnique.mockResolvedValue(pendingOrder)
prisma.cardType.findUnique.mockResolvedValue(mockCardType)
prisma.$transaction.mockResolvedValue([])
})
it('marks order as PAID and creates membership on valid callback', async () => {
const result = await service.handleWxNotify(headers, successBody)
// $transaction called once with an array of two operations
expect(prisma.$transaction).toHaveBeenCalledTimes(1)
const [transactionOps] = prisma.$transaction.mock.calls[0] as [unknown[]]
expect(transactionOps).toHaveLength(2)
// order.update was called with PAID status and transaction id
expect(prisma.order.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: OrderStatus.PAID,
wxTransactionId: successBody.transaction_id,
}),
}),
)
// membership.create was called
expect(prisma.membership.create).toHaveBeenCalledTimes(1)
expect(result).toContain('SUCCESS')
})
it('creates membership with correct expireDate (startDate + durationDays)', async () => {
const beforeCall = Date.now()
await service.handleWxNotify(headers, successBody)
expect(prisma.membership.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
userId: pendingOrder.userId,
cardTypeId: pendingOrder.cardTypeId,
status: MembershipStatus.ACTIVE,
}),
}),
)
const membershipData = prisma.membership.create.mock.calls[0][0].data as {
startDate: Date
expireDate: Date
}
const expectedExpireMs =
membershipData.startDate.getTime() + mockCardType.durationDays * 86_400_000
expect(membershipData.expireDate.getTime()).toBeCloseTo(expectedExpireMs, -2) // within 100ms
expect(membershipData.startDate.getTime()).toBeGreaterThanOrEqual(beforeCall)
})
it('creates membership with correct remainingTimes from cardType', async () => {
await service.handleWxNotify(headers, successBody)
expect(prisma.membership.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
remainingTimes: mockCardType.totalTimes, // 10
}),
}),
)
})
it('creates membership with null remainingTimes for duration-based cardType', async () => {
const durationCardType = { ...mockCardType, totalTimes: null }
prisma.cardType.findUnique.mockResolvedValue(durationCardType)
await service.handleWxNotify(headers, successBody)
expect(prisma.membership.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
remainingTimes: null,
}),
}),
)
})
it('is idempotent: duplicate callback for already-PAID order skips transaction', async () => {
const paidOrder = buildMockOrder({ status: OrderStatus.PAID })
prisma.order.findUnique.mockResolvedValue(paidOrder)
const result = await service.handleWxNotify(headers, successBody)
expect(prisma.$transaction).not.toHaveBeenCalled()
expect(result).toContain('SUCCESS')
})
it('returns gracefully when order is not found', async () => {
prisma.order.findUnique.mockResolvedValue(null)
const result = await service.handleWxNotify(headers, successBody)
expect(prisma.$transaction).not.toHaveBeenCalled()
expect(result).toContain('SUCCESS')
})
it('returns FAIL xml when signature verification fails', async () => {
wechat.verifySignature.mockReturnValue(false)
const result = await service.handleWxNotify(headers, successBody)
expect(prisma.$transaction).not.toHaveBeenCalled()
expect(result).toContain('FAIL')
expect(result).toContain('SIGN_ERROR')
})
it('skips transaction when notification success=false', async () => {
wechat.parseNotification.mockReturnValue({
orderNo: successBody.out_trade_no,
wxTransactionId: '',
success: false,
})
const result = await service.handleWxNotify(headers, successBody)
expect(prisma.$transaction).not.toHaveBeenCalled()
expect(result).toContain('SUCCESS')
})
})
})

View File

@@ -0,0 +1,6 @@
import { IsUUID } from 'class-validator'
export class CreateOrderDto {
@IsUUID()
cardTypeId!: string
}

View File

@@ -0,0 +1,91 @@
import {
Body,
Controller,
Get,
Headers,
HttpCode,
Post,
Query,
UseGuards,
ValidationPipe,
} from '@nestjs/common'
import { 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 { PaymentService } from './payment.service'
import { CreateOrderDto } from './dto/create-order.dto'
@Controller()
export class PaymentController {
constructor(private readonly paymentService: PaymentService) {}
// ─── User endpoints ────────────────────────────────────────────────────────
/**
* POST /payment/create-order
* Authenticated user creates a new WeChat Pay order for a card type.
*/
@Post('payment/create-order')
@UseGuards(JwtAuthGuard)
createOrder(
@CurrentUser('sub') userId: string,
@Body(new ValidationPipe({ whitelist: true })) dto: CreateOrderDto,
) {
return this.paymentService.createOrder(userId, dto.cardTypeId)
}
/**
* GET /payment/orders
* Authenticated user fetches their own order history.
*/
@Get('payment/orders')
@UseGuards(JwtAuthGuard)
getMyOrders(
@CurrentUser('sub') userId: string,
@Query('page') page?: string,
@Query('limit') limit?: string,
) {
return this.paymentService.getMyOrders(
userId,
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 10,
)
}
/**
* POST /payment/wx-notify
* Public WeChat Pay server callback — no authentication required.
* WeChat expects HTTP 200 with XML body indicating SUCCESS / FAIL.
*/
@Post('payment/wx-notify')
@HttpCode(200)
async handleWxNotify(
@Headers() headers: Record<string, string>,
@Body() body: Record<string, unknown>,
) {
return this.paymentService.handleWxNotify(headers, body)
}
// ─── Admin endpoints ───────────────────────────────────────────────────────
/**
* GET /admin/orders
* Admin fetches all orders, optionally filtered by status.
*/
@Get('admin/orders')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
getAllOrders(
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('status') status?: string,
) {
return this.paymentService.getAllOrders(
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 10,
status as any,
)
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common'
import { PrismaModule } from '../prisma/prisma.module'
import { PaymentService } from './payment.service'
import { PaymentController } from './payment.controller'
import { WechatPayService } from './wechat-pay.service'
@Module({
imports: [PrismaModule],
controllers: [PaymentController],
providers: [PaymentService, WechatPayService],
exports: [PaymentService],
})
export class PaymentModule {}

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

View File

@@ -0,0 +1,115 @@
import { Injectable, Logger } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
export interface UnifiedOrderParams {
orderNo: string
amount: number
openid: string
description: string
}
export interface WxPaymentParams {
timeStamp: string
nonceStr: string
package: string
signType: string
paySign: string
}
export interface WxNotification {
orderNo: string
wxTransactionId: string
success: boolean
}
@Injectable()
export class WechatPayService {
private readonly logger = new Logger(WechatPayService.name)
private readonly appId: string
private readonly mchId: string
private readonly mchKey: string
constructor(private readonly config: ConfigService) {
this.appId = this.config.get<string>('WX_APPID') ?? ''
this.mchId = this.config.get<string>('WX_MCH_ID') ?? ''
this.mchKey = this.config.get<string>('WX_MCH_KEY') ?? ''
}
/**
* Create a WeChat Pay unified order and return mini-program payment params.
*
* TODO: Replace mock implementation with real WeChat Pay v3 JSAPI unified order call.
* POST https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi
* Docs: https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter3_5_1.shtml
* Steps:
* 1. Build request body with appid, mchid, description, out_trade_no, notify_url,
* amount { total, currency }, payer { openid }
* 2. Sign request with RSA-SHA256 (merchant private key)
* 3. Extract prepay_id from response
* 4. Build final paySign using HMAC-SHA256 over appId + timeStamp + nonceStr + package
*/
async createUnifiedOrder(params: UnifiedOrderParams): Promise<WxPaymentParams> {
this.logger.log(
`[MOCK] createUnifiedOrder: orderNo=${params.orderNo}, amount=${params.amount}, appId=${this.appId}, mchId=${this.mchId}`,
)
const timeStamp = Math.floor(Date.now() / 1000).toString()
const nonceStr = Math.random().toString(36).substring(2, 18)
const prepayId = `mock_prepay_${params.orderNo}`
return {
timeStamp,
nonceStr,
package: `prepay_id=${prepayId}`,
signType: 'RSA',
paySign: `mock_sign_${nonceStr}`,
}
}
/**
* Verify WeChat Pay callback signature from request headers and body.
*
* TODO: Replace with real WeChat Pay v3 signature verification.
* Steps:
* 1. Extract Wechatpay-Timestamp, Wechatpay-Nonce, Wechatpay-Signature,
* Wechatpay-Serial from headers
* 2. Build message: timestamp + "\n" + nonce + "\n" + body + "\n"
* 3. Verify RSA-SHA256 signature using WeChat platform certificate (identified by serial)
* 4. Check timestamp is within 5 minutes of current time
*/
verifySignature(_headers: Record<string, string>, _body: string): boolean {
// TODO: implement real WeChat Pay v3 signature verification
this.logger.log('[MOCK] verifySignature: returning true')
return true
}
/**
* Parse WeChat Pay callback notification body.
*
* TODO: Replace with real WeChat Pay v3 notification parsing.
* v3 notifications are AES-256-GCM encrypted JSON:
* {
* resource: {
* ciphertext, // base64(AES-GCM encrypted JSON)
* nonce,
* associated_data,
* }
* }
* Steps:
* 1. Decrypt ciphertext using APIV3 key (mchKey)
* 2. Parse decrypted JSON to get transaction info
* 3. Extract out_trade_no (orderNo), transaction_id, trade_state
*/
parseNotification(body: Record<string, unknown>): WxNotification {
// TODO: implement real WeChat Pay v3 AES-256-GCM notification decryption
this.logger.log('[MOCK] parseNotification body received')
const orderNo = (body['out_trade_no'] as string) ?? (body['orderNo'] as string) ?? ''
const wxTransactionId =
(body['transaction_id'] as string) ?? (body['wxTransactionId'] as string) ?? ''
const tradeState = (body['trade_state'] as string) ?? 'SUCCESS'
const success = tradeState === 'SUCCESS'
return { orderNo, wxTransactionId, success }
}
}