feat(M08-A): 接入权益和余额下单抵扣

This commit is contained in:
Codex
2026-06-25 22:39:02 +08:00
parent 3d885852cd
commit dda2b3cbc6
20 changed files with 555 additions and 243 deletions
+109 -41
View File
@@ -1,6 +1,8 @@
import { randomBytes } from 'node:crypto';
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { MySqlPool } from '../db/mysql.js';
import type { MarketingBenefitService } from '../wallets/marketing-benefit-service.js';
import type { WalletLedgerService } from '../wallets/wallet-ledger-service.js';
export type PaymentProvider = 'WECHAT' | 'BALANCE' | 'PACKAGE' | 'GROUP_BUY' | 'TEST';
@@ -31,7 +33,11 @@ export class PaymentError extends Error {
}
export class PaymentRepository {
constructor(private readonly pool: MySqlPool) {}
constructor(
private readonly pool: MySqlPool,
private readonly wallet?: Pick<WalletLedgerService, 'debitInTransaction'>,
private readonly benefits?: Pick<MarketingBenefitService, 'confirmReservedInTransaction'>
) {}
async createPayment(input: {
tenantId: string;
@@ -54,7 +60,7 @@ export class PaymentRepository {
if (input.provider === 'TEST' && !input.testAdapterEnabled) {
throw new PaymentError('TEST_PAYMENT_DISABLED');
}
if (input.provider !== 'TEST') {
if (!['TEST', 'BALANCE'].includes(input.provider)) {
await this.resolveConfig(
connection, input.tenantId, input.platformAppId, order.storeId, input.provider
);
@@ -94,6 +100,36 @@ export class PaymentRepository {
[input.tenantId, paymentId, input.provider, amountCents,
input.provider === 'TEST' ? 'test' : 'configured']
);
if (input.provider === 'BALANCE') {
if (!this.wallet) throw new PaymentError('WALLET_SETTLEMENT_NOT_CONFIGURED');
await this.wallet.debitInTransaction(connection, {
tenantId: input.tenantId,
userId: input.userId,
scopeType: 'STORE',
storeId: order.storeId,
businessType: 'ORDER_PAYMENT',
businessId: input.orderId,
entryType: 'CONSUME',
amountCents,
traceId: input.clientRequestId,
note: 'Customer balance payment',
metadata: { paymentId, provider: input.provider }
});
await this.applyPaymentSuccess(connection, {
paymentId,
orderId: input.orderId,
tenantId: input.tenantId,
userId: input.userId,
amountCents,
providerPaymentId: `BAL-${paymentNo}`,
traceId: input.clientRequestId,
reason: 'Balance payment completed'
});
return this.paymentResponse({
id: paymentId, orderId: input.orderId, paymentNo, provider: input.provider,
status: 'SUCCEEDED', amountCents
} as PaymentRow, false, input.testAdapterEnabled);
}
return this.paymentResponse({
id: paymentId, orderId: input.orderId, paymentNo, provider: input.provider,
status: 'PENDING', amountCents
@@ -150,45 +186,16 @@ export class PaymentRepository {
);
return { paymentId: input.paymentId, status: 'SUCCEEDED', idempotent: true };
}
await connection.execute(
`UPDATE qipai_payments
SET status = 'SUCCEEDED', provider_payment_id = ?,
paid_at = UTC_TIMESTAMP(3), raw_notify = JSON_OBJECT('verified', TRUE)
WHERE tenant_id = ? AND id = ? AND status = 'PENDING'`,
[`TEST-${input.callbackId}`, input.tenantId, input.paymentId]
);
await connection.execute(
`UPDATE qipai_orders
SET paid_amount_cents = paid_amount_cents + ?
WHERE tenant_id = ? AND id = ?`,
[payment.amountCents, input.tenantId, payment.orderId]
);
const order = await this.loadOrder(connection, input.tenantId, payment.orderId, true);
if (Number(order.paidAmountCents) >= Number(order.totalAmountCents)
&& order.status === 'PENDING_PAYMENT') {
const nextVersion = Number((order as OrderRow & { statusVersion?: number }).statusVersion ?? 1) + 1;
await connection.execute(
`UPDATE qipai_orders SET status = 'PAID', status_version = ?,
status_updated_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND id = ?`,
[nextVersion, input.tenantId, payment.orderId]
);
await connection.execute(
`UPDATE qipai_room_reservations
SET status = 'CONSUMED', expires_at = GREATEST(expires_at, ends_at)
WHERE tenant_id = ? AND order_id = ? AND status = 'HELD'`,
[input.tenantId, payment.orderId]
);
await connection.execute(
`INSERT INTO qipai_order_status_history
(tenant_id, order_id, from_status, to_status, action, actor_type,
actor_id, source, reason, trace_id, metadata)
VALUES (?, ?, 'PENDING_PAYMENT', 'PAID', 'CONFIRM_PAYMENT', 'SYSTEM',
NULL, 'PAYMENT', 'Verified payment callback', ?,
JSON_OBJECT('statusVersion', ?, 'paymentId', ?))`,
[input.tenantId, payment.orderId, input.traceId, nextVersion, input.paymentId]
);
}
await this.applyPaymentSuccess(connection, {
paymentId: input.paymentId,
orderId: payment.orderId,
tenantId: input.tenantId,
userId: input.userId,
amountCents: Number(payment.amountCents),
providerPaymentId: `TEST-${input.callbackId}`,
traceId: input.traceId,
reason: 'Verified payment callback'
});
await connection.execute(
`UPDATE qipai_payment_callbacks
SET processing_status = 'PROCESSED', processed_at = UTC_TIMESTAMP(3)
@@ -288,6 +295,67 @@ export class PaymentRepository {
if (!rows[0]) throw new PaymentError('ORDER_ACCESS_FORBIDDEN');
}
async applyPaymentSuccess(
connection: PoolConnection,
input: {
paymentId: string;
orderId: string;
tenantId: string;
userId?: string;
amountCents: number;
providerPaymentId: string;
traceId: string;
reason: string;
}
) {
await connection.execute(
`UPDATE qipai_payments
SET status = 'SUCCEEDED', provider_payment_id = ?,
paid_at = UTC_TIMESTAMP(3), raw_notify = JSON_OBJECT('verified', TRUE)
WHERE tenant_id = ? AND id = ? AND status = 'PENDING'`,
[input.providerPaymentId, input.tenantId, input.paymentId]
);
await connection.execute(
`UPDATE qipai_orders
SET paid_amount_cents = paid_amount_cents + ?
WHERE tenant_id = ? AND id = ?`,
[input.amountCents, input.tenantId, input.orderId]
);
const order = await this.loadOrder(connection, input.tenantId, input.orderId, true);
if (Number(order.paidAmountCents) >= Number(order.totalAmountCents)
&& order.status === 'PENDING_PAYMENT') {
if (this.benefits && input.userId) {
await this.benefits.confirmReservedInTransaction(connection, {
tenantId: input.tenantId,
userId: input.userId,
orderId: input.orderId,
traceId: input.traceId
});
}
const nextVersion = Number((order as OrderRow & { statusVersion?: number }).statusVersion ?? 1) + 1;
await connection.execute(
`UPDATE qipai_orders SET status = 'PAID', status_version = ?,
status_updated_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND id = ?`,
[nextVersion, input.tenantId, input.orderId]
);
await connection.execute(
`UPDATE qipai_room_reservations
SET status = 'CONSUMED', expires_at = GREATEST(expires_at, ends_at)
WHERE tenant_id = ? AND order_id = ? AND status = 'HELD'`,
[input.tenantId, input.orderId]
);
await connection.execute(
`INSERT INTO qipai_order_status_history
(tenant_id, order_id, from_status, to_status, action, actor_type,
actor_id, source, reason, trace_id, metadata)
VALUES (?, ?, 'PENDING_PAYMENT', 'PAID', 'CONFIRM_PAYMENT', 'SYSTEM',
NULL, 'PAYMENT', ?, ?, JSON_OBJECT('statusVersion', ?, 'paymentId', ?))`,
[input.tenantId, input.orderId, input.reason, input.traceId, nextVersion, input.paymentId]
);
}
}
private async transaction<T>(work: (connection: PoolConnection) => Promise<T>) {
const connection = await this.pool.getConnection();
try {