Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
4d15281
feat(accounting): add append-only double-entry ledger (monitoring-only)
TaprootFreak Jun 12, 2026
8719a8d
fix(accounting): address review findings
TaprootFreak Jun 12, 2026
3b71653
fix(accounting): address second-wave review findings
TaprootFreak Jun 12, 2026
b603b29
test(accounting): polish review minors
TaprootFreak Jun 12, 2026
6c17d99
test(accounting): harden branch coverage to >=90% across module
TaprootFreak Jun 13, 2026
d03d691
test(accounting): use Type<unknown> instead of Function in relations …
TaprootFreak Jun 13, 2026
e43c5c6
fix(accounting): widen cent columns to bigint and make lazy account c…
TaprootFreak Jul 14, 2026
e400b73
fix(accounting): stop double-booking card inputs across the cutover b…
TaprootFreak Jul 14, 2026
b899605
fix(accounting): repair transit-age query, arm equity-parity alarm, f…
TaprootFreak Jul 14, 2026
b673ec2
test(accounting): align controller guard assertions with variadic Rol…
TaprootFreak Jul 14, 2026
17b5c26
fix(accounting): address review findings (consumer wedges, dex idempo…
TaprootFreak Jul 14, 2026
2fed1fa
fix(accounting): second review pass (forward-scan lookback, reversal …
TaprootFreak Jul 14, 2026
764a2de
fix(accounting): third review pass (bank-funded completion gate, atom…
TaprootFreak Jul 14, 2026
dce6267
fix(accounting): fourth review pass (late-settling coverage, chain re…
TaprootFreak Jul 15, 2026
93f9174
fix(accounting): fifth review pass (owed-straddling cutover boundary,…
TaprootFreak Jul 15, 2026
e0a0367
fix(accounting): sixth review pass (uniform settled-before-cutover gu…
TaprootFreak Jul 15, 2026
fc3b922
fix(accounting): seventh review pass (pinned cutover boundary, checko…
TaprootFreak Jul 15, 2026
5435ebd
fix(accounting): harden checkout fee ladder and pin-resume semantics
TaprootFreak Jul 15, 2026
bf54b99
fix(accounting): eighth review pass (card collateral cutover, mark br…
TaprootFreak Jul 15, 2026
ca4c457
fix(accounting): ninth review pass (route checkout settlement through…
TaprootFreak Jul 15, 2026
525abae
fix(accounting): tenth review pass (paymentLink cutover, unpriced-row…
TaprootFreak Jul 15, 2026
26ccab8
fix(accounting): eleventh review pass (received opening mutually excl…
TaprootFreak Jul 15, 2026
1b35934
fix(accounting): twelfth review pass (per-row opening keys on the pin…
TaprootFreak Jul 15, 2026
5e2f578
fix(accounting): thirteenth review pass (owed opening joins the bound…
TaprootFreak Jul 15, 2026
6e5a56c
fix(accounting): fourteenth review pass (fourth opening joins the bou…
TaprootFreak Jul 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions migration/1781235331368-AddLedgerTables.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
* @typedef {import('typeorm').QueryRunner} QueryRunner
*/

/**
* Creates the append-only double-entry ledger tables (ledger_account / ledger_tx / ledger_leg).
* New tables only — no ALTER/INSERT on existing tables (CoA bootstrap + cutover run as code jobs).
* Integer-cent columns are PostgreSQL `bigint`: int4 caps at ±2'147'483'647 cents (~21.4M CHF) per row,
* which a single large LM transfer or an aggregate cutover opening can exceed → the INSERT would fail and
* stall the source chain permanently. The entity ValueTransformer maps the driver's bigint string back to
* a JS number and fails loud above Number.MAX_SAFE_INTEGER (never silently rounds). The single-row balance
* gate is a CHECK("amountChfSum" = 0) on ledger_tx, and `sourceId` is character varying(64).
*
* @class
* @implements {MigrationInterface}
*/
module.exports = class AddLedgerTables1781235331368 {
name = 'AddLedgerTables1781235331368';

/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
await queryRunner.query(
`CREATE TABLE "ledger_account" ("id" SERIAL NOT NULL, "updated" TIMESTAMP NOT NULL DEFAULT now(), "created" TIMESTAMP NOT NULL DEFAULT now(), "name" character varying(256) NOT NULL, "type" character varying(32) NOT NULL, "currency" character varying(16) NOT NULL, "active" boolean NOT NULL DEFAULT true, "assetId" integer, CONSTRAINT "UQ_b4080ce191f8cc161d447e6f76d" UNIQUE ("name"), CONSTRAINT "PK_34640393ff83dad2b4627d7ae5f" PRIMARY KEY ("id"))`,
);
await queryRunner.query(`CREATE INDEX "IDX_ab36f1dc36f9ec0a1857633190" ON "ledger_account" ("type") `);
await queryRunner.query(`CREATE INDEX "IDX_6793efdea5c47073f6b5d2af34" ON "ledger_account" ("assetId") `);

await queryRunner.query(
`CREATE TABLE "ledger_tx" ("id" SERIAL NOT NULL, "updated" TIMESTAMP NOT NULL DEFAULT now(), "created" TIMESTAMP NOT NULL DEFAULT now(), "bookingDate" TIMESTAMP NOT NULL, "valueDate" TIMESTAMP NOT NULL, "description" character varying(512), "sourceType" character varying(64) NOT NULL, "sourceId" character varying(64) NOT NULL, "seq" integer NOT NULL DEFAULT 0, "amountChfSum" bigint NOT NULL DEFAULT 0, "reversalOfId" integer, CONSTRAINT "UQ_86a66bea626f9a32e1d26a7b136" UNIQUE ("sourceType", "sourceId", "seq"), CONSTRAINT "CHK_357a2fc90abae910ef69d3822e" CHECK ("amountChfSum" = 0), CONSTRAINT "PK_2a5f197e0dbaa656731fee263d8" PRIMARY KEY ("id"))`,
);
await queryRunner.query(`CREATE INDEX "IDX_e27c60c70525be037830f579b4" ON "ledger_tx" ("bookingDate") `);
await queryRunner.query(`CREATE INDEX "IDX_42c53a01650aaa5e88bb9a3470" ON "ledger_tx" ("reversalOfId") `);

await queryRunner.query(
`CREATE TABLE "ledger_leg" ("id" SERIAL NOT NULL, "updated" TIMESTAMP NOT NULL DEFAULT now(), "created" TIMESTAMP NOT NULL DEFAULT now(), "amount" double precision NOT NULL, "priceChf" double precision, "amountChf" double precision, "amountChfCents" bigint NOT NULL DEFAULT 0, "needsMark" boolean NOT NULL DEFAULT false, "txId" integer NOT NULL, "accountId" integer NOT NULL, CONSTRAINT "PK_6566e1943c692f0caad604015d0" PRIMARY KEY ("id"))`,
);
await queryRunner.query(`CREATE INDEX "IDX_7c939d7bfcc9cc3f71bb3eddd9" ON "ledger_leg" ("txId") `);
await queryRunner.query(`CREATE INDEX "IDX_b8d0b654d708ff1255a49b7e6e" ON "ledger_leg" ("accountId") `);
await queryRunner.query(`CREATE INDEX "IDX_91e1f2192fbd0e1681e461eadb" ON "ledger_leg" ("needsMark") `);

await queryRunner.query(
`ALTER TABLE "ledger_account" ADD CONSTRAINT "FK_6793efdea5c47073f6b5d2af349" FOREIGN KEY ("assetId") REFERENCES "asset"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "ledger_tx" ADD CONSTRAINT "FK_42c53a01650aaa5e88bb9a34700" FOREIGN KEY ("reversalOfId") REFERENCES "ledger_tx"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "ledger_leg" ADD CONSTRAINT "FK_7c939d7bfcc9cc3f71bb3eddd90" FOREIGN KEY ("txId") REFERENCES "ledger_tx"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "ledger_leg" ADD CONSTRAINT "FK_b8d0b654d708ff1255a49b7e6e5" FOREIGN KEY ("accountId") REFERENCES "ledger_account"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
}

/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "ledger_leg" DROP CONSTRAINT "FK_b8d0b654d708ff1255a49b7e6e5"`);
await queryRunner.query(`ALTER TABLE "ledger_leg" DROP CONSTRAINT "FK_7c939d7bfcc9cc3f71bb3eddd90"`);
await queryRunner.query(`ALTER TABLE "ledger_tx" DROP CONSTRAINT "FK_42c53a01650aaa5e88bb9a34700"`);
await queryRunner.query(`ALTER TABLE "ledger_account" DROP CONSTRAINT "FK_6793efdea5c47073f6b5d2af349"`);

await queryRunner.query(`DROP INDEX "public"."IDX_91e1f2192fbd0e1681e461eadb"`);
await queryRunner.query(`DROP INDEX "public"."IDX_b8d0b654d708ff1255a49b7e6e"`);
await queryRunner.query(`DROP INDEX "public"."IDX_7c939d7bfcc9cc3f71bb3eddd9"`);
await queryRunner.query(`DROP TABLE "ledger_leg"`);

await queryRunner.query(`DROP INDEX "public"."IDX_42c53a01650aaa5e88bb9a3470"`);
await queryRunner.query(`DROP INDEX "public"."IDX_e27c60c70525be037830f579b4"`);
await queryRunner.query(`DROP TABLE "ledger_tx"`);

await queryRunner.query(`DROP INDEX "public"."IDX_6793efdea5c47073f6b5d2af34"`);
await queryRunner.query(`DROP INDEX "public"."IDX_ab36f1dc36f9ec0a1857633190"`);
await queryRunner.query(`DROP TABLE "ledger_account"`);
}
};
137 changes: 137 additions & 0 deletions scripts/ledger-isolation-gate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
#!/usr/bin/env node
/* eslint-disable */

// Node implementation of the ledger isolation gate (§4.10 R2 / §10.3) — the bundled fallback used by
// ledger-isolation-gate.sh on hosts WITHOUT a PCRE2-capable grep/rg (e.g. macOS BSD grep). It applies the
// IDENTICAL 4-block forbidden pattern as the shell gate plus the `// ledger-allowlist` post-filter (Minor R4-1),
// scanning the module SOURCE only (*.ts, excluding *.spec.ts / __tests__ / __mocks__). JavaScript's regex engine
// supports the (?!ledger) negative-lookahead natively → never a silent no-op. Prints `file:line:match` per
// offending line and exits 1 when any remains.
//
// Usage: node scripts/ledger-isolation-gate.js [TARGET_DIR]

const fs = require('fs');
const path = require('path');

const TARGET_DIR = process.argv[2] || 'src/subdomains/core/accounting';

// §4.10 / §10.3 Minor R4-1: the forbidden pattern is split into two classes so the `// ledger-allowlist` post-filter
// is SCOPED, not blanket. ALLOWLISTABLE = the DB-write blocks (EntityManager / repo / getRepository / queryRunner /
// QueryBuilder-DSL write paths) — the ONLY constructs a ledger-own write (`manager.save(LedgerTx,…)`) legitimately
// needs to clear. NON-ALLOWLISTABLE = pricing/HTTP (block 1), external feed-read (block 2), logService/settingService
// operative side-effects (block 3) and lifecycle/strategy calls (block 4): a `// ledger-allowlist` comment must NEVER
// silence one of these — there is no sanctioned reason for the ledger module to price, hit a feed, mutate the
// FinancialDataLog/setting tables or drive a source lifecycle, so allowing the marker to clear them would re-open the
// exact isolation hole the gate exists to close. Both classes are kept char-for-char equivalent to the shell gate.
const NON_ALLOWLISTABLE_PATTERN = new RegExp(
[
// Block 1 (pricing / HTTP read)
'pricingService|PricingService|getPrice\\(|getPriceAt|priceProvider|CoinGecko|HttpService',
// Block 2 (external feed-read / balance integration)
'\\brefreshBalances\\(|\\brefreshBankBalance|\\bhasPendingOrders|integration\\.getBalances|integration\\.hasPendingOrders|BankAdapter|balanceIntegrationFactory|LiquidityBalanceIntegrationFactory',
// Block 3 (FinancialDataLog / setting operative side-effects — a service write, not a sanctioned ledger DB write)
'\\blogService\\.(create|update)\\(|\\bsettingService\\.(setObj|updateProcess|addIpToBlacklist|deleteIpFromBlacklist)\\(',
// Block 4 (source lifecycle / strategy calls)
'\\.complete\\(|checkOrderCompletion|syncExchanges|doPayout|checkPayoutCompletionData|triggerWebhook|calculateSpreadFee',
].join('|'),
);

const ALLOWLISTABLE_WRITE_PATTERN = new RegExp(
[
// Block 4a/4b (non-ledger repository write)
'balanceRepo\\.(update|save|insert|delete|remove|upsert|softDelete|softRemove|recover|increment|decrement)\\(|\\b(?!ledger)\\w*Repo(sitory)?\\.(update|save|insert|delete|remove|upsert|softDelete|softRemove|recover|increment|decrement)\\(',
// Block 6 (EntityManager + raw-SQL write paths, Major design-accounting): `\w*[Mm]anager.<write>(` catches the
// idiomatic injected EntityManager regardless of the binding identifier — `manager.save`, `entityManager.save`,
// `dataSource.manager.save` (the bare `\bmanager.` missed `entityManager.` because there is no word boundary
// inside the identifier). `dataSource.query(` AND `queryRunner.query(` join `\w*[Mm]anager.query(` so a raw
// `UPDATE/INSERT` SQL write via ANY of the three escape hatches is flagged (`queryRunner` is the idiomatic
// TypeORM write path — `dataSource.createQueryRunner().query(...)`, exactly the migration pattern — and carries
// no `manager`/`dataSource` token, so it was previously unflagged). The allowlisted ledger-own
// `manager.save(LedgerTx,…) // ledger-allowlist` is cleared by the post-filter.
'\\b\\w*[Mm]anager\\.(save|insert|update|delete|remove|upsert|softDelete|softRemove|recover|increment|decrement|query)\\(|\\bdataSource\\.query\\(|\\bqueryRunner\\.query\\(',
// Block 5 (§10.2 robustness gap): getRepository(X).<write>(…) escapes Block 4a (token before `.save` is
// getRepository(...), not a *Repo identifier); a source-service write with a generic name (bankTxService.update,
// assetService.updateAsset) is not named in Blocks 2/3. The legit ledger READ getRepository(LedgerTx).
// createQueryBuilder() is not matched (no write verb); sanctioned service calls (set/sendMail/get*/find*/…) too.
'getRepository\\([^)]*\\)\\.(save|insert|update|delete|remove|upsert|softDelete|softRemove|recover|increment|decrement)\\(|\\b\\w+Service\\.(save|insert|update|delete|remove|upsert)\\w*\\(',
// Block 7 (QueryBuilder write path, Major design-accounting): a write via the QueryBuilder DSL
// `xRepo.createQueryBuilder().update(BankTx).set(...).execute()` or
// `dataSource.createQueryBuilder().insert().into(BankTx).execute()` escapes Block 4a/5 (the verb is .update/.insert
// ON the builder, not directly after `Repo.`/`getRepository(...)`). Only the WRITE verbs are flagged — a read QB
// chain (.select/.where/.getRawOne, e.g. the ledger nextSeq / reconciliation queries) is NOT matched.
'\\.createQueryBuilder\\([^)]*\\)\\.(update|insert|delete|softDelete)\\(',
].join('|'),
);

const ALLOWLIST_MARKER = 'ledger-allowlist';

function isTestPath(p) {
return p.endsWith('.spec.ts') || p.includes('/__tests__/') || p.includes('/__mocks__/');
}

function collectTsFiles(dir, out) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return; // missing dir → nothing to scan (the caller treats "no matches" as clean)
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === '__tests__' || entry.name === '__mocks__') continue;
collectTsFiles(full, out);
} else if (entry.name.endsWith('.ts') && !isTestPath(full)) {
out.push(full);
}
}
}

const matches = [];
const stat = (() => {
try {
return fs.statSync(TARGET_DIR);
} catch {
return undefined;
}
})();

const files = [];
if (stat?.isDirectory()) {
collectTsFiles(TARGET_DIR, files);
} else if (stat?.isFile() && TARGET_DIR.endsWith('.ts') && !isTestPath(TARGET_DIR)) {
files.push(TARGET_DIR);
}

// A3 fail-closed: an EMPTY scan set (wrong path / renamed tree / bad exclude) must NOT report "clean" — that is a silent
// false pass. Abort with a distinct exit 2 (stderr) so CI never green-lights an un-scanned module. Mirrors the shell
// gate's pre-scan file-count guard (only reachable standalone; the wrapper already guards before delegating here).
if (files.length === 0) {
process.stderr.write(
`ledger-isolation-gate: NO .ts source files scanned in ${TARGET_DIR} (wrong path or empty tree) — failing closed\n`,
);
process.exit(2);
}

for (const file of files) {
const lines = fs.readFileSync(file, 'utf8').split('\n');
lines.forEach((line, i) => {
// A non-allowlistable construct (pricing/feed-read/log/setting/lifecycle) ALWAYS flags, marker or not. An
// allowlistable DB-write only flags when it does NOT carry the `// ledger-allowlist` marker — so the post-filter
// is scoped to the write blocks and can never silently clear a pricing/feed/lifecycle line (§10.3 Minor R4-1).
const flagged =
NON_ALLOWLISTABLE_PATTERN.test(line) ||
(ALLOWLISTABLE_WRITE_PATTERN.test(line) && !line.includes(ALLOWLIST_MARKER));
if (flagged) {
matches.push(`${file}:${i + 1}:${line.trim()}`);
}
});
}

if (matches.length) {
// stdout so the shell wrapper captures it identically to the grep path; the wrapper prints it to stderr + exits 1
console.log(matches.join('\n'));
process.exit(1);
}

process.exit(0);
Loading