235 lines
7.5 KiB
TypeScript
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>`
|
|
}
|
|
}
|