Skip to content
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"`);
}
};
33 changes: 33 additions & 0 deletions migration/1784117860216-AddBankTxTypeCreatedIndex.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
* @typedef {import('typeorm').QueryRunner} QueryRunner
*/

/**
* Backs the monthly bank-cost aggregation (BankTxService.getBankTxFee), which runs every minute from
* the financial-log cron and filters bank_tx by (type = BANK_ACCOUNT_FEE, created >= from). Without a
* matching composite index the query degrades to a full scan over a table that grows continuously.
*
* bank_tx carries no primary key in production (MSSQL->Postgres cutover drift), so this migration adds
* ONLY a secondary index: no foreign key, no column alter, nothing that requires a unique constraint.
*
* @class
* @implements {MigrationInterface}
*/
module.exports = class AddBankTxTypeCreatedIndex1784117860216 {
name = 'AddBankTxTypeCreatedIndex1784117860216';

/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
await queryRunner.query(`CREATE INDEX "IDX_bank_tx_type_created" ON "bank_tx" ("type", "created")`);
}

/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_bank_tx_type_created"`);
}
};
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
Loading