feat(M07-C): 建立优惠券套餐权益核销

This commit is contained in:
Codex
2026-06-24 15:45:22 +08:00
parent ba99beb221
commit 4ae9296f83
9 changed files with 936 additions and 6 deletions
+1 -1
View File
@@ -17,7 +17,7 @@
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs"
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/marketing-benefit-service.test.mjs"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
+7 -3
View File
@@ -42,7 +42,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026062219_m06b_device_topology.up.sql',
'database/migrations/2026062220_m06c_iot_messages.up.sql',
'database/migrations/2026062421_m07a_wallet_ledger.up.sql',
'database/migrations/2026062422_m07b_recharge_plans.up.sql'
'database/migrations/2026062422_m07b_recharge_plans.up.sql',
'database/migrations/2026062423_m07c_benefits.up.sql'
],
verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql',
@@ -66,9 +67,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026062219_m06b_device_topology.verify.sql',
'database/migrations/2026062220_m06c_iot_messages.verify.sql',
'database/migrations/2026062421_m07a_wallet_ledger.verify.sql',
'database/migrations/2026062422_m07b_recharge_plans.verify.sql'
'database/migrations/2026062422_m07b_recharge_plans.verify.sql',
'database/migrations/2026062423_m07c_benefits.verify.sql'
],
down: [
'database/migrations/2026062423_m07c_benefits.down.sql',
'database/migrations/2026062422_m07b_recharge_plans.down.sql',
'database/migrations/2026062421_m07a_wallet_ledger.down.sql',
'database/migrations/2026062220_m06c_iot_messages.down.sql',
@@ -228,7 +231,8 @@ export async function executeMigrationPlan(
5, 8, 5, 2, 1,
3, 3, 9, 1,
1, 1, 1, 4, 7, 1,
1, 1, 7, 7, 1
1, 1, 7, 7, 1,
5, 10, 7, 3, 1
][index] ?? 1;
if (!Array.isArray(result) || result.length < minimumRows) {
throw new Error(
@@ -0,0 +1,461 @@
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { MySqlPool } from '../db/mysql.js';
interface CouponRow extends RowDataPacket {
id: string;
status: string;
validFrom: Date;
validTo: Date;
remainingUses: number;
couponType: 'TIME' | 'FULL_REDUCTION';
discountAmountCents: number;
timeMinutes: number;
minOrderAmountCents: number;
storeId: string | null;
roomCategoryId: string | null;
roomId: string | null;
weekdaysJson: string | number[] | null;
holidayOnly: number;
}
interface PackageRow extends RowDataPacket {
id: string;
status: string;
validFrom: Date;
validTo: Date;
remainingMinutes: number;
remainingAmountCents: number;
storeId: string | null;
roomCategoryId: string | null;
roomId: string | null;
weekdaysJson: string | number[] | null;
holidayOnly: number;
}
interface UsageRow extends RowDataPacket {
id: string;
benefitType: 'COUPON' | 'PACKAGE';
benefitId: string;
status: string;
discountCents: number;
packageCreditCents: number;
minutes: number;
}
export class MarketingBenefitError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class MarketingBenefitService {
constructor(private readonly pool: MySqlPool) {}
async reserveForOrder(input: {
tenantId: string;
userId: string;
orderId: string;
storeId: string;
roomId?: string | null;
roomCategoryId?: string | null;
orderAmountCents: number;
orderStartAt: Date;
isHoliday?: boolean;
couponGrantId?: string | null;
packageHoldingId?: string | null;
packageMinutes?: number;
packageCreditCents?: number;
clientRequestId: string;
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);
});
}
async confirmReserved(input: {
tenantId: string;
userId: string;
orderId: string;
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);
});
}
async releaseReserved(input: {
tenantId: string;
userId: string;
orderId: string;
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);
});
}
private async loadCoupon(
connection: PoolConnection,
input: { tenantId: string; userId: string },
couponGrantId: string
) {
const [rows] = await connection.execute<CouponRow[]>(
`SELECT g.id, g.status, g.valid_from AS validFrom, g.valid_to AS validTo,
g.remaining_uses AS remainingUses,
t.coupon_type AS couponType, t.discount_amount_cents AS discountAmountCents,
t.time_minutes AS timeMinutes, t.min_order_amount_cents AS minOrderAmountCents,
t.store_id AS storeId, t.room_category_id AS roomCategoryId,
t.room_id AS roomId, t.weekdays_json AS weekdaysJson, t.holiday_only AS holidayOnly
FROM qipai_coupon_grants g
INNER JOIN qipai_coupon_templates t
ON t.tenant_id = g.tenant_id AND t.id = g.template_id
WHERE g.tenant_id = ? AND g.user_id = ? AND g.id = ?
AND t.status = 'ACTIVE' AND t.deleted_at IS NULL
FOR UPDATE`,
[input.tenantId, input.userId, couponGrantId]
);
if (!rows[0]) throw new MarketingBenefitError('BENEFIT_COUPON_NOT_FOUND');
return normalizeScopeRow(rows[0]);
}
private async loadPackageHolding(
connection: PoolConnection,
input: { tenantId: string; userId: string },
packageHoldingId: string
) {
const [rows] = await connection.execute<PackageRow[]>(
`SELECT h.id, h.status, h.valid_from AS validFrom, h.valid_to AS validTo,
h.remaining_minutes AS remainingMinutes,
h.remaining_amount_cents AS remainingAmountCents,
p.store_id AS storeId, p.room_category_id AS roomCategoryId,
p.room_id AS roomId, p.weekdays_json AS weekdaysJson, p.holiday_only AS holidayOnly
FROM qipai_package_holdings h
INNER JOIN qipai_package_plans p
ON p.tenant_id = h.tenant_id AND p.id = h.plan_id
WHERE h.tenant_id = ? AND h.user_id = ? AND h.id = ?
AND p.status = 'ACTIVE' AND p.deleted_at IS NULL
FOR UPDATE`,
[input.tenantId, input.userId, packageHoldingId]
);
if (!rows[0]) throw new MarketingBenefitError('BENEFIT_PACKAGE_NOT_FOUND');
return normalizeScopeRow(rows[0]);
}
private async findUsageByRequest(
connection: PoolConnection,
input: { tenantId: string; userId: string; clientRequestId: string }
) {
const [rows] = await connection.execute<UsageRow[]>(
`SELECT id, benefit_type AS benefitType, benefit_id AS benefitId, status,
discount_cents AS discountCents,
package_credit_cents AS packageCreditCents, minutes
FROM qipai_benefit_usages
WHERE tenant_id = ? AND user_id = ? AND client_request_id = ?`,
[input.tenantId, input.userId, input.clientRequestId]
);
return rows.map(normalizeUsageRow);
}
private async loadOrderUsages(
connection: PoolConnection,
input: { tenantId: string; userId: string; orderId: string },
lock: boolean
) {
const [rows] = await connection.execute<UsageRow[]>(
`SELECT id, benefit_type AS benefitType, benefit_id AS benefitId, status,
discount_cents AS discountCents,
package_credit_cents AS packageCreditCents, minutes
FROM qipai_benefit_usages
WHERE tenant_id = ? AND user_id = ? AND order_id = ?
${lock ? 'FOR UPDATE' : ''}`,
[input.tenantId, input.userId, input.orderId]
);
return rows.map(normalizeUsageRow);
}
private async insertUsage(
connection: PoolConnection,
input: {
tenantId: string;
userId: string;
orderId: string;
clientRequestId: string;
traceId: string;
benefitType: 'COUPON' | 'PACKAGE';
benefitId: string;
discountCents: number;
packageCreditCents: number;
minutes: number;
}
) {
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_benefit_usages
(tenant_id, user_id, order_id, benefit_type, benefit_id,
client_request_id, discount_cents, package_credit_cents, minutes, trace_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
input.tenantId, input.userId, input.orderId, input.benefitType, input.benefitId,
input.clientRequestId, input.discountCents, input.packageCreditCents,
input.minutes, input.traceId
]
);
return {
id: String(result.insertId),
benefitType: input.benefitType,
benefitId: input.benefitId,
status: 'FROZEN',
discountCents: input.discountCents,
packageCreditCents: input.packageCreditCents,
minutes: input.minutes
} as UsageRow;
}
private async updateUsageStatus(
connection: PoolConnection,
tenantId: string,
usageId: string,
status: 'CONFIRMED' | 'RELEASED'
) {
await connection.execute(
`UPDATE qipai_benefit_usages
SET status = ?
WHERE tenant_id = ? AND id = ?`,
[status, tenantId, usageId]
);
}
private async transaction<T>(work: (connection: PoolConnection) => Promise<T>) {
const connection = await this.pool.getConnection();
try {
await connection.beginTransaction();
const result = await work(connection);
await connection.commit();
return result;
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
}
}
function assertUsableBenefit(
row: CouponRow | PackageRow,
input: {
storeId: string;
roomId?: string | null;
roomCategoryId?: string | null;
orderAmountCents: number;
orderStartAt: Date;
isHoliday?: boolean;
},
type: 'COUPON' | 'PACKAGE'
) {
if (row.status !== 'AVAILABLE' && row.status !== 'ACTIVE') {
throw new MarketingBenefitError(`BENEFIT_${type}_STATUS_INVALID`);
}
const orderTime = input.orderStartAt.getTime();
if (row.validFrom.getTime() > orderTime || row.validTo.getTime() <= orderTime) {
throw new MarketingBenefitError(`BENEFIT_${type}_EXPIRED`);
}
if ('remainingUses' in row && Number(row.remainingUses) <= 0) {
throw new MarketingBenefitError('BENEFIT_COUPON_USED_UP');
}
if ('minOrderAmountCents' in row && input.orderAmountCents < Number(row.minOrderAmountCents)) {
throw new MarketingBenefitError('BENEFIT_COUPON_MIN_AMOUNT_NOT_MET');
}
if (row.storeId && row.storeId !== input.storeId) {
throw new MarketingBenefitError(`BENEFIT_${type}_STORE_MISMATCH`);
}
if (row.roomCategoryId && row.roomCategoryId !== (input.roomCategoryId ?? null)) {
throw new MarketingBenefitError(`BENEFIT_${type}_ROOM_CATEGORY_MISMATCH`);
}
if (row.roomId && row.roomId !== (input.roomId ?? null)) {
throw new MarketingBenefitError(`BENEFIT_${type}_ROOM_MISMATCH`);
}
if (Number(row.holidayOnly) === 1 && input.isHoliday !== true) {
throw new MarketingBenefitError(`BENEFIT_${type}_HOLIDAY_ONLY`);
}
const weekdays = parseWeekdays(row.weekdaysJson);
if (weekdays.length > 0 && !weekdays.includes(input.orderStartAt.getUTCDay())) {
throw new MarketingBenefitError(`BENEFIT_${type}_WEEKDAY_MISMATCH`);
}
}
function normalizeScopeRow<T extends CouponRow | PackageRow>(row: T): T {
return {
...row,
id: String(row.id),
storeId: row.storeId === null ? null : String(row.storeId),
roomCategoryId: row.roomCategoryId === null ? null : String(row.roomCategoryId),
roomId: row.roomId === null ? null : String(row.roomId)
};
}
function normalizeUsageRow(row: UsageRow): UsageRow {
return {
...row,
id: String(row.id),
benefitId: String(row.benefitId),
discountCents: Number(row.discountCents),
packageCreditCents: Number(row.packageCreditCents),
minutes: Number(row.minutes)
};
}
function parseWeekdays(value: string | number[] | null): number[] {
if (!value) return [];
if (Array.isArray(value)) return value.map(Number);
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed.map(Number) : [];
} catch {
return [];
}
}
function reserveResponse(usages: UsageRow[], idempotent: boolean) {
return {
idempotent,
status: usages.every((usage) => usage.status === usages[0]?.status)
? usages[0]?.status ?? 'EMPTY'
: 'MIXED',
discountCents: usages.reduce((sum, usage) => sum + Number(usage.discountCents), 0),
packageCreditCents: usages.reduce((sum, usage) => sum + Number(usage.packageCreditCents), 0),
minutes: usages.reduce((sum, usage) => sum + Number(usage.minutes), 0),
usages: usages.map((usage) => ({
usageId: String(usage.id),
benefitType: usage.benefitType,
benefitId: String(usage.benefitId),
status: usage.status
}))
};
}
@@ -0,0 +1,250 @@
import assert from 'node:assert/strict';
import {
MarketingBenefitError,
MarketingBenefitService
} from '../dist/wallets/marketing-benefit-service.js';
const validFrom = new Date(Date.now() - 86_400_000);
const validTo = new Date(Date.now() + 86_400_000);
function createHarness(overrides = {}) {
const state = {
coupon: {
id: '401',
status: 'AVAILABLE',
validFrom,
validTo,
remainingUses: 1,
couponType: 'FULL_REDUCTION',
discountAmountCents: 3000,
timeMinutes: 0,
minOrderAmountCents: 10000,
storeId: '11',
roomCategoryId: null,
roomId: null,
weekdaysJson: null,
holidayOnly: 0,
...overrides.coupon
},
packageHolding: {
id: '501',
status: 'ACTIVE',
validFrom,
validTo,
remainingMinutes: 180,
remainingAmountCents: 6000,
storeId: '11',
roomCategoryId: null,
roomId: null,
weekdaysJson: null,
holidayOnly: 0,
...overrides.packageHolding
},
usages: [],
nextUsageId: 700
};
const connection = {
async beginTransaction() {},
async commit() {},
async rollback() {},
release() {},
async execute(sql, params) {
if (sql.includes('FROM qipai_benefit_usages') && sql.includes('client_request_id')) {
return [state.usages.filter((usage) =>
usage.tenantId === String(params[0])
&& usage.userId === String(params[1])
&& usage.clientRequestId === params[2]
).map(toUsageRow), []];
}
if (sql.includes('FROM qipai_coupon_grants')) {
if (state.coupon && String(params[2]) === state.coupon.id) {
return [[state.coupon], []];
}
return [[], []];
}
if (sql.includes('FROM qipai_package_holdings')) {
if (state.packageHolding && String(params[2]) === state.packageHolding.id) {
return [[state.packageHolding], []];
}
return [[], []];
}
if (sql.includes('INSERT INTO qipai_benefit_usages')) {
const usage = {
id: String(state.nextUsageId++),
tenantId: String(params[0]),
userId: String(params[1]),
orderId: String(params[2]),
benefitType: params[3],
benefitId: String(params[4]),
clientRequestId: params[5],
discountCents: Number(params[6]),
packageCreditCents: Number(params[7]),
minutes: Number(params[8]),
traceId: params[9],
status: 'FROZEN'
};
state.usages.push(usage);
return [{ insertId: usage.id, affectedRows: 1 }, []];
}
if (sql.includes("SET status = 'FROZEN'") && sql.includes('qipai_coupon_grants')) {
state.coupon.status = 'FROZEN';
state.coupon.frozenOrderId = String(params[0]);
return [{ affectedRows: 1 }, []];
}
if (sql.includes("SET status = 'FROZEN'") && sql.includes('qipai_package_holdings')) {
state.packageHolding.status = 'FROZEN';
state.packageHolding.frozenOrderId = String(params[0]);
return [{ affectedRows: 1 }, []];
}
if (sql.includes('FROM qipai_benefit_usages') && sql.includes('order_id')) {
return [state.usages.filter((usage) =>
usage.tenantId === String(params[0])
&& usage.userId === String(params[1])
&& usage.orderId === String(params[2])
).map(toUsageRow), []];
}
if (sql.includes('UPDATE qipai_coupon_grants') && sql.includes("used_count = used_count + 1")) {
state.coupon.remainingUses = Math.max(state.coupon.remainingUses - 1, 0);
state.coupon.status = state.coupon.remainingUses === 0 ? 'USED' : 'AVAILABLE';
state.coupon.frozenOrderId = null;
return [{ affectedRows: 1 }, []];
}
if (sql.includes('UPDATE qipai_package_holdings') && sql.includes('remaining_minutes')) {
const usage = state.usages.find((item) =>
item.benefitType === 'PACKAGE' && item.benefitId === state.packageHolding.id
);
state.packageHolding.remainingMinutes -= usage.minutes;
state.packageHolding.remainingAmountCents -= usage.packageCreditCents;
state.packageHolding.status =
state.packageHolding.remainingMinutes === 0
&& state.packageHolding.remainingAmountCents === 0
? 'EXHAUSTED'
: 'ACTIVE';
state.packageHolding.frozenOrderId = null;
return [{ affectedRows: 1 }, []];
}
if (sql.includes('UPDATE qipai_benefit_usages')) {
const usage = state.usages.find((item) =>
item.tenantId === String(params[1]) && item.id === String(params[2])
);
usage.status = params[0];
return [{ affectedRows: 1 }, []];
}
if (sql.includes("SET status = 'AVAILABLE'")) {
state.coupon.status = 'AVAILABLE';
state.coupon.frozenOrderId = null;
return [{ affectedRows: 1 }, []];
}
if (sql.includes("SET status = 'ACTIVE'")) {
state.packageHolding.status = 'ACTIVE';
state.packageHolding.frozenOrderId = null;
return [{ affectedRows: 1 }, []];
}
throw new Error(`Unexpected SQL: ${sql}`);
}
};
const pool = {
async getConnection() {
return connection;
}
};
return { service: new MarketingBenefitService(pool), state };
}
function toUsageRow(usage) {
return {
id: usage.id,
benefitType: usage.benefitType,
benefitId: usage.benefitId,
status: usage.status,
discountCents: usage.discountCents,
packageCreditCents: usage.packageCreditCents,
minutes: usage.minutes
};
}
const baseInput = {
tenantId: '7',
userId: '21',
orderId: '31',
storeId: '11',
orderAmountCents: 12000,
orderStartAt: new Date('2026-06-25T02:00:00.000Z'),
couponGrantId: '401',
packageHoldingId: '501',
packageMinutes: 60,
packageCreditCents: 2000,
clientRequestId: 'benefit-001',
traceId: 'benefit-test'
};
{
const { service, state } = createHarness();
const reserved = await service.reserveForOrder(baseInput);
assert.equal(reserved.status, 'FROZEN');
assert.equal(reserved.discountCents, 3000);
assert.equal(reserved.packageCreditCents, 2000);
assert.equal(reserved.minutes, 60);
assert.equal(state.coupon.status, 'FROZEN');
assert.equal(state.packageHolding.status, 'FROZEN');
const duplicate = await service.reserveForOrder(baseInput);
assert.equal(duplicate.idempotent, true);
assert.equal(state.usages.length, 2);
const confirmed = await service.confirmReserved({
tenantId: '7',
userId: '21',
orderId: '31',
traceId: 'benefit-test-confirm'
});
assert.equal(confirmed.status, 'CONFIRMED');
assert.equal(state.coupon.status, 'USED');
assert.equal(state.packageHolding.status, 'ACTIVE');
assert.equal(state.packageHolding.remainingMinutes, 120);
assert.equal(state.packageHolding.remainingAmountCents, 4000);
const confirmAgain = await service.confirmReserved({
tenantId: '7',
userId: '21',
orderId: '31',
traceId: 'benefit-test-confirm'
});
assert.equal(confirmAgain.idempotent, true);
}
{
const { service } = createHarness({ coupon: { minOrderAmountCents: 20000 } });
await assert.rejects(
() => service.reserveForOrder(baseInput),
(error) => error instanceof MarketingBenefitError
&& error.code === 'BENEFIT_COUPON_MIN_AMOUNT_NOT_MET'
);
}
{
const { service } = createHarness({ packageHolding: { remainingMinutes: 30 } });
await assert.rejects(
() => service.reserveForOrder(baseInput),
(error) => error instanceof MarketingBenefitError
&& error.code === 'BENEFIT_PACKAGE_MINUTES_INSUFFICIENT'
);
}
{
const { service, state } = createHarness();
await service.reserveForOrder({ ...baseInput, clientRequestId: 'benefit-release' });
const released = await service.releaseReserved({
tenantId: '7',
userId: '21',
orderId: '31',
traceId: 'benefit-test-release'
});
assert.equal(released.status, 'RELEASED');
assert.equal(state.coupon.status, 'AVAILABLE');
assert.equal(state.packageHolding.status, 'ACTIVE');
assert.equal(state.packageHolding.remainingMinutes, 180);
}
console.log('PASS: M07-C benefits freeze, confirm, release and validate coupon/package usage.');
+19 -1
View File
@@ -75,6 +75,9 @@ const walletVerifySql = read('database/migrations/2026062421_m07a_wallet_ledger.
const rechargeUpSql = read('database/migrations/2026062422_m07b_recharge_plans.up.sql');
const rechargeDownSql = read('database/migrations/2026062422_m07b_recharge_plans.down.sql');
const rechargeVerifySql = read('database/migrations/2026062422_m07b_recharge_plans.verify.sql');
const benefitUpSql = read('database/migrations/2026062423_m07c_benefits.up.sql');
const benefitDownSql = read('database/migrations/2026062423_m07c_benefits.down.sql');
const benefitVerifySql = read('database/migrations/2026062423_m07c_benefits.verify.sql');
const coreTables = [
'qipai_schema_migrations',
@@ -346,4 +349,19 @@ assert.match(rechargeUpSql, /UNIQUE KEY uq_qipai_recharge_order_request/);
assert.match(rechargeUpSql, /payment_id BIGINT UNSIGNED NULL/);
assert.match(rechargeUpSql, /credited_at DATETIME\(3\) NULL/);
console.log('PASS: M01-B through M07-B migration contracts are present.');
for (const table of [
'qipai_coupon_templates', 'qipai_coupon_grants', 'qipai_package_plans',
'qipai_package_holdings', 'qipai_benefit_usages'
]) {
assert.match(benefitUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`));
assert.match(benefitDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`));
assert.match(benefitVerifySql, new RegExp(`'${table}'`));
}
assert.match(benefitUpSql, /coupon_type VARCHAR/);
assert.match(benefitUpSql, /weekdays_json JSON/);
assert.match(benefitUpSql, /remaining_minutes INT UNSIGNED/);
assert.match(benefitUpSql, /remaining_amount_cents INT UNSIGNED/);
assert.match(benefitUpSql, /UNIQUE KEY uq_qipai_benefit_usage_request/);
assert.match(benefitUpSql, /UNIQUE KEY uq_qipai_benefit_usage_order_benefit/);
console.log('PASS: M01-B through M07-C migration contracts are present.');
+2 -1
View File
@@ -33,7 +33,8 @@ assert.match(plan.file, /2026062218_m05d_profit_sharing\.up\.sql/);
assert.match(plan.file, /2026062219_m06b_device_topology\.up\.sql/);
assert.match(plan.file, /2026062220_m06c_iot_messages\.up\.sql/);
assert.match(plan.file, /2026062421_m07a_wallet_ledger\.up\.sql/);
assert.match(plan.file, /2026062422_m07b_recharge_plans\.up\.sql$/);
assert.match(plan.file, /2026062422_m07b_recharge_plans\.up\.sql/);
assert.match(plan.file, /2026062423_m07c_benefits\.up\.sql$/);
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11);