feat(M08-A): 接入权益和余额下单抵扣
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
|
||||
import type { AccessProfile } from '../auth/rbac-repository.js';
|
||||
import type { MySqlPool } from '../db/mysql.js';
|
||||
import type { MarketingBenefitService } from '../wallets/marketing-benefit-service.js';
|
||||
|
||||
export const orderActions = [
|
||||
'SUBMIT', 'CONFIRM_PAYMENT', 'RESERVE', 'START', 'FINISH', 'CANCEL',
|
||||
@@ -71,7 +72,10 @@ export class OrderStateError extends Error {
|
||||
}
|
||||
|
||||
export class OrderStateRepository {
|
||||
constructor(private readonly pool: MySqlPool) {}
|
||||
constructor(
|
||||
private readonly pool: MySqlPool,
|
||||
private readonly benefits?: Pick<MarketingBenefitService, 'releaseReservedInTransaction'>
|
||||
) {}
|
||||
|
||||
async transition(actor: OrderActor, orderId: string, action: OrderAction, reason = '') {
|
||||
return this.transaction(async (connection) => {
|
||||
@@ -223,6 +227,21 @@ export class OrderStateRepository {
|
||||
WHERE tenant_id = ? AND order_id = ?`,
|
||||
[tenantId, orderId]
|
||||
);
|
||||
if (this.benefits) {
|
||||
const [users] = await connection.execute<RowDataPacket[]>(
|
||||
`SELECT user_id AS userId FROM qipai_order_user_access
|
||||
WHERE tenant_id = ? AND order_id = ? ORDER BY id LIMIT 1`,
|
||||
[tenantId, orderId]
|
||||
);
|
||||
if (users[0]?.userId) {
|
||||
await this.benefits.releaseReservedInTransaction(connection, {
|
||||
tenantId,
|
||||
userId: String(users[0].userId),
|
||||
orderId,
|
||||
traceId: `order-benefit-release-${orderId}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
|
||||
export type PricingMode = 'HOURLY' | 'OVERNIGHT' | 'FULL_DAY';
|
||||
|
||||
@@ -18,6 +19,14 @@ export interface QuoteInput {
|
||||
adjustment?: PricingAdjustment;
|
||||
}
|
||||
|
||||
export interface OrderBenefitInput {
|
||||
couponGrantId?: string | null;
|
||||
packageHoldingId?: string | null;
|
||||
packageMinutes?: number;
|
||||
packageCreditCents?: number;
|
||||
clientRequestId?: string;
|
||||
}
|
||||
|
||||
interface RoomPricingRow extends RowDataPacket {
|
||||
id: string;
|
||||
storeId: string;
|
||||
@@ -33,6 +42,7 @@ interface RoomPricingRow extends RowDataPacket {
|
||||
depositCents: number;
|
||||
minimumMinutes: number;
|
||||
maxAdvanceDays: number;
|
||||
roomCategoryId: string | null;
|
||||
}
|
||||
interface CountRow extends RowDataPacket { total: number }
|
||||
|
||||
@@ -41,7 +51,10 @@ export class PricingError extends Error {
|
||||
}
|
||||
|
||||
export class PricingRepository {
|
||||
constructor(private readonly pool: MySqlPool) {}
|
||||
constructor(
|
||||
private readonly pool: MySqlPool,
|
||||
private readonly benefits?: Pick<MarketingBenefitService, 'reserveForOrderInTransaction'>
|
||||
) {}
|
||||
|
||||
async quote(input: QuoteInput) {
|
||||
const room = await this.loadRoom(this.pool, input.tenantId, input.roomId);
|
||||
@@ -55,6 +68,7 @@ export class PricingRepository {
|
||||
userId: string;
|
||||
holdMinutes?: number;
|
||||
allowedStoreIds?: string[] | null;
|
||||
benefits?: OrderBenefitInput | null;
|
||||
}) {
|
||||
return this.transaction(async (connection) => {
|
||||
const room = await this.loadRoom(connection, input.tenantId, input.roomId, true);
|
||||
@@ -66,7 +80,7 @@ export class PricingRepository {
|
||||
const isHoliday = await this.isHoliday(
|
||||
connection, input.tenantId, input.startAt, room.timezone
|
||||
);
|
||||
const quote = this.calculate(room, input, isHoliday);
|
||||
let quote = this.calculate(room, input, isHoliday);
|
||||
const holdMinutes = Math.min(Math.max(input.holdMinutes ?? 15, 5), 30);
|
||||
const orderNo = `QP${Date.now()}${randomBytes(4).toString('hex').toUpperCase()}`;
|
||||
const [orderResult] = await connection.execute<ResultSetHeader>(
|
||||
@@ -79,6 +93,43 @@ export class PricingRepository {
|
||||
holdMinutes, quote.totalCents]
|
||||
);
|
||||
const orderId = String(orderResult.insertId);
|
||||
let benefitReservation = null;
|
||||
const requestedBenefits = input.benefits;
|
||||
if (this.benefits && requestedBenefits
|
||||
&& (requestedBenefits.couponGrantId || requestedBenefits.packageHoldingId)) {
|
||||
if (!requestedBenefits.clientRequestId) {
|
||||
throw new PricingError('BENEFIT_CLIENT_REQUEST_ID_REQUIRED');
|
||||
}
|
||||
benefitReservation = await this.benefits.reserveForOrderInTransaction(connection, {
|
||||
tenantId: input.tenantId,
|
||||
userId: input.userId,
|
||||
orderId,
|
||||
storeId: String(room.storeId),
|
||||
roomId: input.roomId,
|
||||
roomCategoryId: room.roomCategoryId,
|
||||
orderAmountCents: quote.totalCents,
|
||||
orderStartAt: input.startAt,
|
||||
isHoliday,
|
||||
couponGrantId: requestedBenefits.couponGrantId ?? null,
|
||||
packageHoldingId: requestedBenefits.packageHoldingId ?? null,
|
||||
packageMinutes: requestedBenefits.packageMinutes,
|
||||
packageCreditCents: requestedBenefits.packageCreditCents,
|
||||
clientRequestId: requestedBenefits.clientRequestId,
|
||||
traceId: requestedBenefits.clientRequestId
|
||||
});
|
||||
quote = this.calculate(room, {
|
||||
...input,
|
||||
adjustment: {
|
||||
discountCents: benefitReservation.discountCents,
|
||||
packageCreditCents: benefitReservation.packageCreditCents
|
||||
}
|
||||
}, isHoliday);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_orders SET total_amount_cents = ?
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[quote.totalCents, input.tenantId, orderId]
|
||||
);
|
||||
}
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_order_price_snapshots
|
||||
(tenant_id, order_id, pricing_mode, duration_minutes, unit_price_cents,
|
||||
@@ -115,7 +166,8 @@ export class PricingRepository {
|
||||
storeId: String(room.storeId),
|
||||
reservationId: String(reservationResult.insertId),
|
||||
holdMinutes,
|
||||
quote
|
||||
quote,
|
||||
benefitReservation
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -250,7 +302,8 @@ export class PricingRepository {
|
||||
r.minimum_spend_cents AS minimumSpendCents,
|
||||
r.deposit_cents AS depositCents,
|
||||
r.minimum_minutes AS minimumMinutes,
|
||||
r.max_advance_days AS maxAdvanceDays
|
||||
r.max_advance_days AS maxAdvanceDays,
|
||||
r.room_category_id AS roomCategoryId
|
||||
FROM qipai_rooms r
|
||||
INNER JOIN qipai_stores s
|
||||
ON s.id = r.store_id AND s.tenant_id = r.tenant_id AND s.deleted_at IS NULL
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -19,6 +19,7 @@ interface PaymentRow extends RowDataPacket {
|
||||
paidAmountCents: number;
|
||||
totalAmountCents: number;
|
||||
orderStatus: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
interface RefundRow extends RowDataPacket {
|
||||
@@ -419,44 +420,17 @@ export class WechatPaymentService {
|
||||
);
|
||||
return;
|
||||
}
|
||||
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'`,
|
||||
[transactionId, payment.tenantId, payment.id]
|
||||
);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_orders SET paid_amount_cents = paid_amount_cents + ?
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[payment.amountCents, payment.tenantId, payment.orderId]
|
||||
);
|
||||
const nextPaid = Number(payment.paidAmountCents) + Number(payment.amountCents);
|
||||
if (nextPaid >= Number(payment.totalAmountCents)
|
||||
&& payment.orderStatus === 'PENDING_PAYMENT') {
|
||||
await connection.execute(
|
||||
`UPDATE qipai_orders
|
||||
SET status = 'PAID', status_version = status_version + 1,
|
||||
status_updated_at = UTC_TIMESTAMP(3)
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[payment.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'`,
|
||||
[payment.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 Wechat payment callback', ?,
|
||||
JSON_OBJECT('paymentId', ?))`,
|
||||
[payment.tenantId, payment.orderId, traceId, payment.id]
|
||||
);
|
||||
}
|
||||
const userId = await this.loadOrderUserId(connection, payment.tenantId, payment.orderId);
|
||||
await this.paymentRepository.applyPaymentSuccess(connection, {
|
||||
paymentId: payment.id,
|
||||
orderId: payment.orderId,
|
||||
tenantId: payment.tenantId,
|
||||
userId,
|
||||
amountCents: Number(payment.amountCents),
|
||||
providerPaymentId: transactionId,
|
||||
traceId,
|
||||
reason: 'Verified Wechat payment callback'
|
||||
});
|
||||
await connection.execute(
|
||||
`UPDATE qipai_payment_callbacks
|
||||
SET processing_status = 'PROCESSED', processed_at = UTC_TIMESTAMP(3)
|
||||
@@ -465,6 +439,16 @@ export class WechatPaymentService {
|
||||
);
|
||||
}
|
||||
|
||||
private async loadOrderUserId(connection: PoolConnection, tenantId: string, orderId: string) {
|
||||
const [rows] = await connection.execute<RowDataPacket[]>(
|
||||
`SELECT user_id AS userId FROM qipai_order_user_access
|
||||
WHERE tenant_id = ? AND order_id = ? AND revoked_at IS NULL
|
||||
ORDER BY id LIMIT 1`,
|
||||
[tenantId, orderId]
|
||||
);
|
||||
return rows[0]?.userId ? String(rows[0].userId) : undefined;
|
||||
}
|
||||
|
||||
private async rejectCallback(
|
||||
connection: PoolConnection, tenantId: string, callbackId: string, code: string
|
||||
) {
|
||||
|
||||
@@ -9,7 +9,14 @@ const requestSchema = z.object({
|
||||
roomId: z.string().regex(/^[1-9]\d{0,19}$/),
|
||||
startAt: z.coerce.date(),
|
||||
endAt: z.coerce.date(),
|
||||
pricingMode: z.enum(['HOURLY', 'OVERNIGHT', 'FULL_DAY']).default('HOURLY')
|
||||
pricingMode: z.enum(['HOURLY', 'OVERNIGHT', 'FULL_DAY']).default('HOURLY'),
|
||||
benefits: z.object({
|
||||
couponGrantId: z.string().regex(/^[1-9]\d{0,19}$/).nullable().optional(),
|
||||
packageHoldingId: z.string().regex(/^[1-9]\d{0,19}$/).nullable().optional(),
|
||||
packageMinutes: z.number().int().min(0).optional(),
|
||||
packageCreditCents: z.number().int().min(0).optional(),
|
||||
clientRequestId: z.string().min(8).max(128)
|
||||
}).strict().optional()
|
||||
}).refine((value) => value.endAt > value.startAt);
|
||||
const adminReserveSchema = requestSchema.and(z.object({
|
||||
userId: z.string().regex(/^[1-9]\d{0,19}$/)
|
||||
|
||||
@@ -36,14 +36,16 @@ import { DeviceControlService } from './devices/device-control-service.js';
|
||||
import { MemberProfileService } from './wallets/member-profile-service.js';
|
||||
import { RechargeService } from './wallets/recharge-service.js';
|
||||
import { WalletLedgerService } from './wallets/wallet-ledger-service.js';
|
||||
import { MarketingBenefitService } from './wallets/marketing-benefit-service.js';
|
||||
|
||||
const config = loadConfig();
|
||||
const pool = createMySqlPool(config);
|
||||
const authRepository = new AuthRepository(pool);
|
||||
const accessControl = new RbacRepository(pool);
|
||||
const orderManagementRepository = new OrderManagementRepository(pool);
|
||||
const paymentRepository = new PaymentRepository(pool);
|
||||
const walletLedgerService = new WalletLedgerService(pool);
|
||||
const marketingBenefits = new MarketingBenefitService(pool);
|
||||
const paymentRepository = new PaymentRepository(pool, walletLedgerService, marketingBenefits);
|
||||
const wechatCredentials = parseWechatPayCredentials(config.payment.wechatCredentialsJson);
|
||||
const wechatPayClient = new WechatPayClient(new FetchWechatPayTransport());
|
||||
const thirdPartyCredentials = parseThirdPartyCredentials(config.thirdParty.credentialsJson);
|
||||
@@ -94,13 +96,13 @@ const app = await buildApp({
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
},
|
||||
pricing: {
|
||||
repository: new PricingRepository(pool),
|
||||
repository: new PricingRepository(pool, marketingBenefits),
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
},
|
||||
orderState: {
|
||||
repository: new OrderStateRepository(pool),
|
||||
repository: new OrderStateRepository(pool, marketingBenefits),
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret,
|
||||
|
||||
@@ -67,72 +67,79 @@ export class MarketingBenefitService {
|
||||
traceId: string;
|
||||
}) {
|
||||
return this.transaction(async (connection) => {
|
||||
const duplicate = await this.findUsageByRequest(connection, input);
|
||||
if (duplicate.length > 0) return reserveResponse(duplicate, true);
|
||||
|
||||
const usages: UsageRow[] = [];
|
||||
if (input.couponGrantId) {
|
||||
const coupon = await this.loadCoupon(connection, input, input.couponGrantId);
|
||||
assertUsableBenefit(coupon, input, 'COUPON');
|
||||
const discountCents = coupon.couponType === 'FULL_REDUCTION'
|
||||
? Math.min(Number(coupon.discountAmountCents), input.orderAmountCents)
|
||||
: 0;
|
||||
const minutes = coupon.couponType === 'TIME' ? Number(coupon.timeMinutes) : 0;
|
||||
const usage = await this.insertUsage(connection, {
|
||||
...input,
|
||||
benefitType: 'COUPON',
|
||||
benefitId: coupon.id,
|
||||
discountCents,
|
||||
packageCreditCents: 0,
|
||||
minutes
|
||||
});
|
||||
await connection.execute(
|
||||
`UPDATE qipai_coupon_grants
|
||||
SET status = 'FROZEN', frozen_order_id = ?
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[input.orderId, input.tenantId, coupon.id]
|
||||
);
|
||||
usages.push(usage);
|
||||
}
|
||||
|
||||
if (input.packageHoldingId) {
|
||||
const holding = await this.loadPackageHolding(connection, input, input.packageHoldingId);
|
||||
assertUsableBenefit(holding, input, 'PACKAGE');
|
||||
const packageCreditCents = input.packageCreditCents ?? 0;
|
||||
const minutes = input.packageMinutes ?? 0;
|
||||
if (minutes <= 0 && packageCreditCents <= 0) {
|
||||
throw new MarketingBenefitError('BENEFIT_PACKAGE_USAGE_EMPTY');
|
||||
}
|
||||
if (minutes > Number(holding.remainingMinutes)) {
|
||||
throw new MarketingBenefitError('BENEFIT_PACKAGE_MINUTES_INSUFFICIENT');
|
||||
}
|
||||
if (packageCreditCents > Number(holding.remainingAmountCents)) {
|
||||
throw new MarketingBenefitError('BENEFIT_PACKAGE_AMOUNT_INSUFFICIENT');
|
||||
}
|
||||
const usage = await this.insertUsage(connection, {
|
||||
...input,
|
||||
benefitType: 'PACKAGE',
|
||||
benefitId: holding.id,
|
||||
discountCents: 0,
|
||||
packageCreditCents,
|
||||
minutes
|
||||
});
|
||||
await connection.execute(
|
||||
`UPDATE qipai_package_holdings
|
||||
SET status = 'FROZEN', frozen_order_id = ?
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[input.orderId, input.tenantId, holding.id]
|
||||
);
|
||||
usages.push(usage);
|
||||
}
|
||||
|
||||
if (usages.length === 0) {
|
||||
throw new MarketingBenefitError('BENEFIT_EMPTY');
|
||||
}
|
||||
return reserveResponse(usages, false);
|
||||
return this.reserveForOrderInTransaction(connection, input);
|
||||
});
|
||||
}
|
||||
|
||||
async reserveForOrderInTransaction(
|
||||
connection: PoolConnection,
|
||||
input: Parameters<MarketingBenefitService['reserveForOrder']>[0]
|
||||
) {
|
||||
const duplicate = await this.findUsageByRequest(connection, input);
|
||||
if (duplicate.length > 0) return reserveResponse(duplicate, true);
|
||||
|
||||
const usages: UsageRow[] = [];
|
||||
if (input.couponGrantId) {
|
||||
const coupon = await this.loadCoupon(connection, input, input.couponGrantId);
|
||||
assertUsableBenefit(coupon, input, 'COUPON');
|
||||
const discountCents = coupon.couponType === 'FULL_REDUCTION'
|
||||
? Math.min(Number(coupon.discountAmountCents), input.orderAmountCents)
|
||||
: 0;
|
||||
const minutes = coupon.couponType === 'TIME' ? Number(coupon.timeMinutes) : 0;
|
||||
const usage = await this.insertUsage(connection, {
|
||||
...input,
|
||||
benefitType: 'COUPON',
|
||||
benefitId: coupon.id,
|
||||
discountCents,
|
||||
packageCreditCents: 0,
|
||||
minutes
|
||||
});
|
||||
await connection.execute(
|
||||
`UPDATE qipai_coupon_grants
|
||||
SET status = 'FROZEN', frozen_order_id = ?
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[input.orderId, input.tenantId, coupon.id]
|
||||
);
|
||||
usages.push(usage);
|
||||
}
|
||||
|
||||
if (input.packageHoldingId) {
|
||||
const holding = await this.loadPackageHolding(connection, input, input.packageHoldingId);
|
||||
assertUsableBenefit(holding, input, 'PACKAGE');
|
||||
const packageCreditCents = input.packageCreditCents ?? 0;
|
||||
const minutes = input.packageMinutes ?? 0;
|
||||
if (minutes <= 0 && packageCreditCents <= 0) {
|
||||
throw new MarketingBenefitError('BENEFIT_PACKAGE_USAGE_EMPTY');
|
||||
}
|
||||
if (minutes > Number(holding.remainingMinutes)) {
|
||||
throw new MarketingBenefitError('BENEFIT_PACKAGE_MINUTES_INSUFFICIENT');
|
||||
}
|
||||
if (packageCreditCents > Number(holding.remainingAmountCents)) {
|
||||
throw new MarketingBenefitError('BENEFIT_PACKAGE_AMOUNT_INSUFFICIENT');
|
||||
}
|
||||
const usage = await this.insertUsage(connection, {
|
||||
...input,
|
||||
benefitType: 'PACKAGE',
|
||||
benefitId: holding.id,
|
||||
discountCents: 0,
|
||||
packageCreditCents,
|
||||
minutes
|
||||
});
|
||||
await connection.execute(
|
||||
`UPDATE qipai_package_holdings
|
||||
SET status = 'FROZEN', frozen_order_id = ?
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[input.orderId, input.tenantId, holding.id]
|
||||
);
|
||||
usages.push(usage);
|
||||
}
|
||||
|
||||
if (usages.length === 0) {
|
||||
throw new MarketingBenefitError('BENEFIT_EMPTY');
|
||||
}
|
||||
return reserveResponse(usages, false);
|
||||
}
|
||||
|
||||
async confirmReserved(input: {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
@@ -140,50 +147,57 @@ export class MarketingBenefitService {
|
||||
traceId: string;
|
||||
}) {
|
||||
return this.transaction(async (connection) => {
|
||||
const usages = await this.loadOrderUsages(connection, input, true);
|
||||
if (usages.length === 0) throw new MarketingBenefitError('BENEFIT_USAGE_NOT_FOUND');
|
||||
if (usages.every((usage) => usage.status === 'CONFIRMED')) {
|
||||
return reserveResponse(usages, true);
|
||||
}
|
||||
for (const usage of usages) {
|
||||
if (usage.status !== 'FROZEN') {
|
||||
throw new MarketingBenefitError('BENEFIT_USAGE_STATUS_INVALID');
|
||||
}
|
||||
if (usage.benefitType === 'COUPON') {
|
||||
await connection.execute(
|
||||
`UPDATE qipai_coupon_grants
|
||||
SET status = CASE WHEN remaining_uses <= 1 THEN 'USED' ELSE 'AVAILABLE' END,
|
||||
remaining_uses = GREATEST(remaining_uses - 1, 0),
|
||||
used_count = used_count + 1,
|
||||
used_at = UTC_TIMESTAMP(3),
|
||||
frozen_order_id = NULL
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[input.tenantId, usage.benefitId]
|
||||
);
|
||||
} else {
|
||||
await connection.execute(
|
||||
`UPDATE qipai_package_holdings
|
||||
SET remaining_minutes = GREATEST(remaining_minutes - ?, 0),
|
||||
remaining_amount_cents = GREATEST(remaining_amount_cents - ?, 0),
|
||||
status = CASE
|
||||
WHEN remaining_minutes <= ? AND remaining_amount_cents <= ? THEN 'EXHAUSTED'
|
||||
ELSE 'ACTIVE'
|
||||
END,
|
||||
frozen_order_id = NULL
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[
|
||||
usage.minutes, usage.packageCreditCents,
|
||||
usage.minutes, usage.packageCreditCents,
|
||||
input.tenantId, usage.benefitId
|
||||
]
|
||||
);
|
||||
}
|
||||
await this.updateUsageStatus(connection, input.tenantId, usage.id, 'CONFIRMED');
|
||||
}
|
||||
return reserveResponse(usages.map((usage) => ({ ...usage, status: 'CONFIRMED' })), false);
|
||||
return this.confirmReservedInTransaction(connection, input);
|
||||
});
|
||||
}
|
||||
|
||||
async confirmReservedInTransaction(
|
||||
connection: PoolConnection,
|
||||
input: Parameters<MarketingBenefitService['confirmReserved']>[0]
|
||||
) {
|
||||
const usages = await this.loadOrderUsages(connection, input, true);
|
||||
if (usages.length === 0) return reserveResponse([], true);
|
||||
if (usages.every((usage) => usage.status === 'CONFIRMED')) {
|
||||
return reserveResponse(usages, true);
|
||||
}
|
||||
for (const usage of usages) {
|
||||
if (usage.status !== 'FROZEN') {
|
||||
throw new MarketingBenefitError('BENEFIT_USAGE_STATUS_INVALID');
|
||||
}
|
||||
if (usage.benefitType === 'COUPON') {
|
||||
await connection.execute(
|
||||
`UPDATE qipai_coupon_grants
|
||||
SET status = CASE WHEN remaining_uses <= 1 THEN 'USED' ELSE 'AVAILABLE' END,
|
||||
remaining_uses = GREATEST(remaining_uses - 1, 0),
|
||||
used_count = used_count + 1,
|
||||
used_at = UTC_TIMESTAMP(3),
|
||||
frozen_order_id = NULL
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[input.tenantId, usage.benefitId]
|
||||
);
|
||||
} else {
|
||||
await connection.execute(
|
||||
`UPDATE qipai_package_holdings
|
||||
SET remaining_minutes = GREATEST(remaining_minutes - ?, 0),
|
||||
remaining_amount_cents = GREATEST(remaining_amount_cents - ?, 0),
|
||||
status = CASE
|
||||
WHEN remaining_minutes <= ? AND remaining_amount_cents <= ? THEN 'EXHAUSTED'
|
||||
ELSE 'ACTIVE'
|
||||
END,
|
||||
frozen_order_id = NULL
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[
|
||||
usage.minutes, usage.packageCreditCents,
|
||||
usage.minutes, usage.packageCreditCents,
|
||||
input.tenantId, usage.benefitId
|
||||
]
|
||||
);
|
||||
}
|
||||
await this.updateUsageStatus(connection, input.tenantId, usage.id, 'CONFIRMED');
|
||||
}
|
||||
return reserveResponse(usages.map((usage) => ({ ...usage, status: 'CONFIRMED' })), false);
|
||||
}
|
||||
|
||||
async releaseReserved(input: {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
@@ -191,36 +205,43 @@ export class MarketingBenefitService {
|
||||
traceId: string;
|
||||
}) {
|
||||
return this.transaction(async (connection) => {
|
||||
const usages = await this.loadOrderUsages(connection, input, true);
|
||||
if (usages.length === 0) throw new MarketingBenefitError('BENEFIT_USAGE_NOT_FOUND');
|
||||
if (usages.every((usage) => usage.status === 'RELEASED')) {
|
||||
return reserveResponse(usages, true);
|
||||
}
|
||||
for (const usage of usages) {
|
||||
if (usage.status !== 'FROZEN') {
|
||||
throw new MarketingBenefitError('BENEFIT_USAGE_STATUS_INVALID');
|
||||
}
|
||||
if (usage.benefitType === 'COUPON') {
|
||||
await connection.execute(
|
||||
`UPDATE qipai_coupon_grants
|
||||
SET status = 'AVAILABLE', frozen_order_id = NULL
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[input.tenantId, usage.benefitId]
|
||||
);
|
||||
} else {
|
||||
await connection.execute(
|
||||
`UPDATE qipai_package_holdings
|
||||
SET status = 'ACTIVE', frozen_order_id = NULL
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[input.tenantId, usage.benefitId]
|
||||
);
|
||||
}
|
||||
await this.updateUsageStatus(connection, input.tenantId, usage.id, 'RELEASED');
|
||||
}
|
||||
return reserveResponse(usages.map((usage) => ({ ...usage, status: 'RELEASED' })), false);
|
||||
return this.releaseReservedInTransaction(connection, input);
|
||||
});
|
||||
}
|
||||
|
||||
async releaseReservedInTransaction(
|
||||
connection: PoolConnection,
|
||||
input: Parameters<MarketingBenefitService['releaseReserved']>[0]
|
||||
) {
|
||||
const usages = await this.loadOrderUsages(connection, input, true);
|
||||
if (usages.length === 0) return reserveResponse([], true);
|
||||
if (usages.every((usage) => usage.status === 'RELEASED')) {
|
||||
return reserveResponse(usages, true);
|
||||
}
|
||||
for (const usage of usages) {
|
||||
if (usage.status !== 'FROZEN') continue;
|
||||
if (usage.benefitType === 'COUPON') {
|
||||
await connection.execute(
|
||||
`UPDATE qipai_coupon_grants
|
||||
SET status = 'AVAILABLE', frozen_order_id = NULL
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[input.tenantId, usage.benefitId]
|
||||
);
|
||||
} else {
|
||||
await connection.execute(
|
||||
`UPDATE qipai_package_holdings
|
||||
SET status = 'ACTIVE', frozen_order_id = NULL
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[input.tenantId, usage.benefitId]
|
||||
);
|
||||
}
|
||||
await this.updateUsageStatus(connection, input.tenantId, usage.id, 'RELEASED');
|
||||
}
|
||||
return reserveResponse(usages.map((usage) => (
|
||||
usage.status === 'FROZEN' ? { ...usage, status: 'RELEASED' } : usage
|
||||
)), false);
|
||||
}
|
||||
|
||||
private async loadCoupon(
|
||||
connection: PoolConnection,
|
||||
input: { tenantId: string; userId: string },
|
||||
|
||||
@@ -47,20 +47,27 @@ export class WalletLedgerService {
|
||||
|
||||
async debit(input: WalletMutationInput & { amountCents: number }) {
|
||||
return this.transaction(async (connection) => {
|
||||
const account = await this.ensureAccount(connection, input, true);
|
||||
const duplicate = await this.findDuplicate(connection, account, input);
|
||||
if (duplicate) return ledgerResponse(account, duplicate, true);
|
||||
const amount = nonNegative(input.amountCents, 'WALLET_DEBIT_AMOUNT_INVALID');
|
||||
if (amount <= 0) throw new WalletLedgerError('WALLET_DEBIT_AMOUNT_INVALID');
|
||||
const giftUsed = Math.min(Number(account.giftBalanceCents), amount);
|
||||
const cashUsed = amount - giftUsed;
|
||||
if (cashUsed > Number(account.cashBalanceCents)) {
|
||||
throw new WalletLedgerError('WALLET_BALANCE_INSUFFICIENT');
|
||||
}
|
||||
return this.applyDelta(connection, account, input, -cashUsed, -giftUsed, false);
|
||||
return this.debitInTransaction(connection, input);
|
||||
});
|
||||
}
|
||||
|
||||
async debitInTransaction(
|
||||
connection: PoolConnection,
|
||||
input: WalletMutationInput & { amountCents: number }
|
||||
) {
|
||||
const account = await this.ensureAccount(connection, input, true);
|
||||
const duplicate = await this.findDuplicate(connection, account, input);
|
||||
if (duplicate) return ledgerResponse(account, duplicate, true);
|
||||
const amount = nonNegative(input.amountCents, 'WALLET_DEBIT_AMOUNT_INVALID');
|
||||
if (amount <= 0) throw new WalletLedgerError('WALLET_DEBIT_AMOUNT_INVALID');
|
||||
const giftUsed = Math.min(Number(account.giftBalanceCents), amount);
|
||||
const cashUsed = amount - giftUsed;
|
||||
if (cashUsed > Number(account.cashBalanceCents)) {
|
||||
throw new WalletLedgerError('WALLET_BALANCE_INSUFFICIENT');
|
||||
}
|
||||
return this.applyDelta(connection, account, input, -cashUsed, -giftUsed, false);
|
||||
}
|
||||
|
||||
async adjust(input: WalletMutationInput & {
|
||||
cashDeltaCents?: number;
|
||||
giftDeltaCents?: number;
|
||||
|
||||
Reference in New Issue
Block a user