feat(M08-A): 接入权益和余额下单抵扣
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user