feat(M07-A): 建立双余额账本
This commit is contained in:
@@ -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"
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
|
||||
@@ -40,7 +40,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026062217_m05c_third_party.up.sql',
|
||||
'database/migrations/2026062218_m05d_profit_sharing.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'
|
||||
],
|
||||
verify: [
|
||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
||||
@@ -62,9 +63,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026062217_m05c_third_party.verify.sql',
|
||||
'database/migrations/2026062218_m05d_profit_sharing.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'
|
||||
],
|
||||
down: [
|
||||
'database/migrations/2026062421_m07a_wallet_ledger.down.sql',
|
||||
'database/migrations/2026062220_m06c_iot_messages.down.sql',
|
||||
'database/migrations/2026062219_m06b_device_topology.down.sql',
|
||||
'database/migrations/2026062218_m05d_profit_sharing.down.sql',
|
||||
@@ -220,7 +223,8 @@ export async function executeMigrationPlan(
|
||||
5, 6, 1,
|
||||
3, 7, 5, 1,
|
||||
5, 8, 5, 2, 1,
|
||||
3, 3, 9, 1
|
||||
3, 3, 9, 1,
|
||||
1, 1, 1, 4, 7, 1
|
||||
][index] ?? 1;
|
||||
if (!Array.isArray(result) || result.length < minimumRows) {
|
||||
throw new Error(
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
|
||||
import type { MySqlPool } from '../db/mysql.js';
|
||||
|
||||
export type WalletScopeType = 'TENANT' | 'STORE';
|
||||
export type WalletEntryType = 'RECHARGE' | 'GIFT' | 'CONSUME' | 'REFUND' | 'ADJUST';
|
||||
|
||||
interface WalletAccountRow extends RowDataPacket {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
scopeType: WalletScopeType;
|
||||
storeId: string | null;
|
||||
cashBalanceCents: number;
|
||||
giftBalanceCents: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface WalletLedgerRow extends RowDataPacket {
|
||||
id: string;
|
||||
cashBalanceAfterCents: number;
|
||||
giftBalanceAfterCents: number;
|
||||
cashDeltaCents: number;
|
||||
giftDeltaCents: number;
|
||||
}
|
||||
|
||||
export class WalletLedgerError extends Error {
|
||||
constructor(public readonly code: string) { super(code); }
|
||||
}
|
||||
|
||||
export class WalletLedgerService {
|
||||
constructor(private readonly pool: MySqlPool) {}
|
||||
|
||||
async credit(input: WalletMutationInput & {
|
||||
cashDeltaCents?: number;
|
||||
giftDeltaCents?: 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 cashDelta = nonNegative(input.cashDeltaCents ?? 0, 'WALLET_CASH_DELTA_INVALID');
|
||||
const giftDelta = nonNegative(input.giftDeltaCents ?? 0, 'WALLET_GIFT_DELTA_INVALID');
|
||||
if (cashDelta + giftDelta <= 0) throw new WalletLedgerError('WALLET_CREDIT_ZERO');
|
||||
return this.applyDelta(connection, account, input, cashDelta, giftDelta, false);
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
async adjust(input: WalletMutationInput & {
|
||||
cashDeltaCents?: number;
|
||||
giftDeltaCents?: 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 cashDelta = integer(input.cashDeltaCents ?? 0, 'WALLET_CASH_DELTA_INVALID');
|
||||
const giftDelta = integer(input.giftDeltaCents ?? 0, 'WALLET_GIFT_DELTA_INVALID');
|
||||
if (cashDelta === 0 && giftDelta === 0) throw new WalletLedgerError('WALLET_ADJUST_ZERO');
|
||||
return this.applyDelta(connection, account, input, cashDelta, giftDelta, true);
|
||||
});
|
||||
}
|
||||
|
||||
private async applyDelta(
|
||||
connection: PoolConnection,
|
||||
account: WalletAccountRow,
|
||||
input: WalletMutationInput,
|
||||
cashDelta: number,
|
||||
giftDelta: number,
|
||||
allowNegativeDelta: boolean
|
||||
) {
|
||||
if (!allowNegativeDelta && (cashDelta < 0 || giftDelta < 0) && input.entryType !== 'CONSUME') {
|
||||
throw new WalletLedgerError('WALLET_DELTA_DIRECTION_INVALID');
|
||||
}
|
||||
const nextCash = Number(account.cashBalanceCents) + cashDelta;
|
||||
const nextGift = Number(account.giftBalanceCents) + giftDelta;
|
||||
if (nextCash < 0 || nextGift < 0) throw new WalletLedgerError('WALLET_BALANCE_INSUFFICIENT');
|
||||
await connection.execute(
|
||||
`UPDATE qipai_wallet_accounts
|
||||
SET cash_balance_cents = ?, gift_balance_cents = ?
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[nextCash, nextGift, account.tenantId, account.id]
|
||||
);
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`INSERT INTO qipai_wallet_ledger_entries
|
||||
(tenant_id, account_id, user_id, store_id, business_type, business_id,
|
||||
entry_type, cash_delta_cents, gift_delta_cents, cash_balance_after_cents,
|
||||
gift_balance_after_cents, operator_id, trace_id, note, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[account.tenantId, account.id, account.userId, account.storeId,
|
||||
input.businessType, input.businessId, input.entryType, cashDelta, giftDelta,
|
||||
nextCash, nextGift, input.operatorId ?? null, input.traceId,
|
||||
(input.note ?? '').slice(0, 512), JSON.stringify(input.metadata ?? {})]
|
||||
);
|
||||
return {
|
||||
accountId: account.id,
|
||||
ledgerId: String(result.insertId),
|
||||
cashBalanceCents: nextCash,
|
||||
giftBalanceCents: nextGift,
|
||||
cashDeltaCents: cashDelta,
|
||||
giftDeltaCents: giftDelta,
|
||||
idempotent: false
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureAccount(
|
||||
connection: PoolConnection,
|
||||
input: WalletMutationInput,
|
||||
lock: boolean
|
||||
) {
|
||||
await connection.execute(
|
||||
`INSERT IGNORE INTO qipai_wallet_accounts
|
||||
(tenant_id, user_id, scope_type, store_id)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
[input.tenantId, input.userId, input.scopeType, input.storeId ?? null]
|
||||
);
|
||||
const [rows] = await connection.execute<WalletAccountRow[]>(
|
||||
`SELECT id, tenant_id AS tenantId, user_id AS userId, scope_type AS scopeType,
|
||||
store_id AS storeId, cash_balance_cents AS cashBalanceCents,
|
||||
gift_balance_cents AS giftBalanceCents, status
|
||||
FROM qipai_wallet_accounts
|
||||
WHERE tenant_id = ? AND user_id = ? AND scope_type = ?
|
||||
AND ${input.storeId ? 'store_id = ?' : 'store_id IS NULL'}
|
||||
${lock ? 'FOR UPDATE' : ''}`,
|
||||
input.storeId
|
||||
? [input.tenantId, input.userId, input.scopeType, input.storeId]
|
||||
: [input.tenantId, input.userId, input.scopeType]
|
||||
);
|
||||
const account = rows[0];
|
||||
if (!account) throw new WalletLedgerError('WALLET_ACCOUNT_NOT_FOUND');
|
||||
if (account.status !== 'ACTIVE') throw new WalletLedgerError('WALLET_ACCOUNT_DISABLED');
|
||||
return {
|
||||
...account,
|
||||
id: String(account.id),
|
||||
tenantId: String(account.tenantId),
|
||||
userId: String(account.userId),
|
||||
storeId: account.storeId === null ? null : String(account.storeId)
|
||||
};
|
||||
}
|
||||
|
||||
private async findDuplicate(
|
||||
connection: PoolConnection,
|
||||
account: WalletAccountRow,
|
||||
input: WalletMutationInput
|
||||
) {
|
||||
const [rows] = await connection.execute<WalletLedgerRow[]>(
|
||||
`SELECT id, cash_delta_cents AS cashDeltaCents,
|
||||
gift_delta_cents AS giftDeltaCents,
|
||||
cash_balance_after_cents AS cashBalanceAfterCents,
|
||||
gift_balance_after_cents AS giftBalanceAfterCents
|
||||
FROM qipai_wallet_ledger_entries
|
||||
WHERE tenant_id = ? AND account_id = ?
|
||||
AND business_type = ? AND business_id = ? LIMIT 1`,
|
||||
[account.tenantId, account.id, input.businessType, input.businessId]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface WalletMutationInput {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
scopeType: WalletScopeType;
|
||||
storeId?: string | null;
|
||||
businessType: string;
|
||||
businessId: string;
|
||||
entryType: WalletEntryType;
|
||||
operatorId?: string | null;
|
||||
traceId: string;
|
||||
note?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function ledgerResponse(
|
||||
account: WalletAccountRow,
|
||||
row: WalletLedgerRow,
|
||||
idempotent: boolean
|
||||
) {
|
||||
return {
|
||||
accountId: String(account.id),
|
||||
ledgerId: String(row.id),
|
||||
cashBalanceCents: Number(row.cashBalanceAfterCents),
|
||||
giftBalanceCents: Number(row.giftBalanceAfterCents),
|
||||
cashDeltaCents: Number(row.cashDeltaCents),
|
||||
giftDeltaCents: Number(row.giftDeltaCents),
|
||||
idempotent
|
||||
};
|
||||
}
|
||||
|
||||
function integer(value: number, code: string) {
|
||||
if (!Number.isSafeInteger(value)) throw new WalletLedgerError(code);
|
||||
return value;
|
||||
}
|
||||
|
||||
function nonNegative(value: number, code: string) {
|
||||
const parsed = integer(value, code);
|
||||
if (parsed < 0) throw new WalletLedgerError(code);
|
||||
return parsed;
|
||||
}
|
||||
@@ -69,6 +69,9 @@ const deviceTopologyVerifySql = read('database/migrations/2026062219_m06b_device
|
||||
const iotMessagesUpSql = read('database/migrations/2026062220_m06c_iot_messages.up.sql');
|
||||
const iotMessagesDownSql = read('database/migrations/2026062220_m06c_iot_messages.down.sql');
|
||||
const iotMessagesVerifySql = read('database/migrations/2026062220_m06c_iot_messages.verify.sql');
|
||||
const walletUpSql = read('database/migrations/2026062421_m07a_wallet_ledger.up.sql');
|
||||
const walletDownSql = read('database/migrations/2026062421_m07a_wallet_ledger.down.sql');
|
||||
const walletVerifySql = read('database/migrations/2026062421_m07a_wallet_ledger.verify.sql');
|
||||
|
||||
const coreTables = [
|
||||
'qipai_schema_migrations',
|
||||
@@ -315,5 +318,17 @@ assert.match(iotMessagesUpSql, /command_id VARCHAR\(13\)/);
|
||||
assert.match(iotMessagesUpSql, /uq_qipai_iot_event_dedup/);
|
||||
assert.match(iotMessagesUpSql, /receive_count INT UNSIGNED/);
|
||||
assert.match(iotMessagesUpSql, /PENDING/);
|
||||
for (const table of [
|
||||
'qipai_wallet_scope_policies', 'qipai_wallet_accounts', 'qipai_wallet_ledger_entries'
|
||||
]) {
|
||||
assert.match(walletUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`));
|
||||
assert.match(walletDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`));
|
||||
assert.match(walletVerifySql, new RegExp(`'${table}'`));
|
||||
}
|
||||
assert.match(walletUpSql, /cash_balance_cents INT NOT NULL DEFAULT 0/);
|
||||
assert.match(walletUpSql, /gift_balance_cents INT NOT NULL DEFAULT 0/);
|
||||
assert.match(walletUpSql, /uq_qipai_wallet_ledger_business/);
|
||||
assert.match(walletUpSql, /cash_delta_cents INT NOT NULL DEFAULT 0/);
|
||||
assert.match(walletUpSql, /gift_delta_cents INT NOT NULL DEFAULT 0/);
|
||||
|
||||
console.log('PASS: M01-B through M06-C migration contracts are present.');
|
||||
console.log('PASS: M01-B through M07-A migration contracts are present.');
|
||||
|
||||
@@ -31,7 +31,8 @@ assert.match(plan.file, /2026062216_m05b_wechat_refunds\.up\.sql/);
|
||||
assert.match(plan.file, /2026062217_m05c_third_party\.up\.sql/);
|
||||
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, /2026062220_m06c_iot_messages\.up\.sql/);
|
||||
assert.match(plan.file, /2026062421_m07a_wallet_ledger\.up\.sql$/);
|
||||
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||
assert.ok(plan.statements.length >= 11);
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { WalletLedgerError, WalletLedgerService } from '../dist/wallets/wallet-ledger-service.js';
|
||||
|
||||
const state = {
|
||||
account: null,
|
||||
ledger: [],
|
||||
nextLedgerId: 100
|
||||
};
|
||||
const pool = {
|
||||
async getConnection() {
|
||||
return connection;
|
||||
}
|
||||
};
|
||||
const connection = {
|
||||
async beginTransaction() {},
|
||||
async commit() {},
|
||||
async rollback() {},
|
||||
release() {},
|
||||
async execute(sql, params) {
|
||||
if (sql.includes('INSERT IGNORE INTO qipai_wallet_accounts')) {
|
||||
if (!state.account) {
|
||||
state.account = {
|
||||
id: '51',
|
||||
tenantId: String(params[0]),
|
||||
userId: String(params[1]),
|
||||
scopeType: params[2],
|
||||
storeId: params[3] === null ? null : String(params[3]),
|
||||
cashBalanceCents: 0,
|
||||
giftBalanceCents: 0,
|
||||
status: 'ACTIVE'
|
||||
};
|
||||
}
|
||||
return [{ affectedRows: 1 }, []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_wallet_accounts')) {
|
||||
return [[state.account], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_wallet_ledger_entries')) {
|
||||
const duplicate = state.ledger.find((item) =>
|
||||
item.businessType === params[2] && item.businessId === params[3]
|
||||
);
|
||||
return [duplicate ? [duplicate] : [], []];
|
||||
}
|
||||
if (sql.includes('UPDATE qipai_wallet_accounts')) {
|
||||
state.account.cashBalanceCents = params[0];
|
||||
state.account.giftBalanceCents = params[1];
|
||||
return [{ affectedRows: 1 }, []];
|
||||
}
|
||||
if (sql.includes('INSERT INTO qipai_wallet_ledger_entries')) {
|
||||
const item = {
|
||||
id: String(state.nextLedgerId++),
|
||||
businessType: params[4],
|
||||
businessId: params[5],
|
||||
cashDeltaCents: params[7],
|
||||
giftDeltaCents: params[8],
|
||||
cashBalanceAfterCents: params[9],
|
||||
giftBalanceAfterCents: params[10]
|
||||
};
|
||||
state.ledger.push(item);
|
||||
return [{ insertId: item.id, affectedRows: 1 }, []];
|
||||
}
|
||||
throw new Error(`Unexpected SQL: ${sql}`);
|
||||
}
|
||||
};
|
||||
|
||||
const service = new WalletLedgerService(pool);
|
||||
const base = {
|
||||
tenantId: '7',
|
||||
userId: '21',
|
||||
scopeType: 'STORE',
|
||||
storeId: '11',
|
||||
operatorId: '22',
|
||||
traceId: 'wallet-test'
|
||||
};
|
||||
|
||||
const credited = await service.credit({
|
||||
...base,
|
||||
businessType: 'RECHARGE',
|
||||
businessId: 'pay-001',
|
||||
entryType: 'RECHARGE',
|
||||
cashDeltaCents: 1000,
|
||||
giftDeltaCents: 300
|
||||
});
|
||||
assert.equal(credited.cashBalanceCents, 1000);
|
||||
assert.equal(credited.giftBalanceCents, 300);
|
||||
|
||||
const duplicate = await service.credit({
|
||||
...base,
|
||||
businessType: 'RECHARGE',
|
||||
businessId: 'pay-001',
|
||||
entryType: 'RECHARGE',
|
||||
cashDeltaCents: 1000,
|
||||
giftDeltaCents: 300
|
||||
});
|
||||
assert.equal(duplicate.idempotent, true);
|
||||
assert.equal(state.ledger.length, 1);
|
||||
|
||||
const debited = await service.debit({
|
||||
...base,
|
||||
businessType: 'ORDER',
|
||||
businessId: 'order-001',
|
||||
entryType: 'CONSUME',
|
||||
amountCents: 500
|
||||
});
|
||||
assert.equal(debited.giftDeltaCents, -300);
|
||||
assert.equal(debited.cashDeltaCents, -200);
|
||||
assert.equal(debited.cashBalanceCents, 800);
|
||||
assert.equal(debited.giftBalanceCents, 0);
|
||||
|
||||
await assert.rejects(
|
||||
() => service.debit({
|
||||
...base,
|
||||
businessType: 'ORDER',
|
||||
businessId: 'order-002',
|
||||
entryType: 'CONSUME',
|
||||
amountCents: 9999
|
||||
}),
|
||||
(error) => error instanceof WalletLedgerError
|
||||
&& error.code === 'WALLET_BALANCE_INSUFFICIENT'
|
||||
);
|
||||
|
||||
const adjusted = await service.adjust({
|
||||
...base,
|
||||
businessType: 'ADMIN_ADJUST',
|
||||
businessId: 'adjust-001',
|
||||
entryType: 'ADJUST',
|
||||
cashDeltaCents: -100,
|
||||
giftDeltaCents: 50,
|
||||
note: 'sanitized adjustment'
|
||||
});
|
||||
assert.equal(adjusted.cashBalanceCents, 700);
|
||||
assert.equal(adjusted.giftBalanceCents, 50);
|
||||
|
||||
console.log('PASS: M07-A wallet ledger credits, gift-first debit, idempotency and adjustments work.');
|
||||
Reference in New Issue
Block a user