feat(M07-A): 建立双余额账本
This commit is contained in:
@@ -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