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:verify": "npm run build && node dist/db/migrate-cli.js verify",
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down", "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: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": { "dependencies": {
"@fastify/cors": "^11.2.0", "@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/2026062219_m06b_device_topology.up.sql',
'database/migrations/2026062220_m06c_iot_messages.up.sql', 'database/migrations/2026062220_m06c_iot_messages.up.sql',
'database/migrations/2026062421_m07a_wallet_ledger.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: [ verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql', '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/2026062219_m06b_device_topology.verify.sql',
'database/migrations/2026062220_m06c_iot_messages.verify.sql', 'database/migrations/2026062220_m06c_iot_messages.verify.sql',
'database/migrations/2026062421_m07a_wallet_ledger.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: [ down: [
'database/migrations/2026062423_m07c_benefits.down.sql',
'database/migrations/2026062422_m07b_recharge_plans.down.sql', 'database/migrations/2026062422_m07b_recharge_plans.down.sql',
'database/migrations/2026062421_m07a_wallet_ledger.down.sql', 'database/migrations/2026062421_m07a_wallet_ledger.down.sql',
'database/migrations/2026062220_m06c_iot_messages.down.sql', 'database/migrations/2026062220_m06c_iot_messages.down.sql',
@@ -228,7 +231,8 @@ export async function executeMigrationPlan(
5, 8, 5, 2, 1, 5, 8, 5, 2, 1,
3, 3, 9, 1, 3, 3, 9, 1,
1, 1, 1, 4, 7, 1, 1, 1, 1, 4, 7, 1,
1, 1, 7, 7, 1 1, 1, 7, 7, 1,
5, 10, 7, 3, 1
][index] ?? 1; ][index] ?? 1;
if (!Array.isArray(result) || result.length < minimumRows) { if (!Array.isArray(result) || result.length < minimumRows) {
throw new Error( 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 rechargeUpSql = read('database/migrations/2026062422_m07b_recharge_plans.up.sql');
const rechargeDownSql = read('database/migrations/2026062422_m07b_recharge_plans.down.sql'); const rechargeDownSql = read('database/migrations/2026062422_m07b_recharge_plans.down.sql');
const rechargeVerifySql = read('database/migrations/2026062422_m07b_recharge_plans.verify.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 = [ const coreTables = [
'qipai_schema_migrations', '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, /payment_id BIGINT UNSIGNED NULL/);
assert.match(rechargeUpSql, /credited_at DATETIME\(3\) 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, /2026062219_m06b_device_topology\.up\.sql/);
assert.match(plan.file, /2026062220_m06c_iot_messages\.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, /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.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11); assert.ok(plan.statements.length >= 11);
@@ -0,0 +1,6 @@
DROP TABLE IF EXISTS qipai_benefit_usages;
DROP TABLE IF EXISTS qipai_package_holdings;
DROP TABLE IF EXISTS qipai_package_plans;
DROP TABLE IF EXISTS qipai_coupon_grants;
DROP TABLE IF EXISTS qipai_coupon_templates;
DELETE FROM qipai_schema_migrations WHERE version = '2026062423';
@@ -0,0 +1,149 @@
CREATE TABLE IF NOT EXISTS qipai_coupon_templates (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
store_id BIGINT UNSIGNED NULL,
name VARCHAR(128) NOT NULL,
coupon_type VARCHAR(32) NOT NULL,
discount_amount_cents INT UNSIGNED NOT NULL DEFAULT 0,
time_minutes INT UNSIGNED NOT NULL DEFAULT 0,
min_order_amount_cents INT UNSIGNED NOT NULL DEFAULT 0,
room_category_id BIGINT UNSIGNED NULL,
room_id BIGINT UNSIGNED NULL,
weekdays_json JSON NULL,
holiday_only TINYINT(1) NOT NULL DEFAULT 0,
starts_at DATETIME(3) NULL,
ends_at DATETIME(3) NULL,
valid_days INT UNSIGNED NULL,
usage_limit_per_user INT UNSIGNED NOT NULL DEFAULT 1,
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3),
deleted_at DATETIME(3) NULL,
CONSTRAINT fk_qipai_coupon_template_tenant
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_coupon_template_store
FOREIGN KEY (store_id) REFERENCES qipai_stores(id),
CONSTRAINT fk_qipai_coupon_template_room_category
FOREIGN KEY (room_category_id) REFERENCES qipai_room_categories(id),
CONSTRAINT fk_qipai_coupon_template_room
FOREIGN KEY (room_id) REFERENCES qipai_rooms(id),
KEY idx_qipai_coupon_template_scope
(tenant_id, store_id, coupon_type, status, starts_at, ends_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qipai_coupon_grants (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
template_id BIGINT UNSIGNED NOT NULL,
coupon_code VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'AVAILABLE',
valid_from DATETIME(3) NOT NULL,
valid_to DATETIME(3) NOT NULL,
remaining_uses INT UNSIGNED NOT NULL DEFAULT 1,
used_count INT UNSIGNED NOT NULL DEFAULT 0,
frozen_order_id BIGINT UNSIGNED NULL,
used_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3),
CONSTRAINT fk_qipai_coupon_grant_tenant
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_coupon_grant_user
FOREIGN KEY (user_id) REFERENCES qipai_users(id),
CONSTRAINT fk_qipai_coupon_grant_template
FOREIGN KEY (template_id) REFERENCES qipai_coupon_templates(id),
CONSTRAINT fk_qipai_coupon_grant_order
FOREIGN KEY (frozen_order_id) REFERENCES qipai_orders(id),
UNIQUE KEY uq_qipai_coupon_grant_code (tenant_id, coupon_code),
KEY idx_qipai_coupon_grant_user (tenant_id, user_id, status, valid_to)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qipai_package_plans (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
store_id BIGINT UNSIGNED NULL,
name VARCHAR(128) NOT NULL,
price_cents INT UNSIGNED NOT NULL,
minutes_total INT UNSIGNED NOT NULL DEFAULT 0,
amount_cents_total INT UNSIGNED NOT NULL DEFAULT 0,
room_category_id BIGINT UNSIGNED NULL,
room_id BIGINT UNSIGNED NULL,
weekdays_json JSON NULL,
holiday_only TINYINT(1) NOT NULL DEFAULT 0,
valid_days INT UNSIGNED NULL,
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3),
deleted_at DATETIME(3) NULL,
CONSTRAINT fk_qipai_package_plan_tenant
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_package_plan_store
FOREIGN KEY (store_id) REFERENCES qipai_stores(id),
CONSTRAINT fk_qipai_package_plan_room_category
FOREIGN KEY (room_category_id) REFERENCES qipai_room_categories(id),
CONSTRAINT fk_qipai_package_plan_room
FOREIGN KEY (room_id) REFERENCES qipai_rooms(id),
KEY idx_qipai_package_plan_scope (tenant_id, store_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qipai_package_holdings (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
plan_id BIGINT UNSIGNED NOT NULL,
purchase_order_id BIGINT UNSIGNED NULL,
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
valid_from DATETIME(3) NOT NULL,
valid_to DATETIME(3) NOT NULL,
remaining_minutes INT UNSIGNED NOT NULL DEFAULT 0,
remaining_amount_cents INT UNSIGNED NOT NULL DEFAULT 0,
frozen_order_id BIGINT UNSIGNED NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3),
CONSTRAINT fk_qipai_package_holding_tenant
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_package_holding_user
FOREIGN KEY (user_id) REFERENCES qipai_users(id),
CONSTRAINT fk_qipai_package_holding_plan
FOREIGN KEY (plan_id) REFERENCES qipai_package_plans(id),
CONSTRAINT fk_qipai_package_holding_purchase_order
FOREIGN KEY (purchase_order_id) REFERENCES qipai_orders(id),
CONSTRAINT fk_qipai_package_holding_frozen_order
FOREIGN KEY (frozen_order_id) REFERENCES qipai_orders(id),
KEY idx_qipai_package_holding_user (tenant_id, user_id, status, valid_to)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qipai_benefit_usages (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,
benefit_type VARCHAR(32) NOT NULL,
benefit_id BIGINT UNSIGNED NOT NULL,
client_request_id VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'FROZEN',
discount_cents INT UNSIGNED NOT NULL DEFAULT 0,
package_credit_cents INT UNSIGNED NOT NULL DEFAULT 0,
minutes INT UNSIGNED NOT NULL DEFAULT 0,
trace_id VARCHAR(128) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3),
CONSTRAINT fk_qipai_benefit_usage_tenant
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_benefit_usage_user
FOREIGN KEY (user_id) REFERENCES qipai_users(id),
CONSTRAINT fk_qipai_benefit_usage_order
FOREIGN KEY (order_id) REFERENCES qipai_orders(id),
UNIQUE KEY uq_qipai_benefit_usage_request (tenant_id, user_id, client_request_id),
UNIQUE KEY uq_qipai_benefit_usage_order_benefit
(tenant_id, order_id, benefit_type, benefit_id),
KEY idx_qipai_benefit_usage_order (tenant_id, order_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO qipai_schema_migrations (version, name)
VALUES ('2026062423', 'm07c_benefits');
@@ -0,0 +1,41 @@
SELECT table_name
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name IN (
'qipai_coupon_templates', 'qipai_coupon_grants',
'qipai_package_plans', 'qipai_package_holdings',
'qipai_benefit_usages'
);
SELECT column_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'qipai_coupon_templates'
AND column_name IN (
'coupon_type', 'discount_amount_cents', 'time_minutes',
'min_order_amount_cents', 'room_category_id', 'room_id',
'weekdays_json', 'holiday_only', 'valid_days', 'usage_limit_per_user'
);
SELECT column_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'qipai_package_holdings'
AND column_name IN (
'purchase_order_id', 'remaining_minutes', 'remaining_amount_cents',
'frozen_order_id', 'valid_from', 'valid_to', 'status'
);
SELECT index_name
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'qipai_benefit_usages'
AND index_name IN (
'uq_qipai_benefit_usage_request',
'uq_qipai_benefit_usage_order_benefit',
'idx_qipai_benefit_usage_order'
);
SELECT '2026062423' AS version
FROM qipai_schema_migrations
WHERE version = '2026062423';