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:
333
packages/server/src/payment/__tests__/payment.service.spec.ts
Normal file
333
packages/server/src/payment/__tests__/payment.service.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
6
packages/server/src/payment/dto/create-order.dto.ts
Normal file
6
packages/server/src/payment/dto/create-order.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { IsUUID } from 'class-validator'
|
||||
|
||||
export class CreateOrderDto {
|
||||
@IsUUID()
|
||||
cardTypeId!: string
|
||||
}
|
||||
91
packages/server/src/payment/payment.controller.ts
Normal file
91
packages/server/src/payment/payment.controller.ts
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
13
packages/server/src/payment/payment.module.ts
Normal file
13
packages/server/src/payment/payment.module.ts
Normal 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 {}
|
||||
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>`
|
||||
}
|
||||
}
|
||||
115
packages/server/src/payment/wechat-pay.service.ts
Normal file
115
packages/server/src/payment/wechat-pay.service.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user