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
+20 -1
View File
@@ -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}`
});
}
}
}
}
+57 -4
View File
@@ -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
+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 {
+22 -38
View File
@@ -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
) {
+8 -1
View File
@@ -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}$/)
+5 -3
View File
@@ -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,
+152 -131
View File
@@ -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 },
+18 -11
View File
@@ -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;