Files
mp-pilates/packages/server/src/payment/payment.service.ts
2026-09-10 11:33:19 +08:00

235 lines
7.5 KiB
TypeScript

import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common'
import { CardType, Order } from '@prisma/client'
import { OrderStatus } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { WechatPayService, WxPaymentParams } from './wechat-pay.service'
import { InviteService } from '../invite/invite.service'
import { MembershipService } from '../membership/membership.service'
export interface CreateOrderResult {
order: Order
paymentParams: WxPaymentParams
}
export interface PaginatedOrders<T> {
items: 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,
private readonly inviteService: InviteService,
private readonly membershipService: MembershipService,
) {}
// ─── User: create order ────────────────────────────────────────────────────
async createOrder(userId: string, cardTypeId: string, inviterId?: 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`)
}
if (cardType.type === 'TRIAL') {
await this.inviteService.validateInviterForTrialOrder(userId, inviterId)
}
const referral = await this.prisma.inviteReferral.findUnique({ where: { inviteeId: userId } })
const amount = referral ? Math.round(Number(cardType.price) * 95 / 100) : Number(cardType.price)
const orderNo = `${Date.now()}${Math.random().toString(36).substring(2, 8)}`
const order = await this.prisma.order.create({
data: {
userId,
cardTypeId,
orderNo,
amount,
inviteInviterId: referral?.inviterId,
purchasedCategory: cardType.type,
status: OrderStatus.PENDING,
},
})
const paymentParams = await this.wechatPayService.createUnifiedOrder({
orderNo,
amount,
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()
await this.prisma.$transaction(async (tx) => {
const claimed = await tx.order.updateMany({
where: { id: existingOrder.id, status: OrderStatus.PENDING },
data: { status: OrderStatus.PAID },
})
if (!claimed.count) return
const membership = await this.membershipService.grantPurchasedCard(
tx,
existingOrder.userId,
cardType,
now,
)
await tx.order.update({
where: { id: existingOrder.id },
data: {
status: OrderStatus.PAID,
wxTransactionId: notification.wxTransactionId,
paidAt: now,
membershipId: membership.id,
},
})
await this.inviteService.rewardPaidOrder(tx, existingOrder, now)
})
await this.inviteService.recordTrialOrderPaid(existingOrder.id)
this.logger.log(`Order PAID and membership granted: 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 {
items: 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 }),
])
this.logger.log(`getAllOrders: page=${page}, limit=${limit}, status=${status}, count=${total}`)
return {
items: 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>`
}
}