diff --git a/migration/1781235331368-AddLedgerTables.js b/migration/1781235331368-AddLedgerTables.js new file mode 100644 index 0000000000..0f4bb13ef0 --- /dev/null +++ b/migration/1781235331368-AddLedgerTables.js @@ -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"`); + } +}; diff --git a/migration/1784117860216-AddBankTxTypeCreatedIndex.js b/migration/1784117860216-AddBankTxTypeCreatedIndex.js new file mode 100644 index 0000000000..a2d61642f8 --- /dev/null +++ b/migration/1784117860216-AddBankTxTypeCreatedIndex.js @@ -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"`); + } +}; diff --git a/scripts/ledger-isolation-gate.js b/scripts/ledger-isolation-gate.js new file mode 100644 index 0000000000..4922155c84 --- /dev/null +++ b/scripts/ledger-isolation-gate.js @@ -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.(` 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).(…) 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); diff --git a/scripts/ledger-isolation-gate.sh b/scripts/ledger-isolation-gate.sh new file mode 100755 index 0000000000..f143a953b8 --- /dev/null +++ b/scripts/ledger-isolation-gate.sh @@ -0,0 +1,125 @@ +#!/bin/bash + +# Ledger isolation gate (§4.10 R2 / §10.3) — the CI grep-gate that enforces the Hard Constraints of the +# accounting module statically: no pricing/HTTP, no feed-read/external-balance call, no lifecycle/strategy call, +# and no write on any non-ledger_* table (repository OR EntityManager path). +# +# It is a WRAPPER, not the raw pattern (Minor R4-1): the raw write-block pattern flags EVERY `manager`-write line +# incl. the allowlisted ledger-own writes; the post-filter removes the lines carrying the exact `// ledger-allowlist` +# marker (the only sanctioned manager-writes, into ledger_*). The gate prints every offending `file:line:match` and +# exits non-zero when ANY offending line remains. +# +# SCOPED post-filter (§10.3 Minor R4-1): the pattern is split into two classes and the gate runs TWO passes so the +# `// ledger-allowlist` marker is honoured ONLY for the DB-write blocks. NON_ALLOWLISTABLE_PATTERN (pricing/HTTP, +# external feed-read, logService/settingService side-effects, lifecycle/strategy) is matched WITHOUT the post-filter +# — a `// ledger-allowlist` comment can never silence one of these. WRITE_PATTERN (EntityManager / repo / +# getRepository / queryRunner / QueryBuilder-DSL writes) is matched WITH the `| grep -v 'ledger-allowlist'` +# post-filter — only ledger-own writes into ledger_* legitimately clear it. The two passes' matches are merged. +# +# Engine (§10.3 Minor R3-2): the negative-lookahead `(?!ledger)` is PCRE2-only → grep -P / rg --pcre2. The gate +# picks the first available PCRE2 engine (rg --pcre2, then grep -P / ggrep -P) and ONLY if none is available falls +# back to the bundled Node implementation (scripts/ledger-isolation-gate.js, identical pattern + post-filter) — so +# the gate ALWAYS runs (never a silent no-op), regardless of host grep flavour (macOS BSD grep has no -P). +# +# Usage: +# ./scripts/ledger-isolation-gate.sh [TARGET_DIR] +# TARGET_DIR defaults to src/subdomains/core/accounting (the §10.1 self-test passes a fixtures dir). Only the +# module SOURCE is scanned — *.spec.ts / __tests__ / __mocks__ are excluded (tests legitimately reference mocked +# services and the in-memory ledger uses manager.save). + +set -u + +TARGET_DIR="${1:-src/subdomains/core/accounting}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# A3 fail-closed: if the scan set is EMPTY (wrong path / renamed tree / bad exclude), a grep gate finds no matches and +# would report "clean" (exit 0) — a silent false pass. Count the source files the gate will actually scan (same +# includes/excludes as the grep passes below) and abort with a distinct exit 2 when none exist, so CI never green-lights +# an un-scanned module. +TS_FILE_COUNT="$(find "$TARGET_DIR" -type f -name '*.ts' ! -name '*.spec.ts' ! -path '*/__tests__/*' ! -path '*/__mocks__/*' 2>/dev/null | wc -l | tr -d ' ')" +if [ "$TS_FILE_COUNT" -eq 0 ]; then + echo "ledger-isolation-gate: NO .ts source files scanned in $TARGET_DIR (wrong path or empty tree) — failing closed" >&2 + exit 2 +fi + +# NON_ALLOWLISTABLE_PATTERN (Blocks 1-4): pricing/HTTP, external feed-read, logService/settingService side-effects and +# lifecycle/strategy calls. The `// ledger-allowlist` post-filter is NOT applied to this class — a marker 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). \b anchors keep the tokens injection-name-independent. +NON_ALLOWLISTABLE_PATTERN='pricingService|PricingService|getPrice\(|getPriceAt|priceProvider|CoinGecko|HttpService' +NON_ALLOWLISTABLE_PATTERN+='|\brefreshBalances\(|\brefreshBankBalance|\bhasPendingOrders|integration\.getBalances|integration\.hasPendingOrders|BankAdapter|balanceIntegrationFactory|LiquidityBalanceIntegrationFactory' +NON_ALLOWLISTABLE_PATTERN+='|\blogService\.(create|update)\(|\bsettingService\.(setObj|updateProcess|addIpToBlacklist|deleteIpFromBlacklist)\(' +NON_ALLOWLISTABLE_PATTERN+='|\.complete\(|checkOrderCompletion|syncExchanges|doPayout|checkPayoutCompletionData|triggerWebhook|calculateSpreadFee' + +# WRITE_PATTERN (Blocks 4a/4b, 5, 6, 7): the EntityManager / repository / getRepository / queryRunner / QueryBuilder-DSL +# DB-write paths. The `| grep -v 'ledger-allowlist'` post-filter IS applied to this class — only ledger-own writes into +# ledger_* (e.g. `manager.save(LedgerTx,…) // ledger-allowlist`) legitimately clear it. +WRITE_PATTERN='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 — robustness gap §10.2): `\w*[Mm]anager.(` catches the +# idiomatic injected EntityManager regardless of binding identifier — manager.save, entityManager.save, +# dataSource.manager.save (the bare `\bmanager.` missed `entityManager.` — 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. +WRITE_PATTERN+='|\b\w*[Mm]anager\.(save|insert|update|delete|remove|upsert|softDelete|softRemove|recover|increment|decrement|query)\(|\bdataSource\.query\(|\bqueryRunner\.query\(' +# Block 5 (getRepository-write + source-Service-write — robustness gap §10.2): a write via dataSource.getRepository(X) +# (e.g. getRepository(BankTx).save(...)) escapes Block 4a (the token before `.save` is `getRepository(...)`, not a +# `*Repo`/`*Repository` identifier); a write via an injected source-domain service method with a generic write name +# (e.g. bankTxService.update(...), assetService.updateAsset(...)) is not named in Blocks 2/3. Both are flagged here. +# Sanctioned service calls (settingService.set, notificationService.sendMail, logService.get*, *Service.find*/get*/ +# bookTx/preload/…) do NOT match — their method names are not save/insert/update/delete/remove/upsert*. The legit +# ledger READ `getRepository(LedgerTx).createQueryBuilder()` (booking-service nextSeq) is NOT matched (no write verb). +WRITE_PATTERN+='|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 — robustness gap §10.2): a write via the QueryBuilder DSL +# `xRepo.createQueryBuilder().update(BankTx).set(...).execute()` or `dataSource.createQueryBuilder().insert().into(BankTx)` +# 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. +WRITE_PATTERN+='|\.createQueryBuilder\([^)]*\)\.(update|insert|delete|softDelete)\(' + +# excludes test/mock files: the gate scans production source only (tests reference mocked services intentionally) +EXCLUDES=(--include='*.ts' --exclude='*.spec.ts' --exclude-dir='__tests__' --exclude-dir='__mocks__') + +# runs both passes for a given grep binary and merges their matches: the non-allowlistable class WITHOUT the +# post-filter (always flags) + the write class WITH the `| grep -v 'ledger-allowlist'` post-filter (marker clears it). +run_grep() { # $1 = grep binary + "$1" -rPn "${EXCLUDES[@]}" "$NON_ALLOWLISTABLE_PATTERN" "$TARGET_DIR" 2>/dev/null + "$1" -rPn "${EXCLUDES[@]}" "$WRITE_PATTERN" "$TARGET_DIR" 2>/dev/null | grep -v 'ledger-allowlist' +} + +# same two-pass merge for ripgrep +run_rg() { + rg --pcre2 -n -g '*.ts' -g '!*.spec.ts' -g '!__tests__' -g '!__mocks__' "$NON_ALLOWLISTABLE_PATTERN" "$TARGET_DIR" 2>/dev/null + rg --pcre2 -n -g '*.ts' -g '!*.spec.ts' -g '!__tests__' -g '!__mocks__' "$WRITE_PATTERN" "$TARGET_DIR" 2>/dev/null | grep -v 'ledger-allowlist' +} + +pcre2_grep() { + for g in grep ggrep; do + if command -v "$g" >/dev/null 2>&1 && printf 'x' | "$g" -P 'x' >/dev/null 2>&1; then + echo "$g" + return 0 + fi + done + return 1 +} + +MATCHES="" +if command -v rg >/dev/null 2>&1 && rg --pcre2 --version >/dev/null 2>&1; then + MATCHES="$(run_rg)" +elif GREP_BIN="$(pcre2_grep)"; then + MATCHES="$(run_grep "$GREP_BIN")" +else + # no PCRE2 grep/rg on this host (e.g. macOS BSD grep) → bundled Node fallback (identical split pattern + post-filter) + MATCHES="$(node "$SCRIPT_DIR/ledger-isolation-gate.js" "$TARGET_DIR")" +fi + +if [ -n "$MATCHES" ]; then + echo "ledger-isolation-gate: FORBIDDEN constructs found in $TARGET_DIR (§4.10):" >&2 + echo "$MATCHES" >&2 + exit 1 +fi + +echo "ledger-isolation-gate: clean ($TARGET_DIR)" +exit 0 diff --git a/src/config/config.ts b/src/config/config.ts index add33fa6c7..d3fa6d4559 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -116,6 +116,16 @@ export class Configuration { usePipelinePriceForAllAssets: process.env.USE_PIPELINE_PRICE_FOR_ALL_ASSETS === 'true', }; + ledger = { + reconciliationToleranceChf: +(process.env.LEDGER_RECONCILIATION_TOLERANCE_CHF ?? 1), + transitAlarmThresholdDays: +(process.env.LEDGER_TRANSIT_ALARM_THRESHOLD_DAYS ?? 3), + backfillBatchSize: +(process.env.LEDGER_BACKFILL_BATCH_SIZE ?? 100), + roundingToleranceCents: +(process.env.LEDGER_ROUNDING_TOLERANCE_CENTS ?? 2), + markPreloadDailySampleThresholdDays: +(process.env.LEDGER_MARK_PRELOAD_DAILY_SAMPLE_THRESHOLD_DAYS ?? 2), // §5.2 bounded preload + markPreloadMaxRows: +(process.env.LEDGER_MARK_PRELOAD_MAX_ROWS ?? 5000), // §5.2 hard row-cap backstop + unroutedDepositAlarmDays: +(process.env.LEDGER_UNROUTED_DEPOSIT_ALARM_DAYS ?? 3), // §7.5 age-alarm + }; + defaultVolumeDecimal = 2; defaultPercentageDecimal = 2; diff --git a/src/shared/services/process.service.ts b/src/shared/services/process.service.ts index ebf2e9e795..bcd68bba68 100644 --- a/src/shared/services/process.service.ts +++ b/src/shared/services/process.service.ts @@ -98,6 +98,19 @@ export enum Process { GS_DEBUG = 'GsDebug', GS_DB = 'GsDb', TRANSACTION_AML_CHECK_LOG = 'TransactionAmlCheckLog', + // ledger booking consumers + jobs (§11.1; one own flag per @DfxCron method = kill-switch, Hard Constraint #5) + LEDGER_BOOKING_BANK_TX = 'LedgerBookingBankTx', + LEDGER_BOOKING_EXCHANGE_TX = 'LedgerBookingExchangeTx', + LEDGER_BOOKING_CRYPTO_INPUT = 'LedgerBookingCryptoInput', + LEDGER_BOOKING_PAYOUT = 'LedgerBookingPayout', + LEDGER_BOOKING_BUY_CRYPTO = 'LedgerBookingBuyCrypto', + LEDGER_BOOKING_BUY_FIAT = 'LedgerBookingBuyFiat', + LEDGER_BOOKING_LIQUIDITY_MANAGEMENT = 'LedgerBookingLiquidityManagement', + LEDGER_BOOKING_TRADING_ORDER = 'LedgerBookingTradingOrder', + LEDGER_BOOKING_LIQUIDITY_ORDER = 'LedgerBookingLiquidityOrder', + LEDGER_RECONCILIATION = 'LedgerReconciliation', + LEDGER_MARK_TO_MARKET = 'LedgerMarkToMarket', + LEDGER_CUTOVER = 'LedgerCutover', } const safetyProcesses: Process[] = [ diff --git a/src/subdomains/core/accounting/__tests__/accounting.module.spec.ts b/src/subdomains/core/accounting/__tests__/accounting.module.spec.ts new file mode 100644 index 0000000000..f79ae78a7b --- /dev/null +++ b/src/subdomains/core/accounting/__tests__/accounting.module.spec.ts @@ -0,0 +1,108 @@ +import { createMock } from '@golevelup/ts-jest'; +import { MODULE_METADATA } from '@nestjs/common/constants'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AccountingModule } from '../accounting.module'; +import { LedgerController } from '../controllers/ledger.controller'; +import { LedgerAccountRepository } from '../repositories/ledger-account.repository'; +import { LedgerLegRepository } from '../repositories/ledger-leg.repository'; +import { LedgerTxRepository } from '../repositories/ledger-tx.repository'; +import { BankTxConsumer } from '../services/consumers/bank-tx.consumer'; +import { BuyCryptoConsumer } from '../services/consumers/buy-crypto.consumer'; +import { BuyFiatConsumer } from '../services/consumers/buy-fiat.consumer'; +import { CryptoInputConsumer } from '../services/consumers/crypto-input.consumer'; +import { ExchangeTxConsumer } from '../services/consumers/exchange-tx.consumer'; +import { LiquidityMgmtConsumer } from '../services/consumers/liquidity-mgmt.consumer'; +import { LiquidityOrderDexConsumer } from '../services/consumers/liquidity-order-dex.consumer'; +import { PayoutOrderConsumer } from '../services/consumers/payout-order.consumer'; +import { TradingOrderConsumer } from '../services/consumers/trading-order.consumer'; +import { LedgerAccountService } from '../services/ledger-account.service'; +import { LedgerBookingJobService } from '../services/ledger-booking-job.service'; +import { LedgerBookingService } from '../services/ledger-booking.service'; +import { LedgerBootstrapService } from '../services/ledger-bootstrap.service'; +import { LedgerCutoverService } from '../services/ledger-cutover.service'; +import { LedgerMarkService } from '../services/ledger-mark.service'; +import { LedgerMarkToMarketService } from '../services/ledger-mark-to-market.service'; +import { LedgerQueryService } from '../services/ledger-query.service'; +import { LedgerReconciliationService } from '../services/ledger-reconciliation.service'; + +// Compile/wiring test: instantiate the module's own controllers + providers with every external dependency +// (repositories, SharedModule/Log/Liquidity/Notification services) mocked, then assert each core provider +// actually resolves. This catches a constructor that can no longer be satisfied or a provider dropped from +// the @Module() metadata, without booting the real TypeORM DataSource or the transitive feature modules. +describe('AccountingModule', () => { + let testingModule: TestingModule; + + const controllers: any[] = Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, AccountingModule); + const providers: any[] = Reflect.getMetadata(MODULE_METADATA.PROVIDERS, AccountingModule); + + beforeAll(async () => { + testingModule = await Test.createTestingModule({ controllers, providers }) + .useMocker(() => createMock()) + .compile(); + }); + + afterAll(async () => { + await testingModule?.close(); + }); + + it('declares the LedgerController', () => { + expect(controllers).toEqual([LedgerController]); + }); + + it('resolves the LedgerController', () => { + expect(testingModule.get(LedgerController)).toBeInstanceOf(LedgerController); + }); + + const coreServices: [string, any][] = [ + ['LedgerAccountService', LedgerAccountService], + ['LedgerBootstrapService', LedgerBootstrapService], + ['LedgerBookingService', LedgerBookingService], + ['LedgerMarkService', LedgerMarkService], + ['LedgerBookingJobService', LedgerBookingJobService], + ['LedgerCutoverService', LedgerCutoverService], + ['LedgerMarkToMarketService', LedgerMarkToMarketService], + ['LedgerReconciliationService', LedgerReconciliationService], + ['LedgerQueryService', LedgerQueryService], + ]; + + it.each(coreServices)('resolves the core provider %s', (_name, token) => { + expect(testingModule.get(token)).toBeInstanceOf(token); + }); + + const consumers: [string, any][] = [ + ['BankTxConsumer', BankTxConsumer], + ['ExchangeTxConsumer', ExchangeTxConsumer], + ['CryptoInputConsumer', CryptoInputConsumer], + ['PayoutOrderConsumer', PayoutOrderConsumer], + ['BuyCryptoConsumer', BuyCryptoConsumer], + ['BuyFiatConsumer', BuyFiatConsumer], + ['LiquidityMgmtConsumer', LiquidityMgmtConsumer], + ['LiquidityOrderDexConsumer', LiquidityOrderDexConsumer], + ['TradingOrderConsumer', TradingOrderConsumer], + ]; + + it.each(consumers)('resolves the source consumer %s', (_name, token) => { + expect(testingModule.get(token)).toBeInstanceOf(token); + }); + + const repositories: [string, any][] = [ + ['LedgerAccountRepository', LedgerAccountRepository], + ['LedgerTxRepository', LedgerTxRepository], + ['LedgerLegRepository', LedgerLegRepository], + ]; + + it.each(repositories)('resolves the repository %s', (_name, token) => { + expect(testingModule.get(token)).toBeInstanceOf(token); + }); + + it('registers every metadata provider as resolvable', () => { + for (const token of providers) { + expect(testingModule.get(token)).toBeDefined(); + } + }); + + it('exports nothing', () => { + const exports = Reflect.getMetadata(MODULE_METADATA.EXPORTS, AccountingModule); + expect(exports).toEqual([]); + }); +}); diff --git a/src/subdomains/core/accounting/accounting.module.ts b/src/subdomains/core/accounting/accounting.module.ts new file mode 100644 index 0000000000..df6f6ab4e8 --- /dev/null +++ b/src/subdomains/core/accounting/accounting.module.ts @@ -0,0 +1,101 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ExchangeTx } from 'src/integration/exchange/entities/exchange-tx.entity'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { SharedModule } from 'src/shared/shared.module'; +import { BuyCrypto } from 'src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity'; +import { LiquidityManagementOrder } from 'src/subdomains/core/liquidity-management/entities/liquidity-management-order.entity'; +import { LiquidityManagementModule } from 'src/subdomains/core/liquidity-management/liquidity-management.module'; +import { ReferralModule } from 'src/subdomains/core/referral/referral.module'; +import { RefReward } from 'src/subdomains/core/referral/reward/ref-reward.entity'; +import { BuyFiat } from 'src/subdomains/core/sell-crypto/process/buy-fiat.entity'; +import { TradingOrder } from 'src/subdomains/core/trading/entities/trading-order.entity'; +import { BankTx } from 'src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity'; +import { BankTxRepeat } from 'src/subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.entity'; +import { BankTxReturn } from 'src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.entity'; +import { Bank } from 'src/subdomains/supporting/bank/bank/bank.entity'; +import { LiquidityOrder } from 'src/subdomains/supporting/dex/entities/liquidity-order.entity'; +import { LogModule } from 'src/subdomains/supporting/log/log.module'; +import { NotificationModule } from 'src/subdomains/supporting/notification/notification.module'; +import { CryptoInput } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; +import { PayoutOrder } from 'src/subdomains/supporting/payout/entities/payout-order.entity'; +import { LedgerController } from './controllers/ledger.controller'; +import { LedgerAccount } from './entities/ledger-account.entity'; +import { LedgerLeg } from './entities/ledger-leg.entity'; +import { LedgerTx } from './entities/ledger-tx.entity'; +import { LedgerAccountRepository } from './repositories/ledger-account.repository'; +import { LedgerLegRepository } from './repositories/ledger-leg.repository'; +import { LedgerTxRepository } from './repositories/ledger-tx.repository'; +import { BankTxConsumer } from './services/consumers/bank-tx.consumer'; +import { BuyCryptoConsumer } from './services/consumers/buy-crypto.consumer'; +import { BuyFiatConsumer } from './services/consumers/buy-fiat.consumer'; +import { CryptoInputConsumer } from './services/consumers/crypto-input.consumer'; +import { ExchangeTxConsumer } from './services/consumers/exchange-tx.consumer'; +import { LiquidityMgmtConsumer } from './services/consumers/liquidity-mgmt.consumer'; +import { LiquidityOrderDexConsumer } from './services/consumers/liquidity-order-dex.consumer'; +import { PayoutOrderConsumer } from './services/consumers/payout-order.consumer'; +import { TradingOrderConsumer } from './services/consumers/trading-order.consumer'; +import { LedgerAccountService } from './services/ledger-account.service'; +import { LedgerBookingJobService } from './services/ledger-booking-job.service'; +import { LedgerBookingService } from './services/ledger-booking.service'; +import { LedgerBootstrapService } from './services/ledger-bootstrap.service'; +import { LedgerCutoverService } from './services/ledger-cutover.service'; +import { LedgerMarkService } from './services/ledger-mark.service'; +import { LedgerMarkToMarketService } from './services/ledger-mark-to-market.service'; +import { LedgerQueryService } from './services/ledger-query.service'; +import { LedgerReconciliationService } from './services/ledger-reconciliation.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + LedgerAccount, + LedgerTx, + LedgerLeg, // own (write targets) + BankTx, + ExchangeTx, + PayoutOrder, + CryptoInput, // source entities (read-only) + BuyCrypto, + BuyFiat, + LiquidityManagementOrder, + TradingOrder, + LiquidityOrder, // dex (§4.8a) + RefReward, + Asset, + Bank, // accountIban→bank.asset lookup for the BankTx consumer (§4.2/§1.6) + BankTxReturn, + BankTxRepeat, // chargeback → original BANK_TX_RETURN/REPEAT opening-CHF anchor (§4.2 B-15, read-only) + ]), + SharedModule, // AssetService (CoA §3.2), DataSource + LogModule, // LogService.getFinancialLogs (mark preload §5.2) + LiquidityManagementModule, // LiquidityManagementBalanceService.getBalances (feed read §7.0) + NotificationModule, // NotificationService.sendMail (ledger alarms §7.3/§7.4/§7.5, Major R12-1) + ReferralModule, // RefRewardService.getOpenRefCreditLiability (equity-parity RefCredit baseline §7.6) + ], + controllers: [LedgerController], + providers: [ + LedgerAccountRepository, + LedgerTxRepository, + LedgerLegRepository, + LedgerAccountService, + LedgerBootstrapService, + LedgerBookingService, + LedgerMarkService, + LedgerBookingJobService, + LedgerCutoverService, + LedgerMarkToMarketService, + LedgerReconciliationService, + LedgerQueryService, + BankTxConsumer, + ExchangeTxConsumer, + CryptoInputConsumer, + PayoutOrderConsumer, + BuyCryptoConsumer, + BuyFiatConsumer, + LiquidityMgmtConsumer, + LiquidityOrderDexConsumer, + TradingOrderConsumer, + ], + exports: [], +}) +export class AccountingModule {} diff --git a/src/subdomains/core/accounting/controllers/__tests__/ledger.controller.spec.ts b/src/subdomains/core/accounting/controllers/__tests__/ledger.controller.spec.ts new file mode 100644 index 0000000000..6e22c4911e --- /dev/null +++ b/src/subdomains/core/accounting/controllers/__tests__/ledger.controller.spec.ts @@ -0,0 +1,200 @@ +import { createMock } from '@golevelup/ts-jest'; +import { GUARDS_METADATA, PATH_METADATA } from '@nestjs/common/constants'; +import { DECORATORS } from '@nestjs/swagger/dist/constants'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +import { LedgerAccountsResponseDto, LedgerLegsResponseDto } from '../../dto/ledger-account.dto'; +import { EquityComparisonDto, MarginResponseDto } from '../../dto/ledger-margin.dto'; +import { + LedgerEquityComparisonQuery, + LedgerLegsQuery, + LedgerMarginQuery, + LedgerPeriodQuery, +} from '../../dto/ledger-query.dto'; +import { ReconStatusResponseDto, SuspenseResponseDto } from '../../dto/ledger-reconciliation.dto'; +import { LedgerQueryService } from '../../services/ledger-query.service'; +import { LedgerController } from '../ledger.controller'; + +describe('LedgerController', () => { + let controller: LedgerController; + let ledgerQueryService: LedgerQueryService; + + const from = new Date('2026-01-01T00:00:00.000Z'); + const to = new Date('2026-03-31T00:00:00.000Z'); + + beforeEach(() => { + ledgerQueryService = createMock(); + controller = new LedgerController(ledgerQueryService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('getAccounts', () => { + it('forwards the period query to getAccounts and returns its result', async () => { + const response = {} as LedgerAccountsResponseDto; + const spy = jest.spyOn(ledgerQueryService, 'getAccounts').mockResolvedValue(response); + + const query: LedgerPeriodQuery = { from, to }; + await expect(controller.getAccounts(query)).resolves.toBe(response); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(from, to); + }); + + it('passes undefined boundaries through unchanged', async () => { + const spy = jest.spyOn(ledgerQueryService, 'getAccounts').mockResolvedValue({} as LedgerAccountsResponseDto); + + await controller.getAccounts({}); + + expect(spy).toHaveBeenCalledWith(undefined, undefined); + }); + }); + + describe('getAccountDetail', () => { + it('forwards accountId and the legs query (incl. page) to getAccountDetail', async () => { + const response = {} as LedgerLegsResponseDto; + const spy = jest.spyOn(ledgerQueryService, 'getAccountDetail').mockResolvedValue(response); + + const query: LedgerLegsQuery = { from, to, page: 2 }; + await expect(controller.getAccountDetail(42, query)).resolves.toBe(response); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(42, from, to, 2); + }); + + it('forwards an undefined page (service default applies downstream)', async () => { + const spy = jest.spyOn(ledgerQueryService, 'getAccountDetail').mockResolvedValue({} as LedgerLegsResponseDto); + + await controller.getAccountDetail(7, {}); + + expect(spy).toHaveBeenCalledWith(7, undefined, undefined, undefined); + }); + }); + + describe('getReconStatus', () => { + it('delegates to getReconStatus without arguments', async () => { + const response = {} as ReconStatusResponseDto; + const spy = jest.spyOn(ledgerQueryService, 'getReconStatus').mockResolvedValue(response); + + await expect(controller.getReconStatus()).resolves.toBe(response); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(); + }); + }); + + describe('getSuspense', () => { + it('delegates to getSuspense without arguments', async () => { + const response = {} as SuspenseResponseDto; + const spy = jest.spyOn(ledgerQueryService, 'getSuspense').mockResolvedValue(response); + + await expect(controller.getSuspense()).resolves.toBe(response); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(); + }); + }); + + describe('getMargin', () => { + it('forwards from/to and resolves dailySample to true when the flag is absent', async () => { + const response = {} as MarginResponseDto; + const spy = jest.spyOn(ledgerQueryService, 'getMargin').mockResolvedValue(response); + + const query: LedgerMarginQuery = { from, to }; + await expect(controller.getMargin(query)).resolves.toBe(response); + + expect(spy).toHaveBeenCalledWith(from, to, true); + }); + + it('keeps dailySample true for any value other than the literal "false"', async () => { + const spy = jest.spyOn(ledgerQueryService, 'getMargin').mockResolvedValue({} as MarginResponseDto); + + await controller.getMargin({ dailySample: 'true' }); + + expect(spy).toHaveBeenCalledWith(undefined, undefined, true); + }); + + it('resolves dailySample to false only for the literal "false"', async () => { + const spy = jest.spyOn(ledgerQueryService, 'getMargin').mockResolvedValue({} as MarginResponseDto); + + await controller.getMargin({ from, to, dailySample: 'false' }); + + expect(spy).toHaveBeenCalledWith(from, to, false); + }); + }); + + describe('getEquityComparison', () => { + it('forwards from and resolves dailySample to true when the flag is absent', async () => { + const response = {} as EquityComparisonDto; + const spy = jest.spyOn(ledgerQueryService, 'getEquityComparison').mockResolvedValue(response); + + const query: LedgerEquityComparisonQuery = { from }; + await expect(controller.getEquityComparison(query)).resolves.toBe(response); + + expect(spy).toHaveBeenCalledWith(from, true); + }); + + it('resolves dailySample to false only for the literal "false"', async () => { + const spy = jest.spyOn(ledgerQueryService, 'getEquityComparison').mockResolvedValue({} as EquityComparisonDto); + + await controller.getEquityComparison({ from, dailySample: 'false' }); + + expect(spy).toHaveBeenCalledWith(from, false); + }); + + it('keeps dailySample true for a non-"false" value', async () => { + const spy = jest.spyOn(ledgerQueryService, 'getEquityComparison').mockResolvedValue({} as EquityComparisonDto); + + await controller.getEquityComparison({ dailySample: 'yes' }); + + expect(spy).toHaveBeenCalledWith(undefined, true); + }); + }); + + describe('routing & security metadata', () => { + const endpoints: { handler: keyof LedgerController; path: string }[] = [ + { handler: 'getAccounts', path: 'ledger/accounts' }, + { handler: 'getAccountDetail', path: 'ledger/accounts/:accountId/legs' }, + { handler: 'getReconStatus', path: 'ledger/reconciliation' }, + { handler: 'getSuspense', path: 'ledger/suspense' }, + { handler: 'getMargin', path: 'ledger/margin' }, + { handler: 'getEquityComparison', path: 'ledger/equity-comparison' }, + ]; + + it('is mounted under the dashboard/accounting base path', () => { + expect(Reflect.getMetadata(PATH_METADATA, LedgerController)).toBe('dashboard/accounting'); + }); + + it.each(endpoints)('maps $handler to GET $path', ({ handler, path }) => { + const fn = LedgerController.prototype[handler]; + expect(Reflect.getMetadata(PATH_METADATA, fn)).toBe(path); + }); + + it.each(endpoints)('excludes $handler from the public Swagger surface', ({ handler }) => { + const fn = LedgerController.prototype[handler]; + const excluded = Reflect.getMetadata(DECORATORS.API_EXCLUDE_ENDPOINT, fn); + expect(excluded).toEqual({ disable: true }); + }); + + it.each(endpoints)('guards $handler with AuthGuard, RoleGuard(ADMIN) and UserActiveGuard', ({ handler }) => { + const fn = LedgerController.prototype[handler]; + const guards = Reflect.getMetadata(GUARDS_METADATA, fn) as unknown[]; + + expect(guards).toHaveLength(3); + + // RoleGuard is the only instance-based guard in the chain; it must carry the ADMIN entry role + // (RoleGuardClass holds variadic `entryRoles` since the multi-role OR support on develop) + const roleGuard = guards.find((g) => (g as { entryRoles?: UserRole[] }).entryRoles !== undefined) as { + entryRoles: UserRole[]; + }; + expect(roleGuard).toBeDefined(); + expect(roleGuard.entryRoles).toEqual([UserRole.ADMIN]); + + // the remaining two guards are the AuthGuard passport mixin (anonymous Function) and the UserActiveGuardClass + const guardNames = guards.map((g) => (g as { constructor: { name: string } }).constructor.name); + expect(guardNames).toContain('RoleGuardClass'); + expect(guardNames).toContain('UserActiveGuardClass'); + }); + }); +}); diff --git a/src/subdomains/core/accounting/controllers/ledger.controller.ts b/src/subdomains/core/accounting/controllers/ledger.controller.ts new file mode 100644 index 0000000000..9bbc9c16b1 --- /dev/null +++ b/src/subdomains/core/accounting/controllers/ledger.controller.ts @@ -0,0 +1,75 @@ +import { Controller, Get, Param, ParseIntPipe, Query, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiBearerAuth, ApiExcludeEndpoint, ApiTags } from '@nestjs/swagger'; +import { RoleGuard } from 'src/shared/auth/role.guard'; +import { UserActiveGuard } from 'src/shared/auth/user-active.guard'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +import { LedgerAccountsResponseDto, LedgerLegsResponseDto } from '../dto/ledger-account.dto'; +import { EquityComparisonDto, MarginResponseDto } from '../dto/ledger-margin.dto'; +import { + LedgerEquityComparisonQuery, + LedgerLegsQuery, + LedgerMarginQuery, + LedgerPeriodQuery, +} from '../dto/ledger-query.dto'; +import { ReconStatusResponseDto, SuspenseResponseDto } from '../dto/ledger-reconciliation.dto'; +import { LedgerQueryService } from '../services/ledger-query.service'; + +// guard chain copied from dashboard-financial.controller.ts:24 (RoleGuard ADMIN, §1.4/§8 — NOT the DEBUG +// reconciliation controller). Base prefix dashboard/accounting → /v1/dashboard/accounting/ledger/* (§1.14). +@ApiTags('dashboard') +@Controller('dashboard/accounting') +export class LedgerController { + constructor(private readonly ledgerQueryService: LedgerQueryService) {} + + @Get('ledger/accounts') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), RoleGuard(UserRole.ADMIN), UserActiveGuard()) + async getAccounts(@Query() query: LedgerPeriodQuery): Promise { + return this.ledgerQueryService.getAccounts(query.from, query.to); + } + + @Get('ledger/accounts/:accountId/legs') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), RoleGuard(UserRole.ADMIN), UserActiveGuard()) + async getAccountDetail( + @Param('accountId', ParseIntPipe) accountId: number, + @Query() query: LedgerLegsQuery, + ): Promise { + return this.ledgerQueryService.getAccountDetail(accountId, query.from, query.to, query.page); + } + + @Get('ledger/reconciliation') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), RoleGuard(UserRole.ADMIN), UserActiveGuard()) + async getReconStatus(): Promise { + return this.ledgerQueryService.getReconStatus(); + } + + @Get('ledger/suspense') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), RoleGuard(UserRole.ADMIN), UserActiveGuard()) + async getSuspense(): Promise { + return this.ledgerQueryService.getSuspense(); + } + + @Get('ledger/margin') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), RoleGuard(UserRole.ADMIN), UserActiveGuard()) + async getMargin(@Query() query: LedgerMarginQuery): Promise { + return this.ledgerQueryService.getMargin(query.from, query.to, query.dailySample !== 'false'); + } + + @Get('ledger/equity-comparison') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), RoleGuard(UserRole.ADMIN), UserActiveGuard()) + async getEquityComparison(@Query() query: LedgerEquityComparisonQuery): Promise { + return this.ledgerQueryService.getEquityComparison(query.from, query.dailySample !== 'false'); + } +} diff --git a/src/subdomains/core/accounting/dto/__tests__/ledger-dto.mapper.spec.ts b/src/subdomains/core/accounting/dto/__tests__/ledger-dto.mapper.spec.ts new file mode 100644 index 0000000000..506430c499 --- /dev/null +++ b/src/subdomains/core/accounting/dto/__tests__/ledger-dto.mapper.spec.ts @@ -0,0 +1,188 @@ +import { createCustomLedgerAccount } from '../../entities/__mocks__/ledger-account.entity.mock'; +import { createCustomLedgerLeg } from '../../entities/__mocks__/ledger-leg.entity.mock'; +import { AccountType, LedgerAccount } from '../../entities/ledger-account.entity'; +import { LedgerLeg } from '../../entities/ledger-leg.entity'; +import { LedgerTx } from '../../entities/ledger-tx.entity'; +import { LedgerReconStatus } from '../ledger-account.dto'; +import { + AccountBalance, + AccountReconResult, + AccountReconSnapshot, + LedgerDtoMapper, + SuspenseLegRow, +} from '../ledger-dto.mapper'; +import { LedgerFeedStaleness, LedgerReconResultStatus } from '../ledger-reconciliation.dto'; + +function tx(custom: Partial): LedgerTx { + return Object.assign(new LedgerTx(), { + id: 10, + bookingDate: new Date('2026-06-07T00:00:00.000Z'), + valueDate: new Date('2026-06-08T00:00:00.000Z'), + sourceType: 'buy_fiat', + sourceId: '99001', + seq: 1, + ...custom, + }); +} + +function leg(custom: Partial, txCustom: Partial = {}): LedgerLeg { + return createCustomLedgerLeg({ id: 1, txId: 10, accountId: 5, amount: 100, tx: tx(txCustom), ...custom }); +} + +describe('LedgerDtoMapper', () => { + describe('mapPeriod', () => { + it('emits ISO strings for from/to', () => { + const period = LedgerDtoMapper.mapPeriod( + new Date('2026-06-01T00:00:00.000Z'), + new Date('2026-06-30T00:00:00.000Z'), + ); + expect(period).toEqual({ from: '2026-06-01T00:00:00.000Z', to: '2026-06-30T00:00:00.000Z' }); + }); + }); + + describe('mapAccountBalance', () => { + const account = createCustomLedgerAccount({ + id: 5, + name: 'Scrypt/EUR', + type: AccountType.ASSET, + currency: 'EUR', + }); + + it('maps native + chf balances without a recon snapshot', () => { + const balance: AccountBalance = { account, balanceNative: 1234.5, balanceChf: 1180.25 }; + + const dto = LedgerDtoMapper.mapAccountBalance(balance); + + expect(dto).toEqual({ + accountId: 5, + name: 'Scrypt/EUR', + type: AccountType.ASSET, + currency: 'EUR', + balanceNative: 1234.5, + balanceChf: 1180.25, + reconStatus: undefined, + reconDiff: undefined, + lastVerified: undefined, + }); + }); + + it('attaches the recon snapshot for an ASSET account', () => { + const balance: AccountBalance = { account, balanceNative: 1000, balanceChf: 950 }; + const recon: AccountReconSnapshot = { + reconStatus: LedgerReconStatus.OK, + reconDiff: 0.4, + lastVerified: new Date('2026-06-10T05:00:00.000Z'), + }; + + const dto = LedgerDtoMapper.mapAccountBalance(balance, recon); + + expect(dto.reconStatus).toBe(LedgerReconStatus.OK); + expect(dto.reconDiff).toBe(0.4); + expect(dto.lastVerified).toBe('2026-06-10T05:00:00.000Z'); + }); + }); + + describe('mapLegEntry', () => { + it('maps a leg with its tx fields and a single counter account', () => { + const counter: LedgerAccount = createCustomLedgerAccount({ id: 9, name: 'LIABILITY/buyFiat-received' }); + const entry = leg( + { id: 7, txId: 10, amount: -15000, amountChf: -15000, priceChf: 1 }, + { description: 'fee', reversalOfId: undefined }, + ); + + const dto = LedgerDtoMapper.mapLegEntry(entry, entry.tx.bookingDate, entry.tx.valueDate, counter); + + expect(dto.legId).toBe(7); + expect(dto.txId).toBe(10); + expect(dto.bookingDate).toBe('2026-06-07T00:00:00.000Z'); + expect(dto.valueDate).toBe('2026-06-08T00:00:00.000Z'); + expect(dto.sourceType).toBe('buy_fiat'); + expect(dto.sourceId).toBe('99001'); + expect(dto.seq).toBe(1); + expect(dto.counterAccountId).toBe(9); + expect(dto.counterAccountName).toBe('LIABILITY/buyFiat-received'); + expect(dto.amountNative).toBe(-15000); + expect(dto.amountChf).toBe(-15000); + expect(dto.priceChf).toBe(1); + }); + + it('omits the counter account for a multi-leg tx', () => { + const entry = leg({}); + const dto = LedgerDtoMapper.mapLegEntry(entry, entry.tx.bookingDate, entry.tx.valueDate, undefined); + expect(dto.counterAccountId).toBeUndefined(); + expect(dto.counterAccountName).toBeUndefined(); + }); + }); + + describe('mapReconResult', () => { + it('maps the full per-account recon result', () => { + const account = createCustomLedgerAccount({ id: 5, name: 'Binance/USDT', type: AccountType.ASSET }); + const result: AccountReconResult = { + account, + ledgerBalance: 100.5, + externalFeedBalance: 100.0, + difference: 0.5, + feedTimestamp: new Date('2026-06-10T04:00:00.000Z'), + feedAge: 2, + staleness: LedgerFeedStaleness.FRESH, + status: LedgerReconResultStatus.DIFF, + }; + + const dto = LedgerDtoMapper.mapReconResult(result); + + expect(dto).toEqual({ + accountId: 5, + accountName: 'Binance/USDT', + ledgerBalance: 100.5, + externalFeedBalance: 100.0, + difference: 0.5, + feedTimestamp: '2026-06-10T04:00:00.000Z', + feedAge: 2, + staleness: LedgerFeedStaleness.FRESH, + status: LedgerReconResultStatus.DIFF, + }); + }); + + it('leaves feedTimestamp undefined when the feed is missing', () => { + const account = createCustomLedgerAccount({ id: 5, name: 'Kraken/BTC', type: AccountType.ASSET }); + const dto = LedgerDtoMapper.mapReconResult({ + account, + ledgerBalance: 1, + externalFeedBalance: 0, + difference: 1, + feedTimestamp: undefined, + feedAge: undefined, + staleness: LedgerFeedStaleness.MISSING, + status: LedgerReconResultStatus.UNVERIFIED, + }); + + expect(dto.feedTimestamp).toBeUndefined(); + expect(dto.feedAge).toBeUndefined(); + expect(dto.staleness).toBe(LedgerFeedStaleness.MISSING); + }); + }); + + describe('mapSuspenseLeg', () => { + it('maps a suspense leg with its account currency and age', () => { + // generic untracked-bank-EUR SUSPENSE account with small, non-calibrated amounts — the mapper test only checks + // field passthrough, so it must NOT commit the sensitive named-bank↔~600k volume correlation (Minor R12-1). + const account = createCustomLedgerAccount({ + id: 3, + name: 'SUSPENSE/untracked-bank-EUR', + type: AccountType.SUSPENSE, + currency: 'EUR', + }); + const entry = leg({ id: 2, amount: 5000, amountChf: 4800, account }); + const row: SuspenseLegRow = { leg: entry, bookingDate: entry.tx.bookingDate, age: 12 }; + + const dto = LedgerDtoMapper.mapSuspenseLeg(row); + + expect(dto.legId).toBe(2); + expect(dto.currency).toBe('EUR'); + expect(dto.amountNative).toBe(5000); + expect(dto.amountChf).toBe(4800); + expect(dto.age).toBe(12); + expect(dto.bookingDate).toBe('2026-06-07T00:00:00.000Z'); + }); + }); +}); diff --git a/src/subdomains/core/accounting/dto/__tests__/ledger-dto.shapes.spec.ts b/src/subdomains/core/accounting/dto/__tests__/ledger-dto.shapes.spec.ts new file mode 100644 index 0000000000..87c2b9b4f8 --- /dev/null +++ b/src/subdomains/core/accounting/dto/__tests__/ledger-dto.shapes.spec.ts @@ -0,0 +1,267 @@ +import { plainToInstance } from 'class-transformer'; +import { validateSync } from 'class-validator'; +import { AccountType } from '../../entities/ledger-account.entity'; +import { + LedgerAccountBalanceDto, + LedgerAccountsResponseDto, + LedgerLegEntryDto, + LedgerLegsResponseDto, + LedgerPeriodDto, + LedgerReconStatus, +} from '../ledger-account.dto'; +import { + EquityComparisonDto, + EquityComparisonPeriodDto, + EquityDecompositionDto, + MarginPeriodDto, + MarginResponseDto, +} from '../ledger-margin.dto'; +import { + LedgerEquityComparisonQuery, + LedgerLegsQuery, + LedgerMarginQuery, + LedgerPeriodQuery, +} from '../ledger-query.dto'; +import { + AccountReconResultDto, + LedgerFeedStaleness, + LedgerReconResultStatus, + ReconStatusResponseDto, + SuspenseLegDto, + SuspenseResponseDto, +} from '../ledger-reconciliation.dto'; + +// These response DTOs are pure field carriers; instantiating them executes the class declarations and proves the +// public shape (field names + assignability) the mapper and controller depend on. +// NB: these DTOs must be instantiated with `new` (not a typed object literal) so the runtime class declarations +// actually execute — a type-only annotation is erased and would leave the file uncovered. +describe('ledger account/margin/reconciliation response DTOs', () => { + it('builds a LedgerAccountsResponseDto with nested balances and period', () => { + const period = Object.assign(new LedgerPeriodDto(), { from: '2026-01-01', to: '2026-03-31' }); + const balance = Object.assign(new LedgerAccountBalanceDto(), { + accountId: 5, + name: 'Scrypt/EUR', + type: AccountType.ASSET, + currency: 'EUR', + balanceNative: 1234.5, + balanceChf: 1180.25, + reconStatus: LedgerReconStatus.OK, + reconDiff: 0, + lastVerified: '2026-03-31T05:00:00.000Z', + }); + const dto = Object.assign(new LedgerAccountsResponseDto(), { period, accounts: [balance] }); + + expect(dto).toBeInstanceOf(LedgerAccountsResponseDto); + expect(dto.period.from).toBe('2026-01-01'); + expect(dto.accounts[0].type).toBe(AccountType.ASSET); + expect(dto.accounts[0].balanceChf).toBe(1180.25); + }); + + it('builds a LedgerLegsResponseDto with a leg entry', () => { + const leg = Object.assign(new LedgerLegEntryDto(), { + legId: 7, + txId: 10, + bookingDate: '2026-06-07T00:00:00.000Z', + valueDate: '2026-06-08T00:00:00.000Z', + sourceType: 'buy_fiat', + sourceId: '99001', + seq: 1, + counterAccountId: 9, + counterAccountName: 'LIABILITY/buyFiat-received', + amountNative: -15000, + amountChf: -15000, + priceChf: 1, + }); + const dto = Object.assign(new LedgerLegsResponseDto(), { + accountId: 5, + accountName: 'Scrypt/EUR', + currency: 'EUR', + period: Object.assign(new LedgerPeriodDto(), { from: '2026-01-01', to: '2026-03-31' }), + openingBalance: 0, + closingBalance: -15000, + legs: [leg], + total: 1, + }); + + expect(dto).toBeInstanceOf(LedgerLegsResponseDto); + expect(dto.legs[0]).toBeInstanceOf(LedgerLegEntryDto); + expect(dto.legs[0].counterAccountName).toBe('LIABILITY/buyFiat-received'); + expect(dto.closingBalance).toBe(-15000); + expect(dto.total).toBe(1); + }); + + it('builds a MarginResponseDto with a decomposed period and totals', () => { + const period = Object.assign(new MarginPeriodDto(), { + date: '2026-06-07', + feeIncome: 100, + executionCosts: 40, + otherOpex: 10, + realizedMargin: 60, + fxPnl: -2, + }); + const dto = Object.assign(new MarginResponseDto(), { + periods: [period], + totalFeeIncome: 100, + totalExecutionCosts: 40, + totalOtherOpex: 10, + totalRealizedMargin: 60, + }); + + expect(dto).toBeInstanceOf(MarginResponseDto); + expect(dto.periods[0]).toBeInstanceOf(MarginPeriodDto); + expect(dto.periods[0].realizedMargin).toBe(60); + expect(dto.totalRealizedMargin).toBe(60); + }); + + it('builds an EquityComparisonDto with a decomposition', () => { + const decomposition = Object.assign(new EquityDecompositionDto(), { + transitPhantom: 5, + staleFeed: 3, + spreadFees: 2, + other: 1, + }); + const period = Object.assign(new EquityComparisonPeriodDto(), { + date: '2026-06-07', + journalEquity: 1000, + financialDataLogTotal: 989, + difference: 11, + decomposition, + }); + const dto = Object.assign(new EquityComparisonDto(), { periods: [period] }); + + expect(dto).toBeInstanceOf(EquityComparisonDto); + expect(dto.periods[0]).toBeInstanceOf(EquityComparisonPeriodDto); + expect(dto.periods[0].decomposition).toBeInstanceOf(EquityDecompositionDto); + expect(dto.periods[0].difference).toBe(11); + expect(dto.periods[0].decomposition?.transitPhantom).toBe(5); + }); + + it('builds a ReconStatusResponseDto with a per-account result', () => { + const account = Object.assign(new AccountReconResultDto(), { + accountId: 5, + accountName: 'Binance/USDT', + ledgerBalance: 100.5, + externalFeedBalance: 100, + difference: 0.5, + feedTimestamp: '2026-06-10T04:00:00.000Z', + feedAge: 2, + staleness: LedgerFeedStaleness.FRESH, + status: LedgerReconResultStatus.DIFF, + }); + const dto = Object.assign(new ReconStatusResponseDto(), { + runAt: '2026-06-10T05:00:00.000Z', + accounts: [account], + }); + + expect(dto).toBeInstanceOf(ReconStatusResponseDto); + expect(dto.accounts[0]).toBeInstanceOf(AccountReconResultDto); + expect(dto.accounts[0].status).toBe(LedgerReconResultStatus.DIFF); + expect(dto.accounts[0].staleness).toBe(LedgerFeedStaleness.FRESH); + }); + + it('builds a SuspenseResponseDto with a suspense leg', () => { + const leg = Object.assign(new SuspenseLegDto(), { + legId: 2, + txId: 10, + bookingDate: '2026-06-07T00:00:00.000Z', + sourceType: 'bank_tx', + sourceId: '5000', + amountNative: 5000, + amountChf: 4800, + currency: 'EUR', + age: 12, + }); + const dto = Object.assign(new SuspenseResponseDto(), { totalChf: 4800, legs: [leg] }); + + expect(dto).toBeInstanceOf(SuspenseResponseDto); + expect(dto.legs[0]).toBeInstanceOf(SuspenseLegDto); + expect(dto.totalChf).toBe(4800); + expect(dto.legs[0].age).toBe(12); + }); +}); + +// Query DTOs carry class-validator + @Type transforms; exercising plainToInstance + validateSync runs the +// decorator metadata and proves the coercion/validation rules each endpoint relies on. +describe('ledger query DTOs', () => { + it('coerces from/to to Dates on LedgerPeriodQuery', () => { + const dto = plainToInstance(LedgerPeriodQuery, { + from: '2026-01-01T00:00:00.000Z', + to: '2026-03-31T00:00:00.000Z', + }); + + expect(dto.from).toBeInstanceOf(Date); + expect(dto.to).toBeInstanceOf(Date); + expect(validateSync(dto)).toHaveLength(0); + }); + + it('accepts an empty LedgerPeriodQuery (all optional)', () => { + const dto = plainToInstance(LedgerPeriodQuery, {}); + + expect(dto.from).toBeUndefined(); + expect(validateSync(dto)).toHaveLength(0); + }); + + it('rejects a non-date from on LedgerPeriodQuery', () => { + const dto = plainToInstance(LedgerPeriodQuery, { from: 'not-a-date' }); + const errors = validateSync(dto); + + expect(errors).toHaveLength(1); + expect(errors[0].property).toBe('from'); + expect(errors[0].constraints).toHaveProperty('isDate'); + }); + + it('coerces from/to to Dates and page to a number on LedgerLegsQuery', () => { + const dto = plainToInstance(LedgerLegsQuery, { + from: '2026-01-01T00:00:00.000Z', + to: '2026-03-31T00:00:00.000Z', + page: '3', + }); + + expect(dto.from).toBeInstanceOf(Date); + expect(dto.to).toBeInstanceOf(Date); + expect(dto.page).toBe(3); + expect(validateSync(dto)).toHaveLength(0); + }); + + it('rejects a negative page on LedgerLegsQuery', () => { + const dto = plainToInstance(LedgerLegsQuery, { page: '-1' }); + const errors = validateSync(dto); + + expect(errors).toHaveLength(1); + expect(errors[0].property).toBe('page'); + expect(errors[0].constraints).toHaveProperty('min'); + }); + + it('coerces from/to and keeps dailySample as a string on LedgerMarginQuery', () => { + const dto = plainToInstance(LedgerMarginQuery, { + from: '2026-01-01T00:00:00.000Z', + to: '2026-03-31T00:00:00.000Z', + dailySample: 'false', + }); + + expect(dto.from).toBeInstanceOf(Date); + expect(dto.to).toBeInstanceOf(Date); + expect(dto.dailySample).toBe('false'); + expect(validateSync(dto)).toHaveLength(0); + }); + + it('rejects a non-string dailySample on LedgerMarginQuery', () => { + const dto = plainToInstance(LedgerMarginQuery, { dailySample: 5 as unknown as string }); + const errors = validateSync(dto); + + expect(errors).toHaveLength(1); + expect(errors[0].property).toBe('dailySample'); + expect(errors[0].constraints).toHaveProperty('isString'); + }); + + it('coerces from and keeps dailySample on LedgerEquityComparisonQuery', () => { + const dto = plainToInstance(LedgerEquityComparisonQuery, { + from: '2026-01-01T00:00:00.000Z', + dailySample: 'true', + }); + + expect(dto.from).toBeInstanceOf(Date); + expect(dto.dailySample).toBe('true'); + expect(validateSync(dto)).toHaveLength(0); + }); +}); diff --git a/src/subdomains/core/accounting/dto/ledger-account.dto.ts b/src/subdomains/core/accounting/dto/ledger-account.dto.ts new file mode 100644 index 0000000000..048f644dbd --- /dev/null +++ b/src/subdomains/core/accounting/dto/ledger-account.dto.ts @@ -0,0 +1,59 @@ +import { AccountType } from '../entities/ledger-account.entity'; + +export enum LedgerReconStatus { + OK = 'Ok', + DIFF = 'Diff', + STALE = 'Stale', + UNVERIFIED = 'Unverified', + PLACEHOLDER = 'Placeholder', +} + +export class LedgerAccountBalanceDto { + accountId: number; + name: string; + type: AccountType; + currency: string; + balanceNative: number; + balanceChf: number; + reconStatus?: LedgerReconStatus; + reconDiff?: number; + lastVerified?: string; +} + +export class LedgerPeriodDto { + from: string; + to: string; +} + +export class LedgerAccountsResponseDto { + period: LedgerPeriodDto; + accounts: LedgerAccountBalanceDto[]; +} + +export class LedgerLegEntryDto { + legId: number; + txId: number; + bookingDate: string; + valueDate: string; + description?: string; + sourceType: string; + sourceId: string; + seq: number; + counterAccountId?: number; + counterAccountName?: string; + amountNative: number; + amountChf?: number; + priceChf?: number; + reversalOf?: number; +} + +export class LedgerLegsResponseDto { + accountId: number; + accountName: string; + currency: string; + period: LedgerPeriodDto; + openingBalance: number; + closingBalance: number; + legs: LedgerLegEntryDto[]; + total: number; +} diff --git a/src/subdomains/core/accounting/dto/ledger-dto.mapper.ts b/src/subdomains/core/accounting/dto/ledger-dto.mapper.ts new file mode 100644 index 0000000000..99ee2f1677 --- /dev/null +++ b/src/subdomains/core/accounting/dto/ledger-dto.mapper.ts @@ -0,0 +1,111 @@ +import { LedgerAccount } from '../entities/ledger-account.entity'; +import { LedgerLeg } from '../entities/ledger-leg.entity'; +import { LedgerAccountBalanceDto, LedgerLegEntryDto, LedgerPeriodDto, LedgerReconStatus } from './ledger-account.dto'; +import { + AccountReconResultDto, + LedgerFeedStaleness, + LedgerReconResultStatus, + SuspenseLegDto, +} from './ledger-reconciliation.dto'; + +// aggregated balance of a single account over the requested period (computed in the query service) +export interface AccountBalance { + account: LedgerAccount; + balanceNative: number; + balanceChf: number; +} + +// per-account reconciliation result (computed in the query service against the persisted feed, §7) +export interface AccountReconResult { + account: LedgerAccount; + ledgerBalance: number; + externalFeedBalance: number; + difference: number; + feedTimestamp?: Date; + feedAge?: number; + staleness: LedgerFeedStaleness; + status: LedgerReconResultStatus; +} + +// the recon snapshot a balance row carries into the account list (ASSET accounts only) +export interface AccountReconSnapshot { + reconStatus: LedgerReconStatus; + reconDiff?: number; + lastVerified?: Date; +} + +// a suspense leg joined to its tx (for the open-suspense overview) +export interface SuspenseLegRow { + leg: LedgerLeg; + bookingDate: Date; + age: number; +} + +export class LedgerDtoMapper { + static mapPeriod(from: Date, to: Date): LedgerPeriodDto { + return { from: from.toISOString(), to: to.toISOString() }; + } + + static mapAccountBalance(balance: AccountBalance, recon?: AccountReconSnapshot): LedgerAccountBalanceDto { + return { + accountId: balance.account.id, + name: balance.account.name, + type: balance.account.type, + currency: balance.account.currency, + balanceNative: balance.balanceNative, + balanceChf: balance.balanceChf, + reconStatus: recon?.reconStatus, + reconDiff: recon?.reconDiff, + lastVerified: recon?.lastVerified?.toISOString(), + }; + } + + static mapLegEntry(leg: LedgerLeg, bookingDate: Date, valueDate: Date, counter?: LedgerAccount): LedgerLegEntryDto { + return { + legId: leg.id, + txId: leg.txId, + bookingDate: bookingDate.toISOString(), + valueDate: valueDate.toISOString(), + description: leg.tx?.description, + sourceType: leg.tx?.sourceType, + sourceId: leg.tx?.sourceId, + seq: leg.tx?.seq, + counterAccountId: counter?.id, + counterAccountName: counter?.name, + amountNative: leg.amount, + amountChf: leg.amountChf, + priceChf: leg.priceChf, + reversalOf: leg.tx?.reversalOfId, + }; + } + + static mapReconResult(result: AccountReconResult): AccountReconResultDto { + return { + accountId: result.account.id, + accountName: result.account.name, + ledgerBalance: result.ledgerBalance, + externalFeedBalance: result.externalFeedBalance, + difference: result.difference, + feedTimestamp: result.feedTimestamp?.toISOString(), + feedAge: result.feedAge, + staleness: result.staleness, + status: result.status, + }; + } + + static mapSuspenseLeg(row: SuspenseLegRow): SuspenseLegDto { + const { leg } = row; + return { + legId: leg.id, + txId: leg.txId, + bookingDate: row.bookingDate.toISOString(), + description: leg.tx?.description, + sourceType: leg.tx?.sourceType, + sourceId: leg.tx?.sourceId, + amountNative: leg.amount, + amountChf: leg.amountChf, + currency: leg.account?.currency, + age: row.age, + }; + } +} diff --git a/src/subdomains/core/accounting/dto/ledger-margin.dto.ts b/src/subdomains/core/accounting/dto/ledger-margin.dto.ts new file mode 100644 index 0000000000..b765ee5964 --- /dev/null +++ b/src/subdomains/core/accounting/dto/ledger-margin.dto.ts @@ -0,0 +1,44 @@ +export class MarginPeriodDto { + date: string; + // feeIncome = Σ INCOME/fee-* + INCOME/trading + Σ INCOME/spread-* (spread-* ALWAYS type=INCOME filtered, Minor R12-4) + feeIncome: number; + // executionCosts = Σ EXPENSE/spread-* + EXPENSE/network-fee (+ bank-fee, acquirer-fee) (spread-* ALWAYS type=EXPENSE filtered) + executionCosts: number; + // otherOpex = Σ EXPENSE/refReward + Σ EXPENSE/extraordinary (Major R7-2; equity-reconciliation subtracts it too) + otherOpex: number; + realizedMargin: number; // feeIncome − executionCosts (operative core metric, §7.6) + fxPnl: number; // Σ */fx-revaluation +} + +export class MarginResponseDto { + periods: MarginPeriodDto[]; + totalFeeIncome: number; + totalExecutionCosts: number; + totalOtherOpex: number; + totalRealizedMargin: number; +} + +export class EquityDecompositionDto { + // transitPhantom = Σ ledger_leg.amountChf WHERE account.type=TRANSIT (signed) — Class-2 double-count phantom (Minor R13-5) + transitPhantom: number; + // staleFeed = Σ ledger_leg.amountChf WHERE sourceType='mark_to_market' on unverified accounts — Class-3 (frozen feed) + staleFeed: number; + // spreadFees = Σ EXPENSE/spread-* + EXPENSE/network-fee (+ bank-fee, acquirer-fee) — Class-6 (= executionCosts, type=EXPENSE) + spreadFees: number; + // other = difference − (transitPhantom + staleFeed + spreadFees) — the ONLY residual bucket: Class-5 + // (window-straddling residuals + un-auditable trade rests, NOT an unattributed catch-all; Minor R11-5/R13-5) + other: number; +} + +export class EquityComparisonPeriodDto { + date: string; + // journalEquity = signed Σ over {ASSET,TRANSIT,LIABILITY,SUSPENSE,ROUNDING} balances (no leading minus, §7.6 Major R8-1) → positive + journalEquity: number; + financialDataLogTotal: number; // = totalBalanceChf (log-job.service.ts:120), positive + difference: number; // journalEquity − financialDataLogTotal + decomposition?: EquityDecompositionDto; +} + +export class EquityComparisonDto { + periods: EquityComparisonPeriodDto[]; +} diff --git a/src/subdomains/core/accounting/dto/ledger-query.dto.ts b/src/subdomains/core/accounting/dto/ledger-query.dto.ts new file mode 100644 index 0000000000..b411bbc28c --- /dev/null +++ b/src/subdomains/core/accounting/dto/ledger-query.dto.ts @@ -0,0 +1,61 @@ +import { Type } from 'class-transformer'; +import { IsDate, IsInt, IsOptional, IsString, Min } from 'class-validator'; + +// query DTOs use class-validator + @Type(() => Date) (analog reconciliation.dto.ts) +export class LedgerPeriodQuery { + @IsOptional() + @Type(() => Date) + @IsDate() + from?: Date; + + @IsOptional() + @Type(() => Date) + @IsDate() + to?: Date; +} + +export class LedgerLegsQuery { + @IsOptional() + @Type(() => Date) + @IsDate() + from?: Date; + + @IsOptional() + @Type(() => Date) + @IsDate() + to?: Date; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + page?: number; +} + +export class LedgerMarginQuery { + @IsOptional() + @Type(() => Date) + @IsDate() + from?: Date; + + @IsOptional() + @Type(() => Date) + @IsDate() + to?: Date; + + // string flag (default true; only 'false' disables — analog dashboard-financial.controller.ts dailySample) + @IsOptional() + @IsString() + dailySample?: string; +} + +export class LedgerEquityComparisonQuery { + @IsOptional() + @Type(() => Date) + @IsDate() + from?: Date; + + @IsOptional() + @IsString() + dailySample?: string; +} diff --git a/src/subdomains/core/accounting/dto/ledger-reconciliation.dto.ts b/src/subdomains/core/accounting/dto/ledger-reconciliation.dto.ts new file mode 100644 index 0000000000..d51ccc1886 --- /dev/null +++ b/src/subdomains/core/accounting/dto/ledger-reconciliation.dto.ts @@ -0,0 +1,48 @@ +export enum LedgerFeedStaleness { + FRESH = 'Fresh', + STALE = 'Stale', + MISSING = 'Missing', + PLACEHOLDER = 'Placeholder', +} +export enum LedgerReconResultStatus { + OK = 'Ok', + DIFF = 'Diff', + STALE = 'Stale', + UNVERIFIED = 'Unverified', + SUSPENSE_ALARM = 'SuspenseAlarm', +} + +export class AccountReconResultDto { + accountId: number; + accountName: string; + ledgerBalance: number; + externalFeedBalance: number; + difference: number; + feedTimestamp?: string; + feedAge?: number; + staleness: LedgerFeedStaleness; + status: LedgerReconResultStatus; +} + +export class ReconStatusResponseDto { + runAt: string; + accounts: AccountReconResultDto[]; +} + +export class SuspenseLegDto { + legId: number; + txId: number; + bookingDate: string; + description?: string; + sourceType: string; + sourceId: string; + amountNative: number; + amountChf?: number; + currency: string; + age: number; +} + +export class SuspenseResponseDto { + totalChf: number; + legs: SuspenseLegDto[]; +} diff --git a/src/subdomains/core/accounting/entities/__mocks__/ledger-account.entity.mock.ts b/src/subdomains/core/accounting/entities/__mocks__/ledger-account.entity.mock.ts new file mode 100644 index 0000000000..4b692de7d8 --- /dev/null +++ b/src/subdomains/core/accounting/entities/__mocks__/ledger-account.entity.mock.ts @@ -0,0 +1,17 @@ +import { AccountType, LedgerAccount } from '../ledger-account.entity'; + +const defaultLedgerAccount: Partial = { + id: 1, + name: 'EQUITY/opening-balance', + type: AccountType.EQUITY, + currency: 'CHF', + active: true, +}; + +export function createDefaultLedgerAccount(): LedgerAccount { + return createCustomLedgerAccount({}); +} + +export function createCustomLedgerAccount(customValues: Partial): LedgerAccount { + return Object.assign(new LedgerAccount(), { ...defaultLedgerAccount, ...customValues }); +} diff --git a/src/subdomains/core/accounting/entities/__mocks__/ledger-leg.entity.mock.ts b/src/subdomains/core/accounting/entities/__mocks__/ledger-leg.entity.mock.ts new file mode 100644 index 0000000000..5f36a81f99 --- /dev/null +++ b/src/subdomains/core/accounting/entities/__mocks__/ledger-leg.entity.mock.ts @@ -0,0 +1,16 @@ +import { LedgerLeg } from '../ledger-leg.entity'; + +const defaultLedgerLeg: Partial = { + id: 1, + amount: 0, + amountChfCents: 0, + needsMark: false, +}; + +export function createDefaultLedgerLeg(): LedgerLeg { + return createCustomLedgerLeg({}); +} + +export function createCustomLedgerLeg(customValues: Partial): LedgerLeg { + return Object.assign(new LedgerLeg(), { ...defaultLedgerLeg, ...customValues }); +} diff --git a/src/subdomains/core/accounting/entities/__mocks__/ledger-tx.entity.mock.ts b/src/subdomains/core/accounting/entities/__mocks__/ledger-tx.entity.mock.ts new file mode 100644 index 0000000000..3b1a6fe15b --- /dev/null +++ b/src/subdomains/core/accounting/entities/__mocks__/ledger-tx.entity.mock.ts @@ -0,0 +1,19 @@ +import { LedgerTx } from '../ledger-tx.entity'; + +const defaultLedgerTx: Partial = { + id: 1, + sourceType: 'manual', + sourceId: '1', + seq: 0, + bookingDate: new Date('2026-06-01'), + valueDate: new Date('2026-06-01'), + amountChfSum: 0, +}; + +export function createDefaultLedgerTx(): LedgerTx { + return createCustomLedgerTx({}); +} + +export function createCustomLedgerTx(customValues: Partial): LedgerTx { + return Object.assign(new LedgerTx(), { ...defaultLedgerTx, ...customValues }); +} diff --git a/src/subdomains/core/accounting/entities/__tests__/ledger-relations.spec.ts b/src/subdomains/core/accounting/entities/__tests__/ledger-relations.spec.ts new file mode 100644 index 0000000000..7c2fc214f0 --- /dev/null +++ b/src/subdomains/core/accounting/entities/__tests__/ledger-relations.spec.ts @@ -0,0 +1,142 @@ +import { Type } from '@nestjs/common'; +import { getMetadataArgsStorage } from 'typeorm'; +import { RelationMetadataArgs } from 'typeorm/metadata-args/RelationMetadataArgs'; +import { RelationIdMetadataArgs } from 'typeorm/metadata-args/RelationIdMetadataArgs'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { createCustomLedgerLeg } from '../__mocks__/ledger-leg.entity.mock'; +import { createCustomLedgerTx } from '../__mocks__/ledger-tx.entity.mock'; +import { chfCentsTransformer } from '../ledger-cents.transformer'; +import { LedgerAccount } from '../ledger-account.entity'; +import { LedgerLeg } from '../ledger-leg.entity'; +import { LedgerTx } from '../ledger-tx.entity'; + +// TypeORM keeps the relation/relationId lambdas (type thunks, inverse-side selectors, relationId selectors) in the +// global metadata-args storage. Invoking them executes the exact arrow expressions on the entity definitions and +// asserts that each relation targets the right entity and inverse property — a wrong inverse side or a relationId +// pointed at the wrong relation would make these red. +const storage = getMetadataArgsStorage(); + +function relation(target: Type, propertyName: string): RelationMetadataArgs { + const found = storage.relations.find((r) => r.target === target && r.propertyName === propertyName); + if (!found) throw new Error(`relation ${target.name}.${propertyName} not found`); + return found; +} + +function relationId(target: Type, propertyName: string): RelationIdMetadataArgs { + const found = storage.relationIds.find((r) => r.target === target && r.propertyName === propertyName); + if (!found) throw new Error(`relationId ${target.name}.${propertyName} not found`); + return found; +} + +describe('ledger entity relations', () => { + describe('LedgerLeg', () => { + it('joins tx → LedgerTx with the legs inverse side', () => { + const rel = relation(LedgerLeg, 'tx'); + expect((rel.type as () => unknown)()).toBe(LedgerTx); + + const tx = createCustomLedgerTx({ id: 99 }); + // inverse-side selector returns tx.legs + const inverse = (rel.inverseSideProperty as (t: LedgerTx) => unknown)(tx); + expect(inverse).toBe(tx.legs); + }); + + it('exposes txId as the relationId of tx', () => { + const rel = relationId(LedgerLeg, 'txId'); + const tx = createCustomLedgerTx({ id: 7 }); + const leg = createCustomLedgerLeg({ tx }); + expect((rel.relation as (l: LedgerLeg) => unknown)(leg)).toBe(tx); + }); + + it('joins account → LedgerAccount', () => { + const rel = relation(LedgerLeg, 'account'); + expect((rel.type as () => unknown)()).toBe(LedgerAccount); + }); + + it('exposes accountId as the relationId of account', () => { + const rel = relationId(LedgerLeg, 'accountId'); + const account = Object.assign(new LedgerAccount(), { id: 5 }); + const leg = createCustomLedgerLeg({ account }); + expect((rel.relation as (l: LedgerLeg) => unknown)(leg)).toBe(account); + }); + }); + + describe('LedgerTx', () => { + it('self-references reversalOf → LedgerTx', () => { + const rel = relation(LedgerTx, 'reversalOf'); + expect((rel.type as () => unknown)()).toBe(LedgerTx); + }); + + it('exposes reversalOfId as the relationId of reversalOf', () => { + const rel = relationId(LedgerTx, 'reversalOfId'); + const original = createCustomLedgerTx({ id: 1 }); + const correction = createCustomLedgerTx({ id: 2, reversalOf: original }); + expect((rel.relation as (t: LedgerTx) => unknown)(correction)).toBe(original); + }); + + it('owns legs → LedgerLeg with the tx inverse side', () => { + const rel = relation(LedgerTx, 'legs'); + expect((rel.type as () => unknown)()).toBe(LedgerLeg); + + const leg = createCustomLedgerLeg({ id: 3 }); + const inverse = (rel.inverseSideProperty as (l: LedgerLeg) => unknown)(leg); + expect(inverse).toBe(leg.tx); + }); + }); + + describe('LedgerAccount', () => { + it('joins asset → Asset', () => { + const rel = relation(LedgerAccount, 'asset'); + expect((rel.type as () => unknown)()).toBe(Asset); + }); + + it('exposes assetId as the relationId of asset', () => { + const rel = relationId(LedgerAccount, 'assetId'); + const asset = Object.assign(new Asset(), { id: 42 }); + const account = Object.assign(new LedgerAccount(), { id: 5, asset }); + expect((rel.relation as (a: LedgerAccount) => unknown)(account)).toBe(asset); + }); + }); +}); + +// The integer-cent columns are PostgreSQL bigint (no int4 ±21.4M-CHF-cent ceiling that would stall a source chain on +// a large leg) and carry a ValueTransformer that maps the driver's bigint string back to a JS number, failing loud +// above the safe-integer range instead of silently rounding a cent amount that feeds the balance gate. +function column(target: Type, propertyName: string) { + const found = storage.columns.find((c) => c.target === target && c.propertyName === propertyName); + if (!found) throw new Error(`column ${target.name}.${propertyName} not found`); + return found; +} + +describe('ledger bigint cents columns', () => { + it('declares amountChfCents / amountChfSum as bigint with a transformer (never int4)', () => { + for (const [target, prop] of [ + [LedgerLeg, 'amountChfCents'], + [LedgerTx, 'amountChfSum'], + ] as const) { + const options = column(target, prop).options; + expect(options.type).toBe('bigint'); + expect(options.transformer).toBeDefined(); + } + }); + + describe('chfCentsTransformer', () => { + it('reads the driver bigint string back as a JS number (to() is identity for the write side)', () => { + const from = chfCentsTransformer.from as (v: unknown) => number; + const to = chfCentsTransformer.to as (v: number) => number; + + expect(from('5000000')).toBe(5000000); + expect(typeof from('5000000')).toBe('number'); // not a bigint string + expect(from('-5000000')).toBe(-5000000); + expect(from(0)).toBe(0); + expect(to(5000000)).toBe(5000000); + }); + + it('accepts the safe-integer boundary but fails LOUD beyond it (never silently rounds)', () => { + const from = chfCentsTransformer.from as (v: unknown) => number; + + expect(from(String(Number.MAX_SAFE_INTEGER))).toBe(Number.MAX_SAFE_INTEGER); // 90 trillion CHF, still exact + expect(() => from('9007199254740993')).toThrow(/safe-integer range/); // 2^53 + 1 → refuse, do not round + expect(() => from('900719925474099200000')).toThrow(/safe-integer range/); + }); + }); +}); diff --git a/src/subdomains/core/accounting/entities/ledger-account.entity.ts b/src/subdomains/core/accounting/entities/ledger-account.entity.ts new file mode 100644 index 0000000000..8e1224d19d --- /dev/null +++ b/src/subdomains/core/accounting/entities/ledger-account.entity.ts @@ -0,0 +1,39 @@ +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { IEntity } from 'src/shared/models/entity'; +import { Column, Entity, Index, JoinColumn, ManyToOne, RelationId } from 'typeorm'; + +export enum AccountType { + ASSET = 'Asset', + TRANSIT = 'Transit', + LIABILITY = 'Liability', + INCOME = 'Income', + EXPENSE = 'Expense', + EQUITY = 'Equity', + SUSPENSE = 'Suspense', + ROUNDING = 'Rounding', +} + +@Entity() +export class LedgerAccount extends IEntity { + @Column({ length: 256, unique: true }) + name: string; // deterministic (§3) + + @Index() + @Column({ length: 32 }) + type: AccountType; + + // only ASSET accounts backed by an asset row; nullable read-only join (no cascade, not eager) + @Index() + @ManyToOne(() => Asset, { nullable: true }) + @JoinColumn() + asset?: Asset; + + @RelationId((account: LedgerAccount) => account.asset) + assetId?: number; + + @Column({ length: 16 }) + currency: string; // ticker (CHF/EUR/BTC/…) + + @Column({ default: true }) + active: boolean; // false = historical, no new bookings +} diff --git a/src/subdomains/core/accounting/entities/ledger-cents.transformer.ts b/src/subdomains/core/accounting/entities/ledger-cents.transformer.ts new file mode 100644 index 0000000000..cd03826638 --- /dev/null +++ b/src/subdomains/core/accounting/entities/ledger-cents.transformer.ts @@ -0,0 +1,19 @@ +import { ValueTransformer } from 'typeorm'; + +// PostgreSQL bigint is returned by the driver as a string; map it back to a JS number on read and fail LOUD if a +// value ever leaves the safe-integer range instead of silently rounding (a rounded cent amount would corrupt the +// balance gate). Realistic CHF cent sums stay far below 2^53 (~90'000'000'000'000 CHF), so the guard only fires on +// overflow/corruption — a real error, never a valid amount. Lives in this entity-free module so both ledger_leg and +// ledger_tx (which reference each other) can share one transformer without risking an import cycle. +export const chfCentsTransformer: ValueTransformer = { + to: (value: number): number => value, + from: (value: string | number | null): number => { + if (value == null) return value as unknown as number; // NOT NULL columns → never hit in practice + const cents = Number(value); + if (!Number.isSafeInteger(cents)) + throw new Error( + `Ledger CHF cents value "${value}" is outside the JS safe-integer range (±${Number.MAX_SAFE_INTEGER}); refusing to silently round`, + ); + return cents; + }, +}; diff --git a/src/subdomains/core/accounting/entities/ledger-leg.entity.ts b/src/subdomains/core/accounting/entities/ledger-leg.entity.ts new file mode 100644 index 0000000000..305907e72c --- /dev/null +++ b/src/subdomains/core/accounting/entities/ledger-leg.entity.ts @@ -0,0 +1,42 @@ +import { IEntity } from 'src/shared/models/entity'; +import { Column, Entity, Index, JoinColumn, ManyToOne, RelationId } from 'typeorm'; +import { chfCentsTransformer } from './ledger-cents.transformer'; +import { LedgerAccount } from './ledger-account.entity'; +import { LedgerTx } from './ledger-tx.entity'; + +@Entity() +export class LedgerLeg extends IEntity { + @Index() + @ManyToOne(() => LedgerTx, (tx) => tx.legs, { nullable: false }) + @JoinColumn() + tx: LedgerTx; + + @RelationId((leg: LedgerLeg) => leg.tx) + txId: number; + + @Index() + @ManyToOne(() => LedgerAccount, { nullable: false }) + @JoinColumn() + account: LedgerAccount; + + @RelationId((leg: LedgerLeg) => leg.account) + accountId: number; + + // native, signed (Dr = +, Cr = −); 8-decimal display rounding is a service convention, not DB precision + @Column({ type: 'float' }) + amount: number; + + @Column({ type: 'float', nullable: true }) + priceChf?: number; // CHF rate at booking (null if native/flag only) + + @Column({ type: 'float', nullable: true }) + amountChf?: number; // Util.round(amount × priceChf, 2) (null if no mark) + + // integer cents for checksum (PostgreSQL bigint — no int4 ceiling; string→number via chfCentsTransformer, see §2-header) + @Column({ type: 'bigint', default: 0, transformer: chfCentsTransformer }) + amountChfCents: number; + + @Index() + @Column({ default: false }) + needsMark: boolean; // true = no mark available → mark-to-market job candidate +} diff --git a/src/subdomains/core/accounting/entities/ledger-tx.entity.ts b/src/subdomains/core/accounting/entities/ledger-tx.entity.ts new file mode 100644 index 0000000000..c3b2b73de2 --- /dev/null +++ b/src/subdomains/core/accounting/entities/ledger-tx.entity.ts @@ -0,0 +1,45 @@ +import { IEntity } from 'src/shared/models/entity'; +import { Check, Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, RelationId, Unique } from 'typeorm'; +import { chfCentsTransformer } from './ledger-cents.transformer'; +import { LedgerLeg } from './ledger-leg.entity'; + +// standalone @Entity (no STI) → the CHK lands directly on ledger_tx (§2.2 Minor R1-10) +@Entity() +@Unique(['sourceType', 'sourceId', 'seq']) // idempotency (Issue Z. 62) +@Check(`"amountChfSum" = 0`) // single-row balance gate (CHF cross-asset) +export class LedgerTx extends IEntity { + @Index() + @Column({ type: 'timestamp' }) + bookingDate: Date; // settlement-evidence date (§4 per source) + + @Column({ type: 'timestamp' }) + valueDate: Date; // value date (field ?? bookingDate) + + @Column({ length: 512, nullable: true }) + description?: string; + + @Column({ length: 64 }) + sourceType: string; // bank_tx/ExchangeTrade/exchange_tx/payout_order/crypto_input/buy_crypto/…/cutover/manual/mark_to_market + + @Column({ length: 64 }) + sourceId: string; // source-row id as string (trades: order_id; cutover: logId) + + @Column({ type: 'int', default: 0 }) + seq: number; // tx discriminator per (sourceType, sourceId) + + // self-FK for corrections (§4.12); references the original tx + @Index() + @ManyToOne(() => LedgerTx, { nullable: true }) + @JoinColumn() + reversalOf?: LedgerTx; + + @RelationId((tx: LedgerTx) => tx.reversalOf) + reversalOfId?: number; + + // integer cents (PostgreSQL bigint — no int4 ceiling; string→number via chfCentsTransformer, see §2-header); always 0 per tx + @Column({ type: 'bigint', default: 0, transformer: chfCentsTransformer }) + amountChfSum: number; + + @OneToMany(() => LedgerLeg, (leg) => leg.tx) + legs: LedgerLeg[]; +} diff --git a/src/subdomains/core/accounting/repositories/__tests__/ledger-account.repository.spec.ts b/src/subdomains/core/accounting/repositories/__tests__/ledger-account.repository.spec.ts new file mode 100644 index 0000000000..675d2c20a2 --- /dev/null +++ b/src/subdomains/core/accounting/repositories/__tests__/ledger-account.repository.spec.ts @@ -0,0 +1,27 @@ +import { createMock } from '@golevelup/ts-jest'; +import { EntityManager } from 'typeorm'; +import { BaseRepository } from 'src/shared/repositories/base.repository'; +import { LedgerAccount } from '../../entities/ledger-account.entity'; +import { LedgerAccountRepository } from '../ledger-account.repository'; + +describe('LedgerAccountRepository', () => { + let manager: EntityManager; + let repository: LedgerAccountRepository; + + beforeEach(() => { + manager = createMock(); + repository = new LedgerAccountRepository(manager); + }); + + it('extends the shared BaseRepository', () => { + expect(repository).toBeInstanceOf(BaseRepository); + }); + + it('binds the injected EntityManager', () => { + expect(repository.manager).toBe(manager); + }); + + it('manages the LedgerAccount entity', () => { + expect(repository.target).toBe(LedgerAccount); + }); +}); diff --git a/src/subdomains/core/accounting/repositories/__tests__/ledger-leg.repository.spec.ts b/src/subdomains/core/accounting/repositories/__tests__/ledger-leg.repository.spec.ts new file mode 100644 index 0000000000..3446ec97ce --- /dev/null +++ b/src/subdomains/core/accounting/repositories/__tests__/ledger-leg.repository.spec.ts @@ -0,0 +1,27 @@ +import { createMock } from '@golevelup/ts-jest'; +import { EntityManager } from 'typeorm'; +import { BaseRepository } from 'src/shared/repositories/base.repository'; +import { LedgerLeg } from '../../entities/ledger-leg.entity'; +import { LedgerLegRepository } from '../ledger-leg.repository'; + +describe('LedgerLegRepository', () => { + let manager: EntityManager; + let repository: LedgerLegRepository; + + beforeEach(() => { + manager = createMock(); + repository = new LedgerLegRepository(manager); + }); + + it('extends the shared BaseRepository', () => { + expect(repository).toBeInstanceOf(BaseRepository); + }); + + it('binds the injected EntityManager', () => { + expect(repository.manager).toBe(manager); + }); + + it('manages the LedgerLeg entity', () => { + expect(repository.target).toBe(LedgerLeg); + }); +}); diff --git a/src/subdomains/core/accounting/repositories/__tests__/ledger-tx.repository.spec.ts b/src/subdomains/core/accounting/repositories/__tests__/ledger-tx.repository.spec.ts new file mode 100644 index 0000000000..b828f5b7d4 --- /dev/null +++ b/src/subdomains/core/accounting/repositories/__tests__/ledger-tx.repository.spec.ts @@ -0,0 +1,27 @@ +import { createMock } from '@golevelup/ts-jest'; +import { EntityManager } from 'typeorm'; +import { BaseRepository } from 'src/shared/repositories/base.repository'; +import { LedgerTx } from '../../entities/ledger-tx.entity'; +import { LedgerTxRepository } from '../ledger-tx.repository'; + +describe('LedgerTxRepository', () => { + let manager: EntityManager; + let repository: LedgerTxRepository; + + beforeEach(() => { + manager = createMock(); + repository = new LedgerTxRepository(manager); + }); + + it('extends the shared BaseRepository', () => { + expect(repository).toBeInstanceOf(BaseRepository); + }); + + it('binds the injected EntityManager', () => { + expect(repository.manager).toBe(manager); + }); + + it('manages the LedgerTx entity', () => { + expect(repository.target).toBe(LedgerTx); + }); +}); diff --git a/src/subdomains/core/accounting/repositories/ledger-account.repository.ts b/src/subdomains/core/accounting/repositories/ledger-account.repository.ts new file mode 100644 index 0000000000..6b9accb431 --- /dev/null +++ b/src/subdomains/core/accounting/repositories/ledger-account.repository.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { BaseRepository } from 'src/shared/repositories/base.repository'; +import { EntityManager } from 'typeorm'; +import { LedgerAccount } from '../entities/ledger-account.entity'; + +@Injectable() +export class LedgerAccountRepository extends BaseRepository { + constructor(manager: EntityManager) { + super(LedgerAccount, manager); + } +} diff --git a/src/subdomains/core/accounting/repositories/ledger-leg.repository.ts b/src/subdomains/core/accounting/repositories/ledger-leg.repository.ts new file mode 100644 index 0000000000..4214b07543 --- /dev/null +++ b/src/subdomains/core/accounting/repositories/ledger-leg.repository.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { BaseRepository } from 'src/shared/repositories/base.repository'; +import { EntityManager } from 'typeorm'; +import { LedgerLeg } from '../entities/ledger-leg.entity'; + +@Injectable() +export class LedgerLegRepository extends BaseRepository { + constructor(manager: EntityManager) { + super(LedgerLeg, manager); + } +} diff --git a/src/subdomains/core/accounting/repositories/ledger-tx.repository.ts b/src/subdomains/core/accounting/repositories/ledger-tx.repository.ts new file mode 100644 index 0000000000..9e117b0cbd --- /dev/null +++ b/src/subdomains/core/accounting/repositories/ledger-tx.repository.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { BaseRepository } from 'src/shared/repositories/base.repository'; +import { EntityManager } from 'typeorm'; +import { LedgerTx } from '../entities/ledger-tx.entity'; + +@Injectable() +export class LedgerTxRepository extends BaseRepository { + constructor(manager: EntityManager) { + super(LedgerTx, manager); + } +} diff --git a/src/subdomains/core/accounting/services/__tests__/integration/buy-crypto-cutover.integration.spec.ts b/src/subdomains/core/accounting/services/__tests__/integration/buy-crypto-cutover.integration.spec.ts new file mode 100644 index 0000000000..9716df21bd --- /dev/null +++ b/src/subdomains/core/accounting/services/__tests__/integration/buy-crypto-cutover.integration.spec.ts @@ -0,0 +1,239 @@ +import { createMock } from '@golevelup/ts-jest'; +import { ConfigService } from 'src/config/config'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { BuyCrypto } from 'src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity'; +import { Repository } from 'typeorm'; +import { AccountType } from '../../../entities/ledger-account.entity'; +import { BuyCryptoConsumer } from '../../consumers/buy-crypto.consumer'; +import { InMemoryLedger } from './in-memory-ledger'; + +/** + * §10.2 integration — the MAJOR cutover double-book finding for Card (Checkout) buy_crypto inputs, run against the + * REAL booking + account services over a shared in-memory ledger (InMemoryLedger). Two Card rows straddle the cutover: + * (A) a row OPEN at the cutover: its card-currency GROSS is already in the aggregate ASSET opening (openAssets books + * liquidityBalance.total, which carries the Checkout.com collateral feed of auto-captured card charges) AND the + * cutover booked its per-row buyCrypto-received opening (Major B1), so the forward consumer SKIPS seq0 — re-booking + * it would re-debit the gross on Checkout/{ccy} a SECOND time (permanent phantom, the pre-fix bug) — and books + * only seq1, which closes LIABILITY/buyCrypto-received against the cutover opening to 0; + * (B) a row already SETTLED at the cutover whose `updated` moves post-cutover (chargeback/mail flag): its value is + * already in the aggregate opening (openAssets) → the consumer must re-book NOTHING (no phantom Checkout asset / + * phantom owed). + * Two more rows are OWED-straddling (outputAmount set, not complete at the snapshot → per-row cutover owed opening + * `:buy_crypto-owed:`, §6.1) and complete post-cutover — the consumer must book NEITHER seq0 NOR seq1 + * (the payout_order consumer closes owed against the opening anchor): + * (C) bank-funded: pre-fix the content-change scan threw forever (receivedOpened never true → permanent wedge); + * (D) Card-funded: pre-fix seq0+seq1 booked ON TOP of the owed opening (double count). + * All amounts synthetic; no real customer/account/IBAN (public repo). + */ +describe('Ledger buy_crypto cutover Card double-book (§10.2, MAJOR)', () => { + const SNAPSHOT = new Date('2026-06-07T22:00:00Z'); // cutover snapshot (= ledgerCutoverSnapshotDate) + const PRE_CUTOVER = new Date('2026-05-15T00:00:00Z'); // Card completion BEFORE the snapshot (Scenario B) + const POST_CUTOVER = new Date('2026-06-20T00:00:00Z'); // completion / flag change AFTER the cutover + + let ledger: InMemoryLedger; + let settingService: SettingService; // the consumer's SettingService mock (watermark-write assertions read its spy) + + beforeEach(() => { + new ConfigService(); // sets the Config singleton the booking service + consumer read (§11.2) + + ledger = new InMemoryLedger(); + // CoA bootstrap stand-in: the Card custody account + the up-front non-ASSET accounts (§3.2/§3.4) + ledger.seed('Checkout/EUR', AccountType.ASSET, 'EUR'); + ledger.seed('ROUNDING', AccountType.ROUNDING, 'CHF'); + ledger.seed('EQUITY/opening-balance', AccountType.EQUITY, 'CHF'); + }); + + function buyCrypto(values: Partial): BuyCrypto { + return Object.assign(new BuyCrypto(), { + id: 1, + created: new Date('2026-05-10T00:00:00Z'), + isComplete: false, + checkoutTx: { currency: 'EUR' } as any, // Card input + inputReferenceAmount: 1050, // card-currency gross — the F2 custody native (seq0 booking requires it) + ...values, + }); + } + + // a SettingService modelling a completed cutover: ledgerCutoverLogId + the pinned snapshot date are set, and the + // buy_crypto watermark's lastProcessedId is already PAST the straddling row id (late-settling → the forward id-scan + // skips it) with lastReversalScan = the snapshot (the content-change scan re-selects it via updated > lastReversalScan). + function cutoverSettingService(lastProcessedId: number): SettingService { + const s = createMock(); + jest.spyOn(s, 'get').mockImplementation((key: string) => { + if (key === 'ledgerCutoverLogId') return Promise.resolve('1557344' as any); + if (key === 'ledgerCutoverSnapshotDate') return Promise.resolve(SNAPSHOT.toISOString() as any); + return Promise.resolve(undefined as any); + }); + jest.spyOn(s, 'getObj').mockResolvedValue({ lastProcessedId, lastReversalScan: SNAPSHOT.toISOString() } as any); + jest.spyOn(s, 'set').mockResolvedValue(); + return s; + } + + // wires a BuyCryptoConsumer against the shared ledger; the row is reached ONLY by the §6.3 content-change scan + // (forward id-scan returns [] since id <= lastProcessedId; the content-change scan returns the row via where.updated) + function consumer(row: BuyCrypto, lastProcessedId: number): BuyCryptoConsumer { + const repo = createMock>(); + jest + .spyOn(repo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [row] : [])); + settingService = cutoverSettingService(lastProcessedId); + return new BuyCryptoConsumer( + settingService, + ledger.bookingService, + ledger.accountService, + repo, + ledger.ledgerTxRepository(), + ); + } + + // books the §6.1 per-row cutover owed opening (Cr LIABILITY/buyCrypto-owed −chf / Dr EQUITY +chf) the cutover + // wrote for an owed-straddling row (outputAmount set, not complete at the snapshot); logId matches the mock above + async function bookOwedOpening(rowId: number, chf: number): Promise { + const owed = await ledger.accountService.findOrCreate('LIABILITY/buyCrypto-owed', AccountType.LIABILITY, 'CHF'); + const equity = await ledger.accountService.findOrCreate('EQUITY/opening-balance', AccountType.EQUITY, 'CHF'); + await ledger.bookingService.bookTx({ + sourceType: 'cutover', + sourceId: `1557344:buy_crypto-owed:${rowId}`, + seq: 0, + bookingDate: SNAPSHOT, + valueDate: SNAPSHOT, + description: `Opening buyCrypto-owed from open buy_crypto #${rowId}`, + legs: [ + { account: owed, amount: -chf, priceChf: 1, amountChf: -chf }, + { account: equity, amount: chf, priceChf: 1, amountChf: chf }, + ], + }); + } + + // books the §6.1 per-row cutover received opening (Cr LIABILITY/buyCrypto-received −chf / Dr EQUITY +chf) the cutover + // wrote for an OPEN-at-cutover row (Major B1: Card rows are now included). The completion seq1 closes received against + // this opening; the forward Card seq0 is skipped because this marker exists. + async function bookReceivedOpening(rowId: number, chf: number): Promise { + const received = await ledger.accountService.findOrCreate( + 'LIABILITY/buyCrypto-received', + AccountType.LIABILITY, + 'CHF', + ); + const equity = await ledger.accountService.findOrCreate('EQUITY/opening-balance', AccountType.EQUITY, 'CHF'); + await ledger.bookingService.bookTx({ + sourceType: 'cutover', + sourceId: `1557344:buy_crypto:${rowId}`, + seq: 0, + bookingDate: SNAPSHOT, + valueDate: SNAPSHOT, + description: `Opening buyCrypto-received from open buy_crypto #${rowId}`, + legs: [ + { account: received, amount: -chf, priceChf: 1, amountChf: -chf }, + { account: equity, amount: chf, priceChf: 1, amountChf: chf }, + ], + }); + } + + it('Scenario A: an open Card row completing after the cutover skips seq0 (marker present) and books only seq1 → received closes to 0, Checkout not re-debited', async () => { + // Major B1: the cutover DID open a per-row buyCrypto-received for this OPEN-at-cutover Card row (bookReceivedOpening + // below) and its card gross is already in the aggregate ASSET opening (openAssets → the Checkout.com collateral + // feed; out of this consumer test's scope). It completes post-cutover, so the content-change scan SKIPS seq0 — the + // marker exists, re-booking would re-debit the gross on Checkout/EUR a second time (the pre-fix phantom) — and books + // only seq1 (fee/reclass), which closes received against the cutover opening. + await bookReceivedOpening(70, 1000); // §6.1 per-row cutover received opening for the open Card row (−1000) + + const row = buyCrypto({ + id: 70, + amountInChf: 1000, + totalFeeAmountChf: 10, + isComplete: true, + outputDate: POST_CUTOVER, // completed AFTER the snapshot → completion booked post-cutover + updated: POST_CUTOVER, + }); + + await consumer(row, 100).process(); + + // seq0 SKIPPED (cutover received marker present → the forward card seq0 would double-debit the gross); seq1 booked + expect(ledger.txs.some((t) => t.sourceType === 'buy_crypto' && t.sourceId === '70' && t.seq === 0)).toBe(false); + expect(ledger.txs.some((t) => t.sourceType === 'buy_crypto' && t.sourceId === '70' && t.seq === 1)).toBe(true); + // received: opened −1000 by the cutover, debited +10 fee + +990 reclass (seq1) → closes to 0 (NOT −1000) + expect(ledger.chfBalance('LIABILITY/buyCrypto-received')).toBe(0); + // owed = −(amountInChf − fee) + expect(ledger.chfBalance('LIABILITY/buyCrypto-owed')).toBe(-990); + // Checkout/EUR is NOT touched by the consumer: the card gross is already carried by the aggregate ASSET opening + // (the collateral feed contains it). Pre-fix the forward seq0 re-debited +1000 here → permanent phantom. + expect(ledger.chfBalance('Checkout/EUR')).toBe(0); + expect(ledger.everyTxBalances()).toBe(true); + }); + + it('Scenario B: a Card row settled before the cutover, updated after it, is NOT re-booked (no phantom)', async () => { + // settled pre-cutover (outputDate ≤ snapshot) → value already in the aggregate opening; a post-cutover flag bumps + // `updated` → the content-change scan re-selects it, but the Card gate must skip BOTH seq0 and seq1 (no double-book). + const row = buyCrypto({ + id: 71, + amountInChf: 1000, + totalFeeAmountChf: 10, + isComplete: true, + outputDate: PRE_CUTOVER, // settled BEFORE the snapshot → covered by the aggregate opening + updated: POST_CUTOVER, // chargeback/mail flag set post-cutover → re-selected by the content-change scan + }); + + await consumer(row, 100).process(); + + expect(ledger.txs.filter((t) => t.sourceType === 'buy_crypto' && t.sourceId === '71')).toHaveLength(0); + // no phantom: received untouched (0), Card custody never debited, owed never created + expect(ledger.chfBalance('LIABILITY/buyCrypto-received')).toBe(0); + expect(ledger.chfBalance('Checkout/EUR')).toBe(0); + expect(ledger.hasAccount('LIABILITY/buyCrypto-owed')).toBe(false); + expect(ledger.everyTxBalances()).toBe(true); + }); + + it('Scenario C: an owed-straddling bank-funded row completing after the cutover books nothing and advances (no wedge)', async () => { + // owed-straddling (§6.1): outputAmount was set at the snapshot → the cutover booked the per-row owed opening; the + // payout_order consumer closes owed against that anchor. This consumer must book NEITHER seq0 NOR seq1. Pre-fix, + // the content-change scan threw forever for this row (receivedOpened never true, preCutoverSettled false because + // outputDate is post-cutover) — booking nothing is NOT enough evidence, the cursor MUST also advance past the row. + await bookOwedOpening(72, 990); + const row = buyCrypto({ + id: 72, + amountInChf: 1000, + totalFeeAmountChf: 10, + outputAmount: 0.5, // set pre-cutover → owed-straddling + isComplete: true, + outputDate: POST_CUTOVER, // completed AFTER the snapshot + updated: POST_CUTOVER, + checkoutTx: null, // bank-funded, not Card + bankTx: { id: 600 } as any, + }); + + await consumer(row, 100).process(); + + // nothing booked by this consumer: no seq0/seq1 (the owed opening is the only tx touching this row) + expect(ledger.txs.filter((t) => t.sourceType === 'buy_crypto' && t.sourceId === '72')).toHaveLength(0); + expect(ledger.chfBalance('LIABILITY/buyCrypto-owed')).toBe(-990); // owed still equals the opening value + // the content-change cursor ADVANCED past the row (pre-fix wedge: the scan threw → cursor stayed at the snapshot) + const wmWrites = (settingService.set as jest.Mock).mock.calls.filter((c) => c[0] === 'ledgerWatermark.buy_crypto'); + expect(wmWrites).toHaveLength(1); + expect(JSON.parse(wmWrites[0][1]).lastReversalScan).toBe(POST_CUTOVER.toISOString()); + expect(JSON.parse(wmWrites[0][1]).lastReversalScanId).toBe(72); + expect(ledger.everyTxBalances()).toBe(true); + }); + + it('Scenario D: an owed-straddling Card row completing after the cutover is NOT double-counted (no seq0/seq1)', async () => { + // Card variant of Scenario C: pre-fix, receivedOpened returned true via the Card branch → seq0+seq1 booked IN + // ADDITION to the cutover owed opening → owed doubled to −1980 and Checkout/EUR carried a phantom +1000. + await bookOwedOpening(73, 990); + const row = buyCrypto({ + id: 73, + amountInChf: 1000, + totalFeeAmountChf: 10, + outputAmount: 0.5, // set pre-cutover → owed-straddling + isComplete: true, + outputDate: POST_CUTOVER, + updated: POST_CUTOVER, // checkoutTx { currency: 'EUR' } from the helper → Card-funded + }); + + await consumer(row, 100).process(); + + expect(ledger.txs.filter((t) => t.sourceType === 'buy_crypto' && t.sourceId === '73')).toHaveLength(0); + expect(ledger.chfBalance('Checkout/EUR')).toBe(0); // Card custody never debited + expect(ledger.chfBalance('LIABILITY/buyCrypto-owed')).toBe(-990); // ONLY the opening value, no double count + expect(ledger.hasAccount('LIABILITY/buyCrypto-received')).toBe(false); // received untouched (never created) + expect(ledger.everyTxBalances()).toBe(true); + }); +}); diff --git a/src/subdomains/core/accounting/services/__tests__/integration/checkout-margin.integration.spec.ts b/src/subdomains/core/accounting/services/__tests__/integration/checkout-margin.integration.spec.ts new file mode 100644 index 0000000000..b2139a781a --- /dev/null +++ b/src/subdomains/core/accounting/services/__tests__/integration/checkout-margin.integration.spec.ts @@ -0,0 +1,159 @@ +import { createMock } from '@golevelup/ts-jest'; +import { ConfigService } from 'src/config/config'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { BuyCrypto } from 'src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity'; +import { BankTx, BankTxIndicator, BankTxType } from 'src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity'; +import { BankTxRepeat } from 'src/subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.entity'; +import { BankTxReturn } from 'src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.entity'; +import { Bank } from 'src/subdomains/supporting/bank/bank/bank.entity'; +import { Repository } from 'typeorm'; +import { AccountType } from '../../../entities/ledger-account.entity'; +import { LedgerMarkCache, LedgerMarkService } from '../../ledger-mark.service'; +import { BankTxConsumer } from '../../consumers/bank-tx.consumer'; +import { BuyCryptoConsumer } from '../../consumers/buy-crypto.consumer'; +import { InMemoryLedger } from './in-memory-ledger'; + +/** + * §10.2 integration — the F2 Checkout/{ccy} native convention across BOTH bookers of the account, run against the + * REAL booking + account services over a shared in-memory ledger (InMemoryLedger): + * (a) the Card input seq0 (BuyCryptoConsumer): Dr Checkout/EUR native = card-currency GROSS (inputReferenceAmount + * 1000 EUR), amountChf = gross CHF (950 → implied rate 0.95); + * (b) the CHECKOUT_LTD settlement (BankTxConsumer): Cr Checkout/EUR native GROSS = net 990 + feeNative 10 (ladder 2, + * chargeAmount persisted in the settlement currency), amountChf = −(netChf 940.5 + feeChf 9.5) = −950. + * With native gross on every leg the account closes to 0 in BOTH units (native 1000 − 1000, CHF 950 − 950), so the + * mark-to-market diff (mark × native − chf) is 0 BY CONSTRUCTION — nothing to flush, NO phantom fxPnl: the acquirer + * fee lives exactly once, on EXPENSE/acquirer-fee. Pre-fix the settlement booked native NET (−990) against gross CHF + * → a fee-sized native/CHF mismatch the M2M job flushed as a phantom INCOME/EXPENSE fx-revaluation posting (fee + * counted twice in the margin report). All amounts synthetic; no real customer/account/IBAN (public repo). + */ +describe('Ledger Checkout margin (F2 card-currency gross convention, MAJOR)', () => { + const CREATED = new Date('2026-06-01T00:00:00Z'); + const SETTLED = new Date('2026-06-03T00:00:00Z'); + + let ledger: InMemoryLedger; + + beforeEach(() => { + new ConfigService(); // sets the Config singleton the booking service + consumers read (§11.2) + + ledger = new InMemoryLedger(); + // CoA bootstrap stand-in: the Card custody + the tracked EUR bank ASSET accounts (§3.2/§3.4) + ledger.seedAsset('Checkout/EUR', 'EUR', 270); + ledger.seedAsset('Olkypay/EUR', 'EUR', 269); + ledger.seed('ROUNDING', AccountType.ROUNDING, 'CHF'); + }); + + // no cutover ran (forward-only booking); watermarks start fresh; set() is a no-op spy + function freshSettingService(): SettingService { + const s = createMock(); + jest.spyOn(s, 'get').mockResolvedValue(undefined); + jest.spyOn(s, 'getObj').mockResolvedValue(undefined); + jest.spyOn(s, 'set').mockResolvedValue(); + return s; + } + + // forward id-scan returns the rows; the §4.12 content-change scan (where.updated) returns [] + function repoWith(rows: T[]): Repository { + const repo = createMock>(); + jest + .spyOn(repo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [] : rows)); + return repo; + } + + function buyCryptoConsumer(row: BuyCrypto): BuyCryptoConsumer { + return new BuyCryptoConsumer( + freshSettingService(), + ledger.bookingService, + ledger.accountService, + repoWith([row]), + ledger.ledgerTxRepository(), + ); + } + + function bankTxConsumer(row: BankTx): BankTxConsumer { + // tracked EUR bank (asset 269) resolved by iban; the same-currency mark lookup is never needed (tracked) + const bankRepo = createMock>(); + jest + .spyOn(bankRepo, 'findOne') + .mockImplementation((opts: any) => + Promise.resolve( + opts?.where?.iban === 'EUR-IBAN' + ? Object.assign(new Bank(), { name: 'Olkypay', currency: 'EUR', asset: { id: 269 } }) + : null, + ), + ); + + // EUR mark 0.95 — the SAME mark values the net settlement leg and (via ladder 3, unused here) a CHF-only fee + const markService = createMock(); + jest + .spyOn(markService, 'preload') + .mockResolvedValue(new LedgerMarkCache(new Map([[269, [{ created: new Date('2026-01-01'), priceChf: 0.95 }]]]))); + + const bankTxReturnRepo = createMock>(); + jest.spyOn(bankTxReturnRepo, 'findOne').mockResolvedValue(null); + const bankTxRepeatRepo = createMock>(); + jest.spyOn(bankTxRepeatRepo, 'findOne').mockResolvedValue(null); + + return new BankTxConsumer( + freshSettingService(), + ledger.bookingService, + ledger.accountService, + markService, + repoWith([row]), + bankRepo, + ledger.ledgerTxRepository(), + bankTxReturnRepo, + bankTxRepeatRepo, + ); + } + + it('closes Checkout/EUR to 0 in native AND CHF across seq0 + settlement → no phantom fxPnl for the acquirer fee', async () => { + // (a) Card input seq0: 1000 EUR gross charged on the card, 950 CHF gross → implied rate 0.95 + const card = Object.assign(new BuyCrypto(), { + id: 200, + created: CREATED, + updated: CREATED, + isComplete: false, // input only — the completion seq1 is irrelevant to the custody account + checkoutTx: { currency: 'EUR' } as any, + inputReferenceAmount: 1000, // card-currency gross (F2 custody native) + amountInChf: 950, // gross CHF + }); + await buyCryptoConsumer(card).process(); + + // (b) CHECKOUT_LTD settlement: net 990 EUR to the bank, acquirer fee 10 EUR (chargeAmount, settlement currency, + // ladder 2) / 9.5 CHF (chargeAmountChf) → custody Cr native gross −(990 + 10), CHF −(940.5 + 9.5) + const settlement = Object.assign(new BankTx(), { + id: 300, + created: CREATED, + updated: SETTLED, + bookingDate: SETTLED, + type: BankTxType.CHECKOUT_LTD, + creditDebitIndicator: BankTxIndicator.CREDIT, + accountIban: 'EUR-IBAN', + currency: 'EUR', + amount: 990, + chargeAmount: 10, + chargeCurrency: 'EUR', + chargeAmountChf: 9.5, + }); + await bankTxConsumer(settlement).process(); + + // both bookings committed + expect(ledger.txs.some((t) => t.sourceType === 'buy_crypto' && t.sourceId === '200' && t.seq === 0)).toBe(true); + expect(ledger.txs.some((t) => t.sourceType === 'bank_tx' && t.sourceId === '300' && t.seq === 0)).toBe(true); + + // the F2 convention closes the custody account in BOTH units: native 1000 − (990 + 10) = 0 AND CHF 950 − 950 = 0. + // With a balanced native+CHF pair the mark-to-market diff (mark × native − chf) is 0 by construction → nothing to + // flush → NO phantom fxPnl; the acquirer fee lives exactly once, on EXPENSE/acquirer-fee. + expect(ledger.nativeBalance('Checkout/EUR')).toBe(0); + expect(ledger.chfBalance('Checkout/EUR')).toBe(0); + expect(ledger.chfBalance('EXPENSE/acquirer-fee')).toBe(9.5); + + // settlement side effects stay mark-consistent: bank net 990 EUR / 940.5 CHF; the Card CHF sits on `received` + expect(ledger.nativeBalance('Olkypay/EUR')).toBe(990); + expect(ledger.chfBalance('Olkypay/EUR')).toBe(940.5); + expect(ledger.chfBalance('LIABILITY/buyCrypto-received')).toBe(-950); + + expect(ledger.everyTxBalances()).toBe(true); + }); +}); diff --git a/src/subdomains/core/accounting/services/__tests__/integration/crypto-input-cutover.integration.spec.ts b/src/subdomains/core/accounting/services/__tests__/integration/crypto-input-cutover.integration.spec.ts new file mode 100644 index 0000000000..617d33039f --- /dev/null +++ b/src/subdomains/core/accounting/services/__tests__/integration/crypto-input-cutover.integration.spec.ts @@ -0,0 +1,375 @@ +import { createMock } from '@golevelup/ts-jest'; +import { ConfigService } from 'src/config/config'; +import { ExchangeTx } from 'src/integration/exchange/entities/exchange-tx.entity'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { BuyCrypto } from 'src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity'; +import { LiquidityManagementOrder } from 'src/subdomains/core/liquidity-management/entities/liquidity-management-order.entity'; +import { BuyFiat } from 'src/subdomains/core/sell-crypto/process/buy-fiat.entity'; +import { TradingOrder } from 'src/subdomains/core/trading/entities/trading-order.entity'; +import { Bank } from 'src/subdomains/supporting/bank/bank/bank.entity'; +import { BankTx } from 'src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity'; +import { BankTxRepeat } from 'src/subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.entity'; +import { BankTxReturn } from 'src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.entity'; +import { LiquidityOrder } from 'src/subdomains/supporting/dex/entities/liquidity-order.entity'; +import { Log } from 'src/subdomains/supporting/log/log.entity'; +import { LogService } from 'src/subdomains/supporting/log/log.service'; +import { CryptoInput, PayInStatus } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; +import { PayoutOrder } from 'src/subdomains/supporting/payout/entities/payout-order.entity'; +import { Repository } from 'typeorm'; +import { AccountType } from '../../../entities/ledger-account.entity'; +import { BuyCryptoConsumer } from '../../consumers/buy-crypto.consumer'; +import { BuyFiatConsumer } from '../../consumers/buy-fiat.consumer'; +import { CryptoInputConsumer } from '../../consumers/crypto-input.consumer'; +import { LedgerBootstrapService } from '../../ledger-bootstrap.service'; +import { LedgerCutoverService } from '../../ledger-cutover.service'; +import { LedgerMarkCache, LedgerMarkService } from '../../ledger-mark.service'; +import { InMemoryLedger } from './in-memory-ledger'; + +/** + * §10.2 integration — the MAJOR cutover double-book finding for CRYPTO-funded (swap/sell) buy_crypto/buy_fiat inputs, + * run against the REAL LedgerCutoverService + CryptoInputConsumer + BuyCrypto/BuyFiatConsumer over a shared in-memory + * ledger (InMemoryLedger). The whole chain — cutover opening → forward crypto_input seq0 → completion — actually books, + * so LIABILITY/{bucket}-received provably nets across the boundary. + * + * The window (G-a exclusivity): a buy_crypto/buy_fiat priced at AML (amountInChf set on isConfirmed) but still OPEN at + * the snapshot, funded by a crypto_input NOT yet settled (status ∉ CryptoInputSettledStatus). The forward crypto_input + * seq0 credits ${bucket}-received once it settles post-cutover; the cutover per-row opening would credit it a SECOND + * time (disjoint sourceId namespaces → no UNIQUE backstop), leaving a permanent −amountInChf phantom on received after + * the completion debits once. The fix skips the per-row opening for an unsettled input; a settled input keeps it (its + * seq0 is suppressed as covered-by-cutover). All amounts synthetic; no real customer/account/IBAN (public repo). + */ +describe('Ledger crypto_input-funded cutover double-book (§10.2, MAJOR — G-a exclusivity)', () => { + const SNAPSHOT = new Date('2026-06-07T22:00:00Z'); + const POST_CUTOVER = new Date('2026-06-20T00:00:00Z'); + const LOG_ID = 1557344; + const WALLET = 100; // ZCHF wallet assetId + const CHF_BANK = 101; // CHF payout-bank assetId + + let ledger: InMemoryLedger; + let store: Map; + let settingService: SettingService; + let markService: LedgerMarkService; + + beforeEach(() => { + new ConfigService(); // sets the Config singleton the booking service + consumers read (§11.2) + + ledger = new InMemoryLedger(); + ledger.seedAsset('Ethereum/ZCHF', 'ZCHF', WALLET); + ledger.seedAsset('Maerki/CHF', 'CHF', CHF_BANK); + ledger.seed('ROUNDING', AccountType.ROUNDING, 'CHF'); + ledger.seed('EQUITY/opening-balance', AccountType.EQUITY, 'CHF'); + + // one stateful setting store shared by the cutover AND the consumers: the cutover writes the boundary/watermark/ + // logId/snapshotDate; the consumers read them back (the §4.7 G-a/G-b gate + §6.3 covered-by-cutover guard). + store = new Map(); + settingService = createMock(); + jest.spyOn(settingService, 'get').mockImplementation((key: string) => Promise.resolve(store.get(key) as any)); + jest.spyOn(settingService, 'getObj').mockImplementation((key: string, defaultValue?: any) => { + const raw = store.get(key); + return Promise.resolve(raw != null ? JSON.parse(raw) : defaultValue); + }); + jest.spyOn(settingService, 'set').mockImplementation((key: string, value: string) => { + store.set(key, value); + return Promise.resolve(); + }); + + markService = createMock(); + jest.spyOn(markService, 'preload').mockResolvedValue( + new LedgerMarkCache( + new Map([ + [WALLET, [{ created: new Date('2026-01-01'), priceChf: 1 }]], + [CHF_BANK, [{ created: new Date('2026-01-01'), priceChf: 1 }]], + ]), + ), + ); + }); + + // --- FIXTURES --- // + + function snapshotLog(): Log { + return Object.assign(new Log(), { + id: LOG_ID, + created: SNAPSHOT, + valid: true, + message: JSON.stringify({ assets: {}, tradings: {}, balancesByFinancialType: {}, balancesTotal: {} }), + }); + } + + // a repo whose createQueryBuilder returns a MAX(id) boundary of `max` (getRawMany = [] for the openHoleIds path) + function repoWith(rows: T[], max = 0): Repository { + const repo = createMock>(); + jest.spyOn(repo, 'find').mockResolvedValue(rows as any); + const qb: any = { + select: () => qb, + where: () => qb, + andWhere: () => qb, + getRawOne: () => Promise.resolve({ max }), + getRawMany: () => Promise.resolve([]), + }; + jest.spyOn(repo, 'createQueryBuilder').mockReturnValue(qb); + return repo; + } + + // returns `row` for the cutover's received query (where.outputAmount = IsNull()), [] for the owed query + function receivedOnly(row: T | undefined, cryptoBoundary = 0): Repository { + const repo = repoWith([], 0); + jest + .spyOn(repo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.outputAmount && row ? [row] : [])); + void cryptoBoundary; + return repo; + } + + /** + * builds + runs the REAL cutover over the shared ledger for one open buy_crypto/buy_fiat received row, with the + * crypto_input boundary pinned to `cryptoBoundary` (0 → an unsettled funding input is post-boundary → its forward + * seq0 books; ≥ the input id → it is covered-by-cutover → its seq0 is suppressed). + */ + async function runCutover(opts: { + buyCrypto?: BuyCrypto; + buyFiat?: BuyFiat; + cryptoBoundary?: number; + }): Promise { + const logService = createMock(); + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog()]); + jest.spyOn(logService, 'getLog').mockResolvedValue(snapshotLog()); + + const bootstrapService = createMock(); + jest.spyOn(bootstrapService, 'bootstrap').mockResolvedValue(); + + const service = new LedgerCutoverService( + settingService, + logService, + bootstrapService, + ledger.bookingService, + ledger.accountService, + markService, + receivedOnly(opts.buyFiat), + receivedOnly(opts.buyCrypto), + repoWith([]), + repoWith([]), + repoWith([]), + repoWith([]), + repoWith([], opts.cryptoBoundary ?? 0), + repoWith([]), + repoWith([]), + repoWith([]), + repoWith([]), + repoWith([]), + ); + + await service.run(); + } + + // runs the real CryptoInputConsumer over the shared ledger. `forward` = the row is in the forward id-batch (its seq0 + // books); otherwise it is reached only by the content-change scan (covered-by-cutover → seq0 suppressed). + async function runCryptoInput(ci: CryptoInput, forward: boolean): Promise { + const repo = createMock>(); + jest + .spyOn(repo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve((where?.updated != null) === !forward ? [ci] : [])); + await new CryptoInputConsumer( + settingService, + ledger.bookingService, + ledger.accountService, + markService, + repo, + ).process(); + } + + async function runBuyCrypto(bc: BuyCrypto): Promise { + const repo = createMock>(); + jest + .spyOn(repo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [] : [bc])); + await new BuyCryptoConsumer( + settingService, + ledger.bookingService, + ledger.accountService, + repo, + ledger.ledgerTxRepository(), + ).process(); + } + + async function runBuyFiat(bf: BuyFiat): Promise { + const repo = createMock>(); + jest + .spyOn(repo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [] : [bf])); + await new BuyFiatConsumer( + settingService, + ledger.bookingService, + ledger.accountService, + markService, + repo, + ledger.ledgerTxRepository(), + ).process(); + } + + // --- (a) UNSETTLED crypto_input at the cutover: NO per-row opening, forward seq0 is the sole opener --- // + + it('(a) buy_crypto with an UNSETTLED crypto_input: cutover skips the per-row opening; forward seq0 opens received; completion closes it to 0', async () => { + // OPEN at the cutover (outputAmount null), priced (amountInChf 1000), funded by a crypto_input still ACKNOWLEDGED + const openRow = Object.assign(new BuyCrypto(), { + id: 90, + created: new Date('2026-06-01T00:00:00Z'), + isComplete: false, + amountInChf: 1000, + outputAmount: null, + cryptoInput: { id: 900, status: PayInStatus.ACKNOWLEDGED }, + }); + + await runCutover({ buyCrypto: openRow, cryptoBoundary: 0 }); + + // the fix: NO per-row cutover received opening for the unsettled-input row + expect(ledger.txs.some((t) => t.sourceType === 'cutover' && t.sourceId === `${LOG_ID}:buy_crypto:90`)).toBe(false); + expect(ledger.chfBalance('LIABILITY/buyCrypto-received')).toBe(0); // untouched by the cutover (no credit leg) + + // post-cutover the crypto_input settles → its forward seq0 opens buyCrypto-received exactly once (−1000) + const ci = Object.assign(new CryptoInput(), { + id: 900, + updated: POST_CUTOVER, + status: PayInStatus.FORWARD_CONFIRMED, + amount: 1000, + asset: { id: WALLET, uniqueName: 'Ethereum/ZCHF' }, + buyCrypto: { amountInChf: 1000 }, + }); + await runCryptoInput(ci, true); + + const seq0s = ledger.txs.filter((t) => t.sourceType === 'crypto_input' && t.sourceId === '900' && t.seq === 0); + expect(seq0s).toHaveLength(1); // seq0 booked ONCE (no double-open) + expect(ledger.chfBalance('LIABILITY/buyCrypto-received')).toBe(-1000); // opened only by the forward seq0 + + // completion post-cutover → G-a gate (the seq0 opened received) → seq1 closes received to 0 + const completeRow = Object.assign(new BuyCrypto(), { + id: 90, + created: new Date('2026-06-01T00:00:00Z'), + isComplete: true, + amountInChf: 1000, + totalFeeAmountChf: 10, + outputAmount: 0.5, + outputDate: POST_CUTOVER, + updated: POST_CUTOVER, + cryptoInput: { id: 900 }, + }); + await runBuyCrypto(completeRow); + + expect(ledger.txs.some((t) => t.sourceType === 'buy_crypto' && t.sourceId === '90' && t.seq === 1)).toBe(true); + expect(ledger.chfBalance('LIABILITY/buyCrypto-received')).toBe(0); // NOT −1000 (the pre-fix phantom) + expect(ledger.chfBalance('LIABILITY/buyCrypto-owed')).toBe(-990); // −(amountInChf − fee) + expect(ledger.nativeBalance('Ethereum/ZCHF')).toBe(1000); // wallet debited once by the seq0, never doubled + expect(ledger.everyTxBalances()).toBe(true); + }); + + it('(a) buy_fiat with an UNSETTLED crypto_input: cutover skips the per-row opening; forward seq0 opens received; the sell chain closes received + owed to 0', async () => { + const openRow = Object.assign(new BuyFiat(), { + id: 80, + created: new Date('2026-06-01T00:00:00Z'), + isComplete: false, + amountInChf: 15000, + outputAmount: null, + cryptoInput: { id: 800, status: PayInStatus.ACKNOWLEDGED }, + }); + + await runCutover({ buyFiat: openRow, cryptoBoundary: 0 }); + + expect(ledger.txs.some((t) => t.sourceType === 'cutover' && t.sourceId === `${LOG_ID}:buy_fiat:80`)).toBe(false); + expect(ledger.chfBalance('LIABILITY/buyFiat-received')).toBe(0); // untouched by the cutover (no credit leg) + + const ci = Object.assign(new CryptoInput(), { + id: 800, + updated: POST_CUTOVER, + status: PayInStatus.FORWARD_CONFIRMED, + amount: 15000, + asset: { id: WALLET, uniqueName: 'Ethereum/ZCHF' }, + buyFiat: { amountInChf: 15000 }, + }); + await runCryptoInput(ci, true); + + expect( + ledger.txs.filter((t) => t.sourceType === 'crypto_input' && t.sourceId === '800' && t.seq === 0), + ).toHaveLength(1); + expect(ledger.chfBalance('LIABILITY/buyFiat-received')).toBe(-15000); + + // full regular-sell settlement post-cutover (CHF output): reclassification (seq1) + transmit (seq2) + booked (seq3) + const completeRow = Object.assign(new BuyFiat(), { + id: 80, + created: new Date('2026-06-01T00:00:00Z'), + updated: POST_CUTOVER, + amountInChf: 15000, + totalFeeAmountChf: 148.5, + outputAmount: 14851.5, + outputReferenceAmount: 14851.5, + outputAsset: { name: 'CHF' }, + cryptoInput: { id: 800, updated: POST_CUTOVER }, + fiatOutput: { + isTransmittedDate: POST_CUTOVER, + currency: 'CHF', + bank: { asset: { id: CHF_BANK } }, + bankTx: { bookingDate: POST_CUTOVER }, + }, + }); + await runBuyFiat(completeRow); + + expect(ledger.txs.some((t) => t.sourceType === 'buy_fiat' && t.sourceId === '80' && t.seq === 1)).toBe(true); + expect(ledger.chfBalance('LIABILITY/buyFiat-received')).toBe(0); // closed by seq1 (NOT −15000) + expect(ledger.chfBalance('LIABILITY/buyFiat-owed')).toBe(0); // reclassified then transmitted + booked + expect(ledger.chfBalance('TRANSIT/payout/CHF')).toBe(0); + expect(ledger.chfBalance('Maerki/CHF')).toBe(-14851.5); + expect(ledger.everyTxBalances()).toBe(true); + }); + + // --- (b) SETTLED crypto_input at the cutover: per-row opening kept, forward seq0 suppressed (covered) --- // + + it('(b) buy_crypto with a SETTLED crypto_input: cutover opens the per-row received; forward seq0 is suppressed (covered); completion closes received to 0 via G-b', async () => { + // funded by a crypto_input FORWARD_CONFIRMED at the snapshot → its value is in the aggregate ASSET opening; the + // cutover opens the per-row buyCrypto-received (id 91), and the crypto_input boundary covers input id 910. + const openRow = Object.assign(new BuyCrypto(), { + id: 91, + created: new Date('2026-06-01T00:00:00Z'), + isComplete: false, + amountInChf: 1000, + outputAmount: null, + cryptoInput: { id: 910, status: PayInStatus.FORWARD_CONFIRMED }, + }); + + await runCutover({ buyCrypto: openRow, cryptoBoundary: 910 }); + + // settled input → the per-row opening IS booked (−1000), the sole received opener + expect(ledger.txs.some((t) => t.sourceType === 'cutover' && t.sourceId === `${LOG_ID}:buy_crypto:91`)).toBe(true); + expect(ledger.chfBalance('LIABILITY/buyCrypto-received')).toBe(-1000); + + // the covered crypto_input is re-selected by the content-change scan (updated bump) but its seq0 is suppressed + const ci = Object.assign(new CryptoInput(), { + id: 910, + updated: POST_CUTOVER, + status: PayInStatus.FORWARD_CONFIRMED, + amount: 1000, + asset: { id: WALLET, uniqueName: 'Ethereum/ZCHF' }, + buyCrypto: { amountInChf: 1000 }, + }); + await runCryptoInput(ci, false); + + expect(ledger.txs.some((t) => t.sourceType === 'crypto_input' && t.sourceId === '910' && t.seq === 0)).toBe(false); + expect(ledger.chfBalance('LIABILITY/buyCrypto-received')).toBe(-1000); // still ONLY the cutover opening (no double) + + // completion post-cutover → G-b gate (the cutover marker opened received) → seq1 closes received to 0 + const completeRow = Object.assign(new BuyCrypto(), { + id: 91, + created: new Date('2026-06-01T00:00:00Z'), + isComplete: true, + amountInChf: 1000, + totalFeeAmountChf: 10, + outputAmount: 0.5, + outputDate: POST_CUTOVER, + updated: POST_CUTOVER, + cryptoInput: { id: 910 }, + }); + await runBuyCrypto(completeRow); + + expect(ledger.txs.some((t) => t.sourceType === 'buy_crypto' && t.sourceId === '91' && t.seq === 1)).toBe(true); + expect(ledger.chfBalance('LIABILITY/buyCrypto-received')).toBe(0); + expect(ledger.chfBalance('LIABILITY/buyCrypto-owed')).toBe(-990); + expect(ledger.everyTxBalances()).toBe(true); + }); +}); diff --git a/src/subdomains/core/accounting/services/__tests__/integration/db-write-isolation.integration.spec.ts b/src/subdomains/core/accounting/services/__tests__/integration/db-write-isolation.integration.spec.ts new file mode 100644 index 0000000000..ee55b4686e --- /dev/null +++ b/src/subdomains/core/accounting/services/__tests__/integration/db-write-isolation.integration.spec.ts @@ -0,0 +1,343 @@ +import { createMock } from '@golevelup/ts-jest'; +import { ConfigService } from 'src/config/config'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { Util } from 'src/shared/utils/util'; +import { LiquidityBalance } from 'src/subdomains/core/liquidity-management/entities/liquidity-balance.entity'; +import { LiquidityManagementBalanceService } from 'src/subdomains/core/liquidity-management/services/liquidity-management-balance.service'; +import { RefRewardService } from 'src/subdomains/core/referral/reward/services/ref-reward.service'; +import { BuyFiat } from 'src/subdomains/core/sell-crypto/process/buy-fiat.entity'; +import { LogService } from 'src/subdomains/supporting/log/log.service'; +import { MailRequest } from 'src/subdomains/supporting/notification/interfaces'; +import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; +import { CryptoInput, PayInStatus } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; +import { Repository } from 'typeorm'; +import { AccountType } from '../../../entities/ledger-account.entity'; +import { createCustomLedgerAccount } from '../../../entities/__mocks__/ledger-account.entity.mock'; +import { LedgerAccountRepository } from '../../../repositories/ledger-account.repository'; +import { LedgerLegRepository } from '../../../repositories/ledger-leg.repository'; +import { BuyFiatConsumer } from '../../consumers/buy-fiat.consumer'; +import { CryptoInputConsumer } from '../../consumers/crypto-input.consumer'; +import { LedgerBookingJobService } from '../../ledger-booking-job.service'; +import { LedgerMarkCache, LedgerMarkService } from '../../ledger-mark.service'; +import { LedgerReconciliationService } from '../../ledger-reconciliation.service'; +import { InMemoryLedger } from './in-memory-ledger'; + +const WRITE_METHODS = [ + 'save', + 'update', + 'insert', + 'delete', + 'remove', + 'upsert', + 'softDelete', + 'softRemove', + 'increment', + 'decrement', +] as const; +const ZCHF_WALLET = 200; +const CHF_BANK = 401; +const SETTLED = new Date('2026-06-04T00:00:00Z'); +const FRI = new Date('2026-06-05T00:00:00Z'); +const SUN = new Date('2026-06-07T00:00:00Z'); + +/** + * §10.2 DB-Write-Isolation (Major R3-1 / R9-1 / R12-1) — the dynamic counterpart to the static grep-gate. After a + * consumer run (and an alarm run), it asserts that NO write method of any business-/log-table repository was + * invoked, and that only the sanctioned non-ledger_* writes happened: the two ledger Settings (via settingService.set) + * and the notification queue (via NotificationService.sendMail, R2-exception-b). Every business table (bank_tx, + * exchange_tx, payout_order, crypto_input, buy_crypto, buy_fiat, liquidity_management_order, trading_order) PLUS the + * authoritative `log` table (FinancialDataLog) stay strictly read-only; only ledger_*, the two ledger settings, and + * notification may change. This catches a write that would slip past the static grep (e.g. via a renamed injection + * identifier). + */ +describe('Ledger DB-write isolation after a consumer/alarm run (§10.2)', () => { + let ledger: InMemoryLedger; + let markService: LedgerMarkService; + let refRewardService: RefRewardService; + + const markMap = new Map([ + [ZCHF_WALLET, [{ created: new Date('2026-01-01'), priceChf: 1 }]], + [CHF_BANK, [{ created: new Date('2026-01-01'), priceChf: 1 }]], + ]); + + beforeEach(() => { + new ConfigService(); + ledger = new InMemoryLedger(); + ledger.seedAsset('Ethereum/ZCHF', 'ZCHF', ZCHF_WALLET); + ledger.seedAsset('Maerki/CHF', 'CHF', CHF_BANK); + ledger.seed('ROUNDING', AccountType.ROUNDING, 'CHF'); + markService = createMock(); + jest.spyOn(markService, 'preload').mockResolvedValue(new LedgerMarkCache(markMap)); + refRewardService = createMock(); + jest.spyOn(refRewardService, 'getOpenRefCreditLiability').mockResolvedValue({ amountEur: 0, amountChf: 0 }); + }); + + // a row-count + MAX(updated) snapshot of a business table (§10.2 name-independent backstop): every write — whether + // via the injected repo, a renamed injection, dataSource.getRepository(X), or an injected source service — lands in + // the SAME backing store, so snapshotting the store row-count + MAX(updated) before/after a run catches it without + // depending on the write-method NAME (unlike a per-method spy on one specific injected mock instance). + interface TableSnapshot { + rowCount: number; + maxUpdated: number; // ms epoch, 0 when empty + } + function snapshotTable(rows: T[]): TableSnapshot { + return { + rowCount: rows.length, + maxUpdated: rows.reduce((max, r) => Math.max(max, r.updated?.getTime() ?? 0), 0), + }; + } + + // a source-table repository backed by a mutable `rows` store. ALL write methods are spied on AND mutate the store + // (so a row-count/MAX(updated) snapshot reflects any write); `find` honours the consumer's where/relations enough + // for a read-only observer. The store is what the §10.2 snapshot compares before/after the run. + function readOnlyRepo( + rows: T[], + ): { repo: Repository; store: T[]; writeSpies: jest.SpyInstance[] } { + const store = [...rows]; + const repo = createMock>(); + jest.spyOn(repo, 'find').mockResolvedValue(store as any); + // the spies still assert "no write method ever called"; they ALSO mutate the store so the snapshot would move if a + // write slipped through a renamed path (defence in depth — name-independent snapshot + name-bound spy) + const writeSpies = WRITE_METHODS.map((m) => + jest.spyOn(repo as any, m).mockImplementation((..._args: unknown[]) => { + store.push({ id: (store.length + 1) as any, updated: new Date() } as T); // any write moves the snapshot + return Promise.resolve(undefined as any); + }), + ); + return { repo, store, writeSpies }; + } + + function settingService(): { service: SettingService; setKeys: string[] } { + const service = createMock(); + const setKeys: string[] = []; + jest.spyOn(service, 'getObj').mockResolvedValue(undefined); + jest.spyOn(service, 'get').mockResolvedValue(undefined); // real SettingService.get → string|undefined (analog evidence-week:76) + jest.spyOn(service, 'set').mockImplementation((key: string) => { + setKeys.push(key); + return Promise.resolve(); + }); + return { service, setKeys }; + } + + it('books a buy_fiat chain WITHOUT touching any source-table write method (only ledger_* + ledger settings)', async () => { + const ci = Object.assign(new CryptoInput(), { + id: 10, + updated: SETTLED, + status: PayInStatus.FORWARD_CONFIRMED, + amount: 15000, + asset: { id: ZCHF_WALLET, uniqueName: 'Ethereum/ZCHF' }, + buyFiat: { id: 1, amountInChf: 15000 }, + }); + const bf = Object.assign(new BuyFiat(), { + id: 1, + updated: SETTLED, + amountInChf: 15000, + totalFeeAmountChf: 148.5, + outputAmount: 14851.5, + outputReferenceAmount: 14851.5, + outputAsset: { name: 'CHF' }, + cryptoInput: { id: 10, updated: SETTLED }, + fiatOutput: { + isTransmittedDate: FRI, + currency: 'CHF', + bank: { asset: { id: CHF_BANK } }, + bankTx: { bookingDate: SUN }, + }, + }); + + const ciSrc = readOnlyRepo([ci]); + const bfSrc = readOnlyRepo([bf]); + const ciSetting = settingService(); + const bfSetting = settingService(); + + // §10.2 name-independent backstop: row-count + MAX(updated) snapshot of every source table BEFORE the run + const before = { crypto_input: snapshotTable(ciSrc.store), buy_fiat: snapshotTable(bfSrc.store) }; + + await new CryptoInputConsumer( + ciSetting.service, + ledger.bookingService, + ledger.accountService, + markService, + ciSrc.repo, + ).process(); + await new BuyFiatConsumer( + bfSetting.service, + ledger.bookingService, + ledger.accountService, + markService, + bfSrc.repo, + ledger.ledgerTxRepository(), + ).process(); + + // the run actually booked something (otherwise the isolation assertion is vacuous) + expect(ledger.txs.length).toBeGreaterThan(0); + + // (1) name-independent: the source-table snapshots (row-count + MAX(updated)) are unchanged — catches a write via + // ANY path (renamed injection, getRepository(X).save, an injected source service) because they all mutate the store + expect(snapshotTable(ciSrc.store)).toEqual(before.crypto_input); + expect(snapshotTable(bfSrc.store)).toEqual(before.buy_fiat); + + // (2) name-bound: NO write method of the crypto_input / buy_fiat source repos was ever called (read-only observer) + for (const spy of [...ciSrc.writeSpies, ...bfSrc.writeSpies]) { + expect(spy).not.toHaveBeenCalled(); + } + + // (3) the ONLY non-ledger_* writes are the two ledger watermark settings (sanctioned R2-exception-a) + for (const key of [...ciSetting.setKeys, ...bfSetting.setKeys]) { + expect(key).toMatch(/^ledgerWatermark\.|^ledgerCutoverLogId$/); + } + }); + + it('keeps the authoritative log table read-only: LogService write methods are never called by a consumer', async () => { + // the consumer reads marks via LogService.getFinancialLogs only (preload); create/update must never be called + const logService = createMock(); + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([]); + const createSpy = jest.spyOn(logService, 'create'); + const updateSpy = jest.spyOn(logService, 'update'); + + // a real mark service over the read-only LogService → exercises the genuine getFinancialLogs read path + const realMarkService = new LedgerMarkService(logService); + const ci = Object.assign(new CryptoInput(), { + id: 20, + updated: SETTLED, + status: PayInStatus.FORWARD_CONFIRMED, + amount: 15000, + asset: { id: ZCHF_WALLET, uniqueName: 'Ethereum/ZCHF' }, + buyFiat: { id: 2, amountInChf: 15000 }, + }); + const ciSrc = readOnlyRepo([ci]); + + await new CryptoInputConsumer( + settingService().service, + ledger.bookingService, + ledger.accountService, + realMarkService, + ciSrc.repo, + ).process(); + + expect(logService.getFinancialLogs).toHaveBeenCalled(); // the read path was exercised + expect(createSpy).not.toHaveBeenCalled(); // never written (Hard Constraint #6, the most authoritative table) + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it('the only notification write of an alarm run is via NotificationService.sendMail (count grows by N, R12-1)', async () => { + // model the notification table as a row-count + MAX(id) snapshot (the only sanctioned non-ledger_* "may change" + // business table besides settings) — sendMail INSERTs exactly one row per alarm (+ post-INSERT UPDATEs on the + // same row → COUNT grows by N, not "N writes"; Minor R13-1) + const notification: { id: number }[] = []; + let nextId = 1; + const notificationService = createMock(); + const sendMailSpy = jest.spyOn(notificationService, 'sendMail').mockImplementation((_req: MailRequest) => { + notification.push({ id: nextId++ }); // INSERT (the post-INSERT updateNotification does not grow the count) + return Promise.resolve(); + }); + + const jobService = createMock(); + jest.spyOn(jobService, 'isLedgerReady').mockResolvedValue(true); + const liqBalance = createMock(); + const logService = createMock(); + jest.spyOn(logService, 'getLatestFinancialLog').mockResolvedValue(undefined); + const accountRepo = createMock(); + const legRepo = createMock(); + const recSetting = createMock(); + jest.spyOn(recSetting, 'get').mockResolvedValue('0'); + jest.spyOn(logService, 'getLatestValidFinancialLogs').mockResolvedValue([]); // no snapshot → equity parity skipped + + // an account whose feed is stale → exactly one aggregated "unverified accounts" alarm + const now = new Date(); + const account = createCustomLedgerAccount({ + id: 1005, + name: 'OnChain/5', + type: AccountType.ASSET, + assetId: 5, + asset: Object.assign(new Asset(), { id: 5, blockchain: Blockchain.ETHEREUM }), + } as any); + jest.spyOn(accountRepo, 'find').mockResolvedValue([account]); + jest.spyOn(liqBalance, 'getBalances').mockResolvedValue([ + Object.assign(new LiquidityBalance(), { + asset: { id: 5 } as Asset, + amount: 123, + updated: Util.hoursBefore(10, now), + }), + ]); + const emptyQb: any = {}; + for (const m of ['innerJoin', 'select', 'addSelect', 'where', 'andWhere', 'groupBy', 'addGroupBy', 'having']) { + emptyQb[m] = () => emptyQb; + } + emptyQb.getRawMany = () => Promise.resolve([]); + emptyQb.getRawOne = () => Promise.resolve({ native: '0', chf: '0' }); + jest.spyOn(legRepo, 'createQueryBuilder').mockReturnValue(emptyQb); + + const service = new LedgerReconciliationService( + jobService, + recSetting, + logService, + notificationService, + liqBalance, + accountRepo, + legRepo, + markService, + refRewardService, + ); + + const before = notification.length; + await service.run(); + const after = notification.length; + + // notification grew by exactly the number of alarms sent (count delta == N sendMail INSERTs) + expect(sendMailSpy).toHaveBeenCalledTimes(1); + expect(after - before).toBe(1); + + // the feed read happened (the run actually executed) and only via the whitelisted getBalances (no refresh*) + expect(liqBalance.getBalances).toHaveBeenCalledTimes(1); + expect(liqBalance.refreshBalances).not.toHaveBeenCalled(); + expect(liqBalance.refreshBankBalance).not.toHaveBeenCalled(); + expect(liqBalance.hasPendingOrders).not.toHaveBeenCalled(); + }); + + it('an alarm-free reconciliation run leaves the notification count unchanged', async () => { + const notification: { id: number }[] = []; + const notificationService = createMock(); + jest.spyOn(notificationService, 'sendMail').mockImplementation(() => { + notification.push({ id: notification.length + 1 }); + return Promise.resolve(); + }); + + const jobService = createMock(); + jest.spyOn(jobService, 'isLedgerReady').mockResolvedValue(true); + const liqBalance = createMock(); + jest.spyOn(liqBalance, 'getBalances').mockResolvedValue([]); // no accounts → no diff, no unverified → no alarm + const logService = createMock(); + jest.spyOn(logService, 'getLatestValidFinancialLogs').mockResolvedValue([]); // no snapshot → equity parity skipped + const accountRepo = createMock(); + jest.spyOn(accountRepo, 'find').mockResolvedValue([]); + const legRepo = createMock(); + const emptyQb: any = {}; + for (const m of ['innerJoin', 'select', 'addSelect', 'where', 'andWhere', 'groupBy', 'addGroupBy', 'having']) { + emptyQb[m] = () => emptyQb; + } + emptyQb.getRawMany = () => Promise.resolve([]); + emptyQb.getRawOne = () => Promise.resolve({ native: '0', chf: '0' }); + jest.spyOn(legRepo, 'createQueryBuilder').mockReturnValue(emptyQb); + const recSetting = createMock(); + jest.spyOn(recSetting, 'get').mockResolvedValue('0'); + + const service = new LedgerReconciliationService( + jobService, + recSetting, + logService, + notificationService, + liqBalance, + accountRepo, + legRepo, + markService, + refRewardService, + ); + + await service.run(); + + expect(notification).toHaveLength(0); // no alarm → no notification write (count unchanged) + }); +}); diff --git a/src/subdomains/core/accounting/services/__tests__/integration/evidence-week.integration.spec.ts b/src/subdomains/core/accounting/services/__tests__/integration/evidence-week.integration.spec.ts new file mode 100644 index 0000000000..ac0c494605 --- /dev/null +++ b/src/subdomains/core/accounting/services/__tests__/integration/evidence-week.integration.spec.ts @@ -0,0 +1,744 @@ +import { createMock } from '@golevelup/ts-jest'; +import { ConfigService } from 'src/config/config'; +import { ExchangeName } from 'src/integration/exchange/enums/exchange.enum'; +import { ExchangeTx, ExchangeTxType } from 'src/integration/exchange/entities/exchange-tx.entity'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { Bank } from 'src/subdomains/supporting/bank/bank/bank.entity'; +import { BankTx, BankTxIndicator, BankTxType } from 'src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity'; +import { BankTxRepeat } from 'src/subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.entity'; +import { BankTxReturn } from 'src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.entity'; +import { CryptoInput, PayInStatus } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; +import { BuyFiat } from 'src/subdomains/core/sell-crypto/process/buy-fiat.entity'; +import { Repository } from 'typeorm'; +import { AccountType } from '../../../entities/ledger-account.entity'; +import { LedgerLegRepository } from '../../../repositories/ledger-leg.repository'; +import { BankTxConsumer } from '../../consumers/bank-tx.consumer'; +import { BuyFiatConsumer } from '../../consumers/buy-fiat.consumer'; +import { CryptoInputConsumer } from '../../consumers/crypto-input.consumer'; +import { ExchangeTxConsumer } from '../../consumers/exchange-tx.consumer'; +import { LedgerMarkCache, LedgerMarkService } from '../../ledger-mark.service'; +import { InMemoryLedger } from './in-memory-ledger'; + +// asset ids of the seeded ASSET accounts (synthetic, structurally equal — no real customer/account data) +const ZCHF_WALLET = 200; +const CHF_BANK = 401; +const EUR_BANK = 402; +const SCRYPT_EUR = 50; +const SCRYPT_CHF = 51; +const SCRYPT_USDT = 60; + +const FRI = new Date('2026-06-05T00:00:00Z'); // transmission (isTransmittedDate) +const SUN = new Date('2026-06-07T00:00:00Z'); // bank booking (Class-1 hold) +const SETTLED = new Date('2026-06-04T00:00:00Z'); + +/** + * §10.2 integration tests = the synthetic evidence-week, run against the REAL booking+account services over a + * shared in-memory ledger (InMemoryLedger). Unlike the unit consumer specs (which mock the booking service), this + * proves that the received/owed/TRANSIT/SUSPENSE liabilities net to 0 ACROSS consumers — the Class-1/2/4 + * elimination thesis of Issue #385 — and that the single per-tx invariant Σ amountChfCents = 0 holds over + * everything. All amounts are synthetic/scaled structural ratios (Minor R1-4); no real PRD triple, no real IBAN. + */ +describe('Ledger evidence-week integration (§10.2)', () => { + let ledger: InMemoryLedger; + let markService: LedgerMarkService; + + // ZCHF mark ≈ 1, CHF bank = 1, EUR bank = 0.95, Scrypt assets (EUR 0.95 / CHF 1 / USDT 0.9) + const markMap = new Map([ + [ZCHF_WALLET, [{ created: new Date('2026-01-01'), priceChf: 1 }]], + [CHF_BANK, [{ created: new Date('2026-01-01'), priceChf: 1 }]], + [EUR_BANK, [{ created: new Date('2026-01-01'), priceChf: 0.95 }]], + [SCRYPT_EUR, [{ created: new Date('2026-01-01'), priceChf: 0.95 }]], + [SCRYPT_CHF, [{ created: new Date('2026-01-01'), priceChf: 1 }]], + [SCRYPT_USDT, [{ created: new Date('2026-01-01'), priceChf: 0.9 }]], + ]); + + beforeEach(() => { + new ConfigService(); // sets the Config singleton the booking service + consumers read (§11.2) + + ledger = new InMemoryLedger(); + // CoA bootstrap stand-in: ASSET accounts (by assetId) + the up-front non-ASSET accounts (§3.2/§3.4) + ledger.seedAsset('Ethereum/ZCHF', 'ZCHF', ZCHF_WALLET); + ledger.seedAsset('Maerki/CHF', 'CHF', CHF_BANK); + ledger.seedAsset('Olkypay/EUR', 'EUR', EUR_BANK); + ledger.seedAsset('Scrypt/EUR', 'EUR', SCRYPT_EUR); + ledger.seedAsset('Scrypt/CHF', 'CHF', SCRYPT_CHF); + ledger.seedAsset('Scrypt/USDT', 'USDT', SCRYPT_USDT); + ledger.seed('ROUNDING', AccountType.ROUNDING, 'CHF'); + ledger.seed('EQUITY/opening-balance', AccountType.EQUITY, 'CHF'); + + markService = createMock(); + jest.spyOn(markService, 'preload').mockResolvedValue(new LedgerMarkCache(markMap)); + }); + + // --- FIXTURE FACTORIES (synthetic, structurally equal) --- // + + function settingService(): SettingService { + const s = createMock(); + jest.spyOn(s, 'getObj').mockResolvedValue(undefined); // fresh watermark (cutover-only default already past) + jest.spyOn(s, 'get').mockResolvedValue(undefined); // no ledgerCutoverLogId by default (real default = unset) + jest.spyOn(s, 'set').mockResolvedValue(); + return s; + } + + function cryptoInput(values: Partial): CryptoInput { + return Object.assign(new CryptoInput(), { + id: 1, + updated: SETTLED, + status: PayInStatus.FORWARD_CONFIRMED, + amount: 15000, + asset: { id: ZCHF_WALLET, uniqueName: 'Ethereum/ZCHF' }, + ...values, + }); + } + + function buyFiat(values: Partial): BuyFiat { + return Object.assign(new BuyFiat(), { + id: 1, + updated: SETTLED, + cryptoInput: { id: 10, updated: SETTLED }, + outputAsset: { name: 'CHF' }, + ...values, + }); + } + + function bankTx(values: Partial): BankTx { + return Object.assign(new BankTx(), { + id: 1, + created: new Date('2026-06-01T00:00:00Z'), + updated: new Date('2026-06-01T00:00:00Z'), // IEntity always sets updated; the §4.12 content-change scan reads it + bookingDate: new Date('2026-06-01T00:00:00Z'), + creditDebitIndicator: BankTxIndicator.CREDIT, + currency: 'EUR', + ...values, + }); + } + + function exchangeTx(values: Partial): ExchangeTx { + return Object.assign(new ExchangeTx(), { + id: 1, + created: SETTLED, + externalCreated: SETTLED, + updated: SETTLED, // IEntity always sets updated; the §4.3 content-change scan reads it (combined cursor) + exchange: ExchangeName.SCRYPT, + status: 'ok', + ...values, + }); + } + + // wires a CryptoInput consumer against the shared ledger + function cryptoInputConsumer(rows: CryptoInput[]): CryptoInputConsumer { + const repo = createMock>(); + jest.spyOn(repo, 'find').mockResolvedValue(rows); + return new CryptoInputConsumer(settingService(), ledger.bookingService, ledger.accountService, markService, repo); + } + + // wires a BuyFiat consumer against the shared ledger (its gate reads the in-memory LedgerTx store) + function buyFiatConsumer(rows: BuyFiat[]): BuyFiatConsumer { + const repo = createMock>(); + jest.spyOn(repo, 'find').mockResolvedValue(rows); + return new BuyFiatConsumer( + settingService(), + ledger.bookingService, + ledger.accountService, + markService, + repo, + ledger.ledgerTxRepository(), + ); + } + + // wires a BankTx consumer against the shared ledger; the Bank repo resolves the iban→asset lookup + // empty chargeback-link repos: no BANK_TX_RETURN/REPEAT opening row → the chargeback opening-CHF lookup falls back to + // the close value (the evidence-week fixtures do not exercise the return/repeat chargeback opening anchor) + function chargebackRepos(): [Repository, Repository] { + const ret = createMock>(); + jest.spyOn(ret, 'findOne').mockResolvedValue(null); + const rep = createMock>(); + jest.spyOn(rep, 'findOne').mockResolvedValue(null); + return [ret, rep]; + } + + function bankTxConsumer(rows: BankTx[], banks: Bank[] = []): BankTxConsumer { + const bankTxRepo = createMock>(); + jest.spyOn(bankTxRepo, 'find').mockResolvedValue(rows); + const bankRepo = createMock>(); + // a representative tracked EUR bank (Olkypay/EUR = EUR_BANK) so the §4.2a untracked-bank path resolves the EUR mark + const eurMarkBank = Object.assign(new Bank(), { name: 'Olkypay', currency: 'EUR', asset: { id: EUR_BANK } }); + jest.spyOn(bankRepo, 'findOne').mockImplementation(({ where }: any) => { + if (where?.iban != null) return Promise.resolve(banks.find((b) => b.iban === where.iban) ?? null); + // §4.2a currencyMarkAssetId: an untracked EUR bank borrows the EUR mark from a tracked EUR bank asset + if (where?.currency === 'EUR') return Promise.resolve(eurMarkBank); + return Promise.resolve(null); + }); + const [returnRepo, repeatRepo] = chargebackRepos(); + return new BankTxConsumer( + settingService(), + ledger.bookingService, + ledger.accountService, + markService, + bankTxRepo, + bankRepo, + ledger.ledgerTxRepository(), + returnRepo, + repeatRepo, + ); + } + + // wires an ExchangeTx consumer against the shared ledger (+ the shared leg store for the sweep match) + function exchangeTxConsumer(rows: ExchangeTx[]): ExchangeTxConsumer { + const exchangeTxRepo = createMock>(); + jest.spyOn(exchangeTxRepo, 'find').mockImplementation(({ where, select }: any) => { + if (select) return Promise.resolve([]); // fill-index existing-trades lookup (select-only query) + // the §4.3 content-change scan (where.updated is a combined-cursor Raw) returns [] here — these integration tests + // assert only the forward booking; the ok→failed reversal path is covered by the exchange-tx unit scan tests + if (where?.updated != null) return Promise.resolve([]); + // honour the consumer's settled filter (status='ok' eliminates Class 2) — pending rows are not returned + return Promise.resolve(rows.filter((r) => where?.status == null || r.status === where.status)); + }); + const bankTxRepo = createMock>(); + jest.spyOn(bankTxRepo, 'find').mockResolvedValue([]); // no bank route match → wallet/SUSPENSE branch + + const legRepo = createMock(); + jest.spyOn(legRepo, 'find').mockImplementation(({ where }: any) => { + // §4.3b Raiffeisen sweep match: open Dr posts on the named SUSPENSE account within the date window + const accountId = where?.account?.id; + return Promise.resolve( + ledger.legs.filter( + (l) => l.account?.id === accountId && l.amount > 0 && l.account?.type === AccountType.SUSPENSE, + ) as any, + ); + }); + + return new ExchangeTxConsumer( + settingService(), + ledger.bookingService, + ledger.accountService, + markService, + exchangeTxRepo, + bankTxRepo, + legRepo, + ); + } + + // --- 1. CLASS-1 LIABILITY-HOLD (the 14'980.12 headline, single bf, Fri-transmit → Sun-booking) --- // + + it('Class-1: a single buy_fiat holds its liability until the Sunday booking (15000/148.50/14851.50)', async () => { + // crypto_input opens received −15000 (single booker, §4.1); buy_fiat does the fee/owed/TRANSIT/bank chain. + // the crypto_input eager-loads buyFiat with amountInChf (the received-Cr base anchor, §4.4) + const ci = cryptoInput({ id: 10, amount: 15000, buyFiat: { id: 1, amountInChf: 15000 } as any }); + const bf = buyFiat({ + id: 1, + amountInChf: 15000, + totalFeeAmountChf: 148.5, + outputAmount: 14851.5, + outputReferenceAmount: 14851.5, + outputAsset: { name: 'CHF' } as any, + cryptoInput: { id: 10, updated: SETTLED } as any, + fiatOutput: { + isTransmittedDate: FRI, + currency: 'CHF', + bank: { asset: { id: CHF_BANK } }, + bankTx: { bookingDate: SUN }, + } as any, + }); + + await cryptoInputConsumer([ci]).process(); + await buyFiatConsumer([bf]).process(); + + // received: opened −15000 (crypto_input seq0), debited +15000 (buy_fiat seq1 fee+reclass) → closes to 0 + expect(ledger.chfBalance('LIABILITY/buyFiat-received')).toBe(0); + // owed: opened −14851.50 (seq1 reclass), transmitted +14851.50 (seq2) → closes to 0 + expect(ledger.chfBalance('LIABILITY/buyFiat-owed')).toBe(0); + + // the value stays in TRANSIT between Friday and Sunday — closed only by the Sunday bank booking + const transitTx = ledger.txs.find((t) => t.sourceType === 'buy_fiat' && t.seq === 2); + expect(transitTx.bookingDate).toEqual(FRI); + const bookedTx = ledger.txs.find((t) => t.sourceType === 'buy_fiat' && t.seq === 3); + expect(bookedTx.bookingDate).toEqual(SUN); // the single 14851.50 bank debit happens at bookingDate, NOT Friday + expect(ledger.chfBalance('TRANSIT/payout/CHF')).toBe(0); // nets after the Sunday booking + + // the bank is debited EXACTLY once by exactly the output amount + expect(ledger.chfBalance('Maerki/CHF')).toBe(-14851.5); + expect(ledger.nativeBalance('Maerki/CHF')).toBe(-14851.5); + + // exactly one received-credit (no double input leg, single-booker §4.1) + const receivedCredits = ledger.legs.filter((l) => l.account?.name === 'LIABILITY/buyFiat-received' && l.amount < 0); + expect(receivedCredits).toHaveLength(1); + expect(receivedCredits[0].amountChf).toBe(-15000); + + // INCOME/fee-buyFiat realized exactly the 148.50 fee + expect(ledger.chfBalance('INCOME/fee-buyFiat')).toBe(-148.5); + expect(ledger.everyTxBalances()).toBe(true); + }); + + // --- 2. SEPARATE SYNTHETIC N:1 DEFENSIVE (decoupled from the headline, §1.13 / Major R10-1) --- // + + it('N:1-defensive: three buy_fiats → one fiat_output → ASSET/bank debited once per row (Σ = fiat_output.amount)', async () => { + const sharedOutput = { + isTransmittedDate: FRI, + currency: 'CHF', + bank: { asset: { id: CHF_BANK } }, + bankTx: { bookingDate: SUN }, + amount: 1800, // fiat_output.amount = Σ bf.outputAmount = 1000 + 500 + 300 + }; + const makeCi = (id: number, amount: number, bfId: number) => + cryptoInput({ id, amount, buyFiat: { id: bfId, amountInChf: amount } as any }); + const makeBf = (id: number, ciId: number, out: number) => + buyFiat({ + id, + amountInChf: out, + totalFeeAmountChf: 0, + outputAmount: out, + outputReferenceAmount: out, + outputAsset: { name: 'CHF' } as any, + cryptoInput: { id: ciId, updated: SETTLED } as any, + fiatOutput: sharedOutput as any, + }); + + await cryptoInputConsumer([makeCi(11, 1000, 101), makeCi(12, 500, 102), makeCi(13, 300, 103)]).process(); + await buyFiatConsumer([makeBf(101, 11, 1000), makeBf(102, 12, 500), makeBf(103, 13, 300)]).process(); + + // three seq3 bank legs, each its own outputAmount; ASSET/bank debited by exactly fiat_output.amount = 1800 + const bankLegs = ledger.legs.filter((l) => l.account?.name === 'Maerki/CHF'); + expect(bankLegs).toHaveLength(3); + expect(bankLegs.map((l) => l.amountChf).sort((a, b) => a - b)).toEqual([-1000, -500, -300].sort((a, b) => a - b)); + expect(ledger.chfBalance('Maerki/CHF')).toBe(-1800); // NOT the full bank_tx once, NOT just one of the three + + // all three owed close to 0, TRANSIT closes to 0, every received closes to 0 + expect(ledger.chfBalance('LIABILITY/buyFiat-owed')).toBe(0); + expect(ledger.chfBalance('TRANSIT/payout/CHF')).toBe(0); + expect(ledger.chfBalance('LIABILITY/buyFiat-received')).toBe(0); + + // each buy_fiat carries its own (sourceType, sourceId, seq) idempotency key (no UNIQUE collision) + for (const id of [101, 102, 103]) { + expect(ledger.txs.some((t) => t.sourceType === 'buy_fiat' && t.sourceId === `${id}` && t.seq === 3)).toBe(true); + } + expect(ledger.everyTxBalances()).toBe(true); + }); + + // --- 3. CLASS-2 DOUBLE-COUNTING WINDOW (pending not booked, then ok → exactly one booking) --- // + + it('Class-2: a Scrypt deposit pending is not booked, then ok books once → TRANSIT closes, no imbalance', async () => { + // pending: status != 'ok' → the consumer's settled filter excludes it (no booking) + const pending = exchangeTx({ + id: 1, + type: ExchangeTxType.DEPOSIT, + status: 'pending', + currency: 'EUR', + amount: 1000, + txId: '0xdeposit', + }); + await exchangeTxConsumer([pending]).process(); + expect(ledger.txs.filter((t) => t.sourceType === 'exchange_tx')).toHaveLength(0); + + // ok: wallet→exchange deposit (txId present, no bank route) → TRANSIT/wallet↔Scrypt/EUR + const settled = exchangeTx({ + id: 1, + type: ExchangeTxType.DEPOSIT, + status: 'ok', + currency: 'EUR', + amount: 1000, + amountChf: 950, + txId: '0xdeposit', + }); + // the wallet-side withdrawal closing the same TRANSIT route (mirror leg → nets to 0) + const walletWithdrawal = exchangeTx({ + id: 2, + type: ExchangeTxType.WITHDRAWAL, + status: 'ok', + currency: 'EUR', + amount: 1000, + amountChf: 950, + txId: '0xwithdraw', + }); + await exchangeTxConsumer([settled, walletWithdrawal]).process(); + + // EXACTLY one booking of the deposit (no double count from the pending phase) + expect(ledger.txs.filter((t) => t.sourceType === 'exchange_tx' && t.sourceId === '1')).toHaveLength(1); + // the deposit + withdrawal net the same TRANSIT/wallet↔Scrypt/EUR route to 0 (no journal imbalance) + expect(ledger.chfBalance('TRANSIT/wallet↔Scrypt/EUR')).toBe(0); + expect(ledger.everyTxBalances()).toBe(true); + }); + + // --- 5. CLASS-4 SWEEP → SUSPENSE (generic untracked-bank rule, no bank-name hardcode) --- // + + it('Class-4: an untracked-bank credit lands in SUSPENSE (EUR-mark-valued, no full-value phantom), then is swept', async () => { + // generic untracked-bank rule (no Bank row matches the iban) → SUSPENSE/untracked-bank-{name}-{ccy} (§4.2/§1.6). + // §4.2a SUSPENSE variant (Blocker fix): the SUSPENSE leg is EUR-mark-valued via a representative same-currency + // tracked-bank asset (0.95 × 1000 = 950 CHF) — NOT a needsMark hole. received = amountInChf (948 here, a deliberate + // Mark↔Pricing drift), so the §4.2a plug is the SMALL −2 valuation residual, NOT a full-value +948 phantom in + // INCOME/fx-revaluation (the bug this test now guards). The exchange deposit then sweeps the native SUSPENSE. + const credit = bankTx({ + id: 1, + type: BankTxType.BUY_CRYPTO, + creditDebitIndicator: BankTxIndicator.CREDIT, + currency: 'EUR', + amount: 1000, + bankName: 'Raiffeisen', + accountIban: 'SYNTH-UNTRACKED-IBAN', + buyCrypto: { amountInChf: 948 } as any, // deliberate 2-CHF drift vs EUR-mark × amount (950) + }); + await bankTxConsumer([credit]).process(); + + const suspenseName = 'SUSPENSE/untracked-bank-Raiffeisen-EUR'; + expect(ledger.hasAccount(suspenseName)).toBe(true); + expect(ledger.nativeBalance(suspenseName)).toBe(1000); // Dr SUSPENSE native EUR (value entered, awaiting sweep) + expect(ledger.chfBalance(suspenseName)).toBe(950); // EUR-mark × amount, mark-consistent (NOT a needsMark hole) + expect(ledger.chfBalance('LIABILITY/buyCrypto-received')).toBe(-948); // Cr received fully counted (Class-4 fix) + // the §4.2a plug is the SMALL valuation residual (−2 → Dr EXPENSE/fx-revaluation), NOT a full-value +948 phantom + // (the old bug treated the unmarked SUSPENSE leg as 0 and plugged the FULL received value into INCOME) + expect(ledger.chfBalance('EXPENSE/fx-revaluation')).toBe(-2); // |residual| = 2, far below the full value + expect(ledger.chfBalance('INCOME/fx-revaluation')).toBe(0); // no full-value phantom on the INCOME side either + + // the Scrypt-EUR deposit sweep matches the open SUSPENSE post by amount/date → drives SUSPENSE native back to 0 + const sweep = exchangeTx({ + id: 1, + type: ExchangeTxType.DEPOSIT, + status: 'ok', + exchange: ExchangeName.SCRYPT, + currency: 'EUR', + amount: 1000, + amountChf: 950, + externalCreated: new Date('2026-06-02T00:00:00Z'), + }); + await exchangeTxConsumer([sweep]).process(); + + expect(ledger.nativeBalance(suspenseName)).toBe(0); // swept down, not monotonically growing (Class-4 thesis) + expect(ledger.chfBalance('Scrypt/EUR')).toBe(950); // value arrived in the exchange custody account + expect(ledger.everyTxBalances()).toBe(true); + }); + + // --- 6. TRADE VIA symbol/side --- // + + it('Trade via symbol/side: a Scrypt buy books base/quote legs + the persisted spread, Σ CHF = 0', async () => { + // Scrypt trade: feeAmountChf IS the market spread (§4.3 variant i) → one spread leg, quote leg as plug + const trade = exchangeTx({ + id: 1, + type: ExchangeTxType.TRADE, + status: 'ok', + symbol: 'USDT/CHF', + side: 'buy', + amount: 1000, // base USDT + amountChf: 900, // base CHF value (mark 0.9) + cost: 905, // quote CHF spent + feeAmountChf: 5, // market spread + order: 'order-1', + }); + await exchangeTxConsumer([trade]).process(); + + const tradeTx = ledger.txs.find((t) => t.sourceType === 'ExchangeTrade'); + expect(tradeTx).toBeDefined(); + // base leg (USDT bought) and quote leg (CHF spent) both booked + expect(ledger.nativeBalance('Scrypt/USDT')).toBe(1000); // base +amount + expect(ledger.chfBalance('EXPENSE/spread-Scrypt')).toBe(5); // the persisted market spread as one leg + expect(ledger.everyTxBalances()).toBe(true); + + // null-symbol trade → SUSPENSE rest (not silently dropped) + const unattributable = exchangeTx({ id: 2, type: ExchangeTxType.TRADE, status: 'ok', amountChf: 100 }); + await exchangeTxConsumer([unattributable]).process(); + expect(ledger.hasAccount('SUSPENSE/Scrypt-trade-unattributed')).toBe(true); + }); + + // --- 8. BALANCE INVARIANT OVER EVERYTHING --- // + + it('Balance invariant: every booked tx of the whole evidence week closes to amountChfSum === 0', async () => { + // run a heterogeneous mix through the shared ledger, then assert the single per-tx invariant over ALL of it + const ci = cryptoInput({ id: 10, amount: 15000, buyFiat: { id: 1, amountInChf: 15000 } as any }); + const bf = buyFiat({ + id: 1, + amountInChf: 15000, + totalFeeAmountChf: 148.5, + outputAmount: 14851.5, + outputReferenceAmount: 14851.5, + cryptoInput: { id: 10, updated: SETTLED } as any, + fiatOutput: { + isTransmittedDate: FRI, + currency: 'CHF', + bank: { asset: { id: CHF_BANK } }, + bankTx: { bookingDate: SUN }, + } as any, + }); + const eurCredit = bankTx({ + id: 1, + type: BankTxType.BANK_TX_RETURN, + creditDebitIndicator: BankTxIndicator.CREDIT, + currency: 'EUR', + amount: 100, + accountIban: 'SYNTH-EUR-IBAN', + }); + const eurBank = Object.assign(new Bank(), { + iban: 'SYNTH-EUR-IBAN', + currency: 'EUR', + name: 'Olkypay', + asset: { id: EUR_BANK } as any, + }); + const trade = exchangeTx({ + id: 1, + type: ExchangeTxType.TRADE, + status: 'ok', + symbol: 'USDT/CHF', + side: 'buy', + amount: 1000, + amountChf: 900, + cost: 905, + feeAmountChf: 5, + order: 'order-1', + }); + + await cryptoInputConsumer([ci]).process(); + await buyFiatConsumer([bf]).process(); + await bankTxConsumer([eurCredit], [eurBank]).process(); + await exchangeTxConsumer([trade]).process(); + + // the ONLY per-tx invariant (CHF cross-asset) holds over every tx of the week (Major R9-2) + expect(ledger.txs.length).toBeGreaterThan(0); + expect(ledger.everyTxBalances()).toBe(true); + for (const tx of ledger.txs) { + expect(typeof tx.amountChfSum).toBe('number'); // JS number, never a raw bigint string (bigint column → chfCentsTransformer, Blocker R1-4) + expect(tx.amountChfSum).toBe(0); + } + + // native balances are deliberately NOT 0 for value-boundary txs — they are the custody balances (§7 feed) + expect(ledger.nativeBalance('Maerki/CHF')).not.toBe(0); + expect(ledger.nativeBalance('Scrypt/USDT')).not.toBe(0); + }); + + // --- 9. CUTOVER-STRADDLING CROSS-CONSUMER HANDOFF (Blocker R4-2 + §6.3 late-settling, §4.12) --- // + + const CUTOVER_LOG_ID = '1557344'; + const PRE_CUTOVER = new Date('2026-05-15T00:00:00Z'); // crypto_input settled BEFORE the cutover snapshot + const POST_CUTOVER = new Date('2026-06-04T00:00:00Z'); // buy_fiat outputAmount set AFTER the cutover + + // a SettingService whose ledgerCutoverLogId is set (so the G-b marker `${logId}:buy_fiat:${id}` resolves) and + // whose watermark models a completed cutover: lastProcessedId already PAST the straddling row id (late-settling → + // the forward id-scan skips it) and lastReversalScan = the cutover snapshot date (the content-change scan picks it + // up via updated > lastReversalScan) + function straddlingSettingService(lastProcessedId: number, lastReversalScan: Date): SettingService { + const s = createMock(); + jest + .spyOn(s, 'get') + .mockImplementation((key: string) => + Promise.resolve(key === 'ledgerCutoverLogId' ? (CUTOVER_LOG_ID as any) : undefined), + ); + jest + .spyOn(s, 'getObj') + .mockResolvedValue({ lastProcessedId, lastReversalScan: lastReversalScan.toISOString() } as any); + jest.spyOn(s, 'set').mockResolvedValue(); + return s; + } + + // books the cutover per-row received opening (synthetic seq0 marker, §6.1): Cr LIABILITY/buyFiat-received / Dr + // EQUITY/opening-balance for an open pre-cutover buy_fiat — exactly what LedgerCutoverService.openBuyFiatReceived does + async function openCutoverReceived(buyFiatId: number, amountChf: number): Promise { + const received = await ledger.accountService.findOrCreate( + 'LIABILITY/buyFiat-received', + AccountType.LIABILITY, + 'CHF', + ); + const equity = await ledger.accountService.findOrCreate('EQUITY/opening-balance', AccountType.EQUITY, 'CHF'); + await ledger.bookingService.bookTx({ + sourceType: 'cutover', + sourceId: `${CUTOVER_LOG_ID}:buy_fiat:${buyFiatId}`, + seq: 0, + bookingDate: PRE_CUTOVER, + valueDate: PRE_CUTOVER, + legs: [ + { account: received, amount: -amountChf, priceChf: 1, amountChf: -amountChf }, + { account: equity, amount: amountChf, priceChf: 1, amountChf }, + ], + }); + } + + it('Cutover-straddling buy_fiat: G-b opens seq1 (received NOT via G-a), received + owed close to 0 (Blocker R4-2)', async () => { + // the financing crypto_input settled PRE-cutover → it has NO seq0 ledger_tx (G-a impossible). The cutover opening + // opens buyFiat-received per-row with the synthetic marker; the buy_fiat consumer must resolve G-b from + // ledgerCutoverLogId (the exact `:buy_fiat:${id}` suffix-only match would never hit, Blocker R4-2). + await openCutoverReceived(70, 15000); + expect(ledger.chfBalance('LIABILITY/buyFiat-received')).toBe(-15000); // opened by the cutover, NOT a seq0 ledger_tx + + const bf = buyFiat({ + id: 70, + updated: POST_CUTOVER, // settlement set post-cutover → only the content-change scan re-selects it + amountInChf: 15000, + totalFeeAmountChf: 148.5, + outputAmount: 14851.5, + outputReferenceAmount: 14851.5, + outputAsset: { name: 'CHF' } as any, + cryptoInput: { id: 700, updated: PRE_CUTOVER } as any, // pre-cutover settled → no G-a seq0 exists + fiatOutput: { + isTransmittedDate: FRI, + currency: 'CHF', + bank: { asset: { id: CHF_BANK } }, + bankTx: { bookingDate: SUN }, + } as any, + }); + + // watermark: forward id-scan already past id 70 (lastProcessedId 100) AND lastReversalScan = cutover snapshot → + // the row is reached ONLY by the §6.3 content-change scan (updated > lastReversalScan), NOT the forward id-scan + const setting = straddlingSettingService(100, new Date('2026-06-01T00:00:00Z')); + const repo = createMock>(); + jest.spyOn(repo, 'find').mockImplementation(({ where }: any) => { + // forward scan (where.id present) returns [] (row id <= lastProcessedId); content-change scan (where.updated) + // returns the straddling row (updated > lastReversalScan) + if (where?.updated != null) return Promise.resolve([bf]); + return Promise.resolve([]); + }); + const consumer = new BuyFiatConsumer( + setting, + ledger.bookingService, + ledger.accountService, + markService, + repo, + ledger.ledgerTxRepository(), + ); + + await consumer.process(); + + // (a) G-b opened seq1 → received debited +15000 against the cutover-opened −15000 → closes to 0 + const s1 = ledger.txs.find((t) => t.sourceType === 'buy_fiat' && t.sourceId === '70' && t.seq === 1); + expect(s1).toBeDefined(); + expect(ledger.chfBalance('LIABILITY/buyFiat-received')).toBe(0); + // (b) owed opened by seq1 reclassification (−14851.50), transmitted+booked → closes to 0 + expect(ledger.chfBalance('LIABILITY/buyFiat-owed')).toBe(0); + // (c) the value held in TRANSIT until the Sunday booking, then nets + expect(ledger.chfBalance('TRANSIT/payout/CHF')).toBe(0); + expect(ledger.chfBalance('Maerki/CHF')).toBe(-14851.5); + expect(ledger.everyTxBalances()).toBe(true); + }); + + it('Cutover-straddling buy_fiat WITHOUT the cutover marker: seq1 stays blocked, received stuck at −amountInChf (regression guard)', async () => { + // no openCutoverReceived call → no G-b marker, no G-a seq0 (crypto_input pre-cutover) → the gate stays closed + const bf = buyFiat({ + id: 71, + updated: POST_CUTOVER, + amountInChf: 15000, + totalFeeAmountChf: 148.5, + outputAmount: 14851.5, + outputReferenceAmount: 14851.5, + outputAsset: { name: 'CHF' } as any, + cryptoInput: { id: 710, updated: PRE_CUTOVER } as any, + fiatOutput: { isTransmittedDate: FRI, currency: 'CHF', bank: { asset: { id: CHF_BANK } } } as any, + }); + const setting = straddlingSettingService(100, new Date('2026-06-01T00:00:00Z')); + const repo = createMock>(); + jest + .spyOn(repo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [bf] : [])); + const consumer = new BuyFiatConsumer( + setting, + ledger.bookingService, + ledger.accountService, + markService, + repo, + ledger.ledgerTxRepository(), + ); + + await consumer.process(); + + // gate closed → no seq1 → received was never opened (no marker) and owed never created + expect(ledger.txs.find((t) => t.sourceType === 'buy_fiat' && t.sourceId === '71' && t.seq === 1)).toBeUndefined(); + }); + + // --- 8. REVERSAL seq-MONOTONIE (§4.12, Major R1-2): bank_tx GSHEET → BUY_CRYPTO via changed type --- // + + describe('Reversal/re-book on a content change (§4.12, §10.1 GSHEET→BUY_CRYPTO)', () => { + // a stateful BankTx consumer: a real watermark in a local var (forward id-scan does NOT re-book a row it already + // processed) + a find that honours the two query shapes (forward where.id vs content-change where.updated). The + // reversal cycle is driven by the REAL booking service over the shared in-memory ledger (no mocked reverseTx). + function statefulBankTxConsumer(getRow: () => BankTx): BankTxConsumer { + let wm = { lastProcessedId: 0, lastReversalScan: new Date('2026-06-01T00:00:00Z') }; + const s = createMock(); + jest.spyOn(s, 'getObj').mockImplementation(() => + Promise.resolve({ + lastProcessedId: wm.lastProcessedId, + lastReversalScan: wm.lastReversalScan.toISOString(), + } as any), + ); + jest.spyOn(s, 'set').mockImplementation((_k: string, v: string) => { + const p = JSON.parse(v); + wm = { lastProcessedId: p.lastProcessedId, lastReversalScan: new Date(p.lastReversalScan) }; + return Promise.resolve(); + }); + jest.spyOn(s, 'get').mockResolvedValue(undefined); + + const bankTxRepo = createMock>(); + jest.spyOn(bankTxRepo, 'find').mockImplementation(({ where }: any) => { + const row = getRow(); + // forward id-scan: only rows with id > lastProcessedId; content-change scan: rows with updated > lastReversalScan + if (where?.updated != null) return Promise.resolve(row.updated > wm.lastReversalScan ? [row] : []); + return Promise.resolve(row.id > wm.lastProcessedId ? [row] : []); + }); + const bankRepo = createMock>(); + jest.spyOn(bankRepo, 'findOne').mockResolvedValue(null); // untracked → CHF SUSPENSE/unattributed path + const [returnRepo, repeatRepo] = chargebackRepos(); + + return new BankTxConsumer( + s, + ledger.bookingService, + ledger.accountService, + markService, + bankTxRepo, + bankRepo, + ledger.ledgerTxRepository(), + returnRepo, + repeatRepo, + ); + } + + it('keeps seq0, books a reversal + re-book (BUY_CRYPTO) in the correction range; seq strictly monotonic, no UNIQUE conflict', async () => { + // seq0 = the original GSHEET CREDIT booking: Dr ASSET/bank (CHF) / Cr LIABILITY/unattributed + const row = bankTx({ + id: 900, + type: BankTxType.GSHEET, + creditDebitIndicator: BankTxIndicator.CREDIT, + currency: 'CHF', + amount: 1000, + accountIban: undefined, // untracked → CHF, bank ASSET resolves via SUSPENSE/untracked path + created: new Date('2026-06-02T00:00:00Z'), + updated: new Date('2026-06-02T00:00:00Z'), + }); + + const consumer = statefulBankTxConsumer(() => row); + await consumer.process(); // forward → seq0 GSHEET + + const seq0 = ledger.txs.find((t) => t.sourceType === 'bank_tx' && t.sourceId === '900' && t.seq === 0); + expect(seq0).toBeDefined(); + expect(seq0.reversalOfId).toBeUndefined(); + expect(ledger.chfBalance('LIABILITY/unattributed')).toBe(-1000); // GSHEET credit held as unattributed + + // RE-CLASSIFY: GSHEET → BUY_CRYPTO via a later updated timestamp (the §4.12 textbook-example trigger) + row.type = BankTxType.BUY_CRYPTO; + (row as any).buyCrypto = { amountInChf: 1000 }; + row.updated = new Date('2026-06-03T00:00:00Z'); // updated > lastReversalScan → content-change scan re-selects it + + await consumer.process(); // content-change scan → reversal (seq1) + re-book (seq2) + + const all = ledger.txs + .filter((t) => t.sourceType === 'bank_tx' && t.sourceId === '900') + .sort((a, b) => a.seq - b.seq); + // §4.12 "eigener seq-Namespace": the forward seq0 stays, reversal + re-book live in the reserved correction + // range (≥ 1_000_000) so they never collide with a not-yet-booked forward seq (R3); still strictly monotonic. + expect(all.map((t) => t.seq)).toEqual([0, 1_000_000, 1_000_001]); + + // (a) original seq0 stays untouched (append-only, §4.12 Z.802) + expect(all[0].seq).toBe(0); + expect(all[0].reversalOfId).toBeUndefined(); + + // (b) the reversal of seq0 (reversalOfId points at the ORIGINAL, §4.12 Z.811) with inverted legs + expect(all[1].seq).toBe(1_000_000); + expect(all[1].reversalOfId).toBe(seq0.id); + + // (c) the re-book (reversalOf NULL — a new valid booking), now BUY_CRYPTO → Cr buyCrypto-received + expect(all[2].seq).toBe(1_000_001); + expect(all[2].reversalOfId).toBeUndefined(); + + // net effect: the unattributed liability is fully reversed back to 0, the buyCrypto-received now holds the value + expect(ledger.chfBalance('LIABILITY/unattributed')).toBe(0); + expect(ledger.chfBalance('LIABILITY/buyCrypto-received')).toBe(-1000); + expect(ledger.everyTxBalances()).toBe(true); + + // (d) idempotent: a third run with NO further change books nothing more (no UNIQUE conflict, no extra reversal) + await consumer.process(); + expect(ledger.txs.filter((t) => t.sourceType === 'bank_tx' && t.sourceId === '900')).toHaveLength(3); + }); + }); +}); diff --git a/src/subdomains/core/accounting/services/__tests__/integration/in-memory-ledger.ts b/src/subdomains/core/accounting/services/__tests__/integration/in-memory-ledger.ts new file mode 100644 index 0000000000..4a3bc1e355 --- /dev/null +++ b/src/subdomains/core/accounting/services/__tests__/integration/in-memory-ledger.ts @@ -0,0 +1,242 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Util } from 'src/shared/utils/util'; +import { DataSource, EntityManager, Repository } from 'typeorm'; +import { AccountType, LedgerAccount } from '../../../entities/ledger-account.entity'; +import { LedgerLeg } from '../../../entities/ledger-leg.entity'; +import { LedgerTx } from '../../../entities/ledger-tx.entity'; +import { LedgerAccountRepository } from '../../../repositories/ledger-account.repository'; +import { LedgerAccountService } from '../../ledger-account.service'; +import { LedgerBookingService } from '../../ledger-booking.service'; + +/** + * Shared in-memory ledger harness for the §10.2 evidence-week integration tests. It wires the REAL + * LedgerBookingService + LedgerAccountService against an in-memory store so cross-consumer balances actually + * accumulate and net — the unit consumer specs mock the booking service, so they cannot prove that the + * received/owed/TRANSIT/SUSPENSE liabilities close to 0 ACROSS consumers (the Class-1/2/4 elimination thesis). + * + * The store enforces the same single per-tx invariant the service does (Σ amountChfCents = 0 + the sub-cent + * ROUNDING leg) because the real LedgerBookingService runs unchanged — only its DataSource/manager and the + * account repository are backed by arrays/maps instead of PostgreSQL. No real DB, no external call. + */ +export class InMemoryLedger { + readonly accounts = new Map(); + readonly txs: LedgerTx[] = []; + readonly legs: LedgerLeg[] = []; + + readonly bookingService: LedgerBookingService; + readonly accountService: LedgerAccountService; + + // assetId → account name (so findByAssetId resolves the pre-seeded ASSET accounts) + private readonly assetIdToName = new Map(); + private nextAccountId = 1; + private nextTxId = 1; + private nextLegId = 1; + + constructor() { + const accountRepository = this.buildAccountRepository(); + this.accountService = new LedgerAccountService(accountRepository); + this.bookingService = new LedgerBookingService(this.buildDataSource(), this.accountService); + } + + /** seeds an ASSET account with a real assetId so consumers resolve it via findByAssetId (CoA bootstrap stand-in) */ + seedAsset(name: string, currency: string, assetId: number): LedgerAccount { + const account = this.makeAccount(name, AccountType.ASSET, currency, assetId); + this.accounts.set(name, account); + this.assetIdToName.set(assetId, name); + return account; + } + + /** seeds a non-ASSET account (ROUNDING/EQUITY/…) the bootstrap creates up-front (§3.4) */ + seed(name: string, type: AccountType, currency: string): LedgerAccount { + const account = this.makeAccount(name, type, currency); + this.accounts.set(name, account); + return account; + } + + // --- BALANCE QUERIES (the integration assertions read these) --- // + + /** Σ amountChf over all legs of an account by name (CHF balance, signed Dr +/Cr −) */ + chfBalance(name: string): number { + return Util.round( + this.legsForAccount(name).reduce((sum, leg) => sum + (leg.amountChf ?? 0), 0), + 2, + ); + } + + /** Σ amount over all legs of an account by name (native balance, signed) */ + nativeBalance(name: string): number { + return Util.round( + this.legsForAccount(name).reduce((sum, leg) => sum + leg.amount, 0), + 8, + ); + } + + /** true when the account exists in the store (lazy findOrCreate created it) */ + hasAccount(name: string): boolean { + return this.accounts.has(name); + } + + /** every booked tx satisfies the single per-tx invariant Σ amountChfCents = 0 (and is a JS number) */ + everyTxBalances(): boolean { + return this.txs.every((tx) => typeof tx.amountChfSum === 'number' && tx.amountChfSum === 0); + } + + /** + * In-memory LedgerTx repository for the cross-consumer gate reads (BuyFiat consumer existsBy/findOne, §4.7 + * G-a/G-b). Backs only the read surface those gates use over the shared tx/leg store. + */ + ledgerTxRepository(): Repository { + const repo = createMock>(); + + // the gate reads (§4.7 G-a/G-b, owed-straddling marker) go through existsBy over the shared tx store + jest.spyOn(repo, 'existsBy').mockImplementation((where: any) => { + return Promise.resolve(this.matchingTxs(where).length > 0); + }); + + jest.spyOn(repo, 'findOne').mockImplementation(({ where }: any) => { + const tx = this.txs.find( + (t) => t.sourceType === where.sourceType && t.sourceId === where.sourceId && t.seq === where.seq, + ); + if (!tx) return Promise.resolve(null); + // attach the persisted legs (the gate reads leg.account.name) + return Promise.resolve(Object.assign(new LedgerTx(), tx, { legs: this.legsForTx(tx.id) })); + }); + + return repo; + } + + // the (sourceType, sourceId, seq) predicate behind existsBy (absent field = no filter) + private matchingTxs(where: { sourceType?: string; sourceId?: string; seq?: number }): LedgerTx[] { + return this.txs.filter( + (tx) => + (where.sourceType == null || tx.sourceType === where.sourceType) && + (where.sourceId == null || tx.sourceId === where.sourceId) && + (where.seq == null || tx.seq === where.seq), + ); + } + + private legsForAccount(name: string): LedgerLeg[] { + return this.legs.filter((leg) => leg.account?.name === name); + } + + private legsForTx(txId: number): LedgerLeg[] { + return this.legs.filter((leg) => leg.tx?.id === txId); + } + + // --- IN-MEMORY BACKENDS --- // + + private makeAccount(name: string, type: AccountType, currency: string, assetId?: number): LedgerAccount { + return Object.assign(new LedgerAccount(), { + id: this.nextAccountId++, + name, + type, + currency, + assetId, + asset: assetId != null ? ({ id: assetId } as any) : undefined, + active: true, + }); + } + + // backs LedgerAccountService: findOneBy({name}) / findOneBy({asset:{id}}) / create / save + private buildAccountRepository(): LedgerAccountRepository { + const repo = createMock(); + + jest.spyOn(repo, 'findOneBy').mockImplementation((where: any) => { + if (where?.name != null) return Promise.resolve(this.accounts.get(where.name) ?? null); + if (where?.asset?.id != null) { + const name = this.assetIdToName.get(where.asset.id); + return Promise.resolve((name != null ? this.accounts.get(name) : null) ?? null); + } + return Promise.resolve(null); + }); + + jest.spyOn(repo, 'create').mockImplementation((plain: any) => { + const assetId = plain?.asset?.id; + return this.makeAccount(plain.name, plain.type, plain.currency, assetId) as any; + }); + + jest.spyOn(repo, 'save').mockImplementation((account: any) => { + this.accounts.set(account.name, account); + if (account.assetId != null) this.assetIdToName.set(account.assetId, account.name); + return Promise.resolve(account); + }); + + return repo; + } + + // backs LedgerBookingService: dataSource.transaction (manager.create/save) + getRepository(LedgerTx) for nextSeq + private buildDataSource(): DataSource { + const dataSource = createMock(); + + jest.spyOn(dataSource, 'transaction').mockImplementation((arg: any) => { + const manager = createMock(); + jest.spyOn(manager, 'create').mockImplementation((entity: any, plain: any) => { + const build = (p: any) => { + if (entity !== LedgerTx) return Object.assign(new LedgerLeg(), p); + // mirror the @RelationId(reversalOf) TypeORM populates on load → activeTx (§4.12) reads reversalOfId + return Object.assign(new LedgerTx(), p, p?.reversalOf?.id != null ? { reversalOfId: p.reversalOf.id } : {}); + }; + return (Array.isArray(plain) ? plain.map(build) : build(plain)) as any; + }); + jest.spyOn(manager, 'save').mockImplementation((entity: any, value: any) => { + if (entity === LedgerTx) { + const tx = value as LedgerTx; + tx.id = this.nextTxId++; + this.txs.push(tx); + return Promise.resolve(tx) as any; + } + const legs = value as LedgerLeg[]; + for (const leg of legs) { + leg.id = this.nextLegId++; + this.legs.push(leg); + } + return Promise.resolve(legs) as any; + }); + // §4.12: the manager-scoped seq allocation (reversal + re-book in ONE transaction) reads through + // manager.getRepository → delegate to the same store-backed dataSource.getRepository so the re-book sees the + // just-written reversal (reversal.seq + 1) + jest.spyOn(manager, 'getRepository').mockImplementation((entity: any) => dataSource.getRepository(entity)); + return (arg as (m: EntityManager) => unknown)(manager) as any; + }); + + jest.spyOn(dataSource, 'getRepository').mockReturnValue({ + createQueryBuilder: () => this.nextSeqQueryBuilder(), + // backs LedgerBookingService.activeTx (§4.12 reversal chain): find all tx of a (sourceType, sourceId) with + // their legs (+ each leg's account) attached, ordered by seq ASC + find: ({ where }: any) => + Promise.resolve( + this.txs + .filter((tx) => tx.sourceType === where.sourceType && tx.sourceId === where.sourceId) + .sort((a, b) => a.seq - b.seq) + .map((tx) => Object.assign(new LedgerTx(), tx, { legs: this.legsForTx(tx.id) })), + ), + // backs LedgerBookingService.hasAnyTxAt (content-change scan): any tx at the exact (sourceType, sourceId, seq) + existsBy: (where: any) => Promise.resolve(this.matchingTxs(where).length > 0), + } as any); + + return dataSource; + } + + // mirrors LedgerBookingService.nextSeq: MAX(seq) for (sourceType, sourceId) over the in-memory tx store + private nextSeqQueryBuilder(): any { + let sourceType: string | undefined; + let sourceId: string | undefined; + const qb: any = {}; + qb.select = () => qb; + qb.where = (_expr: string, params: { sourceType: string }) => { + sourceType = params.sourceType; + return qb; + }; + qb.andWhere = (_expr: string, params: { sourceId: string }) => { + sourceId = params.sourceId; + return qb; + }; + qb.getRawOne = () => { + const seqs = this.txs + .filter((tx) => tx.sourceType === sourceType && tx.sourceId === sourceId) + .map((tx) => tx.seq); + return Promise.resolve({ max: seqs.length ? Math.max(...seqs) : null }); + }; + return qb; + } +} diff --git a/src/subdomains/core/accounting/services/__tests__/integration/isolation-gate.spec.ts b/src/subdomains/core/accounting/services/__tests__/integration/isolation-gate.spec.ts new file mode 100644 index 0000000000..42efacafba --- /dev/null +++ b/src/subdomains/core/accounting/services/__tests__/integration/isolation-gate.spec.ts @@ -0,0 +1,278 @@ +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const REPO_ROOT = path.resolve(__dirname, '../../../../../../..'); +const GATE = path.join(REPO_ROOT, 'scripts', 'ledger-isolation-gate.sh'); +const MODULE_DIR = path.join(REPO_ROOT, 'src', 'subdomains', 'core', 'accounting'); + +/** + * §4.10 / §10.1 — the static CI grep-gate (ledger-isolation-gate.sh) self-test. Asserts (a) the accounting MODULE + * source is clean today, and (b) the wrapped gate (PCRE2 engine PLUS the `| grep -v 'ledger-allowlist'` post-filter, + * §10.3 Minor R4-1) flags every known violation and passes every known-allowed construct — so a silently broken + * gate (missing --pcre2, defused pattern) cannot pass unnoticed. The test runs against the WRAPPED gate SCRIPT, + * not the raw pattern: the raw Block-4b pattern flags even `manager.save(LedgerTx, tx) // ledger-allowlist`; only + * the post-filter clears it. + */ +describe('Ledger isolation gate (§4.10 / §10.1 self-test)', () => { + // runs the gate over a target dir; returns { exitCode, output } + function runGate(targetDir: string): { exitCode: number; output: string } { + try { + const out = execFileSync('bash', [GATE, targetDir], { cwd: REPO_ROOT, encoding: 'utf8' }); + return { exitCode: 0, output: out }; + } catch (e: any) { + return { exitCode: e.status ?? 1, output: `${e.stdout ?? ''}${e.stderr ?? ''}` }; + } + } + + // writes a fixture .ts file into a fresh temp dir and runs the gate against that dir + function gateOnFixture(filename: string, content: string): { exitCode: number; output: string } { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ledger-gate-')); + try { + fs.writeFileSync(path.join(dir, filename), content); + return runGate(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + + it('passes over the real accounting module source (clean today)', () => { + const result = runGate(MODULE_DIR); + expect(result.exitCode).toBe(0); + expect(result.output).toMatch(/clean/); + }); + + // --- KNOWN VIOLATIONS MUST FLAG (§10.1, Major R3-1 / R9-1 / Minor R9-1) --- // + + const violations: { name: string; code: string }[] = [ + { name: 'manager.save on a business entity without allowlist', code: 'manager.save(BankTx, e);' }, + { name: 'forbidden pricing read getPrice(', code: 'const p = getPrice(asset, CHF, ANY);' }, + { name: 'forbidden pricing read getPriceAt', code: 'const p = this.x.getPriceAt(a, d);' }, + { name: 'pricingService reference', code: 'this.pricingService.convert(x);' }, + { name: 'a non-ledger repo write', code: 'await bankTxRepo.save(entity);' }, + { name: 'logService.create write into the FinancialDataLog table', code: 'await logService.create(dto);' }, + { name: 'logService.update write into the FinancialDataLog table', code: 'await logService.update(dto);' }, + { name: 'settingService.setObj write with operative side effect', code: 'await settingService.setObj(k, v);' }, + { + name: 'settingService.updateProcess write with operative side effect', + code: 'await settingService.updateProcess(d);', + }, + { name: 'external feed call refreshBalances(', code: 'await this.liqBalance.refreshBalances(rules);' }, + { name: 'external feed call refreshBankBalance', code: 'await svc.refreshBankBalance(dto);' }, + { name: 'external feed call hasPendingOrders', code: 'await svc.hasPendingOrders(rule);' }, + { name: 'lifecycle call .complete(', code: 'await order.complete();' }, + { name: 'lifecycle call doPayout', code: 'await this.payoutService.doPayout(o);' }, + { name: 'manager.query raw write path', code: 'await manager.query("UPDATE bank_tx SET x = 1");' }, + { name: 'EntityManager update on a business entity', code: 'await manager.update(BankTx, id, { x: 1 });' }, + // Block 5 (§10.2 robustness gap) — the two write paths the old gate structurally missed + { name: 'getRepository(X).save write path', code: 'await dataSource.getRepository(BankTx).save({});' }, + { name: 'getRepository(X).update write path', code: 'await dataSource.getRepository(BankTx).update(id, dto);' }, + { name: 'source-service write update(', code: 'await bankTxService.update(id, dto);' }, + { name: 'source-service write updateAsset(', code: 'await assetService.updateAsset(a);' }, + { name: 'source-service write save(', code: 'await bankTxService.save(e);' }, + // Block 6/7 (§10.2 robustness gap, Major design-accounting) — the write/external paths the old gate missed + { name: 'dataSource.query raw write path', code: 'await dataSource.query("UPDATE bank_tx SET x = 1");' }, + { + // queryRunner.query is the idiomatic TypeORM raw-write path (dataSource.createQueryRunner().query(...)) and + // carries no manager/dataSource token → previously unflagged (Block 6 gap, Major design-accounting) + name: 'queryRunner.query raw write path', + code: 'await queryRunner.query("UPDATE bank_tx SET amount = 0");', + }, + { + name: 'entityManager.save on a business entity (\\bmanager. missed it — no word boundary)', + code: 'await entityManager.save(BankTx, x);', + }, + { name: 'entityManager.query raw write path', code: 'await entityManager.query("INSERT INTO bank_tx ...");' }, + { + name: 'QueryBuilder write xRepo.createQueryBuilder().update(BankTx)', + code: 'await xRepo.createQueryBuilder().update(BankTx).set({ x: 1 }).execute();', + }, + { + name: 'QueryBuilder write dataSource.createQueryBuilder().insert().into(BankTx)', + code: 'await dataSource.createQueryBuilder().insert().into(BankTx).execute();', + }, + // atomic in-place writes — increment/decrement emit a real `UPDATE col = col ± x` on a business table and so + // must flag on the repository, EntityManager and getRepository paths (Major isolation gap) + { name: 'repo.increment atomic write path', code: 'await bankTxRepo.increment({ id: 1 }, "amount", 100);' }, + { name: 'repo.decrement atomic write path', code: 'await bankTxRepo.decrement({ id: 1 }, "amount", 100);' }, + { name: 'manager.increment atomic write path', code: 'await manager.increment(BankTx, { id: 1 }, "amount", 100);' }, + { + name: 'getRepository(X).decrement atomic write path', + code: 'await dataSource.getRepository(BankTx).decrement({ id: 1 }, "amount", 100);', + }, + // SCOPED post-filter (§10.3 Minor R4-1): the `// ledger-allowlist` marker is honoured ONLY for the DB-write + // blocks. A non-allowlistable construct (pricing/feed-read/log-setting/lifecycle) MUST still flag even WITH the + // marker — a comment can never re-open the isolation hole the gate exists to close. + { + name: 'pricing read getPrice( WITH a ledger-allowlist marker still flags (scoped post-filter)', + code: 'const p = getPrice(asset, CHF, ANY); // ledger-allowlist', + }, + { + name: 'feed read refreshBalances( WITH a ledger-allowlist marker still flags', + code: 'await this.liqBalance.refreshBalances(rules); // ledger-allowlist', + }, + { + name: 'lifecycle call doPayout WITH a ledger-allowlist marker still flags', + code: 'await this.payoutService.doPayout(o); // ledger-allowlist', + }, + { + name: 'logService.create write WITH a ledger-allowlist marker still flags', + code: 'await logService.create(dto); // ledger-allowlist', + }, + ]; + + it.each(violations)('flags a known violation: $name', ({ code }) => { + const result = gateOnFixture('violation.ts', `export function f() {\n ${code}\n}\n`); + expect(result.exitCode).toBe(1); + expect(result.output).toMatch(/FORBIDDEN/); + }); + + // --- KNOWN-ALLOWED CONSTRUCTS MUST NOT FLAG (§10.1) --- // + + const allowed: { name: string; code: string }[] = [ + { + name: 'allowlisted manager write into ledger_* (post-filter clears it)', + code: 'await manager.save(LedgerTx, tx); // ledger-allowlist', + }, + { + name: 'allowlisted manager.insert into ledger_*', + code: 'await manager.insert(LedgerLeg, legs); // ledger-allowlist', + }, + { name: 'whitelisted feed read getBalances()', code: 'const b = await this.liqBalance.getBalances();' }, + { + name: 'whitelisted feed read getAllLiqBalancesForAssets', + code: 'const b = await this.liqBalance.getAllLiqBalancesForAssets(ids);', + }, + { name: 'settingService.set for a ledger key', code: 'await settingService.set("ledgerCutoverLogId", id);' }, + { name: 'settingService.get for a ledger key', code: 'const v = await settingService.get(k);' }, + { name: 'settingService.getObj for a ledger key', code: 'const v = await settingService.getObj(k);' }, + { name: 'read-only logService.getFinancialLogs', code: 'const r = await logService.getFinancialLogs(from);' }, + { name: 'ledger-own repository write', code: 'await ledgerTxRepository.save(tx);' }, + { name: 'the ledger mark lookup getMarkAt (not getPriceAt)', code: 'const m = marks.getMarkAt(id, date);' }, + { + name: 'a notification alarm via sendMail (sanctioned, not a *Repo.save)', + code: 'await notificationService.sendMail(req);', + }, + // Block 5 must NOT flag the legit ledger READ via getRepository (no write verb) nor sanctioned service reads + { + name: 'ledger READ via getRepository(LedgerTx).createQueryBuilder (booking nextSeq)', + code: 'this.dataSource.getRepository(LedgerTx).createQueryBuilder("tx");', + }, + { + name: 'sanctioned service read accountService.findOrCreate', + code: 'await accountService.findOrCreate(n, t, c);', + }, + { name: 'sanctioned service read assetService.getAssetsWith', code: 'await assetService.getAssetsWith(w);' }, + // Block 7 must NOT flag a READ QueryBuilder chain (.select/.where/.getRawOne — the ledger nextSeq/recon queries) + { + name: 'read QueryBuilder chain createQueryBuilder().select().where()', + code: 'await repo.createQueryBuilder("e").select("MAX(e.id)", "max").where("x").getRawOne();', + }, + // Block 6 must NOT flag a read on the dataSource via a non-write method (transaction wrapper / find) + { + name: 'dataSource.transaction wrapper (not a write verb)', + code: 'await dataSource.transaction(async (m) => m);', + }, + ]; + + it.each(allowed)('does NOT flag an allowed construct: $name', ({ code }) => { + const result = gateOnFixture('allowed.ts', `export function f() {\n ${code}\n}\n`); + expect(result.exitCode).toBe(0); + expect(result.output).toMatch(/clean/); + }); + + // --- POST-FILTER LOAD-BEARING (Minor R4-1): the raw Block-4b pattern would flag the allowlisted line --- // + + it('clears an allowlisted ledger manager.save but still flags a non-allowlisted manager.save in the same file', () => { + const code = [ + 'export function f() {', + ' await manager.save(LedgerTx, tx); // ledger-allowlist', + ' await manager.save(BankTx, e);', + '}', + '', + ].join('\n'); + const result = gateOnFixture('mixed.ts', code); + expect(result.exitCode).toBe(1); // the non-allowlisted manager.save remains + expect(result.output).toContain('manager.save(BankTx, e)'); + expect(result.output).not.toContain('ledger-allowlist'); // the allowlisted line is filtered out of the matches + }); + + // --- POST-FILTER IS SCOPED TO THE WRITE BLOCKS (§10.3 Minor R4-1) --- // + // The marker clears an allowlisted DB-write but must NOT clear a pricing/feed/lifecycle read in the same file: + // the post-filter applies to the write blocks only, never to the non-allowlistable blocks. + + it('clears an allowlisted write but STILL flags a marked pricing read in the same file (scoped post-filter)', () => { + const code = [ + 'export function f() {', + ' await manager.save(LedgerTx, tx); // ledger-allowlist', + ' const p = getPrice(asset, CHF, ANY); // ledger-allowlist', + '}', + '', + ].join('\n'); + const result = gateOnFixture('scoped.ts', code); + expect(result.exitCode).toBe(1); // the marked pricing read is NOT cleared by the post-filter + expect(result.output).toContain('getPrice(asset, CHF, ANY)'); + expect(result.output).not.toContain('manager.save(LedgerTx'); // the allowlisted write IS cleared + }); + + // --- SOURCE-REPO NAMING CONVENTION (§4.10 Block 4a / Minor R3-9, Major isolation gap) --- // + // + // Block 4a flags a non-ledger repo write via `\b(?!ledger)\w*Repo(sitory)?\.(` — i.e. it only catches an + // identifier that ENDS in `Repo`/`Repository`. A write through a generically-named injected source repo (e.g. + // `private readonly src: Repository` → `src.save(e)`, or `feed`) carries no `Repo` token and escapes ALL + // blocks (empirically: `src.save(entity)` / `this.source.update(id, dto)` pass clean). Block 4a's coverage of + // every source repo therefore RESTS on the naming convention that all injected source repos end in `Repo`/ + // `Repository` (the same naming mandate §4.10 already places on the ledger* repos). This self-test enforces that + // convention statically: every `@InjectRepository()` binding in the module source MUST name its field + // `*Repo`/`*Repository`, so the gate's grep can never structurally miss a future source-repo write. + + // collects every accounting-module source .ts file (production only — *.spec.ts/__tests__/__mocks__ excluded) + function collectSourceFiles(dir: string, out: string[]): void { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === '__tests__' || entry.name === '__mocks__') continue; + collectSourceFiles(full, out); + } else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.spec.ts')) { + out.push(full); + } + } + } + + it('every @InjectRepository binding in the module is named *Repo/*Repository (Block 4a coverage rests on it)', () => { + const files: string[] = []; + collectSourceFiles(MODULE_DIR, files); + + // matches `@InjectRepository(Entity) ... : Repository<...>` across one or two physical lines (some + // bindings wrap the @InjectRepository decorator onto its own line) — captures the bound field identifier + const bindingRe = + /@InjectRepository\([^)]*\)[\s\S]*?(?:private|public|protected|readonly)\s+(?:readonly\s+)?(\w+)\s*:/g; + + const offenders: string[] = []; + for (const file of files) { + const src = fs.readFileSync(file, 'utf8'); + for (const m of src.matchAll(bindingRe)) { + const field = m[1]; + if (!/Repo(sitory)?$/.test(field)) { + offenders.push(`${path.relative(MODULE_DIR, file)}: ${field}`); + } + } + } + + expect(offenders).toEqual([]); // any generically-named source repo would silently escape Block 4a + }); + + it('a write via a *Repo-suffixed source repo IS flagged (the convention makes Block 4a reliable)', () => { + const result = gateOnFixture('repo-write.ts', 'export function f() {\n await sourceRepo.save(e);\n}\n'); + expect(result.exitCode).toBe(1); + expect(result.output).toMatch(/FORBIDDEN/); + }); + + it('documents the gap: a write via a generically-named source repo (no *Repo suffix) escapes the grep gate', () => { + // this is WHY the naming convention is enforced above — the gate alone cannot catch this, so the *Repo suffix is + // mandatory; the per-binding self-test is the actual defense line for future generically-named injections. + const result = gateOnFixture('generic-write.ts', 'export function f() {\n await src.save(e);\n}\n'); + expect(result.exitCode).toBe(0); // grep cannot see it → the naming-convention test is what prevents it + }); +}); diff --git a/src/subdomains/core/accounting/services/__tests__/integration/staleness-cutover.integration.spec.ts b/src/subdomains/core/accounting/services/__tests__/integration/staleness-cutover.integration.spec.ts new file mode 100644 index 0000000000..4c63d7e31e --- /dev/null +++ b/src/subdomains/core/accounting/services/__tests__/integration/staleness-cutover.integration.spec.ts @@ -0,0 +1,388 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { ExchangeTx } from 'src/integration/exchange/entities/exchange-tx.entity'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { TestUtil } from 'src/shared/utils/test.util'; +import { Util } from 'src/shared/utils/util'; +import { BuyCrypto } from 'src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity'; +import { LiquidityBalance } from 'src/subdomains/core/liquidity-management/entities/liquidity-balance.entity'; +import { LiquidityManagementOrder } from 'src/subdomains/core/liquidity-management/entities/liquidity-management-order.entity'; +import { LiquidityManagementBalanceService } from 'src/subdomains/core/liquidity-management/services/liquidity-management-balance.service'; +import { RefRewardService } from 'src/subdomains/core/referral/reward/services/ref-reward.service'; +import { BuyFiat } from 'src/subdomains/core/sell-crypto/process/buy-fiat.entity'; +import { TradingOrder } from 'src/subdomains/core/trading/entities/trading-order.entity'; +import { LiquidityOrder } from 'src/subdomains/supporting/dex/entities/liquidity-order.entity'; +import { Bank } from 'src/subdomains/supporting/bank/bank/bank.entity'; +import { BankTx } from 'src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity'; +import { BankTxRepeat } from 'src/subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.entity'; +import { BankTxReturn } from 'src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.entity'; +import { Log } from 'src/subdomains/supporting/log/log.entity'; +import { LogService } from 'src/subdomains/supporting/log/log.service'; +import { MailContext } from 'src/subdomains/supporting/notification/enums'; +import { MailRequest } from 'src/subdomains/supporting/notification/interfaces'; +import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; +import { CryptoInput } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; +import { PayoutOrder } from 'src/subdomains/supporting/payout/entities/payout-order.entity'; +import { Repository } from 'typeorm'; +import { AccountType, LedgerAccount } from '../../../entities/ledger-account.entity'; +import { createCustomLedgerAccount } from '../../../entities/__mocks__/ledger-account.entity.mock'; +import { LedgerAccountRepository } from '../../../repositories/ledger-account.repository'; +import { LedgerLegRepository } from '../../../repositories/ledger-leg.repository'; +import { LedgerAccountService } from '../../ledger-account.service'; +import { LedgerBookingJobService } from '../../ledger-booking-job.service'; +import { LedgerBookingService, LedgerTxInput } from '../../ledger-booking.service'; +import { LedgerBootstrapService } from '../../ledger-bootstrap.service'; +import { LedgerCutoverService } from '../../ledger-cutover.service'; +import { LedgerMarkCache, LedgerMarkService } from '../../ledger-mark.service'; +import { LedgerReconciliationService } from '../../ledger-reconciliation.service'; + +/** + * §10.2 evidence-week — the parts that exercise whole-service runs rather than cross-consumer netting: + * the Class-3 staleness guard (reconciliation run) and the cutover-idempotency (cutover run twice). Both use the + * REAL service with its source dependencies stubbed (no real DB, no external call). Synthetic data only. + */ +describe('Ledger staleness + cutover integration (§10.2)', () => { + // --- 4. CLASS-3 STALENESS GUARD --- // + + describe('Class-3 staleness guard (reconciliation run)', () => { + let service: LedgerReconciliationService; + let jobService: LedgerBookingJobService; + let settingService: SettingService; + let logService: LogService; + let notificationService: NotificationService; + let liquidityManagementBalanceService: LiquidityManagementBalanceService; + let ledgerAccountRepository: LedgerAccountRepository; + let ledgerLegRepository: LedgerLegRepository; + + let mails: MailRequest[]; + let journalNative: string; // the stubbed journal native balance (fed through the nativeBalanceByAccount map, m8) + + function assetAccount(assetId: number, asset: Partial): LedgerAccount { + return createCustomLedgerAccount({ + id: 1000 + assetId, + name: `OnChain/${assetId}`, + type: AccountType.ASSET, + assetId, + asset: Object.assign(new Asset(), { id: assetId, ...asset }) as Asset, + } as any); + } + + function balance(assetId: number, amount: number, updated: Date): LiquidityBalance { + return Object.assign(new LiquidityBalance(), { asset: { id: assetId } as Asset, amount, updated }); + } + + // empty leg query-builder stub (transit/suspense/equity all empty for the staleness focus) + function legQb(): any { + const qb: any = {}; + for (const m of ['innerJoin', 'select', 'addSelect', 'where', 'andWhere', 'groupBy', 'addGroupBy', 'having']) { + qb[m] = () => qb; + } + qb.getRawMany = () => Promise.resolve([]); + qb.getRawOne = () => Promise.resolve({ native: journalNative, chf: '0' }); + return qb; + } + + beforeEach(async () => { + mails = []; + journalNative = '0'; + + jobService = createMock(); + settingService = createMock(); + logService = createMock(); + notificationService = createMock(); + liquidityManagementBalanceService = createMock(); + ledgerAccountRepository = createMock(); + ledgerLegRepository = createMock(); + + jest.spyOn(jobService, 'isLedgerReady').mockResolvedValue(true); + jest.spyOn(notificationService, 'sendMail').mockImplementation((request: MailRequest) => { + mails.push(request); + return Promise.resolve(); + }); + jest.spyOn(ledgerLegRepository, 'createQueryBuilder').mockImplementation(() => legQb()); + jest.spyOn(settingService, 'get').mockResolvedValue('0'); + jest.spyOn(logService, 'getLatestValidFinancialLogs').mockResolvedValue([]); // no snapshot → equity parity skipped + + // §7 unit fix: a mark of 1 CHF/native for the seeded assets so a matching fresh feed produces diff 0 (no alarm) + const markService = createMock(); + jest.spyOn(markService, 'preload').mockResolvedValue({ getMarkAt: () => 1 } as any); + const refRewardService = createMock(); + jest.spyOn(refRewardService, 'getOpenRefCreditLiability').mockResolvedValue({ amountEur: 0, amountChf: 0 }); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LedgerReconciliationService, + TestUtil.provideConfig(), + { provide: LedgerBookingJobService, useValue: jobService }, + { provide: SettingService, useValue: settingService }, + { provide: LogService, useValue: logService }, + { provide: NotificationService, useValue: notificationService }, + { provide: LiquidityManagementBalanceService, useValue: liquidityManagementBalanceService }, + { provide: LedgerAccountRepository, useValue: ledgerAccountRepository }, + { provide: LedgerLegRepository, useValue: ledgerLegRepository }, + { provide: LedgerMarkService, useValue: markService }, + { provide: RefRewardService, useValue: refRewardService }, + ], + }).compile(); + + service = module.get(LedgerReconciliationService); + + // §7.0 (m8): reconcileFreshAsset reads the journal balance from the nativeBalanceByAccount GROUP-BY map — yield + // journalNative for every account (balances.get(account.id)); replaces the former per-account getRawOne. + jest + .spyOn(service as any, 'nativeBalanceByAccount') + .mockImplementation(() => Promise.resolve({ get: () => Util.round(+journalNative, 8) })); + }); + + it('flags a stale-feed account as unverified with ONE suppressible aggregated alarm (no repeat alarm)', async () => { + const now = new Date(); + const account = assetAccount(5, { blockchain: Blockchain.ETHEREUM }); + jest.spyOn(ledgerAccountRepository, 'find').mockResolvedValue([account]); + // feed older than the 4h on-chain-active threshold → stale → unverified + jest + .spyOn(liquidityManagementBalanceService, 'getBalances') + .mockResolvedValue([balance(5, 123, Util.hoursBefore(10, now))]); + + await service.run(); + + const unverified = mails.find((m) => m.context === MailContext.LEDGER_RECONCILIATION); + expect(unverified).toBeDefined(); + // a correlationId + suppressRecurring → NotificationService suppresses the repeat alarm (§7.3 once-per-key/day) + expect(unverified.correlationId).toBeDefined(); + expect(unverified.options?.suppressRecurring).toBe(true); + }); + + it('treats a 1.0 placeholder feed as never-reconcile (no diff alarm, no unverified spam)', async () => { + const now = new Date(); + const account = assetAccount(6, { blockchain: Blockchain.ETHEREUM }); + jest.spyOn(ledgerAccountRepository, 'find').mockResolvedValue([account]); + jest.spyOn(liquidityManagementBalanceService, 'getBalances').mockResolvedValue([balance(6, 1.0, now)]); + + await service.run(); + + expect(mails).toHaveLength(0); // placeholder → skipped, neither diff nor unverified alarm + }); + + it('does not alarm a fresh on-chain feed that matches the journal (within threshold + tolerance)', async () => { + const now = new Date(); + journalNative = '100'; // journal == feed → within reconciliation tolerance → no diff alarm + const account = assetAccount(7, { blockchain: Blockchain.ETHEREUM }); + jest.spyOn(ledgerAccountRepository, 'find').mockResolvedValue([account]); + jest + .spyOn(liquidityManagementBalanceService, 'getBalances') + .mockResolvedValue([balance(7, 100, Util.hoursBefore(2, now))]); + + await service.run(); + + expect(mails.find((m) => m.context === MailContext.LEDGER_RECONCILIATION)).toBeUndefined(); + }); + }); + + // --- 7. CUTOVER-IDEMPOTENZ --- // + + describe('Cutover idempotency (cutover run twice)', () => { + let service: LedgerCutoverService; + let settingService: SettingService; + let logService: LogService; + let bootstrapService: LedgerBootstrapService; + let bookingService: LedgerBookingService; + let accountService: LedgerAccountService; + let markService: LedgerMarkService; + + let booked: LedgerTxInput[]; + let cutoverFlag: string | undefined; + let snapshotPin: string | undefined; + const seqByKey = new Map(); + + const equity = createCustomLedgerAccount({ id: 99, name: 'EQUITY/opening-balance', type: AccountType.EQUITY }); + + function snapshotLog(): Log { + return Object.assign(new Log(), { + id: 1557344, + created: new Date('2026-06-07T22:00:00Z'), + valid: true, + message: JSON.stringify({ + assets: { '100': { priceChf: 2, plusBalance: { liquidity: { liquidityBalance: { total: 10 } } } } }, + tradings: {}, + balancesByFinancialType: {}, + balancesTotal: {}, + }), + }); + } + + beforeEach(async () => { + booked = []; + cutoverFlag = undefined; + snapshotPin = undefined; + seqByKey.clear(); + + settingService = createMock(); + logService = createMock(); + bootstrapService = createMock(); + bookingService = createMock(); + accountService = createMock(); + markService = createMock(); + + // the Setting flag is the primary idempotency guard: set on success, read on the next run. The snapshot pin + // (ledgerCutoverSnapshotLogId) starts unset and is pinned at the first cutover step (Major design-accounting + // R3-1). Every other key (ledgerWatermark., ledgerCutoverBoundary.) reads as UNSET so the + // set-only-if-unset pin step actually computes + writes them (a non-null default would read as already pinned). + jest.spyOn(settingService, 'get').mockImplementation((key: string) => { + if (key === 'ledgerCutoverLogId') return Promise.resolve(cutoverFlag as any); + if (key === 'ledgerCutoverSnapshotLogId') return Promise.resolve(snapshotPin as any); + return Promise.resolve(undefined as any); + }); + jest.spyOn(settingService, 'set').mockImplementation((key: string, value: string) => { + if (key === 'ledgerCutoverLogId') cutoverFlag = value; + if (key === 'ledgerCutoverSnapshotLogId') snapshotPin = value; + return Promise.resolve(); + }); + jest.spyOn(settingService, 'getObj').mockResolvedValue([] as any); + + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog()]); + jest + .spyOn(logService, 'getLog') + .mockImplementation((id: number) => Promise.resolve(id === 1557344 ? snapshotLog() : (undefined as any))); + + // the second guard: UNIQUE-collision-equivalent — a re-booked (sourceType,sourceId,seq) is skipped + jest.spyOn(bookingService, 'bookTx').mockImplementation((input: LedgerTxInput) => { + booked.push(input); + seqByKey.set(`${input.sourceType}:${input.sourceId}`, input.seq + 1); + return Promise.resolve({} as any); + }); + jest + .spyOn(bookingService, 'nextSeq') + .mockImplementation((st: string, sid: string) => Promise.resolve(seqByKey.get(`${st}:${sid}`) ?? 0)); + + jest.spyOn(accountService, 'findOrCreate').mockImplementation((name: string, type: AccountType) => { + if (name === 'EQUITY/opening-balance') return Promise.resolve(equity); + return Promise.resolve(createCustomLedgerAccount({ name, type })); + }); + jest.spyOn(accountService, 'findByAssetId').mockImplementation((id: number) => + Promise.resolve( + createCustomLedgerAccount({ + id: 1000 + id, + name: `Asset/${id}`, + type: AccountType.ASSET, + assetId: id, + } as any), + ), + ); + jest.spyOn(markService, 'preload').mockResolvedValue(new LedgerMarkCache(new Map())); + + const emptyRepo = () => { + const repo = createMock>(); + jest.spyOn(repo, 'find').mockResolvedValue([]); + const maxQb: any = { + select: () => maxQb, + where: () => maxQb, + andWhere: () => maxQb, + getRawOne: () => Promise.resolve({ max: 0 }), + }; + jest.spyOn(repo, 'createQueryBuilder').mockReturnValue(maxQb); + return repo; + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LedgerCutoverService, + { provide: SettingService, useValue: settingService }, + { provide: LogService, useValue: logService }, + { provide: LedgerBootstrapService, useValue: bootstrapService }, + { provide: LedgerBookingService, useValue: bookingService }, + { provide: LedgerAccountService, useValue: accountService }, + { provide: LedgerMarkService, useValue: markService }, + { provide: getRepositoryToken(BuyFiat), useValue: emptyRepo() }, + { provide: getRepositoryToken(BuyCrypto), useValue: emptyRepo() }, + { provide: getRepositoryToken(BankTx), useValue: emptyRepo() }, + { provide: getRepositoryToken(Bank), useValue: emptyRepo() }, + { provide: getRepositoryToken(BankTxReturn), useValue: emptyRepo() }, + { provide: getRepositoryToken(BankTxRepeat), useValue: emptyRepo() }, + { provide: getRepositoryToken(CryptoInput), useValue: emptyRepo() }, + { provide: getRepositoryToken(ExchangeTx), useValue: emptyRepo() }, + { provide: getRepositoryToken(PayoutOrder), useValue: emptyRepo() }, + { provide: getRepositoryToken(LiquidityManagementOrder), useValue: emptyRepo() }, + { provide: getRepositoryToken(TradingOrder), useValue: emptyRepo() }, + { provide: getRepositoryToken(LiquidityOrder), useValue: emptyRepo() }, + ], + }).compile(); + + service = module.get(LedgerCutoverService); + }); + + it('runs the full opening once, then the second run is a no-op (Setting flag + UNIQUE backstop)', async () => { + await service.run(); + + expect(bootstrapService.bootstrap).toHaveBeenCalledTimes(1); + expect(cutoverFlag).toBe('1557344'); // flag set to the used logId + const firstRunBookings = booked.length; + expect(firstRunBookings).toBeGreaterThan(0); // at least the ASSET opening + // opening counter is EQUITY/opening-balance + expect(booked.some((b) => b.legs.some((l) => l.account.type === AccountType.EQUITY))).toBe(true); + + // SECOND run with the same logId → primary Setting guard returns immediately, nothing re-booked + await service.run(); + + expect(bootstrapService.bootstrap).toHaveBeenCalledTimes(1); // not run again + expect(booked).toHaveLength(firstRunBookings); // no additional bookings + }); + + it('is idempotent even if the Setting guard is bypassed (UNIQUE-equivalent nextSeq backstop)', async () => { + await service.run(); + const firstRunBookings = booked.length; + + // simulate a re-run WITHOUT the flag (e.g. flag write lost) — the per-(sourceType,sourceId,seq) nextSeq guard + // (UNIQUE-collision-equivalent) makes every opening a no-op on the second pass + cutoverFlag = undefined; + await service.run(); + + expect(booked).toHaveLength(firstRunBookings); // openings skipped via alreadyBooked (nextSeq > seq) + }); + + it('re-run after a partial crash reuses the PINNED snapshot despite snapshot-window drift (no double-count, R3-1)', async () => { + // Run A crashes in step (3), the watermark+boundary pin — which now PRECEDES every opening — but AFTER the + // snapshot pin of step (2). run() propagates the error to @DfxCron's lock layer (no redundant in-method catch); + // the flag stays unset, the snapshot pin remains committed and ZERO openings were booked. + let crashWatermarks = true; + jest.spyOn(settingService, 'set').mockImplementation((key: string, value: string) => { + if (key === 'ledgerCutoverLogId') cutoverFlag = value; + if (key === 'ledgerCutoverSnapshotLogId') snapshotPin = value; + // a watermark write is step (3) — crash there once, simulating the partial-cutover scenario + if (crashWatermarks && key.startsWith('ledgerWatermark.')) throw new Error('simulated crash in initWatermarks'); + return Promise.resolve(); + }); + + await expect(service.run()).rejects.toThrow('simulated crash in initWatermarks'); + expect(cutoverFlag).toBeUndefined(); // crash before step (5) → flag never set + expect(snapshotPin).toBe('1557344'); // pin survived (set in step (2)) + const runABookings = booked.length; + expect(runABookings).toBe(0); // the pin step precedes every opening → the crash left NOTHING booked + + // Run B: the snapshot WINDOW has drifted — getFinancialLogs now returns a NEWER log (id 9999999, the drift the + // reviewer flagged). Without the pin, maxObj(valid,'created') would pick 9999999 → different opening sourceIds → + // alreadyBooked finds no collision → ALL openings re-booked (Equity ~2×). With the pin, Run B reuses 1557344. + crashWatermarks = false; + const driftedLog = Object.assign(new Log(), { + id: 9999999, + created: new Date('2026-06-09T22:00:00Z'), // newer than the pinned 1557344 (2026-06-07) + valid: true, + message: snapshotLog().message, + }); + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([driftedLog]); + + await service.run(); + + expect(snapshotPin).toBe('1557344'); // pin unchanged — the drifted 9999999 was NOT chosen + expect(cutoverFlag).toBe('1557344'); // Run B completes on the pinned snapshot + expect(booked.length).toBeGreaterThan(runABookings); // the openings were booked in Run B only + // every booked opening carries the pinned logId in its sourceId — none carry the drifted 9999999 + expect(booked.every((b) => !b.sourceId.startsWith('9999999'))).toBe(true); + // and no cutover opening was booked twice across the two runs (Run A booked none, Run B booked each once) + const openingKeys = booked.map((b) => `${b.sourceId}#${b.seq}`); + expect(new Set(openingKeys).size).toBe(openingKeys.length); + }); + }); +}); diff --git a/src/subdomains/core/accounting/services/__tests__/ledger-account.service.spec.ts b/src/subdomains/core/accounting/services/__tests__/ledger-account.service.spec.ts new file mode 100644 index 0000000000..2ba15c4a2c --- /dev/null +++ b/src/subdomains/core/accounting/services/__tests__/ledger-account.service.spec.ts @@ -0,0 +1,137 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AccountType } from '../../entities/ledger-account.entity'; +import { createCustomLedgerAccount } from '../../entities/__mocks__/ledger-account.entity.mock'; +import { LedgerAccountRepository } from '../../repositories/ledger-account.repository'; +import { LedgerAccountService } from '../ledger-account.service'; + +describe('LedgerAccountService', () => { + let service: LedgerAccountService; + let ledgerAccountRepository: LedgerAccountRepository; + + beforeEach(async () => { + ledgerAccountRepository = createMock(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [LedgerAccountService, { provide: LedgerAccountRepository, useValue: ledgerAccountRepository }], + }).compile(); + + service = module.get(LedgerAccountService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('resolves an account character-exact by name', async () => { + const account = createCustomLedgerAccount({ name: 'LIABILITY/paymentLink' }); + jest.spyOn(ledgerAccountRepository, 'findOneBy').mockResolvedValue(account); + + await expect(service.findByName('LIABILITY/paymentLink')).resolves.toBe(account); + expect(ledgerAccountRepository.findOneBy).toHaveBeenCalledWith({ name: 'LIABILITY/paymentLink' }); + }); + + it('returns the existing account on findOrCreate without creating a duplicate (idempotent)', async () => { + const existing = createCustomLedgerAccount({ name: 'ROUNDING', type: AccountType.ROUNDING }); + jest.spyOn(ledgerAccountRepository, 'findOneBy').mockResolvedValue(existing); + const saveSpy = jest.spyOn(ledgerAccountRepository, 'save'); + + const result = await service.findOrCreate('ROUNDING', AccountType.ROUNDING, 'CHF'); + + expect(result).toBe(existing); + expect(saveSpy).not.toHaveBeenCalled(); // re-run no-op + }); + + // findOrCreate is NOT atomic (find→create→save). Two consumer crons lazily creating the SAME account race: the + // loser's save hits UNIQUE(name) (SQLSTATE 23505). It must catch exactly that, reload the winner's row and return + // it (true idempotent no-op) — the old code let the 23505 bubble up and crash the loser's booking run. + it('returns the concurrent winner’s row when save races into a UNIQUE(name) violation (23505)', async () => { + const winner = createCustomLedgerAccount({ name: 'TRANSIT/paymentLink', type: AccountType.TRANSIT }); + jest + .spyOn(ledgerAccountRepository, 'findOneBy') + .mockResolvedValueOnce(null) // initial lookup: not there yet → proceed to create/save + .mockResolvedValueOnce(winner); // reload after the race: the winner committed it + jest.spyOn(ledgerAccountRepository, 'create').mockImplementation((dto: any) => dto); + jest + .spyOn(ledgerAccountRepository, 'save') + .mockRejectedValue(Object.assign(new Error('duplicate key value violates unique constraint'), { code: '23505' })); + + const result = await service.findOrCreate('TRANSIT/paymentLink', AccountType.TRANSIT, 'CHF'); + + expect(result).toBe(winner); + expect(ledgerAccountRepository.findOneBy).toHaveBeenCalledTimes(2); // initial miss + post-race reload + }); + + // only 23505 is swallowed: any OTHER save error (no code / different code) propagates unchanged (no broad catch). + it('propagates a non-unique-violation save error unchanged (no broad catch)', async () => { + jest.spyOn(ledgerAccountRepository, 'findOneBy').mockResolvedValue(null); + jest.spyOn(ledgerAccountRepository, 'create').mockImplementation((dto: any) => dto); + jest + .spyOn(ledgerAccountRepository, 'save') + .mockRejectedValue(Object.assign(new Error('connection terminated'), { code: '08006' })); + + await expect(service.findOrCreate('LIABILITY/x', AccountType.LIABILITY, 'CHF')).rejects.toThrow( + /connection terminated/, + ); + expect(ledgerAccountRepository.findOneBy).toHaveBeenCalledTimes(1); // no post-error reload for a non-race error + }); + + // defensive fail-loud: a 23505 whose row is still not findable (not the expected name race) surfaces the original + // error instead of returning undefined (which would violate the non-nullable return type). + it('re-throws the original 23505 when the row is still not findable after the race', async () => { + const boom = Object.assign(new Error('duplicate key'), { code: '23505' }); + jest.spyOn(ledgerAccountRepository, 'findOneBy').mockResolvedValue(null); // never resolves the row + jest.spyOn(ledgerAccountRepository, 'create').mockImplementation((dto: any) => dto); + jest.spyOn(ledgerAccountRepository, 'save').mockRejectedValue(boom); + + await expect(service.findOrCreate('LIABILITY/ghost', AccountType.LIABILITY, 'CHF')).rejects.toBe(boom); + }); + + it('creates a new ASSET account with assetId relation when missing', async () => { + jest.spyOn(ledgerAccountRepository, 'findOneBy').mockResolvedValue(null); + jest.spyOn(ledgerAccountRepository, 'create').mockImplementation((dto: any) => dto); + jest.spyOn(ledgerAccountRepository, 'save').mockImplementation((a: any) => Promise.resolve(a)); + + const result = await service.findOrCreate('Kraken/EUR', AccountType.ASSET, 'EUR', 100); + + expect(result).toMatchObject({ name: 'Kraken/EUR', type: AccountType.ASSET, currency: 'EUR' }); + expect((result as any).asset).toEqual({ id: 100 }); + }); + + it('looks up an ASSET account by assetId via the relation', async () => { + const account = createCustomLedgerAccount({ name: 'Kraken/EUR', type: AccountType.ASSET }); + jest.spyOn(ledgerAccountRepository, 'findOneBy').mockResolvedValue(account); + + await expect(service.findByAssetId(100)).resolves.toBe(account); + expect(ledgerAccountRepository.findOneBy).toHaveBeenCalledWith({ asset: { id: 100 } }); + }); + + // findByAssetId normalises a repository null to undefined (the `?? undefined` branch, line 16) — callers branch on + // `if (!account)` to throw a CoA-bootstrap-missing error, so the null→undefined mapping must hold. + it('returns undefined (not null) from findByAssetId when no account exists for the assetId', async () => { + jest.spyOn(ledgerAccountRepository, 'findOneBy').mockResolvedValue(null); + + await expect(service.findByAssetId(404)).resolves.toBeUndefined(); + }); + + // findByName mirrors the same null→undefined normalisation (the `?? undefined` branch of findByName) + it('returns undefined (not null) from findByName when the account does not exist', async () => { + jest.spyOn(ledgerAccountRepository, 'findOneBy').mockResolvedValue(null); + + await expect(service.findByName('LIABILITY/does-not-exist')).resolves.toBeUndefined(); + }); + + // findOrCreate creates a non-ASSET account WITHOUT an assetId relation (the `assetId != null ? … : undefined` + // false branch, line 35) — a CHF LIABILITY bucket carries no asset relation. + it('creates a LIABILITY account with NO asset relation when assetId is omitted (undefined branch)', async () => { + jest.spyOn(ledgerAccountRepository, 'findOneBy').mockResolvedValue(null); + jest.spyOn(ledgerAccountRepository, 'create').mockImplementation((dto: any) => dto); + jest.spyOn(ledgerAccountRepository, 'save').mockImplementation((a: any) => Promise.resolve(a)); + + const result = await service.findOrCreate('LIABILITY/unattributed', AccountType.LIABILITY, 'CHF'); + + expect(result).toMatchObject({ name: 'LIABILITY/unattributed', type: AccountType.LIABILITY, currency: 'CHF' }); + expect((result as any).asset).toBeUndefined(); // no assetId → no asset relation + expect((result as any).active).toBe(true); // default active=true + }); +}); diff --git a/src/subdomains/core/accounting/services/__tests__/ledger-booking-job.service.spec.ts b/src/subdomains/core/accounting/services/__tests__/ledger-booking-job.service.spec.ts new file mode 100644 index 0000000000..e794cc5a93 --- /dev/null +++ b/src/subdomains/core/accounting/services/__tests__/ledger-booking-job.service.spec.ts @@ -0,0 +1,201 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { Process } from 'src/shared/services/process.service'; +import { DFX_CRONJOB_PARAMS, DfxCronParams } from 'src/shared/utils/cron'; +import { BankTxConsumer } from '../consumers/bank-tx.consumer'; +import { BuyCryptoConsumer } from '../consumers/buy-crypto.consumer'; +import { BuyFiatConsumer } from '../consumers/buy-fiat.consumer'; +import { CryptoInputConsumer } from '../consumers/crypto-input.consumer'; +import { ExchangeTxConsumer } from '../consumers/exchange-tx.consumer'; +import { LiquidityMgmtConsumer } from '../consumers/liquidity-mgmt.consumer'; +import { LiquidityOrderDexConsumer } from '../consumers/liquidity-order-dex.consumer'; +import { PayoutOrderConsumer } from '../consumers/payout-order.consumer'; +import { TradingOrderConsumer } from '../consumers/trading-order.consumer'; +import { getLedgerWatermark, LedgerBookingJobService, setLedgerWatermark } from '../ledger-booking-job.service'; + +describe('LedgerBookingJobService', () => { + let service: LedgerBookingJobService; + let settingService: SettingService; + let bankTxConsumer: BankTxConsumer; + let exchangeTxConsumer: ExchangeTxConsumer; + let cryptoInputConsumer: CryptoInputConsumer; + let payoutOrderConsumer: PayoutOrderConsumer; + let buyCryptoConsumer: BuyCryptoConsumer; + let buyFiatConsumer: BuyFiatConsumer; + let liquidityMgmtConsumer: LiquidityMgmtConsumer; + let liquidityOrderDexConsumer: LiquidityOrderDexConsumer; + let tradingOrderConsumer: TradingOrderConsumer; + + beforeEach(async () => { + settingService = createMock(); + bankTxConsumer = createMock(); + exchangeTxConsumer = createMock(); + cryptoInputConsumer = createMock(); + payoutOrderConsumer = createMock(); + buyCryptoConsumer = createMock(); + buyFiatConsumer = createMock(); + liquidityMgmtConsumer = createMock(); + liquidityOrderDexConsumer = createMock(); + tradingOrderConsumer = createMock(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LedgerBookingJobService, + { provide: SettingService, useValue: settingService }, + { provide: BankTxConsumer, useValue: bankTxConsumer }, + { provide: ExchangeTxConsumer, useValue: exchangeTxConsumer }, + { provide: CryptoInputConsumer, useValue: cryptoInputConsumer }, + { provide: PayoutOrderConsumer, useValue: payoutOrderConsumer }, + { provide: BuyCryptoConsumer, useValue: buyCryptoConsumer }, + { provide: BuyFiatConsumer, useValue: buyFiatConsumer }, + { provide: LiquidityMgmtConsumer, useValue: liquidityMgmtConsumer }, + { provide: LiquidityOrderDexConsumer, useValue: liquidityOrderDexConsumer }, + { provide: TradingOrderConsumer, useValue: tradingOrderConsumer }, + ], + }).compile(); + + service = module.get(LedgerBookingJobService); + }); + + it('is defined', () => { + expect(service).toBeDefined(); + }); + + describe('isLedgerReady (cutover gate, Blocker R1-6)', () => { + it('is false until ledgerCutoverLogId is set', async () => { + jest.spyOn(settingService, 'get').mockResolvedValue(undefined); + expect(await service.isLedgerReady()).toBe(false); + }); + + it('is true once ledgerCutoverLogId is set', async () => { + jest.spyOn(settingService, 'get').mockResolvedValue('1557344'); + expect(await service.isLedgerReady()).toBe(true); + }); + }); + + describe('cron wrappers gate on isLedgerReady (no-op until cutover)', () => { + it('does not run a consumer while the ledger is not ready', async () => { + jest.spyOn(settingService, 'get').mockResolvedValue(undefined); + await service.runBankTx(); + await service.runExchangeTx(); + await service.runCryptoInput(); + await service.runPayoutOrder(); + await service.runBuyCrypto(); + await service.runBuyFiat(); + await service.runLiquidityMgmt(); + await service.runLiquidityOrderDex(); + await service.runTradingOrder(); + expect(bankTxConsumer.process).not.toHaveBeenCalled(); + expect(exchangeTxConsumer.process).not.toHaveBeenCalled(); + expect(cryptoInputConsumer.process).not.toHaveBeenCalled(); + expect(payoutOrderConsumer.process).not.toHaveBeenCalled(); + expect(buyCryptoConsumer.process).not.toHaveBeenCalled(); + expect(buyFiatConsumer.process).not.toHaveBeenCalled(); + expect(liquidityMgmtConsumer.process).not.toHaveBeenCalled(); + expect(liquidityOrderDexConsumer.process).not.toHaveBeenCalled(); + expect(tradingOrderConsumer.process).not.toHaveBeenCalled(); + }); + + it('runs the consumers once the ledger is ready', async () => { + jest.spyOn(settingService, 'get').mockResolvedValue('1'); + await service.runBankTx(); + await service.runExchangeTx(); + await service.runCryptoInput(); + await service.runPayoutOrder(); + await service.runBuyCrypto(); + await service.runBuyFiat(); + await service.runLiquidityMgmt(); + await service.runLiquidityOrderDex(); + await service.runTradingOrder(); + expect(bankTxConsumer.process).toHaveBeenCalledTimes(1); + expect(exchangeTxConsumer.process).toHaveBeenCalledTimes(1); + expect(cryptoInputConsumer.process).toHaveBeenCalledTimes(1); + expect(payoutOrderConsumer.process).toHaveBeenCalledTimes(1); + expect(buyCryptoConsumer.process).toHaveBeenCalledTimes(1); + expect(buyFiatConsumer.process).toHaveBeenCalledTimes(1); + expect(liquidityMgmtConsumer.process).toHaveBeenCalledTimes(1); + expect(liquidityOrderDexConsumer.process).toHaveBeenCalledTimes(1); + expect(tradingOrderConsumer.process).toHaveBeenCalledTimes(1); + }); + }); + + describe('@DfxCron kill-switch (Hard Constraint #5, Minor R9-2)', () => { + // every registered cron method must carry a Process.LEDGER_BOOKING_* flag (no silent no-guard cron) + const expectedFlags: Record = { + runBankTx: Process.LEDGER_BOOKING_BANK_TX, + runExchangeTx: Process.LEDGER_BOOKING_EXCHANGE_TX, + runCryptoInput: Process.LEDGER_BOOKING_CRYPTO_INPUT, + runPayoutOrder: Process.LEDGER_BOOKING_PAYOUT, + runBuyCrypto: Process.LEDGER_BOOKING_BUY_CRYPTO, + runBuyFiat: Process.LEDGER_BOOKING_BUY_FIAT, + runLiquidityMgmt: Process.LEDGER_BOOKING_LIQUIDITY_MANAGEMENT, + runLiquidityOrderDex: Process.LEDGER_BOOKING_LIQUIDITY_ORDER, + runTradingOrder: Process.LEDGER_BOOKING_TRADING_ORDER, + }; + + for (const [method, flag] of Object.entries(expectedFlags)) { + it(`${method} carries its own ${flag} process flag`, () => { + const params: DfxCronParams = Reflect.getMetadata( + DFX_CRONJOB_PARAMS, + LedgerBookingJobService.prototype[method as keyof LedgerBookingJobService], + ); + expect(params).toBeDefined(); + expect(params.process).toBe(flag); + }); + } + }); + + describe('watermark helpers (§11.3, set via settingService.set as JSON)', () => { + it('reads a watermark via getObj and parses lastReversalScan to a Date', async () => { + jest.spyOn(settingService, 'getObj').mockResolvedValue({ + lastProcessedId: 42, + lastReversalScan: '2026-06-01T00:00:00.000Z', + lastReversalScanId: 9, + } as any); + const wm = await getLedgerWatermark(settingService, 'bank_tx'); + expect(wm.lastProcessedId).toBe(42); + expect(wm.lastReversalScan).toBeInstanceOf(Date); + expect(wm.lastReversalScan.toISOString()).toBe('2026-06-01T00:00:00.000Z'); + expect(wm.lastReversalScanId).toBe(9); // combined (updated, id) cursor id-tiebreak (§4.12) + }); + + it('defaults lastReversalScanId to 0 for a legacy watermark without the field', async () => { + jest + .spyOn(settingService, 'getObj') + .mockResolvedValue({ lastProcessedId: 42, lastReversalScan: '2026-06-01T00:00:00.000Z' } as any); + const wm = await getLedgerWatermark(settingService, 'bank_tx'); + expect(wm.lastReversalScanId).toBe(0); // backward-compatible read of a pre-cursor watermark + }); + + it('returns undefined when no watermark exists yet', async () => { + jest.spyOn(settingService, 'getObj').mockResolvedValue(undefined); + expect(await getLedgerWatermark(settingService, 'bank_tx')).toBeUndefined(); + }); + + it('writes a watermark exclusively via settingService.set (never setObj/settingRepo, §4.10 R2-exception-a)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + await setLedgerWatermark(settingService, 'crypto_input', { + lastProcessedId: 7, + lastReversalScan: new Date('2026-06-02T00:00:00.000Z'), + lastReversalScanId: 3, + }); + expect(setSpy).toHaveBeenCalledWith( + 'ledgerWatermark.crypto_input', + JSON.stringify({ lastProcessedId: 7, lastReversalScan: '2026-06-02T00:00:00.000Z', lastReversalScanId: 3 }), + ); + }); + + it('serializes lastReversalScanId as 0 when omitted (combined-cursor default)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + await setLedgerWatermark(settingService, 'crypto_input', { + lastProcessedId: 7, + lastReversalScan: new Date('2026-06-02T00:00:00.000Z'), + }); + expect(setSpy).toHaveBeenCalledWith( + 'ledgerWatermark.crypto_input', + JSON.stringify({ lastProcessedId: 7, lastReversalScan: '2026-06-02T00:00:00.000Z', lastReversalScanId: 0 }), + ); + }); + }); +}); diff --git a/src/subdomains/core/accounting/services/__tests__/ledger-booking.service.spec.ts b/src/subdomains/core/accounting/services/__tests__/ledger-booking.service.spec.ts new file mode 100644 index 0000000000..788e100812 --- /dev/null +++ b/src/subdomains/core/accounting/services/__tests__/ledger-booking.service.spec.ts @@ -0,0 +1,744 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { TestUtil } from 'src/shared/utils/test.util'; +import { DataSource, EntityManager } from 'typeorm'; +import { AccountType } from '../../entities/ledger-account.entity'; +import { createCustomLedgerAccount } from '../../entities/__mocks__/ledger-account.entity.mock'; +import { createCustomLedgerLeg } from '../../entities/__mocks__/ledger-leg.entity.mock'; +import { createCustomLedgerTx } from '../../entities/__mocks__/ledger-tx.entity.mock'; +import { LedgerTx } from '../../entities/ledger-tx.entity'; +import { LedgerLeg } from '../../entities/ledger-leg.entity'; +import { LedgerAccountService } from '../ledger-account.service'; +import { LedgerBookingService, LedgerLegInput } from '../ledger-booking.service'; + +describe('LedgerBookingService', () => { + let service: LedgerBookingService; + + let dataSource: DataSource; + let ledgerAccountService: LedgerAccountService; + + let savedLegs: LedgerLeg[]; + + const walletAsset = createCustomLedgerAccount({ + id: 10, + name: 'Binance/BTC', + type: AccountType.ASSET, + currency: 'BTC', + }); + const exchangeAsset = createCustomLedgerAccount({ + id: 11, + name: 'Scrypt/BTC', + type: AccountType.ASSET, + currency: 'BTC', + }); + const liability = createCustomLedgerAccount({ + id: 20, + name: 'LIABILITY/buyFiat-received', + type: AccountType.LIABILITY, + currency: 'CHF', + }); + const roundingAccount = createCustomLedgerAccount({ + id: 99, + name: 'ROUNDING', + type: AccountType.ROUNDING, + currency: 'CHF', + }); + + beforeEach(async () => { + savedLegs = []; + + dataSource = createMock(); + ledgerAccountService = createMock(); + + // mock transaction: invoke callback with a manager that echoes create/save + jest.spyOn(dataSource, 'transaction').mockImplementation((arg: any) => { + const runInTransaction = typeof arg === 'function' ? arg : arg; + const manager = createMock(); + jest.spyOn(manager, 'create').mockImplementation((_entity: any, plain: any) => { + const isArray = Array.isArray(plain); + const build = (p: any) => + _entity === LedgerTx ? Object.assign(new LedgerTx(), p) : Object.assign(new LedgerLeg(), p); + return (isArray ? plain.map(build) : build(plain)) as any; + }); + jest.spyOn(manager, 'save').mockImplementation((_entity: any, value: any) => { + if (_entity === LedgerLeg) savedLegs = value as LedgerLeg[]; + return Promise.resolve(value) as any; + }); + // §4.12 (F2): the manager-scoped seq allocation reads through manager.getRepository → delegate to whatever + // dataSource.getRepository a test sets, so the in-transaction seq read sees the same source of truth + jest.spyOn(manager, 'getRepository').mockImplementation((entity: any) => dataSource.getRepository(entity)); + return runInTransaction(manager) as any; + }); + + jest.spyOn(ledgerAccountService, 'findByName').mockResolvedValue(roundingAccount); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TestUtil.provideConfig(), + LedgerBookingService, + { provide: DataSource, useValue: dataSource }, + { provide: LedgerAccountService, useValue: ledgerAccountService }, + ], + }).compile(); + + service = module.get(LedgerBookingService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + // nextSeq `(max ?? -1) + 1` (line 235): an empty (sourceType,sourceId) namespace → MAX(seq) is null → nextSeq 0. + // nextCorrectionSeq then lifts it into the reserved correction range. Proves seq allocation starts at 0, not NaN. + it('allocates seq 0 for an empty source namespace (MAX(seq) null → ?? -1)', async () => { + const qb: any = { + select: () => qb, + where: () => qb, + andWhere: () => qb, + getRawOne: () => Promise.resolve({ max: null }), // no existing tx for the source + }; + jest.spyOn(dataSource, 'getRepository').mockReturnValue({ createQueryBuilder: () => qb } as any); + + expect(await service.nextSeq('bank_tx', '404')).toBe(0); // (null ?? -1) + 1 = 0 + expect(await service.nextCorrectionSeq('bank_tx', '404')).toBe(1_000_000); // lifted into the correction range + }); + + it('books a balanced cross-asset tx with amountChfSum === 0', async () => { + const legs: LedgerLegInput[] = [ + { account: walletAsset, amount: 1, priceChf: 50000, amountChf: 50000 }, + { account: liability, amount: -50000, amountChf: -50000 }, + ]; + + const tx = await service.bookTx({ + sourceType: 'crypto_input', + sourceId: '1', + seq: 0, + bookingDate: new Date('2026-06-01'), + legs, + }); + + expect(tx.amountChfSum).toBe(0); + expect(typeof tx.amountChfSum).toBe('number'); // JS number, never a raw bigint string (bigint column → chfCentsTransformer, Blocker R1-4) + expect(savedLegs).toHaveLength(2); + expect(savedLegs.reduce((s, l) => s + l.amountChfCents, 0)).toBe(0); // real addition, no string concat + expect(savedLegs.map((l) => l.amountChfCents)).toEqual([5000000, -5000000]); + }); + + it('appends a sub-cent ROUNDING leg when CHF rest is within tolerance', async () => { + const legs: LedgerLegInput[] = [ + { account: walletAsset, amount: 1, priceChf: 50000.01, amountChf: 50000.01 }, + { account: liability, amount: -50000, amountChf: -50000 }, + ]; + + const tx = await service.bookTx({ + sourceType: 'crypto_input', + sourceId: '2', + seq: 0, + bookingDate: new Date('2026-06-01'), + legs, + }); + + expect(tx.amountChfSum).toBe(0); + expect(savedLegs).toHaveLength(3); + + const rounding = savedLegs.find((l) => l.account.type === AccountType.ROUNDING); + expect(rounding).toBeDefined(); + expect(rounding.amount).toBe(0); + expect(rounding.priceChf).toBeNull(); + expect(rounding.amountChfCents).toBe(-1); // closes 50000.01 ↔ -50000 (1 cent) + expect(savedLegs.reduce((s, l) => s + l.amountChfCents, 0)).toBe(0); + }); + + it('throws when CHF imbalance exceeds the rounding tolerance (structural spread not plugged)', async () => { + const legs: LedgerLegInput[] = [ + { account: walletAsset, amount: 1, priceChf: 50050, amountChf: 50050 }, + { account: liability, amount: -50000, amountChf: -50000 }, + ]; + + await expect( + service.bookTx({ sourceType: 'crypto_input', sourceId: '3', seq: 0, bookingDate: new Date('2026-06-01'), legs }), + ).rejects.toThrow(/imbalance/i); + + expect(savedLegs).toHaveLength(0); // tx not booked + }); + + it('does NOT apply a native balance check on value-boundary tx (asset ↔ liability)', async () => { + const logSpy = jest.spyOn((service as any).logger, 'error'); + const legs: LedgerLegInput[] = [ + { account: walletAsset, amount: 1, priceChf: 50000, amountChf: 50000 }, // BTC one-sided, correct + { account: liability, amount: -50000, amountChf: -50000 }, + ]; + + await service.bookTx({ + sourceType: 'crypto_input', + sourceId: '4', + seq: 0, + bookingDate: new Date('2026-06-01'), + legs, + }); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('logs a native imbalance only for pure same-asset transfers', async () => { + const logSpy = jest.spyOn((service as any).logger, 'error'); + const legs: LedgerLegInput[] = [ + { account: exchangeAsset, amount: 1, priceChf: 50000, amountChf: 50000 }, + { account: walletAsset, amount: -1, priceChf: 50000, amountChf: -50000 }, // same BTC ccy, balanced native + ]; + + await service.bookTx({ + sourceType: 'exchange_tx', + sourceId: '5', + seq: 0, + bookingDate: new Date('2026-06-01'), + legs, + }); + + expect(logSpy).not.toHaveBeenCalled(); // native nets to 0 → no error + + logSpy.mockClear(); + const unbalanced: LedgerLegInput[] = [ + { account: exchangeAsset, amount: 1, priceChf: 50000, amountChf: 50000 }, + { account: walletAsset, amount: -2, priceChf: 25000, amountChf: -50000 }, // native BTC ≠ 0 + ]; + + await service.bookTx({ + sourceType: 'exchange_tx', + sourceId: '6', + seq: 0, + bookingDate: new Date('2026-06-01'), + legs: unbalanced, + }); + + expect(logSpy).toHaveBeenCalled(); // pure same-asset transfer with native imbalance → logged + }); + + it('reverses a tx with inverted legs and the next free seq', async () => { + jest.spyOn(dataSource, 'getRepository').mockReturnValue({ + createQueryBuilder: () => ({ + select: () => ({ + where: () => ({ + andWhere: () => ({ getRawOne: () => Promise.resolve({ max: 1 }) }), + }), + }), + }), + } as any); + + const original = createCustomLedgerTx({ + id: 7, + sourceType: 'bank_tx', + sourceId: '202000', + seq: 0, + legs: [ + createCustomLedgerLeg({ + account: walletAsset, + amount: 1, + priceChf: 50000, + amountChf: 50000, + amountChfCents: 5000000, + }), + createCustomLedgerLeg({ account: liability, amount: -50000, amountChf: -50000, amountChfCents: -5000000 }), + ], + }); + + const reversal = await service.reverseTx(original); + + expect(reversal.seq).toBe(1_000_000); // reversal lives in the reserved correction range (§4.12 eigener Namespace) + expect(reversal.reversalOf).toBe(original); + expect(reversal.amountChfSum).toBe(0); + expect(savedLegs.map((l) => l.amountChfCents)).toEqual([-5000000, 5000000]); // inverted + }); + + describe('reverseAndRebookIfChanged (§4.12 content-change cycle)', () => { + // an in-memory ledger_tx store the service reads via getRepository(LedgerTx).find / .createQueryBuilder(nextSeq) + // and writes via dataSource.transaction (manager.create/save). Lets the full reversal cycle run for real. + let txStore: LedgerTx[]; + let nextId: number; + + beforeEach(() => { + txStore = []; + nextId = 1; + + // capture booked tx into the store so activeTx/nextSeq see the running state + jest.spyOn(dataSource, 'transaction').mockImplementation((arg: any) => { + const manager = createMock(); + jest.spyOn(manager, 'create').mockImplementation((entity: any, plain: any) => { + const build = (p: any) => + entity === LedgerTx + ? Object.assign(new LedgerTx(), p, p?.reversalOf?.id != null ? { reversalOfId: p.reversalOf.id } : {}) + : Object.assign(new LedgerLeg(), p); + return (Array.isArray(plain) ? plain.map(build) : build(plain)) as any; + }); + jest.spyOn(manager, 'save').mockImplementation((entity: any, value: any) => { + if (entity === LedgerTx) { + const tx = value as LedgerTx; + tx.id = nextId++; + txStore.push(tx); + return Promise.resolve(tx) as any; + } + // attach legs to their tx (so a later find returns them) + const legs = value as LedgerLeg[]; + for (const leg of legs) (leg.tx.legs ??= []).push(leg); + savedLegs = legs; + return Promise.resolve(legs) as any; + }); + // §4.12 (F2): the in-transaction seq read goes through manager.getRepository → delegate to the store-backed + // dataSource.getRepository so the re-book's seq allocation sees the uncommitted reversal (reversal.seq + 1) + jest.spyOn(manager, 'getRepository').mockImplementation((entity: any) => dataSource.getRepository(entity)); + return (arg as (m: EntityManager) => unknown)(manager) as any; + }); + + jest.spyOn(dataSource, 'getRepository').mockReturnValue({ + find: ({ where }: any) => + Promise.resolve( + txStore + .filter((tx) => tx.sourceType === where.sourceType && tx.sourceId === where.sourceId) + .sort((a, b) => a.seq - b.seq), + ), + // hasAnyTxAt: any tx exists at the exact (sourceType, sourceId, seq) + existsBy: (where: any) => + Promise.resolve( + txStore.some( + (tx) => tx.sourceType === where.sourceType && tx.sourceId === where.sourceId && tx.seq === where.seq, + ), + ), + createQueryBuilder: () => { + let st: string, sid: string; + const qb: any = {}; + qb.select = () => qb; + qb.where = (_e: string, p: any) => ((st = p.sourceType), qb); + qb.andWhere = (_e: string, p: any) => ((sid = p.sourceId), qb); + qb.getRawOne = () => { + const seqs = txStore.filter((t) => t.sourceType === st && t.sourceId === sid).map((t) => t.seq); + return Promise.resolve({ max: seqs.length ? Math.max(...seqs) : null }); + }; + return qb; + }, + } as any); + }); + + const seq0Input = (amountChf: number) => ({ + sourceType: 'bank_tx', + sourceId: '900', + seq: 0, + bookingDate: new Date('2026-06-01'), + legs: [ + { account: walletAsset, amount: 1, priceChf: amountChf, amountChf }, + { account: liability, amount: -amountChf, priceChf: 1, amountChf: -amountChf }, + ], + }); + + it('books a reversal + re-book when a leg amount changed beyond tolerance; seq strictly monotonic', async () => { + await service.bookTx(seq0Input(50000)); // seq0 + + const changed = await service.reverseAndRebookIfChanged(seq0Input(60000)); // amountChf changed + expect(changed).toBe(true); + + const all = txStore.filter((t) => t.sourceId === '900').sort((a, b) => a.seq - b.seq); + // §4.12 "eigener seq-Namespace": the forward seq0 stays, reversal + re-book live in the reserved correction + // range (≥ 1_000_000) so they never collide with a not-yet-booked forward seq of a multi-seq source (R3). + expect(all.map((t) => t.seq)).toEqual([0, 1_000_000, 1_000_001]); // strictly monotone over the cycle + expect(all[1].reversalOfId).toBe(all[0].id); // the reversal reverses the ORIGINAL seq0 + expect(all[2].reversalOfId).toBeUndefined(); // the re-book is a new valid booking + }); + + // §4.12 (F2): reversal + re-book run in ONE transaction. A crash DURING the re-book (after the reversal was + // written) must roll BOTH back — otherwise a flat-reversal state persists and the next forward run collides on + // the original seq0 (a UNIQUE loop). The transaction mock here is ATOMIC: tx saved in the callback are buffered + // and only flushed to txStore when the callback RESOLVES; a throw discards the buffer (models a real ROLLBACK). + it('rolls back BOTH the reversal and the re-book when the re-book save crashes (one-transaction atomicity)', async () => { + await service.bookTx(seq0Input(50000)); // committed forward seq0 (via the describe's default tx mock) + + let ledgerTxSaves = 0; + jest.spyOn(dataSource, 'transaction').mockImplementation(async (arg: any) => { + const buffer: LedgerTx[] = []; + const manager = createMock(); + jest.spyOn(manager, 'create').mockImplementation((entity: any, plain: any) => { + const build = (p: any) => + entity === LedgerTx + ? Object.assign(new LedgerTx(), p, p?.reversalOf?.id != null ? { reversalOfId: p.reversalOf.id } : {}) + : Object.assign(new LedgerLeg(), p); + return (Array.isArray(plain) ? plain.map(build) : build(plain)) as any; + }); + jest.spyOn(manager, 'save').mockImplementation((entity: any, value: any) => { + if (entity === LedgerTx) { + if (++ledgerTxSaves === 2) return Promise.reject(new Error('re-book save crashed')); // 2nd = the re-book + const tx = value as LedgerTx; + tx.id = nextId++; + buffer.push(tx); // buffered, NOT yet visible in txStore + return Promise.resolve(tx) as any; + } + const legs = value as LedgerLeg[]; + for (const leg of legs) (leg.tx.legs ??= []).push(leg); + savedLegs = legs; + return Promise.resolve(legs) as any; + }); + // seq reads inside the tx see txStore + the buffered (uncommitted) reversal → the re-book gets reversal.seq + 1 + jest.spyOn(manager, 'getRepository').mockReturnValue({ + createQueryBuilder: () => { + let st: string, sid: string; + const qb: any = {}; + qb.select = () => qb; + qb.where = (_e: string, p: any) => ((st = p.sourceType), qb); + qb.andWhere = (_e: string, p: any) => ((sid = p.sourceId), qb); + qb.getRawOne = () => { + const seqs = [...txStore, ...buffer] + .filter((t) => t.sourceType === st && t.sourceId === sid) + .map((t) => t.seq); + return Promise.resolve({ max: seqs.length ? Math.max(...seqs) : null }); + }; + return qb; + }, + } as any); + + const result = await (arg as (m: EntityManager) => unknown)(manager); + txStore.push(...buffer); // flush only on resolve; the throw above skips this line (rollback) + return result as any; + }); + + await expect(service.reverseAndRebookIfChanged(seq0Input(60000))).rejects.toThrow(); + + const forSource = txStore.filter((t) => t.sourceId === '900'); + expect(forSource).toHaveLength(1); // only the original seq0 survived — the reversal was rolled back + expect(forSource[0].seq).toBe(0); + expect(txStore.some((t) => t.reversalOfId != null)).toBe(false); // no orphan flat reversal persisted + }); + + it('is a no-op when nothing changed (idempotent re-scan) and when a sub-tolerance mark drift occurs', async () => { + await service.bookTx(seq0Input(50000)); // seq0 + + expect(await service.reverseAndRebookIfChanged(seq0Input(50000))).toBe(false); // identical → no-op + // a priceChf drift below 1e-6 and amountChf below 0.005 must NOT trigger a reversal (§4.12 tolerances) + const subTol = { + ...seq0Input(50000), + legs: [ + { account: walletAsset, amount: 1 + 5e-9, priceChf: 50000 + 5e-7, amountChf: 50000 + 0.002 }, + { account: liability, amount: -50000, priceChf: 1, amountChf: -50000 }, + ], + }; + expect(await service.reverseAndRebookIfChanged(subTol)).toBe(false); + expect(txStore.filter((t) => t.sourceId === '900')).toHaveLength(1); // still only seq0 + }); + + it('returns false when no original booking exists at the seq (forward booker owns it)', async () => { + expect(await service.reverseAndRebookIfChanged(seq0Input(50000))).toBe(false); + expect(txStore).toHaveLength(0); + }); + + it('reverseActiveIfBooked reverses flat when the row is no longer bookable', async () => { + await service.bookTx(seq0Input(50000)); // seq0 + + const reversed = await service.reverseActiveIfBooked('bank_tx', '900', 0); + expect(reversed).toBe(true); + + const all = txStore.filter((t) => t.sourceId === '900').sort((a, b) => a.seq - b.seq); + expect(all.map((t) => t.seq)).toEqual([0, 1_000_000]); // forward seq0 + reversal in the correction range + expect(all[1].reversalOfId).toBe(all[0].id); // flat reversal, no re-book + }); + + it('reverseActiveIfBooked is a no-op (false) when nothing is booked at the seq', async () => { + expect(await service.reverseActiveIfBooked('bank_tx', '900', 0)).toBe(false); + expect(txStore).toHaveLength(0); + }); + + // §4.12 activeTx walk: after a FIRST correction (seq0 reversed → re-book at 1_000_001), a SECOND content change + // must reverse the LIVE re-book (NOT the original seq0) — the activeTx loop has to follow the chain twice. + it('follows the reversal chain across TWO correction cycles (activeTx loop iterates, re-book of a re-book)', async () => { + await service.bookTx(seq0Input(50000)); // seq0 + + expect(await service.reverseAndRebookIfChanged(seq0Input(60000))).toBe(true); // cycle 1: re-book at 1_000_001 + expect(await service.reverseAndRebookIfChanged(seq0Input(70000))).toBe(true); // cycle 2: reverses the re-book + + const all = txStore.filter((t) => t.sourceId === '900').sort((a, b) => a.seq - b.seq); + // forward seq0 + (reversal, re-book) ×2 → 5 tx, strictly monotone in the correction range + expect(all.map((t) => t.seq)).toEqual([0, 1_000_000, 1_000_001, 1_000_002, 1_000_003]); + // the cycle-2 reversal must target the cycle-1 re-book (seq 1_000_001), proving the walk advanced past seq0 + const rebook1 = all.find((t) => t.seq === 1_000_001); + const reversal2 = all.find((t) => t.seq === 1_000_002); + expect(reversal2.reversalOfId).toBe(rebook1.id); + // the live re-book carries the latest value (70000) + const live = all.find((t) => t.seq === 1_000_003); + const liveLeg = live.legs.find((l) => l.account.id === walletAsset.id); + expect(liveLeg.amountChf).toBe(70000); + }); + + // §4.12 activeTx: a flat reversal (reversed, NO re-book) makes the source "nothing booked now" → a later + // reverseAndRebookIfChanged finds no active tx and is a no-op (returns false, walk returns undefined). + it('treats a flat-reversed source as nothing-booked: a later reverse-and-rebook is a no-op', async () => { + await service.bookTx(seq0Input(50000)); // seq0 + await service.reverseActiveIfBooked('bank_tx', '900', 0); // flat reversal, no re-book + + const changed = await service.reverseAndRebookIfChanged(seq0Input(99999)); + expect(changed).toBe(false); // activeTx walk hits the flat reversal → undefined → nothing to correct + + const all = txStore.filter((t) => t.sourceId === '900'); + expect(all).toHaveLength(2); // still only forward seq0 + the flat reversal, no new re-book + }); + + it('hasActiveTxAt is true for a live forward booking and false after a flat reversal', async () => { + await service.bookTx(seq0Input(50000)); // seq0 live + expect(await service.hasActiveTxAt('bank_tx', '900', 0)).toBe(true); + + await service.reverseActiveIfBooked('bank_tx', '900', 0); // flat reversal → nothing active at seq0 + expect(await service.hasActiveTxAt('bank_tx', '900', 0)).toBe(false); + }); + + it('hasActiveTxAt is false for a seq that was never booked', async () => { + expect(await service.hasActiveTxAt('bank_tx', '900', 5)).toBe(false); + }); + + // §4.12 activeTx re-book selection (adjacency): the walk follows the corrected re-book at EXACTLY reversal.seq + 1 + // and ignores any higher, non-adjacent booking above the reversal. This seeds the adjacent re-book (1_000_001) plus + // a higher decoy (1_000_002, inserted first, out of seq order) and asserts the walk resolves the chain to the + // adjacent one (1_000_001), so a re-scan with the same value is a no-op. + it('follows the adjacent re-book at reversal.seq + 1 and ignores a higher non-adjacent booking', async () => { + await service.bookTx(seq0Input(50000)); // forward seq0 (id 1) + const original = txStore.find((t) => t.seq === 0)!; + + // seed a reversal of seq0 plus the adjacent re-book (1_000_001) and a higher non-adjacent decoy (out of seq order) + const reversal = Object.assign(new LedgerTx(), { + id: nextId++, + sourceType: 'bank_tx', + sourceId: '900', + seq: 1_000_000, + reversalOfId: original.id, + legs: [], + }); + const rebookLater = Object.assign(new LedgerTx(), { + id: nextId++, + sourceType: 'bank_tx', + sourceId: '900', + seq: 1_000_002, // higher, non-adjacent (reversal.seq + 2) → the adjacency walk must ignore it + legs: [ + { account: walletAsset, amount: 1, priceChf: 60000, amountChf: 60000 }, + { account: liability, amount: -60000, priceChf: 1, amountChf: -60000 }, + ], + }); + const rebookEarliest = Object.assign(new LedgerTx(), { + id: nextId++, + sourceType: 'bank_tx', + sourceId: '900', + seq: 1_000_001, // adjacent (reversal.seq + 1) → the chain's next link + legs: [ + { account: walletAsset, amount: 1, priceChf: 50000, amountChf: 50000 }, + { account: liability, amount: -50000, priceChf: 1, amountChf: -50000 }, + ], + }); + txStore.push(reversal, rebookLater, rebookEarliest); + + // a re-scan with the SAME value as the adjacent re-book (50000) → the walk resolves the active tx to the + // adjacent re-book (1_000_001), finds it unchanged → no-op (false). Resolving to the non-adjacent 1_000_002 + // (60000) instead would see a diff and wrongly book another reversal. + const changed = await service.reverseAndRebookIfChanged(seq0Input(50000)); + expect(changed).toBe(false); // active tx = the 50000 re-book at 1_000_001 → unchanged → no reversal + expect(txStore.filter((t) => t.sourceId === '900')).toHaveLength(4); // no new tx appended + }); + + // §4.12 activeTx adjacency (M2): a FLAT reversal (reversed, NO re-book) must resolve to "nothing booked", even + // when an UNRELATED foreign correction (reversalOf NULL) sits at a HIGHER seq across a seq gap. The corrected + // re-book is required at EXACTLY reversal.seq + 1; the OLD "smallest seq > reversal.seq" picker would grab the + // foreign tx and wrongly report a phantom active booking (e.g. an ExchangeTrade fill flipped ok→failed). + it('activeTx returns undefined for a flat reversal even when a foreign correction sits at a higher seq (adjacency)', async () => { + await service.bookTx(seq0Input(50000)); // forward seq0 (id 1, reversalOf NULL) + const original = txStore.find((t) => t.seq === 0)!; + + const reversal = Object.assign(new LedgerTx(), { + id: nextId++, + sourceType: 'bank_tx', + sourceId: '900', + seq: 1_000_000, + reversalOfId: original.id, // flat reversal of seq0 — NO re-book at 1_000_001 + legs: [], + }); + const foreign = Object.assign(new LedgerTx(), { + id: nextId++, + sourceType: 'bank_tx', + sourceId: '900', + seq: 1_000_005, // an unrelated correction (reversalOf NULL) across a seq gap — NOT the re-book of seq0 + reversalOfId: undefined, + legs: [ + { account: walletAsset, amount: 1, priceChf: 99999, amountChf: 99999 }, + { account: liability, amount: -99999, priceChf: 1, amountChf: -99999 }, + ], + }); + txStore.push(reversal, foreign); + + const active = await (service as any).activeTx('bank_tx', '900', 0); + expect(active).toBeUndefined(); // flat reversal → nothing booked now; the foreign 1_000_005 tx must NOT be grabbed + }); + + // §4.12 needsMark reversal (line 103) + legsDiffer null tolerances (lines 206/207): a seq0 booked with a + // needsMark ASSET leg (amountChf undefined). (a) re-scanning with the SAME needsMark legs → legsDiffer compares + // null-vs-null via `within` (line 206 → true) → no-op. (b) re-scanning with the needsMark leg now VALUED → `within` + // sees null-vs-number (line 207 → false) → reversal; the reversal inverts the needsMark leg → its amountChf stays + // undefined (line 103 `: undefined` arm), proving the reversal preserves the unmarked state. + // BOTH legs unmarked so the tx balances at 0 CHF cents (amountChf undefined == 0 cents) and bookTx accepts it, + // while the legs carry needsMark=true / amountChf=undefined — the basis for the null-tolerance comparisons. + const needsMarkInput = (marked: boolean) => ({ + sourceType: 'bank_tx', + sourceId: '950', + seq: 0, + bookingDate: new Date('2026-06-01'), + legs: marked + ? [ + { account: walletAsset, amount: 1, priceChf: 50000, amountChf: 50000 }, + { account: liability, amount: -50000, priceChf: 1, amountChf: -50000 }, + ] + : [ + { account: walletAsset, amount: 1, priceChf: null, amountChf: undefined, needsMark: true }, + { account: liability, amount: -1, priceChf: null, amountChf: undefined, needsMark: true }, + ], + }); + + it('no-ops when an unmarked seq0 is re-scanned unchanged (legsDiffer null-vs-null, line 206)', async () => { + await service.bookTx(needsMarkInput(false)); // seq0 with TWO needsMark legs (both amountChf undefined, 0 cents) + + expect(await service.reverseAndRebookIfChanged(needsMarkInput(false))).toBe(false); // null vs null -> unchanged + expect(txStore.filter((t) => t.sourceId === '950')).toHaveLength(1); // no reversal appended + }); + + it('reverses an unmarked seq0 when its legs become valued, keeping the reversed unmarked legs undefined (lines 103/207)', async () => { + await service.bookTx(needsMarkInput(false)); // seq0 with TWO needsMark legs + + // the legs now carry CHF values -> legsDiffer within(null, 50000) is false (line 207) -> reversal + re-book + expect(await service.reverseAndRebookIfChanged(needsMarkInput(true))).toBe(true); + + const all = txStore.filter((t) => t.sourceId === '950').sort((a, b) => a.seq - b.seq); + expect(all.map((t) => t.seq)).toEqual([0, 1_000_000, 1_000_001]); + // the reversal (seq 1_000_000) inverts the ORIGINAL (unmarked) legs; each original amountChf was undefined -> + // its reversal leg is ALSO undefined (line 103 ": undefined" arm), NOT -0, and stays needsMark. + const reversal = all.find((t) => t.seq === 1_000_000); + for (const rl of reversal.legs) { + expect(rl.amountChf == null).toBe(true); // value-less (line 103 ": undefined" arm) — NOT a number, NOT -0 + expect(typeof rl.amountChf).not.toBe('number'); // a -0 from a buggy `-leg.amountChf` would fail here + expect(rl.needsMark).toBe(true); + } + }); + + it('reverseAndRebookIfChanged returns true on a leg-COUNT change (different number of legs)', async () => { + await service.bookTx(seq0Input(50000)); // 2-leg seq0 + + // a 3-leg fresh input (extra EXPENSE leg) → legs.length differs → content changed regardless of tolerances + const threeLeg = { + sourceType: 'bank_tx', + sourceId: '900', + seq: 0, + bookingDate: new Date('2026-06-01'), + legs: [ + { account: walletAsset, amount: 1, priceChf: 50000, amountChf: 49990 }, + { account: liability, amount: -50000, priceChf: 1, amountChf: -50000 }, + { account: exchangeAsset, amount: 0, priceChf: 1, amountChf: 10 }, // extra leg + ], + }; + expect(await service.reverseAndRebookIfChanged(threeLeg)).toBe(true); + }); + + // --- §4.12 value-coupled chain reversal (M3) --- // + + // a value-coupled 2-seq chain on the SAME liability: seq0 opens the liability at −chf, seq1 closes it at +chf → + // net 0. sum of `liability` amountChf across the whole store = the liability's running balance. + const chainSeqs = (chf: number) => [ + { + sourceType: 'bank_tx', + sourceId: '960', + seq: 0, + bookingDate: new Date('2026-06-01'), + legs: [ + { account: walletAsset, amount: 1, priceChf: chf, amountChf: chf }, + { account: liability, amount: -chf, priceChf: 1, amountChf: -chf }, // opens liability at −chf + ], + }, + { + sourceType: 'bank_tx', + sourceId: '960', + seq: 1, + bookingDate: new Date('2026-06-01'), + legs: [ + { account: liability, amount: chf, priceChf: 1, amountChf: chf }, // closes liability at +chf + { account: exchangeAsset, amount: -1, priceChf: chf, amountChf: -chf }, + ], + }, + ]; + const liabilityBalance = () => + txStore + .filter((t) => t.sourceId === '960') + .flatMap((t) => t.legs) + .filter((l) => l.account.id === liability.id) + .reduce((s, l) => s + (l.amountChf ?? 0), 0); + + it('reverses the WHOLE value-coupled chain on a change so the shared liability still closes to 0 (M3)', async () => { + const [seq0, seq1] = chainSeqs(50000); + await service.bookTx(seq0); + await service.bookTx(seq1); + expect(liabilityBalance()).toBe(0); // opened −50000 (seq0), closed +50000 (seq1) + + // amountInChf changes 50000 → 60000: reversing ONLY seq0 would leave the liability at −10000 (the M3 bug). The + // chain method reverses+rebooks BOTH active seqs → the liability closes cent-exact to 0 again. + const changed = await service.reverseAndRebookChainIfChanged(chainSeqs(60000)); + expect(changed).toBe(true); + expect(liabilityBalance()).toBe(0); // −50000 +50000(rev) −60000(rebook) +50000 −50000(rev) +60000(rebook) = 0 + + // the live re-books carry the NEW value (60000) at both seqs + const active0 = await (service as any).activeTx('bank_tx', '960', 0); + const active1 = await (service as any).activeTx('bank_tx', '960', 1); + expect(active0.legs.find((l: LedgerLeg) => l.account.id === liability.id).amountChf).toBe(-60000); + expect(active1.legs.find((l: LedgerLeg) => l.account.id === liability.id).amountChf).toBe(60000); + }); + + it('reverseAndRebookChainIfChanged is a no-op (false) when nothing in the chain changed', async () => { + const [seq0, seq1] = chainSeqs(50000); + await service.bookTx(seq0); + await service.bookTx(seq1); + + expect(await service.reverseAndRebookChainIfChanged(chainSeqs(50000))).toBe(false); // identical → no-op + expect(txStore.filter((t) => t.sourceId === '960')).toHaveLength(2); // no reversal/re-book appended + }); + + it('reverseAndRebookChainIfChanged reverses ONLY the currently-active seqs (a not-yet-booked later seq is skipped)', async () => { + const [seq0] = chainSeqs(50000); + await service.bookTx(seq0); // only seq0 booked; seq1 not yet settled + + // seq0 changed, seq1 has no active booking → the chain reverses seq0 alone; seq1 is left to the forward path + const changed = await service.reverseAndRebookChainIfChanged(chainSeqs(60000)); + expect(changed).toBe(true); + const all = txStore.filter((t) => t.sourceId === '960').sort((a, b) => a.seq - b.seq); + expect(all.map((t) => t.seq)).toEqual([0, 1_000_000, 1_000_001]); // seq0 reversal+rebook only, no seq1 tx + }); + + it('reverseAndRebookChainIfChanged returns false when no seq in the chain is booked yet', async () => { + expect(await service.reverseAndRebookChainIfChanged(chainSeqs(50000))).toBe(false); + expect(txStore.filter((t) => t.sourceId === '960')).toHaveLength(0); + }); + + it('hasAnyTxAt is true once a seq is booked (even after a flat reversal) and false for an unbooked seq', async () => { + await service.bookTx(seq0Input(50000)); // seq0 booked + expect(await service.hasAnyTxAt('bank_tx', '900', 0)).toBe(true); + expect(await service.hasAnyTxAt('bank_tx', '900', 5)).toBe(false); // never booked at seq 5 + + await service.reverseActiveIfBooked('bank_tx', '900', 0); // flat reversal — the original seq0 tx still EXISTS + expect(await service.hasActiveTxAt('bank_tx', '900', 0)).toBe(false); // nothing ACTIVE + expect(await service.hasAnyTxAt('bank_tx', '900', 0)).toBe(true); // but a tx still exists at seq0 (append-only) + }); + }); + + describe('rounding-account guard', () => { + it('throws when the ROUNDING account is missing while a sub-cent rest needs plugging (CoA bootstrap missing)', async () => { + jest.spyOn(ledgerAccountService, 'findByName').mockResolvedValue(undefined); // no ROUNDING account + const legs: LedgerLegInput[] = [ + { account: walletAsset, amount: 1, priceChf: 50000.01, amountChf: 50000.01 }, // 1-cent rest needs ROUNDING + { account: liability, amount: -50000, amountChf: -50000 }, + ]; + + await expect( + service.bookTx({ + sourceType: 'crypto_input', + sourceId: '9', + seq: 0, + bookingDate: new Date('2026-06-01'), + legs, + }), + ).rejects.toThrow(/ROUNDING/); + }); + }); +}); diff --git a/src/subdomains/core/accounting/services/__tests__/ledger-bootstrap.service.spec.ts b/src/subdomains/core/accounting/services/__tests__/ledger-bootstrap.service.spec.ts new file mode 100644 index 0000000000..b036818432 --- /dev/null +++ b/src/subdomains/core/accounting/services/__tests__/ledger-bootstrap.service.spec.ts @@ -0,0 +1,211 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AssetType } from 'src/shared/models/asset/asset.entity'; +import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { LiquidityBalance } from 'src/subdomains/core/liquidity-management/entities/liquidity-balance.entity'; +import { LiquidityManagementBalanceService } from 'src/subdomains/core/liquidity-management/services/liquidity-management-balance.service'; +import { AccountType } from '../../entities/ledger-account.entity'; +import { createCustomLedgerAccount } from '../../entities/__mocks__/ledger-account.entity.mock'; +import { LedgerAccountService } from '../ledger-account.service'; +import { LedgerBootstrapService } from '../ledger-bootstrap.service'; + +describe('LedgerBootstrapService', () => { + let service: LedgerBootstrapService; + + let ledgerAccountService: LedgerAccountService; + let assetService: AssetService; + let liquidityManagementBalanceService: LiquidityManagementBalanceService; + + let created: { name: string; type: AccountType; currency: string; assetId?: number; active?: boolean }[]; + + beforeEach(async () => { + created = []; + + ledgerAccountService = createMock(); + assetService = createMock(); + liquidityManagementBalanceService = createMock(); + + jest + .spyOn(ledgerAccountService, 'findOrCreate') + .mockImplementation(async (name, type, currency, assetId, active) => { + created.push({ name, type, currency, assetId, active }); + return createCustomLedgerAccount({ name, type, currency }); + }); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LedgerBootstrapService, + { provide: LedgerAccountService, useValue: ledgerAccountService }, + { provide: AssetService, useValue: assetService }, + { provide: LiquidityManagementBalanceService, useValue: liquidityManagementBalanceService }, + ], + }).compile(); + + service = module.get(LedgerBootstrapService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('creates ASSET accounts from custody asset rows with name=uniqueName, currency=dexName, assetId set', async () => { + const custody = createCustomAsset({ + id: 100, + uniqueName: 'Kraken/EUR', + name: 'EUR', + dexName: 'EUR', + type: AssetType.CUSTODY, + }); + jest.spyOn(assetService, 'getAssetsWith').mockResolvedValue([custody]); + jest.spyOn(liquidityManagementBalanceService, 'getBalances').mockResolvedValue([]); + + await service.bootstrap(); + + const asset = created.find((c) => c.name === 'Kraken/EUR'); + expect(asset).toMatchObject({ type: AccountType.ASSET, currency: 'EUR', assetId: 100 }); + }); + + it('falls back to asset.name when dexName is null (currency is NOT NULL, Minor R7-8)', async () => { + const custody = createCustomAsset({ + id: 101, + uniqueName: 'Sumixx/FOO', + name: 'FOO', + dexName: null, + type: AssetType.CUSTODY, + }); + jest.spyOn(assetService, 'getAssetsWith').mockResolvedValue([custody]); + jest.spyOn(liquidityManagementBalanceService, 'getBalances').mockResolvedValue([]); + + await service.bootstrap(); + + expect(created.find((c) => c.name === 'Sumixx/FOO')?.currency).toBe('FOO'); + }); + + it('includes on-chain wallet assets present in liquidity_balance and excludes CUSTOM/PRESALE', async () => { + const walletToken = createCustomAsset({ + id: 200, + uniqueName: 'Ethereum/USDT', + name: 'USDT', + dexName: 'USDT', + type: AssetType.TOKEN, + }); + const customAsset = createCustomAsset({ + id: 201, + uniqueName: 'Custom/X', + name: 'X', + dexName: 'X', + type: AssetType.CUSTOM, + }); + const presaleAsset = createCustomAsset({ + id: 202, + uniqueName: 'Presale/Y', + name: 'Y', + dexName: 'Y', + type: AssetType.PRESALE, + }); + + jest.spyOn(assetService, 'getAssetsWith').mockResolvedValue([walletToken, customAsset, presaleAsset]); + jest.spyOn(liquidityManagementBalanceService, 'getBalances').mockResolvedValue([ + Object.assign(new LiquidityBalance(), { asset: walletToken, amount: 5 }), + Object.assign(new LiquidityBalance(), { asset: customAsset, amount: 0 }), // CUSTOM excluded even with feed + ]); + + await service.bootstrap(); + + const assetNames = created.filter((c) => c.type === AccountType.ASSET).map((c) => c.name); + expect(assetNames).toContain('Ethereum/USDT'); + expect(assetNames).not.toContain('Custom/X'); + expect(assetNames).not.toContain('Presale/Y'); + }); + + it('creates the full §3.4 named CoA including INCOME venue-spread symmetry', async () => { + jest.spyOn(assetService, 'getAssetsWith').mockResolvedValue([]); + jest.spyOn(liquidityManagementBalanceService, 'getBalances').mockResolvedValue([]); + + await service.bootstrap(); + + const names = created.map((c) => c.name); + + // LIABILITY -owed/-received split, no generic LIABILITY/buyCrypto|buyFiat + expect(names).toContain('LIABILITY/buyFiat-received'); + expect(names).toContain('LIABILITY/buyFiat-owed'); + expect(names).toContain('LIABILITY/buyCrypto-received'); + expect(names).toContain('LIABILITY/buyCrypto-owed'); + expect(names).toContain('LIABILITY/paymentLink'); + expect(names).toContain('LIABILITY/manual-debt'); + expect(names).not.toContain('LIABILITY/buyCrypto'); + expect(names).not.toContain('LIABILITY/buyFiat'); + + // INCOME venue-spread accounts symmetric to EXPENSE (Major R12-2) + for (const venue of ['Binance', 'Scrypt', 'MEXC', 'XT', 'Kraken']) { + expect(names).toContain(`INCOME/spread-${venue}`); + expect(names).toContain(`EXPENSE/spread-${venue}`); + } + expect(names).toContain('INCOME/fx-revaluation'); + expect(names).toContain('EXPENSE/fx-revaluation'); // 're-', not fx-valuation (Minor R3-4) + expect(names).toContain('EXPENSE/refReward'); // camelCase (Minor R2-4) + expect(names).toContain('EXPENSE/spread-arbitrage'); + + // EQUITY + single ROUNDING + SUSPENSE + expect(names).toContain('EQUITY/opening-balance'); + expect(names).toContain('EQUITY/retained-earnings'); + expect(names.filter((n) => n === 'ROUNDING')).toHaveLength(1); + expect(names).not.toContain('INCOME/rounding'); + expect(names).not.toContain('EXPENSE/rounding'); + expect(names).toContain('SUSPENSE'); + expect(names).toContain('SUSPENSE/untracked-bank-Raiffeisen-EUR'); + }); + + it('creates direction-neutral TRANSIT accounts (↔, never →)', async () => { + jest.spyOn(assetService, 'getAssetsWith').mockResolvedValue([]); + jest.spyOn(liquidityManagementBalanceService, 'getBalances').mockResolvedValue([]); + + await service.bootstrap(); + + const transitNames = created.filter((c) => c.type === AccountType.TRANSIT).map((c) => c.name); + expect(transitNames).toContain('TRANSIT/bank↔Scrypt/EUR'); + expect(transitNames).toContain('TRANSIT/bank↔bank/CHF'); + expect(transitNames).toContain('TRANSIT/wallet↔Binance/USDT'); + expect(transitNames).toContain('TRANSIT/payout/CHF'); + expect(transitNames).toContain('TRANSIT/internal-fx/EUR'); + expect(transitNames.some((n) => n.includes('→'))).toBe(false); + + // currency is the native ticker + expect(created.find((c) => c.name === 'TRANSIT/wallet↔Binance/USDT')?.currency).toBe('USDT'); + }); + + it('is idempotent — a SECOND bootstrap() creates no new account (findOrCreate resolves existing by name)', async () => { + jest.spyOn(assetService, 'getAssetsWith').mockResolvedValue([]); + jest.spyOn(liquidityManagementBalanceService, 'getBalances').mockResolvedValue([]); + + // model the real findOrCreate semantics: lookup-by-name, create-if-missing, re-run no-op (UNIQUE(name)). A name + // already in the store returns the EXISTING account and is NOT pushed again → `created` only grows on first sight. + const store = new Map>(); + (ledgerAccountService.findOrCreate as jest.Mock).mockImplementation( + async (name: string, type: AccountType, currency: string, assetId?: number, active?: boolean) => { + const existing = store.get(name); + if (existing) return existing; // re-run no-op (the second bootstrap must hit only this branch) + created.push({ name, type, currency, assetId, active }); + const acc = createCustomLedgerAccount({ name, type, currency }); + store.set(name, acc); + return acc; + }, + ); + + // FIRST run: populates the CoA + await service.bootstrap(); + const firstRunCalls = (ledgerAccountService.findOrCreate as jest.Mock).mock.calls.length; + const firstRunCreated = created.length; + expect(firstRunCreated).toBeGreaterThan(0); + expect(new Set(created.map((c) => c.name)).size).toBe(created.length); // first run: no duplicate names + + // SECOND run: actually call bootstrap() AGAIN — every account already exists → store hit → NO new creation. + await service.bootstrap(); + const secondRunCalls = (ledgerAccountService.findOrCreate as jest.Mock).mock.calls.length - firstRunCalls; + + expect(secondRunCalls).toBe(firstRunCalls); // the re-run probes the SAME number of accounts (full deterministic CoA) + expect(created.length).toBe(firstRunCreated); // …but adds ZERO new accounts → findOrCreate idempotency proven + expect(new Set(created.map((c) => c.name)).size).toBe(created.length); // still no duplicate name was created + }); +}); diff --git a/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts b/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts new file mode 100644 index 0000000000..a0c8ac5d70 --- /dev/null +++ b/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts @@ -0,0 +1,1743 @@ +import { createMock } from '@golevelup/ts-jest'; +import { CronExpression } from '@nestjs/schedule'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ExchangeTx } from 'src/integration/exchange/entities/exchange-tx.entity'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { Process } from 'src/shared/services/process.service'; +import { DFX_CRONJOB_PARAMS, DfxCronParams } from 'src/shared/utils/cron'; +import { BuyCrypto } from 'src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity'; +import { LiquidityManagementOrder } from 'src/subdomains/core/liquidity-management/entities/liquidity-management-order.entity'; +import { TradingOrder } from 'src/subdomains/core/trading/entities/trading-order.entity'; +import { LiquidityOrder } from 'src/subdomains/supporting/dex/entities/liquidity-order.entity'; +import { BuyFiat } from 'src/subdomains/core/sell-crypto/process/buy-fiat.entity'; +import { Log } from 'src/subdomains/supporting/log/log.entity'; +import { LogService } from 'src/subdomains/supporting/log/log.service'; +import { Bank } from 'src/subdomains/supporting/bank/bank/bank.entity'; +import { BankTx } from 'src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity'; +import { BankTxRepeat } from 'src/subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.entity'; +import { BankTxReturn } from 'src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.entity'; +import { CryptoInput, PayInStatus } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; +import { PayoutOrder } from 'src/subdomains/supporting/payout/entities/payout-order.entity'; +import { Repository } from 'typeorm'; +import { AccountType, LedgerAccount } from '../../entities/ledger-account.entity'; +import { createCustomLedgerAccount } from '../../entities/__mocks__/ledger-account.entity.mock'; +import { LedgerAccountService } from '../ledger-account.service'; +import { LedgerBookingService, LedgerTxInput } from '../ledger-booking.service'; +import { LedgerBootstrapService } from '../ledger-bootstrap.service'; +import { LedgerCutoverService } from '../ledger-cutover.service'; +import { LedgerMarkCache, LedgerMarkService } from '../ledger-mark.service'; + +// synthetic snapshot — structurally equal, NO real customer/account data (public repo) +function snapshotLog(assets: Record): Log { + return Object.assign(new Log(), { + id: 1557344, + created: new Date('2026-06-07T22:00:00Z'), + valid: true, + message: JSON.stringify({ assets, tradings: {}, balancesByFinancialType: {}, balancesTotal: {} }), + }); +} + +function buyFiat(values: Partial): BuyFiat { + return Object.assign(new BuyFiat(), { + id: 1, + created: new Date('2026-06-01T00:00:00Z'), + isComplete: false, + ...values, + }); +} + +function buyCrypto(values: Partial): BuyCrypto { + return Object.assign(new BuyCrypto(), { + id: 1, + created: new Date('2026-06-01T00:00:00Z'), + isComplete: false, + ...values, + }); +} + +describe('LedgerCutoverService', () => { + let service: LedgerCutoverService; + + let settingService: SettingService; + let logService: LogService; + let bootstrapService: LedgerBootstrapService; + let bookingService: LedgerBookingService; + let accountService: LedgerAccountService; + let markService: LedgerMarkService; + let buyFiatRepo: Repository; + let buyCryptoRepo: Repository; + let bankTxRepo: Repository; + let bankRepo: Repository; + let bankTxReturnRepo: Repository; + let bankTxRepeatRepo: Repository; + let cryptoInputRepo: Repository; + let exchangeTxRepo: Repository; + let payoutOrderRepo: Repository; + let liquidityManagementOrderRepo: Repository; + let tradingOrderRepo: Repository; + let liquidityOrderRepo: Repository; + + let booked: LedgerTxInput[]; + let nextSeqByKey: Map; + + const equity = createCustomLedgerAccount({ id: 99, name: 'EQUITY/opening-balance', type: AccountType.EQUITY }); + + function assetAccount(assetId: number): LedgerAccount { + return createCustomLedgerAccount({ + id: 1000 + assetId, + name: `Asset/${assetId}`, + type: AccountType.ASSET, + assetId, + }); + } + + // isCoveredByCutoverOpening reads the crypto_input boundary via settingService.getObj. Stub it per test so the per-row + // received guard keys on the SAME immutable at-snapshot boundary the forward crypto_input seq0 suppression uses (§6.3) + // — NOT the mutable live status. Other getObj keys (e.g. balanceLogDebtPositions) keep the [] default. + function stubCryptoInputBoundary(boundary: { boundaryId: number; holeIds: number[] } | undefined): void { + jest + .spyOn(settingService, 'getObj') + .mockImplementation((key: string) => + Promise.resolve(key === 'ledgerCutoverBoundary.crypto_input' ? (boundary as any) : ([] as any)), + ); + } + + beforeEach(async () => { + booked = []; + nextSeqByKey = new Map(); + + settingService = createMock(); + logService = createMock(); + bootstrapService = createMock(); + bookingService = createMock(); + accountService = createMock(); + markService = createMock(); + buyFiatRepo = createMock>(); + buyCryptoRepo = createMock>(); + bankTxRepo = createMock>(); + bankRepo = createMock>(); + bankTxReturnRepo = createMock>(); + bankTxRepeatRepo = createMock>(); + cryptoInputRepo = createMock>(); + exchangeTxRepo = createMock>(); + payoutOrderRepo = createMock>(); + liquidityManagementOrderRepo = createMock>(); + tradingOrderRepo = createMock>(); + liquidityOrderRepo = createMock>(); + + jest.spyOn(bookingService, 'bookTx').mockImplementation((input: LedgerTxInput) => { + booked.push(input); + return Promise.resolve({} as any); + }); + jest.spyOn(bookingService, 'nextSeq').mockImplementation((sourceType: string, sourceId: string) => { + return Promise.resolve(nextSeqByKey.get(`${sourceType}:${sourceId}`) ?? 0); + }); + + jest.spyOn(accountService, 'findOrCreate').mockImplementation((name: string, type: AccountType) => { + if (name === 'EQUITY/opening-balance') return Promise.resolve(equity); + return Promise.resolve(createCustomLedgerAccount({ name, type })); + }); + jest.spyOn(accountService, 'findByAssetId').mockImplementation((id: number) => Promise.resolve(assetAccount(id))); + + // default: no open rows / no manual debt / empty mark cache + jest.spyOn(buyFiatRepo, 'find').mockResolvedValue([]); + jest.spyOn(buyCryptoRepo, 'find').mockResolvedValue([]); + jest.spyOn(bankTxRepo, 'find').mockResolvedValue([]); + jest.spyOn(bankTxReturnRepo, 'find').mockResolvedValue([]); + jest.spyOn(bankTxRepeatRepo, 'find').mockResolvedValue([]); + // m7: openBankTxReturn/Repeat/Unattributed value each bank leg from a single bankByIban() map (bankRepo.find), + // NOT a per-row bankRepo.findOne — default to no banks (tests that need a match override with a keyed iban) + jest.spyOn(bankRepo, 'find').mockResolvedValue([]); + jest.spyOn(settingService, 'getObj').mockResolvedValue([] as any); + jest.spyOn(markService, 'preload').mockResolvedValue(new LedgerMarkCache(new Map())); + + // watermark MAX(id) query builder stub (chainable where/andWhere for the per-consumer settled filters). getRawMany + // backs the §6.3 openHoleIds/idsUpToBoundary path; with MAX(id)=0 the boundary is empty so no holes are queried. + const maxQb: any = { + select: () => maxQb, + where: () => maxQb, + andWhere: () => maxQb, + getRawOne: () => Promise.resolve({ max: 0 }), + getRawMany: () => Promise.resolve([]), + }; + for (const repo of [ + bankTxRepo, + cryptoInputRepo, + exchangeTxRepo, + payoutOrderRepo, + buyCryptoRepo, + buyFiatRepo, + liquidityManagementOrderRepo, + tradingOrderRepo, + liquidityOrderRepo, + ]) { + jest.spyOn(repo, 'createQueryBuilder').mockReturnValue(maxQb); + } + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LedgerCutoverService, + { provide: SettingService, useValue: settingService }, + { provide: LogService, useValue: logService }, + { provide: LedgerBootstrapService, useValue: bootstrapService }, + { provide: LedgerBookingService, useValue: bookingService }, + { provide: LedgerAccountService, useValue: accountService }, + { provide: LedgerMarkService, useValue: markService }, + { provide: getRepositoryToken(BuyFiat), useValue: buyFiatRepo }, + { provide: getRepositoryToken(BuyCrypto), useValue: buyCryptoRepo }, + { provide: getRepositoryToken(BankTx), useValue: bankTxRepo }, + { provide: getRepositoryToken(Bank), useValue: bankRepo }, + { provide: getRepositoryToken(BankTxReturn), useValue: bankTxReturnRepo }, + { provide: getRepositoryToken(BankTxRepeat), useValue: bankTxRepeatRepo }, + { provide: getRepositoryToken(CryptoInput), useValue: cryptoInputRepo }, + { provide: getRepositoryToken(ExchangeTx), useValue: exchangeTxRepo }, + { provide: getRepositoryToken(PayoutOrder), useValue: payoutOrderRepo }, + { provide: getRepositoryToken(LiquidityManagementOrder), useValue: liquidityManagementOrderRepo }, + { provide: getRepositoryToken(TradingOrder), useValue: tradingOrderRepo }, + { provide: getRepositoryToken(LiquidityOrder), useValue: liquidityOrderRepo }, + ], + }).compile(); + + service = module.get(LedgerCutoverService); + }); + + it('is defined', () => { + expect(service).toBeDefined(); + }); + + it('runs as @DfxCron (NOT onModuleInit, Major R2-6) with its own LEDGER_CUTOVER kill-switch', () => { + const params: DfxCronParams = Reflect.getMetadata(DFX_CRONJOB_PARAMS, LedgerCutoverService.prototype.run); + expect(params.expression).toBe(CronExpression.EVERY_5_MINUTES); + expect(params.process).toBe(Process.LEDGER_CUTOVER); + }); + + describe('idempotency (Setting primary guard, §6.3)', () => { + it('is a no-op when ledgerCutoverLogId is already set (double-run = no-op)', async () => { + jest.spyOn(settingService, 'get').mockResolvedValue('1557344'); + + await service.run(); + + expect(bootstrapService.bootstrap).not.toHaveBeenCalled(); + expect(bookingService.bookTx).not.toHaveBeenCalled(); + expect(settingService.set).not.toHaveBeenCalled(); + }); + + it('runs the full sequence when no cutover has happened yet', async () => { + jest.spyOn(settingService, 'get').mockResolvedValue(undefined); + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + + await service.run(); + + expect(bootstrapService.bootstrap).toHaveBeenCalledTimes(1); + }); + + // Major B2: openAssets keys each ASSET opening on its OWN sourceId `:asset:` at seq 0 (per-asset + // marker), NOT a running seq over one `` sourceId. So a retry after a partial crash re-books EXACTLY the + // assets whose per-asset opening is missing — with no running-seq drift when a previously-skipped asset gets a CoA + // account in between. Here asset 100 was already booked on the crashed first run (its per-asset nextSeq is 1) and + // asset 200 was not; the retry must book ONLY 200, at seq 0, and re-book nothing. + it('re-books exactly the missing per-asset openings on a retry after a partial crash (no seq drift, B2)', async () => { + jest.spyOn(settingService, 'get').mockResolvedValue(undefined); + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([ + snapshotLog({ + '100': { + priceChf: 2, + plusBalance: { total: 0, liquidity: { total: 0, liquidityBalance: { total: 10 } } }, + minusBalance: { total: 0 }, + error: '', + }, + '200': { + priceChf: 3, + plusBalance: { total: 0, liquidity: { total: 0, liquidityBalance: { total: 5 } } }, + minusBalance: { total: 0 }, + error: '', + }, + }), + ]); + // asset 100's per-asset opening was already booked on the crashed first run → alreadyBooked(seq 0) true → skipped + nextSeqByKey.set('cutover:1557344:asset:100', 1); + + await service.run(); + + // asset 100 NOT re-booked (idempotent per-asset skip); asset 200 booked exactly once, at seq 0, its own sourceId + expect(booked.some((b) => b.sourceId === '1557344:asset:100')).toBe(false); + const tx200 = booked.find((b) => b.sourceId === '1557344:asset:200'); + expect(tx200).toBeDefined(); + expect(tx200.seq).toBe(0); + const assetLeg = tx200.legs.find((l) => l.account.type === AccountType.ASSET); + expect(assetLeg.amount).toBe(5); // native opening booked exactly once (no drift) + expect(assetLeg.amountChf).toBe(15); // 5 × priceChf 3 + }); + }); + + describe('failure-isolation (§6.3)', () => { + // run() no longer catches — @DfxCron's lock layer catches + logs (CONTRIBUTING: no redundant try/catch in a + // @DfxCron method). The failure-isolation semantic holds: the throw propagates before the flag is set, so the flag + // stays unset and all consumers stay no-op. + it('propagates a cutover error and never sets the flag (consumers stay no-op)', async () => { + jest.spyOn(settingService, 'get').mockResolvedValue(undefined); + jest.spyOn(logService, 'getFinancialLogs').mockRejectedValue(new Error('boom')); + + await expect(service.run()).rejects.toThrow('boom'); + + const flagSet = (settingService.set as jest.Mock).mock.calls.find((c) => c[0] === 'ledgerCutoverLogId'); + expect(flagSet).toBeUndefined(); + }); + }); + + describe('opening-balance construction (§6.1)', () => { + beforeEach(() => { + jest.spyOn(settingService, 'get').mockResolvedValue(undefined); + }); + + it('opens ASSET from liquidityBalance + paymentDeposit + manualLiq + custom (never plusBalance.total)', async () => { + const snapshot = snapshotLog({ + '100': { + priceChf: 2, + plusBalance: { + total: 9999, // must be ignored (pending phantoms) + liquidity: { total: 0, liquidityBalance: { total: 10 }, paymentDepositBalance: 3, manualLiqPosition: 1 }, + custom: { total: 1 }, + }, + minusBalance: { total: 0 }, + error: '', + }, + }); + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshot]); + + await service.run(); + + const assetTx = booked.find((b) => b.legs.some((l) => l.account.type === AccountType.ASSET)); + expect(assetTx).toBeDefined(); + const assetLeg = assetTx.legs.find((l) => l.account.type === AccountType.ASSET); + expect(assetLeg.amount).toBe(15); // 10 + 3 + 1 + 1, NOT 9999 + expect(assetLeg.amountChf).toBe(30); // 15 × priceChf 2 + // 2-leg: EQUITY counter = −ASSET CHF → Σ CHF 0 (§6.2) + const equityLeg = assetTx.legs.find((l) => l.account.type === AccountType.EQUITY); + expect(equityLeg.amountChf).toBe(-30); + }); + + it('treats a 1.0 placeholder feed as opening 0 (no ASSET leg)', async () => { + const snapshot = snapshotLog({ + '100': { + priceChf: 2, + plusBalance: { total: 5, liquidity: { total: 1, liquidityBalance: { total: 1.0 } } }, + minusBalance: { total: 0 }, + error: '', + }, + }); + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshot]); + + await service.run(); + + expect(booked.some((b) => b.legs.some((l) => l.account.type === AccountType.ASSET))).toBe(false); + }); + + it('opens buyFiat-received per row CHF=amountInChf with the synthetic seq0 marker (R4-2)', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest.spyOn(buyFiatRepo, 'find').mockImplementation(({ where }: any) => { + // received query: outputAmount IS NULL + if (where?.outputAmount) return Promise.resolve([buyFiat({ id: 42, amountInChf: 15000, outputAmount: null })]); + return Promise.resolve([]); + }); + + await service.run(); + + const receivedTx = booked.find((b) => b.sourceId === '1557344:buy_fiat:42'); + expect(receivedTx).toBeDefined(); + expect(receivedTx.seq).toBe(0); + const liabilityLeg = receivedTx.legs.find((l) => l.account.type === AccountType.LIABILITY); + expect(liabilityLeg.account.name).toBe('LIABILITY/buyFiat-received'); + expect(liabilityLeg.amountChf).toBe(-15000); // Cr LIABILITY (CHF-denominated, amountInChf) + }); + + // --- G-a exclusivity (Major): the per-row received opening vs the forward crypto_input seq0 --- // + // A priced open row whose funding crypto_input was NOT settled at the snapshot (real window: amountInChf is set at + // AML on isConfirmed, before the input reaches FORWARD_CONFIRMED) must get NO per-row received opening — the forward + // crypto_input seq0 is the SOLE received opener. Opening it here too would double-credit ${bucket}-received to + // −2·amountInChf (the cutover / crypto_input sourceId namespaces are disjoint → no UNIQUE backstop), leaving a + // permanent −amountInChf phantom after the completion debits once. A settled input keeps the per-row opening (its + // seq0 is suppressed as covered-by-cutover). Card-/bank-funded rows (cryptoInput=null) are unchanged. + describe('G-a exclusivity: per-row received opening vs the forward crypto_input seq0 (Major)', () => { + it('skips the buyFiat-received opening for a crypto-funded row whose input was NOT settled at the snapshot', async () => { + stubCryptoInputBoundary({ boundaryId: 0, holeIds: [] }); // input 800 > boundary 0 → post-boundary → not covered + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest.spyOn(buyFiatRepo, 'find').mockImplementation(({ where }: any) => { + if (where?.outputAmount) + return Promise.resolve([ + buyFiat({ + id: 80, + amountInChf: 15000, + outputAmount: null, + cryptoInput: { id: 800, status: PayInStatus.ACKNOWLEDGED } as any, // unsettled at the snapshot + }), + ]); + return Promise.resolve([]); + }); + + await service.run(); + + // no per-row opening → the forward crypto_input seq0 opens buyFiat-received exactly once (no double-credit) + expect(booked.some((b) => b.sourceId === '1557344:buy_fiat:80')).toBe(false); + }); + + it('opens the buyFiat-received per row when the funding crypto_input WAS settled at the snapshot', async () => { + stubCryptoInputBoundary({ boundaryId: 810, holeIds: [] }); // 810 <= 810, not a hole → covered + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest.spyOn(buyFiatRepo, 'find').mockImplementation(({ where }: any) => { + if (where?.outputAmount) + return Promise.resolve([ + buyFiat({ + id: 81, + amountInChf: 15000, + outputAmount: null, + cryptoInput: { id: 810, status: PayInStatus.FORWARD_CONFIRMED } as any, // settled → value in the aggregate + }), + ]); + return Promise.resolve([]); + }); + + await service.run(); + + // settled input → its seq0 is suppressed as covered-by-cutover, so the per-row opening is the sole opener + const receivedTx = booked.find((b) => b.sourceId === '1557344:buy_fiat:81'); + expect(receivedTx).toBeDefined(); + const liabilityLeg = receivedTx.legs.find((l) => l.account.type === AccountType.LIABILITY); + expect(liabilityLeg.account.name).toBe('LIABILITY/buyFiat-received'); + expect(liabilityLeg.amountChf).toBe(-15000); + }); + + it('skips the buyFiat paymentLink opening too when the funding crypto_input was NOT settled at the snapshot', async () => { + // the guard sits before openBuyFiatRow, so it covers the paymentLink branch as well: the forward crypto_input + // seq0 (ci.isPayment) opens LIABILITY/paymentLink for an unsettled input, so a per-row paymentLink opening here + // would double-credit it exactly like the received bucket. + stubCryptoInputBoundary({ boundaryId: 0, holeIds: [] }); // 820 > 0 → not covered → both paymentLink and received skipped + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest.spyOn(buyFiatRepo, 'find').mockImplementation(({ where }: any) => { + if (where?.outputAmount) + return Promise.resolve([ + buyFiat({ + id: 82, + amountInChf: 1000, + outputAmount: null, + cryptoInput: { id: 820, paymentLinkPayment: { id: 1 }, status: PayInStatus.ACKNOWLEDGED } as any, // unsettled + }), + ]); + return Promise.resolve([]); + }); + + await service.run(); + + expect(booked.some((b) => b.sourceId === '1557344:buy_fiat-paymentLink:82')).toBe(false); + expect(booked.some((b) => b.sourceId === '1557344:buy_fiat:82')).toBe(false); + }); + + it('skips the buyCrypto-received opening for a crypto-funded row whose input was NOT settled at the snapshot', async () => { + stubCryptoInputBoundary({ boundaryId: 0, holeIds: [] }); // input 900 > boundary 0 → post-boundary → not covered + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest.spyOn(buyCryptoRepo, 'find').mockImplementation(({ where }: any) => { + if (where?.outputAmount) + return Promise.resolve([ + buyCrypto({ + id: 90, + amountInChf: 1000, + outputAmount: null, + cryptoInput: { id: 900, status: PayInStatus.ACKNOWLEDGED } as any, // unsettled at the snapshot + }), + ]); + return Promise.resolve([]); + }); + + await service.run(); + + expect(booked.some((b) => b.sourceId === '1557344:buy_crypto:90')).toBe(false); + }); + + it('opens the buyCrypto-received per row when the funding crypto_input WAS settled at the snapshot', async () => { + stubCryptoInputBoundary({ boundaryId: 910, holeIds: [] }); // 910 <= 910, not a hole → covered + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest.spyOn(buyCryptoRepo, 'find').mockImplementation(({ where }: any) => { + if (where?.outputAmount) + return Promise.resolve([ + buyCrypto({ + id: 91, + amountInChf: 1000, + outputAmount: null, + cryptoInput: { id: 910, status: PayInStatus.COMPLETED } as any, // settled → value in the aggregate + }), + ]); + return Promise.resolve([]); + }); + + await service.run(); + + const receivedTx = booked.find((b) => b.sourceId === '1557344:buy_crypto:91'); + expect(receivedTx).toBeDefined(); + const liabilityLeg = receivedTx.legs.find((l) => l.account.type === AccountType.LIABILITY); + expect(liabilityLeg.account.name).toBe('LIABILITY/buyCrypto-received'); + expect(liabilityLeg.amountChf).toBe(-1000); + }); + + it('opens the buyCrypto-received per row for a Card-funded row (cryptoInput=null, regression)', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest.spyOn(buyCryptoRepo, 'find').mockImplementation(({ where }: any) => { + if (where?.outputAmount) + return Promise.resolve([ + buyCrypto({ id: 92, amountInChf: 1000, outputAmount: null, checkoutTx: { currency: 'EUR' } as any }), + ]); + return Promise.resolve([]); + }); + + await service.run(); + + // Card row: cryptoInput=null → the guard does not fire → the per-row received opening still books (unchanged) + expect(booked.some((b) => b.sourceId === '1557344:buy_crypto:92')).toBe(true); + }); + + it('opens the buyCrypto-received per row for a bank-funded row (cryptoInput=null, regression)', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest.spyOn(buyCryptoRepo, 'find').mockImplementation(({ where }: any) => { + if (where?.outputAmount) + return Promise.resolve([ + buyCrypto({ id: 93, amountInChf: 1000, outputAmount: null, bankTx: { id: 500 } as any }), + ]); + return Promise.resolve([]); + }); + + await service.run(); + + expect(booked.some((b) => b.sourceId === '1557344:buy_crypto:93')).toBe(true); + }); + + it('skips the buyFiat-received opening for a LIVE-settled input the boundary classifies as post-boundary (divergence, no double-credit)', async () => { + // The per-row opening must be EXACTLY complementary to the forward crypto_input seq0 suppression: both key on the + // SAME pinned at-snapshot boundary, NEVER on the mutable live status. Here the input settled LIVE + // (FORWARD_CONFIRMED) AFTER the snapshot — inside a fail-loud retry window — so the boundary still classifies it + // as post-boundary (boundaryId 0). The forward seq0 opens received (live-settled + not covered); a per-row + // opening here too would permanently double-credit the bucket. The guard keys on the boundary → the per-row + // opening is SKIPPED. + stubCryptoInputBoundary({ boundaryId: 0, holeIds: [] }); // input is post-boundary (settled AFTER the snapshot) + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest.spyOn(buyFiatRepo, 'find').mockImplementation(({ where }: any) => { + if (where?.outputAmount) + return Promise.resolve([ + buyFiat({ + id: 83, + amountInChf: 15000, + outputAmount: null, + cryptoInput: { id: 830, status: PayInStatus.FORWARD_CONFIRMED } as any, // LIVE settled in the retry window + }), + ]); + return Promise.resolve([]); + }); + + await service.run(); + + // NO per-row cutover opening → the forward crypto_input seq0 is the sole received opener (no double-credit) + expect(booked.some((b) => b.sourceId === '1557344:buy_fiat:83')).toBe(false); + }); + + it('skips the buyCrypto-received opening for a LIVE-settled input the boundary classifies as post-boundary (divergence, no double-credit)', async () => { + stubCryptoInputBoundary({ boundaryId: 0, holeIds: [] }); // input is post-boundary (settled AFTER the snapshot) + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest.spyOn(buyCryptoRepo, 'find').mockImplementation(({ where }: any) => { + if (where?.outputAmount) + return Promise.resolve([ + buyCrypto({ + id: 94, + amountInChf: 1000, + outputAmount: null, + cryptoInput: { id: 940, status: PayInStatus.FORWARD_CONFIRMED } as any, // LIVE settled in the retry window + }), + ]); + return Promise.resolve([]); + }); + + await service.run(); + + expect(booked.some((b) => b.sourceId === '1557344:buy_crypto:94')).toBe(false); + }); + }); + + it('opens buyFiat-owed per row CHF = outputAmount × fiat-mark for a foreign-currency (EUR) output (R6-1)', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest + .spyOn(markService, 'preload') + .mockResolvedValue(new LedgerMarkCache(new Map([[7, [{ created: new Date('2026-06-01'), priceChf: 0.95 }]]]))); + jest.spyOn(buyFiatRepo, 'find').mockImplementation(({ where }: any) => { + // owed query: isComplete false, no outputAmount filter → return the owed row + if (!where?.outputAmount) { + return Promise.resolve([buyFiat({ id: 43, outputAmount: 1000, outputAsset: { id: 7, name: 'EUR' } as any })]); + } + return Promise.resolve([]); + }); + + await service.run(); + + const owedTx = booked.find((b) => b.sourceId === '1557344:buy_fiat-owed:43'); + expect(owedTx).toBeDefined(); + const liabilityLeg = owedTx.legs.find((l) => l.account.type === AccountType.LIABILITY); + expect(liabilityLeg.account.name).toBe('LIABILITY/buyFiat-owed'); + expect(liabilityLeg.amountChf).toBe(-950); // 1000 EUR × 0.95, NOT the raw 1000 (FX basis, R6-1) + }); + + it('opens buyFiat-owed at mark 1 for a CHF output', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest.spyOn(buyFiatRepo, 'find').mockImplementation(({ where }: any) => { + if (!where?.outputAmount) { + return Promise.resolve([ + buyFiat({ id: 44, outputAmount: 14851.5, outputAsset: { id: 1, name: 'CHF' } as any }), + ]); + } + return Promise.resolve([]); + }); + + await service.run(); + + const owedTx = booked.find((b) => b.sourceId === '1557344:buy_fiat-owed:44'); + const liabilityLeg = owedTx.legs.find((l) => l.account.type === AccountType.LIABILITY); + expect(liabilityLeg.amountChf).toBe(-14851.5); // CHF output → mark 1 + }); + + // §4.7b/§6.1 (F1): a paymentLink-funded open buy_fiat (outputAmount NULL) whose financing crypto_input settled + // PRE-cutover (status ∈ CryptoInputSettledStatus → its value is in the aggregate opening, no forward seq0) is opened + // on LIABILITY/paymentLink via the per-row marker `${logId}:buy_fiat-paymentLink:${id}` at the gross (amountInChf) — + // NOT buyFiat-received (which the forward bookPaymentLink path never consumes → permanent content-scan wedge + wrong + // bucket). The G-a exclusivity guard keeps this opening for a settled input; an UNSETTLED paymentLink input is + // skipped instead (its forward seq0 opens paymentLink — covered by the dedicated G-a exclusivity tests below). + it('opens a paymentLink-funded buyFiat-received row on LIABILITY/paymentLink, not buyFiat-received (F1)', async () => { + stubCryptoInputBoundary({ boundaryId: 815, holeIds: [] }); // covered → paymentLink opening books + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest.spyOn(buyFiatRepo, 'find').mockImplementation(({ where }: any) => { + if (where?.outputAmount) { + return Promise.resolve([ + buyFiat({ + id: 45, + amountInChf: 1000, + outputAmount: null, + cryptoInput: { id: 815, paymentLinkPayment: { id: 1 }, status: PayInStatus.FORWARD_CONFIRMED } as any, // settled pre-cutover + }), + ]); + } + return Promise.resolve([]); + }); + + await service.run(); + + const plTx = booked.find((b) => b.sourceId === '1557344:buy_fiat-paymentLink:45'); + expect(plTx).toBeDefined(); + const liabilityLeg = plTx.legs.find((l) => l.account.type === AccountType.LIABILITY); + expect(liabilityLeg.account.name).toBe('LIABILITY/paymentLink'); + expect(liabilityLeg.amountChf).toBe(-1000); // Cr paymentLink at the gross (amountInChf), CHF-denominated + expect(booked.some((b) => b.sourceId === '1557344:buy_fiat:45')).toBe(false); // NOT the buyFiat-received bucket + expect(booked.some((b) => b.sourceId === '1557344:buy_fiat-owed:45')).toBe(false); + }); + + // §4.7b/§6.1 (F1): the owed path (outputAmount NOT NULL) also routes a paymentLink row to LIABILITY/paymentLink at + // the gross (amountInChf), NOT buyFiat-owed (outputAmount × mark). Exercises the openBuyFiatOwed paymentLink branch. + it('opens a paymentLink-funded owed-straddling buyFiat row on LIABILITY/paymentLink, not buyFiat-owed (F1)', async () => { + stubCryptoInputBoundary({ boundaryId: 860, holeIds: [] }); // input 860 <= boundary → covered → paymentLink opening books + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest.spyOn(buyFiatRepo, 'find').mockImplementation(({ where }: any) => { + // owed query (no outputAmount key) → a paymentLink row with outputAmount set + if (!where?.outputAmount) { + return Promise.resolve([ + buyFiat({ + id: 46, + amountInChf: 1000, + outputAmount: 940, + outputAsset: { id: 1, name: 'CHF' } as any, + cryptoInput: { id: 860, paymentLinkPayment: { id: 1 } } as any, + }), + ]); + } + return Promise.resolve([]); + }); + + await service.run(); + + const plTx = booked.find((b) => b.sourceId === '1557344:buy_fiat-paymentLink:46'); + expect(plTx).toBeDefined(); + const liabilityLeg = plTx.legs.find((l) => l.account.type === AccountType.LIABILITY); + expect(liabilityLeg.account.name).toBe('LIABILITY/paymentLink'); + expect(liabilityLeg.amountChf).toBe(-1000); // gross (amountInChf), NOT outputAmount × mark (940) + expect(booked.some((b) => b.sourceId === '1557344:buy_fiat-owed:46')).toBe(false); // NOT the owed bucket + }); + + // §6.1 (F1) G-a divergence — the openBuyFiatOwed guard. The buyFiat-owed and paymentLink per-row openings must be + // EXACTLY complementary to the forward crypto_input seq0 suppression: both key on the pinned at-snapshot boundary + // (isCoveredByCutoverOpening), NEVER on the mutable live status. Two owed-straddling rows with the SAME live-settled + // status but split by the boundary: the covered one opens (its seq0 is suppressed → the per-row opening is the sole + // opener), the post-boundary one is skipped (its forward seq0 is the sole opener → a per-row opening would + // double-credit the bucket, a permanent phantom with no UNIQUE backstop). + it('opens the buyFiat-owed per row for a covered input but skips a post-boundary one (G-a divergence, F1)', async () => { + stubCryptoInputBoundary({ boundaryId: 870, holeIds: [] }); // 870 covered; 880 > boundary → not covered + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest.spyOn(buyFiatRepo, 'find').mockImplementation(({ where }: any) => { + if (!where?.outputAmount) + return Promise.resolve([ + buyFiat({ + id: 47, + outputAmount: 1000, + outputAsset: { id: 1, name: 'CHF' } as any, + cryptoInput: { id: 870, status: PayInStatus.FORWARD_CONFIRMED } as any, // covered by the boundary + }), + buyFiat({ + id: 48, + outputAmount: 1000, + outputAsset: { id: 1, name: 'CHF' } as any, + cryptoInput: { id: 880, status: PayInStatus.FORWARD_CONFIRMED } as any, // post-boundary → not covered + }), + ]); + return Promise.resolve([]); + }); + + await service.run(); + + expect(booked.some((b) => b.sourceId === '1557344:buy_fiat-owed:47')).toBe(true); // covered → opens as before + expect(booked.some((b) => b.sourceId === '1557344:buy_fiat-owed:48')).toBe(false); // not covered → forward seq0 is sole opener + }); + + // §6.1 (F1) G-a divergence — the paymentLink branch of openBuyFiatOwed obeys the SAME boundary guard: a covered + // input opens LIABILITY/paymentLink at the gross (amountInChf); a post-boundary input is skipped (its forward seq0 + // opens paymentLink → a per-row opening would double-credit it). + it('opens the buyFiat paymentLink per row for a covered input but skips a post-boundary one (G-a divergence, F1)', async () => { + stubCryptoInputBoundary({ boundaryId: 890, holeIds: [] }); // 890 covered; 895 > boundary → not covered + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest.spyOn(buyFiatRepo, 'find').mockImplementation(({ where }: any) => { + if (!where?.outputAmount) + return Promise.resolve([ + buyFiat({ + id: 51, + amountInChf: 1000, + outputAmount: 940, + outputAsset: { id: 1, name: 'CHF' } as any, + cryptoInput: { id: 890, paymentLinkPayment: { id: 1 }, status: PayInStatus.FORWARD_CONFIRMED } as any, // covered + }), + buyFiat({ + id: 52, + amountInChf: 1000, + outputAmount: 940, + outputAsset: { id: 1, name: 'CHF' } as any, + cryptoInput: { id: 895, paymentLinkPayment: { id: 1 }, status: PayInStatus.FORWARD_CONFIRMED } as any, // not covered + }), + ]); + return Promise.resolve([]); + }); + + await service.run(); + + expect(booked.some((b) => b.sourceId === '1557344:buy_fiat-paymentLink:51')).toBe(true); // covered → opens + expect(booked.some((b) => b.sourceId === '1557344:buy_fiat-paymentLink:52')).toBe(false); // not covered → skipped + }); + + // §6.1 (F1) G-a divergence — the openBuyCryptoOwed guard. The per-row buyCrypto-owed opening must be EXACTLY + // complementary to the forward crypto_input seq0/seq1 handling: it keys on the pinned at-snapshot boundary + // (isCoveredByCutoverOpening), NEVER on the mutable live status. A COVERED input (its value is in the aggregate + // opening, its forward seq0 suppressed) keeps the per-row owed opening — the sole owed opener. + it('opens the buyCrypto-owed per row when the funding crypto_input IS covered by the boundary (G-a divergence, F1)', async () => { + stubCryptoInputBoundary({ boundaryId: 950, holeIds: [] }); // input 950 <= boundary → covered + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest + .spyOn(markService, 'preload') + .mockResolvedValue( + new LedgerMarkCache(new Map([[42, [{ created: new Date('2026-06-01'), priceChf: 30000 }]]])), + ); + jest.spyOn(buyCryptoRepo, 'find').mockImplementation(({ where }: any) => { + if (!where?.outputAmount) + return Promise.resolve([ + buyCrypto({ + id: 62, + outputAmount: 0.5, + outputAsset: { id: 42 } as any, + cryptoInput: { id: 950, status: PayInStatus.FORWARD_CONFIRMED } as any, // covered by the boundary + }), + ]); + return Promise.resolve([]); + }); + + await service.run(); + + expect(booked.some((b) => b.sourceId === '1557344:buy_crypto-owed:62')).toBe(true); // covered → opens as before + }); + + // §6.1 (F1) G-a divergence — the mirror case: an input NOT covered by the pinned boundary (a post-boundary settle, or + // an `updated` bump FORWARD_CONFIRMED→COMPLETED inside the [snapshot→pin] retry window) is SKIPPED here. Its forward + // crypto_input seq0 opens buyCrypto-received and the completion seq1 closes it; a per-row owed opening would make that + // seq1 SKIP via hasCutoverOwedOpening → the received leg would never close (orphaned received phantom, no UNIQUE + // backstop). The forward seq0/seq1 chain is the sole handler. + it('skips the buyCrypto-owed opening for a crypto-funded row whose input is NOT covered (G-a divergence, F1)', async () => { + stubCryptoInputBoundary({ boundaryId: 950, holeIds: [] }); // input 960 > boundary → not covered + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest + .spyOn(markService, 'preload') + .mockResolvedValue( + new LedgerMarkCache(new Map([[42, [{ created: new Date('2026-06-01'), priceChf: 30000 }]]])), + ); + jest.spyOn(buyCryptoRepo, 'find').mockImplementation(({ where }: any) => { + if (!where?.outputAmount) + return Promise.resolve([ + buyCrypto({ + id: 63, + outputAmount: 0.5, + outputAsset: { id: 42 } as any, + cryptoInput: { id: 960, status: PayInStatus.FORWARD_CONFIRMED } as any, // post-boundary → not covered + }), + ]); + return Promise.resolve([]); + }); + + await service.run(); + + expect(booked.some((b) => b.sourceId === '1557344:buy_crypto-owed:63')).toBe(false); // not covered → forward seq0/seq1 is sole handler + }); + + // m6 fail-loud: a feedless buyCrypto-owed opening (no mark for the outputAsset) would book a CHF liability with + // native 0 that the mark-to-market job can NEVER revalue → the cutover THROWS and leaves the ledger-ready flag + // unset (retry once the mark feed is back), rather than silently dropping the value into a stale zero-opening. + it('throws (m6) on a feedless buyCrypto-owed opening and leaves the ledger-ready flag unset', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest.spyOn(buyCryptoRepo, 'find').mockImplementation(({ where }: any) => { + if (!where?.outputAmount) { + return Promise.resolve([buyCrypto({ id: 50, outputAmount: 2, outputAsset: { id: 999 } as any })]); // no mark + } + return Promise.resolve([]); + }); + + await expect(service.run()).rejects.toThrow(/without a mark/i); + + const flagSet = (settingService.set as jest.Mock).mock.calls.find((c) => c[0] === 'ledgerCutoverLogId'); + expect(flagSet).toBeUndefined(); // ledger-ready flag NOT set → all consumers stay no-op, retry next cron run + expect(booked.some((b) => b.sourceId === '1557344:buy_crypto-owed:50')).toBe(false); // no zero-opening booked + }); + + // §6.1 (Major design-accounting): an open BANK_TX_RETURN (chargebackBankTx IS NULL) is opened per-row, CHF-valued + // = amount × bankMark, with the marker the post-cutover chargeback consumer resolves → bankTx-return closes to 0. + it('opens bankTx-return per row CHF = amount × EUR-mark with the synthetic marker (Major design-accounting)', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest + .spyOn(markService, 'preload') + .mockResolvedValue( + new LedgerMarkCache(new Map([[269, [{ created: new Date('2026-06-01'), priceChf: 0.95 }]]])), + ); + jest + .spyOn(bankRepo, 'find') + .mockResolvedValue([ + Object.assign(new Bank(), { iban: 'EUR-IBAN', name: 'Olkypay', currency: 'EUR', asset: { id: 269 } }), + ] as any); + jest.spyOn(bankTxReturnRepo, 'find').mockResolvedValue([ + Object.assign(new BankTxReturn(), { + bankTx: Object.assign(new BankTx(), { id: 70, amount: 100, accountIban: 'EUR-IBAN', currency: 'EUR' }), + }), + ] as any); + + await service.run(); + + const returnTx = booked.find((b) => b.sourceId === '1557344:bank_tx-return:70'); + expect(returnTx).toBeDefined(); + expect(returnTx.seq).toBe(0); + const liabilityLeg = returnTx.legs.find((l) => l.account.type === AccountType.LIABILITY); + expect(liabilityLeg.account.name).toBe('LIABILITY/bankTx-return'); + expect(liabilityLeg.amountChf).toBe(-95); // 100 EUR × 0.95, NOT the raw 100 (CHF-denominated §3.4) + }); + + // §6.1: an open BANK_TX_REPEAT (chargebackBankTx IS NULL) on a CHF bank → mark 1, marker bank_tx-repeat + it('opens bankTx-repeat per row at mark 1 for a CHF bank', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest + .spyOn(bankRepo, 'find') + .mockResolvedValue([ + Object.assign(new Bank(), { iban: 'CHF-IBAN', name: 'Yapeal', currency: 'CHF', asset: { id: 100 } }), + ] as any); + jest.spyOn(bankTxRepeatRepo, 'find').mockResolvedValue([ + Object.assign(new BankTxRepeat(), { + bankTx: Object.assign(new BankTx(), { id: 80, amount: 250, accountIban: 'CHF-IBAN', currency: 'CHF' }), + }), + ] as any); + + await service.run(); + + const repeatTx = booked.find((b) => b.sourceId === '1557344:bank_tx-repeat:80'); + expect(repeatTx).toBeDefined(); + const liabilityLeg = repeatTx.legs.find((l) => l.account.type === AccountType.LIABILITY); + expect(liabilityLeg.account.name).toBe('LIABILITY/bankTx-repeat'); + expect(liabilityLeg.amountChf).toBe(-250); // CHF bank → mark 1 + }); + + // §6.1: open unattributed credits (GSheet/Pending/Unknown/NULL CRDT) are opened AGGREGATED, CHF = Σ(amount × mark) + it('opens an aggregated LIABILITY/unattributed from open bank_tx credits (Major design-accounting)', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest + .spyOn(markService, 'preload') + .mockResolvedValue( + new LedgerMarkCache(new Map([[269, [{ created: new Date('2026-06-01'), priceChf: 0.95 }]]])), + ); + jest + .spyOn(bankRepo, 'find') + .mockResolvedValue([ + Object.assign(new Bank(), { iban: 'EUR-IBAN', name: 'Olkypay', currency: 'EUR', asset: { id: 269 } }), + ] as any); + // ONE find() serves both ORed where-branches (typed GSheet/Pending/Unknown credits + NULL-type credits) + jest + .spyOn(bankTxRepo, 'find') + .mockResolvedValue([ + Object.assign(new BankTx(), { id: 90, amount: 1000, accountIban: 'EUR-IBAN', currency: 'EUR' }), + Object.assign(new BankTx(), { id: 91, amount: 2000, accountIban: 'EUR-IBAN', currency: 'EUR' }), + ] as any); + + await service.run(); + + const unattributedTx = booked.find((b) => b.sourceId === '1557344:unattributed'); + expect(unattributedTx).toBeDefined(); + const liabilityLeg = unattributedTx.legs.find((l) => l.account.type === AccountType.LIABILITY); + expect(liabilityLeg.account.name).toBe('LIABILITY/unattributed'); + expect(liabilityLeg.amountChf).toBe(-2850); // (1000 + 2000) × 0.95, aggregated, CHF-denominated + }); + }); + + describe('watermark init + flag last (§6.3 Blocker R3-1)', () => { + it('initialises every consumer watermark and sets ledgerCutoverLogId LAST', async () => { + jest.spyOn(settingService, 'get').mockResolvedValue(undefined); + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + + await service.run(); + + const keys = setSpy.mock.calls.map((c) => c[0]); + expect(keys).toContain('ledgerWatermark.bank_tx'); + expect(keys).toContain('ledgerWatermark.crypto_input'); + expect(keys).toContain('ledgerWatermark.buy_fiat'); + expect(keys).toContain('ledgerWatermark.buy_crypto'); + expect(keys).toContain('ledgerWatermark.exchange_tx'); + expect(keys).toContain('ledgerWatermark.payout_order'); + // §6.3 Z.910-917 / Blocker R3-1: the three on-chain/LM/trading sources MUST be initialised too — a missing + // watermark would default to lastProcessedId:0 → full-history backfill + ASSET double-count vs openAssets + expect(keys).toContain('ledgerWatermark.liquidity_management_order'); + expect(keys).toContain('ledgerWatermark.trading_order'); + expect(keys).toContain('ledgerWatermark.liquidity_order'); + + // flag is set LAST — the atomic "ledger ready" marker (§6.3 step 5) + expect(keys[keys.length - 1]).toBe('ledgerCutoverLogId'); + expect(setSpy).toHaveBeenLastCalledWith('ledgerCutoverLogId', '1557344'); + }); + + it('writes watermarks with lastReversalScan = snapshotDate', async () => { + jest.spyOn(settingService, 'get').mockResolvedValue(undefined); + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + + await service.run(); + + const bankWm = setSpy.mock.calls.find((c) => c[0] === 'ledgerWatermark.bank_tx'); + expect(JSON.parse(bankWm[1]).lastReversalScan).toBe('2026-06-07T22:00:00.000Z'); + }); + + // §6.3: the pinned snapshot date is exported (= snapshot.created) so a forward consumer can classify a + // pre-cutover-settled row; it MUST be set before the ready flag (a consumer that sees the flag can already classify). + it('exports the snapshot date (ledgerCutoverSnapshotDate) = snapshot.created before the ready flag', async () => { + jest.spyOn(settingService, 'get').mockResolvedValue(undefined); + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + + await service.run(); + + const keys = setSpy.mock.calls.map((c) => c[0]); + const dateCall = setSpy.mock.calls.find((c) => c[0] === 'ledgerCutoverSnapshotDate'); + expect(dateCall).toBeDefined(); + expect(dateCall[1]).toBe('2026-06-07T22:00:00.000Z'); // = snapshot.created + expect(keys.indexOf('ledgerCutoverSnapshotDate')).toBeLessThan(keys.indexOf('ledgerCutoverLogId')); + }); + + // §6.3 Z.917 / Blocker R3-1: the per-consumer settled-filter MUST be applied to the watermark MAX(id) query, + // otherwise a high-id pre-cutover NON-settled row sets the watermark too high (payout_order: skips a later + // Complete-transition → phantom liability) / too low (exchange_tx: re-books a row the ASSET-opening already + // covers → double-count). This asserts the filter predicate, not just the key existence. + it('applies the settled-status filter so a non-settled high-id row does NOT set the watermark', async () => { + jest.spyOn(settingService, 'get').mockResolvedValue(undefined); + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + + // a query builder that records the andWhere status-filter and returns MAX(id) only over rows matching it: + // row {id:200, status:notSettled} (high id) + row {id:50, status:settled} → filtered MAX = 50. + const filteredQb = (settledValue: string) => { + const rows = [ + { id: 200, status: 'notSettled' }, + { id: 50, status: settledValue }, + ]; + let statusPredicate: ((s: string) => boolean) | undefined; + const qb: any = { + select: () => qb, + where: () => qb, + andWhere: (clause: string, params: Record) => { + // capture the status predicate from the consumer-specific filter (= :poStatus / = :etStatus) + const match = /e\.status\s*=\s*:(\w+)/.exec(clause); + if (match) { + const expected = params[match[1]]; + statusPredicate = (s: string) => s === expected; + } + return qb; + }, + getRawOne: () => { + const matching = rows.filter((r) => (statusPredicate ? statusPredicate(r.status) : true)); + const max = matching.length ? Math.max(...matching.map((r) => r.id)) : null; + return Promise.resolve({ max }); + }, + // §6.3 openHoleIds id query — the boundary content is asserted separately; here it only needs to resolve + getRawMany: () => Promise.resolve([]), + }; + return qb; + }; + + jest.spyOn(payoutOrderRepo, 'createQueryBuilder').mockReturnValue(filteredQb('Complete') as any); + jest.spyOn(exchangeTxRepo, 'createQueryBuilder').mockReturnValue(filteredQb('ok') as any); + + await service.run(); + + const payoutWm = setSpy.mock.calls.find((c) => c[0] === 'ledgerWatermark.payout_order'); + const exchangeWm = setSpy.mock.calls.find((c) => c[0] === 'ledgerWatermark.exchange_tx'); + expect(JSON.parse(payoutWm[1]).lastProcessedId).toBe(50); // NOT 200 → filter applied + expect(JSON.parse(exchangeWm[1]).lastProcessedId).toBe(50); // NOT 200 → filter applied + }); + + // maxSettledId fallbacks (lines 638-640): getRawOne returning `undefined` (→ `?? { max: null }`) and a row whose + // MAX(id) is NULL (no matching rows → `max ?? 0` → 0). Both must yield lastProcessedId 0 (no row to anchor on), + // never NaN/undefined in the persisted watermark. + it('writes lastProcessedId 0 when the MAX(id) query returns null or undefined (no settled rows)', async () => { + jest.spyOn(settingService, 'get').mockResolvedValue(undefined); + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + + const nullMaxQb: any = { select: () => nullMaxQb, where: () => nullMaxQb, andWhere: () => nullMaxQb }; + nullMaxQb.getRawOne = () => Promise.resolve({ max: null }); // max ?? 0 → 0 + const undefinedRawQb: any = { + select: () => undefinedRawQb, + where: () => undefinedRawQb, + andWhere: () => undefinedRawQb, + }; + undefinedRawQb.getRawOne = () => Promise.resolve(undefined); // (await …) ?? { max: null } → { max: null } → 0 + + jest.spyOn(payoutOrderRepo, 'createQueryBuilder').mockReturnValue(nullMaxQb); + jest.spyOn(exchangeTxRepo, 'createQueryBuilder').mockReturnValue(undefinedRawQb); + + await service.run(); + + const payoutWm = setSpy.mock.calls.find((c) => c[0] === 'ledgerWatermark.payout_order'); + const exchangeWm = setSpy.mock.calls.find((c) => c[0] === 'ledgerWatermark.exchange_tx'); + expect(JSON.parse(payoutWm[1]).lastProcessedId).toBe(0); // MAX null → 0 + expect(JSON.parse(exchangeWm[1]).lastProcessedId).toBe(0); // getRawOne undefined → { max: null } → 0 + }); + + // §6.3: the guard sources persist an immutable cutover boundary (ledgerCutoverBoundary.) = boundaryId + // (MAX settled id) + holeIds (ids <= boundary that were OPEN at the snapshot = allRecent MINUS settledRecent). A + // settled-at-snapshot row must NEVER appear as a hole (that would re-book it post-cutover → double-count). Here: + // MAX(settled)=100, recent ids <= 100 = [40,50,60], settled-recent = [40,60] → holeIds = [50]. Non-guard sources + // (bank_tx) persist NO boundary. + it('persists the cutover boundary (boundaryId + open-hole ids) for a guard source but not for a non-guard source', async () => { + jest.spyOn(settingService, 'get').mockResolvedValue(undefined); + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + + // a fresh qb per createQueryBuilder call so the status-filter flag never leaks across the MAX(id) and the two + // openHoleIds id queries: no status filter → all recent ids; status filter applied → only the settled recent ids. + jest.spyOn(cryptoInputRepo, 'createQueryBuilder').mockImplementation(() => { + let hasStatusFilter = false; + const qb: any = { + select: () => qb, + where: () => qb, + andWhere: (clause: string) => { + if (/e\.status/i.test(clause)) hasStatusFilter = true; + return qb; + }, + getRawOne: () => Promise.resolve({ max: 100 }), + getRawMany: () => Promise.resolve((hasStatusFilter ? [40, 60] : [40, 50, 60]).map((id) => ({ id }))), + }; + return qb; + }); + + await service.run(); + + const ciBoundary = setSpy.mock.calls.find((c) => c[0] === 'ledgerCutoverBoundary.crypto_input'); + expect(ciBoundary).toBeDefined(); + expect(JSON.parse(ciBoundary[1])).toEqual({ boundaryId: 100, holeIds: [50] }); // 50 was open → the only hole + // bank_tx is watermark-only (not a guard source) → no boundary persisted + expect(setSpy.mock.calls.some((c) => c[0] === 'ledgerCutoverBoundary.bank_tx')).toBe(false); + }); + + // §6.3 (F1): boundary+watermark are pinned set-only-if-unset BEFORE any opening, so a fail-loud opening retry + // reuses the run-1 boundary VERBATIM instead of recomputing it against a drifted DB. Here row 50 is settled at + // the snapshot in run 1 (→ holeIds []), but its `updated` is bumped between the runs, so the run-2 settled + // predicate no longer matches it (a recompute would yield holeIds [50]). Freezing the boundary prevents the + // forward consumer from reclassifying row 50 as a hole and double-booking its seq0 onto the aggregate opening + // (the F1 double-count). + it('reuses the pinned boundary+watermark verbatim on a fail-loud retry (set-only-if-unset, no recompute)', async () => { + // STATEFUL setting store: run 1 pins snapshot+boundary+watermarks, then crashes in the openings; run 2 (the + // cron retry hours later) must reuse every pinned value instead of recomputing it. + const settings = new Map(); + jest.spyOn(settingService, 'get').mockImplementation((key: string) => Promise.resolve(settings.get(key) as any)); + jest.spyOn(settingService, 'set').mockImplementation((key: string, value: string) => { + settings.set(key, value); + return Promise.resolve(); + }); + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest + .spyOn(logService, 'getLog') + .mockImplementation((id: number) => Promise.resolve(id === 1557344 ? snapshotLog({}) : (undefined as any))); + + let run = 1; + // crypto_input qb: MAX(id) 100 in BOTH runs; the openHoleIds id-queries differ — run 1 sees row 50 SETTLED + // (with-status-filter [50,60] → holeIds []), in run 2 row 50 fell OUT of the settled predicate (`updated` + // bumped between the runs → with-status-filter only [60] → a recompute would record holeIds [50]). + jest.spyOn(cryptoInputRepo, 'createQueryBuilder').mockImplementation(() => { + let hasStatusFilter = false; + const qb: any = { + select: () => qb, + where: () => qb, + andWhere: (clause: string) => { + if (/e\.status/i.test(clause)) hasStatusFilter = true; + return qb; + }, + getRawOne: () => Promise.resolve({ max: 100 }), + getRawMany: () => + Promise.resolve((hasStatusFilter ? (run === 1 ? [50, 60] : [60]) : [50, 60]).map((id) => ({ id }))), + }; + return qb; + }); + + // run 1 throws AFTER the pin step: an owed buy_crypto row whose outputAsset has no mark (empty LedgerMarkCache + // from the default beforeEach) → bookReceivedOwedOpening throws → openings abort, ready flag stays unset. The + // owed query is the only buyCryptoRepo.find with an outputAsset relation (openBuyCryptoOwed). + jest.spyOn(buyCryptoRepo, 'find').mockImplementation(({ where, relations }: any) => { + if (run === 1 && where?.isComplete === false && relations?.outputAsset) { + return Promise.resolve([buyCrypto({ id: 60, outputAmount: 2, outputAsset: { id: 999 } as any })]); + } + return Promise.resolve([]); // run 2: the row resolved in the meantime → no open rows left + }); + + await expect(service.run()).rejects.toThrow(/without a mark/i); + expect(settings.get('ledgerCutoverLogId')).toBeUndefined(); // fail-loud → flag unset → the cron retries + + run = 2; + await service.run(); // retry against the drifted DB → completes WITHOUT recomputing boundary/watermark + + // run-1 boundary reused verbatim — NOT overwritten to holeIds [50] from the drifted run-2 predicate + expect(JSON.parse(settings.get('ledgerCutoverBoundary.crypto_input'))).toEqual({ boundaryId: 100, holeIds: [] }); + const setCalls = (settingService.set as jest.Mock).mock.calls; + expect(setCalls.filter((c) => c[0] === 'ledgerCutoverBoundary.crypto_input')).toHaveLength(1); // pinned ONCE + const wmCalls = setCalls.filter((c) => c[0] === 'ledgerWatermark.crypto_input'); + expect(wmCalls).toHaveLength(1); // the watermark is pinned exactly once too (run 2 reuses it) + expect(JSON.parse(wmCalls[0][1]).lastProcessedId).toBe(100); // == boundaryId (derived from the pinned boundary) + expect(settings.get('ledgerCutoverLogId')).toBe('1557344'); // run 2 completed on the pinned snapshot + }); + + // §6.3 (F1): mid-loop resume — a crash BETWEEN the boundary write and the watermark write (boundary pinned, + // watermark unset) must re-derive the watermark from the PINNED boundaryId on the retry, never from a fresh + // MAX(id) recompute (the drifted DB would yield a different id → lastProcessedId ≠ boundaryId → the guard's + // covered/hole classification and the forward scan would disagree about the same rows). + it('derives the watermark from an already-pinned boundary without recomputing it (mid-loop resume)', async () => { + const settings = new Map(); + // pre-seeded state: the previous run pinned the crypto_input boundary, then crashed before its watermark write + settings.set('ledgerCutoverBoundary.crypto_input', JSON.stringify({ boundaryId: 100, holeIds: [] })); + jest.spyOn(settingService, 'get').mockImplementation((key: string) => Promise.resolve(settings.get(key) as any)); + jest.spyOn(settingService, 'set').mockImplementation((key: string, value: string) => { + settings.set(key, value); + return Promise.resolve(); + }); + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest + .spyOn(logService, 'getLog') + .mockImplementation((id: number) => Promise.resolve(id === 1557344 ? snapshotLog({}) : (undefined as any))); + + // drifted DB on the retry: a fresh MAX(id) would now yield 777 — the pinned boundary (100) must win + jest.spyOn(cryptoInputRepo, 'createQueryBuilder').mockImplementation(() => { + const qb: any = { + select: () => qb, + where: () => qb, + andWhere: () => qb, + getRawOne: () => Promise.resolve({ max: 777 }), + getRawMany: () => Promise.resolve([{ id: 777 }]), + }; + return qb; + }); + + await service.run(); + + // the boundary was NOT recomputed/overwritten (no write at all — it was already pinned) + const setCalls = (settingService.set as jest.Mock).mock.calls; + expect(setCalls.filter((c) => c[0] === 'ledgerCutoverBoundary.crypto_input')).toHaveLength(0); + expect(JSON.parse(settings.get('ledgerCutoverBoundary.crypto_input'))).toEqual({ boundaryId: 100, holeIds: [] }); + // the watermark derives from the PINNED boundaryId (100), not from the drifted MAX(id) 777 + expect(JSON.parse(settings.get('ledgerWatermark.crypto_input')).lastProcessedId).toBe(100); + expect(settings.get('ledgerCutoverLogId')).toBe('1557344'); // the resume completes the cutover + }); + }); + + // --- SNAPSHOT SELECTION + PINNING (§6.3 / Major design-accounting R3-1) --- // + + describe('snapshot selection + pinning (R3-1)', () => { + it('throws when there is no valid FinancialDataLog snapshot ≤ cutoff date (flag stays unset)', async () => { + jest.spyOn(settingService, 'get').mockResolvedValue(undefined); + // selectSnapshot reads getFinancialLogs and keeps only rows created ≤ now; a single FUTURE-dated row is filtered + // out → no snapshot → cutover throws → the throw propagates to @DfxCron → flag stays unset. + const future = Object.assign(new Log(), { id: 1, created: new Date(Date.now() + 86400000), valid: true }); + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([future]); + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + + await expect(service.run()).rejects.toThrow('No valid FinancialDataLog snapshot available for cutover'); + + expect(bookingService.bookTx).not.toHaveBeenCalled(); + expect(setSpy.mock.calls.find((c) => c[0] === 'ledgerCutoverLogId')).toBeUndefined(); + }); + + it('reuses the PINNED snapshot logId on a re-run (stable sourceIds → idempotent re-book)', async () => { + // a previous partial run pinned snapshot logId 999 → the re-run must reuse exactly logId 999, NOT re-select the + // newest log (which would drift the per-row opening sourceIds and double-count, R3-1). + jest.spyOn(settingService, 'get').mockImplementation((key: string) => { + if (key === 'ledgerCutoverSnapshotLogId') return Promise.resolve('999'); + return Promise.resolve(undefined); // ledgerCutoverLogId unset → cutover proceeds + }); + const pinned = Object.assign(new Log(), { + id: 999, + created: new Date('2026-06-07T22:00:00Z'), + valid: true, + message: JSON.stringify({ assets: {}, tradings: {}, balancesByFinancialType: {}, balancesTotal: {} }), + }); + const getLogSpy = jest.spyOn(logService, 'getLog').mockResolvedValue(pinned); + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + + await service.run(); + + expect(getLogSpy).toHaveBeenCalledWith(999); // reused the pinned id, NOT a fresh selection + // the flag is set to the pinned logId (auditable: value = the reused snapshot id) + expect(setSpy).toHaveBeenLastCalledWith('ledgerCutoverLogId', '999'); + }); + + it('throws when the pinned snapshot log no longer exists (flag stays unset)', async () => { + jest.spyOn(settingService, 'get').mockImplementation((key: string) => { + if (key === 'ledgerCutoverSnapshotLogId') return Promise.resolve('999'); + return Promise.resolve(undefined); + }); + jest.spyOn(logService, 'getLog').mockResolvedValue(null); // pinned log was deleted + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + + await expect(service.run()).rejects.toThrow('Pinned cutover snapshot FinancialDataLog #999 no longer exists'); + + expect(setSpy.mock.calls.find((c) => c[0] === 'ledgerCutoverLogId')).toBeUndefined(); // flag never set + }); + + it('pins the freshly-selected snapshot before booking any opening (set-only-if-unset)', async () => { + // no pin yet → selectSnapshot picks the newest valid log; pinnedSnapshot writes the pin BEFORE any opening tx. + const snapshot = snapshotLog({}); + jest.spyOn(settingService, 'get').mockResolvedValue(undefined); // both keys unset + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshot]); + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + + await service.run(); + + const pinCall = setSpy.mock.calls.find((c) => c[0] === 'ledgerCutoverSnapshotLogId'); + expect(pinCall).toBeDefined(); + expect(pinCall[1]).toBe('1557344'); // the selected snapshot id is pinned + }); + + it('honours a concurrent pin: a different logId won under the set-only-if-unset re-read → uses that one', async () => { + // selectSnapshot picks snapshot 1557344, but between the set and the re-read a concurrent runner pinned 888. + // The re-read returns 888 (≠ the selected id) → pinnedSnapshot resolves logId 888 via getLog and uses it. + const concurrentLog = Object.assign(new Log(), { + id: 888, + created: new Date('2026-06-07T21:00:00Z'), + valid: true, + message: JSON.stringify({ assets: {}, tradings: {}, balancesByFinancialType: {}, balancesTotal: {} }), + }); + let pinReads = 0; + jest.spyOn(settingService, 'get').mockImplementation((key: string) => { + if (key === 'ledgerCutoverLogId') return Promise.resolve(undefined); + if (key === 'ledgerCutoverSnapshotLogId') { + pinReads++; + // first read (the set-only-if-unset guard) = unset → it sets; the re-read returns the concurrent 888 + return Promise.resolve(pinReads <= 1 ? undefined : '888'); + } + return Promise.resolve(undefined); + }); + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + const getLogSpy = jest.spyOn(logService, 'getLog').mockResolvedValue(concurrentLog); + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + + await service.run(); + + expect(getLogSpy).toHaveBeenCalledWith(888); // the concurrent pin won → its log is loaded + expect(setSpy).toHaveBeenLastCalledWith('ledgerCutoverLogId', '888'); // flag set to the concurrent winner + }); + + it('throws when the snapshot message is not parseable JSON (flag stays unset)', async () => { + jest.spyOn(settingService, 'get').mockResolvedValue(undefined); + const broken = Object.assign(new Log(), { + id: 1557344, + created: new Date('2026-06-07T22:00:00Z'), + valid: true, + message: '{ not valid json', + }); + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([broken]); + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + + await expect(service.run()).rejects.toThrow('message is not parseable'); + + expect(bookingService.bookTx).not.toHaveBeenCalled(); + expect(setSpy.mock.calls.find((c) => c[0] === 'ledgerCutoverLogId')).toBeUndefined(); + }); + }); + + // --- ADDITIONAL OPENING BRANCHES (§6.1) --- // + + describe('further opening branches (§6.1)', () => { + beforeEach(() => { + jest.spyOn(settingService, 'get').mockResolvedValue(undefined); + }); + + it('skips an ASSET whose asset is not in the CoA (findByAssetId → undefined)', async () => { + const snapshot = snapshotLog({ + '100': { + priceChf: 2, + plusBalance: { total: 5, liquidity: { total: 5, liquidityBalance: { total: 5 } } }, + minusBalance: { total: 0 }, + error: '', + }, + }); + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshot]); + jest.spyOn(accountService, 'findByAssetId').mockResolvedValue(undefined); // asset not in the CoA + + await service.run(); + + expect(booked.some((b) => b.legs.some((l) => l.account.type === AccountType.ASSET))).toBe(false); + }); + + it('opens an ASSET with needsMark when priceChf is non-finite (feedless → mark-to-market values later)', async () => { + const snapshot = snapshotLog({ + '100': { + priceChf: NaN, // non-finite → no CHF valuation now → needsMark + plusBalance: { total: 5, liquidity: { total: 5, liquidityBalance: { total: 5 } } }, + minusBalance: { total: 0 }, + error: '', + }, + }); + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshot]); + + await service.run(); + + const assetTx = booked.find((b) => b.legs.some((l) => l.account.type === AccountType.ASSET)); + const assetLeg = assetTx.legs.find((l) => l.account.type === AccountType.ASSET); + expect(assetLeg.amount).toBe(5); // native opening still booked + expect(assetLeg.needsMark).toBe(true); // feedless → no CHF, valued by mark-to-market + expect(assetLeg.amountChf).toBeUndefined(); + }); + + // F2: a buyFiat-received row with a NULL amountInChf has no CHF anchor → no per-row opening. Its id is PINNED as + // unpriced-at-cutover so the forward consumer skips+advances (alarm) instead of wedging on the never-opened gate. + it('pins a buyFiat-received row with a null amountInChf as unpriced-at-cutover (no per-row opening; F2)', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + jest.spyOn(buyFiatRepo, 'find').mockImplementation(({ where }: any) => { + if (where?.outputAmount) return Promise.resolve([buyFiat({ id: 42, amountInChf: null, outputAmount: null })]); + return Promise.resolve([]); + }); + + await service.run(); + + expect(booked.some((b) => b.sourceId === '1557344:buy_fiat:42')).toBe(false); // no CHF anchor → no per-row opening + const pin = setSpy.mock.calls.find((c) => c[0] === 'ledgerCutoverUnpricedIds.buy_fiat'); + expect(pin).toBeDefined(); + expect(JSON.parse(pin[1])).toEqual([42]); // F2: pinned → forward consumer skips+advances (alarm), never wedges + }); + + // F2: bank/crypto-funded AND Card open received rows with a NULL amountInChf are pinned as unpriced-at-cutover; a + // priced row is still opened normally. The pin lets the forward buildCardInputSeq0 suppress the Card seq0 (its gross + // is in the aggregate opening) and the completion scan skip+advance without a wedge. + it('opens priced buyCrypto-received rows and pins null-amountInChf rows (bank + Card) as unpriced (F2)', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + jest.spyOn(buyCryptoRepo, 'find').mockImplementation(({ where }: any) => { + // received query: outputAmount IS NULL → one priced row (60) + one unpriced bank/crypto (61) + one unpriced Card (62) + if (where?.outputAmount) { + return Promise.resolve([ + buyCrypto({ id: 60, amountInChf: 15000, outputAmount: null }), + buyCrypto({ id: 61, amountInChf: null, outputAmount: null }), + buyCrypto({ id: 62, amountInChf: null, outputAmount: null, checkoutTx: { currency: 'EUR' } as any }), + ]); + } + return Promise.resolve([]); + }); + + await service.run(); + + const receivedTx = booked.find((b) => b.sourceId === '1557344:buy_crypto:60'); + expect(receivedTx).toBeDefined(); + const liabilityLeg = receivedTx.legs.find((l) => l.account.type === AccountType.LIABILITY); + expect(liabilityLeg.account.name).toBe('LIABILITY/buyCrypto-received'); + expect(liabilityLeg.amountChf).toBe(-15000); + expect(booked.some((b) => b.sourceId === '1557344:buy_crypto:61')).toBe(false); // unpriced → no opening + expect(booked.some((b) => b.sourceId === '1557344:buy_crypto:62')).toBe(false); // unpriced Card → no opening + const pin = setSpy.mock.calls.find((c) => c[0] === 'ledgerCutoverUnpricedIds.buy_crypto'); + expect(pin).toBeDefined(); + expect(JSON.parse(pin[1])).toEqual([61, 62]); // both unpriced ids pinned (bank/crypto + Card) + }); + + // MAJOR (B1): Card inputs (checkoutTx != null) are INCLUDED in the per-row buyCrypto-received opening, symmetrically + // to bank/crypto-funded open rows — an open Card row's gross is already in the aggregate ASSET opening (the + // Checkout.com collateral feed) and this per-row opening covers the received LIABILITY side. The forward Card seq0 + // is gated to SKIP when this marker exists (buy-crypto.consumer.buildCardInputSeq0). Asserts the received query does + // NOT filter on checkoutTx (both the non-Card and the Card row are opened). + it('includes Card inputs (checkoutTx) in the per-row buyCrypto-received opening (B1)', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + let receivedWhere: any; + jest.spyOn(buyCryptoRepo, 'find').mockImplementation(({ where }: any) => { + // received query: outputAmount IS NULL. The service must NOT filter on checkoutTx → capture the where to assert + // the Card row is returned (not filtered out) and opened per-row. + if (where?.outputAmount) { + receivedWhere = where; + return Promise.resolve([ + buyCrypto({ id: 60, amountInChf: 15000, outputAmount: null }), // non-Card → opened + buyCrypto({ id: 61, amountInChf: 15000, outputAmount: null, checkoutTx: { currency: 'EUR' } as any }), // Card + ]); + } + return Promise.resolve([]); + }); + + await service.run(); + + expect(receivedWhere?.checkoutTx).toBeUndefined(); // no checkoutTx filter → Card rows are NOT excluded (B1) + expect(booked.some((b) => b.sourceId === '1557344:buy_crypto:60')).toBe(true); // non-Card opened + expect(booked.some((b) => b.sourceId === '1557344:buy_crypto:61')).toBe(true); // Card ALSO opened (B1) + }); + + it('skips a bankTx-return row whose underlying bank_tx amount is null (nothing to anchor)', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest.spyOn(bankTxReturnRepo, 'find').mockResolvedValue([ + Object.assign(new BankTxReturn(), { + bankTx: Object.assign(new BankTx(), { id: 70, amount: null, accountIban: 'CHF-IBAN', currency: 'CHF' }), + }), + ] as any); + + await service.run(); + + expect(booked.some((b) => b.sourceId === '1557344:bank_tx-return:70')).toBe(false); + }); + + // m6 fail-loud: an unattributed aggregate with a credit that cannot be valued (no mark) would book a CHF liability + // with native 0, never revalued → the cutover THROWS and leaves the flag unset (retry when the feed is back). + it('throws (m6) on an unvaluable unattributed credit and leaves the ledger-ready flag unset', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + // no mark in the cache + the bank match resolves to an EUR bank whose asset has no mark → mark == null → unvaluable + jest + .spyOn(bankRepo, 'find') + .mockResolvedValue([ + Object.assign(new Bank(), { iban: 'EUR-IBAN', name: 'Olkypay', currency: 'EUR', asset: { id: 269 } }), + ] as any); + jest + .spyOn(bankTxRepo, 'find') + .mockResolvedValueOnce([ + Object.assign(new BankTx(), { id: 90, amount: 1000, accountIban: 'EUR-IBAN', currency: 'EUR' }), + ] as any) + .mockResolvedValueOnce([]); + + await expect(service.run()).rejects.toThrow(/without a mark/i); + + const flagSet = (settingService.set as jest.Mock).mock.calls.find((c) => c[0] === 'ledgerCutoverLogId'); + expect(flagSet).toBeUndefined(); + expect(booked.some((b) => b.sourceId === '1557344:unattributed')).toBe(false); + }); + + it('skips an unattributed credit row with a null amount (no opening when nothing else valued)', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest + .spyOn(bankTxRepo, 'find') + .mockResolvedValueOnce([ + Object.assign(new BankTx(), { id: 90, amount: null, accountIban: 'CHF-IBAN', currency: 'CHF' }), + ] as any) + .mockResolvedValueOnce([]); + + await service.run(); + + expect(booked.some((b) => b.sourceId === '1557344:unattributed')).toBe(false); // amountChf 0 + no needsMark → none + }); + + it('values an unattributed credit at mark 1 via the bankTx.currency=CHF fast-path (no bank row needed)', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest.spyOn(bankRepo, 'find').mockResolvedValue([]); // no Bank row, but the tx itself is CHF → mark 1 + jest + .spyOn(bankTxRepo, 'find') + .mockResolvedValueOnce([ + Object.assign(new BankTx(), { id: 90, amount: 500, accountIban: 'CHF-IBAN', currency: 'CHF' }), + ] as any) + .mockResolvedValueOnce([]); + + await service.run(); + + const unattributedTx = booked.find((b) => b.sourceId === '1557344:unattributed'); + expect(unattributedTx).toBeDefined(); + const liabilityLeg = unattributedTx.legs.find((l) => l.account.type === AccountType.LIABILITY); + expect(liabilityLeg.amountChf).toBe(-500); // CHF → mark 1, NOT needsMark + }); + + // §6.1 openBuyFiatOwed: a row with outputAmount NULL is skipped (no owed value to anchor) — line 292 + it('skips a buyFiat-owed row whose outputAmount is null (nothing to anchor)', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest.spyOn(buyFiatRepo, 'find').mockImplementation(({ where }: any) => { + if (!where?.outputAmount) return Promise.resolve([buyFiat({ id: 50, outputAmount: null })]); + return Promise.resolve([]); + }); + + await service.run(); + + expect(booked.some((b) => b.sourceId === '1557344:buy_fiat-owed:50')).toBe(false); + }); + + // m6 fail-loud (F2): a foreign-currency (EUR) buyFiat-owed opening without a fiat-mark would silently skip an + // opening the forward path can NEVER supply — buy-fiat bookRegular skips seq1 for an owed-straddling row and + // anchors seq2/seq3 on this opening, so the row would gate-block the content-change scan forever. The cutover + // THROWS and leaves the ledger-ready flag unset (retry once the mark feed is back). + it('throws (m6) on a foreign-currency buyFiat-owed opening when its fiat-mark is missing', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest.spyOn(markService, 'preload').mockResolvedValue(new LedgerMarkCache(new Map())); // no mark for asset 7 + jest.spyOn(buyFiatRepo, 'find').mockImplementation(({ where }: any) => { + if (!where?.outputAmount) { + return Promise.resolve([buyFiat({ id: 51, outputAmount: 1000, outputAsset: { id: 7, name: 'EUR' } as any })]); + } + return Promise.resolve([]); + }); + + await expect(service.run()).rejects.toThrow(/without a mark/i); + + const flagSet = (settingService.set as jest.Mock).mock.calls.find((c) => c[0] === 'ledgerCutoverLogId'); + expect(flagSet).toBeUndefined(); // ledger-ready flag NOT set → all consumers stay no-op, retry next cron run + expect(booked.some((b) => b.sourceId === '1557344:buy_fiat-owed:51')).toBe(false); // no zero-opening booked + }); + + // fiatMark (`assetId != null` false side): a foreign-currency buyFiat-owed whose outputAsset has NO id → + // fiatMark(undefined, …) returns undefined → amountChf undefined → the same m6 fail-loud as the missing-mark case. + it('throws (m6) on a foreign-currency buyFiat-owed opening whose outputAsset has no id (fiatMark undefined)', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest.spyOn(buyFiatRepo, 'find').mockImplementation(({ where }: any) => { + if (!where?.outputAmount) { + // outputAsset.name 'EUR' (not CHF) but id undefined → fiatMark(undefined) → undefined → throw + return Promise.resolve([buyFiat({ id: 53, outputAmount: 1000, outputAsset: { name: 'EUR' } as any })]); + } + return Promise.resolve([]); + }); + + await expect(service.run()).rejects.toThrow(/without a mark/i); + + const flagSet = (settingService.set as jest.Mock).mock.calls.find((c) => c[0] === 'ledgerCutoverLogId'); + expect(flagSet).toBeUndefined(); + expect(booked.some((b) => b.sourceId === '1557344:buy_fiat-owed:53')).toBe(false); + }); + + // §6.1 openBuyCryptoOwed: a row WITH a mark for its outputAsset → CHF = outputAmount × mark (line 350 valued side, + // the complement of the feedless needsMark test). + it('opens a buyCrypto-owed at CHF = outputAmount × mark when the outputAsset is feeded', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest + .spyOn(markService, 'preload') + .mockResolvedValue( + new LedgerMarkCache(new Map([[42, [{ created: new Date('2026-06-01'), priceChf: 30000 }]]])), + ); + jest.spyOn(buyCryptoRepo, 'find').mockImplementation(({ where }: any) => { + if (!where?.outputAmount) { + return Promise.resolve([buyCrypto({ id: 54, outputAmount: 0.5, outputAsset: { id: 42 } as any })]); + } + return Promise.resolve([]); + }); + + await service.run(); + + const owedTx = booked.find((b) => b.sourceId === '1557344:buy_crypto-owed:54'); + expect(owedTx).toBeDefined(); + const liabilityLeg = owedTx.legs.find((l) => l.account.type === AccountType.LIABILITY); + expect(liabilityLeg.amountChf).toBe(-15000); // 0.5 × 30000, NOT needsMark + expect(liabilityLeg.needsMark).toBe(false); + }); + + // §6.1 openBuyCryptoOwed line 349 (`outputAsset?.id != null` FALSE side): an owed row whose outputAsset has NO id → + // mark undefined → amountChf undefined → m6 fail-loud (the CHF owed opening would never be revalued). Distinct from + // the id-present-no-mark case, but the same fail-loud outcome. + it('throws (m6) on a buyCrypto-owed opening whose outputAsset has no id (line 349 null-id side)', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest.spyOn(buyCryptoRepo, 'find').mockImplementation(({ where }: any) => { + if (!where?.outputAmount) { + return Promise.resolve([buyCrypto({ id: 55, outputAmount: 2, outputAsset: {} as any })]); // no id → no mark + } + return Promise.resolve([]); + }); + + await expect(service.run()).rejects.toThrow(/without a mark/i); + + const flagSet = (settingService.set as jest.Mock).mock.calls.find((c) => c[0] === 'ledgerCutoverLogId'); + expect(flagSet).toBeUndefined(); + expect(booked.some((b) => b.sourceId === '1557344:buy_crypto-owed:55')).toBe(false); + }); + + // §6.1 openBuyCryptoOwed: a row with outputAmount NULL is skipped — line 347 + it('skips a buyCrypto-owed row whose outputAmount is null', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest.spyOn(buyCryptoRepo, 'find').mockImplementation(({ where }: any) => { + if (!where?.outputAmount) return Promise.resolve([buyCrypto({ id: 52, outputAmount: null })]); + return Promise.resolve([]); + }); + + await service.run(); + + expect(booked.some((b) => b.sourceId === '1557344:buy_crypto-owed:52')).toBe(false); + }); + + // §6.1 openOpenLiabilityRow (bankTx-return/repeat): a row with a valid amount but NO mark → amountChf undefined → + // m6 fail-loud (a CHF return opening booked with native 0 is never revalued). EUR bank, empty mark cache. + it('throws (m6) on a bankTx-return opening when the bank mark is missing', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest.spyOn(markService, 'preload').mockResolvedValue(new LedgerMarkCache(new Map())); // no EUR mark + jest + .spyOn(bankRepo, 'find') + .mockResolvedValue([ + Object.assign(new Bank(), { iban: 'EUR-IBAN', name: 'Olkypay', currency: 'EUR', asset: { id: 269 } }), + ] as any); + jest.spyOn(bankTxReturnRepo, 'find').mockResolvedValue([ + Object.assign(new BankTxReturn(), { + bankTx: Object.assign(new BankTx(), { id: 95, amount: 400, accountIban: 'EUR-IBAN', currency: 'EUR' }), + }), + ] as any); + + await expect(service.run()).rejects.toThrow(/without a mark/i); + + const flagSet = (settingService.set as jest.Mock).mock.calls.find((c) => c[0] === 'ledgerCutoverLogId'); + expect(flagSet).toBeUndefined(); + expect(booked.some((b) => b.sourceId === '1557344:bank_tx-return:95')).toBe(false); + }); + + // §4.2/§1.6 bankMark: a bank_tx with NO accountIban (line 489 null side) and currency EUR → no bank lookup, no + // asset → mark undefined → amountChf undefined → m6 fail-loud (the CHF unattributed bucket is never revalued). + it('throws (m6) on an unattributed credit whose bank_tx has no accountIban and a non-CHF currency', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest.spyOn(markService, 'preload').mockResolvedValue(new LedgerMarkCache(new Map())); + jest + .spyOn(bankTxRepo, 'find') + .mockResolvedValueOnce([ + Object.assign(new BankTx(), { id: 96, amount: 700, accountIban: null, currency: 'EUR' }), // no iban → no bank + ] as any) + .mockResolvedValueOnce([]); + + await expect(service.run()).rejects.toThrow(/without a mark/i); + + const flagSet = (settingService.set as jest.Mock).mock.calls.find((c) => c[0] === 'ledgerCutoverLogId'); + expect(flagSet).toBeUndefined(); + expect(booked.some((b) => b.sourceId === '1557344:unattributed')).toBe(false); + }); + + // §4.2/§1.6 bankMark: a bank match whose asset is undefined (line 495 `asset?.id != null` false side) and a + // non-CHF currency → mark undefined → amountChf undefined → m6 fail-loud. + it('throws (m6) on an unattributed credit whose matched bank has no asset', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest.spyOn(markService, 'preload').mockResolvedValue(new LedgerMarkCache(new Map())); + jest + .spyOn(bankRepo, 'find') + .mockResolvedValue([ + Object.assign(new Bank(), { iban: 'EUR-IBAN', name: 'Olkypay', currency: 'EUR', asset: undefined }), + ] as any); + jest + .spyOn(bankTxRepo, 'find') + .mockResolvedValueOnce([ + Object.assign(new BankTx(), { id: 97, amount: 800, accountIban: 'EUR-IBAN', currency: 'EUR' }), + ] as any) + .mockResolvedValueOnce([]); + + await expect(service.run()).rejects.toThrow(/without a mark/i); + + const flagSet = (settingService.set as jest.Mock).mock.calls.find((c) => c[0] === 'ledgerCutoverLogId'); + expect(flagSet).toBeUndefined(); + expect(booked.some((b) => b.sourceId === '1557344:unattributed')).toBe(false); + }); + }); + + // --- MANUAL DEBT OPENING (§6.1 D15 C.f) --- // + + describe('manual-debt opening (§6.1)', () => { + beforeEach(() => { + jest.spyOn(settingService, 'get').mockResolvedValue(undefined); + }); + + it('opens a manual-debt liability per position CHF = priceChf × value (Dr EQUITY / Cr LIABILITY/manual-debt)', async () => { + const snapshot = snapshotLog({ '100': { priceChf: 2 } }); + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshot]); + jest.spyOn(settingService, 'getObj').mockResolvedValue([{ assetId: 100, value: 1000 }] as any); // one debt position + + await service.run(); + + const manualTx = booked.find((b) => b.sourceId === '1557344:manual-debt:100'); + expect(manualTx).toBeDefined(); + expect(manualTx.seq).toBe(0); // per-row opening (unique sourceId per assetId), like the received/owed markers + const liabilityLeg = manualTx.legs.find((l) => l.account.type === AccountType.LIABILITY); + expect(liabilityLeg.account.name).toBe('LIABILITY/manual-debt'); + expect(liabilityLeg.amountChf).toBe(-2000); // −(priceChf 2 × value 1000), the debt side only (Minor R6-5) + expect(liabilityLeg.amount).toBe(-2000); // CHF-denominated liability → native amount = CHF (priceChf 1) + expect(liabilityLeg.priceChf).toBe(1); + const equityLeg = manualTx.legs.find((l) => l.account.type === AccountType.EQUITY); + expect(equityLeg.amountChf).toBe(2000); // Dr EQUITY/opening-balance counter + }); + + it('skips a manual-debt position with no value (no leg)', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({ '100': { priceChf: 2 } })]); + jest.spyOn(settingService, 'getObj').mockResolvedValue([ + { assetId: 100, value: 0 }, // no value → skipped, books nothing + { assetId: 100, value: 500 }, // bookable + ] as any); + + await service.run(); + + const manualTxs = booked.filter((b) => b.sourceId === '1557344:manual-debt:100'); + expect(manualTxs).toHaveLength(1); // only the valued position books + expect(manualTxs[0].seq).toBe(0); + expect(manualTxs[0].legs.find((l) => l.account.type === AccountType.LIABILITY).amountChf).toBe(-1000); + }); + + // m6 fail-loud (F1): a manual-debt position whose asset has no priceChf in the snapshot would book a CHF liability + // (no assetId) the mark-to-market job can NEVER revalue → the cutover THROWS and leaves the ledger-ready flag + // unset (retry once the price feed is back), rather than silently dropping the CHF value or booking native units. + it('throws (m6) on a manual-debt position whose asset has no priceChf in the snapshot', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); // asset 100 not in the snapshot + jest.spyOn(settingService, 'getObj').mockResolvedValue([{ assetId: 100, value: 1000 }] as any); + + await expect(service.run()).rejects.toThrow(/without a mark/i); + + const flagSet = (settingService.set as jest.Mock).mock.calls.find((c) => c[0] === 'ledgerCutoverLogId'); + expect(flagSet).toBeUndefined(); // ledger-ready flag NOT set → all consumers stay no-op, retry next cron run + expect(booked.some((b) => b.sourceId === '1557344:manual-debt:100')).toBe(false); // no zero-opening booked + }); + + it('books no manual-debt opening when there are no debt positions', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest.spyOn(settingService, 'getObj').mockResolvedValue([] as any); + + await service.run(); + + expect(booked.some((b) => b.sourceId?.startsWith('1557344:manual-debt'))).toBe(false); + }); + }); + + // --- IDEMPOTENT RE-RUN (alreadyBooked UNIQUE backstop, §6.2) --- // + + it('does not re-book an opening already present at its seq (alreadyBooked backstop, idempotent re-run)', async () => { + const snapshot = snapshotLog({ + '100': { + priceChf: 2, + plusBalance: { total: 5, liquidity: { total: 5, liquidityBalance: { total: 5 } } }, + minusBalance: { total: 0 }, + error: '', + }, + }); + jest.spyOn(settingService, 'get').mockResolvedValue(undefined); + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshot]); + // the ASSET opening sits at its per-asset sourceId `:asset:` seq 0 (B2) → nextSeq already returns 1 + // → alreadyBooked(seq 0) true → no re-book + nextSeqByKey.set('cutover:1557344:asset:100', 1); + + await service.run(); + + expect(booked.some((b) => b.legs.some((l) => l.account.type === AccountType.ASSET))).toBe(false); + }); +}); diff --git a/src/subdomains/core/accounting/services/__tests__/ledger-mark-to-market.service.spec.ts b/src/subdomains/core/accounting/services/__tests__/ledger-mark-to-market.service.spec.ts new file mode 100644 index 0000000000..a84887a9f6 --- /dev/null +++ b/src/subdomains/core/accounting/services/__tests__/ledger-mark-to-market.service.spec.ts @@ -0,0 +1,388 @@ +import { createMock } from '@golevelup/ts-jest'; +import { CronExpression } from '@nestjs/schedule'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Config } from 'src/config/config'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { Process } from 'src/shared/services/process.service'; +import { DFX_CRONJOB_PARAMS, DfxCronParams } from 'src/shared/utils/cron'; +import { TestUtil } from 'src/shared/utils/test.util'; +import { AccountType, LedgerAccount } from '../../entities/ledger-account.entity'; +import { createCustomLedgerAccount } from '../../entities/__mocks__/ledger-account.entity.mock'; +import { LedgerAccountRepository } from '../../repositories/ledger-account.repository'; +import { LedgerLegRepository } from '../../repositories/ledger-leg.repository'; +import { LedgerBookingJobService } from '../ledger-booking-job.service'; +import { LedgerBookingService, LedgerTxInput } from '../ledger-booking.service'; +import { LedgerAccountService } from '../ledger-account.service'; +import { LedgerMarkCache, LedgerMarkService } from '../ledger-mark.service'; +import { LedgerMarkToMarketService } from '../ledger-mark-to-market.service'; + +interface LegQueryStub { + candidateIds?: number[]; // selectCandidates getRawMany — single page (returned regardless of the lastId watermark) + candidatePages?: Record; // selectCandidates getRawMany — paged by lastId watermark (overrides candidateIds) + balance?: { native: string; chf: string }; // accountBalance getRawOne + alreadyBookedCount?: number; // alreadyBooked getCount +} + +describe('LedgerMarkToMarketService', () => { + let service: LedgerMarkToMarketService; + + let jobService: LedgerBookingJobService; + let settingService: SettingService; + let bookingService: LedgerBookingService; + let accountService: LedgerAccountService; + let markService: LedgerMarkService; + let ledgerAccountRepository: LedgerAccountRepository; + let ledgerLegRepository: LedgerLegRepository; + + let booked: LedgerTxInput[]; + let legStub: LegQueryStub; + + const fxIncome = createCustomLedgerAccount({ id: 80, name: 'INCOME/fx-revaluation', type: AccountType.INCOME }); + const fxExpense = createCustomLedgerAccount({ id: 81, name: 'EXPENSE/fx-revaluation', type: AccountType.EXPENSE }); + + function markedAccount(assetId: number): LedgerAccount { + return createCustomLedgerAccount({ + id: 1000 + assetId, + name: `Asset/${assetId}`, + type: AccountType.ASSET, + assetId, + }); + } + + // a chainable query-builder stub that resolves its terminal method from legStub by query shape. selectCandidates' + // `.andWhere('leg.accountId > :lastId', { lastId })` is captured per builder instance so getRawMany can serve a + // different page per id-watermark (candidatePages) — the basis for the multi-page pagination test. + function legQb(): any { + const qb: any = {}; + let lastId = 0; // the id-watermark this builder was filtered on (selectCandidates page) + const chain = () => qb; + qb.innerJoin = chain; + qb.select = chain; + qb.addSelect = chain; + qb.where = chain; + qb.andWhere = (_clause: string, params?: { lastId?: number }) => { + if (params && typeof params.lastId === 'number') lastId = params.lastId; + return qb; + }; + qb.groupBy = chain; + qb.having = chain; + qb.orderBy = chain; + qb.limit = chain; + qb.getRawMany = () => { + const ids = legStub.candidatePages ? (legStub.candidatePages[lastId] ?? []) : (legStub.candidateIds ?? []); + return Promise.resolve(ids.map((id) => ({ accountId: id }))); + }; + qb.getRawOne = () => Promise.resolve(legStub.balance ?? { native: '0', chf: '0' }); + qb.getCount = () => Promise.resolve(legStub.alreadyBookedCount ?? 0); + return qb; + } + + beforeEach(async () => { + booked = []; + legStub = {}; + + jobService = createMock(); + settingService = createMock(); + bookingService = createMock(); + accountService = createMock(); + markService = createMock(); + ledgerAccountRepository = createMock(); + ledgerLegRepository = createMock(); + + jest.spyOn(jobService, 'isLedgerReady').mockResolvedValue(true); + jest.spyOn(bookingService, 'bookTx').mockImplementation((input: LedgerTxInput) => { + booked.push(input); + return Promise.resolve({} as any); + }); + jest.spyOn(accountService, 'findOrCreate').mockImplementation((name: string) => { + return Promise.resolve(name === 'INCOME/fx-revaluation' ? fxIncome : fxExpense); + }); + jest.spyOn(ledgerLegRepository, 'createQueryBuilder').mockImplementation(() => legQb()); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LedgerMarkToMarketService, + TestUtil.provideConfig(), + { provide: LedgerBookingJobService, useValue: jobService }, + { provide: SettingService, useValue: settingService }, + { provide: LedgerBookingService, useValue: bookingService }, + { provide: LedgerAccountService, useValue: accountService }, + { provide: LedgerMarkService, useValue: markService }, + { provide: LedgerAccountRepository, useValue: ledgerAccountRepository }, + { provide: LedgerLegRepository, useValue: ledgerLegRepository }, + ], + }).compile(); + + service = module.get(LedgerMarkToMarketService); + }); + + it('is defined', () => { + expect(service).toBeDefined(); + }); + + it('runs off-peak at 04:00 with its own LEDGER_MARK_TO_MARKET kill-switch (Hard Constraint #5, §5.3)', () => { + const params: DfxCronParams = Reflect.getMetadata(DFX_CRONJOB_PARAMS, LedgerMarkToMarketService.prototype.run); + expect(params.expression).toBe(CronExpression.EVERY_DAY_AT_4AM); + expect(params.process).toBe(Process.LEDGER_MARK_TO_MARKET); + }); + + it('no-ops while the ledger is not ready (cutover-gate)', async () => { + jest.spyOn(jobService, 'isLedgerReady').mockResolvedValue(false); + + await service.run(); + + expect(bookingService.bookTx).not.toHaveBeenCalled(); + }); + + it('books a positive revaluation Dr ASSET / Cr INCOME/fx-revaluation when the mark rises', async () => { + legStub = { candidateIds: [205], balance: { native: '100', chf: '90' }, alreadyBookedCount: 0 }; + jest.spyOn(ledgerAccountRepository, 'findBy').mockResolvedValue([markedAccount(5)]); + // mark 1.0 → newChf = 100; oldChf = 90; diff +10 + jest + .spyOn(markService, 'preload') + .mockResolvedValue(new LedgerMarkCache(new Map([[5, [{ created: new Date('2026-06-01'), priceChf: 1.0 }]]]))); + + await service.run(); + + expect(booked).toHaveLength(1); + const tx = booked[0]; + expect(tx.sourceType).toBe('mark_to_market'); + expect(tx.sourceId).toBe('1005'); + + const assetLeg = tx.legs.find((l) => l.account.type === AccountType.ASSET); + expect(assetLeg.amount).toBe(0); // native unchanged, CHF re-valuation only (§5.3) + expect(assetLeg.amountChf).toBe(10); + + const fxLeg = tx.legs.find((l) => l.account.name === 'INCOME/fx-revaluation'); + expect(fxLeg.amountChf).toBe(-10); // Σ CHF = 0 + }); + + it('books a negative revaluation against EXPENSE/fx-revaluation when the mark falls', async () => { + legStub = { candidateIds: [205], balance: { native: '100', chf: '120' }, alreadyBookedCount: 0 }; + jest.spyOn(ledgerAccountRepository, 'findBy').mockResolvedValue([markedAccount(5)]); + jest + .spyOn(markService, 'preload') + .mockResolvedValue(new LedgerMarkCache(new Map([[5, [{ created: new Date('2026-06-01'), priceChf: 1.0 }]]]))); + + await service.run(); + + const fxLeg = booked[0].legs.find((l) => l.account.type === AccountType.EXPENSE); + expect(fxLeg.account.name).toBe('EXPENSE/fx-revaluation'); + expect(booked[0].legs.find((l) => l.account.type === AccountType.ASSET).amountChf).toBe(-20); + }); + + it('does not book when the account is still feedless (no mark → no phantom revaluation)', async () => { + legStub = { candidateIds: [205], balance: { native: '100', chf: '0' }, alreadyBookedCount: 0 }; + jest.spyOn(ledgerAccountRepository, 'findBy').mockResolvedValue([markedAccount(5)]); + jest.spyOn(markService, 'preload').mockResolvedValue(new LedgerMarkCache(new Map())); // no mark for asset 5 + + await service.run(); + + expect(bookingService.bookTx).not.toHaveBeenCalled(); + }); + + // line 147: a candidate whose native balance is ≈0 (closed) is skipped — nothing to revalue. + it('does not revalue a closed account (native balance ≈ 0)', async () => { + legStub = { candidateIds: [205], balance: { native: '0', chf: '5' }, alreadyBookedCount: 0 }; + jest.spyOn(ledgerAccountRepository, 'findBy').mockResolvedValue([markedAccount(5)]); + jest + .spyOn(markService, 'preload') + .mockResolvedValue(new LedgerMarkCache(new Map([[5, [{ created: new Date('2026-06-01'), priceChf: 1.0 }]]]))); + + await service.run(); + + expect(bookingService.bookTx).not.toHaveBeenCalled(); // |nativeBalance| ≤ 1e-8 → closed → no revaluation + }); + + // lines 183-184: accountBalance treats a NULL getRawOne (no legs) as native 0 / chf 0 (the `?? 0` fallbacks). An + // account whose balance query returns {native:null, chf:null} → nativeBalance 0 → closed → no booking. + it('treats a null balance query result as native 0 / chf 0 (no revaluation)', async () => { + legStub = { candidateIds: [205], balance: { native: null as any, chf: null as any }, alreadyBookedCount: 0 }; + jest.spyOn(ledgerAccountRepository, 'findBy').mockResolvedValue([markedAccount(5)]); + jest + .spyOn(markService, 'preload') + .mockResolvedValue(new LedgerMarkCache(new Map([[5, [{ created: new Date('2026-06-01'), priceChf: 1.0 }]]]))); + + await service.run(); + + expect(bookingService.bookTx).not.toHaveBeenCalled(); // null → 0 → closed + }); + + // line 88: a full first page (= batchSize) followed by an EMPTY second page → the loop fetches page 2, sees no ids, + // and breaks via `if (!page.ids.length) break` (the explicit empty-page exit, distinct from the partial-page exit). + it('exits cleanly when the page AFTER a full first page is empty (empty-page break, line 88)', async () => { + const batchSize = Config.ledger.backfillBatchSize; + const page1Ids = Array.from({ length: batchSize }, (_, i) => 101 + i); + const page1MaxId = page1Ids[page1Ids.length - 1]; + + legStub = { + candidatePages: { 0: page1Ids, [page1MaxId]: [] }, // page 1 full, page 2 empty + balance: { native: '100', chf: '90' }, + alreadyBookedCount: 0, + }; + const accountById = new Map( + page1Ids.map((id) => [ + id, + createCustomLedgerAccount({ id, name: `Asset/${id}`, type: AccountType.ASSET, assetId: 5 }), + ]), + ); + jest + .spyOn(ledgerAccountRepository, 'findBy') + .mockImplementation((where: any) => + Promise.resolve((where.id.value as number[]).map((id) => accountById.get(id))), + ); + jest + .spyOn(markService, 'preload') + .mockResolvedValue(new LedgerMarkCache(new Map([[5, [{ created: new Date('2026-06-01'), priceChf: 1.0 }]]]))); + + await service.run(); + + expect(booked).toHaveLength(page1Ids.length); // exactly page-1 accounts revalued; page-2 empty → clean exit + }); + + it('does not book when the CHF difference is sub-cent', async () => { + legStub = { candidateIds: [205], balance: { native: '100', chf: '100' }, alreadyBookedCount: 0 }; + jest.spyOn(ledgerAccountRepository, 'findBy').mockResolvedValue([markedAccount(5)]); + jest + .spyOn(markService, 'preload') + .mockResolvedValue(new LedgerMarkCache(new Map([[5, [{ created: new Date('2026-06-01'), priceChf: 1.0 }]]]))); + + await service.run(); + + expect(bookingService.bookTx).not.toHaveBeenCalled(); + }); + + it('is idempotent within the same day (already-booked day → no-op)', async () => { + legStub = { candidateIds: [205], balance: { native: '100', chf: '90' }, alreadyBookedCount: 1 }; + jest.spyOn(ledgerAccountRepository, 'findBy').mockResolvedValue([markedAccount(5)]); + jest + .spyOn(markService, 'preload') + .mockResolvedValue(new LedgerMarkCache(new Map([[5, [{ created: new Date('2026-06-01'), priceChf: 1.0 }]]]))); + + await service.run(); + + expect(bookingService.bookTx).not.toHaveBeenCalled(); + }); + + it('no-ops when no open accounts qualify', async () => { + legStub = { candidateIds: [] }; + + await service.run(); + + expect(markService.preload).not.toHaveBeenCalled(); + expect(bookingService.bookTx).not.toHaveBeenCalled(); + }); + + // run() no longer wraps markToMarket in a try/catch — @DfxCron's lock layer catches + logs (CONTRIBUTING: no + // redundant try/catch in a @DfxCron method). A markToMarket failure therefore propagates out of run() to that layer. + it('propagates a markToMarket error out of run() (handled by @DfxCron, no redundant in-method catch)', async () => { + legStub = { candidateIds: [205], balance: { native: '100', chf: '90' }, alreadyBookedCount: 0 }; + jest.spyOn(ledgerLegRepository, 'createQueryBuilder').mockImplementation(() => { + throw new Error('db down'); // the very first selectCandidates query throws + }); + + await expect(service.run()).rejects.toThrow('db down'); + + expect(bookingService.bookTx).not.toHaveBeenCalled(); + }); + + // per-account failure-isolation (line 81): one account's revaluation throwing must NOT abort the others — the loop + // catches, logs, and continues to the next account, which still books its revaluation. + it('isolates a single failing account: the others are still revalued (catch in the revalue loop)', async () => { + legStub = { candidateIds: [205, 206], balance: { native: '100', chf: '90' }, alreadyBookedCount: 0 }; + const good = markedAccount(5); // assetId 5 has a mark → revalues fine + const bad = createCustomLedgerAccount({ id: 9000, name: 'Asset/bad', type: AccountType.ASSET, assetId: 6 }); + jest.spyOn(ledgerAccountRepository, 'findBy').mockResolvedValue([bad, good]); // bad processed first + // bookTx throws for the bad account (id 9000 → sourceId '9000'), succeeds for the good one + jest.spyOn(bookingService, 'bookTx').mockImplementation((input: LedgerTxInput) => { + if (input.sourceId === '9000') return Promise.reject(new Error('boom')); + booked.push(input); + return Promise.resolve({} as any); + }); + jest.spyOn(markService, 'preload').mockResolvedValue( + new LedgerMarkCache( + new Map([ + [5, [{ created: new Date('2026-06-01'), priceChf: 1.0 }]], + [6, [{ created: new Date('2026-06-01'), priceChf: 1.0 }]], + ]), + ), + ); + const errSpy = jest.spyOn(service['logger'], 'error'); + + await service.run(); + + expect(errSpy).toHaveBeenCalledWith('Failed to mark-to-market ledger account 9000:', expect.any(Error)); + expect(booked.map((t) => t.sourceId)).toEqual(['1005']); // the good account still booked despite the bad one + }); + + // §4.2-Note B-19 / Point 4: a CHF-denominated LIABILITY bucket (assetId=NULL) carries no native FX exposure → it is + // CHF-stable and MUST NOT be revalued (a balance with no asset cannot be re-marked; there is no wandering drift to + // correct). Even if such an account were returned as a candidate, revalue() must early-return at assetId==null. + it('never revalues a CHF-denominated LIABILITY (assetId=NULL) — no phantom drift on a CHF-stable balance', async () => { + const chfLiability = createCustomLedgerAccount({ + id: 9001, + name: 'LIABILITY/unattributed', + type: AccountType.LIABILITY, + assetId: null, + } as any); + legStub = { candidateIds: [9001], balance: { native: '950', chf: '900' }, alreadyBookedCount: 0 }; + jest.spyOn(ledgerAccountRepository, 'findBy').mockResolvedValue([chfLiability]); + jest + .spyOn(markService, 'preload') + .mockResolvedValue(new LedgerMarkCache(new Map([[5, [{ created: new Date('2026-06-01'), priceChf: 1.0 }]]]))); + + await service.run(); + + expect(bookingService.bookTx).not.toHaveBeenCalled(); // assetId=NULL → no re-mark, no phantom revaluation + }); + + // §5.3 (Major, analog reconciliation §7.0): the candidate universe is paginated by id-watermark, NOT truncated at + // a single `.limit(batchSize)`. This drives selectCandidates over two pages: page 1 (lastId 0) fills a whole + // batchSize window → the loop must fetch page 2 (lastId = page-1 maxId); page 2 is smaller → exhausted. Accounts on + // BOTH pages must be revalued (a single-page truncation would silently never re-mark page 2 → permanently stale CHF). + it('paginates the candidate universe and revalues accounts beyond the first batchSize page', async () => { + const batchSize = Config.ledger.backfillBatchSize; // 100 (test default) + + // page 1 fills the whole window (ids 101..100+batchSize), page 2 (lastId = page-1 maxId) is a single trailing id + const page1Ids = Array.from({ length: batchSize }, (_, i) => 101 + i); + const page1MaxId = page1Ids[page1Ids.length - 1]; + const page2Ids = [page1MaxId + 1]; + + legStub = { + candidatePages: { 0: page1Ids, [page1MaxId]: page2Ids }, + balance: { native: '100', chf: '90' }, // newChf = 100 (mark 1.0 × 100), oldChf = 90 → diff +10 → books + alreadyBookedCount: 0, + }; + + // every candidate shares assetId 5 (one mark in the cache) but a distinct account id → distinct revaluation-tx + const accountById = new Map( + [...page1Ids, ...page2Ids].map((id) => [ + id, + createCustomLedgerAccount({ id, name: `Asset/${id}`, type: AccountType.ASSET, assetId: 5 }), + ]), + ); + const selectCandidates = jest.spyOn(service as any, 'selectCandidates'); + jest + .spyOn(ledgerAccountRepository, 'findBy') + .mockImplementation((where: any) => + Promise.resolve((where.id.value as number[]).map((id) => accountById.get(id))), + ); + jest + .spyOn(markService, 'preload') + .mockResolvedValue(new LedgerMarkCache(new Map([[5, [{ created: new Date('2026-06-01'), priceChf: 1.0 }]]]))); + + await service.run(); + + // selectCandidates is called twice: page 1 (full window) → loop fetches page 2 (smaller → exhausted) + expect(selectCandidates).toHaveBeenCalledTimes(2); + expect(selectCandidates).toHaveBeenNthCalledWith(1, 0, batchSize); + expect(selectCandidates).toHaveBeenNthCalledWith(2, page1MaxId, batchSize); + + // every account on BOTH pages is revalued (one tx per account) — none beyond the first page is silently dropped + expect(booked).toHaveLength(page1Ids.length + page2Ids.length); + const bookedSourceIds = booked.map((tx) => tx.sourceId); + expect(bookedSourceIds).toContain(`${page1Ids[0]}`); // first id of page 1 + expect(bookedSourceIds).toContain(`${page1MaxId}`); // last id of page 1 (the watermark) + expect(bookedSourceIds).toContain(`${page2Ids[0]}`); // the page-2 account beyond the first batchSize window + }); +}); diff --git a/src/subdomains/core/accounting/services/__tests__/ledger-mark.service.spec.ts b/src/subdomains/core/accounting/services/__tests__/ledger-mark.service.spec.ts new file mode 100644 index 0000000000..f3c963ac9d --- /dev/null +++ b/src/subdomains/core/accounting/services/__tests__/ledger-mark.service.spec.ts @@ -0,0 +1,235 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { TestUtil } from 'src/shared/utils/test.util'; +import { createCustomLog } from 'src/subdomains/supporting/log/__mocks__/log.entity.mock'; +import { Log } from 'src/subdomains/supporting/log/log.entity'; +import { LogService } from 'src/subdomains/supporting/log/log.service'; +import { LedgerMarkService } from '../ledger-mark.service'; + +function financialLog(created: Date, assets: Record): Log { + return createCustomLog({ + system: 'LogService', + subsystem: 'FinancialDataLog', + created, + message: JSON.stringify({ assets }), + }); +} + +describe('LedgerMarkService', () => { + let service: LedgerMarkService; + let logService: LogService; + + beforeEach(async () => { + logService = createMock(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [TestUtil.provideConfig(), LedgerMarkService, { provide: LogService, useValue: logService }], + }).compile(); + + service = module.get(LedgerMarkService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + // Major B5 bridge — the youngest available mark (latest ≤ now), memoized, used as the fallback when a historical mark + // is missing at the booking date. + describe('getLatestMark (B5 bridge)', () => { + const daysAgo = (n: number) => new Date(Date.now() - n * 24 * 60 * 60 * 1000); + + it('returns the youngest available priceChf ≤ now and memoizes the recent-log read', async () => { + const spy = jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([ + financialLog(daysAgo(2), { '7': { priceChf: 100 } }), + financialLog(daysAgo(1), { '7': { priceChf: 110 } }), // youngest ≤ now + ]); + + expect(await service.getLatestMark(7)).toBe(110); + expect(await service.getLatestMark(7)).toBe(110); // second call reuses the memoized map + expect(spy).toHaveBeenCalledTimes(1); // no per-call re-query (memoized within the TTL) + }); + + it('returns undefined for an asset absent from every recent log (feedless → caller defers)', async () => { + jest + .spyOn(logService, 'getFinancialLogs') + .mockResolvedValue([financialLog(daysAgo(1), { '7': { priceChf: 110 } })]); + + expect(await service.getLatestMark(999)).toBeUndefined(); + }); + + it('ignores logs created after now (upper-bound ≤ now)', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([ + financialLog(daysAgo(1), { '7': { priceChf: 110 } }), + financialLog(new Date(Date.now() + 60_000), { '7': { priceChf: 999 } }), // future → excluded + ]); + + expect(await service.getLatestMark(7)).toBe(110); + }); + }); + + it('returns the priceChf of the latest mark ≤ bookingDate (stage 2)', async () => { + jest + .spyOn(logService, 'getFinancialLogs') + .mockResolvedValue([ + financialLog(new Date('2026-06-01'), { '5': { priceChf: 50000 } }), + financialLog(new Date('2026-06-02'), { '5': { priceChf: 51000 } }), + financialLog(new Date('2026-06-03'), { '5': { priceChf: 52000 } }), + ]); + + const cache = await service.preload(new Date('2026-06-01'), new Date('2026-06-03')); + + expect(cache.getMarkAt(5, new Date('2026-06-02T12:00:00Z'))).toBe(51000); // latest ≤ bookingDate + expect(cache.getMarkAt(5, new Date('2026-06-03'))).toBe(52000); + }); + + it('returns undefined when no log row ≤ bookingDate exists (stage 3 → needsMark)', async () => { + jest + .spyOn(logService, 'getFinancialLogs') + .mockResolvedValue([financialLog(new Date('2026-06-05'), { '5': { priceChf: 50000 } })]); + + const cache = await service.preload(new Date('2026-06-05'), new Date('2026-06-05')); + + expect(cache.getMarkAt(5, new Date('2026-06-04'))).toBeUndefined(); // no mark before bookingDate + }); + + it('returns undefined when a log row exists but its assets JSON lacks the assetId (Minor R5-5)', async () => { + jest + .spyOn(logService, 'getFinancialLogs') + .mockResolvedValue([financialLog(new Date('2026-06-01'), { '7': { priceChf: 1.0 } })]); + + const cache = await service.preload(new Date('2026-06-01'), new Date('2026-06-01')); + + expect(cache.getMarkAt(999, new Date('2026-06-01'))).toBeUndefined(); // absent assetId → no mark, not 0, not throw + }); + + it('skips non-finite priceChf entries (no phantom 0 mark)', async () => { + jest + .spyOn(logService, 'getFinancialLogs') + .mockResolvedValue([financialLog(new Date('2026-06-01'), { '5': { priceChf: NaN } })]); + + const cache = await service.preload(new Date('2026-06-01'), new Date('2026-06-01')); + + expect(cache.getMarkAt(5, new Date('2026-06-01'))).toBeUndefined(); + }); + + it('never throws on malformed message JSON (defensive parse)', async () => { + jest + .spyOn(logService, 'getFinancialLogs') + .mockResolvedValue([createCustomLog({ created: new Date('2026-06-01'), message: 'not-json' })]); + + const cache = await service.preload(new Date('2026-06-01'), new Date('2026-06-01')); + + expect(cache.getMarkAt(5, new Date('2026-06-01'))).toBeUndefined(); + }); + + it('uses dailySample when the span exceeds the threshold (bounded preload)', async () => { + const spy = jest + .spyOn(logService, 'getFinancialLogs') + .mockResolvedValue([financialLog(new Date('2026-06-01'), { '5': { priceChf: 50000 } })]); + + await service.preload(new Date('2026-06-01'), new Date('2026-06-10')); // 9 days > threshold 2 + + expect(spy).toHaveBeenCalledWith(new Date('2026-06-01'), true); // dailySample = true + }); + + it('uses the full minute-tick for fresh windows within the threshold', async () => { + const spy = jest + .spyOn(logService, 'getFinancialLogs') + .mockResolvedValue([financialLog(new Date('2026-06-01'), { '5': { priceChf: 50000 } })]); + + await service.preload(new Date('2026-06-01'), new Date('2026-06-01T06:00:00Z')); // < 2 days + + expect(spy).toHaveBeenCalledWith(new Date('2026-06-01'), false); // full tick + }); + + it('trims log rows whose created is strictly after `to` (upper-bound filter before pagination)', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([ + financialLog(new Date('2026-06-01'), { '5': { priceChf: 50000 } }), + financialLog(new Date('2026-06-03'), { '5': { priceChf: 52000 } }), // beyond `to` → must be dropped + ]); + + const cache = await service.preload(new Date('2026-06-01'), new Date('2026-06-02')); + + expect(cache.getMarkAt(5, new Date('2026-06-02'))).toBe(50000); // only the in-window row survives, NOT 52000 + }); + + // §5.2 step 3 pagination backstop: when the first bounded read returns more than markPreloadMaxRows rows the service + // re-loads in created-continuation windows. With markPreloadMaxRows=1 the first read (2 rows) trips the backstop. + describe('pagination backstop (rows > markPreloadMaxRows)', () => { + let pagedService: LedgerMarkService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + // override BOTH ledger fields (Object.assign is shallow → a partial ledger would drop the threshold) + TestUtil.provideConfig({ + ledger: { markPreloadMaxRows: 1, markPreloadDailySampleThresholdDays: 2 } as any, + }), + LedgerMarkService, + { provide: LogService, useValue: logService }, + ], + }).compile(); + pagedService = module.get(LedgerMarkService); + }); + + it('re-loads via created-continuation windows and walks the cache across windows', async () => { + const w1a = financialLog(new Date('2026-06-01T00:00:00Z'), { '5': { priceChf: 50000 } }); + const w1b = financialLog(new Date('2026-06-01T01:00:00Z'), { '5': { priceChf: 51000 } }); + const w2 = financialLog(new Date('2026-06-01T02:00:00Z'), { '5': { priceChf: 52000 } }); + + const spy = jest.spyOn(logService, 'getFinancialLogs'); + // first call (the trigger read) returns 2 rows > maxRows(1) → backstop kicks in; + // paginate then re-queries from the windowStart cursor. + spy + .mockResolvedValueOnce([w1a, w1b]) // trigger read: > maxRows → paginate + .mockResolvedValueOnce([w1a, w1b]) // window 1: 2 rows, lastCreated advances the cursor + .mockResolvedValueOnce([w2]) // window 2: 1 row < maxRows → loop stops after this window + .mockResolvedValue([]); + + const cache = await pagedService.preload(new Date('2026-06-01T00:00:00Z'), new Date('2026-06-01T03:00:00Z')); + + // the continuation windows were used (>1 getFinancialLogs call beyond the trigger read) + expect(spy.mock.calls.length).toBeGreaterThan(1); + // all three marks made it into the cache built from the paginated rows + expect(cache.getMarkAt(5, new Date('2026-06-01T00:30:00Z'))).toBe(50000); + expect(cache.getMarkAt(5, new Date('2026-06-01T01:30:00Z'))).toBe(51000); + expect(cache.getMarkAt(5, new Date('2026-06-01T02:30:00Z'))).toBe(52000); + }); + + it('stops the pagination loop on the first empty window (no infinite loop)', async () => { + const trigger = [ + financialLog(new Date('2026-06-01T00:00:00Z'), { '5': { priceChf: 50000 } }), + financialLog(new Date('2026-06-01T01:00:00Z'), { '5': { priceChf: 51000 } }), + ]; + const spy = jest + .spyOn(logService, 'getFinancialLogs') + .mockResolvedValueOnce(trigger) // trigger read → paginate + .mockResolvedValueOnce([]); // first window already empty → break immediately + + const cache = await pagedService.preload(new Date('2026-06-01T00:00:00Z'), new Date('2026-06-01T03:00:00Z')); + + expect(spy).toHaveBeenCalledTimes(2); // trigger + one empty window, then break + expect(cache.getMarkAt(5, new Date('2026-06-01T00:30:00Z'))).toBeUndefined(); // empty window → empty cache + }); + + it('breaks when a window does not advance the created cursor (lastCreated <= windowStart guard)', async () => { + // every paginated window returns the SAME single timestamp at/below windowStart → the lastCreated<=windowStart + // guard breaks the loop instead of re-querying the identical window forever. + const stuck = financialLog(new Date('2026-06-01T00:00:00Z'), { '5': { priceChf: 50000 } }); + const trigger = [ + stuck, + financialLog(new Date('2026-06-01T00:00:00Z'), { '6': { priceChf: 2 } }), // same created → 2 rows > maxRows + ]; + const spy = jest + .spyOn(logService, 'getFinancialLogs') + .mockResolvedValueOnce(trigger) // trigger read → paginate + .mockResolvedValue(trigger); // every window returns the same created → cursor cannot advance → break + + const cache = await pagedService.preload(new Date('2026-06-01T00:00:00Z'), new Date('2026-06-01T03:00:00Z')); + + // it must NOT spin: the guard breaks after the first (non-advancing) window + expect(spy.mock.calls.length).toBeLessThan(5); + expect(cache.getMarkAt(5, new Date('2026-06-01T00:00:00Z'))).toBe(50000); + }); + }); +}); diff --git a/src/subdomains/core/accounting/services/__tests__/ledger-query.service.spec.ts b/src/subdomains/core/accounting/services/__tests__/ledger-query.service.spec.ts new file mode 100644 index 0000000000..7a14d76e27 --- /dev/null +++ b/src/subdomains/core/accounting/services/__tests__/ledger-query.service.spec.ts @@ -0,0 +1,890 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { TestUtil } from 'src/shared/utils/test.util'; +import { Util } from 'src/shared/utils/util'; +import { LiquidityBalance } from 'src/subdomains/core/liquidity-management/entities/liquidity-balance.entity'; +import { LiquidityManagementBalanceService } from 'src/subdomains/core/liquidity-management/services/liquidity-management-balance.service'; +import { Log } from 'src/subdomains/supporting/log/log.entity'; +import { LogService } from 'src/subdomains/supporting/log/log.service'; +import { LedgerReconStatus } from '../../dto/ledger-account.dto'; +import { LedgerFeedStaleness, LedgerReconResultStatus } from '../../dto/ledger-reconciliation.dto'; +import { createCustomLedgerAccount } from '../../entities/__mocks__/ledger-account.entity.mock'; +import { createCustomLedgerLeg } from '../../entities/__mocks__/ledger-leg.entity.mock'; +import { AccountType, LedgerAccount } from '../../entities/ledger-account.entity'; +import { LedgerLeg } from '../../entities/ledger-leg.entity'; +import { LedgerTx } from '../../entities/ledger-tx.entity'; +import { LedgerAccountRepository } from '../../repositories/ledger-account.repository'; +import { LedgerLegRepository } from '../../repositories/ledger-leg.repository'; +import { LedgerMarkCache, LedgerMarkService } from '../ledger-mark.service'; +import { LedgerQueryService } from '../ledger-query.service'; +import { FeedStatus, LedgerReconciliationService } from '../ledger-reconciliation.service'; + +// holds the values returned by the chainable leg query-builder, keyed by a discriminator from the captured SQL +interface LegQbStub { + balancesByAccount?: { accountId: number; native: string; chf: string }[]; + marginRows?: { bucket: string; type: AccountType; name: string; chf: string }[]; + suspenseLegs?: LedgerLeg[]; + detailLegs?: LedgerLeg[]; + detailTotal?: number; + counterLegs?: LedgerLeg[]; + rawOne?: Record; // keyed by discriminator +} + +describe('LedgerQueryService', () => { + let service: LedgerQueryService; + + let ledgerAccountRepository: LedgerAccountRepository; + let ledgerLegRepository: LedgerLegRepository; + let reconciliationService: LedgerReconciliationService; + let liquidityManagementBalanceService: LiquidityManagementBalanceService; + let markService: LedgerMarkService; + let logService: LogService; + + let qbStub: LegQbStub; + + // §7 unit fix: a persisted-mark cache the recon path uses to value the native journal↔feed diff in CHF. Every + // FRESH-feed ok/diff test needs a mark for its asset id (else mapReconStatus → unverified, never silently ok). + function markCache(byAssetId: Record): LedgerMarkCache { + return new LedgerMarkCache( + new Map(Object.entries(byAssetId).map(([id, priceChf]) => [+id, [{ created: new Date(0), priceChf }]])), + ); + } + + function assetAccount(id: number, assetId: number, name: string): LedgerAccount { + return createCustomLedgerAccount({ + id, + name, + type: AccountType.ASSET, + assetId, + currency: 'EUR', + asset: Object.assign(new Asset(), { id: assetId }), + }); + } + + function feed(assetId: number, amount: number, updated: Date): LiquidityBalance { + return Object.assign(new LiquidityBalance(), { asset: { id: assetId } as Asset, amount, updated }); + } + + function financeLog(created: string, totalBalanceChf: number): Log { + return Object.assign(new Log(), { + id: 1, + created: new Date(created), + message: JSON.stringify({ + assets: {}, + tradings: {}, + balancesByFinancialType: {}, + balancesTotal: { totalBalanceChf }, + }), + }); + } + + function legTx(custom: Partial): LedgerTx { + return Object.assign(new LedgerTx(), { + id: 1, + bookingDate: new Date('2026-06-07T00:00:00.000Z'), + valueDate: new Date('2026-06-07T00:00:00.000Z'), + sourceType: 'buy_fiat', + sourceId: '1', + seq: 0, + ...custom, + }); + } + + function makeLeg(custom: Partial, account?: LedgerAccount, txCustom: Partial = {}): LedgerLeg { + return createCustomLedgerLeg({ + id: 1, + txId: 1, + accountId: account?.id ?? 5, + amount: 0, + account, + tx: legTx(txCustom), + ...custom, + }); + } + + // chainable query-builder stub: records select/where, resolves terminal methods by the captured expressions + function legQb(): any { + const qb: any = { _selects: [] as string[], _wheres: [] as string[] }; + const chain = () => qb; + qb.innerJoin = chain; + qb.innerJoinAndSelect = chain; + qb.leftJoin = chain; + qb.select = (expr: string) => { + qb._selects.push(expr); + return qb; + }; + qb.addSelect = (expr: string) => { + qb._selects.push(expr); + return qb; + }; + qb.where = (expr: string) => { + qb._wheres.push(expr); + return qb; + }; + qb.andWhere = (expr: string) => { + qb._wheres.push(expr); + return qb; + }; + qb.groupBy = chain; + qb.addGroupBy = chain; + qb.having = chain; + qb.orderBy = chain; + qb.addOrderBy = chain; + qb.skip = chain; + qb.take = chain; + + qb.getRawMany = () => { + const selects = qb._selects.join(' '); + // margin query selects account.type per row; the balances query does not + if (selects.includes('account.type')) return Promise.resolve(qbStub.marginRows ?? []); + return Promise.resolve(qbStub.balancesByAccount ?? []); + }; + qb.getRawOne = () => Promise.resolve({ native: '0', chf: '0' }); + qb.getMany = () => Promise.resolve(qbStub.suspenseLegs ?? []); + qb.getManyAndCount = () => Promise.resolve([qbStub.detailLegs ?? [], qbStub.detailTotal ?? 0]); + + return qb; + } + + // §7.6 batched equity-comparison query (cumulativeEquityByDay): a chainable stub that records the addSelect + // expressions + bound parameters and resolves getRawMany with the supplied day-grouped delta rows. + interface EquityGroupedRow { + day: string; + equity: string; + transit: string; + stale: string; + spread: string; + } + function equityGroupedQb( + rows: EquityGroupedRow[], + captured?: { selects: string[]; params: Record }, + ): any { + const qb: any = {}; + const chain = () => qb; + qb.innerJoin = chain; + qb.select = (e: string) => (captured?.selects.push(e), qb); + qb.addSelect = (e: string) => (captured?.selects.push(e), qb); + qb.where = chain; + qb.andWhere = chain; + qb.groupBy = chain; + qb.setParameters = (p: Record) => (captured && Object.assign(captured.params, p), qb); + qb.getRawMany = () => Promise.resolve(rows); + qb.getRawOne = () => Promise.resolve(null); + return qb; + } + + beforeEach(async () => { + qbStub = {}; + + ledgerAccountRepository = createMock(); + ledgerLegRepository = createMock(); + reconciliationService = createMock(); + liquidityManagementBalanceService = createMock(); + markService = createMock(); + logService = createMock(); + + jest.spyOn(ledgerLegRepository, 'createQueryBuilder').mockImplementation(() => legQb()); + jest.spyOn(ledgerLegRepository, 'find').mockResolvedValue([]); + jest.spyOn(ledgerAccountRepository, 'find').mockResolvedValue([]); + jest.spyOn(ledgerAccountRepository, 'findOneBy').mockResolvedValue(null); + jest.spyOn(liquidityManagementBalanceService, 'getBalances').mockResolvedValue([]); + // default marks: mark 1 for every asset id used in the FRESH ok/diff tests → the native diff values the same in + // CHF (mark 1), so the pre-existing "diff 50 > tol 1" lands on diff and the "diff 0" cases on ok + jest.spyOn(markService, 'preload').mockResolvedValue(markCache({ 11: 1, 12: 1, 13: 1, 14: 1, 100: 1 })); + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([]); + // default: fresh feed (real classifyFeed reused via the actual reconciliation service in targeted tests) + jest + .spyOn(reconciliationService, 'classifyFeed') + .mockReturnValue({ status: FeedStatus.FRESH } as ReturnType); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LedgerQueryService, + TestUtil.provideConfig({ ledger: { reconciliationToleranceChf: 1 } }), + { provide: LedgerAccountRepository, useValue: ledgerAccountRepository }, + { provide: LedgerLegRepository, useValue: ledgerLegRepository }, + { provide: LedgerReconciliationService, useValue: reconciliationService }, + { provide: LiquidityManagementBalanceService, useValue: liquidityManagementBalanceService }, + { provide: LedgerMarkService, useValue: markService }, + { provide: LogService, useValue: logService }, + ], + }).compile(); + + service = module.get(LedgerQueryService); + }); + + it('is defined', () => { + expect(service).toBeDefined(); + }); + + describe('getAccounts', () => { + it('aggregates native + chf balances per account and attaches the recon snapshot for ASSET accounts', async () => { + const asset = assetAccount(5, 100, 'Binance/EUR'); + const liability = createCustomLedgerAccount({ + id: 6, + name: 'LIABILITY/buyFiat-received', + type: AccountType.LIABILITY, + currency: 'CHF', + }); + jest.spyOn(ledgerAccountRepository, 'find').mockResolvedValue([asset, liability]); + jest + .spyOn(liquidityManagementBalanceService, 'getBalances') + .mockResolvedValue([feed(100, 1000, new Date('2026-06-10T05:00:00.000Z'))]); + qbStub.balancesByAccount = [ + { accountId: 5, native: '1000.5', chf: '950.25' }, + { accountId: 6, native: '-500', chf: '-500' }, + ]; + + const res = await service.getAccounts(undefined, new Date('2026-06-11T00:00:00.000Z')); + + const assetDto = res.accounts.find((a) => a.accountId === 5); + expect(assetDto.balanceNative).toBe(1000.5); + expect(assetDto.balanceChf).toBe(950.25); + // diff = 1000.5 − 1000 = 0.5 ≤ tolerance(1) → ok + expect(assetDto.reconStatus).toBe(LedgerReconStatus.OK); + + const liabilityDto = res.accounts.find((a) => a.accountId === 6); + expect(liabilityDto.balanceChf).toBe(-500); + // non-ASSET accounts carry no feed → no recon snapshot + expect(liabilityDto.reconStatus).toBeUndefined(); + }); + + it('flags an ASSET account with a feed diff above tolerance as diff', async () => { + const asset = assetAccount(5, 100, 'Binance/EUR'); + jest.spyOn(ledgerAccountRepository, 'find').mockResolvedValue([asset]); + jest + .spyOn(liquidityManagementBalanceService, 'getBalances') + .mockResolvedValue([feed(100, 100, new Date('2026-06-10T05:00:00.000Z'))]); + qbStub.balancesByAccount = [{ accountId: 5, native: '150', chf: '150' }]; + + const res = await service.getAccounts(); + + expect(res.accounts[0].reconStatus).toBe(LedgerReconStatus.DIFF); // diff 50 > tolerance 1 + expect(res.accounts[0].reconDiff).toBe(50); + }); + + // §7 unit fix (F3): a crypto ASSET account with a SMALL native diff whose CHF value (× mark) exceeds the CHF + // tolerance must flag `diff` — the old code compared the native diff directly against the CHF tolerance. + it('flags a crypto ASSET account whose small native diff exceeds the tolerance once valued in CHF (mark)', async () => { + const btc = assetAccount(8, 200, 'Kraken/BTC'); + jest.spyOn(ledgerAccountRepository, 'find').mockResolvedValue([btc]); + jest + .spyOn(liquidityManagementBalanceService, 'getBalances') + .mockResolvedValue([feed(200, 1.0, new Date('2026-06-10T05:00:00.000Z'))]); + jest.spyOn(reconciliationService, 'classifyFeed').mockReturnValue({ status: FeedStatus.FRESH } as any); + jest.spyOn(markService, 'preload').mockResolvedValue(markCache({ 200: 50000 })); // large BTC mark + qbStub.balancesByAccount = [{ accountId: 8, native: '1.001', chf: '50050' }]; // native diff 0.001 vs feed 1.0 + + const res = await service.getAccounts(); + + // native diff 0.001 × mark 50000 = 50 CHF > tolerance 1 → diff (the native diff alone would be ≤ 1 → wrongly ok) + expect(res.accounts[0].reconStatus).toBe(LedgerReconStatus.DIFF); + expect(res.accounts[0].reconDiff).toBe(0.001); // the reported difference stays NATIVE + }); + + it('defaults missing-balance accounts to 0', async () => { + const asset = assetAccount(5, 100, 'Binance/EUR'); + jest.spyOn(ledgerAccountRepository, 'find').mockResolvedValue([asset]); + qbStub.balancesByAccount = []; + + const res = await service.getAccounts(); + + expect(res.accounts[0].balanceNative).toBe(0); + expect(res.accounts[0].balanceChf).toBe(0); + }); + + // reconSnapshot lastVerified (L339): `result.staleness === 'fresh' ? feedTimestamp : undefined` — the NON-fresh + // side. A STALE ASSET account that HAS a feed (timestamp present) must STILL yield lastVerified=undefined because + // staleness !== 'fresh'. A broken comparator that always returned feedTimestamp would leak the stale timestamp. + it('leaves lastVerified undefined for a STALE ASSET account even though its feed has a timestamp (L339 non-fresh)', async () => { + const asset = assetAccount(5, 100, 'Olkypay/EUR'); + jest.spyOn(ledgerAccountRepository, 'find').mockResolvedValue([asset]); + jest + .spyOn(liquidityManagementBalanceService, 'getBalances') + .mockResolvedValue([feed(100, 100, new Date('2026-06-01T05:00:00.000Z'))]); + jest.spyOn(reconciliationService, 'classifyFeed').mockReturnValue({ status: FeedStatus.STALE } as any); + qbStub.balancesByAccount = [{ accountId: 5, native: '120', chf: '120' }]; + + const res = await service.getAccounts(); + + const dto = res.accounts.find((a) => a.accountId === 5); + expect(dto.reconStatus).toBe(LedgerReconStatus.STALE); // mapReconStatus(STALE, …) → stale + expect(dto.reconDiff).toBe(20); // ledger 120 − feed 100 + expect(dto.lastVerified).toBeUndefined(); // staleness stale → non-fresh side of L339 + }); + }); + + describe('getReconStatus', () => { + it('maps fresh / stale / missing feeds to the right staleness + status', async () => { + const fresh = assetAccount(1, 11, 'Binance/EUR'); + const stale = assetAccount(2, 12, 'Olkypay/EUR'); + const missing = assetAccount(3, 13, 'Kraken/BTC'); + jest.spyOn(ledgerAccountRepository, 'find').mockResolvedValue([fresh, stale, missing]); + jest + .spyOn(liquidityManagementBalanceService, 'getBalances') + .mockResolvedValue([ + feed(11, 100, new Date('2026-06-10T05:00:00.000Z')), + feed(12, 100, new Date('2026-06-01T05:00:00.000Z')), + ]); + jest.spyOn(reconciliationService, 'classifyFeed').mockImplementation((balance) => { + if (!balance) return { status: FeedStatus.NO_FEED } as any; + return { + status: balance.asset.id === 12 ? FeedStatus.STALE : FeedStatus.FRESH, + } as any; + }); + // nativeBalanceByAccount → 100 for every account id (fresh: journal 100 vs feed 100 → diff 0 → ok) + jest.spyOn(service as any, 'nativeBalanceByAccount').mockResolvedValue( + new Map([ + [1, 100], + [2, 100], + [3, 100], + ]), + ); + + const res = await service.getReconStatus(); + + expect(res.runAt).toBeDefined(); + const byId = new Map(res.accounts.map((a) => [a.accountId, a])); + expect(byId.get(1).staleness).toBe(LedgerFeedStaleness.FRESH); + expect(byId.get(1).status).toBe(LedgerReconResultStatus.OK); + expect(byId.get(2).staleness).toBe(LedgerFeedStaleness.STALE); + expect(byId.get(2).status).toBe(LedgerReconResultStatus.STALE); + expect(byId.get(3).staleness).toBe(LedgerFeedStaleness.MISSING); + expect(byId.get(3).status).toBe(LedgerReconResultStatus.UNVERIFIED); + expect(byId.get(3).externalFeedBalance).toBe(0); + }); + + it('skips accounts without an assetId', async () => { + const noAsset = createCustomLedgerAccount({ + id: 9, + name: 'ROUNDING', + type: AccountType.ASSET, + assetId: undefined, + }); + jest.spyOn(ledgerAccountRepository, 'find').mockResolvedValue([noAsset]); + + const res = await service.getReconStatus(); + + expect(res.accounts).toHaveLength(0); + }); + + // mapStaleness PLACEHOLDER branch (line 370): a 1.0-placeholder feed → staleness 'placeholder', status 'unverified' + // (mapReconStatus: non-FRESH and non-STALE → unverified). The fresh/stale/missing test never hits PLACEHOLDER. + it('maps a placeholder feed to staleness "placeholder" and status "unverified"', async () => { + const placeholder = assetAccount(4, 14, 'Base/ZCHF'); + jest.spyOn(ledgerAccountRepository, 'find').mockResolvedValue([placeholder]); + jest + .spyOn(liquidityManagementBalanceService, 'getBalances') + .mockResolvedValue([feed(14, 1.0, new Date('2026-06-10T05:00:00.000Z'))]); + jest.spyOn(reconciliationService, 'classifyFeed').mockReturnValue({ status: FeedStatus.PLACEHOLDER } as any); + jest.spyOn(service as any, 'nativeBalanceByAccount').mockResolvedValue(new Map([[4, 0]])); + + const res = await service.getReconStatus(); + + const acc = res.accounts.find((a) => a.accountId === 4); + expect(acc).toBeDefined(); + expect(acc.staleness).toBe(LedgerFeedStaleness.PLACEHOLDER); // line 370 + expect(acc.status).toBe(LedgerReconResultStatus.UNVERIFIED); // placeholder → not fresh, not stale → unverified + }); + }); + + describe('getSuspense', () => { + it('sums chf and maps each suspense leg with its age', async () => { + // generic untracked-bank-EUR SUSPENSE account: the test only exercises sum/age mapping. The fixture amounts are + // deliberately small, non-calibrated values — NOT the real ~600k untracked-sweep volume — so no sensitive + // bank↔volume correlation is committed to the public repo (Minor R12-1; that calibration lives in analysis-docs). + const account = createCustomLedgerAccount({ + id: 3, + name: 'SUSPENSE/untracked-bank-EUR', + type: AccountType.SUSPENSE, + currency: 'EUR', + }); + const legA = makeLeg({ id: 1, amount: 5000, amountChf: 4800, account }, account, { + bookingDate: new Date('2026-06-01T00:00:00.000Z'), + }); + const legB = makeLeg({ id: 2, amount: 1000, amountChf: 950, account }, account, { + bookingDate: new Date('2026-06-05T00:00:00.000Z'), + }); + qbStub.suspenseLegs = [legA, legB]; + + const res = await service.getSuspense(); + + expect(res.totalChf).toBe(5750); + expect(res.legs).toHaveLength(2); + expect(res.legs[0].currency).toBe('EUR'); + expect(res.legs[0].age).toBeGreaterThan(0); + }); + + it('treats null amountChf as 0 in the total', async () => { + const account = createCustomLedgerAccount({ + id: 3, + name: 'SUSPENSE', + type: AccountType.SUSPENSE, + currency: 'CHF', + }); + const legA = makeLeg({ id: 1, amount: 10, amountChf: undefined, account }, account); + qbStub.suspenseLegs = [legA]; + + const res = await service.getSuspense(); + + expect(res.totalChf).toBe(0); + }); + }); + + describe('getMargin', () => { + it('splits INCOME vs EXPENSE spread accounts by type and isolates otherOpex + fxPnl (Minor R12-4 / Major R7-2)', async () => { + // INCOME accounts carry Cr (negative) chf; EXPENSE accounts Dr (positive) chf + qbStub.marginRows = [ + { bucket: '2026-06-07', type: AccountType.INCOME, name: 'INCOME/fee-buyFiat', chf: '-148.50' }, + { bucket: '2026-06-07', type: AccountType.INCOME, name: 'INCOME/spread-Scrypt', chf: '-10' }, // maker rebate + { bucket: '2026-06-07', type: AccountType.EXPENSE, name: 'EXPENSE/spread-Binance', chf: '20' }, + { bucket: '2026-06-07', type: AccountType.EXPENSE, name: 'EXPENSE/network-fee', chf: '5' }, + { bucket: '2026-06-07', type: AccountType.EXPENSE, name: 'EXPENSE/refReward', chf: '30' }, + { bucket: '2026-06-07', type: AccountType.EXPENSE, name: 'EXPENSE/extraordinary', chf: '7' }, + { bucket: '2026-06-07', type: AccountType.INCOME, name: 'INCOME/fx-revaluation', chf: '-12' }, + { bucket: '2026-06-07', type: AccountType.EXPENSE, name: 'EXPENSE/fx-revaluation', chf: '4' }, + ]; + + const res = await service.getMargin(new Date('2026-06-01'), new Date('2026-06-30'), true); + + const day = res.periods[0]; + expect(day.feeIncome).toBe(158.5); // 148.50 fee + 10 rebate (both INCOME, sign-flipped) + expect(day.executionCosts).toBe(25); // spread-Binance 20 + network-fee 5 (NOT refReward/extraordinary/fx) + expect(day.otherOpex).toBe(37); // refReward 30 + extraordinary 7 + expect(day.fxPnl).toBe(8); // INCOME fx 12 − EXPENSE fx 4 (net gain) + expect(day.realizedMargin).toBe(133.5); // 158.50 − 25 + expect(res.totalFeeIncome).toBe(158.5); + expect(res.totalRealizedMargin).toBe(133.5); + expect(res.totalOtherOpex).toBe(37); + }); + + it('does not double-count the EXPENSE spread-arbitrage into feeIncome', async () => { + qbStub.marginRows = [ + { bucket: 'all', type: AccountType.INCOME, name: 'INCOME/trading', chf: '-100' }, + { bucket: 'all', type: AccountType.EXPENSE, name: 'EXPENSE/spread-arbitrage', chf: '40' }, + { bucket: 'all', type: AccountType.INCOME, name: 'INCOME/spread-arbitrage', chf: '-5' }, + ]; + + const res = await service.getMargin(undefined, undefined, false); + + const period = res.periods[0]; + expect(period.feeIncome).toBe(105); // trading 100 + INCOME/spread-arbitrage 5 only + expect(period.executionCosts).toBe(40); // EXPENSE/spread-arbitrage only + }); + + // default-arg (L187): getMargin(from?, to?, dailySample = true) — omit the 3rd arg so the default-param + // initializer (`= true`) is exercised. The call must still produce a correct daily period: a day-bucketed INCOME + // row contributes its sign-flipped magnitude to feeIncome (a sign flip would yield −42, not 42). + it('runs with the default dailySample when called with only from/to (L187 default param)', async () => { + qbStub.marginRows = [{ bucket: '2026-06-07', type: AccountType.INCOME, name: 'INCOME/fee-buyFiat', chf: '-42' }]; + + const res = await service.getMargin(new Date('2026-06-01'), new Date('2026-06-30')); + + expect(res.periods).toHaveLength(1); + expect(res.periods[0].date).toBe('2026-06-07'); // bucketKey normalised day key + expect(res.periods[0].feeIncome).toBe(42); // INCOME −42 → +42 (sign-flipped magnitude) + expect(res.totalFeeIncome).toBe(42); + }); + + // F2: EXPENSE/acquirer-fee lands in executionCosts EXACTLY ONCE and never in fxPnl — with the Checkout/{ccy} + // native-gross convention the mark-to-market job books no phantom */fx-revaluation for the acquirer fee, so the + // margin report carries the fee in one bucket only (pre-fix it showed up in executionCosts AND fxPnl). + it('buckets EXPENSE/acquirer-fee into executionCosts exactly once (never fxPnl)', async () => { + qbStub.marginRows = [ + { bucket: '2026-06-07', type: AccountType.EXPENSE, name: 'EXPENSE/network-fee', chf: '5' }, + { bucket: '2026-06-07', type: AccountType.EXPENSE, name: 'EXPENSE/acquirer-fee', chf: '7' }, + ]; + + const res = await service.getMargin(new Date('2026-06-01'), new Date('2026-06-30'), true); + + const day = res.periods[0]; + expect(day.executionCosts).toBe(12); // network-fee 5 + acquirer-fee 7 → the fee counted exactly once + expect(day.fxPnl).toBe(0); // and NOT mirrored into fxPnl (no phantom fx posting exists for it) + expect(res.totalExecutionCosts).toBe(12); + }); + + // dailySample with TWO day buckets returned out of order → the periods MUST come back sorted ascending by date + // (covers the marginBuckets `.sort((a,b) => a.date.localeCompare(b.date))` comparator, which a single bucket never + // invokes). A broken/removed sort would leave them in insertion (reverse) order and fail this. + it('returns daily margin periods sorted ascending by date (multi-bucket sort)', async () => { + qbStub.marginRows = [ + { bucket: '2026-06-09', type: AccountType.INCOME, name: 'INCOME/fee-buyFiat', chf: '-20' }, // later day first + { bucket: '2026-06-07', type: AccountType.INCOME, name: 'INCOME/fee-buyFiat', chf: '-10' }, + { bucket: '2026-06-08', type: AccountType.EXPENSE, name: 'EXPENSE/network-fee', chf: '5' }, + ]; + + const res = await service.getMargin(new Date('2026-06-01'), new Date('2026-06-30'), true); + + expect(res.periods.map((p) => p.date)).toEqual(['2026-06-07', '2026-06-08', '2026-06-09']); // ascending, sorted + expect(res.periods[0].feeIncome).toBe(10); // 2026-06-07 bucket + expect(res.periods[2].feeIncome).toBe(20); // 2026-06-09 bucket + }); + }); + + describe('getEquityComparison', () => { + it('computes journalEquity, difference and the four-bucket decomposition from ONE day-grouped query', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([financeLog('2026-06-10T00:00:00.000Z', 20000)]); + // one unverified account → the stale-feed CASE is active in the grouped query + const unverified = assetAccount(2, 12, 'Olkypay/EUR'); + jest.spyOn(ledgerAccountRepository, 'find').mockResolvedValue([unverified]); + jest.spyOn(liquidityManagementBalanceService, 'getBalances').mockResolvedValue([]); + jest.spyOn(reconciliationService, 'classifyFeed').mockReturnValue({ status: FeedStatus.NO_FEED } as any); + jest + .spyOn(ledgerLegRepository, 'createQueryBuilder') + .mockReturnValue( + equityGroupedQb([{ day: '2026-06-10', equity: '19000', transit: '-500', stale: '-200', spread: '-100' }]), + ); + + const res = await service.getEquityComparison(new Date('2026-06-01'), true); + + const period = res.periods[0]; + expect(period.journalEquity).toBe(19000); + expect(period.financialDataLogTotal).toBe(20000); + expect(period.difference).toBe(-1000); // 19000 − 20000 + // other = difference − (transit + stale + spread) = −1000 − (−800) = −200 + expect(period.decomposition.transitPhantom).toBe(-500); + expect(period.decomposition.staleFeed).toBe(-200); + expect(period.decomposition.spreadFees).toBe(-100); + expect(period.decomposition.other).toBe(-200); + const { transitPhantom, staleFeed, spreadFees, other } = period.decomposition; + expect(Util.round(transitPhantom + staleFeed + spreadFees + other, 2)).toBe(period.difference); + }); + + it('skips logs without a totalBalanceChf', async () => { + const broken = Object.assign(new Log(), { id: 2, created: new Date('2026-06-10'), message: '{ not json' }); + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([broken]); + jest.spyOn(ledgerLegRepository, 'createQueryBuilder').mockReturnValue(equityGroupedQb([])); + + const res = await service.getEquityComparison(new Date('2026-06-01')); + + expect(res.periods).toHaveLength(0); + }); + + // Finding 5(a): `from` is mandatory — a missing `from` used to scan the ENTIRE ledger history per period. Fail loud. + it('rejects a missing from with BadRequest (no unbounded full-history scan)', async () => { + await expect(service.getEquityComparison(undefined, true)).rejects.toBeInstanceOf(BadRequestException); + }); + + // Finding 5(a): the [from, now] range is capped server-side (MAX_EQUITY_COMPARISON_RANGE_DAYS = 92) + it('rejects a from further back than the 92-day range cap', async () => { + await expect(service.getEquityComparison(Util.daysBefore(120), true)).rejects.toBeInstanceOf(BadRequestException); + }); + + // Finding 5: no logs in range → empty periods AND no leg query at all (early return before the grouped scan) + it('returns empty periods and runs no leg query when there are no logs in range', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([]); + const qbSpy = jest.spyOn(ledgerLegRepository, 'createQueryBuilder'); + + const res = await service.getEquityComparison(new Date('2026-06-01')); + + expect(res.periods).toHaveLength(0); + expect(qbSpy).not.toHaveBeenCalled(); + }); + + // Finding 5(b): the feed read (via unverifiedAccountIds) happens ONCE for the whole comparison, not once per period + it('reads the feed exactly once regardless of the period count (perf b)', async () => { + jest + .spyOn(logService, 'getFinancialLogs') + .mockResolvedValue([ + financeLog('2026-06-08T00:00:00.000Z', 100), + financeLog('2026-06-09T00:00:00.000Z', 100), + financeLog('2026-06-10T00:00:00.000Z', 100), + ]); + jest.spyOn(ledgerAccountRepository, 'find').mockResolvedValue([]); + const getBalancesSpy = jest.spyOn(liquidityManagementBalanceService, 'getBalances').mockResolvedValue([]); + jest.spyOn(ledgerLegRepository, 'createQueryBuilder').mockReturnValue(equityGroupedQb([])); + + await service.getEquityComparison(new Date('2026-06-01'), true); + + expect(getBalancesSpy).toHaveBeenCalledTimes(1); // ONE feed read for N periods (was N) + }); + + // Finding 5(c): the grouped day-deltas are turned into RUNNING cumulative totals — a later period sees the sum of + // its own day plus every earlier day, not just its own bucket. + it('accumulates the grouped day-deltas into running cumulative totals per period (perf c)', async () => { + jest + .spyOn(logService, 'getFinancialLogs') + .mockResolvedValue([financeLog('2026-06-08T12:00:00.000Z', 0), financeLog('2026-06-09T12:00:00.000Z', 0)]); + jest.spyOn(ledgerAccountRepository, 'find').mockResolvedValue([]); + jest.spyOn(liquidityManagementBalanceService, 'getBalances').mockResolvedValue([]); + jest.spyOn(ledgerLegRepository, 'createQueryBuilder').mockReturnValue( + equityGroupedQb([ + { day: '2026-06-08', equity: '100', transit: '0', stale: '0', spread: '0' }, + { day: '2026-06-09', equity: '50', transit: '0', stale: '0', spread: '0' }, + ]), + ); + + const res = await service.getEquityComparison(new Date('2026-06-01'), true); + + expect(res.periods[0].journalEquity).toBe(100); // day 08 cumulative + expect(res.periods[1].journalEquity).toBe(150); // day 08 + day 09 running cumulative + }); + + // Finding 5(c) guard: with NO unverified accounts the stale component is a literal 0 and NO unverifiedAccountIds + // parameter is bound — an empty `IN ()` list would be invalid SQL on real PG. + it('uses a literal-0 stale component (no unverified accounts) and binds no empty IN list', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([financeLog('2026-06-10T00:00:00.000Z', 500)]); + const fresh = assetAccount(1, 11, 'Binance/EUR'); + jest.spyOn(ledgerAccountRepository, 'find').mockResolvedValue([fresh]); + jest + .spyOn(liquidityManagementBalanceService, 'getBalances') + .mockResolvedValue([feed(11, 0, new Date('2026-06-10T05:00:00.000Z'))]); + jest.spyOn(reconciliationService, 'classifyFeed').mockReturnValue({ status: FeedStatus.FRESH } as any); + + const captured = { selects: [] as string[], params: {} as Record }; + jest + .spyOn(ledgerLegRepository, 'createQueryBuilder') + .mockReturnValue( + equityGroupedQb([{ day: '2026-06-10', equity: '500', transit: '0', stale: '0', spread: '0' }], captured), + ); + + const res = await service.getEquityComparison(new Date('2026-06-01'), true); + + expect(res.periods[0].decomposition.staleFeed).toBe(0); + expect(captured.selects).toContain('0'); // the literal-0 stale addSelect + expect(captured.params).not.toHaveProperty('unverifiedAccountIds'); // no empty IN list bound + }); + }); + + describe('getAccountDetail', () => { + it('throws NotFoundException for an unknown account', async () => { + jest.spyOn(ledgerAccountRepository, 'findOneBy').mockResolvedValue(null); + + await expect(service.getAccountDetail(999)).rejects.toThrow(NotFoundException); + }); + + it('maps legs with opening/closing balance and a 2-leg counter account', async () => { + const account = createCustomLedgerAccount({ + id: 5, + name: 'ASSET/bank-CHF', + type: AccountType.ASSET, + currency: 'CHF', + }); + const counter = createCustomLedgerAccount({ id: 6, name: 'LIABILITY/buyFiat-owed', type: AccountType.LIABILITY }); + jest.spyOn(ledgerAccountRepository, 'findOneBy').mockResolvedValue(account); + + const leg5 = makeLeg({ id: 1, txId: 10, accountId: 5, amount: 100, amountChf: 100, account }, account, { + id: 10, + }); + const legCounter = makeLeg({ id: 2, txId: 10, accountId: 6, amount: -100, account: counter }, counter, { + id: 10, + }); + qbStub.detailLegs = [leg5]; + qbStub.detailTotal = 1; + jest.spyOn(ledgerLegRepository, 'find').mockResolvedValue([leg5, legCounter]); + jest.spyOn(service as any, 'nativeBalanceBefore').mockResolvedValue(50); + jest.spyOn(service as any, 'nativeBalanceInPeriod').mockResolvedValue(100); + + const res = await service.getAccountDetail(5, new Date('2026-06-01'), new Date('2026-06-30'), 0); + + expect(res.accountName).toBe('ASSET/bank-CHF'); + expect(res.currency).toBe('CHF'); + expect(res.openingBalance).toBe(50); + expect(res.closingBalance).toBe(150); // 50 + 100 + expect(res.total).toBe(1); + expect(res.legs).toHaveLength(1); + expect(res.legs[0].counterAccountId).toBe(6); + expect(res.legs[0].counterAccountName).toBe('LIABILITY/buyFiat-owed'); + }); + + it('returns undefined counter account for a ≥3-leg tx (no single counterparty)', async () => { + const account = createCustomLedgerAccount({ id: 5, name: 'ASSET/bank-CHF', type: AccountType.ASSET }); + const a = createCustomLedgerAccount({ id: 6, name: 'EXPENSE/network-fee', type: AccountType.EXPENSE }); + const b = createCustomLedgerAccount({ id: 7, name: 'LIABILITY/owed', type: AccountType.LIABILITY }); + jest.spyOn(ledgerAccountRepository, 'findOneBy').mockResolvedValue(account); + + const leg5 = makeLeg({ id: 1, txId: 10, accountId: 5, amount: 100, amountChf: 100, account }, account, { + id: 10, + }); + // a 3-leg tx: account 5 + TWO counterparts → no single counter account + const legA = makeLeg({ id: 2, txId: 10, accountId: 6, amount: -60, account: a }, a, { id: 10 }); + const legB = makeLeg({ id: 3, txId: 10, accountId: 7, amount: -40, account: b }, b, { id: 10 }); + qbStub.detailLegs = [leg5]; + qbStub.detailTotal = 1; + jest.spyOn(ledgerLegRepository, 'find').mockResolvedValue([leg5, legA, legB]); + jest.spyOn(service as any, 'nativeBalanceBefore').mockResolvedValue(0); + jest.spyOn(service as any, 'nativeBalanceInPeriod').mockResolvedValue(100); + + const res = await service.getAccountDetail(5, undefined, undefined, 0); + + expect(res.legs[0].counterAccountId).toBeUndefined(); // ≥3-leg → no single counter party + }); + }); + + // --- REAL QUERY-BUILDER PATHS (the private balance helpers run against a captured getRawOne, NOT mocked away) --- // + + // A query-builder whose getRawOne returns a value keyed by what the query selects/filters, so the actual private + // helpers (nativeBalanceBefore/InPeriod, nativeBalanceByAccount, journalEquityAt, transitPhantom, staleFeed, + // spreadFees) execute their COALESCE `?? 0` rounding paths instead of being stubbed. + describe('balance helpers run against the real query-builder', () => { + // keyed resolution of getRawOne by a discriminator captured from select/where clauses + function richLegQb(resolve: (selects: string, wheres: string) => Record | null): any { + const qb: any = { _selects: [] as string[], _wheres: [] as string[] }; + const chain = () => qb; + qb.innerJoin = chain; + qb.innerJoinAndSelect = chain; + qb.select = (e: string) => (qb._selects.push(e), qb); + qb.addSelect = (e: string) => (qb._selects.push(e), qb); + qb.where = (e: string) => (qb._wheres.push(e), qb); + qb.andWhere = (e: string) => (qb._wheres.push(e), qb); + qb.groupBy = chain; + qb.addGroupBy = chain; + qb.orderBy = chain; + qb.addOrderBy = chain; + qb.skip = chain; + qb.take = chain; + qb.getRawOne = () => Promise.resolve(resolve(qb._selects.join(' '), qb._wheres.join(' '))); + qb.getRawMany = () => Promise.resolve([]); + qb.getManyAndCount = () => Promise.resolve([[], 0]); + qb.getMany = () => Promise.resolve([]); + return qb; + } + + it('getAccountDetail computes opening/closing from the real native-balance query-builder (COALESCE rounding)', async () => { + const account = createCustomLedgerAccount({ id: 5, name: 'ASSET/bank-CHF', type: AccountType.ASSET }); + jest.spyOn(ledgerAccountRepository, 'findOneBy').mockResolvedValue(account); + jest.spyOn(ledgerLegRepository, 'find').mockResolvedValue([]); + + jest.spyOn(ledgerLegRepository, 'createQueryBuilder').mockImplementation(() => + richLegQb((_selects, wheres) => { + // nativeBalanceBefore → `tx.bookingDate < :from`; nativeBalanceInPeriod → `>= :from` AND `<= :to` + if (wheres.includes('< :from')) return { native: '50.123456789' }; // opening (rounded to 8 dp) + if (wheres.includes('>= :from')) return { native: '100' }; // period native + return { native: '0' }; + }), + ); + + const res = await service.getAccountDetail(5, new Date('2026-06-01'), new Date('2026-06-30'), 0); + + expect(res.openingBalance).toBe(50.12345679); // Util.round(…, 8) on the real getRawOne value + expect(res.closingBalance).toBe(150.12345679); // opening + 100 + }); + + it('nativeBalanceBefore defaults to 0 when getRawOne returns null (no legs before the period)', async () => { + const account = createCustomLedgerAccount({ id: 5, name: 'ASSET/bank-CHF', type: AccountType.ASSET }); + jest.spyOn(ledgerAccountRepository, 'findOneBy').mockResolvedValue(account); + jest.spyOn(ledgerLegRepository, 'find').mockResolvedValue([]); + jest.spyOn(ledgerLegRepository, 'createQueryBuilder').mockImplementation(() => + richLegQb((_selects, wheres) => { + if (wheres.includes('>= :from')) return { native: '100' }; + return null; // nativeBalanceBefore getRawOne → null → ?? 0 + }), + ); + + const res = await service.getAccountDetail(5, new Date('2026-06-01'), new Date('2026-06-30'), 0); + + expect(res.openingBalance).toBe(0); // null getRawOne → 0 + expect(res.closingBalance).toBe(100); + }); + + it('getReconStatus uses the real nativeBalanceByAccount (empty getRawMany → ledger balance 0)', async () => { + const asset = assetAccount(1, 11, 'Binance/EUR'); + jest.spyOn(ledgerAccountRepository, 'find').mockResolvedValue([asset]); + jest + .spyOn(liquidityManagementBalanceService, 'getBalances') + .mockResolvedValue([feed(11, 0, new Date('2026-06-10T05:00:00.000Z'))]); + // nativeBalanceByAccount getRawMany → [] → empty map → balances.get(id) ?? 0 → 0; feed 0 → diff 0 → ok (fresh) + jest.spyOn(ledgerLegRepository, 'createQueryBuilder').mockImplementation(() => richLegQb(() => null)); + + const res = await service.getReconStatus(); + + expect(res.accounts[0].ledgerBalance).toBe(0); // account absent from the GROUP-BY map → 0 + expect(res.accounts[0].status).toBe(LedgerReconResultStatus.OK); // diff 0 ≤ tolerance + }); + + // m8: getReconStatus reads each account's journal balance from ONE GROUP-BY map (nativeBalanceByAccount), keyed + // by account.id — proving the per-account SUM(leg.amount) rows map to the right accounts (no per-account query). + it('getReconStatus maps each account journal balance from the GROUP-BY getRawMany rows (per-account, m8)', async () => { + const a1 = assetAccount(1, 11, 'Binance/EUR'); + const a2 = assetAccount(2, 12, 'Kraken/BTC'); + jest.spyOn(ledgerAccountRepository, 'find').mockResolvedValue([a1, a2]); + jest.spyOn(liquidityManagementBalanceService, 'getBalances').mockResolvedValue([]); + const qb: any = {}; + qb.select = () => qb; + qb.addSelect = () => qb; + qb.groupBy = () => qb; + // ONE GROUP-BY pass keyed by account.id → account 1 → 100, account 2 → 250 + qb.getRawMany = () => + Promise.resolve([ + { accountId: 1, native: '100' }, + { accountId: 2, native: '250' }, + ]); + jest.spyOn(ledgerLegRepository, 'createQueryBuilder').mockReturnValue(qb); + + const res = await service.getReconStatus(); + + const byId = new Map(res.accounts.map((a) => [a.accountId, a])); + expect(byId.get(1).ledgerBalance).toBe(100); // per-account journal from the GROUP-BY map + expect(byId.get(2).ledgerBalance).toBe(250); + }); + + // Finding 5(b): unverifiedAccountIds skips an ASSET account whose assetId is null (the `account.assetId == null` + // guard) BEFORE classifyFeed, and the resulting id-set is bound ONCE into the grouped stale-feed CASE. A + // genuinely-unverified (NO_FEED) account keeps the set non-empty so the stale component is aggregated, not 0. + it('skips a null-assetId ASSET account in the unverified scan and binds only real unverified ids into the grouped query', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([financeLog('2026-06-10T00:00:00.000Z', 20000)]); + + const nullAssetIdAccount = createCustomLedgerAccount({ + id: 7, + name: 'ASSET/no-asset-link', + type: AccountType.ASSET, + assetId: undefined, // → the `assetId == null` guard, skipped before classifyFeed + }); + const unverified = assetAccount(2, 12, 'Olkypay/EUR'); // NO_FEED → non-fresh → keeps the id-set non-empty + jest.spyOn(ledgerAccountRepository, 'find').mockResolvedValue([nullAssetIdAccount, unverified]); + jest.spyOn(liquidityManagementBalanceService, 'getBalances').mockResolvedValue([]); + jest.spyOn(reconciliationService, 'classifyFeed').mockReturnValue({ status: FeedStatus.NO_FEED } as any); + + const captured = { selects: [] as string[], params: {} as Record }; + jest + .spyOn(ledgerLegRepository, 'createQueryBuilder') + .mockReturnValue( + equityGroupedQb([{ day: '2026-06-10', equity: '19000', transit: '0', stale: '-200', spread: '0' }], captured), + ); + + const res = await service.getEquityComparison(new Date('2026-06-01'), true); + + expect(res.periods[0].journalEquity).toBe(19000); + expect(res.periods[0].decomposition.staleFeed).toBe(-200); // active stale-feed CASE (unverified set non-empty) + expect(captured.params.unverifiedAccountIds).toEqual([unverified.id]); // only the real one; null-assetId skipped + }); + }); + + // --- getAccounts balancesByAccount real aggregation + getMargin empty / unknown-bucket --- // + + describe('aggregation edge cases', () => { + it('getMargin returns empty totals when no INCOME/EXPENSE rows exist in the period', async () => { + qbStub.marginRows = []; + + const res = await service.getMargin(new Date('2026-06-01'), new Date('2026-06-30'), true); + + expect(res.periods).toHaveLength(0); + expect(res.totalFeeIncome).toBe(0); + expect(res.totalExecutionCosts).toBe(0); + expect(res.totalRealizedMargin).toBe(0); + }); + + it('getMargin normalises a DATE bucket value to a YYYY-MM-DD day key (dailySample driver shape)', async () => { + // a driver may return the CAST(... AS DATE) bucket as a full timestamp string → bucketKey must slice the day + qbStub.marginRows = [ + { bucket: '2026-06-07T00:00:00.000Z', type: AccountType.INCOME, name: 'INCOME/fee-buyFiat', chf: '-10' }, + ]; + + const res = await service.getMargin(new Date('2026-06-01'), new Date('2026-06-30'), true); + + expect(res.periods[0].date).toBe('2026-06-07'); // normalised day key + expect(res.periods[0].feeIncome).toBe(10); + }); + + it('getSuspense returns 0 total and no legs when there are no SUSPENSE legs', async () => { + qbStub.suspenseLegs = []; + + const res = await service.getSuspense(); + + expect(res.totalChf).toBe(0); + expect(res.legs).toHaveLength(0); + }); + }); +}); diff --git a/src/subdomains/core/accounting/services/__tests__/ledger-reconciliation.service.spec.ts b/src/subdomains/core/accounting/services/__tests__/ledger-reconciliation.service.spec.ts new file mode 100644 index 0000000000..1d2664bdea --- /dev/null +++ b/src/subdomains/core/accounting/services/__tests__/ledger-reconciliation.service.spec.ts @@ -0,0 +1,758 @@ +import { createMock } from '@golevelup/ts-jest'; +import { CronExpression } from '@nestjs/schedule'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { Process } from 'src/shared/services/process.service'; +import { DFX_CRONJOB_PARAMS, DfxCronParams } from 'src/shared/utils/cron'; +import { TestUtil } from 'src/shared/utils/test.util'; +import { Util } from 'src/shared/utils/util'; +import { LiquidityBalance } from 'src/subdomains/core/liquidity-management/entities/liquidity-balance.entity'; +import { LiquidityManagementBalanceService } from 'src/subdomains/core/liquidity-management/services/liquidity-management-balance.service'; +import { RefRewardService } from 'src/subdomains/core/referral/reward/services/ref-reward.service'; +import { Log } from 'src/subdomains/supporting/log/log.entity'; +import { LogService } from 'src/subdomains/supporting/log/log.service'; +import { MailContext, MailType } from 'src/subdomains/supporting/notification/enums'; +import { MailRequest } from 'src/subdomains/supporting/notification/interfaces'; +import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; +import { AccountType, LedgerAccount } from '../../entities/ledger-account.entity'; +import { createCustomLedgerAccount } from '../../entities/__mocks__/ledger-account.entity.mock'; +import { LedgerAccountRepository } from '../../repositories/ledger-account.repository'; +import { LedgerLegRepository } from '../../repositories/ledger-leg.repository'; +import { LedgerBookingJobService } from '../ledger-booking-job.service'; +import { LedgerMarkService } from '../ledger-mark.service'; +import { CustodyClass, FeedStatus, LedgerReconciliationService } from '../ledger-reconciliation.service'; + +interface LegQueryStub { + native?: string; // journal native balance per account (fed through the nativeBalanceByAccount map, §7.0 m8) + equityChf?: string; // journalEquity getRawOne + transit?: { id: number; name: string; native: string }[]; // checkTransitAge open-account candidates (F3) + transitLegs?: { amount: string; bookingDate: Date }[]; // openResidualSince per-account ordered legs (F3) + suspense?: { name: string; chf: string }[]; +} + +describe('LedgerReconciliationService', () => { + let service: LedgerReconciliationService; + + let jobService: LedgerBookingJobService; + let settingService: SettingService; + let logService: LogService; + let notificationService: NotificationService; + let liquidityManagementBalanceService: LiquidityManagementBalanceService; + let ledgerAccountRepository: LedgerAccountRepository; + let ledgerLegRepository: LedgerLegRepository; + let markService: LedgerMarkService; + let refRewardService: RefRewardService; + + let mails: MailRequest[]; + let legStub: LegQueryStub; + let nativeBalanceSpy: jest.SpyInstance; + + function assetAccount(assetId: number, asset?: Partial): LedgerAccount { + return createCustomLedgerAccount({ + id: 1000 + assetId, + name: `Asset/${assetId}`, + type: AccountType.ASSET, + assetId, + asset: asset ? (Object.assign(new Asset(), { id: assetId, ...asset }) as Asset) : undefined, + } as any); + } + + function balance(assetId: number, amount: number, updated: Date): LiquidityBalance { + return Object.assign(new LiquidityBalance(), { asset: { id: assetId } as Asset, amount, updated }); + } + + // the last N VALID FinancialDataLog snapshots (getLatestValidFinancialLogs), one per totalBalanceChf value + function validLogs(totals: number[]): Log[] { + return totals.map((totalBalanceChf, i) => + Object.assign(new Log(), { + id: 100 + i, + created: new Date('2026-06-11T00:00:00Z'), + valid: true, + message: JSON.stringify({ + assets: {}, + tradings: {}, + balancesByFinancialType: {}, + balancesTotal: { totalBalanceChf }, + }), + }), + ); + } + + // chainable leg query-builder stub resolving its terminal method by the captured select/where expressions + function legQb(): any { + const qb: any = { _selects: [] as string[], _wheres: [] as string[] }; + const chain = () => qb; + qb.innerJoin = chain; + qb.select = (expr: string) => { + qb._selects.push(expr); + return qb; + }; + qb.addSelect = (expr: string) => { + qb._selects.push(expr); + return qb; + }; + qb.where = (expr: string) => { + qb._wheres.push(expr); + return qb; + }; + qb.andWhere = chain; + qb.orderBy = chain; + qb.addOrderBy = chain; + qb.groupBy = chain; + qb.addGroupBy = chain; + qb.having = chain; + qb.getRawMany = () => { + const selects = qb._selects.join(' '); + if (selects.includes('bookingDate')) return Promise.resolve(legStub.transitLegs ?? []); // openResidualSince (F3) + if (selects.includes('COALESCE')) return Promise.resolve(legStub.suspense ?? []); // checkSuspense (Σ COALESCE amountChf) + if (selects.includes('SUM(leg.amount)')) return Promise.resolve(legStub.transit ?? []); // checkTransitAge candidates + return Promise.resolve([]); + }; + qb.getRawOne = () => { + const wheres = qb._wheres.join(' '); + if (wheres.includes('account.type IN')) return Promise.resolve({ chf: legStub.equityChf ?? '0' }); // journalEquity + return Promise.resolve({ native: legStub.native ?? '0' }); // journalNativeBalance + }; + return qb; + } + + beforeEach(async () => { + mails = []; + legStub = {}; + + jobService = createMock(); + settingService = createMock(); + logService = createMock(); + notificationService = createMock(); + liquidityManagementBalanceService = createMock(); + ledgerAccountRepository = createMock(); + ledgerLegRepository = createMock(); + markService = createMock(); + refRewardService = createMock(); + + jest.spyOn(jobService, 'isLedgerReady').mockResolvedValue(true); + jest.spyOn(notificationService, 'sendMail').mockImplementation((request: MailRequest) => { + mails.push(request); + return Promise.resolve(); + }); + jest.spyOn(liquidityManagementBalanceService, 'getBalances').mockResolvedValue([]); + jest.spyOn(ledgerAccountRepository, 'find').mockResolvedValue([]); + jest.spyOn(ledgerLegRepository, 'createQueryBuilder').mockImplementation(() => legQb()); + jest.spyOn(settingService, 'get').mockResolvedValue('0'); + // default: a mark of 1 CHF/native for every asset (diffChf == native diff) so the diff-alarm tests read cleanly; + // Finding-4 tests override preload with a realistic mark (e.g. BTC 52'000, meme-coin 1e-7, or no mark). + jest.spyOn(markService, 'preload').mockResolvedValue({ getMarkAt: () => 1 } as any); + jest.spyOn(refRewardService, 'getOpenRefCreditLiability').mockResolvedValue({ amountEur: 0, amountChf: 0 }); + // default: no valid snapshot → equity parity skipped (the §7.6 tests override this with real snapshots) + jest.spyOn(logService, 'getLatestValidFinancialLogs').mockResolvedValue([]); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LedgerReconciliationService, + TestUtil.provideConfig(), + { provide: LedgerBookingJobService, useValue: jobService }, + { provide: SettingService, useValue: settingService }, + { provide: LogService, useValue: logService }, + { provide: NotificationService, useValue: notificationService }, + { provide: LiquidityManagementBalanceService, useValue: liquidityManagementBalanceService }, + { provide: LedgerAccountRepository, useValue: ledgerAccountRepository }, + { provide: LedgerLegRepository, useValue: ledgerLegRepository }, + { provide: LedgerMarkService, useValue: markService }, + { provide: RefRewardService, useValue: refRewardService }, + ], + }).compile(); + + service = module.get(LedgerReconciliationService); + + // §7.0 (m8): journal native balances come from ONE GROUP-BY map (nativeBalanceByAccount). The default map yields + // legStub.native for EVERY account (mirrors the former per-account getRawOne default); the dedicated getRawMany + // test restores this spy to exercise the real GROUP-BY→map wiring. + nativeBalanceSpy = jest + .spyOn(service as any, 'nativeBalanceByAccount') + .mockImplementation(() => Promise.resolve({ get: () => Util.round(+(legStub.native ?? '0'), 8) })); + }); + + it('is defined', () => { + expect(service).toBeDefined(); + }); + + it('runs off-peak at 05:00 (1h after mark-to-market) with its own LEDGER_RECONCILIATION kill-switch', () => { + const params: DfxCronParams = Reflect.getMetadata(DFX_CRONJOB_PARAMS, LedgerReconciliationService.prototype.run); + expect(params.expression).toBe(CronExpression.EVERY_DAY_AT_5AM); + expect(params.process).toBe(Process.LEDGER_RECONCILIATION); + }); + + it('no-ops while the ledger is not ready (cutover-gate)', async () => { + jest.spyOn(jobService, 'isLedgerReady').mockResolvedValue(false); + + await service.run(); + + expect(liquidityManagementBalanceService.getBalances).not.toHaveBeenCalled(); + }); + + it('reads the feed exactly once per run (§7.0 Minor R13-2)', async () => { + jest.spyOn(logService, 'getLatestValidFinancialLogs').mockResolvedValue(validLogs([1000])); + + await service.run(); + + expect(liquidityManagementBalanceService.getBalances).toHaveBeenCalledTimes(1); + }); + + describe('staleness classification (§7.1)', () => { + const now = new Date('2026-06-11T12:00:00Z'); + + it('classifies a 1.0 placeholder feed as PLACEHOLDER (never reconcile)', () => { + const account = assetAccount(5, { blockchain: Blockchain.ETHEREUM }); + const result = service.classifyFeed(balance(5, 1.0, now), account, now); + expect(result.status).toBe(FeedStatus.PLACEHOLDER); + }); + + it('classifies a missing feed as NO_FEED', () => { + const account = assetAccount(5, { blockchain: Blockchain.ETHEREUM }); + expect(service.classifyFeed(undefined, account, now).status).toBe(FeedStatus.NO_FEED); + }); + + it('classifies a recent on-chain feed as FRESH (within 4h)', () => { + const account = assetAccount(5, { blockchain: Blockchain.ETHEREUM }); + const result = service.classifyFeed(balance(5, 123, Util.hoursBefore(2, now)), account, now); + expect(result.status).toBe(FeedStatus.FRESH); + }); + + it('classifies an old on-chain feed as STALE (beyond 4h)', () => { + const account = assetAccount(5, { blockchain: Blockchain.ETHEREUM }); + const result = service.classifyFeed(balance(5, 123, Util.hoursBefore(10, now)), account, now); + expect(result.status).toBe(FeedStatus.STALE); + }); + + it('gives a bank-custody account the 96h SEPA threshold (fresh at 50h)', () => { + const account = assetAccount(269, { bank: { id: 1 } as any }); + const result = service.classifyFeed(balance(269, 5000, Util.hoursBefore(50, now)), account, now); + expect(result.status).toBe(FeedStatus.FRESH); + expect(result.thresholdHours).toBe(96); + }); + }); + + describe('asset reconciliation + alarm suppression (§7.2/§7.3)', () => { + it('emits a tolerance-respecting diff alarm for a fresh account out of balance', async () => { + const now = new Date(); + jest + .spyOn(ledgerAccountRepository, 'find') + .mockResolvedValue([assetAccount(5, { blockchain: Blockchain.ETHEREUM })]); + jest + .spyOn(liquidityManagementBalanceService, 'getBalances') + .mockResolvedValue([balance(5, 100, Util.hoursBefore(1, now))]); + legStub.native = '150'; // journal 150 vs feed 100 → diff 50 > tolerance + + await service.run(); + + const reconMail = mails.find((m) => m.context === MailContext.LEDGER_RECONCILIATION); + expect(reconMail).toBeDefined(); + expect(reconMail.type).toBe(MailType.ERROR_MONITORING); + // suppression: a per-account/day correlationId + suppressRecurring (§7.3) + expect(reconMail.correlationId).toContain('ledger-recon-'); + expect(reconMail.options?.suppressRecurring).toBe(true); + }); + + it('aggregates unverified (stale) accounts into ONE daily alarm, no per-asset spam (§7.3)', async () => { + const now = new Date(); + jest + .spyOn(ledgerAccountRepository, 'find') + .mockResolvedValue([ + assetAccount(5, { blockchain: Blockchain.ETHEREUM }), + assetAccount(6, { blockchain: Blockchain.ETHEREUM }), + ]); + jest.spyOn(liquidityManagementBalanceService, 'getBalances').mockResolvedValue([ + balance(5, 100, Util.hoursBefore(10, now)), // stale + balance(6, 200, Util.hoursBefore(10, now)), // stale + ]); + + await service.run(); + + const reconMails = mails.filter((m) => m.context === MailContext.LEDGER_RECONCILIATION); + expect(reconMails).toHaveLength(1); // single aggregated alarm + expect(reconMails[0].correlationId).toContain('ledger-unverified-'); + }); + + it('paginates the ASSET-account universe in batches — accounts beyond the first batch ARE reconciled (Minor R13-2, MAJOR)', async () => { + const now = new Date(); + + // simulate a universe larger than backfillBatchSize by paginating: a full first page (= batchSize accounts) + // then a short final page containing the account that the OLD truncating code would never have reconciled. + // The id-watermark loop must request the second page and reconcile it. + const { Config } = await import('src/config/config'); + const size = Config.ledger.backfillBatchSize; + + const firstPage = Array.from({ length: size }, (_, i) => + assetAccount(1000 + i, { blockchain: Blockchain.ETHEREUM }), + ); + const beyondBatch = assetAccount(9999, { blockchain: Blockchain.ETHEREUM }); + + jest.spyOn(ledgerAccountRepository, 'find').mockImplementation((options: any) => { + const lastId = options?.where?.id?._value ?? options?.where?.id?.value ?? 0; + if (lastId === 0) return Promise.resolve(firstPage); // page 1 (full → loop continues) + return Promise.resolve([beyondBatch]); // page 2 (short → loop ends) + }); + + // a fresh feed for the beyond-batch account that is OUT of balance → must produce a diff alarm if reconciled + jest + .spyOn(liquidityManagementBalanceService, 'getBalances') + .mockResolvedValue([balance(9999, 100, Util.hoursBefore(1, now))]); + legStub.native = '150'; // journal 150 vs feed 100 → diff 50 > tolerance + + await service.run(); + + // the OLD code (single find, take: batchSize) would never have loaded account 9999 → no alarm; the paginated + // loop reconciles it → a per-account diff alarm proves the second page was visited. + const reconMail = mails.find( + (m) => m.context === MailContext.LEDGER_RECONCILIATION && m.correlationId?.includes('ledger-recon-'), + ); + expect(reconMail).toBeDefined(); + expect(ledgerAccountRepository.find).toHaveBeenCalledTimes(2); // two pages requested + }); + + // §7.0 line 128: an ASSET account with a null assetId is skipped in reconcileAssets (no feed key) — no alarm, + // no crash. A second real account in the same batch still reconciles, proving the loop continues past the skip. + it('skips an ASSET account with a null assetId during reconciliation (continue)', async () => { + const now = new Date(); + const noAsset = createCustomLedgerAccount({ + id: 500, + name: 'PHANTOM', + type: AccountType.ASSET, + assetId: undefined, + } as any); + const real = assetAccount(5, { blockchain: Blockchain.ETHEREUM }); + jest.spyOn(ledgerAccountRepository, 'find').mockResolvedValue([noAsset, real]); + jest + .spyOn(liquidityManagementBalanceService, 'getBalances') + .mockResolvedValue([balance(5, 100, Util.hoursBefore(1, now))]); + legStub.native = '150'; // the REAL account is out of balance → its diff alarm proves the loop didn't abort + + await service.run(); + + const reconMail = mails.find((m) => m.correlationId?.includes('ledger-recon-')); + expect(reconMail).toBeDefined(); // account 5 still reconciled (the null-assetId one was skipped, not fatal) + }); + + // §7 line 202: reconcileFreshAsset treats a null feed amount as 0 (the `balance.amount ?? 0` fallback). A fresh + // balance whose amount is null + a non-zero journal → diff = journal − 0 → alarm. + it('treats a null feed amount as 0 when computing the diff (balance.amount ?? 0)', async () => { + const now = new Date(); + jest + .spyOn(ledgerAccountRepository, 'find') + .mockResolvedValue([assetAccount(5, { blockchain: Blockchain.ETHEREUM })]); + // a present, FRESH balance whose amount is null → classifyFeed must say FRESH for reconcileFreshAsset to run; + // force FRESH via the spy, feed amount null → feedAmount 0 → diff = 150 − 0 = 150 > tolerance → alarm + const nullAmt = Object.assign(new LiquidityBalance(), { asset: { id: 5 } as any, amount: null, updated: now }); + jest.spyOn(liquidityManagementBalanceService, 'getBalances').mockResolvedValue([nullAmt]); + jest.spyOn(service, 'classifyFeed').mockReturnValue({ status: FeedStatus.FRESH } as any); + legStub.native = '150'; + + await service.run(); + + const reconMail = mails.find((m) => m.correlationId?.includes('ledger-recon-')); + expect(reconMail).toBeDefined(); + const errors = (reconMail.input as { errors: string[] }).errors; + expect(errors[0]).toContain('feed 0'); // null feed amount → 0 (the `balance.amount ?? 0` fallback) + }); + + it('does NOT alarm on a placeholder feed (§7.1)', async () => { + const now = new Date(); + jest + .spyOn(ledgerAccountRepository, 'find') + .mockResolvedValue([assetAccount(5, { blockchain: Blockchain.ETHEREUM })]); + jest.spyOn(liquidityManagementBalanceService, 'getBalances').mockResolvedValue([balance(5, 1.0, now)]); + + await service.run(); + + expect(mails.filter((m) => m.context === MailContext.LEDGER_RECONCILIATION)).toHaveLength(0); + }); + + // m3: reconcileAssets MUST eager-load account.asset (+ its bank), else classifyCustody(account.asset) sees + // undefined → every account falls to ON_CHAIN_INACTIVE (24h) and bank accounts (should be 96h BANK_ACTIVE) are + // wrongly reported unverified. Assert both the relations on the find AND the resulting BANK_ACTIVE classification. + it('loads account.asset with its bank so a bank account classifies BANK_ACTIVE (96h), not ON_CHAIN_INACTIVE (m3)', async () => { + const now = new Date(); + const bankAccount = assetAccount(269, { bank: { id: 1 } as any }); + const findSpy = jest.spyOn(ledgerAccountRepository, 'find').mockResolvedValue([bankAccount]); + // a 50h-old bank feed is FRESH under the 96h SEPA threshold; matching journal → no diff, no unverified alarm + jest + .spyOn(liquidityManagementBalanceService, 'getBalances') + .mockResolvedValue([balance(269, 5000, Util.hoursBefore(50, now))]); + legStub.native = '5000'; + + await service.run(); + + // the eager relation is the fix — without it classifyCustody(account.asset) sees undefined → ON_CHAIN_INACTIVE + expect(findSpy).toHaveBeenCalledWith(expect.objectContaining({ relations: { asset: { bank: true } } })); + // BANK_ACTIVE (96h) → the 50h feed stays FRESH → neither an unverified nor a diff alarm + expect(mails.some((m) => m.correlationId?.includes('ledger-unverified-'))).toBe(false); + expect(mails.some((m) => m.correlationId?.includes('ledger-recon-'))).toBe(false); + // and the classification itself: a bank-linked asset → BANK_ACTIVE 96h threshold (via classifyFeed) + expect(service.classifyFeed(balance(269, 5000, Util.hoursBefore(50, now)), bankAccount, now).thresholdHours).toBe( + 96, + ); + }); + }); + + // m8: the journal native balance for reconcileFreshAsset comes from ONE GROUP-BY pass (nativeBalanceByAccount), + // not a per-account SUM query inside the batched loop. The map keys on account.id (Σ leg.amount per account). + describe('journal native balance GROUP-BY map (§7.0 / m8)', () => { + it('nativeBalanceByAccount builds the journal map from ONE GROUP-BY getRawMany (per-account)', async () => { + nativeBalanceSpy.mockRestore(); // exercise the REAL helper (not the default spy) + + const qb: any = {}; + qb.select = () => qb; + qb.addSelect = () => qb; + qb.groupBy = () => qb; + qb.getRawMany = () => + Promise.resolve([ + { accountId: 1005, native: '150.123456789' }, + { accountId: 1006, native: '-2.5' }, + ]); + jest.spyOn(ledgerLegRepository, 'createQueryBuilder').mockReturnValue(qb); + + const map = await (service as any).nativeBalanceByAccount(); + + expect(map.get(1005)).toBe(Util.round(150.123456789, 8)); // Σ rounded to 8 dp + expect(map.get(1006)).toBe(-2.5); + expect(map.get(9999)).toBeUndefined(); // an account absent from the aggregate → caller falls back to ?? 0 + }); + }); + + describe('transit-age + suspense alarms (§7.4/§7.5)', () => { + it('emits a transit-overdue alarm for an open transit balance older than the threshold', async () => { + const oldDate = Util.daysBefore(10); // well beyond the 3-day default threshold + legStub.transit = [{ id: 7, name: 'TRANSIT/payout/CHF', native: '14851.5' }]; + legStub.transitLegs = [{ amount: '14851.5', bookingDate: oldDate }]; // opened 10d ago, never closed → open since then + + await service.run(); + + expect(mails.some((m) => m.context === MailContext.LEDGER_TRANSIT_OVERDUE)).toBe(true); + }); + + // F3: a churning transit route that repeatedly opens and closes must be aged from the LAST zero-crossing of its + // cumulative balance, NOT MIN(bookingDate) over all legs. A route that closed to 0 and re-opened fresh has a YOUNG + // residual → no overdue alarm, even though its very first leg is ancient (alert-fatigue fix). + it('ages a churning transit route from the last zero-crossing, not its first leg (F3)', async () => { + legStub.transit = [{ id: 8, name: 'TRANSIT/payout/CHF', native: '50' }]; // net ≠ 0 → an open candidate + legStub.transitLegs = [ + { amount: '100', bookingDate: Util.daysBefore(20) }, // opened 20d ago … + { amount: '-100', bookingDate: Util.daysBefore(19) }, // … closed to 0 the next day (that run ended) + { amount: '50', bookingDate: Util.daysBefore(1) }, // re-opened fresh 1d ago → residual age = 1d + ]; + + await service.run(); + + expect(mails.some((m) => m.context === MailContext.LEDGER_TRANSIT_OVERDUE)).toBe(false); // 1d < 3d threshold + }); + + // Finding 1 / F3 (regression): the residual age comes from ledger_tx.bookingDate — bookingDate lives on ledger_tx, + // NOT ledger_leg. openResidualSince MUST join leg.tx and select tx.bookingDate; a leg.bookingDate references a + // non-existent column and crashes EVERY reconciliation run on real PG. Also assert MIN(tx.bookingDate) is gone (F3: + // no longer an aggregate over ALL legs). Turns red on a dropped leg.tx join or a regression to leg.bookingDate. + it('reads the residual age from ledger_tx (openResidualSince joins leg.tx + tx.bookingDate, never leg.bookingDate)', async () => { + const calls = { innerJoins: [] as [string, string][], selects: [] as string[] }; + const qb: any = { _selects: [] as string[] }; + qb.innerJoin = (a: string, b: string) => { + calls.innerJoins.push([a, b]); + return qb; + }; + qb.select = (e: string) => (calls.selects.push(e), qb._selects.push(e), qb); + qb.addSelect = (e: string) => (calls.selects.push(e), qb._selects.push(e), qb); + qb.where = () => qb; + qb.orderBy = () => qb; + qb.addOrderBy = () => qb; + qb.groupBy = () => qb; + qb.addGroupBy = () => qb; + qb.having = () => qb; + // the candidate aggregate returns one open account → openResidualSince runs; its per-account query (the one + // selecting bookingDate) then returns the (empty) leg list + qb.getRawMany = () => + Promise.resolve( + qb._selects.join(' ').includes('bookingDate') ? [] : [{ id: 9, name: 'TRANSIT/x', native: '5' }], + ); + jest.spyOn(ledgerLegRepository, 'createQueryBuilder').mockReturnValue(qb); + + await (service as any).checkTransitAge(new Date()); + + expect(calls.innerJoins).toContainEqual(['leg.tx', 'tx']); // the join that makes tx.bookingDate reachable + expect(calls.innerJoins).toContainEqual(['leg.account', 'account']); + expect(calls.selects).toContain('tx.bookingDate'); // age read from ledger_tx + expect(calls.selects).not.toContain('leg.bookingDate'); // the crashing regression (no such column) + expect(calls.selects).not.toContain('MIN(tx.bookingDate)'); // F3: no longer an aggregate over ALL legs + }); + + it('emits a suspense alarm when a SUSPENSE balance exceeds its threshold', async () => { + legStub.suspense = [{ name: 'SUSPENSE', chf: '5000' }]; + // generic SUSPENSE threshold 0 → 5000 > 0 → alarm + + await service.run(); + + expect(mails.some((m) => m.context === MailContext.LEDGER_SUSPENSE)).toBe(true); + }); + + // §7.5 line 263: a 'deposit-unrouted' SUSPENSE uses the UNROUTED threshold, NOT the generic one. With the unrouted + // threshold set ABOVE the balance and the generic threshold 0, a deposit-unrouted balance must be SUPPRESSED while + // a generic SUSPENSE of the same amount alarms — proving the per-name threshold branch is taken. + it('applies the deposit-unrouted threshold (not the generic) to a deposit-unrouted SUSPENSE', async () => { + jest.spyOn(settingService, 'get').mockImplementation((key: string) => { + if (key === 'ledgerUnroutedDepositThresholdChf') return Promise.resolve('10000'); // high → suppresses + return Promise.resolve('0'); // generic threshold 0 + }); + legStub.suspense = [ + { name: 'SUSPENSE/Scrypt-deposit-unrouted/EUR', chf: '5000' }, // 5000 < 10000 → suppressed + ]; + + await service.run(); + + expect(mails.some((m) => m.context === MailContext.LEDGER_SUSPENSE)).toBe(false); // unrouted threshold applied + }); + + // §7.5 line 266: SUSPENSE balances exist but ALL are within their thresholds → no alarm (the `!alarms.length` exit). + it('emits no suspense alarm when every SUSPENSE balance is within its threshold', async () => { + jest.spyOn(settingService, 'get').mockResolvedValue('10000'); // both thresholds high + legStub.suspense = [{ name: 'SUSPENSE', chf: '5000' }]; // 5000 < 10000 → no alarm + + await service.run(); + + expect(mails.some((m) => m.context === MailContext.LEDGER_SUSPENSE)).toBe(false); + }); + }); + + describe('equity parity (§7.6)', () => { + it('computes journalEquity as the signed balance-account sum, no leading minus (R8-1)', async () => { + // assert the COMPUTED value directly via the private journalEquity() helper (not just a logged string): a leading + // minus / sign flip (R8-1) or a wrong COALESCE would change this number. The query is disambiguated by its + // 'account.type IN' where clause in legQb → returns equityChf. + legStub.equityChf = '16050.005'; // also proves the Util.round(_, 2) on the raw sum + const journalEquity = await (service as any).journalEquity(); + expect(journalEquity).toBe(16050.01); // round(16050.005, 2), positive (sign-consistent with totalBalanceChf) + }); + + // Finding 2(d): the baseline is the MEDIAN of the last valid snapshots, NOT the single latest one — a lone + // ±snapshot-skew spike (here 130'000) must NOT move the baseline. Finding 3: the open RefCredit liability is + // folded into the baseline and reported as its own component. adjustedDifference = journalEquity − (median + refCredit). + it('compares against the median of the last valid snapshots (spike-immune) and folds in openRefCredit', async () => { + // last 5 valid totals incl. a transient +130k skew spike → median = 16005 (spike ignored) + jest + .spyOn(logService, 'getLatestValidFinancialLogs') + .mockResolvedValue(validLogs([16000, 130000, 16010, 15990, 16005])); + jest.spyOn(refRewardService, 'getOpenRefCreditLiability').mockResolvedValue({ amountEur: 38, amountChf: 40 }); + legStub.equityChf = '16050'; + const logSpy = jest.spyOn(service['logger'], 'info'); + + await service.run(); + + const msg = logSpy.mock.calls.find((c) => c[0].includes('equity parity'))![0] as string; + const median = Number(/medianTotalBalanceChf (-?\d+(?:\.\d+)?)/.exec(msg)![1]); + const refCredit = Number(/openRefCreditChf (-?\d+(?:\.\d+)?)/.exec(msg)![1]); + const adjusted = Number(/adjustedDifference (-?\d+(?:\.\d+)?)/.exec(msg)![1]); + expect(median).toBe(16005); // median of [15990,16000,16005,16010,130000] — the 130k spike does NOT skew it + expect(refCredit).toBe(40); + expect(adjusted).toBe(Util.round(16050 - (16005 + 40), 2)); // journalEquity − (median + refCredit) = 5 + expect(adjusted).toBe(5); + + // threshold defaults to 0 → |5| > 0 → a LEDGER_EQUITY_PARITY alarm with day-key suppression fires + const alarm = mails.find((m) => m.context === MailContext.LEDGER_EQUITY_PARITY); + expect(alarm).toBeDefined(); + expect(alarm.type).toBe(MailType.ERROR_MONITORING); + expect(alarm.correlationId).toContain('ledger-equity-parity-'); + expect(alarm.options?.suppressRecurring).toBe(true); + const errors = (alarm.input as { errors: string[] }).errors; + expect(errors.some((e) => e.includes('openRefCreditChf 40'))).toBe(true); + expect(errors.some((e) => e.includes('adjustedDifference 5'))).toBe(true); + }); + + // Finding 3: a book that differs from the log EXACTLY by the open ref credit is fully explained → adjustedDifference + // 0 → NO alarm. Without folding in RefCredit the raw difference would be 40 and this would be a permanent false alarm. + it('does NOT alarm when the book differs from the log solely by the open RefCredit liability', async () => { + jest.spyOn(logService, 'getLatestValidFinancialLogs').mockResolvedValue(validLogs([16000])); + jest.spyOn(refRewardService, 'getOpenRefCreditLiability').mockResolvedValue({ amountEur: 38, amountChf: 40 }); + legStub.equityChf = '16040'; // = median(16000) + refCredit(40) → adjustedDifference 0 + const logSpy = jest.spyOn(service['logger'], 'info'); + + await service.run(); + + expect(mails.some((m) => m.context === MailContext.LEDGER_EQUITY_PARITY)).toBe(false); + const msg = logSpy.mock.calls.find((c) => c[0].includes('equity parity'))![0] as string; + expect(Number(/adjustedDifference (-?\d+(?:\.\d+)?)/.exec(msg)![1])).toBe(0); + }); + + // Finding 2(b/c): the alarm is threshold-gated via the 'ledgerEquityParityThresholdChf' runtime setting (analog + // ledgerSuspenseThresholdChf). A difference within the threshold logs but does NOT alarm. + it('suppresses the alarm when the adjusted difference is within the runtime threshold', async () => { + jest.spyOn(settingService, 'get').mockImplementation((key: string) => { + if (key === 'ledgerEquityParityThresholdChf') return Promise.resolve('100'); // high → suppresses + return Promise.resolve('0'); + }); + jest.spyOn(logService, 'getLatestValidFinancialLogs').mockResolvedValue(validLogs([16000])); + jest.spyOn(refRewardService, 'getOpenRefCreditLiability').mockResolvedValue({ amountEur: 0, amountChf: 0 }); + legStub.equityChf = '16050'; // adjustedDifference 50 ≤ threshold 100 → no alarm + + await service.run(); + + expect(mails.some((m) => m.context === MailContext.LEDGER_EQUITY_PARITY)).toBe(false); + }); + + it('skips the parity check when there is no valid FinancialDataLog snapshot', async () => { + jest.spyOn(logService, 'getLatestValidFinancialLogs').mockResolvedValue([]); + const logSpy = jest.spyOn(service['logger'], 'info'); + + await service.run(); + + expect(logSpy.mock.calls.find((c) => c[0].includes('equity parity'))).toBeUndefined(); + expect(mails.some((m) => m.context === MailContext.LEDGER_EQUITY_PARITY)).toBe(false); + expect(refRewardService.getOpenRefCreditLiability).not.toHaveBeenCalled(); // returned before the refCredit read + }); + + it('skips the parity check when every snapshot lacks a totalBalanceChf', async () => { + const noTotal = Object.assign(new Log(), { + id: 2, + created: new Date('2026-06-11T00:00:00Z'), + valid: true, + message: JSON.stringify({ assets: {}, tradings: {}, balancesByFinancialType: {}, balancesTotal: {} }), + }); + jest.spyOn(logService, 'getLatestValidFinancialLogs').mockResolvedValue([noTotal]); + const logSpy = jest.spyOn(service['logger'], 'info'); + + await service.run(); + + expect(logSpy.mock.calls.find((c) => c[0].includes('equity parity'))).toBeUndefined(); + expect(mails.some((m) => m.context === MailContext.LEDGER_EQUITY_PARITY)).toBe(false); + }); + }); + + // Finding 4: the native journal↔feed diff is valued in CHF (× mark) BEFORE the CHF-tolerance compare. A native + // tolerance is ~52'000× too loose for BTC and far too tight for meme-coins. No mark → unverified, never silent 0. + describe('reconciliation diff unit conversion via mark (§7 / Finding 4)', () => { + it('alarms on a tiny native BTC diff once valued in CHF (0.001 × 52 000 = 52 CHF > tolerance)', async () => { + const now = new Date(); + jest + .spyOn(ledgerAccountRepository, 'find') + .mockResolvedValue([assetAccount(5, { blockchain: Blockchain.BITCOIN })]); + jest + .spyOn(liquidityManagementBalanceService, 'getBalances') + .mockResolvedValue([balance(5, 0, Util.hoursBefore(1, now))]); + jest.spyOn(markService, 'preload').mockResolvedValue({ getMarkAt: () => 52000 } as any); // BTC mark + legStub.native = '0.001'; // journal 0.001 vs feed 0 → 0.001 native ≈ 52 CHF + + await service.run(); + + const reconMail = mails.find((m) => m.correlationId?.includes('ledger-recon-')); + expect(reconMail).toBeDefined(); // OLD code: |0.001| ≤ 1 → no alarm (masked a 52 CHF gap); NEW: 52 CHF > 1 → alarm + const errors = (reconMail.input as { errors: string[] }).errors; + expect(errors[0]).toContain('52 CHF'); + expect(errors[0]).toContain('@ mark 52000'); + }); + + it('does NOT alarm on a large native meme-coin diff worth cents in CHF (1 000 000 × 1e-7 = 0.1 CHF ≤ tolerance)', async () => { + const now = new Date(); + jest + .spyOn(ledgerAccountRepository, 'find') + .mockResolvedValue([assetAccount(5, { blockchain: Blockchain.ETHEREUM })]); + jest + .spyOn(liquidityManagementBalanceService, 'getBalances') + .mockResolvedValue([balance(5, 0, Util.hoursBefore(1, now))]); + jest.spyOn(markService, 'preload').mockResolvedValue({ getMarkAt: () => 0.0000001 } as any); // near-worthless mark + legStub.native = '1000000'; // journal 1e6 vs feed 0 → 0.1 CHF + + await service.run(); + + // OLD code: |1e6| > 1 → false alarm; NEW: 0.1 CHF ≤ 1 → no diff alarm (and it is fresh+marked → not unverified) + expect(mails.some((m) => m.context === MailContext.LEDGER_RECONCILIATION)).toBe(false); + }); + + it('treats a fresh account with NO mark as unverified (never silently valued at 0)', async () => { + const now = new Date(); + jest + .spyOn(ledgerAccountRepository, 'find') + .mockResolvedValue([assetAccount(5, { blockchain: Blockchain.ETHEREUM })]); + jest + .spyOn(liquidityManagementBalanceService, 'getBalances') + .mockResolvedValue([balance(5, 100, Util.hoursBefore(1, now))]); + jest.spyOn(markService, 'preload').mockResolvedValue({ getMarkAt: () => undefined } as any); // no mark + legStub.native = '150'; // journal 150 vs feed 100 → would be a diff, but without a mark it can't be valued + + await service.run(); + + const unverified = mails.find((m) => m.correlationId?.includes('ledger-unverified-')); + expect(unverified).toBeDefined(); + expect((unverified.input as { errors: string[] }).errors.some((e) => e.includes('no-mark'))).toBe(true); + expect(mails.some((m) => m.correlationId?.includes('ledger-recon-'))).toBe(false); // no silent 0-valued diff alarm + }); + }); + + describe('classification + run error branches', () => { + const now = new Date('2026-06-11T12:00:00Z'); + + // §7.1: exchange-blockchain assets are EXCHANGE_ACTIVE — the only exchange class. EXCHANGE_FEEDLESS was removed + // ("no exchange balance feed" is exactly the generic NO_FEED path) and EXCHANGE_ORDER_DRIVEN too (all configured + // exchanges refresh their balances every minute, ungated — no objective order-driven criterion exists). + it('classifies a Kraken-blockchain (exchange) custody as EXCHANGE_ACTIVE (4h threshold)', () => { + const account = assetAccount(7, { blockchain: Blockchain.KRAKEN }); + const result = service.classifyFeed(balance(7, 100, Util.hoursBefore(2, now)), account, now); + expect(result.custodyClass).toBe(CustodyClass.EXCHANGE_ACTIVE); + expect(result.thresholdHours).toBe(4); + expect(result.status).toBe(FeedStatus.FRESH); + }); + + // §7.1 F3: XT/MEXC belong to the blockchain.enum.ts `// Exchanges` group — the pre-fix KRAKEN/BINANCE-only check + // misclassified them ON_CHAIN_ACTIVE (same 4h threshold by accident, but the wrong class in every alarm text). + it('classifies XT- and MEXC-blockchain assets as EXCHANGE_ACTIVE (4h threshold)', () => { + for (const blockchain of [Blockchain.XT, Blockchain.MEXC]) { + const result = service.classifyFeed( + balance(8, 100, Util.hoursBefore(2, now)), + assetAccount(8, { blockchain }), + now, + ); + expect(result.custodyClass).toBe(CustodyClass.EXCHANGE_ACTIVE); + expect(result.thresholdHours).toBe(4); + expect(result.status).toBe(FeedStatus.FRESH); + } + }); + + // §7.1 F3: OLKY_FROZEN is the frozen/dead-bank marker → BANK_DEAD: its feed never refreshes again, so the last + // snapshot counts for 7d once, then the account is permanently unverified (STALE) — never a 4h on-chain flip. + it('classifies an OLKY_FROZEN (dead bank) asset as BANK_DEAD (168h threshold, then permanently STALE)', () => { + const account = assetAccount(9, { blockchain: Blockchain.OLKY_FROZEN }); + + const withinWeek = service.classifyFeed(balance(9, 100, Util.hoursBefore(100, now)), account, now); + expect(withinWeek.custodyClass).toBe(CustodyClass.BANK_DEAD); + expect(withinWeek.thresholdHours).toBe(7 * 24); + expect(withinWeek.status).toBe(FeedStatus.FRESH); // 100h < 168h → the last dead-bank snapshot still counts + + const beyondWeek = service.classifyFeed(balance(9, 100, Util.hoursBefore(169, now)), account, now); + expect(beyondWeek.status).toBe(FeedStatus.STALE); // > 7d → unverified from here on + + const noFeed = service.classifyFeed(undefined, account, now); + expect(noFeed.status).toBe(FeedStatus.NO_FEED); + expect(noFeed.custodyClass).toBe(CustodyClass.BANK_DEAD); + }); + + // §7.1 F3: a bank-blockchain asset (blockchain.enum.ts `// Banks` group) WITHOUT a `bank` relation must still get + // the SEPA/bank threshold — pre-fix it fell through to ON_CHAIN_ACTIVE (4h) and went unverified after 4h. + it('classifies a bank-blockchain asset without a bank relation as BANK_ACTIVE (96h threshold)', () => { + const account = assetAccount(10, { blockchain: Blockchain.SUMIXX }); + const result = service.classifyFeed(balance(10, 100, Util.hoursBefore(50, now)), account, now); + expect(result.custodyClass).toBe(CustodyClass.BANK_ACTIVE); + expect(result.thresholdHours).toBe(96); + expect(result.status).toBe(FeedStatus.FRESH); // 50h < 96h + }); + + it('classifies a no-asset account as ON_CHAIN_INACTIVE (24h default)', () => { + const account = createCustomLedgerAccount({ id: 1, name: 'x', type: AccountType.ASSET, assetId: 1 } as any); + const result = service.classifyFeed(balance(1, 100, Util.hoursBefore(2, now)), account, now); + expect(result.thresholdHours).toBe(24); // ON_CHAIN_INACTIVE (asset undefined) + }); + + // run() no longer wraps reconcile in a try/catch — @DfxCron's lock layer catches + logs (CONTRIBUTING: no + // redundant try/catch in a @DfxCron method). A reconcile failure therefore propagates out of run() to that layer. + it('propagates a reconcile error out of run() (handled by @DfxCron, no redundant in-method catch)', async () => { + jest.spyOn(liquidityManagementBalanceService, 'getBalances').mockRejectedValue(new Error('feed down')); + + await expect(service.run()).rejects.toThrow('feed down'); + }); + }); +}); diff --git a/src/subdomains/core/accounting/services/consumers/__tests__/bank-tx.consumer.spec.ts b/src/subdomains/core/accounting/services/consumers/__tests__/bank-tx.consumer.spec.ts new file mode 100644 index 0000000000..160128ea95 --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/__tests__/bank-tx.consumer.spec.ts @@ -0,0 +1,1490 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { TestUtil } from 'src/shared/utils/test.util'; +import { Util } from 'src/shared/utils/util'; +import { Bank } from 'src/subdomains/supporting/bank/bank/bank.entity'; +import { BankTx, BankTxIndicator, BankTxType } from 'src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity'; +import { BankTxRepeat } from 'src/subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.entity'; +import { BankTxReturn } from 'src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.entity'; +import { Repository } from 'typeorm'; +import { LedgerTx } from '../../../entities/ledger-tx.entity'; +import { AccountType, LedgerAccount } from '../../../entities/ledger-account.entity'; +import { createCustomLedgerAccount } from '../../../entities/__mocks__/ledger-account.entity.mock'; +import { LedgerAccountService } from '../../ledger-account.service'; +import { LedgerBookingService, LedgerLegInput, LedgerTxInput } from '../../ledger-booking.service'; +import { LedgerMarkCache, LedgerMarkService } from '../../ledger-mark.service'; +import { BankTxConsumer } from '../bank-tx.consumer'; + +const eurAsset = { id: 269 } as any; + +function bankTx(values: Partial): BankTx { + return Object.assign(new BankTx(), { + id: 1, + created: new Date('2026-06-01T00:00:00Z'), + updated: new Date('2026-06-02T00:00:00Z'), // IEntity always sets updated; the §4.12 content-change scan reads it + bookingDate: new Date('2026-06-02T00:00:00Z'), + creditDebitIndicator: BankTxIndicator.CREDIT, + accountIban: 'CHF-IBAN', + amount: 1000, + ...values, + }); +} + +function account(name: string, type: AccountType, currency: string, assetId?: number): LedgerAccount { + return createCustomLedgerAccount({ id: Math.floor(Math.random() * 1e6), name, type, currency, assetId } as any); +} + +describe('BankTxConsumer', () => { + let consumer: BankTxConsumer; + let bookingService: LedgerBookingService; + let accountService: LedgerAccountService; + let markService: LedgerMarkService; + let settingService: SettingService; + let bankTxRepo: Repository; + let bankRepo: Repository; + let ledgerTxRepo: Repository; + let bankTxReturnRepo: Repository; + let bankTxRepeatRepo: Repository; + + let booked: LedgerTxInput[]; + let createdAccounts: Map; + + const chfBankAccount = account('Yapeal/CHF', AccountType.ASSET, 'CHF', 100); + const eurBankAccount = account('Olkypay/EUR', AccountType.ASSET, 'EUR', 269); + + beforeEach(async () => { + booked = []; + createdAccounts = new Map(); + + bookingService = createMock(); + accountService = createMock(); + markService = createMock(); + settingService = createMock(); + bankTxRepo = createMock>(); + bankRepo = createMock>(); + ledgerTxRepo = createMock>(); + bankTxReturnRepo = createMock>(); + bankTxRepeatRepo = createMock>(); + + // by default no cutover opening exists → BUY_CRYPTO_RETURN owed-Dr falls back to the completion CHF + jest.spyOn(ledgerTxRepo, 'findOne').mockResolvedValue(null); + jest.spyOn(settingService, 'get').mockResolvedValue(undefined); + // by default a chargeback resolves to no opening row → opening-CHF lookup falls back to the close value + jest.spyOn(bankTxReturnRepo, 'findOne').mockResolvedValue(null); + jest.spyOn(bankTxRepeatRepo, 'findOne').mockResolvedValue(null); + + jest.spyOn(bookingService, 'bookTx').mockImplementation((input: LedgerTxInput) => { + booked.push(input); + return Promise.resolve({} as any); + }); + + jest + .spyOn(accountService, 'findByAssetId') + .mockImplementation((assetId: number) => Promise.resolve(assetId === 269 ? eurBankAccount : chfBankAccount)); + jest + .spyOn(accountService, 'findByName') + .mockImplementation((name: string) => Promise.resolve(createdAccounts.get(name))); + jest + .spyOn(accountService, 'findOrCreate') + .mockImplementation((name: string, type: AccountType, currency: string) => { + const existing = createdAccounts.get(name); + if (existing) return Promise.resolve(existing); + const acc = account(name, type, currency); + createdAccounts.set(name, acc); + return Promise.resolve(acc); + }); + + // CHF mark default = 1, EUR mark default = 0.95 (overridable per test) + jest + .spyOn(markService, 'preload') + .mockResolvedValue(new LedgerMarkCache(new Map([[269, [{ created: new Date('2026-01-01'), priceChf: 0.95 }]]]))); + jest.spyOn(markService, 'getLatestMark').mockResolvedValue(undefined); // B5: no bridge by default (tests opt in) + + jest.spyOn(settingService, 'getObj').mockResolvedValue(undefined); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TestUtil.provideConfig(), + BankTxConsumer, + { provide: LedgerBookingService, useValue: bookingService }, + { provide: LedgerAccountService, useValue: accountService }, + { provide: LedgerMarkService, useValue: markService }, + { provide: SettingService, useValue: settingService }, + { provide: getRepositoryToken(BankTx), useValue: bankTxRepo }, + { provide: getRepositoryToken(Bank), useValue: bankRepo }, + { provide: getRepositoryToken(LedgerTx), useValue: ledgerTxRepo }, + { provide: getRepositoryToken(BankTxReturn), useValue: bankTxReturnRepo }, + { provide: getRepositoryToken(BankTxRepeat), useValue: bankTxRepeatRepo }, + ], + }).compile(); + + consumer = module.get(BankTxConsumer); + }); + + function mockBatch(rows: BankTx[], bank?: Partial): void { + // forward id-scan (where.id) returns the rows; the §4.12 content-change scan (where.updated MoreThan) returns [] + // so the unit tests assert only the forward booking — the reversal path is covered by its own scan tests below + jest + .spyOn(bankTxRepo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [] : rows)); + jest.spyOn(bankRepo, 'findOne').mockImplementation((opts: any) => { + const iban = opts?.where?.iban; + if (iban === 'EUR-IBAN') + return Promise.resolve( + Object.assign(new Bank(), { name: 'Olkypay', currency: 'EUR', asset: eurAsset, ...bank }), + ); + if (iban === 'UNTRACKED-IBAN') + return Promise.resolve( + Object.assign(new Bank(), { name: 'Raiffeisen', currency: 'EUR', asset: null, ...bank }), + ); + if (iban === 'CHF-IBAN') + return Promise.resolve( + Object.assign(new Bank(), { name: 'Yapeal', currency: 'CHF', asset: { id: 100 }, ...bank }), + ); + + // §4.2a representative same-currency tracked-bank lookup (currencyMarkAssetId): an untracked EUR bank borrows + // the EUR mark from a tracked EUR bank asset (here Olkypay/EUR = 269). No iban → this is the currency lookup. + if (iban == null && opts?.where?.currency === 'EUR') + return Promise.resolve(Object.assign(new Bank(), { name: 'Olkypay', currency: 'EUR', asset: eurAsset })); + + return Promise.resolve(null); + }); + } + + const cents = (legs: LedgerLegInput[]) => + legs.reduce((s, l) => s + Math.round(Math.round((l.amountChf ?? 0) * 100)), 0); + + it('is defined', () => { + expect(consumer).toBeDefined(); + }); + + it('skips BUY_FIAT rows entirely (single-booker, Blocker R4-1)', async () => { + mockBatch([bankTx({ type: BankTxType.BUY_FIAT, creditDebitIndicator: BankTxIndicator.DEBIT })]); + await consumer.process(); + expect(booked).toHaveLength(0); + }); + + it('skips TEST_FIAT_FIAT rows (mapper=null)', async () => { + mockBatch([bankTx({ type: BankTxType.TEST_FIAT_FIAT })]); + await consumer.process(); + expect(booked).toHaveLength(0); + }); + + it('books BUY_CRYPTO CRDT on a CHF bank as a 2-leg tx (no fx plug)', async () => { + const buyCrypto = { amountInChf: 1000 } as any; + mockBatch([bankTx({ type: BankTxType.BUY_CRYPTO, accountIban: 'CHF-IBAN', amount: 1000, buyCrypto })]); + await consumer.process(); + + expect(booked).toHaveLength(1); + const legs = booked[0].legs; + expect(legs).toHaveLength(2); + expect(legs[0].account).toBe(chfBankAccount); + expect(legs[0].amountChf).toBe(1000); + const received = legs[1]; + expect(received.account.name).toBe('LIABILITY/buyCrypto-received'); + expect(received.amountChf).toBe(-1000); + expect(cents(legs)).toBe(0); + }); + + it('books BUY_CRYPTO CRDT on an EUR bank as a 3-leg fx-plug tx (§4.2a)', async () => { + // EUR-mark 0.95 × 10000 = 9500 bank leg; amountInChf 9480 → plug −20 → EXPENSE/fx-revaluation + const buyCrypto = { amountInChf: 9480 } as any; + mockBatch([bankTx({ type: BankTxType.BUY_CRYPTO, accountIban: 'EUR-IBAN', amount: 10000, buyCrypto })]); + await consumer.process(); + + const legs = booked[0].legs; + expect(legs).toHaveLength(3); + expect(legs[0].account).toBe(eurBankAccount); + expect(legs[0].amountChf).toBe(9500); // mark-consistent, NOT amountInChf + expect(legs[1].account.name).toBe('LIABILITY/buyCrypto-received'); + expect(legs[1].amountChf).toBe(-9480); // base anchor + const plug = legs[2]; + expect(plug.account.name).toBe('EXPENSE/fx-revaluation'); + expect(plug.amountChf).toBe(-20); + expect(cents(legs)).toBe(0); + }); + + it('books BUY_CRYPTO on an untracked Raiffeisen bank as a 3-leg fx-plug against SUSPENSE (§4.2a SUSPENSE variant)', async () => { + // §4.2a Raiffeisen-untracked: the SUSPENSE leg is EUR-mark-valued via the representative same-currency tracked-bank + // asset (0.95 × 10000 = 9500), received = amountInChf (9480), plug = the −20 valuation residual — NOT a full-value + // phantom. The SUSPENSE leg carries assetId=null but a real CHF (the mark comes from a tracked EUR bank, not the + // SUSPENSE account itself); the plug is the small Mark↔Pricing drift, identical to the tracked-EUR-bank §4.2a case. + const buyCrypto = { amountInChf: 9480 } as any; + mockBatch([bankTx({ type: BankTxType.BUY_CRYPTO, accountIban: 'UNTRACKED-IBAN', amount: 10000, buyCrypto })]); + await consumer.process(); + + const legs = booked[0].legs; + expect(legs).toHaveLength(3); + const suspense = legs.find((l) => l.account.name === 'SUSPENSE/untracked-bank-Raiffeisen-EUR'); + expect(suspense).toBeDefined(); + expect(suspense.amount).toBe(10000); // native EUR (awaits the §4.3b sweep) + expect(suspense.amountChf).toBe(9500); // EUR-mark × amount, mark-consistent — NOT a needsMark hole + expect(suspense.needsMark).toBe(false); + const received = legs.find((l) => l.account.name === 'LIABILITY/buyCrypto-received'); + expect(received.amountChf).toBe(-9480); // base anchor (fully counted, Class-4 fix) + const plug = legs.find((l) => l.account.name?.includes('fx-revaluation')); + expect(plug.account.name).toBe('EXPENSE/fx-revaluation'); + expect(plug.amountChf).toBe(-20); // small valuation residual (Mark↔Pricing), NOT the full +9480 phantom + expect(cents(legs)).toBe(0); + }); + + // Major B5 — an untracked bank with NO same-currency tracked mark: the SUSPENSE leg has no assetId to bridge (and no + // mark anywhere), so the mixed BUY_CRYPTO tx (unvalued bank/SUSPENSE leg + valued received leg) DEFERS rather than + // handing an unbalanceable set to bookTx (never a silent plug). + it('defers BUY_CRYPTO on an untracked bank with no same-currency tracked mark (B5)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + const buyCrypto = { amountInChf: 9480 } as any; + mockBatch( + [bankTx({ type: BankTxType.BUY_CRYPTO, accountIban: 'UNTRACKED-IBAN', amount: 10000, buyCrypto })], + undefined, + ); + // override: no tracked EUR bank → the currency lookup returns null + jest.spyOn(bankRepo, 'findOne').mockImplementation((opts: any) => { + const iban = opts?.where?.iban; + if (iban === 'UNTRACKED-IBAN') + return Promise.resolve(Object.assign(new Bank(), { name: 'Raiffeisen', currency: 'EUR', asset: null })); + return Promise.resolve(null); // currency lookup finds no tracked EUR bank + }); + await consumer.process(); + + expect(booked[0]).toBeUndefined(); // deferred: unbalanceable mixed tx, nothing to bridge on the SUSPENSE leg + expect(setSpy).not.toHaveBeenCalled(); // watermark NOT advanced (retry next run) + }); + + it('books BUY_CRYPTO_RETURN on an EUR bank: owed-Dr = completion CHF, fx-plug absorbs the mark drift (§4.2a/R2-2)', async () => { + // owed was opened at completion CHF = amountInChf − totalFeeAmountChf = 9480 − 30 = 9450; the EUR-return + // settlement mark gives bank-Cr = 0.95 × 10000 = −9500 → owed must NOT be set to +9500 (that would null the + // plug). owed-Dr +9450, bank-Cr −9500 → fx-plug +50 → INCOME/fx-revaluation, owed closes to 0. + const buyCrypto = { id: 77, amountInChf: 9480, totalFeeAmountChf: 30 } as any; + mockBatch([ + bankTx({ + type: BankTxType.BUY_CRYPTO_RETURN, + creditDebitIndicator: BankTxIndicator.DEBIT, + accountIban: 'EUR-IBAN', + amount: 10000, + buyCrypto, + }), + ]); + await consumer.process(); + + const legs = booked[0].legs; + const owed = legs.find((l) => l.account.name === 'LIABILITY/buyCrypto-owed'); + expect(owed.amountChf).toBe(9450); // completion CHF (amountInChf − totalFeeAmountChf), NOT −(bank-mark) + const bank = legs.find((l) => l.account === eurBankAccount); + expect(bank.amountChf).toBe(-9500); // EUR-mark × amount (mark-consistent) + const plug = legs.find((l) => l.account.name?.includes('fx-revaluation')); + expect(plug).toBeDefined(); // the 50-CHF drift IS plugged (would be 0 with the old −(bank) owed value) + expect(plug.account.name).toBe('INCOME/fx-revaluation'); + expect(plug.amountChf).toBe(50); + expect(cents(legs)).toBe(0); + }); + + it('books BUY_CRYPTO_RETURN on a CHF bank as a 2-leg tx (drift 0, no plug)', async () => { + // CHF account: amount == amountInChf, completion CHF = 1000 − 0 = 1000, bank-Cr = −1000 → plug 0 → 2-leg + const buyCrypto = { id: 78, amountInChf: 1000, totalFeeAmountChf: 0 } as any; + mockBatch([ + bankTx({ + type: BankTxType.BUY_CRYPTO_RETURN, + creditDebitIndicator: BankTxIndicator.DEBIT, + accountIban: 'CHF-IBAN', + amount: 1000, + buyCrypto, + }), + ]); + await consumer.process(); + + const legs = booked[0].legs; + expect(legs).toHaveLength(2); + const owed = legs.find((l) => l.account.name === 'LIABILITY/buyCrypto-owed'); + expect(owed.amountChf).toBe(1000); + expect(legs.find((l) => l.account === chfBankAccount).amountChf).toBe(-1000); + expect(cents(legs)).toBe(0); + }); + + it('books KRAKEN DBIT as Dr TRANSIT/bank↔Kraken / Cr ASSET/bank (CHF, route nets to 0)', async () => { + mockBatch([ + bankTx({ + type: BankTxType.KRAKEN, + creditDebitIndicator: BankTxIndicator.DEBIT, + accountIban: 'CHF-IBAN', + amount: 500, + }), + ]); + await consumer.process(); + + const legs = booked[0].legs; + const transit = legs.find((l) => l.account.name === 'TRANSIT/bank↔Kraken/CHF'); + const bank = legs.find((l) => l.account === chfBankAccount); + expect(transit).toBeDefined(); + expect(bank).toBeDefined(); + expect(bank.amount).toBe(-500); // DBIT reduces bank + expect(transit.amount).toBe(500); + expect(cents(legs)).toBe(0); + }); + + it('books KRAKEN CRDT as Dr ASSET/bank / Cr TRANSIT/bank↔Kraken', async () => { + mockBatch([ + bankTx({ + type: BankTxType.KRAKEN, + creditDebitIndicator: BankTxIndicator.CREDIT, + accountIban: 'CHF-IBAN', + amount: 500, + }), + ]); + await consumer.process(); + const bank = booked[0].legs.find((l) => l.account === chfBankAccount); + expect(bank.amount).toBe(500); + expect(cents(booked[0].legs)).toBe(0); + }); + + it('books INTERNAL against TRANSIT/bank↔bank', async () => { + mockBatch([ + bankTx({ + type: BankTxType.INTERNAL, + creditDebitIndicator: BankTxIndicator.DEBIT, + accountIban: 'CHF-IBAN', + amount: 300, + }), + ]); + await consumer.process(); + expect(booked[0].legs.some((l) => l.account.name === 'TRANSIT/bank↔bank/CHF')).toBe(true); + expect(cents(booked[0].legs)).toBe(0); + }); + + it('books FIAT_FIAT single-row against TRANSIT/internal-fx', async () => { + mockBatch([ + bankTx({ + type: BankTxType.FIAT_FIAT, + creditDebitIndicator: BankTxIndicator.CREDIT, + accountIban: 'CHF-IBAN', + amount: 300, + }), + ]); + await consumer.process(); + expect(booked[0].legs.some((l) => l.account.name === 'TRANSIT/internal-fx/CHF')).toBe(true); + expect(cents(booked[0].legs)).toBe(0); + }); + + it('books BANK_ACCOUNT_FEE: EXPENSE/bank-fee (chargeAmountChf) / ASSET/bank, EUR-mark-consistent + fx plug', async () => { + mockBatch([ + bankTx({ + type: BankTxType.BANK_ACCOUNT_FEE, + creditDebitIndicator: BankTxIndicator.DEBIT, + accountIban: 'EUR-IBAN', + amount: 50, + chargeAmount: 50, + chargeAmountChf: 48.2, + }), + ]); + await consumer.process(); + + const legs = booked[0].legs; + const expense = legs.find((l) => l.account.name === 'EXPENSE/bank-fee'); + expect(expense.amountChf).toBe(48.2); // Pricing anchor + const bank = legs.find((l) => l.account === eurBankAccount); + expect(bank.amountChf).toBe(-47.5); // EUR-mark × chargeAmount (0.95 × 50) + const plug = legs.find((l) => l.account.name?.includes('fx-revaluation')); + expect(plug).toBeDefined(); // 0.70 CHF residual > 2c → fx plug, not ROUNDING + expect(cents(legs)).toBe(0); + }); + + it('books EXTRAORDINARY_EXPENSES: EXPENSE/extraordinary / ASSET/bank', async () => { + mockBatch([ + bankTx({ + type: BankTxType.EXTRAORDINARY_EXPENSES, + creditDebitIndicator: BankTxIndicator.DEBIT, + accountIban: 'CHF-IBAN', + amount: 200, + }), + ]); + await consumer.process(); + expect(booked[0].legs.some((l) => l.account.name === 'EXPENSE/extraordinary')).toBe(true); + expect(cents(booked[0].legs)).toBe(0); + }); + + it('books BANK_TX_RETURN CRDT: ASSET/bank / LIABILITY/bankTx-return', async () => { + mockBatch([ + bankTx({ + type: BankTxType.BANK_TX_RETURN, + creditDebitIndicator: BankTxIndicator.CREDIT, + accountIban: 'CHF-IBAN', + amount: 100, + }), + ]); + await consumer.process(); + expect(booked[0].legs.some((l) => l.account.name === 'LIABILITY/bankTx-return')).toBe(true); + expect(cents(booked[0].legs)).toBe(0); + }); + + it('books BANK_TX_REPEAT CRDT: ASSET/bank / LIABILITY/bankTx-repeat', async () => { + mockBatch([ + bankTx({ + type: BankTxType.BANK_TX_REPEAT, + creditDebitIndicator: BankTxIndicator.CREDIT, + accountIban: 'CHF-IBAN', + amount: 100, + }), + ]); + await consumer.process(); + expect(booked[0].legs.some((l) => l.account.name === 'LIABILITY/bankTx-repeat')).toBe(true); + expect(cents(booked[0].legs)).toBe(0); + }); + + it('books BANK_TX_REPEAT_CHARGEBACK DBIT: LIABILITY/bankTx-repeat / ASSET/bank', async () => { + mockBatch([ + bankTx({ + type: BankTxType.BANK_TX_REPEAT_CHARGEBACK, + creditDebitIndicator: BankTxIndicator.DEBIT, + accountIban: 'CHF-IBAN', + amount: 100, + }), + ]); + await consumer.process(); + const liability = booked[0].legs.find((l) => l.account.name === 'LIABILITY/bankTx-repeat'); + expect(liability.amountChf).toBe(100); + expect(cents(booked[0].legs)).toBe(0); + }); + + it('books BANK_TX_RETURN_CHARGEBACK DBIT with EXPENSE/bank-fee', async () => { + mockBatch([ + bankTx({ + type: BankTxType.BANK_TX_RETURN_CHARGEBACK, + creditDebitIndicator: BankTxIndicator.DEBIT, + accountIban: 'CHF-IBAN', + amount: 100, + chargeAmountChf: 5, + }), + ]); + await consumer.process(); + const legs = booked[0].legs; + expect(legs.some((l) => l.account.name === 'LIABILITY/bankTx-return')).toBe(true); + expect(legs.some((l) => l.account.name === 'EXPENSE/bank-fee' && l.amountChf === 5)).toBe(true); + expect(cents(legs)).toBe(0); + }); + + // §4.2 B-15 (Major design-accounting): the chargeback debits the liability with the CHF it was OPENED with (the + // BANK_TX_RETURN credit's CHF), NOT the chargeback-time bank-mark close value. On an EUR account where the mark + // drifted between opening and chargeback, the drift must land in fx-revaluation, NOT stay a phantom on bankTx-return. + it('BANK_TX_RETURN_CHARGEBACK on EUR uses the OPENING CHF anchor + routes the mark drift to fx-revaluation', async () => { + // the original BANK_TX_RETURN opening bank_tx #50 opened LIABILITY/bankTx-return at EUR-mark@open: 100 EUR × 0.92 = + // 92 CHF. The chargeback at EUR-mark@chargeback 0.95 would value the bank leg at −95 CHF. + jest + .spyOn(bankTxReturnRepo, 'findOne') + .mockResolvedValue(Object.assign(new BankTxReturn(), { bankTx: { id: 50 } }) as any); + const openingLeg = { account: { name: 'LIABILITY/bankTx-return' }, amountChf: -92 } as any; + jest + .spyOn(ledgerTxRepo, 'findOne') + .mockImplementation(({ where }: any) => + where?.sourceType === 'bank_tx' && where?.sourceId === '50' + ? Promise.resolve(Object.assign(new LedgerTx(), { legs: [openingLeg] }) as any) + : Promise.resolve(null), + ); + + mockBatch([ + bankTx({ + id: 51, + type: BankTxType.BANK_TX_RETURN_CHARGEBACK, + creditDebitIndicator: BankTxIndicator.DEBIT, + accountIban: 'EUR-IBAN', + amount: 100, // EUR + }), + ]); + await consumer.process(); + + const legs = booked[0].legs; + const liability = legs.find((l) => l.account.name === 'LIABILITY/bankTx-return'); + const bank = legs.find((l) => l.account.name === 'Olkypay/EUR'); + expect(liability.amountChf).toBe(92); // OPENING CHF (Dr), NOT the −(bank) close value 95 + expect(bank.amountChf).toBe(-95); // EUR-mark@chargeback × amount = 100 × 0.95 (mark-consistent for §7) + // the +92 − 95 = −3 drift must NOT vanish: it closes via an fx-revaluation plug, liability stays at the anchor + expect(legs.some((l) => l.account.name.endsWith('/fx-revaluation'))).toBe(true); + expect(cents(legs)).toBe(0); + }); + + // §4.2 (Major design-accounting): symmetric for BANK_TX_REPEAT_CHARGEBACK — opening CHF anchor + fx-plug so + // bankTx-repeat closes cent-exact even when the EUR mark drifted between the BANK_TX_REPEAT credit and the chargeback. + it('BANK_TX_REPEAT_CHARGEBACK on EUR uses the OPENING CHF anchor + routes the mark drift to fx-revaluation', async () => { + jest + .spyOn(bankTxRepeatRepo, 'findOne') + .mockResolvedValue(Object.assign(new BankTxRepeat(), { bankTx: { id: 60 } }) as any); + const openingLeg = { account: { name: 'LIABILITY/bankTx-repeat' }, amountChf: -92 } as any; + jest + .spyOn(ledgerTxRepo, 'findOne') + .mockImplementation(({ where }: any) => + where?.sourceType === 'bank_tx' && where?.sourceId === '60' + ? Promise.resolve(Object.assign(new LedgerTx(), { legs: [openingLeg] }) as any) + : Promise.resolve(null), + ); + + mockBatch([ + bankTx({ + id: 61, + type: BankTxType.BANK_TX_REPEAT_CHARGEBACK, + creditDebitIndicator: BankTxIndicator.DEBIT, + accountIban: 'EUR-IBAN', + amount: 100, // EUR + }), + ]); + await consumer.process(); + + const legs = booked[0].legs; + expect(legs.find((l) => l.account.name === 'LIABILITY/bankTx-repeat').amountChf).toBe(92); // opening anchor + expect(legs.find((l) => l.account.name === 'Olkypay/EUR').amountChf).toBe(-95); // EUR-mark × amount + expect(legs.some((l) => l.account.name.endsWith('/fx-revaluation'))).toBe(true); + expect(cents(legs)).toBe(0); + }); + + // no opening at all found (untracked chain / opening older than the 90d cutover lookback, no bank_tx seq0 AND no + // cutover marker) → fall back to the close value, 2-leg, no plug (the prior behaviour stays intact, tx self-balances) + it('BANK_TX_REPEAT_CHARGEBACK with no opening row falls back to the close value (2-leg, no plug)', async () => { + // default mocks: bankTxRepeatRepo.findOne → null (no opening row) + settingService.get → undefined (no cutover) + mockBatch([ + bankTx({ + type: BankTxType.BANK_TX_REPEAT_CHARGEBACK, + creditDebitIndicator: BankTxIndicator.DEBIT, + accountIban: 'CHF-IBAN', + amount: 100, + }), + ]); + await consumer.process(); + const legs = booked[0].legs; + expect(legs.find((l) => l.account.name === 'LIABILITY/bankTx-repeat').amountChf).toBe(100); // close value + expect(legs.some((l) => l.account.name.endsWith('/fx-revaluation'))).toBe(false); // no drift → no plug + expect(cents(legs)).toBe(0); + }); + + // §6.1 + §4.2 (Major design-accounting): a cutover-straddling BANK_TX_RETURN (opened pre-cutover by the cutover, NOT + // a bank_tx seq0 tx) whose chargeback settles post-cutover MUST anchor on the CUTOVER opening-CHF so the + // LIABILITY/bankTx-return closes cent-exact to 0 — NOT the −Σ(bank+fee) fallback, which would leave it phantom-negative. + it('BANK_TX_RETURN_CHARGEBACK on a cutover-straddling row anchors on the CUTOVER opening CHF (liability closes to 0)', async () => { + // chargeback resolves to opening bank_tx #50; there is NO bank_tx seq0 opening (settled pre-cutover, below the + // watermark) — only the cutover per-row opening `1557344:bank_tx-return:50` (Cr −92 CHF). EUR-mark@chargeback 0.95. + jest + .spyOn(bankTxReturnRepo, 'findOne') + .mockResolvedValue(Object.assign(new BankTxReturn(), { bankTx: { id: 50 } }) as any); + jest.spyOn(settingService, 'get').mockResolvedValue('1557344'); // cutover happened, logId 1557344 + const cutoverLeg = { account: { name: 'LIABILITY/bankTx-return' }, amountChf: -92 } as any; + jest + .spyOn(ledgerTxRepo, 'findOne') + .mockImplementation(({ where }: any) => + where?.sourceType === 'cutover' && where?.sourceId === '1557344:bank_tx-return:50' + ? Promise.resolve(Object.assign(new LedgerTx(), { legs: [cutoverLeg] }) as any) + : Promise.resolve(null), + ); + + mockBatch([ + bankTx({ + id: 51, + type: BankTxType.BANK_TX_RETURN_CHARGEBACK, + creditDebitIndicator: BankTxIndicator.DEBIT, + accountIban: 'EUR-IBAN', + amount: 100, // EUR + }), + ]); + await consumer.process(); + + const legs = booked[0].legs; + expect(legs.find((l) => l.account.name === 'LIABILITY/bankTx-return').amountChf).toBe(92); // CUTOVER opening anchor + expect(legs.find((l) => l.account.name === 'Olkypay/EUR').amountChf).toBe(-95); // EUR-mark@chargeback × amount + // +92 − 95 = −3 drift closes via an fx-revaluation plug; the liability stays exactly on the opening anchor → 0 + expect(legs.some((l) => l.account.name.endsWith('/fx-revaluation'))).toBe(true); + expect(cents(legs)).toBe(0); + }); + + it('books CHECKOUT_LTD CRDT: ASSET/bank net + EXPENSE/acquirer-fee / ASSET/Checkout gross (card-currency native)', async () => { + createdAccounts.set('Checkout/EUR', account('Checkout/EUR', AccountType.ASSET, 'EUR', 270)); + mockBatch([ + bankTx({ + type: BankTxType.CHECKOUT_LTD, + creditDebitIndicator: BankTxIndicator.CREDIT, + accountIban: 'EUR-IBAN', + amount: 100, + chargeAmountChf: 3, + accountingAmountAfterFeeChf: 95, + }), + ]); + await consumer.process(); + + const legs = booked[0].legs; + expect(legs.some((l) => l.account.name === 'EXPENSE/acquirer-fee' && l.amountChf === 3)).toBe(true); + const checkout = legs.find((l) => l.account.name === 'Checkout/EUR'); + expect(checkout.amountChf).toBe(-(95 + 3)); // gross CHF = net + fee (Minor R3-5) + // F2 ladder 3: no chargeAmount persisted → feeNative = feeChf / mark = 3 / 0.95 = 3.15789474 → custody native + // gross = −(100 + 3.15789474) in CARD currency (was −100 net pre-fix — the fee-sized native hole the + // mark-to-market job flushed as a phantom fxPnl posting) + expect(checkout.amount).toBe(-103.15789474); + // MARK-CONSISTENCY: |amountChf| ≈ mark × |amount| within 1 cent → nothing left for M2M but genuine FX drift + expect(Math.abs(checkout.amountChf)).toBeCloseTo(0.95 * Math.abs(checkout.amount), 2); + expect(cents(legs)).toBe(0); + }); + + // F2 ladder 2: the acquirer fee is persisted in the settlement currency (chargeCurrency === tx.currency) → its + // native is used directly, no mark bridge needed. The mark-consistency residual (0.95 × 103.2 = 98.04 vs 98) is + // the chargeAmountChf-vs-mark pricing drift — a REAL spread, not a phantom — and stays within 5 cents here. + it('books CHECKOUT_LTD with a same-currency chargeAmount: custody native gross = net + chargeAmount (ladder 2)', async () => { + createdAccounts.set('Checkout/EUR', account('Checkout/EUR', AccountType.ASSET, 'EUR', 270)); + mockBatch([ + bankTx({ + type: BankTxType.CHECKOUT_LTD, + creditDebitIndicator: BankTxIndicator.CREDIT, + accountIban: 'EUR-IBAN', + amount: 100, + currency: 'EUR', // settlement currency — matches chargeCurrency → ladder 2 + chargeAmount: 3.2, + chargeCurrency: 'EUR', + chargeAmountChf: 3, + }), + ]); + await consumer.process(); + + const legs = booked[0].legs; + expect(legs.some((l) => l.account.name === 'EXPENSE/acquirer-fee' && l.amountChf === 3)).toBe(true); + const checkout = legs.find((l) => l.account.name === 'Checkout/EUR'); + expect(checkout.amount).toBe(-103.2); // −(net 100 + chargeAmount 3.2), card currency + expect(checkout.amountChf).toBe(-98); // −(netChf 95 + feeChf 3) + expect(Math.abs(checkout.amountChf)).toBeCloseTo(0.95 * Math.abs(checkout.amount), 1); // within 5 cents + expect(cents(legs)).toBe(0); + }); + + // F2 ladder 4 (fail-loud): a non-zero acquirer fee with neither a same-currency chargeAmount nor a mark cannot be + // booked mark-consistently → throw → failure-isolation (nothing booked, watermark stays put, retried next run) + it('throws (failure-isolation) on CHECKOUT_LTD with a non-zero fee, no chargeAmount and no mark', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + createdAccounts.set('Checkout/EUR', account('Checkout/EUR', AccountType.ASSET, 'EUR', 270)); + mockBatch( + [ + bankTx({ + id: 106, + type: BankTxType.CHECKOUT_LTD, + creditDebitIndicator: BankTxIndicator.CREDIT, + accountIban: 'UNTRACKED-IBAN', + amount: 100, + chargeAmountChf: 3, // non-zero fee, no chargeAmount + }), + ], + undefined, + ); + // override: untracked EUR bank + NO tracked EUR bank → no mark resolvable (ladder 3 unavailable) + jest.spyOn(bankRepo, 'findOne').mockImplementation((opts: any) => { + const iban = opts?.where?.iban; + if (iban === 'UNTRACKED-IBAN') + return Promise.resolve(Object.assign(new Bank(), { name: 'Raiffeisen', currency: 'EUR', asset: null })); + return Promise.resolve(null); // currency lookup finds no tracked EUR bank + }); + await consumer.process(); + + expect(booked).toHaveLength(0); // buildLegs threw → bookTx never called for the row + expect(setSpy).not.toHaveBeenCalled(); // throw → watermark not advanced (row retried next run) + }); + + // F1: a non-CHF CHECKOUT_LTD with NO historical mark at the booking date but a CURRENT mark (getLatestMark). The two + // ASSET legs (bank + Checkout custody) are bridged so the fee-carrying MIXED tx balances instead of wedging bookTx + // head-of-line (its Σ = feeChf ≠ 0 would trip the appendRoundingLeg imbalance guard). needsMark stays true → the + // mark-to-market job revalues the gross later; the F2 gross native convention is untouched by the bridge. + it('bridges a no-historical-mark CHECKOUT_LTD via the current mark so the fee-carrying tx balances (F1)', async () => { + createdAccounts.set('Checkout/EUR', account('Checkout/EUR', AccountType.ASSET, 'EUR', 270)); + jest.spyOn(markService, 'preload').mockResolvedValue(new LedgerMarkCache(new Map())); // no historical mark anywhere + jest.spyOn(markService, 'getLatestMark').mockResolvedValue(0.95); // but a current mark exists → bridgeable + mockBatch([ + bankTx({ + id: 104, + type: BankTxType.CHECKOUT_LTD, + creditDebitIndicator: BankTxIndicator.CREDIT, + accountIban: 'EUR-IBAN', + amount: 100, + currency: 'EUR', + chargeAmount: 3.2, + chargeCurrency: 'EUR', // ladder 2 → feeNative 3.2 + chargeAmountChf: 3.04, // 0.95 × 3.2 → the bridged legs net exactly (no residual plug) + }), + ]); + await consumer.process(); + + expect(booked).toHaveLength(1); + const legs = booked[0].legs; + const bank = legs.find((l) => l.account === eurBankAccount); + const checkout = legs.find((l) => l.account.name === 'Checkout/EUR'); + expect(bank.amountChf).toBe(95); // bridged 0.95 × 100 + expect(bank.needsMark).toBe(true); // provisional basis → re-marked later + expect(checkout.amount).toBe(-103.2); // gross native (F2 convention) untouched by the bridge + expect(checkout.amountChf).toBe(-98.04); // bridged 0.95 × 103.2 + expect(checkout.needsMark).toBe(true); + expect(cents(legs)).toBe(0); // balances (was a throw-wedge pre-fix) + }); + + // F1: a non-CHF CHECKOUT_LTD with a non-zero fee and NO mark anywhere (no historical, no current). The bridge finds + // nothing, so the MIXED tx defers via failure-isolation instead of a permanent throw-wedge — the watermark stays put + // and the row retries once the feed has the asset (§4.2a Major B5 fail-closed). + it('defers a no-mark CHECKOUT_LTD with a non-zero fee (F1 defer, no throw-wedge)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + createdAccounts.set('Checkout/EUR', account('Checkout/EUR', AccountType.ASSET, 'EUR', 270)); + jest.spyOn(markService, 'preload').mockResolvedValue(new LedgerMarkCache(new Map())); // no historical mark + // getLatestMark stays the default undefined → no bridge anywhere + mockBatch([ + bankTx({ + id: 105, + type: BankTxType.CHECKOUT_LTD, + creditDebitIndicator: BankTxIndicator.CREDIT, + accountIban: 'EUR-IBAN', + amount: 100, + currency: 'EUR', + chargeAmount: 3.2, + chargeCurrency: 'EUR', + chargeAmountChf: 3.04, + }), + ]); + await consumer.process(); + + expect(booked).toHaveLength(0); // deferred: unbalanceable mixed tx, nothing to bridge + expect(setSpy).not.toHaveBeenCalled(); // watermark NOT advanced (retry next run) + }); + + it('books GSHEET CRDT to LIABILITY/unattributed (EUR-mark in both legs)', async () => { + mockBatch([ + bankTx({ + type: BankTxType.GSHEET, + creditDebitIndicator: BankTxIndicator.CREDIT, + accountIban: 'EUR-IBAN', + amount: 1000, + }), + ]); + await consumer.process(); + const legs = booked[0].legs; + const bank = legs.find((l) => l.account === eurBankAccount); + const liab = legs.find((l) => l.account.name === 'LIABILITY/unattributed'); + expect(bank.amountChf).toBe(950); // EUR-mark × amount + expect(liab.amountChf).toBe(-950); // same CHF, 2-leg, no fx plug + expect(legs).toHaveLength(2); + expect(cents(legs)).toBe(0); + }); + + it('books GSHEET DBIT to SUSPENSE', async () => { + mockBatch([ + bankTx({ + type: BankTxType.GSHEET, + creditDebitIndicator: BankTxIndicator.DEBIT, + accountIban: 'CHF-IBAN', + amount: 100, + }), + ]); + await consumer.process(); + expect(booked[0].legs.some((l) => l.account.name === 'SUSPENSE')).toBe(true); + expect(cents(booked[0].legs)).toBe(0); + }); + + it('books UNKNOWN against SUSPENSE', async () => { + mockBatch([ + bankTx({ + type: BankTxType.UNKNOWN, + creditDebitIndicator: BankTxIndicator.CREDIT, + accountIban: 'CHF-IBAN', + amount: 100, + }), + ]); + await consumer.process(); + expect(booked[0].legs.some((l) => l.account.name === 'SUSPENSE')).toBe(true); + expect(cents(booked[0].legs)).toBe(0); + }); + + it('advances the watermark only after a successful batch and stops on a booking error (failure-isolation)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + jest + .spyOn(bookingService, 'bookTx') + .mockResolvedValueOnce({} as any) + .mockRejectedValueOnce(new Error('boom')); + + mockBatch([ + bankTx({ + id: 5, + type: BankTxType.EXTRAORDINARY_EXPENSES, + creditDebitIndicator: BankTxIndicator.DEBIT, + accountIban: 'CHF-IBAN', + amount: 10, + }), + bankTx({ + id: 6, + type: BankTxType.EXTRAORDINARY_EXPENSES, + creditDebitIndicator: BankTxIndicator.DEBIT, + accountIban: 'CHF-IBAN', + amount: 10, + }), + ]); + + await consumer.process(); + + // watermark advanced to 5 (the successful row), not 6 + expect(setSpy).toHaveBeenCalledTimes(1); + const written = JSON.parse(setSpy.mock.calls[0][1]); + expect(written.lastProcessedId).toBe(5); + }); + + it('no-ops on an empty batch', async () => { + mockBatch([]); + const setSpy = jest.spyOn(settingService, 'set'); + await consumer.process(); + expect(booked).toHaveLength(0); + expect(setSpy).not.toHaveBeenCalled(); + }); + + // --- ADDITIONAL TYPE / ERROR BRANCHES --- // + + it('books PENDING CRDT to LIABILITY/unattributed (same branch as GSHEET CRDT)', async () => { + mockBatch([ + bankTx({ + type: BankTxType.PENDING, + creditDebitIndicator: BankTxIndicator.CREDIT, + accountIban: 'EUR-IBAN', + amount: 1000, + }), + ]); + await consumer.process(); + expect(booked[0].legs.some((l) => l.account.name === 'LIABILITY/unattributed')).toBe(true); + expect(cents(booked[0].legs)).toBe(0); + }); + + it('books PENDING DBIT to SUSPENSE (non-credit unattributed → SUSPENSE)', async () => { + mockBatch([ + bankTx({ + type: BankTxType.PENDING, + creditDebitIndicator: BankTxIndicator.DEBIT, + accountIban: 'CHF-IBAN', + amount: 100, + }), + ]); + await consumer.process(); + expect(booked[0].legs.some((l) => l.account.name === 'SUSPENSE')).toBe(true); + expect(cents(booked[0].legs)).toBe(0); + }); + + // §4.2 buyCryptoLegs guard: BUY_CRYPTO without buyCrypto.amountInChf throws → failure-isolation + it('throws (failure-isolation) on BUY_CRYPTO without buyCrypto.amountInChf', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + mockBatch([ + bankTx({ id: 7, type: BankTxType.BUY_CRYPTO, accountIban: 'CHF-IBAN', amount: 1000, buyCrypto: undefined }), + ]); + await consumer.process(); + expect(booked).toHaveLength(0); + expect(setSpy).not.toHaveBeenCalled(); // throw → watermark not advanced + }); + + // §4.2 defensive default: an unmapped bank_tx type (bad DB data) → buildLegs default → undefined → skipped + it('skips an unmapped bank_tx type (defensive default branch)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + mockBatch([bankTx({ id: 8, type: 'WeirdType' as BankTxType, accountIban: 'CHF-IBAN', amount: 100 })]); + await consumer.process(); + expect(booked).toHaveLength(0); + expect(JSON.parse(setSpy.mock.calls[0][1]).lastProcessedId).toBe(8); // skip (not error) → watermark advances + }); + + // §4.2 bankContext no-bank-match fallback: no accountIban → untracked, currency from the tx → SUSPENSE/untracked + it('books an UNKNOWN with no accountIban against an untracked SUSPENSE from the tx currency', async () => { + mockBatch([ + bankTx({ + id: 9, + type: BankTxType.UNKNOWN, + creditDebitIndicator: BankTxIndicator.CREDIT, + accountIban: null, // no bank match → untracked, currency from tx + currency: 'CHF', + amount: 100, + }), + ]); + await consumer.process(); + // untracked CHF → SUSPENSE leg + the bank leg is a SUSPENSE/untracked-bank-unknown-CHF account (CHF → mark 1) + expect(booked[0].legs.some((l) => l.account.name === 'SUSPENSE/untracked-bank-unknown-CHF')).toBe(true); + expect(cents(booked[0].legs)).toBe(0); + }); + + // §4.2a BUY_CRYPTO_RETURN cutover-straddling: the owed-Dr anchors on the CUTOVER opening CHF (per-row marker), + // NOT the completion CHF — so owed closes cent-exact to 0 even for a row whose owed was opened by the cutover. + it('BUY_CRYPTO_RETURN anchors the owed-Dr on the cutover opening CHF for a straddling row', async () => { + jest.spyOn(settingService, 'get').mockResolvedValue('1557344'); // cutover ran, logId 1557344 + const cutoverLeg = { account: { name: 'LIABILITY/buyCrypto-owed' }, amountChf: -48000 } as any; + jest + .spyOn(ledgerTxRepo, 'findOne') + .mockImplementation(({ where }: any) => + where?.sourceType === 'cutover' && where?.sourceId === '1557344:buy_crypto-owed:88' + ? Promise.resolve(Object.assign(new LedgerTx(), { legs: [cutoverLeg] }) as any) + : Promise.resolve(null), + ); + // completion (if it were used) = 49500 − 100 = 49400 → distinct from the 48000 opening on purpose + const buyCrypto = { id: 88, amountInChf: 49500, totalFeeAmountChf: 100 } as any; + mockBatch([ + bankTx({ + id: 90, + type: BankTxType.BUY_CRYPTO_RETURN, + creditDebitIndicator: BankTxIndicator.DEBIT, + accountIban: 'EUR-IBAN', + amount: 50000, // EUR-mark 0.95 → bank-Cr −47500 + buyCrypto, + }), + ]); + await consumer.process(); + + const legs = booked[0].legs; + const owed = legs.find((l) => l.account.name === 'LIABILITY/buyCrypto-owed'); + expect(owed.amountChf).toBe(48000); // the CUTOVER opening anchor, NOT the 49400 completion CHF + expect(legs.find((l) => l.account === eurBankAccount).amountChf).toBe(-47500); // EUR-mark × amount + expect(cents(legs)).toBe(0); // the 48000 − 47500 = 500 drift closes via the fx plug + }); + + // §4.2 CHECKOUT_LTD with no acquirer fee (chargeAmountChf null → 0): no EXPENSE/acquirer-fee leg, gross == net + it('books CHECKOUT_LTD with no acquirer fee (chargeAmountChf null → no fee leg)', async () => { + createdAccounts.set('Checkout/CHF', account('Checkout/CHF', AccountType.ASSET, 'CHF', 271)); + mockBatch([ + bankTx({ + type: BankTxType.CHECKOUT_LTD, + creditDebitIndicator: BankTxIndicator.CREDIT, + accountIban: 'CHF-IBAN', + amount: 100, + chargeAmountChf: null, // no fee → fee leg skipped, gross = net + }), + ]); + await consumer.process(); + const legs = booked[0].legs; + expect(legs.some((l) => l.account.name === 'EXPENSE/acquirer-fee')).toBe(false); + const checkout = legs.find((l) => l.account.name === 'Checkout/CHF'); + expect(checkout.amount).toBe(-100); // feeChf 0 → feeNative 0 (F2 ladder 1) → native gross == net + expect(checkout.amountChf).toBe(-100); // gross == net (no fee) + expect(cents(legs)).toBe(0); + }); + + // --- §4.12 CONTENT-CHANGE SCAN (reconcileBooking) --- // + + it('content-change scan: reverses+rebooks an already-booked row whose legs changed (§4.12)', async () => { + const rebookSpy = jest.spyOn(bookingService, 'reverseAndRebookIfChanged').mockResolvedValue(true); + const reverseSpy = jest.spyOn(bookingService, 'reverseActiveIfBooked').mockResolvedValue(false); + const changed = bankTx({ + id: 30, + type: BankTxType.EXTRAORDINARY_EXPENSES, + creditDebitIndicator: BankTxIndicator.DEBIT, + accountIban: 'CHF-IBAN', + amount: 200, + }); + // forward id-scan empty; content-change scan (where.updated) returns the changed row + jest + .spyOn(bankTxRepo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [changed] : [])); + + await consumer.process(); + + expect(rebookSpy).toHaveBeenCalledTimes(1); + expect(rebookSpy.mock.calls[0][0].sourceId).toBe('30'); // recomputed seq0 legs for the changed row + expect(reverseSpy).not.toHaveBeenCalled(); // still a bookable type → reverse-and-rebook, NOT a flat reversal + }); + + it('content-change scan: flat-reverses a row whose type became a skip type (buildSeq0Input undefined)', async () => { + const reverseSpy = jest.spyOn(bookingService, 'reverseActiveIfBooked').mockResolvedValue(true); + const rebookSpy = jest.spyOn(bookingService, 'reverseAndRebookIfChanged').mockResolvedValue(false); + // the row flipped to BUY_FIAT (a skip type) → buildSeq0Input undefined → flat reversal at (bank_tx, id, 0) + const becameSkip = bankTx({ + id: 31, + type: BankTxType.BUY_FIAT, + creditDebitIndicator: BankTxIndicator.DEBIT, + accountIban: 'CHF-IBAN', + amount: 100, + }); + jest + .spyOn(bankTxRepo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [becameSkip] : [])); + + await consumer.process(); + + expect(reverseSpy).toHaveBeenCalledWith('bank_tx', '31', 0); // flat reversal at the booked identifiers + expect(rebookSpy).not.toHaveBeenCalled(); + }); + + // --- ADDITIONAL BRANCH COVERAGE --- // + + // §4.12 content-change scan callback `tx.bookingDate ?? tx.created` — a re-classified row whose bookingDate is null + // → the scan preloads marks over [daysBefore(2, tx.created), tx.created] (the null-side of the ?? feeds the lookback + // window so getMarkAt finds the latest mark at-or-before the row timestamp for a non-CHF asset). + it('content-change scan uses a daysBefore(2, tx.created) lookback for the mark window when bookingDate is null', async () => { + const rebookSpy = jest.spyOn(bookingService, 'reverseAndRebookIfChanged').mockResolvedValue(true); + const preloadSpy = jest.spyOn(markService, 'preload'); + const created = new Date('2026-05-20T00:00:00Z'); + const changed = bankTx({ + id: 40, + type: BankTxType.EXTRAORDINARY_EXPENSES, + creditDebitIndicator: BankTxIndicator.DEBIT, + accountIban: 'CHF-IBAN', + amount: 200, + created, + bookingDate: null, // null → the scan callback falls back to tx.created for the mark window + }); + jest + .spyOn(bankTxRepo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [changed] : [])); + + await consumer.process(); + + expect(rebookSpy).toHaveBeenCalledTimes(1); + expect(rebookSpy.mock.calls[0][0].sourceId).toBe('40'); + // the scan callback preload (the LAST preload call) has a daysBefore(2, tx.created) lower bound + tx.created upper + const lastPreload = preloadSpy.mock.calls[preloadSpy.mock.calls.length - 1]; + expect(lastPreload[0]).toEqual(Util.daysBefore(2, created)); + expect(lastPreload[1]).toBe(created); + }); + + // M1 (forward mark lookback): a forward row whose settlement (bookingDate ?? created) PREDATES `created` must + // anchor the mark-preload window on the earliest settlement (2 days earlier), NOT min(created). Without it, + // getMarkAt finds no mark at-or-before that row's bookingDate → needsMark → the asymmetric EUR-mark bank leg + // throws a CHF imbalance in appendRoundingLeg and the row wedges permanently. + it('anchors the forward mark window on the earliest bookingDate ?? created (2 days earlier) so an early-settling row books (M1)', async () => { + const preloadSpy = jest.spyOn(markService, 'preload'); + const bookingDate = new Date('2026-05-18T00:00:00Z'); // settlement PREDATES `created` + const created = new Date('2026-05-20T00:00:00Z'); + mockBatch([ + bankTx({ + id: 42, + type: BankTxType.BANK_TX_RETURN, + creditDebitIndicator: BankTxIndicator.CREDIT, + accountIban: 'EUR-IBAN', // EUR bank → the ASSET leg is EUR-mark-valued (needs a mark ≤ bookingDate) + amount: 100, + created, + bookingDate, + }), + ]); + + await consumer.process(); + + // load-bearing regression guard: the forward preload lower bound is anchored on min(bookingDate ?? created) − 2d, + // NOT min(created) — this assertion fails if the fix is reverted (the window that supplies the pre-`created` mark) + expect(preloadSpy.mock.calls[0][0]).toEqual(Util.daysBefore(2, bookingDate)); + + // given the window supplies the mark, the row books cleanly: a VALUED EUR bank leg (no needsMark) and Σ CHF = 0 + const tx = booked.find((b) => b.sourceId === '42'); + expect(tx).toBeDefined(); + const bankLeg = tx.legs.find((l) => l.account.type === AccountType.ASSET); + expect(bankLeg.needsMark).toBe(false); + expect(bankLeg.amountChf).toBe(95); // 100 EUR × 0.95 + expect(cents(tx.legs)).toBe(0); // Σ CHF = 0 → no CHF-imbalance throw in appendRoundingLeg + }); + + // §4.2 buildSeq0Input `tx.bookingDate ?? tx.created` + `tx.valueDate ?? bookingDate` — a forward row with BOTH + // bookingDate AND valueDate null → both settlement dates fall back to tx.created (source lines 153/154 null sides). + it('buildSeq0Input falls back to tx.created for both bookingDate and valueDate when null (lines 153/154 null sides)', async () => { + const created = new Date('2026-05-21T00:00:00Z'); + mockBatch([ + bankTx({ + id: 41, + type: BankTxType.EXTRAORDINARY_EXPENSES, + creditDebitIndicator: BankTxIndicator.DEBIT, + accountIban: 'CHF-IBAN', + amount: 200, + created, + bookingDate: null, // → bookingDate = created + valueDate: null, // → valueDate = bookingDate = created + }), + ]); + await consumer.process(); + + expect(booked).toHaveLength(1); + expect(booked[0].bookingDate).toBe(created); + expect(booked[0].valueDate).toBe(created); + }); + + // §4.2a buyCryptoOwedChf guard: a BUY_CRYPTO_RETURN whose buyCrypto.amountInChf is null AND no cutover opening → + // throws (source line 279) → failure-isolation, nothing booked, watermark not advanced. + it('throws (failure-isolation) on BUY_CRYPTO_RETURN without buyCrypto.amountInChf and no cutover (line 279)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + const buyCrypto = { id: 91, amountInChf: null } as any; // no completion anchor; default mocks → no cutover opening + mockBatch([ + bankTx({ + id: 92, + type: BankTxType.BUY_CRYPTO_RETURN, + creditDebitIndicator: BankTxIndicator.DEBIT, + accountIban: 'CHF-IBAN', + amount: 1000, + buyCrypto, + }), + ]); + await consumer.process(); + + expect(booked).toHaveLength(0); + expect(setSpy).not.toHaveBeenCalled(); // throw → watermark stays put + }); + + // §4.2a buyCryptoOwedChf `totalFeeAmountChf ?? 0` null side (source line 280): a return whose buyCrypto has + // amountInChf set but totalFeeAmountChf null → owed = amountInChf − 0 (CHF account → no fee subtracted, no plug). + it('BUY_CRYPTO_RETURN owed-Dr uses amountInChf − 0 when totalFeeAmountChf is null (line 280 null side)', async () => { + const buyCrypto = { id: 93, amountInChf: 1000, totalFeeAmountChf: null } as any; + mockBatch([ + bankTx({ + id: 94, + type: BankTxType.BUY_CRYPTO_RETURN, + creditDebitIndicator: BankTxIndicator.DEBIT, + accountIban: 'CHF-IBAN', + amount: 1000, + buyCrypto, + }), + ]); + await consumer.process(); + + const legs = booked[0].legs; + const owed = legs.find((l) => l.account.name === 'LIABILITY/buyCrypto-owed'); + expect(owed.amountChf).toBe(1000); // amountInChf (1000) − (totalFeeAmountChf ?? 0 = 0) + expect(legs.find((l) => l.account === chfBankAccount).amountChf).toBe(-1000); + expect(cents(legs)).toBe(0); + }); + + // §6.1 cutoverOwedOpeningChf leg amountChf null (source line 295): a cutover opening tx exists for the buy_crypto but + // its buyCrypto-owed leg carries amountChf == null → cutoverOwedOpeningChf returns undefined → fall back to the + // completion CHF (amountInChf − totalFeeAmountChf). + it('BUY_CRYPTO_RETURN falls back to completion CHF when the cutover owed leg amountChf is null (line 295)', async () => { + jest.spyOn(settingService, 'get').mockResolvedValue('1557344'); // cutover ran + const cutoverLeg = { account: { name: 'LIABILITY/buyCrypto-owed' }, amountChf: null } as any; // null → undefined + jest + .spyOn(ledgerTxRepo, 'findOne') + .mockImplementation(({ where }: any) => + where?.sourceType === 'cutover' && where?.sourceId === '1557344:buy_crypto-owed:95' + ? Promise.resolve(Object.assign(new LedgerTx(), { legs: [cutoverLeg] }) as any) + : Promise.resolve(null), + ); + const buyCrypto = { id: 95, amountInChf: 1000, totalFeeAmountChf: 40 } as any; + mockBatch([ + bankTx({ + id: 96, + type: BankTxType.BUY_CRYPTO_RETURN, + creditDebitIndicator: BankTxIndicator.DEBIT, + accountIban: 'CHF-IBAN', + amount: 1000, + buyCrypto, + }), + ]); + await consumer.process(); + + const legs = booked[0].legs; + const owed = legs.find((l) => l.account.name === 'LIABILITY/buyCrypto-owed'); + expect(owed.amountChf).toBe(960); // completion CHF = 1000 − 40 (NOT the null cutover leg) + expect(cents(legs)).toBe(0); + }); + + // §4.2a transferLegs counter-leg undefined side (source lines 342-344): a KRAKEN on an UNTRACKED EUR bank with NO + // same-currency tracked mark → the bank leg needsMark (amountChf undefined) → the TRANSIT counter leg also carries + // amountChf undefined + needsMark true (the `bank.amountChf != null ? … : undefined` else branch). + it('KRAKEN on an untracked bank with no mark: TRANSIT counter leg is needsMark/amountChf undefined (lines 342-344)', async () => { + mockBatch( + [ + bankTx({ + id: 97, + type: BankTxType.KRAKEN, + creditDebitIndicator: BankTxIndicator.DEBIT, + accountIban: 'UNTRACKED-IBAN', + amount: 500, + }), + ], + undefined, + ); + // override: untracked EUR bank + NO tracked EUR bank for the currency mark lookup → mark unresolvable + jest.spyOn(bankRepo, 'findOne').mockImplementation((opts: any) => { + const iban = opts?.where?.iban; + if (iban === 'UNTRACKED-IBAN') + return Promise.resolve(Object.assign(new Bank(), { name: 'Raiffeisen', currency: 'EUR', asset: null })); + return Promise.resolve(null); // currency lookup → no tracked EUR bank + }); + await consumer.process(); + + const legs = booked[0].legs; + const bank = legs.find((l) => l.account.name === 'SUSPENSE/untracked-bank-Raiffeisen-EUR'); + const transit = legs.find((l) => l.account.name === 'TRANSIT/bank↔Kraken/EUR'); + expect(bank.amount).toBe(-500); // DBIT + expect(bank.needsMark).toBe(true); + expect(bank.amountChf).toBeUndefined(); + expect(transit.amount).toBe(500); // -bank.amount + expect(transit.amountChf).toBeUndefined(); // else side of `bank.amountChf != null ? -bank.amountChf : undefined` + expect(transit.needsMark).toBe(true); // inherits bank.needsMark + }); + + // §4.2a suspenseLegs counter-leg undefined side (source line 563): an UNKNOWN on an untracked EUR bank with NO mark + // → the bank leg needsMark → the SUSPENSE counter leg carries amountChf undefined + needsMark true. + it('UNKNOWN on an untracked bank with no mark: SUSPENSE counter leg is needsMark/amountChf undefined (line 563)', async () => { + mockBatch( + [ + bankTx({ + id: 98, + type: BankTxType.UNKNOWN, + creditDebitIndicator: BankTxIndicator.CREDIT, + accountIban: 'UNTRACKED-IBAN', + amount: 500, + }), + ], + undefined, + ); + jest.spyOn(bankRepo, 'findOne').mockImplementation((opts: any) => { + const iban = opts?.where?.iban; + if (iban === 'UNTRACKED-IBAN') + return Promise.resolve(Object.assign(new Bank(), { name: 'Raiffeisen', currency: 'EUR', asset: null })); + return Promise.resolve(null); + }); + await consumer.process(); + + const legs = booked[0].legs; + const bank = legs.find((l) => l.account.name === 'SUSPENSE/untracked-bank-Raiffeisen-EUR'); + const suspense = legs.find((l) => l.account.name === 'SUSPENSE'); + expect(bank.amount).toBe(500); // CREDIT + expect(bank.needsMark).toBe(true); + expect(bank.amountChf).toBeUndefined(); + expect(suspense.amount).toBe(-500); // -bank.amount + expect(suspense.amountChf).toBeUndefined(); // else side of the ?? : undefined + expect(suspense.needsMark).toBe(true); + }); + + // §4.2 bankAccountFeeLegs `tx.chargeAmount ?? tx.amount` + `tx.chargeAmountChf ?? -(bank.amountChf ?? 0)` null sides + // (source lines 355-357): chargeAmount null → uses tx.amount; chargeAmountChf null → expense = −bank.amountChf. + it('BANK_ACCOUNT_FEE with chargeAmount/chargeAmountChf null uses tx.amount and −bank.amountChf (lines 355-357)', async () => { + mockBatch([ + bankTx({ + id: 99, + type: BankTxType.BANK_ACCOUNT_FEE, + creditDebitIndicator: BankTxIndicator.DEBIT, + accountIban: 'EUR-IBAN', + amount: 50, // used as the charge because chargeAmount is null + chargeAmount: null, + chargeAmountChf: null, + }), + ]); + await consumer.process(); + + const legs = booked[0].legs; + const bank = legs.find((l) => l.account === eurBankAccount); + const expense = legs.find((l) => l.account.name === 'EXPENSE/bank-fee'); + expect(bank.amount).toBe(-50); // −charge, charge = tx.amount (chargeAmount null) + expect(bank.amountChf).toBe(-47.5); // EUR-mark 0.95 × 50 + expect(expense.amountChf).toBe(47.5); // −(bank.amountChf) since chargeAmountChf null + expect(legs.some((l) => l.account.name?.includes('fx-revaluation'))).toBe(false); // expense == −bank → no residual + expect(cents(legs)).toBe(0); + }); + + // §4.2 liabilityCreditLegs `-(bank.amountChf ?? 0)` bank.amountChf null side (source line 392): a BANK_TX_RETURN on + // an untracked bank with no mark → bank leg amountChf undefined → liability leg uses −0 = 0. + it('BANK_TX_RETURN on an untracked bank with no mark: liability uses 0 (line 392 null side)', async () => { + mockBatch( + [ + bankTx({ + id: 100, + type: BankTxType.BANK_TX_RETURN, + creditDebitIndicator: BankTxIndicator.CREDIT, + accountIban: 'UNTRACKED-IBAN', + amount: 100, + }), + ], + undefined, + ); + jest.spyOn(bankRepo, 'findOne').mockImplementation((opts: any) => { + const iban = opts?.where?.iban; + if (iban === 'UNTRACKED-IBAN') + return Promise.resolve(Object.assign(new Bank(), { name: 'Raiffeisen', currency: 'EUR', asset: null })); + return Promise.resolve(null); + }); + await consumer.process(); + + const legs = booked[0].legs; + const bank = legs.find((l) => l.account.name === 'SUSPENSE/untracked-bank-Raiffeisen-EUR'); + const liability = legs.find((l) => l.account.name === 'LIABILITY/bankTx-return'); + expect(bank.needsMark).toBe(true); + expect(bank.amountChf).toBeUndefined(); + expect(liability.amountChf).toBe(-0); // −(bank.amountChf ?? 0) = −0 (the null-coalesce fallback) + }); + + // §4.2 cutoverOpeningLiabilityChf `cutoverLogId == null` (source line 500): a BANK_TX_RETURN_CHARGEBACK whose + // chargeback resolves to an opening bank_tx id (via the return repo) but there is NO bank_tx seq0 opening leg AND no + // cutover → cutoverOpeningLiabilityChf bails on the null logId → fall back to the −Σ(others) close value. + it('BANK_TX_RETURN_CHARGEBACK with an opening id but no seq0 leg and no cutover falls back to close value (line 500)', async () => { + jest + .spyOn(bankTxReturnRepo, 'findOne') + .mockResolvedValue(Object.assign(new BankTxReturn(), { bankTx: { id: 70 } }) as any); // opening id resolved + // ledgerTxRepo.findOne stays the default null → no bank_tx seq0 opening leg; settingService.get stays undefined + mockBatch([ + bankTx({ + id: 71, + type: BankTxType.BANK_TX_RETURN_CHARGEBACK, + creditDebitIndicator: BankTxIndicator.DEBIT, + accountIban: 'CHF-IBAN', + amount: 100, + chargeAmountChf: 5, + }), + ]); + await consumer.process(); + + const legs = booked[0].legs; + const liability = legs.find((l) => l.account.name === 'LIABILITY/bankTx-return'); + // CHF bank-Cr = −100, fee = +5 → fallback close value = −(−100 + 5) = 95 + expect(liability.amountChf).toBe(95); + expect(legs.some((l) => l.account.name.endsWith('/fx-revaluation'))).toBe(false); // self-balances → no plug + expect(cents(legs)).toBe(0); + }); + + // §6.1 cutoverOpeningLiabilityChf `bucket === 'bankTx-repeat'` marker branch (source line 502): a cutover-straddling + // BANK_TX_REPEAT_CHARGEBACK whose opening is only in the cutover under the 'bank_tx-repeat' marker → anchors on it. + it('BANK_TX_REPEAT_CHARGEBACK cutover-straddling anchors via the bank_tx-repeat marker (line 502 repeat branch)', async () => { + jest + .spyOn(bankTxRepeatRepo, 'findOne') + .mockResolvedValue(Object.assign(new BankTxRepeat(), { bankTx: { id: 80 } }) as any); + jest.spyOn(settingService, 'get').mockResolvedValue('1557344'); // cutover ran + const cutoverLeg = { account: { name: 'LIABILITY/bankTx-repeat' }, amountChf: -92 } as any; + jest.spyOn(ledgerTxRepo, 'findOne').mockImplementation(({ where }: any) => + // only the cutover marker resolves (no bank_tx seq0 opening); marker uses 'bank_tx-repeat' + where?.sourceType === 'cutover' && where?.sourceId === '1557344:bank_tx-repeat:80' + ? Promise.resolve(Object.assign(new LedgerTx(), { legs: [cutoverLeg] }) as any) + : Promise.resolve(null), + ); + mockBatch([ + bankTx({ + id: 81, + type: BankTxType.BANK_TX_REPEAT_CHARGEBACK, + creditDebitIndicator: BankTxIndicator.DEBIT, + accountIban: 'EUR-IBAN', + amount: 100, // EUR-mark 0.95 → bank-Cr −95 + }), + ]); + await consumer.process(); + + const legs = booked[0].legs; + expect(legs.find((l) => l.account.name === 'LIABILITY/bankTx-repeat').amountChf).toBe(92); // cutover anchor + expect(legs.find((l) => l.account.name === 'Olkypay/EUR').amountChf).toBe(-95); // EUR-mark × amount + expect(legs.some((l) => l.account.name.endsWith('/fx-revaluation'))).toBe(true); // +92 − 95 = −3 drift plugged + expect(cents(legs)).toBe(0); + }); + + // §6.1 cutoverOpeningLiabilityChf cutover leg amountChf == null (source line 508): the cutover marker resolves a tx + // but its liability leg carries amountChf null → cutoverOpeningLiabilityChf returns undefined → fall back to the + // −Σ(others) close value. + it('BANK_TX_RETURN_CHARGEBACK falls back to close value when the cutover opening leg amountChf is null (line 508)', async () => { + jest + .spyOn(bankTxReturnRepo, 'findOne') + .mockResolvedValue(Object.assign(new BankTxReturn(), { bankTx: { id: 72 } }) as any); + jest.spyOn(settingService, 'get').mockResolvedValue('1557344'); + const cutoverLeg = { account: { name: 'LIABILITY/bankTx-return' }, amountChf: null } as any; // null → undefined + jest + .spyOn(ledgerTxRepo, 'findOne') + .mockImplementation(({ where }: any) => + where?.sourceType === 'cutover' && where?.sourceId === '1557344:bank_tx-return:72' + ? Promise.resolve(Object.assign(new LedgerTx(), { legs: [cutoverLeg] }) as any) + : Promise.resolve(null), + ); + mockBatch([ + bankTx({ + id: 73, + type: BankTxType.BANK_TX_RETURN_CHARGEBACK, + creditDebitIndicator: BankTxIndicator.DEBIT, + accountIban: 'CHF-IBAN', + amount: 100, + }), + ]); + await consumer.process(); + + const legs = booked[0].legs; + // no usable opening anchor → fallback close value = −(bank −100) = 100 (CHF, no fee) + expect(legs.find((l) => l.account.name === 'LIABILITY/bankTx-return').amountChf).toBe(100); + expect(legs.some((l) => l.account.name.endsWith('/fx-revaluation'))).toBe(false); + expect(cents(legs)).toBe(0); + }); + + // §4.2 checkoutLtdLegs grossChf needsMark side (source lines 528-534): a CHECKOUT_LTD on an untracked bank with NO + // mark → bank leg needsMark (netChf undefined) → grossChf undefined → the Checkout custody leg needsMark, amountChf + // undefined. + it('CHECKOUT_LTD on an untracked bank with no mark: Checkout custody leg is needsMark/amountChf undefined (lines 528-534)', async () => { + createdAccounts.set('Checkout/EUR', account('Checkout/EUR', AccountType.ASSET, 'EUR', 270)); + mockBatch( + [ + bankTx({ + id: 101, + type: BankTxType.CHECKOUT_LTD, + creditDebitIndicator: BankTxIndicator.CREDIT, + accountIban: 'UNTRACKED-IBAN', + amount: 100, + chargeAmountChf: 0, // no fee leg, isolate the grossChf needsMark branch + }), + ], + undefined, + ); + jest.spyOn(bankRepo, 'findOne').mockImplementation((opts: any) => { + const iban = opts?.where?.iban; + if (iban === 'UNTRACKED-IBAN') + return Promise.resolve(Object.assign(new Bank(), { name: 'Raiffeisen', currency: 'EUR', asset: null })); + return Promise.resolve(null); // no tracked EUR bank → mark unresolvable + }); + await consumer.process(); + + const legs = booked[0].legs; + const bank = legs.find((l) => l.account.name === 'SUSPENSE/untracked-bank-Raiffeisen-EUR'); + const checkout = legs.find((l) => l.account.name === 'Checkout/EUR'); + expect(bank.needsMark).toBe(true); + expect(bank.amountChf).toBeUndefined(); // netChf undefined + expect(checkout.amount).toBe(-100); // -tx.amount (native still booked) + expect(checkout.amountChf).toBeUndefined(); // grossChf undefined → no CHF + expect(checkout.needsMark).toBe(true); // grossChf == null + }); + + // §4.2a withFxPlug residual POSITIVE side (source lines 606-607): a BANK_ACCOUNT_FEE on EUR where the Pricing + // chargeAmountChf is BELOW the EUR-mark bank value → the residual is ≥ 0 → INCOME/fx-revaluation (the existing fee + // test hits the EXPENSE side; this hits the INCOME side). + it('BANK_ACCOUNT_FEE routes a positive residual to INCOME/fx-revaluation (lines 606-607 positive side)', async () => { + // EUR-mark 0.95 × 50 = bank-Cr −47.5; chargeAmountChf (expense Dr) = 47.0 → sum = −0.5 → residual +0.5 (>2c) → + // INCOME/fx-revaluation +0.5 closes the tx. + mockBatch([ + bankTx({ + id: 102, + type: BankTxType.BANK_ACCOUNT_FEE, + creditDebitIndicator: BankTxIndicator.DEBIT, + accountIban: 'EUR-IBAN', + amount: 50, + chargeAmount: 50, + chargeAmountChf: 47.0, + }), + ]); + await consumer.process(); + + const legs = booked[0].legs; + const plug = legs.find((l) => l.account.name?.includes('fx-revaluation')); + expect(plug.account.name).toBe('INCOME/fx-revaluation'); // residual ≥ 0 → INCOME, NOT EXPENSE + expect(plug.amountChf).toBe(0.5); + expect(cents(legs)).toBe(0); + }); + + // §4.2 bankAccount throw (source line 618): a TRACKED bank whose findByAssetId returns undefined (CoA bootstrap + // missing) → throws → failure-isolation, nothing booked, watermark unchanged. + it('throws (failure-isolation) when a tracked bank has no ledger account (CoA bootstrap missing, line 618)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + jest.spyOn(accountService, 'findByAssetId').mockResolvedValue(undefined); // tracked bank → no CoA account + mockBatch([ + bankTx({ + id: 103, + type: BankTxType.EXTRAORDINARY_EXPENSES, + creditDebitIndicator: BankTxIndicator.DEBIT, + accountIban: 'CHF-IBAN', // tracked bank (asset id 100) + amount: 100, + }), + ]); + await consumer.process(); + + expect(booked).toHaveLength(0); + expect(setSpy).not.toHaveBeenCalled(); // throw → watermark not advanced + }); + + // §4.2 bankContext `tx.currency ?? CHF` null side (source line 666): no bank match AND tx.currency null → currency + // defaults to CHF → SUSPENSE/untracked-bank-unknown-CHF (CHF → mark 1, 2-leg, no plug). + it('bankContext defaults to CHF when there is no bank match and tx.currency is null (line 666 null side)', async () => { + mockBatch([ + bankTx({ + id: 104, + type: BankTxType.UNKNOWN, + creditDebitIndicator: BankTxIndicator.CREDIT, + accountIban: null, // no bank match + currency: null, // → currency defaults to CHF + amount: 100, + }), + ]); + await consumer.process(); + + const legs = booked[0].legs; + const bank = legs.find((l) => l.account.name === 'SUSPENSE/untracked-bank-unknown-CHF'); + expect(bank).toBeDefined(); // currency resolved to CHF + expect(bank.amount).toBe(100); + expect(bank.amountChf).toBe(100); // CHF → mark 1 + expect(bank.needsMark).toBe(false); + expect(cents(legs)).toBe(0); + }); +}); diff --git a/src/subdomains/core/accounting/services/consumers/__tests__/buy-crypto.consumer.spec.ts b/src/subdomains/core/accounting/services/consumers/__tests__/buy-crypto.consumer.spec.ts new file mode 100644 index 0000000000..a359b1b40d --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/__tests__/buy-crypto.consumer.spec.ts @@ -0,0 +1,709 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { TestUtil } from 'src/shared/utils/test.util'; +import { Util } from 'src/shared/utils/util'; +import { BuyCrypto } from 'src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity'; +import { Repository } from 'typeorm'; +import { AccountType, LedgerAccount } from '../../../entities/ledger-account.entity'; +import { LedgerLeg } from '../../../entities/ledger-leg.entity'; +import { LedgerTx } from '../../../entities/ledger-tx.entity'; +import { createCustomLedgerAccount } from '../../../entities/__mocks__/ledger-account.entity.mock'; +import { LedgerAccountService } from '../../ledger-account.service'; +import { LedgerBookingService, LedgerLegInput, LedgerTxInput } from '../../ledger-booking.service'; +import { BuyCryptoConsumer } from '../buy-crypto.consumer'; + +function buyCrypto(values: Record): BuyCrypto { + return Object.assign(new BuyCrypto(), { + id: 1, + created: new Date('2026-06-01T00:00:00Z'), + updated: new Date('2026-06-02T00:00:00Z'), + isComplete: false, + ...values, + }); +} + +function account(name: string, type: AccountType, currency: string): LedgerAccount { + return createCustomLedgerAccount({ id: Math.floor(Math.random() * 1e6), name, type, currency } as any); +} + +describe('BuyCryptoConsumer', () => { + let consumer: BuyCryptoConsumer; + let bookingService: LedgerBookingService; + let accountService: LedgerAccountService; + let settingService: SettingService; + let buyCryptoRepo: Repository; + let ledgerTxRepo: Repository; + + let booked: LedgerTxInput[]; + let accounts: Map; + let nextSeqValue: number; + let activeKeys: Set; // `${sourceId}:${seq}` with an active booking — backs hasActiveTxAt (per-seq, R3) + let gateOpen: boolean; // simulates a seq0 crypto_input ledger_tx existing + + beforeEach(async () => { + booked = []; + nextSeqValue = 0; + activeKeys = new Set(); + gateOpen = true; + accounts = new Map([['Checkout/EUR', account('Checkout/EUR', AccountType.ASSET, 'EUR')]]); + + bookingService = createMock(); + accountService = createMock(); + settingService = createMock(); + buyCryptoRepo = createMock>(); + ledgerTxRepo = createMock>(); + + jest.spyOn(bookingService, 'bookTx').mockImplementation((input: LedgerTxInput) => { + booked.push(input); + activeKeys.add(`${input.sourceId}:${input.seq}`); // a freshly booked (sourceId,seq) is now active + return Promise.resolve({} as any); + }); + jest.spyOn(bookingService, 'nextSeq').mockImplementation(() => Promise.resolve(nextSeqValue)); + // alreadyBooked → hasActiveTxAt: true iff a booking exists AT this (sourceId, seq) (NOT nextSeq>seq, R3) + jest + .spyOn(bookingService, 'hasActiveTxAt') + .mockImplementation((_st: string, sid: string, s: number) => Promise.resolve(activeKeys.has(`${sid}:${s}`))); + + jest.spyOn(accountService, 'findByName').mockImplementation((name: string) => Promise.resolve(accounts.get(name))); + jest + .spyOn(accountService, 'findOrCreate') + .mockImplementation((name: string, type: AccountType, currency: string) => { + const existing = accounts.get(name); + if (existing) return Promise.resolve(existing); + const acc = account(name, type, currency); + accounts.set(name, acc); + return Promise.resolve(acc); + }); + + // gate lookup: existsBy returns true when the gate is open (seq0 crypto_input / cutover marker exists). For the + // G-b cutover path the consumer also resolves the snapshot logId prefix from ledgerCutoverLogId (Blocker R4-2): + // a gate-open run models a completed cutover (logId set) so the `${logId}:buy_crypto:${id}` marker is resolvable. + // The owed-straddling marker (`:buy_crypto-owed:`) is absent by default — a row with a cutover owed opening is + // skipped entirely by book(), so tests that need it mock existsBy explicitly. + jest + .spyOn(ledgerTxRepo, 'existsBy') + .mockImplementation(({ sourceId }: any) => + Promise.resolve(gateOpen && !`${sourceId}`.includes(':buy_crypto-owed:')), + ); + + jest.spyOn(settingService, 'getObj').mockResolvedValue(undefined); + jest + .spyOn(settingService, 'get') + .mockImplementation((key: string) => + Promise.resolve(key === 'ledgerCutoverLogId' && gateOpen ? '1557344' : undefined), + ); + jest.spyOn(settingService, 'set').mockResolvedValue(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TestUtil.provideConfig(), + BuyCryptoConsumer, + { provide: LedgerBookingService, useValue: bookingService }, + { provide: LedgerAccountService, useValue: accountService }, + { provide: SettingService, useValue: settingService }, + { provide: getRepositoryToken(BuyCrypto), useValue: buyCryptoRepo }, + { provide: getRepositoryToken(LedgerTx), useValue: ledgerTxRepo }, + ], + }).compile(); + + consumer = module.get(BuyCryptoConsumer); + }); + + const cents = (legs: LedgerLegInput[]) => legs.reduce((s, l) => s + Math.round((l.amountChf ?? 0) * 100), 0); + // forward id-scan returns the rows; the §4.12 content-change scan (where has `updated`, not `id`) returns [] — + // its late-settling/cutover-straddling coverage is asserted in the integration spec (no double-book here) + const mockBatch = (rows: BuyCrypto[]) => + jest + .spyOn(buyCryptoRepo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [] : rows)); + const seq = (n: number) => booked.find((b) => b.seq === n); + const leg = (tx: LedgerTxInput, name: string) => tx.legs.find((l) => l.account.name === name); + + it('is defined', () => { + expect(consumer).toBeDefined(); + }); + + // §4.6 seq0 — Card input only: Dr ASSET/Checkout (native = card-currency gross, amountChf = gross CHF, F2) / + // Cr LIABILITY/buyCrypto-received (= amountInChf) + it('books a Card input seq0 against Checkout custody (Bank/crypto inputs are NOT booked here)', async () => { + gateOpen = false; // no cutover → no per-row received marker → the forward seq0 IS this Card row's own opening (B1) + mockBatch([ + buyCrypto({ id: 1, amountInChf: 1000, inputReferenceAmount: 1050, checkoutTx: { currency: 'EUR' } as any }), + ]); + await consumer.process(); + + const tx = seq(0); + const custody = leg(tx, 'Checkout/EUR'); + expect(custody.amount).toBe(1050); // F2: native = card-currency GROSS (inputReferenceAmount), not a CHF number + expect(custody.amountChf).toBe(1000); // gross CHF + expect(custody.priceChf).toBe(Util.round(1000 / 1050, 8)); // implied CHF-per-card-unit conversion of this row + expect(leg(tx, 'LIABILITY/buyCrypto-received').amountChf).toBe(-1000); // CHF-denominated LIABILITY unchanged + expect(cents(tx.legs)).toBe(0); + }); + + // F2 fail-loud: a priced Card row (amountInChf set) without its card-currency reference amount is a data error — + // booking native 0 on the card-currency custody account would silently corrupt the account's native unit → throw + // → failure-isolation (nothing booked, watermark not advanced, retried next run). + it('throws (failure-isolation) on a Card row with amountInChf but no inputReferenceAmount', async () => { + gateOpen = false; // no cutover marker → buildCardInputSeq0 reaches the inputReferenceAmount fail-loud (B1) + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + mockBatch([ + buyCrypto({ id: 25, amountInChf: 1000, inputReferenceAmount: null, checkoutTx: { currency: 'EUR' } as any }), + ]); + await consumer.process(); + + expect(booked).toHaveLength(0); + expect(setSpy).not.toHaveBeenCalled(); // throw → watermark stays put + }); + + it('does NOT book seq0 for a non-Card input (no checkoutTx → CryptoInput/BankTx single booker)', async () => { + mockBatch([buyCrypto({ id: 2, amountInChf: 1000, isComplete: false })]); + await consumer.process(); + expect(seq(0)).toBeUndefined(); + }); + + // §10.2 (Major R4-3) — completion chain closes received to 0, owed = −(amountInChf − fee), 4-leg single tx + it('books a cent-exact 4-leg completion (fee against received + reclassification received→owed)', async () => { + mockBatch([buyCrypto({ id: 3, amountInChf: 15000, totalFeeAmountChf: 148.5, isComplete: true })]); + await consumer.process(); + + const tx = seq(1); + expect(tx.legs).toHaveLength(4); + const receivedLegs = tx.legs.filter((l) => l.account.name === 'LIABILITY/buyCrypto-received'); + const receivedSum = receivedLegs.reduce((s, l) => s + (l.amountChf ?? 0), 0); + expect(receivedSum).toBe(15000); // +148.50 (fee) + 14851.50 (reclass) → closes the −15000 received to 0 + expect(leg(tx, 'INCOME/fee-buyCrypto').amountChf).toBe(-148.5); + expect(leg(tx, 'LIABILITY/buyCrypto-owed').amountChf).toBe(-14851.5); + expect(cents(tx.legs)).toBe(0); + }); + + // §5.1 additive null-strategy (line 149): a completion with totalFeeAmountChf null → fee 0, reclass = amountInChf. + // The fee leg is +0 and owed closes at the full amountInChf (no fee carved out). + it('treats a null totalFeeAmountChf as fee 0 in the completion (additive null-strategy)', async () => { + mockBatch([buyCrypto({ id: 33, amountInChf: 15000, totalFeeAmountChf: null, isComplete: true })]); + await consumer.process(); + + const tx = seq(1); + expect(tx).toBeDefined(); + expect(leg(tx, 'INCOME/fee-buyCrypto').amountChf).toBe(-0); // fee 0 → INCOME credit −0 + expect(leg(tx, 'LIABILITY/buyCrypto-owed').amountChf).toBe(-15000); // owed = −(amountInChf − 0) + const receivedSum = tx.legs + .filter((l) => l.account.name === 'LIABILITY/buyCrypto-received') + .reduce((s, l) => s + (l.amountChf ?? 0), 0); + expect(receivedSum).toBe(15000); // closes the −15000 received to 0 + expect(cents(tx.legs)).toBe(0); + }); + + // §4.6 paymentLink: the fee leg is INCOME/fee-paymentLink instead of fee-buyCrypto + it('books the completion fee as INCOME/fee-paymentLink when paymentLinkPayment is present', async () => { + mockBatch([ + buyCrypto({ + id: 4, + amountInChf: 1000, + totalFeeAmountChf: 10, + isComplete: true, + cryptoInput: { paymentLinkPayment: { id: 1 } } as any, + }), + ]); + await consumer.process(); + + const tx = seq(1); + expect(leg(tx, 'INCOME/fee-paymentLink').amountChf).toBe(-10); + expect(leg(tx, 'INCOME/fee-buyCrypto')).toBeUndefined(); + expect(cents(tx.legs)).toBe(0); + }); + + // §4.7 G-a/G-b gate: seq1 is skipped while `received` is not yet opened (CryptoInput consumer not caught up) + it('skips the completion (seq1) while the received gate is closed (no seq0/cutover opening)', async () => { + gateOpen = false; + mockBatch([buyCrypto({ id: 5, amountInChf: 1000, totalFeeAmountChf: 10, isComplete: true })]); + await consumer.process(); + expect(seq(1)).toBeUndefined(); + }); + + it('does NOT advance the watermark past a gate-blocked completion (retry next run)', async () => { + gateOpen = false; + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + mockBatch([buyCrypto({ id: 6, amountInChf: 1000, totalFeeAmountChf: 10, isComplete: true })]); + await consumer.process(); + expect(setSpy).not.toHaveBeenCalled(); // watermark unchanged + }); + + it('does nothing for a not-yet-complete crypto-input buy_crypto (no seq0, no seq1)', async () => { + mockBatch([buyCrypto({ id: 7, amountInChf: 1000, isComplete: false })]); + await consumer.process(); + expect(booked).toHaveLength(0); + }); + + it('is idempotent: skips seq1 when an active booking already exists at seq1 (re-run)', async () => { + activeKeys.add('8:0').add('8:1'); // seq0 + completion seq1 of buy_crypto 8 already booked + mockBatch([buyCrypto({ id: 8, amountInChf: 1000, totalFeeAmountChf: 10, isComplete: true })]); + await consumer.process(); + expect(seq(1)).toBeUndefined(); + }); + + // R3 — content-change reversal of seq0 BEFORE the completion is booked must NOT strand seq1: the reversal/re-book + // live in the correction range (≥1_000_000), seq1 is still free, and the completion books + closes received to 0. + it('books the completion (seq1) even after a seq0 content-change reversal (no stranded later seq, R3)', async () => { + // model the post-reversal ledger state: seq0 reversed+rebooked into the correction range; seq1 NOT yet booked. + // hasActiveTxAt(seq0)=true (a live re-book exists), hasActiveTxAt(seq1)=false (never booked) — the exact state the + // old `nextSeq>seq` gate mis-read as "seq1 booked" because MAX(seq) had jumped into the correction range. + activeKeys.add('10:0'); + nextSeqValue = 1_000_002; // MAX(seq) jumped past 1 after the reversal/re-book — the trap the old gate fell into + mockBatch([buyCrypto({ id: 10, amountInChf: 1000, totalFeeAmountChf: 10, isComplete: true })]); + + await consumer.process(); + + const tx = seq(1); + expect(tx).toBeDefined(); // completion booked despite MAX(seq) being far above 1 + const receivedSum = tx.legs + .filter((l) => l.account.name === 'LIABILITY/buyCrypto-received') + .reduce((s, l) => s + (l.amountChf ?? 0), 0); + expect(receivedSum).toBe(1000); // +10 fee + 990 reclass → closes the −1000 received to 0 + expect(cents(tx.legs)).toBe(0); + }); + + it('advances the watermark after a successful batch', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + mockBatch([buyCrypto({ id: 9, amountInChf: 1000, totalFeeAmountChf: 10, isComplete: true })]); + await consumer.process(); + const written = JSON.parse(setSpy.mock.calls[0][1]); + expect(written.lastProcessedId).toBe(9); + }); + + it('no-ops on an empty batch', async () => { + mockBatch([]); + await consumer.process(); + expect(booked).toHaveLength(0); + }); + + // --- ERROR / GATE BRANCHES --- // + + // §4.6 failure-isolation: a Card input whose Checkout account is missing throws in bookCardInput → break, watermark + // not advanced (retry next run) + it('stops the batch and leaves the watermark when the Checkout account is missing (failure-isolation)', async () => { + gateOpen = false; // no cutover marker → buildCardInputSeq0 reaches checkoutAccount() → throws on the missing acct (B1) + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + accounts.delete('Checkout/EUR'); // findByName('Checkout/EUR') → undefined → throw in checkoutAccount + mockBatch([ + buyCrypto({ id: 20, amountInChf: 1000, inputReferenceAmount: 1050, checkoutTx: { currency: 'EUR' } as any }), + ]); + await consumer.process(); + + expect(booked).toHaveLength(0); + expect(setSpy).not.toHaveBeenCalled(); // throw → break before advancing + }); + + // §4.7 G-a gate: a non-Card buy_crypto whose crypto_input seq0 ledger_tx exists (existsBy) opens the completion + it('books the completion via the G-a gate (crypto_input seq0 ledger_tx exists)', async () => { + // non-Card (no checkoutTx) but the crypto_input seq0 ledger_tx exists → receivedOpened true via G-a (existsBy) + mockBatch([ + buyCrypto({ + id: 21, + amountInChf: 1000, + totalFeeAmountChf: 10, + isComplete: true, + cryptoInput: { id: 555 } as any, // G-a: existsBy(crypto_input, 555, seq0) true (gateOpen=true default) + }), + ]); + await consumer.process(); + + const tx = seq(1); + expect(tx).toBeDefined(); // G-a opened received → completion books + const receivedSum = tx.legs + .filter((l) => l.account.name === 'LIABILITY/buyCrypto-received') + .reduce((s, l) => s + (l.amountChf ?? 0), 0); + expect(receivedSum).toBe(1000); // closes received to 0 + }); + + // §4.7 receivedOpened: cutover not run (ledgerCutoverLogId unset) AND no G-a → gate closed → completion skipped + it('skips the completion when the cutover has not run and no G-a opening exists', async () => { + gateOpen = false; // existsBy → false AND ledgerCutoverLogId → undefined + mockBatch([ + buyCrypto({ + id: 22, + amountInChf: 1000, + totalFeeAmountChf: 10, + isComplete: true, + cryptoInput: { id: 556 } as any, // G-a existsBy → false; cutoverReceivedSourceId → undefined (no logId) + }), + ]); + await consumer.process(); + + expect(seq(1)).toBeUndefined(); // gate closed → no completion + }); + + // §4.6 bookCompletion guard: an isComplete row with null amountInChf throws → failure-isolation + it('throws (failure-isolation) on a complete buy_crypto with null amountInChf', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + // Card input so seq0 is skipped (amountInChf null → buildCardInputSeq0 returns undefined, no seq0), but isComplete + // → book() reaches bookCompletion which throws on the null amountInChf. + mockBatch([buyCrypto({ id: 23, amountInChf: null, isComplete: true, checkoutTx: { currency: 'EUR' } as any })]); + await consumer.process(); + + expect(booked).toHaveLength(0); // seq0 skipped (no amountInChf), completion throws + expect(setSpy).not.toHaveBeenCalled(); // throw → watermark not advanced + }); + + // --- §4.12 / §6.3 CONTENT-CHANGE SCAN --- // + + // M3: the content-change scan reverse-and-rebooks the value-coupled Card seq0 / completion seq1 CHAIN, then books any + // newly-settled completion. Reversing only seq0 while seq1 is booked would leave `received` non-zero + // (liability closure breaks) → the chain method reverses the whole active chain atomically. + it('runs the content-change scan: reverse-and-rebooks the Card seq0/seq1 chain then books the completion', async () => { + gateOpen = false; // no cutover marker → the Card seq0 IS this row's opening, so the chain carries both seq0 + seq1 (B1) + const chainSpy = jest.spyOn(bookingService, 'reverseAndRebookChainIfChanged').mockResolvedValue(true); + const changed = buyCrypto({ + id: 30, + amountInChf: 1000, + inputReferenceAmount: 1050, // card-currency gross (F2 custody native) + totalFeeAmountChf: 10, + isComplete: true, + checkoutTx: { currency: 'EUR' } as any, // Card input → buildCardInputSeq0 resolves → chain carries seq0 + seq1 + }); + // forward id-scan empty; content-change scan (where.updated) returns the changed Card row + jest + .spyOn(buyCryptoRepo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [changed] : [])); + + await consumer.process(); + + expect(chainSpy).toHaveBeenCalledTimes(1); // the value-coupled chain reverse-and-rebook ran + const chain = chainSpy.mock.calls[0][0]; + expect(chain.map((i) => i.seq).sort((a, b) => a - b)).toEqual([0, 1]); // BOTH the Card seq0 and the completion seq1 + expect(chain.every((i) => i.sourceId === '30')).toBe(true); + // and the idempotent forward book() then appended the completion seq1 + expect(booked.some((b) => b.seq === 1 && b.sourceId === '30')).toBe(true); + }); + + // a content-change row that is a NON-Card input has no Card seq0 here → the value-coupled chain carries only the + // completion seq1; the idempotent book() still appends the completion. + it('content-change scan: a non-Card row has no Card seq0 in the chain but still books its completion', async () => { + const chainSpy = jest.spyOn(bookingService, 'reverseAndRebookChainIfChanged').mockResolvedValue(false); + const changed = buyCrypto({ + id: 31, + amountInChf: 1000, + totalFeeAmountChf: 10, + isComplete: true, + cryptoInput: { id: 557 } as any, // non-Card → no seq0 here, G-a gate open (gateOpen default) + }); + jest + .spyOn(buyCryptoRepo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [changed] : [])); + + await consumer.process(); + + expect(chainSpy).toHaveBeenCalledTimes(1); + expect(chainSpy.mock.calls[0][0].map((i) => i.seq)).toEqual([1]); // only the completion seq1, no Card seq0 + expect(booked.some((b) => b.seq === 1 && b.sourceId === '31')).toBe(true); // completion still booked + }); + + // C2: a row settled at/before the cutover snapshot whose gate is permanently closed (its value is already in the + // aggregate opening) must be SKIPPED+advanced by the content-change scan, NOT throw — throwing would wedge the scan + // forever at this cursor. A post-cutover flag change re-selects the row; book() gate-blocks; the scan advances. + it('advances (does NOT throw/wedge) for a pre-cutover-settled gate-blocked row in the content-change scan (C2)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + jest.spyOn(settingService, 'get').mockImplementation((key: string) => { + if (key === 'ledgerCutoverLogId') return Promise.resolve('1557344'); + if (key === 'ledgerCutoverSnapshotDate') return Promise.resolve('2026-06-07T22:00:00.000Z'); + return Promise.resolve(undefined); + }); + jest.spyOn(ledgerTxRepo, 'existsBy').mockResolvedValue(false); // gate closed: neither G-a nor G-b opening + const settled = buyCrypto({ + id: 45, + amountInChf: 1000, + totalFeeAmountChf: 10, + isComplete: true, + outputDate: new Date('2026-05-15T00:00:00Z'), // settled BEFORE the snapshot → value in the aggregate opening + updated: new Date('2026-06-20T00:00:00Z'), // a post-cutover flag change re-selects it in the content-change scan + cryptoInput: { id: 558 } as any, // non-Card, gate closed (no crypto_input seq0 booked) + }); + jest + .spyOn(buyCryptoRepo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [settled] : [])); + + await expect(consumer.process()).resolves.toBeUndefined(); // no throw propagates out of the scan + expect(booked.some((b) => b.sourceId === '45')).toBe(false); // value in the aggregate opening → nothing booked + expect(setSpy).toHaveBeenCalled(); // the content-change cursor ADVANCED past it (skip, not throw/wedge) + }); + + // --- §6.3 CUTOVER CARD DOUBLE-BOOK GUARD (MAJOR: settledBeforeCutover) --- // + + // a Card row already settled at the cutover (outputDate ≤ snapshot) whose `updated` moves post-cutover (chargeback/ + // mail flag) must NOT be re-booked — its value is in the aggregate opening. Neither seq0 nor seq1 is booked. + it('does NOT re-book a Card row settled before the cutover (Scenario B, double-book guard)', async () => { + jest.spyOn(settingService, 'get').mockImplementation((key: string) => { + if (key === 'ledgerCutoverLogId') return Promise.resolve('1557344'); + if (key === 'ledgerCutoverSnapshotDate') return Promise.resolve('2026-06-07T22:00:00.000Z'); + return Promise.resolve(undefined); + }); + const changed = buyCrypto({ + id: 40, + amountInChf: 1000, + totalFeeAmountChf: 10, + isComplete: true, + outputDate: new Date('2026-05-15T00:00:00Z'), // settled BEFORE the snapshot → covered by the aggregate opening + updated: new Date('2026-06-20T00:00:00Z'), // a post-cutover flag change re-selects it in the content-change scan + checkoutTx: { currency: 'EUR' } as any, + }); + jest + .spyOn(buyCryptoRepo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [changed] : [])); + + await consumer.process(); + + expect(booked.some((b) => b.sourceId === '40')).toBe(false); // neither seq0 nor seq1 booked + }); + + // Major B1: a Card row OPEN at the cutover that completes AFTER it (outputDate > snapshot) has its gross in the + // aggregate ASSET opening AND a per-row cutover buyCrypto-received opening (the default existsBy models the marker as + // present). The forward seq0 is SKIPPED (re-booking would double-debit the gross on Checkout/{ccy}); only seq1 books, + // closing received against the cutover opening. (Scenario A) + it('skips seq0 (cutover received marker present) and books only seq1 for an open-at-cutover Card row (Scenario A)', async () => { + jest.spyOn(settingService, 'get').mockImplementation((key: string) => { + if (key === 'ledgerCutoverLogId') return Promise.resolve('1557344'); + if (key === 'ledgerCutoverSnapshotDate') return Promise.resolve('2026-06-07T22:00:00.000Z'); + return Promise.resolve(undefined); + }); + const changed = buyCrypto({ + id: 41, + amountInChf: 1000, + inputReferenceAmount: 1050, // card-currency gross (F2 custody native) + totalFeeAmountChf: 10, + isComplete: true, + outputDate: new Date('2026-06-20T00:00:00Z'), // completed AFTER the snapshot → completion booked post-cutover + updated: new Date('2026-06-20T00:00:00Z'), + checkoutTx: { currency: 'EUR' } as any, + }); + jest + .spyOn(buyCryptoRepo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [changed] : [])); + + await consumer.process(); + + const s0 = booked.find((b) => b.sourceId === '41' && b.seq === 0); + const s1 = booked.find((b) => b.sourceId === '41' && b.seq === 1); + expect(s0).toBeUndefined(); // forward seq0 SKIPPED (cutover received marker present → would double-debit the gross) + expect(s1).toBeDefined(); // completion books against the cutover received opening (gate open via G-b marker) + const receivedSum = s1.legs + .filter((l) => l.account.name === 'LIABILITY/buyCrypto-received') + .reduce((s, l) => s + (l.amountChf ?? 0), 0); + expect(receivedSum).toBe(1000); // seq1 debits received +10 fee + +990 reclass → closes the cutover opening's −1000 + }); + + // Major B1: a POST-cutover Card row (cutover has run, but this row was created after the snapshot → no per-row cutover + // opening) books its OWN forward seq0 as the received opening; the gate then opens via that seq0 (alreadyBooked), not a + // marker. Distinguishes the no-marker forward path from the open-at-cutover G-b path above. + it('books seq0 (its own opening) + seq1 for a post-cutover Card row with no cutover marker', async () => { + jest.spyOn(settingService, 'get').mockImplementation((key: string) => { + if (key === 'ledgerCutoverLogId') return Promise.resolve('1557344'); + if (key === 'ledgerCutoverSnapshotDate') return Promise.resolve('2026-06-07T22:00:00.000Z'); + return Promise.resolve(undefined); + }); + jest.spyOn(ledgerTxRepo, 'existsBy').mockResolvedValue(false); // no per-row cutover received/owed marker for this row + mockBatch([ + buyCrypto({ + id: 42, + amountInChf: 1000, + inputReferenceAmount: 1050, + totalFeeAmountChf: 10, + isComplete: true, + outputDate: new Date('2026-06-20T00:00:00Z'), // created + completed after the cutover → no per-row opening + checkoutTx: { currency: 'EUR' } as any, + }), + ]); + + await consumer.process(); + + const s0 = booked.find((b) => b.sourceId === '42' && b.seq === 0); + const s1 = booked.find((b) => b.sourceId === '42' && b.seq === 1); + expect(s0).toBeDefined(); // no marker → the forward seq0 IS this Card row's own received opening + expect(s1).toBeDefined(); // gate opened by that forward seq0 (alreadyBooked) → completion books + const receivedSum = [...s0.legs, ...s1.legs] + .filter((l) => l.account.name === 'LIABILITY/buyCrypto-received') + .reduce((s, l) => s + (l.amountChf ?? 0), 0); + expect(receivedSum).toBe(0); // seq0 −1000 + seq1 (+10 +990) → received closes to 0 + }); + + // --- F1: BANK-FINANCED buy_crypto GATE (§4.7 bank branch) --- // + + // A BANK-financed buy_crypto (bankTx set, no checkoutTx, no cryptoInput) has its buyCrypto-received opened by the + // BankTx consumer as the bank_tx seq0 booking — NOT by a crypto_input seq0 (G-a) nor a cutover marker (G-b). + // receivedOpened must gate on an ACTIVE bank_tx seq0 tx for the funding bank_tx, else seq1 is never booked. + it('books the bank-financed completion (seq1) once the bank_tx seq0 received-opening exists (F1 bank branch)', async () => { + gateOpen = false; // G-a (existsBy) and G-b (cutover logId) both closed + activeKeys.add('500:0'); // the BankTx consumer opened buyCrypto-received at bank_tx seq0 (bank_tx id 500) + mockBatch([ + buyCrypto({ + id: 1, + isComplete: true, + amountInChf: 1000, + totalFeeAmountChf: 10, + outputDate: new Date('2026-06-05'), + bankTx: { id: 500 } as any, // funding bank_tx id ≠ buy_crypto id + }), + ]); + await consumer.process(); + + expect(seq(1)).toBeDefined(); // bank branch opened the gate → completion books + expect(cents(seq(1).legs)).toBe(0); + }); + + // F1 negative: with G-a/G-b closed AND no bank_tx seq0 opening, the bank-financed completion stays gate-blocked + it('skips the bank-financed completion while no bank_tx seq0 opening exists (F1 gate closed)', async () => { + gateOpen = false; // activeKeys empty → hasActiveTxAt('bank_tx', '500', 0) is false + mockBatch([ + buyCrypto({ + id: 1, + isComplete: true, + amountInChf: 1000, + totalFeeAmountChf: 10, + outputDate: new Date('2026-06-05'), + bankTx: { id: 500 } as any, + }), + ]); + await consumer.process(); + + expect(seq(1)).toBeUndefined(); // no bank opening + G-a/G-b closed → gate closed + }); + + // F1 content-change gate-block: a bank-financed row re-selected by the §4.12 content-change scan whose received is + // NOT yet opened must THROW inside the scan callback so the combined (updated, id) cursor is NOT advanced past it — + // the late-settling row is retried next run, not lost (a swallowed false return would silently drop the completion). + it('does NOT advance the content-change cursor when a bank-financed completion is gate-blocked (F1)', async () => { + gateOpen = false; + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + const blocked = buyCrypto({ + id: 50, + isComplete: true, + amountInChf: 1000, + totalFeeAmountChf: 10, + bankTx: { id: 500 } as any, // no bank_tx seq0 opening (activeKeys empty) → gate closed + }); + // forward id-scan empty; the content-change scan (where.updated) returns the gate-blocked bank row + jest + .spyOn(buyCryptoRepo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [blocked] : [])); + + await expect(consumer.process()).resolves.toBeUndefined(); // the scan swallows the throw internally + + expect(seq(1)).toBeUndefined(); // completion NOT booked (gate closed) + expect(setSpy).not.toHaveBeenCalled(); // content-change cursor NOT advanced past the gate-blocked row + }); + + // --- F3: PAYMENTLINK-FUNDED buy_crypto COMPLETION --- // + + // F3: a paymentLink-funded buy_crypto's crypto_input seq0 (§4.4 isPayment) credited LIABILITY/paymentLink — NOT + // buyCrypto-received. The completion seq1 MUST clear paymentLink (fee → INCOME/fee-paymentLink + reclassification → + // owed + venue-spread), NOT debit buyCrypto-received (never credited for this row). Debiting the wrong bucket drifts + // both liabilities apart unbounded (equity-neutral, parity-blind). seq0 opening = 1000 → seq1 debits paymentLink by + // exactly 1000 → paymentLink closes to 0 and buyCrypto-received is never touched. + it('clears paymentLink (not buyCrypto-received) for a paymentLink-funded completion; both liabilities to 0 (F3)', async () => { + // the crypto_input seq0 paymentLink opening leg = −1000 (Mark×amount of the crypto received) + jest.spyOn(ledgerTxRepo, 'findOne').mockImplementation(({ where }: any) => { + if (where?.sourceType === 'crypto_input') { + const pl = account('LIABILITY/paymentLink', AccountType.LIABILITY, 'CHF'); + const plLeg = Object.assign(new LedgerLeg(), { account: pl, amountChf: -1000 }); + return Promise.resolve(Object.assign(new LedgerTx(), { legs: [plLeg] })); + } + return Promise.resolve(null); + }); + mockBatch([ + buyCrypto({ + id: 70, + amountInChf: 1000, + totalFeeAmountChf: 10, + isComplete: true, + cryptoInput: { id: 555, paymentLinkPayment: { id: 1 } } as any, // isPayment → seq0 credited paymentLink + }), + ]); + await consumer.process(); + + const tx = seq(1); + expect(tx).toBeDefined(); + expect(leg(tx, 'INCOME/fee-paymentLink').amountChf).toBe(-10); // fee realized as paymentLink income + expect(leg(tx, 'LIABILITY/buyCrypto-owed').amountChf).toBe(-990); // reclassification → owed = −(1000 − 10) + expect(tx.legs.some((l) => l.account.name === 'LIABILITY/buyCrypto-received')).toBe(false); // received NEVER touched + // paymentLink debited by fee 10 + reclass 990 + venueSpread 0 = 1000 → closes the seq0 −1000 credit to 0 + const plDr = tx.legs + .filter((l) => l.account.name === 'LIABILITY/paymentLink') + .reduce((s, l) => s + (l.amountChf ?? 0), 0); + expect(plDr).toBe(1000); + expect(cents(tx.legs)).toBe(0); + }); + + // F3: a paymentLink-funded buy_crypto with a venue spread (seq0 opening 1050 vs amountInChf 1000) → the extra 50 goes + // to the fx-revaluation plug (valuation drift, NOT income); paymentLink still closes to exactly the opening (1050). + it('routes the paymentLink venue spread to fx-revaluation and still closes paymentLink to the opening (F3)', async () => { + jest.spyOn(ledgerTxRepo, 'findOne').mockImplementation(({ where }: any) => { + if (where?.sourceType === 'crypto_input') { + const pl = account('LIABILITY/paymentLink', AccountType.LIABILITY, 'CHF'); + const plLeg = Object.assign(new LedgerLeg(), { account: pl, amountChf: -1050 }); // opening 1050 > amountInChf 1000 + return Promise.resolve(Object.assign(new LedgerTx(), { legs: [plLeg] })); + } + return Promise.resolve(null); + }); + mockBatch([ + buyCrypto({ + id: 71, + amountInChf: 1000, + totalFeeAmountChf: 10, + isComplete: true, + cryptoInput: { id: 556, paymentLinkPayment: { id: 1 } } as any, + }), + ]); + await consumer.process(); + + const tx = seq(1); + const fx = leg(tx, 'INCOME/fx-revaluation'); + expect(fx.amountChf).toBe(-50); // venueSpread +50 ≥ 0 → INCOME credit −venueSpread + expect(leg(tx, 'LIABILITY/buyCrypto-owed').amountChf).toBe(-990); + const plDr = tx.legs + .filter((l) => l.account.name === 'LIABILITY/paymentLink') + .reduce((s, l) => s + (l.amountChf ?? 0), 0); + expect(plDr).toBe(1050); // fee 10 + reclass 990 + venueSpread 50 → closes the −1050 opening to 0 + expect(cents(tx.legs)).toBe(0); + }); + + // --- F2: UNPRICED-AT-CUTOVER GUARD --- // + + // F2: a Card row unpriced at the cutover (amountInChf NULL then → pinned) that gets priced post-cutover must NOT + // re-book its forward seq0 — its card-currency gross is already in the aggregate ASSET opening (the Checkout collateral + // feed); re-debiting Checkout/{ccy} would double-count. buildCardInputSeq0 skips the pinned id and the gate-blocked + // completion skips+advances with an ERROR alarm (no head-of-line wedge), instead of throwing forever. + it('does NOT re-book the Card seq0 gross for an unpriced-at-cutover row and skips+advances with an alarm (F2)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + const errorSpy = jest.spyOn((consumer as any).logger, 'error'); + jest + .spyOn(settingService, 'get') + .mockImplementation((key: string) => Promise.resolve(key === 'ledgerCutoverLogId' ? '1557344' : undefined)); + jest + .spyOn(settingService, 'getObj') + .mockImplementation((key: string) => + Promise.resolve(key === 'ledgerCutoverUnpricedIds.buy_crypto' ? [80] : undefined), + ); + jest.spyOn(ledgerTxRepo, 'existsBy').mockResolvedValue(false); // no per-row cutover received/owed opening for id 80 + const priced = buyCrypto({ + id: 80, + amountInChf: 1000, // priced now (was NULL at the cutover → pinned) + inputReferenceAmount: 1050, + totalFeeAmountChf: 10, + isComplete: true, + outputDate: new Date('2026-06-20T00:00:00Z'), // completed AFTER the snapshot (straddling, not pre-cutover-settled) + updated: new Date('2026-06-20T00:00:00Z'), + checkoutTx: { currency: 'EUR' } as any, + }); + jest + .spyOn(buyCryptoRepo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [priced] : [])); + + await expect(consumer.process()).resolves.toBeUndefined(); // no throw + + expect(booked.some((b) => b.sourceId === '80' && b.seq === 0)).toBe(false); // Card seq0 NOT re-booked (no double gross) + expect(booked.some((b) => b.sourceId === '80' && b.seq === 1)).toBe(false); // completion gate-blocked → skipped + expect(setSpy).toHaveBeenCalled(); // content-change cursor ADVANCED (skip, not wedge) + expect(errorSpy.mock.calls.some((c) => /unpriced at the cutover/i.test(String(c[0])))).toBe(true); // F2 alarm + }); +}); diff --git a/src/subdomains/core/accounting/services/consumers/__tests__/buy-fiat.consumer.spec.ts b/src/subdomains/core/accounting/services/consumers/__tests__/buy-fiat.consumer.spec.ts new file mode 100644 index 0000000000..3372494919 --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/__tests__/buy-fiat.consumer.spec.ts @@ -0,0 +1,1366 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { TestUtil } from 'src/shared/utils/test.util'; +import { BuyFiat } from 'src/subdomains/core/sell-crypto/process/buy-fiat.entity'; +import { Repository } from 'typeorm'; +import { AccountType, LedgerAccount } from '../../../entities/ledger-account.entity'; +import { LedgerLeg } from '../../../entities/ledger-leg.entity'; +import { LedgerTx } from '../../../entities/ledger-tx.entity'; +import { createCustomLedgerAccount } from '../../../entities/__mocks__/ledger-account.entity.mock'; +import { LedgerAccountService } from '../../ledger-account.service'; +import { LedgerBookingService, LedgerLegInput, LedgerTxInput } from '../../ledger-booking.service'; +import { LedgerMarkCache, LedgerMarkService } from '../../ledger-mark.service'; +import { BuyFiatConsumer } from '../buy-fiat.consumer'; + +const CHF_BANK_ASSET_ID = 401; +const EUR_BANK_ASSET_ID = 402; + +const FRI = new Date('2026-06-05T00:00:00Z'); // transmission +const SUN = new Date('2026-06-07T00:00:00Z'); // bank booking (Class-1 hold) + +function buyFiat(values: Record): BuyFiat { + return Object.assign(new BuyFiat(), { + id: 1, + updated: new Date('2026-06-04T00:00:00Z'), + cryptoInput: { id: 10, updated: new Date('2026-06-04T00:00:00Z') }, + outputAsset: { name: 'CHF' }, + ...values, + }); +} + +function account(name: string, type: AccountType, currency: string, assetId?: number): LedgerAccount { + return createCustomLedgerAccount({ id: Math.floor(Math.random() * 1e6), name, type, currency, assetId } as any); +} + +describe('BuyFiatConsumer', () => { + let consumer: BuyFiatConsumer; + let bookingService: LedgerBookingService; + let accountService: LedgerAccountService; + let markService: LedgerMarkService; + let settingService: SettingService; + let buyFiatRepo: Repository; + let ledgerTxRepo: Repository; + + let booked: LedgerTxInput[]; + let accounts: Map; + let nextSeqValue: number; + let activeKeys: Set; // `${sourceId}:${seq}` with an active booking — backs hasActiveTxAt (per-seq, R3) + let gateOpen: boolean; // existsBy result (received/cutover gate) + let seq0PaymentLinkChf: number | undefined; // the seq0 paymentLink opening leg amountChf (negative) + let cutoverOwedOpeningChf: number | undefined; // the cutover buyFiat-owed opening leg amountChf (negative) + let cutoverLogId: string | undefined; // ledgerCutoverLogId setting (enables the owed-opening lookup) + + const chfBank = account('Bank/CHF', AccountType.ASSET, 'CHF', CHF_BANK_ASSET_ID); + const eurBank = account('Bank/EUR', AccountType.ASSET, 'EUR', EUR_BANK_ASSET_ID); + + const markMap = new Map([[EUR_BANK_ASSET_ID, [{ created: new Date('2026-01-01'), priceChf: 0.95 }]]]); + + beforeEach(async () => { + booked = []; + nextSeqValue = 0; + activeKeys = new Set(); + gateOpen = true; + seq0PaymentLinkChf = undefined; + cutoverOwedOpeningChf = undefined; + cutoverLogId = undefined; + accounts = new Map([ + ['Bank/CHF', chfBank], + ['Bank/EUR', eurBank], + ]); + + bookingService = createMock(); + accountService = createMock(); + markService = createMock(); + settingService = createMock(); + buyFiatRepo = createMock>(); + ledgerTxRepo = createMock>(); + + jest.spyOn(bookingService, 'bookTx').mockImplementation((input: LedgerTxInput) => { + booked.push(input); + activeKeys.add(`${input.sourceId}:${input.seq}`); // a freshly booked (sourceId,seq) is now active + return Promise.resolve({} as any); + }); + jest.spyOn(bookingService, 'nextSeq').mockImplementation(() => Promise.resolve(nextSeqValue)); + // alreadyBooked → hasActiveTxAt: true iff a booking exists AT this (sourceId, seq) (NOT nextSeq>seq, R3) + jest + .spyOn(bookingService, 'hasActiveTxAt') + .mockImplementation((_st: string, sid: string, s: number) => Promise.resolve(activeKeys.has(`${sid}:${s}`))); + + jest + .spyOn(accountService, 'findByAssetId') + .mockImplementation((assetId: number) => Promise.resolve(assetId === EUR_BANK_ASSET_ID ? eurBank : chfBank)); + jest + .spyOn(accountService, 'findOrCreate') + .mockImplementation((name: string, type: AccountType, currency: string) => { + const existing = accounts.get(name); + if (existing) return Promise.resolve(existing); + const acc = account(name, type, currency); + accounts.set(name, acc); + return Promise.resolve(acc); + }); + + jest.spyOn(ledgerTxRepo, 'existsBy').mockImplementation(() => Promise.resolve(gateOpen)); + jest.spyOn(ledgerTxRepo, 'findOne').mockImplementation(({ where }: any) => { + // cutover buyFiat-owed opening lookup (§4.7a/§6.1): sourceType='cutover', sourceId=':buy_fiat-owed:' + if (where?.sourceType === 'cutover') { + if (cutoverOwedOpeningChf == null) return Promise.resolve(undefined); + const owedAccount = account('LIABILITY/buyFiat-owed', AccountType.LIABILITY, 'CHF'); + const owedLeg = Object.assign(new LedgerLeg(), { account: owedAccount, amountChf: cutoverOwedOpeningChf }); + return Promise.resolve(Object.assign(new LedgerTx(), { legs: [owedLeg] })); + } + // seq0 paymentLink opening lookup (§4.7b): sourceType='crypto_input' + if (seq0PaymentLinkChf == null) return Promise.resolve(undefined); + const plAccount = account('LIABILITY/paymentLink', AccountType.LIABILITY, 'CHF'); + const leg = Object.assign(new LedgerLeg(), { account: plAccount, amountChf: seq0PaymentLinkChf }); + return Promise.resolve(Object.assign(new LedgerTx(), { legs: [leg] })); + }); + + jest.spyOn(markService, 'preload').mockResolvedValue(new LedgerMarkCache(markMap)); + jest.spyOn(markService, 'getLatestMark').mockResolvedValue(undefined); // B5: no bridge by default (tests opt in) + jest.spyOn(settingService, 'getObj').mockResolvedValue(undefined); + jest.spyOn(settingService, 'get').mockImplementation(() => Promise.resolve(cutoverLogId)); + jest.spyOn(settingService, 'set').mockResolvedValue(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TestUtil.provideConfig(), + BuyFiatConsumer, + { provide: LedgerBookingService, useValue: bookingService }, + { provide: LedgerAccountService, useValue: accountService }, + { provide: LedgerMarkService, useValue: markService }, + { provide: SettingService, useValue: settingService }, + { provide: getRepositoryToken(BuyFiat), useValue: buyFiatRepo }, + { provide: getRepositoryToken(LedgerTx), useValue: ledgerTxRepo }, + ], + }).compile(); + + consumer = module.get(BuyFiatConsumer); + }); + + const cents = (legs: LedgerLegInput[]) => legs.reduce((s, l) => s + Math.round((l.amountChf ?? 0) * 100), 0); + // forward id-scan returns the rows; the §4.12 content-change scan (where has `updated`, not `id`) returns [] — + // its late-settling/cutover-straddling coverage is asserted in the integration spec (no double-book here) + const mockBatch = (rows: BuyFiat[]) => + jest + .spyOn(buyFiatRepo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [] : rows)); + const seq = (n: number) => booked.find((b) => b.seq === n); + const leg = (tx: LedgerTxInput, name: string) => tx.legs.find((l) => l.account.name === name); + const sumOn = (name: string) => + booked + .flatMap((b) => b.legs) + .filter((l) => l.account.name === name) + .reduce((s, l) => s + (l.amountChf ?? 0), 0); + + it('is defined', () => { + expect(consumer).toBeDefined(); + }); + + // regression: Bank.asset is NOT eager, so the seq3 bank-ASSET Cr leg (bankCrLeg / outputMark reads + // bf.fiatOutput.bank.asset) is undefined at runtime unless the scans eager-load it → every tracked settlement + // booking throws and the watermark wedges. BOTH the forward id-scan and the §4.12 content-change scan must request + // fiatOutput.bank.asset. + it('loads fiatOutput.bank.asset in both the forward and the content-change relation trees', async () => { + const findSpy = jest.spyOn(buyFiatRepo, 'find').mockImplementation(() => Promise.resolve([])); + + await consumer.process(); + + const forwardCall = findSpy.mock.calls.find((c) => (c[0] as any).where?.id != null); + const scanCall = findSpy.mock.calls.find((c) => (c[0] as any).where?.updated != null); + expect((forwardCall[0] as any).relations.fiatOutput).toEqual({ bankTx: true, bank: { asset: true } }); + expect((scanCall[0] as any).relations.fiatOutput).toEqual({ bankTx: true, bank: { asset: true } }); + }); + + // §10.2 Class-1-Liability-Hold = the 14'980.12 headline (single bf 68310, 15'000 / 148.50 / 14'851.50, Sunday) + it('books the regular sell chain: received→owed→TRANSIT(hold)→bank, both liabilities close to 0', async () => { + mockBatch([ + buyFiat({ + id: 1, + amountInChf: 15000, + totalFeeAmountChf: 148.5, + outputAmount: 14851.5, + outputReferenceAmount: 14851.5, + outputAsset: { name: 'CHF' }, + fiatOutput: { + isTransmittedDate: FRI, + currency: 'CHF', + bank: { asset: { id: CHF_BANK_ASSET_ID } }, + bankTx: { bookingDate: SUN }, + } as any, + }), + ]); + await consumer.process(); + + // seq1: fee against received + reclassification received→owed + const s1 = seq(1); + expect(s1.legs).toHaveLength(4); + expect( + s1.legs.filter((l) => l.account.name === 'LIABILITY/buyFiat-received').reduce((s, l) => s + l.amountChf, 0), + ).toBe(15000); + expect(leg(s1, 'INCOME/fee-buyFiat').amountChf).toBe(-148.5); + expect(leg(s1, 'LIABILITY/buyFiat-owed').amountChf).toBe(-14851.5); + + // seq2 transmit on Friday (Class-1 hold): owed → TRANSIT + const s2 = seq(2); + expect(s2.bookingDate).toEqual(FRI); + expect(leg(s2, 'LIABILITY/buyFiat-owed').amountChf).toBe(14851.5); + expect(leg(s2, 'TRANSIT/payout/CHF').amountChf).toBe(-14851.5); + + // seq3 booked on Sunday (bank_tx.bookingDate, NOT Friday): TRANSIT → bank + const s3 = seq(3); + expect(s3.bookingDate).toEqual(SUN); + expect(leg(s3, 'TRANSIT/payout/CHF').amountChf).toBe(14851.5); + expect(leg(s3, 'Bank/CHF').amountChf).toBe(-14851.5); + + // seq0 (the −15000 received credit) is booked by the CryptoInput consumer (single booker, §4.1); this + // consumer debits received by exactly +15000 → closes the externally-opened −15000 to 0. + expect(sumOn('LIABILITY/buyFiat-received')).toBe(15000); + // owed: opened −14851.50 (seq1), transmitted +14851.50 (seq2) → closes to 0 within this consumer + expect(sumOn('LIABILITY/buyFiat-owed')).toBe(0); + // TRANSIT held between Friday and Sunday, then closed by seq3 → nets to 0 + expect(sumOn('TRANSIT/payout/CHF')).toBe(0); + for (const tx of booked) expect(cents(tx.legs)).toBe(0); + }); + + // §4.7a EUR output: seq3 carries an FX-P&L leg for the EUR drift between reclassification and booking + it('books an EUR-output seq3 with an fx-revaluation residual leg (§4.7a)', async () => { + mockBatch([ + buyFiat({ + id: 2, + amountInChf: 10000, + totalFeeAmountChf: 0, + outputAmount: 10500, // EUR + outputReferenceAmount: 10500, + outputAsset: { name: 'EUR' }, + fiatOutput: { + isTransmittedDate: FRI, + currency: 'EUR', + bank: { asset: { id: EUR_BANK_ASSET_ID } }, + bankTx: { bookingDate: SUN }, + } as any, + }), + ]); + await consumer.process(); + + const s3 = seq(3); + // TRANSIT Dr = owed_chf (10000); bank Cr = 10500 EUR × 0.95 = 9975 CHF; sum = +25 → residual −25 + expect(leg(s3, 'TRANSIT/payout/EUR').amountChf).toBe(10000); + expect(leg(s3, 'Bank/EUR').amountChf).toBe(-9975); + expect(leg(s3, 'EXPENSE/fx-revaluation').amountChf).toBe(-25); // residual = −(10000 − 9975) = −25 < 0 → EXPENSE + expect(cents(s3.legs)).toBe(0); + }); + + // §4.7a/§6.1 owed-straddling (Blocker R6-1): a pre-cutover open buy_fiat (owed opened by the cutover at the + // opening CHF) settles post-cutover via seq2/seq3 only — seq1 is skipped (would never open the received gate) and + // owed closes cent-exact to 0 with the opening-CHF anchor; the Opening↔Settlement mark drift lands in the FX leg. + it('settles an owed-straddling buy_fiat end-to-end: skip seq1, owed closes to 0 via the opening anchor', async () => { + cutoverLogId = '1557344'; + cutoverOwedOpeningChf = -9500; // cutover opened buyFiat-owed at outputAmount(10000 EUR) × mark@snapshot(0.95) + gateOpen = false; // the received seq0 gate would NEVER open (the financing crypto_input settled pre-cutover) + mockBatch([ + buyFiat({ + id: 6, + amountInChf: 10000, + totalFeeAmountChf: 50, + outputAmount: 10000, // EUR + outputReferenceAmount: 10000, + outputAsset: { name: 'EUR' }, + fiatOutput: { + isTransmittedDate: FRI, + currency: 'EUR', + bank: { asset: { id: EUR_BANK_ASSET_ID } }, + bankTx: { bookingDate: SUN }, + } as any, + }), + ]); + await consumer.process(); + + // seq1 is NOT booked (owed-straddling: reclassification ran pre-cutover, anchored in the cutover opening) + expect(seq(1)).toBeUndefined(); + + // seq2 transmit: owed-Dr = opening anchor (+9500), NOT the completion CHF (10000 − 50 = 9950) + const s2 = seq(2); + expect(leg(s2, 'LIABILITY/buyFiat-owed').amountChf).toBe(9500); + expect(leg(s2, 'TRANSIT/payout/EUR').amountChf).toBe(-9500); + + // seq3 booked: TRANSIT +9500; bank Cr = 10000 EUR × 0.95 = −9500 → drift 0 here, closes flat + const s3 = seq(3); + expect(leg(s3, 'TRANSIT/payout/EUR').amountChf).toBe(9500); + expect(leg(s3, 'Bank/EUR').amountChf).toBe(-9500); + + // owed: opened −9500 (cutover, external), debited +9500 (seq2) → closes cent-exact to 0 + expect(sumOn('LIABILITY/buyFiat-owed')).toBe(9500); // this consumer's debit; the −9500 opening was external + expect(sumOn('TRANSIT/payout/EUR')).toBe(0); + for (const tx of booked) expect(cents(tx.legs)).toBe(0); + }); + + // §4.7 G-a/G-b gate: seq1 skipped while received not opened; watermark not advanced past the row + it('skips seq1 and does not advance the watermark while the received gate is closed', async () => { + gateOpen = false; // neither G-a nor G-b + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + mockBatch([ + buyFiat({ + id: 3, + amountInChf: 15000, + totalFeeAmountChf: 148.5, + outputAmount: 14851.5, + outputReferenceAmount: 14851.5, + }), + ]); + await consumer.process(); + expect(seq(1)).toBeUndefined(); + expect(setSpy).not.toHaveBeenCalled(); + }); + + // §4.7b PaymentLink merchant payout: clears LIABILITY/paymentLink (not received/owed) to 0 + it('books the paymentLink merchant path: fee → INCOME/fee-paymentLink, paymentLink closes to 0 (Blocker R8-1)', async () => { + seq0PaymentLinkChf = -1000; // seq0 opened paymentLink at −Mark×amount (1000 CHF crypto value) + mockBatch([ + buyFiat({ + id: 4, + amountInChf: 1000, + totalFeeAmountChf: 20, + paymentLinkFee: 0.01, + outputReferenceAmount: 950, // fiat reference + outputAmount: 940, // net merchant fiat (= reference − plFee 10) + outputAsset: { name: 'CHF' }, + cryptoInput: { id: 10, updated: new Date('2026-06-04T00:00:00Z'), paymentLinkPayment: { id: 1 } }, + fiatOutput: { + isTransmittedDate: FRI, + currency: 'CHF', + bank: { asset: { id: CHF_BANK_ASSET_ID } }, + bankTx: { bookingDate: SUN }, + } as any, + }), + ]); + await consumer.process(); + + // it chose the §4.7b path (no received/owed legs anywhere) + expect(sumOn('LIABILITY/buyFiat-received')).toBe(0); + expect(sumOn('LIABILITY/buyFiat-owed')).toBe(0); + + // seq1 realizes BOTH DFX fee shares (product fee 20 + merchant plFee = 950 − 940 = 10, CHF-valued) as INCOME. + // plFeeNative = outputReferenceAmount − outputAmount = 950 − 940 = 10; owedReferenceRate = amountInChf/ref = + // 1000/950 ≈ 1.05263158; plFeeChf = round(10 × 1.05263158, 2) = 10.53; feeChf = round(20 + 10.53, 2) = 30.53. + const s1 = seq(1); + const feeIncome = leg(s1, 'INCOME/fee-paymentLink'); + expect(feeIncome).toBeDefined(); + // concrete amount + account + sign (NOT merely toBeDefined): INCOME credit is −feeChf, exact to the cent. A + // swapped sign, a wrong fee base, or the merchant plFee being dropped would all change this exact value. + expect(feeIncome.account.name).toBe('INCOME/fee-paymentLink'); + expect(feeIncome.account.type).toBe(AccountType.INCOME); + expect(feeIncome.amountChf).toBe(-30.53); // −(product fee 20 + merchant plFee 10.53), credit sign + // the matching Dr is on LIABILITY/paymentLink for the same +feeChf (debits paymentLink toward 0) + const plFeeDr = s1.legs + .filter((l) => l.account.name === 'LIABILITY/paymentLink') + .reduce((s, l) => s + (l.amountChf ?? 0), 0); + expect(Math.round(plFeeDr * 100)).toBeGreaterThan(0); // paymentLink debited (positive) in seq1 + + // the merchant clearing legs hit LIABILITY/paymentLink (character-exact, NOT merchant-payable) + expect(seq(2).legs.some((l) => l.account.name === 'LIABILITY/paymentLink')).toBe(true); + expect(leg(seq(3), 'Bank/CHF')).toBeDefined(); + + // seq0 (the −1000 paymentLink credit) is booked by the CryptoInput consumer (§4.4 isPayment); this consumer + // debits paymentLink by exactly the opening value (+1000) across seq1+seq2 → closes the external −1000 to 0. + expect(Math.round(sumOn('LIABILITY/paymentLink') * 100)).toBe(100000); // +1000 CHF + for (const tx of booked) expect(cents(tx.legs)).toBe(0); + }); + + // §4.7b gate: seq1 skipped while the seq0 paymentLink opening does not yet exist + it('skips the paymentLink seq1 while the seq0 paymentLink opening is missing (gate)', async () => { + seq0PaymentLinkChf = undefined; // CryptoInput consumer has not opened paymentLink yet + mockBatch([ + buyFiat({ + id: 5, + amountInChf: 1000, + totalFeeAmountChf: 20, + outputReferenceAmount: 950, + outputAmount: 940, + cryptoInput: { id: 10, updated: new Date('2026-06-04T00:00:00Z'), paymentLinkPayment: { id: 1 } }, + }), + ]); + await consumer.process(); + expect(seq(1)).toBeUndefined(); + }); + + // §10.2 N:1-Defensive (synthetic): three buyFiats → one fiat_output/bank_tx → ASSET/bank debited once per row + it('books seq2/seq3 PER buyFiat (N:1 @OneToMany defensive, Major R10-1)', async () => { + const sharedOutput = (amount: number) => ({ + isTransmittedDate: FRI, + currency: 'CHF', + bank: { asset: { id: CHF_BANK_ASSET_ID } }, + bankTx: { bookingDate: SUN }, + amount, // fiat_output.amount = Σ bf.outputAmount = 1800 + }); + const make = (id: number, out: number) => + buyFiat({ + id, + amountInChf: out, + totalFeeAmountChf: 0, + outputAmount: out, + outputReferenceAmount: out, + outputAsset: { name: 'CHF' }, + fiatOutput: sharedOutput(1800) as any, + }); + mockBatch([make(101, 1000), make(102, 500), make(103, 300)]); + await consumer.process(); + + // three seq3 legs, each its own outputAmount; ASSET/bank debited EXACTLY by fiat_output.amount = 1800 total + const bankLegs = booked.filter((b) => b.seq === 3).map((b) => leg(b, 'Bank/CHF')); + expect(bankLegs).toHaveLength(3); + expect(bankLegs.map((l) => l.amountChf).sort((a, b) => a - b)).toEqual([-1000, -500, -300].sort((a, b) => a - b)); + expect(sumOn('Bank/CHF')).toBe(-1800); // Σ bf.outputAmount == fiat_output.amount, NOT booked once + + // all owed close to 0, TRANSIT closes to 0 + expect(sumOn('LIABILITY/buyFiat-owed')).toBe(0); + expect(sumOn('TRANSIT/payout/CHF')).toBe(0); + }); + + it('is idempotent: skips a fully booked row (re-run, active at seq1/2/3)', async () => { + activeKeys.add('6:1').add('6:2').add('6:3'); // all forward seqs of buy_fiat 6 already active + mockBatch([ + buyFiat({ + id: 6, + amountInChf: 1000, + totalFeeAmountChf: 10, + outputAmount: 990, + outputReferenceAmount: 990, + fiatOutput: { + isTransmittedDate: FRI, + currency: 'CHF', + bank: { asset: { id: CHF_BANK_ASSET_ID } }, + bankTx: { bookingDate: SUN }, + } as any, + }), + ]); + await consumer.process(); + expect(booked).toHaveLength(0); + }); + + // R3 — content-change reversal of seq1 BEFORE transmit/booked must NOT strand seq2/seq3: reversal/re-book live in + // the correction range (≥1_000_000), so seq2/seq3 are still free and book; owed + TRANSIT close cent-exact to 0. + it('books seq2/seq3 even after a seq1 content-change reversal (no stranded later seqs, R3)', async () => { + // model the post-reversal state: seq1 reversed+rebooked into the correction range (active at seq1), seq2/seq3 NOT + // yet booked. nextSeq has jumped past 3 — the exact trap the old `nextSeq>seq` gate mis-read as "2/3 booked". + activeKeys.add('8:1'); + nextSeqValue = 1_000_002; + mockBatch([ + buyFiat({ + id: 8, + amountInChf: 15000, + totalFeeAmountChf: 148.5, + outputAmount: 14851.5, + outputReferenceAmount: 14851.5, + outputAsset: { name: 'CHF' }, + fiatOutput: { + isTransmittedDate: FRI, + currency: 'CHF', + bank: { asset: { id: CHF_BANK_ASSET_ID } }, + bankTx: { bookingDate: SUN }, + } as any, + }), + ]); + + await consumer.process(); + + expect(seq(1)).toBeUndefined(); // seq1 is active → NOT re-booked at the literal seq1 + const s2 = seq(2); + const s3 = seq(3); + expect(s2).toBeDefined(); // transmit booked despite nextSeq being far above 2 + expect(s3).toBeDefined(); // booked booked despite nextSeq being far above 3 + expect(leg(s2, 'LIABILITY/buyFiat-owed').amountChf).toBe(14851.5); + expect(leg(s3, 'Bank/CHF').amountChf).toBe(-14851.5); + // owed: debited +14851.50 (seq2) — the −14851.50 came from the (reversed-then-rebooked) seq1; TRANSIT nets to 0 + expect(sumOn('TRANSIT/payout/CHF')).toBe(0); + for (const tx of booked) expect(cents(tx.legs)).toBe(0); + }); + + it('advances the watermark after a successful batch', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + mockBatch([ + buyFiat({ id: 7, amountInChf: 1000, totalFeeAmountChf: 10, outputAmount: 990, outputReferenceAmount: 990 }), + ]); + await consumer.process(); + const written = JSON.parse(setSpy.mock.calls[0][1]); + expect(written.lastProcessedId).toBe(7); + }); + + it('no-ops on an empty batch', async () => { + mockBatch([]); + await consumer.process(); + expect(booked).toHaveLength(0); + }); + + // processForward catch (lines 105-107): a bookTx error stops the batch and leaves the watermark unchanged + // (failure-isolation). bookReclassification → bookTx rejects on seq1 → caught in the for-loop → break, set NOT called. + it('stops the batch and does not advance the watermark when bookTx throws (failure-isolation)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + jest.spyOn(bookingService, 'bookTx').mockRejectedValue(new Error('boom')); + mockBatch([ + buyFiat({ + id: 11, + amountInChf: 15000, + totalFeeAmountChf: 148.5, + outputAmount: 14851.5, + outputReferenceAmount: 14851.5, + outputAsset: { name: 'CHF' }, + fiatOutput: { + isTransmittedDate: FRI, + currency: 'CHF', + bank: { asset: { id: CHF_BANK_ASSET_ID } }, + bankTx: { bookingDate: SUN }, + } as any, + }), + ]); + await consumer.process(); + expect(booked).toHaveLength(0); // the rejecting bookTx never pushed + expect(setSpy).not.toHaveBeenCalled(); // watermark NOT advanced + }); + + // buildReclassificationSeq1 throw (line 166): a regular row with outputAmount set and the gate OPEN but + // amountInChf == null throws `has outputAmount but amountInChf is null` → caught by processForward → set NOT called. + it('isolates the buildReclassificationSeq1 throw when amountInChf is null (watermark unchanged)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + gateOpen = true; // received gate OPEN → we reach buildReclassificationSeq1, not the gate skip + mockBatch([ + buyFiat({ + id: 12, + amountInChf: null, // the trigger: outputAmount set but amountInChf null + totalFeeAmountChf: 0, + outputAmount: 5000, + outputReferenceAmount: 5000, + outputAsset: { name: 'CHF' }, + }), + ]); + await consumer.process(); + expect(seq(1)).toBeUndefined(); + expect(booked).toHaveLength(0); + expect(setSpy).not.toHaveBeenCalled(); + }); + + // appendFxResidual early-return (line 334): a regular EUR sell whose bank.asset has NO mark → the bank leg + // needsMark → NO fx-residual leg appended (also covers outputMark returning undefined for a non-CHF output whose + // mark is missing, lines 364-368). seq3 carries only TRANSIT + the unmarked bank leg. + // Major B5 — no output-bank mark ANYWHERE: the mixed seq3 (valued TRANSIT owed leg + unvalued bank-ASSET leg) cannot + // balance and the bridge finds no mark → the row DEFERS (nothing booked at seq3, watermark unchanged). + it('defers seq3 when the output-bank leg has no mark anywhere (mixed tx, B5)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + mockBatch([ + buyFiat({ + id: 13, + amountInChf: 9000, + totalFeeAmountChf: 0, + outputAmount: 10000, // EUR + outputReferenceAmount: 10000, + outputAsset: { name: 'EUR' }, + fiatOutput: { + isTransmittedDate: FRI, + currency: 'EUR', + bank: { asset: { id: 999 } }, // 999 is NOT in markMap → getMarkAt → undefined; getLatestMark → undefined too + bankTx: { bookingDate: SUN }, + } as any, + }), + ]); + await consumer.process(); + + expect(seq(3)).toBeUndefined(); // seq3 deferred (unbalanceable mixed tx, no bridge) + expect(setSpy).not.toHaveBeenCalled(); // watermark NOT advanced + }); + + // Major B5 bridge — a missing HISTORICAL output-bank mark but a present current mark: the bank-ASSET leg is valued with + // the youngest available mark (bridge), needsMark STAYS true so the mark-to-market job corrects the basis later, and + // seq3 balances via the fx-revaluation plug. + it('bridges a missing historical output-bank mark and books seq3 balanced, needsMark stays true (B5)', async () => { + jest.spyOn(markService, 'getLatestMark').mockResolvedValue(0.95); // youngest available EUR-bank mark for asset 999 + mockBatch([ + buyFiat({ + id: 13, + amountInChf: 9000, + totalFeeAmountChf: 0, + outputAmount: 10000, // EUR + outputReferenceAmount: 10000, + outputAsset: { name: 'EUR' }, + fiatOutput: { + isTransmittedDate: FRI, + currency: 'EUR', + bank: { asset: { id: 999 } }, + bankTx: { bookingDate: SUN }, + } as any, + }), + ]); + await consumer.process(); + + const s3 = seq(3); + const bankLeg = s3.legs.find((l) => l.needsMark); + expect(bankLeg.amount).toBe(-10000); // native −outputAmount + expect(bankLeg.amountChf).toBe(-9500); // bridged: 0.95 × −10000 + expect(bankLeg.needsMark).toBe(true); // stays true → mark-to-market re-marks later + // balanced: TRANSIT +9000, bank −9500 → residual +500 → fx-revaluation plug + expect(s3.legs.some((l) => /fx-revaluation/.test(l.account.name))).toBe(true); + const cents = s3.legs.reduce((s, l) => s + Math.round((l.amountChf ?? 0) * 100), 0); + expect(cents).toBe(0); + }); + + // outputMark bankAssetId == null branch (lines 364-368 false arm): the bank.asset exists (no bankCrLeg throw) but + // its id is undefined → outputMark returns undefined without a getMarkAt lookup → bank leg needsMark, no fx leg. + // Major B5 — bank.asset has no id (outputMark null-id arm) → nothing to bridge (no assetId to look up): the mixed seq3 + // defers rather than handing an unbalanceable set to bookTx. + it('defers seq3 when the output bank.asset has no id (nothing to bridge — B5)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + mockBatch([ + buyFiat({ + id: 14, + amountInChf: 8000, + totalFeeAmountChf: 0, + outputAmount: 8400, // EUR + outputReferenceAmount: 8400, + outputAsset: { name: 'EUR' }, + fiatOutput: { + isTransmittedDate: FRI, + currency: 'EUR', + bank: { asset: {} }, // asset present (no throw) but id undefined → outputMark null-id arm + bankTx: { bookingDate: SUN }, + } as any, + }), + ]); + await consumer.process(); + + expect(seq(3)).toBeUndefined(); // seq3 deferred (no assetId to bridge, unbalanceable mixed tx) + expect(setSpy).not.toHaveBeenCalled(); // watermark NOT advanced + }); + + // appendFxResidual INCOME side (line 340 ≥0 arm): an EUR sell where owed_chf < bank-CHF → positive residual → + // INCOME/fx-revaluation. owed_chf = 9000; bank = 10000 EUR × 0.95 = −9500; Σ = −500 → residual +500 → INCOME. + it('books a POSITIVE fx residual to INCOME/fx-revaluation (§4.7a income side)', async () => { + mockBatch([ + buyFiat({ + id: 15, + amountInChf: 9000, + totalFeeAmountChf: 0, + outputAmount: 10000, // EUR + outputReferenceAmount: 10000, + outputAsset: { name: 'EUR' }, + fiatOutput: { + isTransmittedDate: FRI, + currency: 'EUR', + bank: { asset: { id: EUR_BANK_ASSET_ID } }, + bankTx: { bookingDate: SUN }, + } as any, + }), + ]); + await consumer.process(); + + const s3 = seq(3); + expect(leg(s3, 'TRANSIT/payout/EUR').amountChf).toBe(9000); + expect(leg(s3, 'Bank/EUR').amountChf).toBe(-9500); // 10000 × 0.95 + const fx = leg(s3, 'INCOME/fx-revaluation'); + expect(fx.account.type).toBe(AccountType.INCOME); + expect(fx.amountChf).toBe(500); // residual = −(9000 − 9500) = +500 ≥ 0 → INCOME + expect(s3.legs.some((l) => l.account.name === 'EXPENSE/fx-revaluation')).toBe(false); + expect(cents(s3.legs)).toBe(0); + }); + + // bookPaymentLinkFee venueSpread == 0 (line 276 false arm): a paymentLink row where openingChf − outputChf − + // feeChf == 0 → ONLY the 2 fee legs, NO venueSpread legs. rate = 1000/1000 = 1; outputChf = 980; plFeeNative = + // 1000 − 980 = 20 → plFeeChf 20; feeChf = 20 + 20 = 40; opening 1020 → venueSpread = 1020 − 980 − 40 = 0. + it('books only the 2 fee legs when the paymentLink venue spread is exactly 0', async () => { + seq0PaymentLinkChf = -1020; // opening = 1020 + mockBatch([ + buyFiat({ + id: 16, + amountInChf: 1000, + totalFeeAmountChf: 20, + outputReferenceAmount: 1000, + outputAmount: 980, + outputAsset: { name: 'CHF' }, + cryptoInput: { id: 10, updated: new Date('2026-06-04T00:00:00Z'), paymentLinkPayment: { id: 1 } }, + }), + ]); + await consumer.process(); + + const s1 = seq(1); + expect(s1.legs).toHaveLength(2); // venueSpread == 0 → the spread legs are NOT pushed + expect(leg(s1, 'INCOME/fee-paymentLink').amountChf).toBe(-40); // −(20 product + 20 plFee) + const plDr = s1.legs.filter((l) => l.account.name === 'LIABILITY/paymentLink').reduce((s, l) => s + l.amountChf, 0); + expect(plDr).toBe(40); // single +40 fee debit, no spread debit + expect(s1.legs.some((l) => /fx-revaluation/.test(l.account.name))).toBe(false); + expect(cents(s1.legs)).toBe(0); + }); + + // bookPaymentLinkFee venueSpread ≥ 0 (line 278 income arm): a positive venue spread → INCOME/fx-revaluation. + // rate = 1; outputChf = 900; plFeeNative = 1000 − 900 = 100 → plFeeChf 100; feeChf = 10 + 100 = 110; opening 1050 + // → venueSpread = 1050 − 900 − 110 = +40 → INCOME. + it('books a POSITIVE paymentLink venue spread to INCOME/fx-revaluation', async () => { + seq0PaymentLinkChf = -1050; // opening = 1050 + mockBatch([ + buyFiat({ + id: 17, + amountInChf: 1000, + totalFeeAmountChf: 10, + outputReferenceAmount: 1000, + outputAmount: 900, + outputAsset: { name: 'CHF' }, + cryptoInput: { id: 10, updated: new Date('2026-06-04T00:00:00Z'), paymentLinkPayment: { id: 1 } }, + }), + ]); + await consumer.process(); + + const s1 = seq(1); + expect(s1.legs).toHaveLength(4); // 2 fee + 2 spread + expect(leg(s1, 'INCOME/fee-paymentLink').amountChf).toBe(-110); + const fx = leg(s1, 'INCOME/fx-revaluation'); + expect(fx.account.type).toBe(AccountType.INCOME); + expect(fx.amountChf).toBe(-40); // venueSpread +40 ≥ 0 → INCOME credit −venueSpread + // paymentLink debited by feeChf + venueSpread = 110 + 40 = 150 + const plDr = s1.legs.filter((l) => l.account.name === 'LIABILITY/paymentLink').reduce((s, l) => s + l.amountChf, 0); + expect(plDr).toBe(150); + expect(s1.legs.some((l) => l.account.name === 'EXPENSE/fx-revaluation')).toBe(false); + expect(cents(s1.legs)).toBe(0); + }); + + // owedReferenceRate ref==null + CHF output (line 359 → returns 1): a paymentLink CHF row with + // outputReferenceAmount null → owed_chf = outputAmount × 1. Asserted on seq2 transmit (paymentLink Dr = outputChf). + it('uses owedReferenceRate 1 for a CHF paymentLink row with a null reference', async () => { + seq0PaymentLinkChf = -500; + mockBatch([ + buyFiat({ + id: 18, + amountInChf: 500, + totalFeeAmountChf: 0, + outputReferenceAmount: null, // ref null → CHF → rate 1 + outputAmount: 500, + outputAsset: { name: 'CHF' }, + cryptoInput: { id: 10, updated: new Date('2026-06-04T00:00:00Z'), paymentLinkPayment: { id: 1 } }, + fiatOutput: { isTransmittedDate: FRI, currency: 'CHF' } as any, + }), + ]); + await consumer.process(); + + const s2 = seq(2); + expect(leg(s2, 'LIABILITY/paymentLink').amountChf).toBe(500); // outputAmount × 1 + expect(leg(s2, 'TRANSIT/payout/CHF').amountChf).toBe(-500); + expect(cents(s2.legs)).toBe(0); + }); + + // m5 fail-loud: a non-CHF paymentLink row with a null outputReferenceAmount cannot derive owedReferenceRate → the + // consumer THROWS instead of the old silent 0-fallback (which would zero owedChf/plFeeChf and misclassify the whole + // value into the fx-revaluation plug). The forward loop catches → failure-isolation: nothing booked, watermark stays. + it('throws (failure-isolation) for a non-CHF paymentLink row with a null reference (owedReferenceRate, m5)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + seq0PaymentLinkChf = -100; // opening present so the §4.7b seq1 is attempted (where owedReferenceRate throws) + mockBatch([ + buyFiat({ + id: 19, + amountInChf: 100, + totalFeeAmountChf: 0, + outputReferenceAmount: null, // ref null + non-CHF (EUR) → owedReferenceRate throws (no 0-fallback) + outputAmount: 10000, // EUR + outputAsset: { name: 'EUR' }, + cryptoInput: { id: 10, updated: new Date('2026-06-04T00:00:00Z'), paymentLinkPayment: { id: 1 } }, + fiatOutput: { isTransmittedDate: FRI, currency: 'EUR' } as any, + }), + ]); + await consumer.process(); + + expect(booked).toHaveLength(0); // owedReferenceRate threw before any leg was booked + expect(setSpy).not.toHaveBeenCalled(); // watermark unchanged (retry next run) + }); + + // bankCrLeg throw (line 313): a regular sell reaching seq3 whose fiatOutput.bank.asset is missing throws + // `fiatOutput has no bank.asset` → caught by processForward → failure-isolation (seq3 absent, watermark unchanged). + // seq1/seq2 ARE booked (they precede the throw); seq3 is not, and set is NOT called. + it('isolates the bankCrLeg throw when the output bank.asset is missing (untracked output bank)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + mockBatch([ + buyFiat({ + id: 20, + amountInChf: 1000, + totalFeeAmountChf: 10, + outputAmount: 990, + outputReferenceAmount: 990, + outputAsset: { name: 'CHF' }, + fiatOutput: { + isTransmittedDate: FRI, + currency: 'CHF', + bank: {}, // no asset → bankCrLeg throws + bankTx: { bookingDate: SUN }, + } as any, + }), + ]); + await consumer.process(); + + expect(seq(1)).toBeDefined(); // seq1 booked before the seq3 throw + expect(seq(2)).toBeDefined(); // seq2 booked before the seq3 throw + expect(seq(3)).toBeUndefined(); // seq3 threw → never booked + expect(setSpy).not.toHaveBeenCalled(); // watermark NOT advanced + }); + + // receivedOpened G-b path (lines 388-394): G-a closed (crypto_input seq0 absent) but the cutover received marker + // exists → seq1 opens via G-b. existsBy is false for the crypto_input G-a query and true for the cutover G-b query. + it('opens seq1 via the cutover received marker (G-b) when G-a is closed', async () => { + cutoverLogId = '1557344'; // enables cutoverReceivedSourceId → the G-b existsBy runs + // distinguish the two gate queries by sourceType: G-a (crypto_input) closed, G-b (cutover) open + jest + .spyOn(ledgerTxRepo, 'existsBy') + .mockImplementation(({ sourceType }: any) => Promise.resolve(sourceType === 'cutover')); + mockBatch([ + buyFiat({ + id: 21, + amountInChf: 1000, + totalFeeAmountChf: 10, + outputAmount: 990, + outputReferenceAmount: 990, + outputAsset: { name: 'CHF' }, + fiatOutput: { + isTransmittedDate: FRI, + currency: 'CHF', + bank: { asset: { id: CHF_BANK_ASSET_ID } }, + bankTx: { bookingDate: SUN }, + } as any, + }), + ]); + await consumer.process(); + + const s1 = seq(1); // gate opened via G-b → seq1 IS booked + expect(s1).toBeDefined(); + expect(leg(s1, 'LIABILITY/buyFiat-owed').amountChf).toBe(-990); // −(1000 − 10) + expect(sumOn('LIABILITY/buyFiat-received')).toBe(1000); + }); + + // §4.12 + M3 content-change scan (process lines 60-90): a settled regular row surfaced ONLY by the content-change + // scan (where.updated) → the value-coupled reclassification/transmit/settlement CHAIN is reverse-and-rebooked (not + // just seq1), then the idempotent book() appends any not-yet-booked seqs. + it('reverse-and-rebooks the value-coupled chain on a content-change scan and appends the later seqs (M3)', async () => { + const chainSpy = jest.spyOn(bookingService, 'reverseAndRebookChainIfChanged').mockResolvedValue(true); + activeKeys.add('22:1'); // seq1 already exists → book() skips it; only seq2/seq3 append + jest.spyOn(buyFiatRepo, 'find').mockImplementation(({ where }: any) => + Promise.resolve( + where?.updated != null + ? [ + buyFiat({ + id: 22, + amountInChf: 1000, + totalFeeAmountChf: 10, + outputAmount: 990, + outputReferenceAmount: 990, + outputAsset: { name: 'CHF' }, + fiatOutput: { + isTransmittedDate: FRI, + currency: 'CHF', + bank: { asset: { id: CHF_BANK_ASSET_ID } }, + bankTx: { bookingDate: SUN }, + } as any, + }), + ] + : [], + ), + ); + await consumer.process(); + + expect(chainSpy).toHaveBeenCalledTimes(1); + const chain = chainSpy.mock.calls[0][0]; + // the value-coupled chain carries reclassification seq1 + transmit seq2 + settlement seq3 (M3: whole chain, not seq1) + expect(chain.map((i) => i.seq).sort((a, b) => a - b)).toEqual([1, 2, 3]); + expect( + leg( + chain.find((i) => i.seq === 1), + 'LIABILITY/buyFiat-owed', + ).amountChf, + ).toBe(-990); + // the idempotent forward book() then appends transmit + booked + expect(seq(2)).toBeDefined(); + expect(seq(3)).toBeDefined(); + expect(leg(seq(2), 'LIABILITY/buyFiat-owed').amountChf).toBe(990); + expect(leg(seq(3), 'Bank/CHF').amountChf).toBe(-990); + }); + + // §4.12 content-change scan, owed-straddling variant (process lines 73-77): owedOpeningChf != null → the seq1 + // reverse is SKIPPED (the reclassification was anchored in the cutover opening), but book() still settles seq2/seq3. + it('skips the seq1 reverse for an owed-straddling row in the content-change scan but still settles', async () => { + cutoverLogId = '1557344'; + cutoverOwedOpeningChf = -9500; // owed opening anchor → owedOpeningChf != null + gateOpen = false; // received gate stays closed for this row (would never open via G-a) + const reverseSpy = jest.spyOn(bookingService, 'reverseAndRebookIfChanged').mockResolvedValue(true); + jest.spyOn(buyFiatRepo, 'find').mockImplementation(({ where }: any) => + Promise.resolve( + where?.updated != null + ? [ + buyFiat({ + id: 23, + amountInChf: 10000, + totalFeeAmountChf: 50, + outputAmount: 10000, // EUR + outputReferenceAmount: 10000, + outputAsset: { name: 'EUR' }, + fiatOutput: { + isTransmittedDate: FRI, + currency: 'EUR', + bank: { asset: { id: EUR_BANK_ASSET_ID } }, + bankTx: { bookingDate: SUN }, + } as any, + }), + ] + : [], + ), + ); + await consumer.process(); + + expect(reverseSpy).not.toHaveBeenCalled(); // owed-straddling → seq1 reverse skipped + expect(seq(1)).toBeUndefined(); // never booked by this consumer (anchored in the opening) + // seq2/seq3 settle against the +9500 opening anchor; bank 10000 EUR × 0.95 = −9500 → drift 0 + expect(leg(seq(2), 'LIABILITY/buyFiat-owed').amountChf).toBe(9500); + expect(leg(seq(3), 'Bank/EUR').amountChf).toBe(-9500); + expect(sumOn('TRANSIT/payout/EUR')).toBe(0); + }); + + // §4.12 content-change scan gate-block (line 81): book() returns false (received gate closed) → the scan throws, + // which runContentChangeScan catches → the cursor is NOT advanced (set not called) and process() resolves cleanly. + it('throws on a gate-blocked content-change row so the cursor is not advanced (R6-1)', async () => { + gateOpen = false; // received gate closed → bookRegular returns false (book gate-blocked) + cutoverLogId = undefined; // no G-b → receivedOpened false + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + jest.spyOn(bookingService, 'reverseAndRebookIfChanged').mockResolvedValue(true); + jest.spyOn(buyFiatRepo, 'find').mockImplementation(({ where }: any) => + Promise.resolve( + where?.updated != null + ? [ + buyFiat({ + id: 24, + amountInChf: 1000, + totalFeeAmountChf: 10, + outputAmount: 990, // outputAmount set → seq1 attempted, but the gate is closed + outputReferenceAmount: 990, + outputAsset: { name: 'CHF' }, + }), + ] + : [], + ), + ); + // runContentChangeScan swallows the line-81 throw → process resolves, but the watermark cursor stays put + await expect(consumer.process()).resolves.toBeUndefined(); + expect(seq(1)).toBeUndefined(); // gate-blocked → nothing booked + expect(setSpy).not.toHaveBeenCalled(); // cursor NOT advanced past the late-settling row + }); + + // M4: an UNPRICED row (outputAmount null) surfaced by the content-change scan must NOT throw. The old callback called + // buildReclassificationSeq1 unconditionally, which throws on a null amountInChf and wedged the scan. The + // `outputAmount != null` guard (matching the forward path) treats it as "not yet settled": the chain is not built and + // book() no-ops, so the cursor advances. + it('does NOT throw for an unpriced (outputAmount null) row in the content-change scan (M4 guard)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + const chainSpy = jest.spyOn(bookingService, 'reverseAndRebookChainIfChanged'); + jest.spyOn(buyFiatRepo, 'find').mockImplementation(({ where }: any) => + Promise.resolve( + where?.updated != null + ? [ + buyFiat({ + id: 60, + amountInChf: null, + outputAmount: null, + outputReferenceAmount: null, + outputAsset: { name: 'CHF' }, + }), + ] + : [], + ), + ); + + await expect(consumer.process()).resolves.toBeUndefined(); // no throw propagates (M4: unpriced ≠ error) + expect(chainSpy).not.toHaveBeenCalled(); // outputAmount null → chain not built (no seq1 to reverse) + expect(booked).toHaveLength(0); // nothing settled → nothing booked + expect(setSpy).toHaveBeenCalled(); // the content-change cursor ADVANCED (not-yet-settled, not a wedge) + }); + + // C2: a regular sell settled at/before the cutover snapshot whose received gate is permanently closed (its value is + // already in the aggregate opening) must be SKIPPED+advanced by the content-change scan, NOT throw — throwing would + // wedge the scan forever. A post-cutover flag change re-selects the row; book() gate-blocks; preCutoverSettled → skip. + it('advances (does NOT throw/wedge) for a pre-cutover-settled gate-blocked row in the content-change scan (C2)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + gateOpen = false; // received gate closed (no G-a/G-b) + jest + .spyOn(settingService, 'get') + .mockImplementation((key: string) => + Promise.resolve(key === 'ledgerCutoverSnapshotDate' ? '2026-06-07T22:00:00.000Z' : undefined), + ); + jest.spyOn(buyFiatRepo, 'find').mockImplementation(({ where }: any) => + Promise.resolve( + where?.updated != null + ? [ + buyFiat({ + id: 61, + amountInChf: 1000, + totalFeeAmountChf: 10, + outputAmount: 990, + outputReferenceAmount: 990, + outputAsset: { name: 'CHF' }, + outputDate: new Date('2026-05-15T00:00:00Z'), // settled BEFORE the snapshot → value in the aggregate + updated: new Date('2026-06-20T00:00:00Z'), // a post-cutover flag change re-selects it in the scan + }), + ] + : [], + ), + ); + + await expect(consumer.process()).resolves.toBeUndefined(); // no throw propagates out of the scan + expect(booked).toHaveLength(0); // value in the aggregate opening → nothing booked + expect(setSpy).toHaveBeenCalled(); // the content-change cursor ADVANCED past it (skip, not throw/wedge) + }); + + // buildReclassificationSeq1 paymentLink guard (L165): the content-change scan calls buildReclassificationSeq1 for a + // PAYMENTLINK row → `if (bf.cryptoInput?.paymentLinkPayment) return undefined` → no seq1 reverse (paymentLink owns + // its own venue-spread seq1). book() still runs the §4.7b path → INCOME/fee-paymentLink booked, owed/received absent. + it('returns undefined from buildReclassificationSeq1 for a paymentLink content-change row (no seq1 reverse, L165)', async () => { + seq0PaymentLinkChf = -1000; // opening present so the §4.7b seq1 books + const reverseSpy = jest.spyOn(bookingService, 'reverseAndRebookIfChanged').mockResolvedValue(true); + jest.spyOn(buyFiatRepo, 'find').mockImplementation(({ where }: any) => + Promise.resolve( + where?.updated != null + ? [ + buyFiat({ + id: 25, + amountInChf: 1000, + totalFeeAmountChf: 20, + outputReferenceAmount: 950, + outputAmount: 940, + outputAsset: { name: 'CHF' }, + cryptoInput: { id: 10, updated: new Date('2026-06-04T00:00:00Z'), paymentLinkPayment: { id: 1 } }, + fiatOutput: { + isTransmittedDate: FRI, + currency: 'CHF', + bank: { asset: { id: CHF_BANK_ASSET_ID } }, + bankTx: { bookingDate: SUN }, + } as any, + }), + ] + : [], + ), + ); + await consumer.process(); + + expect(reverseSpy).not.toHaveBeenCalled(); // paymentLink → buildReclassificationSeq1 undefined → no seq1 reverse + // book() still ran the §4.7b path: the seq1 fee income exists, and NO regular received/owed legs were booked + expect(leg(seq(1), 'INCOME/fee-paymentLink')).toBeDefined(); + expect(sumOn('LIABILITY/buyFiat-received')).toBe(0); + expect(sumOn('LIABILITY/buyFiat-owed')).toBe(0); + }); + + // buildReclassificationSeq1 fee null-fallback (L168): a regular sell with totalFeeAmountChf == null → fee 0 → + // reclassChf = amountInChf − 0 = amountInChf; owed credited −amountInChf, fee-income credit is exactly −0. + it('treats a null totalFeeAmountChf as fee 0 in the seq1 reclassification (L168)', async () => { + mockBatch([ + buyFiat({ + id: 26, + amountInChf: 1000, + totalFeeAmountChf: null, // fee null → ?? 0 + outputAmount: 1000, + outputReferenceAmount: 1000, + outputAsset: { name: 'CHF' }, + fiatOutput: { isTransmittedDate: FRI, currency: 'CHF' } as any, + }), + ]); + await consumer.process(); + + const s1 = seq(1); + expect(s1.legs).toHaveLength(4); + expect(leg(s1, 'INCOME/fee-buyFiat').amountChf).toBe(-0); // −fee = −0 (fee 0 from ?? 0) + expect(leg(s1, 'LIABILITY/buyFiat-owed').amountChf).toBe(-1000); // −(amountInChf − 0) + expect(sumOn('LIABILITY/buyFiat-received')).toBe(1000); // +fee(0) + reclass(1000) + expect(cents(s1.legs)).toBe(0); + }); + + // bookSettlement bookingDate null-fallback (L211): seq3 bankTx with bookingDate == null but created set → + // bookingDate = bankTx.created. The seq3 tx is dated at created, NOT undefined. + it('falls back to bankTx.created for the seq3 booking date when bookingDate is null (L211)', async () => { + const CREATED = new Date('2026-06-08T00:00:00Z'); + mockBatch([ + buyFiat({ + id: 27, + amountInChf: 1000, + totalFeeAmountChf: 10, + outputAmount: 990, + outputReferenceAmount: 990, + outputAsset: { name: 'CHF' }, + fiatOutput: { + isTransmittedDate: FRI, + currency: 'CHF', + bank: { asset: { id: CHF_BANK_ASSET_ID } }, + bankTx: { bookingDate: null, created: CREATED }, // bookingDate null → ?? created + } as any, + }), + ]); + await consumer.process(); + + const s3 = seq(3); + expect(s3.bookingDate).toEqual(CREATED); // null bookingDate → fell back to bankTx.created + expect(leg(s3, 'Bank/CHF').amountChf).toBe(-990); + }); + + // bookPaymentLinkFee fee null-fallback (L259): a paymentLink row with totalFeeAmountChf == null → totalFee 0 → + // feeChf = 0 + plFeeChf. rate = 1000/950 ≈ 1.05263158; plFeeNative = 950 − 940 = 10; plFeeChf = 10.53; feeChf = 10.53. + it('treats a null totalFeeAmountChf as 0 in the paymentLink seq1 fee (L259)', async () => { + seq0PaymentLinkChf = -1000; + mockBatch([ + buyFiat({ + id: 28, + amountInChf: 1000, + totalFeeAmountChf: null, // fee null → ?? 0 + outputReferenceAmount: 950, + outputAmount: 940, + outputAsset: { name: 'CHF' }, + cryptoInput: { id: 10, updated: new Date('2026-06-04T00:00:00Z'), paymentLinkPayment: { id: 1 } }, + }), + ]); + await consumer.process(); + + const s1 = seq(1); + expect(leg(s1, 'INCOME/fee-paymentLink').amountChf).toBe(-10.53); // −(0 product + 10.53 plFee) + expect(cents(s1.legs)).toBe(0); + }); + + // bankCrLeg outputAmount null-fallback (L316) + owedChf regular null-fallbacks (L352 x2): a regular sell reaching + // seq2/seq3 with outputAmount == null (→ seq1 skipped by the outputAmount gate) AND amountInChf == null AND + // totalFeeAmountChf == null → owedChf = (0) − (0) = 0; bankCrLeg outputAmount = 0 → native −0, CHF −0 (CHF mark 1). + // The REGULAR-sell owedChf still defaults a null amountInChf/totalFeeAmountChf to 0 in seq2 (unchanged); but A5 makes + // seq3 FAIL LOUD on a null outputAmount (the bank-ASSET leg cannot be valued) instead of booking a 0-native/0-CHF leg. + it('defaults null amountInChf/totalFeeAmountChf to 0 in seq2 but fails loud on a null outputAmount in seq3 (A5)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + mockBatch([ + buyFiat({ + id: 29, + amountInChf: null, // → regular owedChf amountInChf ?? 0 (unchanged) + totalFeeAmountChf: null, // → regular owedChf totalFeeAmountChf ?? 0 (unchanged) + outputAmount: null, // → seq1 skipped; seq3 bankCrLeg fails loud (A5) + outputReferenceAmount: 0, + outputAsset: { name: 'CHF' }, + fiatOutput: { + isTransmittedDate: FRI, + currency: 'CHF', + bank: { asset: { id: CHF_BANK_ASSET_ID } }, + bankTx: { bookingDate: SUN }, + } as any, + }), + ]); + await consumer.process(); + + expect(seq(1)).toBeUndefined(); // outputAmount null → seq1 gate skips it + const s2 = seq(2); + expect(leg(s2, 'LIABILITY/buyFiat-owed').amountChf).toBe(0); // regular owedChf: (0) − (0) + expect(leg(s2, 'TRANSIT/payout/CHF').amountChf).toBe(-0); + expect(seq(3)).toBeUndefined(); // A5: seq3 bankCrLeg fails loud on the null outputAmount (never a 0-native leg) + expect(setSpy).not.toHaveBeenCalled(); // watermark NOT advanced → retry + }); + + // owedChf paymentLink outputAmount null-fallback (L351): a paymentLink row reaching seq2 with outputAmount == null + // → owedChf = (0) × rate = 0. seq1 is skipped (outputAmount gate); seq2 transmits paymentLink 0 / TRANSIT −0. + // A5 fail-loud — a paymentLink row being transmitted with a NULL outputAmount is a data error: `outputAmount ?? 0` + // would zero the merchant fiat payout in owedChf. The seq2 transmit throws (never a silent 0) → failure-isolation. + it('fails loud on a paymentLink transmit (seq2) with a null outputAmount (A5)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + seq0PaymentLinkChf = -100; // opening present (irrelevant: seq1 skipped by the outputAmount gate) + mockBatch([ + buyFiat({ + id: 30, + amountInChf: 100, + totalFeeAmountChf: 0, + outputReferenceAmount: 100, + outputAmount: null, // paymentLink owedChf with null outputAmount → A5 throws + outputAsset: { name: 'CHF' }, + cryptoInput: { id: 10, updated: new Date('2026-06-04T00:00:00Z'), paymentLinkPayment: { id: 1 } }, + fiatOutput: { isTransmittedDate: FRI, currency: 'CHF' } as any, + }), + ]); + await consumer.process(); + + expect(seq(1)).toBeUndefined(); // outputAmount null → §4.7b seq1 gate skips it + expect(seq(2)).toBeUndefined(); // seq2 transmit fails loud (owedChf refuses a 0-fallback) + expect(setSpy).not.toHaveBeenCalled(); // watermark NOT advanced → retry + }); + + // outputCurrency fallback chain (L371): outputAsset undefined → uses fiatOutput.currency. EUR fiatOutput with no + // outputAsset → the seq2 transit account is TRANSIT/payout/EUR (proves fiatOutput.currency, not the CHF default). + it('uses fiatOutput.currency for outputCurrency when outputAsset is undefined (L371 second arm)', async () => { + mockBatch([ + buyFiat({ + id: 31, + amountInChf: 1000, + totalFeeAmountChf: 10, + outputAmount: 990, + outputReferenceAmount: 990, + outputAsset: undefined, // → fall through to fiatOutput.currency + fiatOutput: { isTransmittedDate: FRI, currency: 'EUR' } as any, + }), + ]); + await consumer.process(); + + const s2 = seq(2); + expect(leg(s2, 'TRANSIT/payout/EUR')).toBeDefined(); // outputCurrency = fiatOutput.currency = 'EUR' + expect(leg(s2, 'TRANSIT/payout/EUR').amountChf).toBe(-990); // −owedChf (1000 − 10) + expect(leg(s2, 'LIABILITY/buyFiat-owed').amountChf).toBe(990); + }); + + // outputCurrency fallback chain (L371): outputAsset undefined AND fiatOutput.currency undefined → final CHF default. + // The seq2 transit account is TRANSIT/payout/CHF (proves the CHF tail of `?? bf.fiatOutput?.currency ?? CHF`). + it('defaults outputCurrency to CHF when both outputAsset and fiatOutput.currency are undefined (L371 CHF tail)', async () => { + mockBatch([ + buyFiat({ + id: 32, + amountInChf: 1000, + totalFeeAmountChf: 10, + outputAmount: 990, + outputReferenceAmount: 990, + outputAsset: undefined, // → fiatOutput.currency + fiatOutput: { isTransmittedDate: FRI } as any, // currency undefined → CHF + }), + ]); + await consumer.process(); + + const s2 = seq(2); + expect(leg(s2, 'TRANSIT/payout/CHF')).toBeDefined(); // both undefined → CHF + expect(leg(s2, 'TRANSIT/payout/CHF').amountChf).toBe(-990); + expect(leg(s2, 'LIABILITY/buyFiat-owed').amountChf).toBe(990); + }); + + // paymentLinkOpeningChf null-cryptoInput guard (L424): a paymentLink row whose cryptoInput.id is null → the opening + // gate returns undefined at the FIRST guard (no findOne lookup) → seq1 gate-blocked, book() returns false, nothing + // booked. Distinct from the missing-seq0 test (id 5), which DOES reach findOne (cryptoInput.id set). + it('gate-blocks the paymentLink seq1 when cryptoInput.id is null (L424 guard, no findOne lookup)', async () => { + seq0PaymentLinkChf = -1000; // would satisfy the gate IF findOne were reached — but the null-id guard returns first + const findOneSpy = jest.spyOn(ledgerTxRepo, 'findOne'); + mockBatch([ + buyFiat({ + id: 33, + amountInChf: 1000, + totalFeeAmountChf: 20, + outputReferenceAmount: 950, + outputAmount: 940, + outputAsset: { name: 'CHF' }, + cryptoInput: { id: undefined, updated: new Date('2026-06-04T00:00:00Z'), paymentLinkPayment: { id: 1 } }, + }), + ]); + await consumer.process(); + + expect(seq(1)).toBeUndefined(); // null cryptoInput.id → opening undefined → seq1 gate-blocked + expect(findOneSpy).not.toHaveBeenCalled(); // the L424 guard short-circuits BEFORE the seq0 findOne lookup + }); + + // assetAccount not-found throw (L452): a regular sell reaching seq3 whose bank.asset.id resolves but + // findByAssetId returns undefined → `throw ... CoA bootstrap missing`. Caught by processForward → seq3 absent, + // watermark unchanged. seq1/seq2 (which precede the throw) ARE booked. Distinct from the bank.asset-missing throw + // (id 20, L313): here the asset IS present, only the ledger account lookup fails. + it('throws from assetAccount when findByAssetId returns undefined for a present bank asset (L452)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + jest.spyOn(accountService, 'findByAssetId').mockResolvedValue(null); // CoA bootstrap missing for the bank asset + mockBatch([ + buyFiat({ + id: 34, + amountInChf: 1000, + totalFeeAmountChf: 10, + outputAmount: 990, + outputReferenceAmount: 990, + outputAsset: { name: 'CHF' }, + fiatOutput: { + isTransmittedDate: FRI, + currency: 'CHF', + bank: { asset: { id: 777 } }, // asset present (no L313 throw) but findByAssetId → null → L452 throw + bankTx: { bookingDate: SUN }, + } as any, + }), + ]); + await consumer.process(); + + expect(seq(1)).toBeDefined(); // booked before the seq3 assetAccount throw + expect(seq(2)).toBeDefined(); + expect(seq(3)).toBeUndefined(); // assetAccount threw → seq3 never booked + expect(setSpy).not.toHaveBeenCalled(); // failure-isolation: watermark NOT advanced + }); + + // --- F1: CUTOVER-STRADDLING PAYMENTLINK buy_fiat --- // + + // §4.7b/§6.1 (F1): a cutover-straddling paymentLink buy_fiat whose financing crypto_input settled PRE-cutover has NO + // crypto_input seq0 — the cutover opened its paymentLink liability via the per-row marker + // `${logId}:buy_fiat-paymentLink:${id}` instead of a buyFiat-received/-owed opening. The forward bookPaymentLink path + // resolves the opening from that marker (G-b), books the seq1/seq2/seq3 chain, and paymentLink closes cent-exact to 0 + // — no permanent content-scan wedge, and the opening sits in the paymentLink bucket (not received/owed). + it('settles a cutover-straddling paymentLink buy_fiat against the cutover paymentLink anchor (G-b, F1)', async () => { + cutoverLogId = '1557344'; + seq0PaymentLinkChf = undefined; // no crypto_input seq0 (financed pre-cutover) + // the cutover paymentLink opening for this row = −1000 (the gross crypto value in CHF); only the paymentLink marker + // resolves (a buyFiat-owed cutover lookup would return undefined → no false owed anchor) + jest.spyOn(ledgerTxRepo, 'findOne').mockImplementation(({ where }: any) => { + if (where?.sourceType === 'cutover' && `${where?.sourceId}`.includes(':buy_fiat-paymentLink:')) { + const pl = account('LIABILITY/paymentLink', AccountType.LIABILITY, 'CHF'); + const plLeg = Object.assign(new LedgerLeg(), { account: pl, amountChf: -1000 }); + return Promise.resolve(Object.assign(new LedgerTx(), { legs: [plLeg] })); + } + return Promise.resolve(undefined); + }); + mockBatch([ + buyFiat({ + id: 70, + amountInChf: 1000, + totalFeeAmountChf: 0, + outputReferenceAmount: 1000, + outputAmount: 1000, // net merchant fiat (CHF), 1:1 → clean venue spread 0 + outputAsset: { name: 'CHF' }, + cryptoInput: { id: 10, updated: new Date('2026-06-04T00:00:00Z'), paymentLinkPayment: { id: 1 } }, + fiatOutput: { + isTransmittedDate: FRI, + currency: 'CHF', + bank: { asset: { id: CHF_BANK_ASSET_ID } }, + bankTx: { bookingDate: SUN }, + } as any, + }), + ]); + await consumer.process(); + + // the gate opened via the cutover paymentLink marker (G-b) → seq1 IS booked (pre-fix: gate-blocked forever = wedge) + expect(seq(1)).toBeDefined(); + // it chose the paymentLink path — the opening landed in the paymentLink bucket, NOT received/owed + expect(sumOn('LIABILITY/buyFiat-received')).toBe(0); + expect(sumOn('LIABILITY/buyFiat-owed')).toBe(0); + // paymentLink debited across seq1+seq2 by exactly the opening (1000) → closes the external cutover −1000 to 0 + expect(Math.round(sumOn('LIABILITY/paymentLink') * 100)).toBe(100000); // +1000 CHF + for (const tx of booked) expect(cents(tx.legs)).toBe(0); + }); + + // --- F2: UNPRICED-AT-CUTOVER GUARD --- // + + // F2: a buy_fiat unpriced at the cutover (amountInChf NULL then → pinned) that settles post-cutover surfaces in the + // content-change scan with a PERMANENTLY-closed received gate (its value is in the aggregate opening; no per-row + // opening exists). The scan must SKIP+advance with an ERROR alarm instead of throwing forever (head-of-line wedge). + it('skips+advances (alarm, no throw) an unpriced-at-cutover buy_fiat in the content-change scan (F2)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + const errorSpy = jest.spyOn((consumer as any).logger, 'error'); + gateOpen = false; // received gate permanently closed (no per-row opening was booked) + cutoverLogId = undefined; // no G-a/G-b opening resolvable + jest + .spyOn(settingService, 'getObj') + .mockImplementation((key: string) => + Promise.resolve(key === 'ledgerCutoverUnpricedIds.buy_fiat' ? [80] : undefined), + ); + jest.spyOn(buyFiatRepo, 'find').mockImplementation(({ where }: any) => + Promise.resolve( + where?.updated != null + ? [ + buyFiat({ + id: 80, + amountInChf: 1000, // priced now (was NULL at the cutover → pinned) + totalFeeAmountChf: 10, + outputAmount: 990, + outputReferenceAmount: 990, + outputAsset: { name: 'CHF' }, + }), + ] + : [], + ), + ); + + await expect(consumer.process()).resolves.toBeUndefined(); // no throw propagates out of the scan + + expect(seq(1)).toBeUndefined(); // gate-blocked → nothing booked (value already in the aggregate opening) + expect(setSpy).toHaveBeenCalled(); // content-change cursor ADVANCED (skip, not wedge) + expect(errorSpy.mock.calls.some((c) => /unpriced at the cutover/i.test(String(c[0])))).toBe(true); // F2 alarm + }); +}); diff --git a/src/subdomains/core/accounting/services/consumers/__tests__/crypto-input.consumer.spec.ts b/src/subdomains/core/accounting/services/consumers/__tests__/crypto-input.consumer.spec.ts new file mode 100644 index 0000000000..1ed1b08dc1 --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/__tests__/crypto-input.consumer.spec.ts @@ -0,0 +1,716 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { TestUtil } from 'src/shared/utils/test.util'; +import { Util } from 'src/shared/utils/util'; +import { CryptoInput, PayInStatus, PayInType } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; +import { Repository } from 'typeorm'; +import { AccountType, LedgerAccount } from '../../../entities/ledger-account.entity'; +import { createCustomLedgerAccount } from '../../../entities/__mocks__/ledger-account.entity.mock'; +import { LedgerAccountService } from '../../ledger-account.service'; +import { LedgerBookingService, LedgerLegInput, LedgerTxInput } from '../../ledger-booking.service'; +import { LedgerMarkCache, LedgerMarkService } from '../../ledger-mark.service'; +import { CryptoInputConsumer } from '../crypto-input.consumer'; + +const ZCHF_ASSET_ID = 200; +const BTC_ASSET_ID = 201; + +function cryptoInput(values: Record): CryptoInput { + return Object.assign(new CryptoInput(), { + id: 1, + updated: new Date('2026-06-01T00:00:00Z'), + status: PayInStatus.FORWARD_CONFIRMED, + amount: 15000, + asset: { id: ZCHF_ASSET_ID, uniqueName: 'Ethereum/ZCHF' }, + ...values, + }); +} + +function account(name: string, type: AccountType, currency: string, assetId?: number): LedgerAccount { + return createCustomLedgerAccount({ id: Math.floor(Math.random() * 1e6), name, type, currency, assetId } as any); +} + +describe('CryptoInputConsumer', () => { + let consumer: CryptoInputConsumer; + let bookingService: LedgerBookingService; + let accountService: LedgerAccountService; + let markService: LedgerMarkService; + let settingService: SettingService; + let cryptoInputRepo: Repository; + + let booked: LedgerTxInput[]; + let accounts: Map; + let nextSeqValue: number; + let activeKeys: Set; // `${sourceId}:${seq}` with an active booking — backs hasActiveTxAt (per-seq, R3) + + const zchfWallet = account('Ethereum/ZCHF', AccountType.ASSET, 'ZCHF', ZCHF_ASSET_ID); + const btcWallet = account('Bitcoin/BTC', AccountType.ASSET, 'BTC', BTC_ASSET_ID); + + // ZCHF mark ≈ 1; BTC mark = 50300 (so 1 BTC ≠ amountInChf 50000 → fx plug −300) + const markMap = new Map([ + [ZCHF_ASSET_ID, [{ created: new Date('2026-01-01'), priceChf: 1 }]], + [BTC_ASSET_ID, [{ created: new Date('2026-01-01'), priceChf: 50300 }]], + ]); + + beforeEach(async () => { + booked = []; + nextSeqValue = 0; + activeKeys = new Set(); + accounts = new Map([ + ['Ethereum/ZCHF', zchfWallet], + ['Bitcoin/BTC', btcWallet], + ]); + + bookingService = createMock(); + accountService = createMock(); + markService = createMock(); + settingService = createMock(); + cryptoInputRepo = createMock>(); + + jest.spyOn(bookingService, 'bookTx').mockImplementation((input: LedgerTxInput) => { + booked.push(input); + activeKeys.add(`${input.sourceId}:${input.seq}`); // a freshly booked (sourceId,seq) is now active + return Promise.resolve({} as any); + }); + jest.spyOn(bookingService, 'nextSeq').mockImplementation(() => Promise.resolve(nextSeqValue)); + // alreadyBooked → hasActiveTxAt: true iff a booking exists AT this (sourceId, seq) (NOT nextSeq>seq, R3) + jest + .spyOn(bookingService, 'hasActiveTxAt') + .mockImplementation((_st: string, sid: string, s: number) => Promise.resolve(activeKeys.has(`${sid}:${s}`))); + + jest + .spyOn(accountService, 'findByAssetId') + .mockImplementation((assetId: number) => Promise.resolve(assetId === BTC_ASSET_ID ? btcWallet : zchfWallet)); + jest + .spyOn(accountService, 'findOrCreate') + .mockImplementation((name: string, type: AccountType, currency: string) => { + const existing = accounts.get(name); + if (existing) return Promise.resolve(existing); + const acc = account(name, type, currency); + accounts.set(name, acc); + return Promise.resolve(acc); + }); + + jest.spyOn(markService, 'preload').mockResolvedValue(new LedgerMarkCache(markMap)); + jest.spyOn(markService, 'getLatestMark').mockResolvedValue(undefined); // B5: no bridge by default (tests opt in) + jest.spyOn(settingService, 'getObj').mockResolvedValue(undefined); + jest.spyOn(settingService, 'set').mockResolvedValue(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TestUtil.provideConfig(), + CryptoInputConsumer, + { provide: LedgerBookingService, useValue: bookingService }, + { provide: LedgerAccountService, useValue: accountService }, + { provide: LedgerMarkService, useValue: markService }, + { provide: SettingService, useValue: settingService }, + { provide: getRepositoryToken(CryptoInput), useValue: cryptoInputRepo }, + ], + }).compile(); + + consumer = module.get(CryptoInputConsumer); + }); + + const cents = (legs: LedgerLegInput[]) => legs.reduce((s, l) => s + Math.round((l.amountChf ?? 0) * 100), 0); + // forward id-scan (where.id) returns the rows; the §4.12 content-change scan (where.updated MoreThan) returns [] + const mockBatch = (rows: CryptoInput[]) => + jest + .spyOn(cryptoInputRepo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [] : rows)); + + it('is defined', () => { + expect(consumer).toBeDefined(); + }); + + // §10.2 fixture (A) — stable ZCHF input: 3-leg with a near-zero fx plug + it('books a stable ZCHF buyFiat input opening received at exactly −amountInChf', async () => { + mockBatch([ + cryptoInput({ + id: 1, + amount: 15000, + asset: { id: ZCHF_ASSET_ID, uniqueName: 'Ethereum/ZCHF' }, + buyFiat: { amountInChf: 15000 } as any, + }), + ]); + await consumer.process(); + + const seq0 = booked.find((b) => b.seq === 0); + const assetLeg = seq0.legs.find((l) => l.account.name === 'Ethereum/ZCHF'); + const received = seq0.legs.find((l) => l.account.name === 'LIABILITY/buyFiat-received'); + expect(assetLeg.amountChf).toBe(15000); // mark 1 × 15000 + expect(received.amountChf).toBe(-15000); // base anchor amountInChf + expect(cents(seq0.legs)).toBe(0); // plug ≈ 0 + }); + + // §10.2 fixture (B) — volatile BTC input: 3-leg with a real fx plug, received anchored at amountInChf + it('books a volatile BTC buyFiat input as a 3-leg fx-plug tx, received = −amountInChf (Blocker R7-1)', async () => { + mockBatch([ + cryptoInput({ + id: 2, + amount: 1, + asset: { id: BTC_ASSET_ID, uniqueName: 'Bitcoin/BTC' }, + buyFiat: { amountInChf: 50000 } as any, + }), + ]); + await consumer.process(); + + const seq0 = booked.find((b) => b.seq === 0); + expect(seq0.legs).toHaveLength(3); + const assetLeg = seq0.legs.find((l) => l.account.name === 'Bitcoin/BTC'); + const received = seq0.legs.find((l) => l.account.name === 'LIABILITY/buyFiat-received'); + const plug = seq0.legs.find((l) => l.account.name?.includes('fx-revaluation')); + expect(assetLeg.amountChf).toBe(50300); // mark × amount (NOT the pricing reference) + expect(received.amountChf).toBe(-50000); // base anchor → seq1 clear closes received to 0 + // diff amountInChf − mark×amount = 50000 − 50300 = −300 < 0 → EXPENSE/fx-revaluation (§4.2a/§4.4a prose; + // the §4.4a fixture annotation "→ Cr INCOME" contradicts both prose rules and is treated as the design typo) + expect(plug.account.name).toBe('EXPENSE/fx-revaluation'); + expect(plug.amountChf).toBe(-300); + expect(cents(seq0.legs)).toBe(0); + }); + + it('books a volatile buyCrypto-swap input against LIABILITY/buyCrypto-received', async () => { + mockBatch([ + cryptoInput({ + id: 3, + amount: 1, + asset: { id: BTC_ASSET_ID, uniqueName: 'Bitcoin/BTC' }, + buyCrypto: { amountInChf: 50000 } as any, + }), + ]); + await consumer.process(); + const seq0 = booked.find((b) => b.seq === 0); + expect(seq0.legs.some((l) => l.account.name === 'LIABILITY/buyCrypto-received')).toBe(true); + expect(cents(seq0.legs)).toBe(0); + }); + + // §10.2 fixture (C) — paymentLink: 2-leg, mark-based, no fx plug (same mark both legs) + it('books an isPayment input as a 2-leg mark-based tx against LIABILITY/paymentLink', async () => { + mockBatch([ + cryptoInput({ + id: 4, + amount: 1, + txType: PayInType.PAYMENT, + asset: { id: BTC_ASSET_ID, uniqueName: 'Bitcoin/BTC' }, + }), + ]); + await consumer.process(); + + const seq0 = booked.find((b) => b.seq === 0); + expect(seq0.legs).toHaveLength(2); + const paymentLink = seq0.legs.find((l) => l.account.name === 'LIABILITY/paymentLink'); + const assetLeg = seq0.legs.find((l) => l.account.name === 'Bitcoin/BTC'); + expect(assetLeg.amountChf).toBe(50300); + expect(paymentLink.amountChf).toBe(-50300); // same mark both legs, no plug + expect(cents(seq0.legs)).toBe(0); + }); + + // §10.2 fixture (B)(d) / Major B5 — no mark ANYWHERE (asset never fed): the mixed seq0 (needsMark crypto leg + −50000 + // received anchor) cannot balance and the bridge finds no mark → the row DEFERS (nothing booked, watermark unchanged), + // NEVER an unbalanceable set handed to bookTx. A feedless asset is a genuine data state, retried next run. + it('defers (no booking, watermark unchanged) when the ASSET leg has no mark anywhere (B5)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + mockBatch([ + cryptoInput({ + id: 5, + amount: 1, + asset: { id: 999, uniqueName: 'Unknown/XYZ' }, // no mark in markMap + buyFiat: { amountInChf: 50000 } as any, + }), + ]); + jest + .spyOn(accountService, 'findByAssetId') + .mockResolvedValue(account('Unknown/XYZ', AccountType.ASSET, 'XYZ', 999)); + // markService.getLatestMark → undefined (auto-mock) → no bridge → resolveLegsOrDefer throws → failure-isolation + await consumer.process(); + + expect(booked.find((b) => b.seq === 0)).toBeUndefined(); // nothing booked (deferred) + expect(setSpy).not.toHaveBeenCalled(); // watermark NOT advanced → retry next run + }); + + // Major B5 bridge — a missing HISTORICAL mark but a present current mark: the crypto leg is valued with the youngest + // available mark (bridge), needsMark STAYS true so the mark-to-market job corrects the basis later, and the seq0 + // balances via the real fx-revaluation spread (crypto value vs the amountInChf anchor) — no wedge. + it('bridges a missing historical mark with the latest available mark and books balanced, needsMark stays true (B5)', async () => { + jest.spyOn(markService, 'getLatestMark').mockResolvedValue(48000); // youngest available mark for asset 999 + mockBatch([ + cryptoInput({ + id: 5, + amount: 1, + asset: { id: 999, uniqueName: 'Unknown/XYZ' }, + buyFiat: { amountInChf: 50000 } as any, + }), + ]); + jest + .spyOn(accountService, 'findByAssetId') + .mockResolvedValue(account('Unknown/XYZ', AccountType.ASSET, 'XYZ', 999)); + + await consumer.process(); + + const seq0 = booked.find((b) => b.seq === 0); + const assetLeg = seq0.legs.find((l) => l.account.name === 'Unknown/XYZ'); + expect(assetLeg.amountChf).toBe(48000); // bridged: 48000 × 1 + expect(assetLeg.needsMark).toBe(true); // stays true → mark-to-market re-marks to the real rate later + // real fx-revaluation spread plug (+2000 = 50000 anchor − 48000 bridged), NOT a phantom full-value plug + const fx = seq0.legs.find((l) => l.account.name?.includes('fx-revaluation')); + expect(fx?.amountChf).toBe(2000); + const cents = seq0.legs.reduce((s, l) => s + Math.round((l.amountChf ?? 0) * 100), 0); + expect(cents).toBe(0); // balances + }); + + it('books the forward fee (seq1) only when outTxId + forwardFeeAmountChf are set', async () => { + mockBatch([ + cryptoInput({ + id: 6, + amount: 1, + asset: { id: BTC_ASSET_ID, uniqueName: 'Bitcoin/BTC' }, + buyFiat: { amountInChf: 50000 } as any, + outTxId: '0xforward', + forwardFeeAmount: 0.0001, + forwardFeeAmountChf: 5, + }), + ]); + await consumer.process(); + + const seq1 = booked.find((b) => b.seq === 1); + expect(seq1).toBeDefined(); + const networkFee = seq1.legs.find((l) => l.account.name === 'EXPENSE/network-fee'); + const wallet = seq1.legs.find((l) => l.account.name === 'Bitcoin/BTC'); + expect(networkFee.amountChf).toBe(5); + expect(wallet.amountChf).toBe(-5); + expect(cents(seq1.legs)).toBe(0); + }); + + it('does NOT book a forward fee leg when forwardFeeAmountChf is null (null strategy)', async () => { + mockBatch([ + cryptoInput({ + id: 7, + amount: 1, + asset: { id: BTC_ASSET_ID, uniqueName: 'Bitcoin/BTC' }, + buyFiat: { amountInChf: 50000 } as any, + outTxId: '0xforward', + forwardFeeAmountChf: null, + }), + ]); + await consumer.process(); + expect(booked.some((b) => b.seq === 1)).toBe(false); + }); + + it('is idempotent: skips seq0 when an active booking already exists at seq0 (re-run)', async () => { + activeKeys.add('8:0'); // seq0 of crypto_input 8 already active + mockBatch([ + cryptoInput({ + id: 8, + amount: 15000, + asset: { id: ZCHF_ASSET_ID, uniqueName: 'Ethereum/ZCHF' }, + buyFiat: { amountInChf: 15000 } as any, + }), + ]); + await consumer.process(); + expect(booked.some((b) => b.seq === 0)).toBe(false); + }); + + it('advances the watermark after a successful batch', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + mockBatch([ + cryptoInput({ + id: 9, + amount: 15000, + asset: { id: ZCHF_ASSET_ID, uniqueName: 'Ethereum/ZCHF' }, + buyFiat: { amountInChf: 15000 } as any, + }), + ]); + await consumer.process(); + const written = JSON.parse(setSpy.mock.calls[0][1]); + expect(written.lastProcessedId).toBe(9); + }); + + it('no-ops on an empty batch', async () => { + mockBatch([]); + await consumer.process(); + expect(booked).toHaveLength(0); + }); + + // --- ERROR / SKIP BRANCHES --- // + + // §4.4 skip: a crypto_input that is neither buyFiat/buyCrypto nor isPayment has no anchor → buildSeq0Input returns + // undefined → no seq0 tx (the watermark still advances; it is a skip, not a failure) + it('skips seq0 for a crypto_input with no buyFiat/buyCrypto anchor and not isPayment', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + mockBatch([ + cryptoInput({ id: 20, amount: 1, asset: { id: BTC_ASSET_ID, uniqueName: 'Bitcoin/BTC' } }), // no anchor + ]); + await consumer.process(); + + expect(booked).toHaveLength(0); // no anchor → no seq0 tx at all + expect(JSON.parse(setSpy.mock.calls[0][1]).lastProcessedId).toBe(20); // skip → watermark advances + }); + + // §4.4 walletAsset throw: a crypto_input with no asset throws → failure-isolation: watermark NOT advanced + it('stops the batch and leaves the watermark when a crypto_input has no asset (failure-isolation)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + mockBatch([cryptoInput({ id: 21, amount: 1, asset: null, buyFiat: { amountInChf: 100 } as any })]); + await consumer.process(); + + expect(booked).toHaveLength(0); + expect(setSpy).not.toHaveBeenCalled(); // throw → break before advancing + }); + + // §4.4 forward-fee idempotency: seq1 already active → bookForwardFee no-ops (only seq0 books) + it('does NOT re-book the forward fee (seq1) when it is already active (re-run)', async () => { + activeKeys.add('22:1'); // seq1 already booked + mockBatch([ + cryptoInput({ + id: 22, + amount: 1, + asset: { id: BTC_ASSET_ID, uniqueName: 'Bitcoin/BTC' }, + buyFiat: { amountInChf: 50000 } as any, + outTxId: '0xforward', + forwardFeeAmount: 0.0001, + forwardFeeAmountChf: 5, + }), + ]); + await consumer.process(); + + expect(booked.some((b) => b.seq === 1)).toBe(false); // seq1 already active → skipped + expect(booked.some((b) => b.seq === 0)).toBe(true); // seq0 still booked (not yet active) + }); + + // --- §4.12 CONTENT-CHANGE SCAN --- // + + // the content-change scan recomputes the seq0 input and calls reverseAndRebookIfChanged for a row past the cursor + it('runs the §4.12 content-change scan: recomputes seq0 and calls reverseAndRebookIfChanged', async () => { + const rebookSpy = jest.spyOn(bookingService, 'reverseAndRebookIfChanged').mockResolvedValue(true); + const changed = cryptoInput({ + id: 30, + amount: 1, + asset: { id: BTC_ASSET_ID, uniqueName: 'Bitcoin/BTC' }, + buyFiat: { amountInChf: 50000 } as any, + }); + // forward id-scan empty; the content-change scan (where.updated) returns the changed row + jest + .spyOn(cryptoInputRepo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [changed] : [])); + + await consumer.process(); + + expect(rebookSpy).toHaveBeenCalledTimes(1); + expect(rebookSpy.mock.calls[0][0].sourceId).toBe('30'); // recomputed seq0 input for the changed row + }); + + // §5.2 lookback: the content-change scan preloads marks over [daysBefore(2, ci.updated), ci.updated] — NOT a + // zero-width [updated, updated] window — so getMarkAt finds the latest mark at-or-before the row timestamp for a + // non-CHF asset (a zero-width window would leave the cache empty → CHF-unbalanced re-book → the cursor wedges). + it('preloads the content-change marks over a daysBefore(2, ci.updated) lookback window', async () => { + const preloadSpy = jest.spyOn(markService, 'preload'); + const updated = new Date('2026-05-15T09:00:00Z'); + const changed = cryptoInput({ + id: 31, + updated, + amount: 1, + asset: { id: BTC_ASSET_ID, uniqueName: 'Bitcoin/BTC' }, + buyFiat: { amountInChf: 50000 } as any, + }); + jest.spyOn(bookingService, 'reverseAndRebookIfChanged').mockResolvedValue(true); + jest + .spyOn(cryptoInputRepo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [changed] : [])); + + await consumer.process(); + + // forward batch is empty → the only preload call is the content-change scan's + const lastPreload = preloadSpy.mock.calls[preloadSpy.mock.calls.length - 1]; + expect(lastPreload[0]).toEqual(Util.daysBefore(2, updated)); + expect(lastPreload[1]).toEqual(updated); + }); + + // a content-change row with no anchor → buildSeq0Input undefined → reverseAndRebookIfChanged NOT called (no-op) + it('content-change scan no-ops a row that has no bookable seq0 input', async () => { + const rebookSpy = jest.spyOn(bookingService, 'reverseAndRebookIfChanged').mockResolvedValue(false); + const changed = cryptoInput({ id: 31, amount: 1, asset: { id: BTC_ASSET_ID, uniqueName: 'Bitcoin/BTC' } }); // no anchor + jest + .spyOn(cryptoInputRepo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [changed] : [])); + + await consumer.process(); + + expect(rebookSpy).not.toHaveBeenCalled(); // no seq0 input → nothing to reverse/rebook + }); + + // C1: a settled crypto_input surfaced ONLY by the content-change scan (the id-watermark advanced over it before it + // reached a settled status) must be FORWARD-booked — book() runs from the scan callback under the settled-status gate. + it('forward-books a late-settling crypto_input surfaced only by the content-change scan (C1)', async () => { + const late = cryptoInput({ + id: 50, + amount: 15000, + asset: { id: ZCHF_ASSET_ID, uniqueName: 'Ethereum/ZCHF' }, + buyFiat: { amountInChf: 15000 } as any, + }); + jest + .spyOn(cryptoInputRepo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [late] : [])); + + await consumer.process(); + + const seq0 = booked.find((b) => b.seq === 0 && b.sourceId === '50'); + expect(seq0).toBeDefined(); // forward-booked by the content-change scan (the forward id-scan never returned it) + expect(seq0.legs.find((l) => l.account.name === 'LIABILITY/buyFiat-received').amountChf).toBe(-15000); + }); + + // C1 gate: a NOT-yet-settled crypto_input in the content-change scan is NOT forward-booked (the settled-status gate + // matches the forward filter) — its later settle bump on `updated` re-selects it. + it('does NOT forward-book a not-yet-settled crypto_input in the content-change scan (settled-status gate)', async () => { + const notSettled = cryptoInput({ + id: 51, + status: PayInStatus.CREATED, // not in CryptoInputSettledStatus + amount: 15000, + asset: { id: ZCHF_ASSET_ID, uniqueName: 'Ethereum/ZCHF' }, + buyFiat: { amountInChf: 15000 } as any, + }); + jest + .spyOn(cryptoInputRepo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [notSettled] : [])); + + await consumer.process(); + + expect(booked.some((b) => b.sourceId === '51')).toBe(false); // not settled → not booked + }); + + // --- §6.3 COVERED-BY-CUTOVER-OPENING GUARD (C1) --- // + + // §6.3: a crypto_input already SETTLED at the cutover snapshot has its value in the aggregate ASSET opening. When its + // `updated` is bumped post-cutover the content-change scan re-selects it, but its seq0 must NOT be re-booked — that + // would double-count the ASSET + book a phantom liability. boundaryId 10, no holes → id 5 <= 10 and not a hole → covered. + it('does NOT re-book a pre-cutover-settled crypto_input bumped post-cutover (covered by the aggregate opening)', async () => { + const settled = cryptoInput({ + id: 5, + amount: 15000, + asset: { id: ZCHF_ASSET_ID, uniqueName: 'Ethereum/ZCHF' }, + buyFiat: { amountInChf: 15000 } as any, + }); + jest + .spyOn(settingService, 'getObj') + .mockImplementation((key: string) => + Promise.resolve(key === 'ledgerCutoverBoundary.crypto_input' ? { boundaryId: 10, holeIds: [] } : undefined), + ); + jest + .spyOn(cryptoInputRepo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [settled] : [])); + + await consumer.process(); + + expect(booked.some((b) => b.sourceId === '5')).toBe(false); // covered → no fresh seq0 booking + }); + + // §6.3: an open-at-cutover crypto_input (value NOT in the aggregate opening) that settles post-cutover MUST book its + // seq0 exactly once. Both the post-boundary path (id 12 > boundary 10) and the recorded-hole path (id 4 in holeIds) + // book fresh — the guard suppresses only genuinely covered rows. + it('books exactly one seq0 for an open-at-cutover crypto_input that settles post-cutover (hole + post-boundary)', async () => { + const postBoundary = cryptoInput({ + id: 12, + amount: 15000, + asset: { id: ZCHF_ASSET_ID, uniqueName: 'Ethereum/ZCHF' }, + buyFiat: { amountInChf: 15000 } as any, + }); + const hole = cryptoInput({ + id: 4, + amount: 15000, + asset: { id: ZCHF_ASSET_ID, uniqueName: 'Ethereum/ZCHF' }, + buyFiat: { amountInChf: 15000 } as any, + }); + jest + .spyOn(settingService, 'getObj') + .mockImplementation((key: string) => + Promise.resolve(key === 'ledgerCutoverBoundary.crypto_input' ? { boundaryId: 10, holeIds: [4] } : undefined), + ); + jest + .spyOn(cryptoInputRepo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [hole, postBoundary] : [])); + + await consumer.process(); + + expect(booked.filter((b) => b.sourceId === '12' && b.seq === 0)).toHaveLength(1); // post-boundary → booked once + expect(booked.filter((b) => b.sourceId === '4' && b.seq === 0)).toHaveLength(1); // recorded hole → booked once + }); + + // --- ADDITIONAL BRANCH COVERAGE --- // + + // line 117 UNDEFINED side: the resolved wallet LedgerAccount has assetId == null → the `wallet.assetId != null` + // ternary takes the `: undefined` branch (NOT getMarkAt) → mark undefined → assetChf undefined → asset leg needsMark. + // Major B5: a needsMark leg with NO assetId cannot be bridged (nothing to look up) → the mixed seq0 defers rather than + // handing an unbalanceable set to bookTx. (Distinct from the id-5 test, which resolves a wallet WITH assetId.) + it('defers when the wallet account has a null assetId (no mark, nothing to bridge — B5)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + mockBatch([ + cryptoInput({ + id: 40, + amount: 1, + asset: { id: BTC_ASSET_ID, uniqueName: 'Bitcoin/BTC' }, + buyFiat: { amountInChf: 50000 } as any, + }), + ]); + // wallet resolved WITHOUT an assetId → wallet.assetId == null → `: undefined` side of the line-117 ternary + jest.spyOn(accountService, 'findByAssetId').mockResolvedValue(account('Bitcoin/BTC', AccountType.ASSET, 'BTC')); // no assetId + + await consumer.process(); + + expect(booked.find((b) => b.seq === 0)).toBeUndefined(); // nothing booked (deferred — no assetId to bridge) + expect(setSpy).not.toHaveBeenCalled(); // watermark NOT advanced + }); + + // F5: an isPayment input on a feedless asset (no mark ANYWHERE) DEFERS. The paymentLink LIABILITY is CHF-denominated + // (assetId=NULL) and the mark-to-market job (assetId IS NOT NULL) could NEVER revalue it, so booking it unvalued would + // leave the merchant liability reading null forever downstream (§4.7b/F3 paymentLinkOpeningChf → permanent wedge). + // A feedless asset is a genuine data state → fail-loud (nothing booked, watermark unchanged), retried next run. + it('defers an isPayment input when the asset has no mark anywhere (F5)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + mockBatch([ + cryptoInput({ + id: 41, + amount: 1, + txType: PayInType.PAYMENT, + asset: { id: 999, uniqueName: 'Unknown/XYZ' }, // no mark in markMap, getLatestMark → undefined + }), + ]); + jest + .spyOn(accountService, 'findByAssetId') + .mockResolvedValue(account('Unknown/XYZ', AccountType.ASSET, 'XYZ', 999)); + + await consumer.process(); + + expect(booked.find((b) => b.seq === 0)).toBeUndefined(); // deferred (feedless) — no unvalued paymentLink liability + expect(setSpy).not.toHaveBeenCalled(); // watermark NOT advanced → retry next run + }); + + // F5 bridge: an isPayment input with NO historical mark but a present current mark → the wallet leg is bridged with the + // youngest available mark (needsMark STAYS true → the mark-to-market job re-marks the wallet ASSET later) and the + // paymentLink LIABILITY is booked at exactly that bridged CHF value — never unvalued, so it reads a real value + // downstream and the buy-fiat/buy-crypto paymentLink completion can clear it (§4.7b/F3). + it('bridges an isPayment input with the latest mark and books the paymentLink liability valued (F5)', async () => { + jest.spyOn(markService, 'getLatestMark').mockResolvedValue(48000); // youngest available mark for asset 999 + mockBatch([ + cryptoInput({ + id: 41, + amount: 1, + txType: PayInType.PAYMENT, + asset: { id: 999, uniqueName: 'Unknown/XYZ' }, + }), + ]); + jest + .spyOn(accountService, 'findByAssetId') + .mockResolvedValue(account('Unknown/XYZ', AccountType.ASSET, 'XYZ', 999)); + + await consumer.process(); + + const seq0 = booked.find((b) => b.seq === 0); + expect(seq0.legs).toHaveLength(2); + const assetLeg = seq0.legs.find((l) => l.account.name === 'Unknown/XYZ'); + const paymentLink = seq0.legs.find((l) => l.account.name === 'LIABILITY/paymentLink'); + expect(assetLeg.amountChf).toBe(48000); // bridged: 48000 × 1 + expect(assetLeg.needsMark).toBe(true); // stays true → mark-to-market re-marks the wallet ASSET to the real rate later + expect(paymentLink.amountChf).toBe(-48000); // valued from the bridged wallet CHF (never null) + expect(paymentLink.amount).toBe(-48000); // CHF-denominated: native == CHF value + expect(paymentLink.needsMark).toBe(false); // booked with a value NOW; a CHF liability is never re-marked + expect(cents(seq0.legs)).toBe(0); // same value both legs → balances, no plug + }); + + // B7 forward-fee: forwardFeeAmount (feeNative) null while forwardFeeAmountChf set → the native fee is DERIVED from + // feeChf via the wallet mark (feeChf / mark), NEVER the CHF value booked as native units. BTC has a mark (50300) → + // native = round(5 / 50300, 8), priceChf = 50300, amountChf = −5. + it('derives the native forward fee from feeChf via the wallet mark when forwardFeeAmount is null (B7)', async () => { + mockBatch([ + cryptoInput({ + id: 42, + amount: 1, + asset: { id: BTC_ASSET_ID, uniqueName: 'Bitcoin/BTC' }, + buyFiat: { amountInChf: 50000 } as any, + outTxId: '0xforward', + forwardFeeAmount: null, // feeNative null → derive native from feeChf via the BTC mark (never CHF as native) + forwardFeeAmountChf: 5, + }), + ]); + await consumer.process(); + + const seq1 = booked.find((b) => b.seq === 1); + expect(seq1).toBeDefined(); + const networkFee = seq1.legs.find((l) => l.account.name === 'EXPENSE/network-fee'); + const wallet = seq1.legs.find((l) => l.account.name === 'Bitcoin/BTC'); + expect(networkFee.amountChf).toBe(5); + expect(wallet.amount).toBe(-Util.round(5 / 50300, 8)); // native derived: feeChf / mark, NOT the CHF value + expect(wallet.amountChf).toBe(-5); + expect(wallet.priceChf).toBe(50300); // the wallet mark used to derive the native + expect(cents(seq1.legs)).toBe(0); + }); + + // B7 fail-loud: forwardFeeAmount null AND no mark ANYWHERE for the wallet asset → refuse to book a CHF value as native + // units; throw (failure-isolation, retry) instead. seq0 is pre-marked as already booked (activeKeys) so book() skips + // it — otherwise F5 would defer the feedless isPayment seq0 first — isolating the forward-fee seq1 B7 guard. + it('fails loud when the forward fee has no native amount and no mark to derive it (B7)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + activeKeys.add('43:0'); // seq0 already booked on a prior run → book() skips it, reaching the seq1 B7 guard directly + mockBatch([ + cryptoInput({ + id: 43, + amount: 1, + txType: PayInType.PAYMENT, + asset: { id: 999, uniqueName: 'Unknown/XYZ' }, // no mark in markMap, getLatestMark → undefined + outTxId: '0xforward', + forwardFeeAmount: null, // no native fee AND no mark → B7 throws rather than book CHF as native + forwardFeeAmountChf: 5, + }), + ]); + jest + .spyOn(accountService, 'findByAssetId') + .mockResolvedValue(account('Unknown/XYZ', AccountType.ASSET, 'XYZ', 999)); + await consumer.process(); + + expect(booked.find((b) => b.seq === 1)).toBeUndefined(); // forward fee NOT booked (fail-loud), never CHF as native + expect(setSpy).not.toHaveBeenCalled(); // watermark NOT advanced → retry + }); + + // lines 202/206 appendFxPlug POSITIVE residual: amountInChf > mark×amount → residual ≥ 0 → INCOME/fx-revaluation. + // BTC mark 50300, amount 1, buyFiat.amountInChf 50600 → asset +50300, received −50600, sum −300 → residual +300. + it('books an INCOME/fx-revaluation plug when the valuation residual is positive', async () => { + mockBatch([ + cryptoInput({ + id: 43, + amount: 1, + asset: { id: BTC_ASSET_ID, uniqueName: 'Bitcoin/BTC' }, + buyFiat: { amountInChf: 50600 } as any, + }), + ]); + await consumer.process(); + + const seq0 = booked.find((b) => b.seq === 0); + expect(seq0.legs).toHaveLength(3); + const assetLeg = seq0.legs.find((l) => l.account.name === 'Bitcoin/BTC'); + const received = seq0.legs.find((l) => l.account.name === 'LIABILITY/buyFiat-received'); + const plug = seq0.legs.find((l) => l.account.name?.includes('fx-revaluation')); + expect(assetLeg.amountChf).toBe(50300); + expect(received.amountChf).toBe(-50600); + expect(plug.account.name).toBe('INCOME/fx-revaluation'); // residual +300 ≥ 0 → income side + expect(plug.amountChf).toBe(300); + expect(cents(seq0.legs)).toBe(0); + }); + + // line 227 walletAsset throw: an asset object IS present but findByAssetId returns undefined → throws (CoA bootstrap + // missing) → failure-isolation: watermark NOT advanced, nothing booked. (Distinct from the id-21 'no asset' test, + // which throws earlier at line 225.) + it('stops the batch and leaves the watermark when the ledger account for the asset is missing (CoA bootstrap)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + mockBatch([ + cryptoInput({ + id: 44, + amount: 1, + asset: { id: BTC_ASSET_ID, uniqueName: 'Bitcoin/BTC' }, + buyFiat: { amountInChf: 50000 } as any, + }), + ]); + jest.spyOn(accountService, 'findByAssetId').mockResolvedValue(undefined); // account not found → throw at line 227 + + await consumer.process(); + + expect(booked).toHaveLength(0); + expect(setSpy).not.toHaveBeenCalled(); // throw → break before advancing the watermark + }); +}); diff --git a/src/subdomains/core/accounting/services/consumers/__tests__/exchange-tx.consumer.spec.ts b/src/subdomains/core/accounting/services/consumers/__tests__/exchange-tx.consumer.spec.ts new file mode 100644 index 0000000000..2c354dad1d --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/__tests__/exchange-tx.consumer.spec.ts @@ -0,0 +1,1276 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ExchangeTx, ExchangeTxType } from 'src/integration/exchange/entities/exchange-tx.entity'; +import { ExchangeName } from 'src/integration/exchange/enums/exchange.enum'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { TestUtil } from 'src/shared/utils/test.util'; +import { Util } from 'src/shared/utils/util'; +import { BankTx } from 'src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity'; +import { Repository } from 'typeorm'; +import { AccountType, LedgerAccount } from '../../../entities/ledger-account.entity'; +import { createCustomLedgerAccount } from '../../../entities/__mocks__/ledger-account.entity.mock'; +import { LedgerLegRepository } from '../../../repositories/ledger-leg.repository'; +import { LedgerAccountService } from '../../ledger-account.service'; +import { LedgerBookingService, LedgerLegInput, LedgerTxInput } from '../../ledger-booking.service'; +import { LedgerMarkCache, LedgerMarkService } from '../../ledger-mark.service'; +import { ExchangeTxConsumer } from '../exchange-tx.consumer'; + +function exchangeTx(values: Partial): ExchangeTx { + return Object.assign(new ExchangeTx(), { + id: 1, + created: new Date('2026-06-01T00:00:00Z'), + externalCreated: new Date('2026-06-01T00:00:00Z'), + updated: new Date('2026-06-02T00:00:00Z'), // IEntity always sets updated; the §4.3 content-change scan reads it + exchange: ExchangeName.SCRYPT, + status: 'ok', + ...values, + }); +} + +function account(name: string, type: AccountType, currency: string, assetId?: number): LedgerAccount { + return createCustomLedgerAccount({ id: Math.floor(Math.random() * 1e6), name, type, currency, assetId } as any); +} + +describe('ExchangeTxConsumer', () => { + let consumer: ExchangeTxConsumer; + let bookingService: LedgerBookingService; + let accountService: LedgerAccountService; + let markService: LedgerMarkService; + let settingService: SettingService; + let exchangeTxRepo: Repository; + let bankTxRepo: Repository; + let ledgerLegRepository: LedgerLegRepository; + + let booked: LedgerTxInput[]; + let accounts: Map; + + // mark map: asset id 50 (Scrypt/EUR), 51 (Scrypt/CHF), 60 (Scrypt/USDT) + const markMap = new Map([ + [50, [{ created: new Date('2026-01-01'), priceChf: 0.95 }]], + [51, [{ created: new Date('2026-01-01'), priceChf: 1 }]], + [60, [{ created: new Date('2026-01-01'), priceChf: 0.9 }]], + ]); + + beforeEach(async () => { + booked = []; + accounts = new Map([ + ['Scrypt/EUR', account('Scrypt/EUR', AccountType.ASSET, 'EUR', 50)], + ['Scrypt/CHF', account('Scrypt/CHF', AccountType.ASSET, 'CHF', 51)], + ['Scrypt/USDT', account('Scrypt/USDT', AccountType.ASSET, 'USDT', 60)], + ['Binance/USDT', account('Binance/USDT', AccountType.ASSET, 'USDT', 60)], + ['Binance/BTC', account('Binance/BTC', AccountType.ASSET, 'BTC', 70)], + ]); + + bookingService = createMock(); + accountService = createMock(); + markService = createMock(); + settingService = createMock(); + exchangeTxRepo = createMock>(); + bankTxRepo = createMock>(); + ledgerLegRepository = createMock(); + + jest.spyOn(bookingService, 'bookTx').mockImplementation((input: LedgerTxInput) => { + booked.push(input); + return Promise.resolve({} as any); + }); + jest.spyOn(bookingService, 'nextSeq').mockResolvedValue(0); + + jest.spyOn(accountService, 'findByName').mockImplementation((name: string) => Promise.resolve(accounts.get(name))); + jest + .spyOn(accountService, 'findOrCreate') + .mockImplementation((name: string, type: AccountType, currency: string) => { + const existing = accounts.get(name); + if (existing) return Promise.resolve(existing); + const acc = account(name, type, currency); + accounts.set(name, acc); + return Promise.resolve(acc); + }); + + jest.spyOn(markService, 'preload').mockResolvedValue(new LedgerMarkCache(markMap)); + jest.spyOn(markService, 'getLatestMark').mockResolvedValue(undefined); // B5: no bridge by default (tests opt in) + jest.spyOn(settingService, 'getObj').mockResolvedValue(undefined); + jest.spyOn(settingService, 'set').mockResolvedValue(); + jest.spyOn(bankTxRepo, 'find').mockResolvedValue([]); + jest.spyOn(ledgerLegRepository, 'find').mockResolvedValue([]); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TestUtil.provideConfig(), + ExchangeTxConsumer, + { provide: LedgerBookingService, useValue: bookingService }, + { provide: LedgerAccountService, useValue: accountService }, + { provide: LedgerMarkService, useValue: markService }, + { provide: SettingService, useValue: settingService }, + { provide: getRepositoryToken(ExchangeTx), useValue: exchangeTxRepo }, + { provide: getRepositoryToken(BankTx), useValue: bankTxRepo }, + { provide: LedgerLegRepository, useValue: ledgerLegRepository }, + ], + }).compile(); + + consumer = module.get(ExchangeTxConsumer); + }); + + const cents = (legs: LedgerLegInput[]) => legs.reduce((s, l) => s + Math.round((l.amountChf ?? 0) * 100), 0); + + function mockBatch(rows: ExchangeTx[]): void { + jest.spyOn(exchangeTxRepo, 'find').mockImplementation((opts: any) => { + // the §4.3 content-change scan (where.updated is a combined-cursor Raw) returns [] → the forward path is asserted + // in isolation; the ok→failed reversal path has its own dedicated tests (mockContentChange below) + if (opts?.where?.updated != null) return Promise.resolve([]); + // the fill-index preload re-queries with type=Trade — return the same trade rows for ranking + if (opts?.where?.order != null) return Promise.resolve(rows.filter((r) => r.type === ExchangeTxType.TRADE)); + return Promise.resolve(rows); + }); + } + + // wires the forward scan empty and the §4.3 content-change scan to return the given changed rows (status-agnostic) + function mockContentChange(forward: ExchangeTx[], changed: ExchangeTx[]): void { + jest.spyOn(exchangeTxRepo, 'find').mockImplementation((opts: any) => { + if (opts?.where?.updated != null) return Promise.resolve(changed); // the content-change scan rows + // B4: the fill-index preload query is status-AGNOSTIC (where: { type, order } — no status filter), so a fill that + // flipped away from 'ok' keeps its id-ordered rank slot (survivors never shift). Faithfully honour opts.where.status + // so the mock returns exactly what the query asks: post-fix (no status) → all fills; a reverted status='ok' filter + // → only the still-ok ones (which would shift the survivors, failing the B4 test). + if (opts?.where?.order != null) { + const trades = [...forward, ...changed].filter((r) => r.type === ExchangeTxType.TRADE); + return Promise.resolve( + opts.where.status != null ? trades.filter((r) => r.status === opts.where.status) : trades, + ); + } + return Promise.resolve(forward.filter((r) => opts?.where?.status == null || r.status === opts.where.status)); + }); + } + + it('is defined', () => { + expect(consumer).toBeDefined(); + }); + + it('books a bank-routed Deposit against TRANSIT/bank↔{ex}/{ccy} (R2)', async () => { + jest.spyOn(bankTxRepo, 'find').mockResolvedValue([Object.assign(new BankTx(), { amount: 1000 })] as BankTx[]); + mockBatch([exchangeTx({ id: 1, type: ExchangeTxType.DEPOSIT, currency: 'EUR', amount: 1000, amountChf: 950 })]); + await consumer.process(); + + const legs = booked[0].legs; + expect(legs.find((l) => l.account.name === 'Scrypt/EUR').amount).toBe(1000); + expect(legs.find((l) => l.account.name === 'TRANSIT/bank↔Scrypt/EUR')).toBeDefined(); + expect(cents(legs)).toBe(0); + }); + + it('routes a wallet Deposit (txId present, no bank match) to TRANSIT/wallet↔{ex}/{ccy} (R3)', async () => { + mockBatch([ + exchangeTx({ + id: 1, + type: ExchangeTxType.DEPOSIT, + currency: 'USDT', + amount: 1000, + amountChf: 900, + txId: '0xabc', + }), + ]); + await consumer.process(); + expect(booked[0].legs.find((l) => l.account.name === 'TRANSIT/wallet↔Scrypt/USDT')).toBeDefined(); + expect(cents(booked[0].legs)).toBe(0); + }); + + it('routes an unroutable Deposit to SUSPENSE/{exchange}-deposit-unrouted (R4)', async () => { + mockBatch([exchangeTx({ id: 1, type: ExchangeTxType.DEPOSIT, currency: 'USDT', amount: 1000, amountChf: 900 })]); + await consumer.process(); + expect(booked[0].legs.find((l) => l.account.name === 'SUSPENSE/Scrypt-deposit-unrouted/USDT')).toBeDefined(); + expect(cents(booked[0].legs)).toBe(0); + }); + + it('routes a Scrypt/EUR Deposit matching an open Raiffeisen SUSPENSE post to that SUSPENSE (R1/§4.3b)', async () => { + accounts.set( + 'SUSPENSE/untracked-bank-Raiffeisen-EUR', + account('SUSPENSE/untracked-bank-Raiffeisen-EUR', AccountType.SUSPENSE, 'EUR'), + ); + jest + .spyOn(ledgerLegRepository, 'find') + .mockResolvedValue([{ amount: 1000, account: { name: 'SUSPENSE/untracked-bank-Raiffeisen-EUR' } } as any]); + mockBatch([exchangeTx({ id: 1, type: ExchangeTxType.DEPOSIT, currency: 'EUR', amount: 1000, amountChf: 950 })]); + await consumer.process(); + + const counter = booked[0].legs.find((l) => l.account.name === 'SUSPENSE/untracked-bank-Raiffeisen-EUR'); + expect(counter).toBeDefined(); + expect(cents(booked[0].legs)).toBe(0); + }); + + it('leaves an ambiguous Raiffeisen sweep (two equal posts) in SUSPENSE without guessing', async () => { + accounts.set( + 'SUSPENSE/untracked-bank-Raiffeisen-EUR', + account('SUSPENSE/untracked-bank-Raiffeisen-EUR', AccountType.SUSPENSE, 'EUR'), + ); + jest.spyOn(ledgerLegRepository, 'find').mockResolvedValue([{ amount: 1000 } as any, { amount: 1000 } as any]); + mockBatch([exchangeTx({ id: 1, type: ExchangeTxType.DEPOSIT, currency: 'EUR', amount: 1000, amountChf: 950 })]); + await consumer.process(); + + const legs = booked[0].legs; + // no guessing: only the deposit's own 2 legs — no extra leg synthesised to close a specific ambiguous post + expect(legs).toHaveLength(2); + const asset = legs.find((l) => l.account.name === 'Scrypt/EUR'); + expect(asset.amount).toBe(1000); + expect(asset.amountChf).toBe(950); + const suspenseLegs = legs.filter((l) => l.account.name === 'SUSPENSE/untracked-bank-Raiffeisen-EUR'); + expect(suspenseLegs).toHaveLength(1); // one deposit-worth counter leg, NOT a per-post closing leg + expect(suspenseLegs[0].amountChf).toBe(-950); // one deposit's value → both open +1000 posts remain, no sweep + expect(cents(legs)).toBe(0); // balanced + }); + + it('books a Withdrawal mirror (Dr counter / Cr ASSET)', async () => { + jest.spyOn(bankTxRepo, 'find').mockResolvedValue([Object.assign(new BankTx(), { amount: 1000 })] as BankTx[]); + mockBatch([exchangeTx({ id: 1, type: ExchangeTxType.WITHDRAWAL, currency: 'EUR', amount: 1000, amountChf: 950 })]); + await consumer.process(); + const asset = booked[0].legs.find((l) => l.account.name === 'Scrypt/EUR'); + expect(asset.amount).toBe(-1000); // withdrawal reduces exchange asset + expect(cents(booked[0].legs)).toBe(0); + }); + + it('falls back to the mark when Deposit amountChf is null (Minor R9-4) and flags needsMark when no mark', async () => { + mockBatch([ + exchangeTx({ id: 1, type: ExchangeTxType.DEPOSIT, currency: 'EUR', amount: 1000, amountChf: null, txId: '0x1' }), + ]); + await consumer.process(); + const asset = booked[0].legs.find((l) => l.account.name === 'Scrypt/EUR'); + expect(asset.amountChf).toBe(950); // mark 0.95 × 1000 + expect(asset.needsMark).toBe(false); + + booked = []; + accounts.set('Scrypt/XYZ', account('Scrypt/XYZ', AccountType.ASSET, 'XYZ', 999)); // no mark for id 999 + mockBatch([ + exchangeTx({ id: 2, type: ExchangeTxType.DEPOSIT, currency: 'XYZ', amount: 5, amountChf: null, txId: '0x2' }), + ]); + await consumer.process(); + const xyz = booked[0].legs.find((l) => l.account.name === 'Scrypt/XYZ'); + expect(xyz.needsMark).toBe(true); + expect(xyz.amountChf).toBeUndefined(); + }); + + it('books a Scrypt buy Trade with ONE persisted spread leg + quote leg as plug (Blocker R6-1)', async () => { + // base USDT (amount 1000, amountChf 900), quote CHF (cost 905), feeAmountChf = market spread −5 (rebate) + mockBatch([ + exchangeTx({ + id: 1, + exchange: ExchangeName.SCRYPT, + type: ExchangeTxType.TRADE, + symbol: 'USDT/CHF', + side: 'buy', + order: 'O-1', + amount: 1000, + amountChf: 900, + cost: 905, + feeAmountChf: -5, + }), + ]); + await consumer.process(); + + const legs = booked[0].legs; + // exactly one spread leg = persisted feeAmountChf; negative → INCOME/spread-Scrypt (rebate, Minor R6-4) + const spread = legs.filter((l) => l.account.name?.includes('spread-Scrypt')); + expect(spread).toHaveLength(1); + expect(spread[0].account.name).toBe('INCOME/spread-Scrypt'); + expect(spread[0].amountChf).toBe(-5); // the persisted feeAmountChf (rebate) is the exact spread-leg value + // quote leg is the plug; no extra mark-based quote-spread leg + const quote = legs.find((l) => l.account.name === 'Scrypt/CHF'); + expect(quote).toBeDefined(); + expect(cents(legs)).toBe(0); // closes without ROUNDING throw + expect(booked[0].sourceType).toBe('ExchangeTrade'); + expect(booked[0].sourceId).toBe('Scrypt:O-1'); // B3: exchange-prefixed composite sourceId + }); + + it('books a positive Scrypt spread to EXPENSE/spread-Scrypt', async () => { + mockBatch([ + exchangeTx({ + id: 1, + exchange: ExchangeName.SCRYPT, + type: ExchangeTxType.TRADE, + symbol: 'USDT/CHF', + side: 'buy', + order: 'O-2', + amount: 1000, + amountChf: 900, + cost: 905, + feeAmountChf: 5, + }), + ]); + await consumer.process(); + expect(booked[0].legs.some((l) => l.account.name === 'EXPENSE/spread-Scrypt' && l.amountChf === 5)).toBe(true); + expect(cents(booked[0].legs)).toBe(0); + }); + + it('books a ccxt (Binance) Trade with a mark-based quote leg + a separate venue fee leg', async () => { + // base USDT (amount 1000, amountChf 900 base mark), quote BTC (cost 0.01 × mark), separate fee +2 + markMap.set(70, [{ created: new Date('2026-01-01'), priceChf: 90000 }]); // BTC mark + mockBatch([ + exchangeTx({ + id: 1, + exchange: ExchangeName.BINANCE, + type: ExchangeTxType.TRADE, + symbol: 'USDT/BTC', + side: 'buy', + order: 'O-3', + amount: 1000, + amountChf: 900, + cost: 0.01, + feeAmountChf: 2, + }), + ]); + await consumer.process(); + + const legs = booked[0].legs; + // separate venue fee leg = persisted feeAmountChf + expect(legs.some((l) => l.account.name === 'EXPENSE/spread-Binance' && l.amountChf === 2)).toBe(true); + // quote leg carries its own mark (not a plug), value-boundary needs no native check + expect(legs.find((l) => l.account.name === 'Binance/BTC')).toBeDefined(); + expect(cents(legs)).toBe(0); + }); + + it('routes a Trade with no symbol/side to SUSPENSE/{exchange}-trade-unattributed', async () => { + mockBatch([ + exchangeTx({ id: 1, type: ExchangeTxType.TRADE, symbol: null, side: null, amount: 100, amountChf: 90 }), + ]); + await consumer.process(); + expect(booked[0].legs.some((l) => l.account.name === 'SUSPENSE/Scrypt-trade-unattributed')).toBe(true); + expect(cents(booked[0].legs)).toBe(0); + }); + + it('assigns batch-stable, re-run-idempotent fill_index per (exchange, order)', async () => { + const f1 = exchangeTx({ + id: 10, + type: ExchangeTxType.TRADE, + symbol: 'USDT/CHF', + side: 'buy', + order: 'O-9', + amount: 100, + amountChf: 90, + cost: 90, + }); + const f2 = exchangeTx({ + id: 11, + type: ExchangeTxType.TRADE, + symbol: 'USDT/CHF', + side: 'buy', + order: 'O-9', + amount: 100, + amountChf: 90, + cost: 90, + }); + mockBatch([f1, f2]); + await consumer.process(); + + const seqs = booked.map((b) => b.seq).sort((a, b) => a - b); + expect(seqs).toEqual([0, 1]); // deterministic 0-based ranks by id + expect(booked.every((b) => b.sourceId === 'Scrypt:O-9')).toBe(true); // B3: exchange-prefixed composite sourceId + }); + + // the watermark does not persist in this spec (getObj → undefined, set → no-op), so a naive second process() + // re-selects the same rows. The real backstop against double-booking is the DB UNIQUE(sourceType, sourceId, seq) + // constraint + the forward loop's failure-isolation: here bookTx enforces UNIQUE, and buildFillIndexMap re-derives + // the SAME 0-based ranks, so the re-run's identical keys collide and no second booking lands. If fill_index were NOT + // reproduced (e.g. run 2 produced seq 2,3) booked.length would be 4; the `=== 2` assertion is what discriminates. + it('is re-run idempotent: a second run reproduces the same fill_index and does not double-book (UNIQUE backstop)', async () => { + const keys = new Set(); + jest.spyOn(bookingService, 'bookTx').mockImplementation((input: LedgerTxInput) => { + const key = `${input.sourceType}:${input.sourceId}:${input.seq}`; + if (keys.has(key)) return Promise.reject(new Error('duplicate ledger_tx key (UNIQUE)')); // DB backstop + keys.add(key); + booked.push(input); + return Promise.resolve({} as any); + }); + const f1 = exchangeTx({ + id: 10, + type: ExchangeTxType.TRADE, + symbol: 'USDT/CHF', + side: 'buy', + order: 'O-9', + amount: 100, + amountChf: 90, + cost: 90, + }); + const f2 = exchangeTx({ + id: 11, + type: ExchangeTxType.TRADE, + symbol: 'USDT/CHF', + side: 'buy', + order: 'O-9', + amount: 100, + amountChf: 90, + cost: 90, + }); + mockBatch([f1, f2]); + + await consumer.process(); + await consumer.process(); // re-run: forward re-selects, buildFillIndexMap re-derives the same 0-based ranks + + // no double-booking: the re-run's identical (sourceId, seq) keys collided with the UNIQUE backstop → still 2 txs + expect(booked).toHaveLength(2); + // deterministic, reproduced fill_index + expect(booked.map((b) => b.seq).sort((a, b) => a - b)).toEqual([0, 1]); + expect(booked.every((b) => b.sourceType === 'ExchangeTrade' && b.sourceId === 'Scrypt:O-9')).toBe(true); // B3 + }); + + it('advances the watermark after a successful batch', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + mockBatch([ + exchangeTx({ id: 7, type: ExchangeTxType.DEPOSIT, currency: 'EUR', amount: 100, amountChf: 95, txId: '0x' }), + ]); + await consumer.process(); + const written = JSON.parse(setSpy.mock.calls[0][1]); + expect(written.lastProcessedId).toBe(7); + }); + + // --- §4.3 REVERSAL TRIGGER (status ok→failed/canceled + content change) --- // + + it('flat-reverses a Deposit whose status flipped ok→failed (content-change scan, §4.3)', async () => { + const reverseSpy = jest.spyOn(bookingService, 'reverseActiveIfBooked').mockResolvedValue(true); + const rebookSpy = jest.spyOn(bookingService, 'reverseAndRebookIfChanged').mockResolvedValue(false); + // forward batch empty; the row now has status='failed' and is selected only by the status-agnostic content scan + const failed = exchangeTx({ id: 5, type: ExchangeTxType.DEPOSIT, currency: 'EUR', amount: 100, status: 'failed' }); + mockContentChange([], [failed]); + await consumer.process(); + + // ok→failed → flat reversal at the row's booked identifiers (exchange_tx, id, seq 0), NOT a re-book + expect(reverseSpy).toHaveBeenCalledWith('exchange_tx', '5', 0); + expect(rebookSpy).not.toHaveBeenCalled(); + }); + + it('flat-reverses a Trade whose status flipped ok→canceled at its order/fill-index seq (§4.3)', async () => { + const reverseSpy = jest.spyOn(bookingService, 'reverseActiveIfBooked').mockResolvedValue(true); + const canceled = exchangeTx({ + id: 12, + type: ExchangeTxType.TRADE, + symbol: 'USDT/CHF', + side: 'buy', + order: 'O-7', + amount: 100, + amountChf: 90, + cost: 90, + status: 'canceled', + }); + mockContentChange([], [canceled]); + await consumer.process(); + + // trade reversal targets sourceType=ExchangeTrade, sourceId=`${exchange}:${order}` (B3), seq=fill-index (0 for the + // only fill of O-7) + expect(reverseSpy).toHaveBeenCalledWith('ExchangeTrade', 'Scrypt:O-7', 0); + }); + + // Major B4: an ok→failed flip of a SIBLING fill must NOT shift the survivors' fill-index seqs. Order O-40 has three + // fills [id 10, 20, 30]; fill 20 flipped to failed. The ranking is status-agnostic (over ALL fills of the order), so + // fill 30 keeps its id-ordered slot seq 2 — NOT shifted down to 1 by fill 20 dropping out of the 'ok' set (the pre-fix + // corruption). The survivor's correction targets seq 2. The faithful preload mock honours opts.where.status, so a + // reverted status='ok' filter would return only [10, 30] → rank fill 30 at seq 1 → this test would fail. + it('keeps a survivor fill index stable when a sibling fill flips ok→failed (no seq shift, B4)', async () => { + const rebookSpy = jest.spyOn(bookingService, 'reverseAndRebookIfChanged').mockResolvedValue(true); + const fill = (id: number, status = 'ok') => + exchangeTx({ + id, + type: ExchangeTxType.TRADE, + symbol: 'USDT/CHF', + side: 'buy', + order: 'O-40', + amount: 100, + amountChf: 90, + cost: 90, + status, + }); + const f10 = fill(10); + const f20 = fill(20, 'failed'); // sibling flipped away from 'ok' + const f30 = fill(30); // survivor being reconciled by the content-change scan + + jest.spyOn(exchangeTxRepo, 'find').mockImplementation((opts: any) => { + if (opts?.where?.updated != null) return Promise.resolve([f30]); // content-change scan re-selects the survivor + if (opts?.where?.order != null) { + const all = [f10, f20, f30]; // ALL fills of O-40, any status + return Promise.resolve(opts.where.status != null ? all.filter((r) => r.status === opts.where.status) : all); + } + return Promise.resolve([]); // forward id-scan empty + }); + + await consumer.process(); + + expect(rebookSpy).toHaveBeenCalledTimes(1); + expect(rebookSpy.mock.calls[0][0].seq).toBe(2); // fill 30 keeps rank 2 (status-agnostic) — NOT shifted to 1 + expect(rebookSpy.mock.calls[0][0].sourceId).toBe('Scrypt:O-40'); + }); + + it('reverses + re-books an ok Deposit whose amount changed (content-change, §4.12)', async () => { + const reverseSpy = jest.spyOn(bookingService, 'reverseActiveIfBooked').mockResolvedValue(true); + const rebookSpy = jest.spyOn(bookingService, 'reverseAndRebookIfChanged').mockResolvedValue(true); + const changedRow = exchangeTx({ + id: 6, + type: ExchangeTxType.DEPOSIT, + currency: 'EUR', + amount: 200, + amountChf: 190, + txId: '0x', + }); + mockContentChange([], [changedRow]); + await consumer.process(); + + // status still 'ok' → recompute legs + reverse-and-rebook-if-changed (NOT a flat reversal) + expect(rebookSpy).toHaveBeenCalledTimes(1); + expect(rebookSpy.mock.calls[0][0].sourceId).toBe('6'); + expect(reverseSpy).not.toHaveBeenCalled(); + }); + + it('content-change scan no-ops a row of an unhandled type (buildSpec undefined → nothing to correct)', async () => { + const reverseSpy = jest.spyOn(bookingService, 'reverseActiveIfBooked').mockResolvedValue(false); + const rebookSpy = jest.spyOn(bookingService, 'reverseAndRebookIfChanged').mockResolvedValue(false); + // an unhandled type → buildSpec returns undefined → reconcileBooking returns early, no reverse/rebook + const unhandled = exchangeTx({ id: 8, type: 'Reward' as ExchangeTxType, amount: 1, status: 'ok' }); + mockContentChange([], [unhandled]); + await consumer.process(); + + expect(reverseSpy).not.toHaveBeenCalled(); + expect(rebookSpy).not.toHaveBeenCalled(); + }); + + // --- C1: LATE-SETTLING FORWARD COVERAGE (content-change scan) --- // + + // C1: an exchange_tx that becomes status='ok' only AFTER the forward id-watermark advanced over it is + // forward-unreachable (the forward scan filters status='ok'). The status-agnostic content-change scan must + // forward-book it: reconcileBooking finds nothing active to correct and no tx ever booked at the seq → bookTx. + it('forward-books a late-settling ok exchange_tx surfaced only by the content-change scan (C1)', async () => { + const rebookSpy = jest.spyOn(bookingService, 'reverseAndRebookIfChanged').mockResolvedValue(false); // nothing active + jest.spyOn(bookingService, 'hasAnyTxAt').mockResolvedValue(false); // never booked at this seq + const late = exchangeTx({ + id: 40, + type: ExchangeTxType.DEPOSIT, + currency: 'EUR', + amount: 100, + amountChf: 95, + txId: '0x', + status: 'ok', + }); + mockContentChange([], [late]); + await consumer.process(); + + expect(rebookSpy).toHaveBeenCalledTimes(1); // tried to correct → nothing active + expect(booked.map((b) => b.sourceId)).toEqual(['40']); // then forward-booked because no tx ever existed at the seq + }); + + // C1: an active-unchanged (or flat-reversed) row in the content-change scan must NOT be re-booked — hasAnyTxAt guards + // the bookTx so no UNIQUE collision on the append-only original seq. + it('does NOT forward-book an ok exchange_tx that already has a tx at its seq (hasAnyTxAt guard)', async () => { + jest.spyOn(bookingService, 'reverseAndRebookIfChanged').mockResolvedValue(false); // unchanged & active + jest.spyOn(bookingService, 'hasAnyTxAt').mockResolvedValue(true); // a tx already exists at this seq + const unchanged = exchangeTx({ + id: 41, + type: ExchangeTxType.DEPOSIT, + currency: 'EUR', + amount: 100, + amountChf: 95, + txId: '0x', + status: 'ok', + }); + mockContentChange([], [unchanged]); + await consumer.process(); + + expect(booked).toHaveLength(0); // hasAnyTxAt true → no forward book (would collide with the existing seq) + }); + + // C1 two-run integration: run 1 books the ok row 61 and advances the id-watermark OVER the still-pending id 60; row + // 60 flips to ok; run 2's forward id-scan (id > 61) no longer returns it, but the content-change scan forward-books it. + it('forward-books an exchange_tx that flipped to ok AFTER the id-watermark advanced over it (C1, two runs)', async () => { + const bookedKeys = new Set(); + jest.spyOn(bookingService, 'bookTx').mockImplementation((input: LedgerTxInput) => { + bookedKeys.add(`${input.sourceType}:${input.sourceId}:${input.seq}`); + booked.push(input); + return Promise.resolve({} as any); + }); + jest.spyOn(bookingService, 'reverseAndRebookIfChanged').mockResolvedValue(false); // nothing active to correct + jest.spyOn(bookingService, 'reverseActiveIfBooked').mockResolvedValue(false); + jest + .spyOn(bookingService, 'hasAnyTxAt') + .mockImplementation((st: string, sid: string, seq: number) => + Promise.resolve(bookedKeys.has(`${st}:${sid}:${seq}`)), + ); + + const settled = exchangeTx({ + id: 61, + type: ExchangeTxType.DEPOSIT, + currency: 'EUR', + amount: 100, + amountChf: 95, + txId: '0x', + status: 'ok', + }); + const lateBefore = exchangeTx({ + id: 60, + type: ExchangeTxType.DEPOSIT, + currency: 'EUR', + amount: 50, + amountChf: 47, + txId: '0x', + status: 'pending', + }); + const lateAfter = exchangeTx({ + id: 60, + type: ExchangeTxType.DEPOSIT, + currency: 'EUR', + amount: 50, + amountChf: 47, + txId: '0x', + status: 'ok', + }); + + // RUN 1: forward books ok row 61; the content-change scan sees the still-pending row 60 (flat-reverse no-op, never + // booked) and the already-booked row 61 (hasAnyTxAt true → no re-book). + mockContentChange([settled], [lateBefore, settled]); + await consumer.process(); + expect(booked.map((b) => b.sourceId)).toEqual(['61']); // only row 61 booked (row 60 still pending) + + // RUN 2: row 60 flipped to ok. Forward (id > 61) returns nothing; the content-change scan forward-books row 60. + mockContentChange([], [lateAfter]); + await consumer.process(); + expect(booked.map((b) => b.sourceId).sort()).toEqual(['60', '61']); // row 60 finally booked on the second run + }); + + // §6.3 covered-by-cutover-opening guard: an exchange_tx already 'ok' (settled) at the cutover snapshot is in the + // aggregate ASSET opening; a post-cutover `updated` bump re-selects it in the content-change scan, but its seq0 must + // NOT be re-booked (double-count). The guard keys on the exchange_tx row id, not the composite trade sourceId. Without + // the guard this row WOULD forward-book (reverseAndRebookIfChanged found nothing active + hasAnyTxAt false). + it('does NOT re-book a pre-cutover-settled exchange_tx re-selected by the content-change scan (covered)', async () => { + jest.spyOn(bookingService, 'reverseAndRebookIfChanged').mockResolvedValue(false); // nothing active to correct + jest.spyOn(bookingService, 'hasAnyTxAt').mockResolvedValue(false); // no tx at the seq → would book if not covered + jest + .spyOn(settingService, 'getObj') + .mockImplementation((key: string) => + Promise.resolve(key === 'ledgerCutoverBoundary.exchange_tx' ? { boundaryId: 100, holeIds: [] } : undefined), + ); + const covered = exchangeTx({ + id: 42, + type: ExchangeTxType.DEPOSIT, + currency: 'EUR', + amount: 100, + amountChf: 95, + txId: '0x', + status: 'ok', + }); + mockContentChange([], [covered]); + await consumer.process(); + + expect(booked).toHaveLength(0); // id 42 <= boundary 100, not a hole → covered → no fresh seq0 + }); + + // --- FORWARD ERROR / UNHANDLED-TYPE BRANCHES --- // + + it('skips an unhandled exchange_tx type in the forward scan (buildSpec undefined → no booking)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + mockBatch([exchangeTx({ id: 9, type: 'Reward' as ExchangeTxType, amount: 1 })]); + await consumer.process(); + + expect(booked).toHaveLength(0); // unhandled type → spec undefined → no bookTx + expect(JSON.parse(setSpy.mock.calls[0][1]).lastProcessedId).toBe(9); // skip is not a failure → watermark advances + }); + + it('stops the forward batch and leaves the watermark on a booking error (failure-isolation)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + // a deposit whose exchangeAsset is missing → exchangeAssetByCcy throws → break, watermark unchanged + jest.spyOn(accountService, 'findByName').mockResolvedValue(undefined); // no ledger account → throw + mockBatch([ + exchangeTx({ id: 10, type: ExchangeTxType.DEPOSIT, currency: 'EUR', amount: 100, amountChf: 95, txId: '0x' }), + ]); + await consumer.process(); + + expect(booked).toHaveLength(0); + expect(setSpy).not.toHaveBeenCalled(); // throw → break before advancing + }); + + // §4.3 markValue no-mark: a ccxt Trade whose quote asset has no mark → the quote leg is needsMark and the + // mark-based quote-spread plug is SKIPPED (no silent plug, §4.3) — the quote leg carries amountChf undefined + // Major B5 — no mark ANYWHERE for the ccxt quote asset: the mixed trade (valued base leg + unvalued quote leg) cannot + // balance and the bridge finds no mark → the row DEFERS (nothing booked, watermark unchanged), never a silent plug. + it('defers a ccxt trade when the quote mark is missing everywhere (mixed tx, B5)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + accounts.set('Binance/XYZ', account('Binance/XYZ', AccountType.ASSET, 'XYZ', 998)); // no mark for 998 + mockBatch([ + exchangeTx({ + id: 11, + exchange: ExchangeName.BINANCE, + type: ExchangeTxType.TRADE, + symbol: 'USDT/XYZ', + side: 'buy', + order: 'O-11', + amount: 1000, + amountChf: 900, // base persisted + cost: 5, + feeAmountChf: 0, + }), + ]); + await consumer.process(); + + expect(booked[0]).toBeUndefined(); // deferred: unbalanceable mixed trade, no quote bridge + expect(setSpy).not.toHaveBeenCalled(); // watermark NOT advanced + }); + + // §4.3 parseSymbol guard: a Trade with a malformed symbol (no '/') is unattributable → SUSPENSE, not a base/quote split + it('routes a Trade with a malformed symbol (no slash) to SUSPENSE', async () => { + mockBatch([ + exchangeTx({ id: 12, type: ExchangeTxType.TRADE, symbol: 'USDTCHF', side: 'buy', amount: 100, amountChf: 90 }), + ]); + await consumer.process(); + + expect(booked[0].legs.some((l) => l.account.name === 'SUSPENSE/Scrypt-trade-unattributed')).toBe(true); + expect(cents(booked[0].legs)).toBe(0); + }); + + // §4.3 ccxt quote-spread plug: a Binance buy where the base mark and quote mark leave a >2c residual gets a + // mark-based EXPENSE/INCOME spread-{exchange} plug leg (distinct from the venue fee leg) + it('appends a mark-based quote-spread plug leg that closes the base↔quote mark residual (ccxt)', async () => { + markMap.set(70, [{ created: new Date('2026-01-01'), priceChf: 90000 }]); // BTC mark + // base USDT amountChf 900 (persisted) vs quote BTC cost 0.01 × 90000 = 900 → residual 0; shift the base to + // create a residual: amountChf 905 base → 905 − 900 = 5 CHF residual → spread plug leg + mockBatch([ + exchangeTx({ + id: 13, + exchange: ExchangeName.BINANCE, + type: ExchangeTxType.TRADE, + symbol: 'USDT/BTC', + side: 'buy', + order: 'O-13', + amount: 1000, + amountChf: 905, + cost: 0.01, + feeAmountChf: 0, // no separate venue fee → the residual surfaces only as the mark-based spread plug + }), + ]); + await consumer.process(); + + const spread = booked[0].legs.filter((l) => l.account.name?.includes('spread-Binance')); + expect(spread).toHaveLength(1); // exactly the mark-based quote-spread plug + expect(cents(booked[0].legs)).toBe(0); + }); + + // §4.3b matchRaiffeisenSweep 'none': the Raiffeisen SUSPENSE account EXISTS (findByName resolves it) but there + // are NO matching open posts in the ≤5d window (ledgerLegRepository.find → []) → 'none' → routing FALLS THROUGH + // the R1 block to R2 (bank match here) → TRANSIT/bank↔Scrypt/EUR, NOT the Raiffeisen SUSPENSE. + it('falls through a Scrypt/EUR Deposit to R2 (TRANSIT) when the Raiffeisen sweep window has no match (none)', async () => { + accounts.set( + 'SUSPENSE/untracked-bank-Raiffeisen-EUR', + account('SUSPENSE/untracked-bank-Raiffeisen-EUR', AccountType.SUSPENSE, 'EUR'), + ); + jest.spyOn(ledgerLegRepository, 'find').mockResolvedValue([]); // account exists but no posts → 'none' + jest.spyOn(bankTxRepo, 'find').mockResolvedValue([Object.assign(new BankTx(), { amount: 1000 })] as BankTx[]); + mockBatch([exchangeTx({ id: 14, type: ExchangeTxType.DEPOSIT, currency: 'EUR', amount: 1000, amountChf: 950 })]); + await consumer.process(); + + const legs = booked[0].legs; + expect(legs.find((l) => l.account.name === 'TRANSIT/bank↔Scrypt/EUR')).toBeDefined(); + expect(legs.some((l) => l.account.name === 'SUSPENSE/untracked-bank-Raiffeisen-EUR')).toBe(false); + expect(cents(legs)).toBe(0); + }); + + // §4.3b matchRaiffeisenSweep 'none' → no bank, no txId → R4 unrouted SUSPENSE (still NOT the Raiffeisen SUSPENSE) + it('falls through a Scrypt/EUR Deposit to R4 (unrouted SUSPENSE) when neither sweep nor bank nor txId matches', async () => { + accounts.set( + 'SUSPENSE/untracked-bank-Raiffeisen-EUR', + account('SUSPENSE/untracked-bank-Raiffeisen-EUR', AccountType.SUSPENSE, 'EUR'), + ); + jest.spyOn(ledgerLegRepository, 'find').mockResolvedValue([]); // 'none' + mockBatch([exchangeTx({ id: 15, type: ExchangeTxType.DEPOSIT, currency: 'EUR', amount: 1000, amountChf: 950 })]); + await consumer.process(); + + const legs = booked[0].legs; + expect(legs.find((l) => l.account.name === 'SUSPENSE/Scrypt-deposit-unrouted/EUR')).toBeDefined(); + expect(legs.some((l) => l.account.name === 'SUSPENSE/untracked-bank-Raiffeisen-EUR')).toBe(false); + expect(cents(legs)).toBe(0); + }); + + // routeCounterAccount `tx.currency ?? tx.asset`: a wallet Deposit with currency NULL but asset set → ccy resolves + // from the asset ticker → the routed TRANSIT account name uses the asset ticker (USDT), and exchangeAsset too. + it('resolves ccy from tx.asset when currency is null (routed account uses the asset ticker)', async () => { + mockBatch([ + exchangeTx({ + id: 16, + type: ExchangeTxType.DEPOSIT, + currency: null, + asset: 'USDT', + amount: 1000, + amountChf: 900, + txId: '0xfeed', + }), + ]); + await consumer.process(); + + const legs = booked[0].legs; + expect(legs.find((l) => l.account.name === 'Scrypt/USDT').amount).toBe(1000); // exchangeAsset via asset ticker + expect(legs.find((l) => l.account.name === 'TRANSIT/wallet↔Scrypt/USDT')).toBeDefined(); + expect(cents(legs)).toBe(0); + }); + + // exchangeAsset throws when BOTH currency and asset are missing (depositSpec calls exchangeAsset FIRST, before + // routeCounterAccount/hasBankRouteMatch) → failure-isolation: no booking, watermark NOT advanced. + it('does not book and leaves the watermark when a Deposit has neither currency nor asset (exchangeAsset throws)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + mockBatch([ + exchangeTx({ id: 17, type: ExchangeTxType.DEPOSIT, currency: null, asset: null, amount: 1000, amountChf: 900 }), + ]); + await consumer.process(); + + expect(booked).toHaveLength(0); + expect(setSpy).not.toHaveBeenCalled(); // throw → break before advancing + }); + + // tradeSpec SELL side (isBuy false), ccxt: baseAmount = −amount, base amountChf = −baseChf; quoteAmount = +cost, + // quote amountChf = +quoteChf. Base leg amount negative, quote leg positive, cents close to 0. + it('books a Binance SELL Trade with a negative base leg and a positive quote leg (isBuy false)', async () => { + markMap.set(70, [{ created: new Date('2026-01-01'), priceChf: 90000 }]); // BTC mark → quote 0.01 × 90000 = 900 + mockBatch([ + exchangeTx({ + id: 18, + exchange: ExchangeName.BINANCE, + type: ExchangeTxType.TRADE, + symbol: 'USDT/BTC', + side: 'sell', + order: 'O-18', + amount: 1000, + amountChf: 900, // base persisted + cost: 0.01, + feeAmountChf: 0, + }), + ]); + await consumer.process(); + + const legs = booked[0].legs; + const base = legs.find((l) => l.account.name === 'Binance/USDT'); + expect(base.amount).toBe(-1000); // sell reduces base + expect(base.amountChf).toBe(-900); // −baseChf + const quote = legs.find((l) => l.account.name === 'Binance/BTC'); + expect(quote.amount).toBe(0.01); // +cost + expect(quote.amountChf).toBe(900); // +quoteChf + expect(cents(legs)).toBe(0); + }); + + // tradeSpec `tx.amountChf ?? this.markValue(...)`: a ccxt trade with amountChf NULL → baseChf falls to + // markValue(baseAccount). Binance/USDT mark 0.9 × 1000 = 900 → base leg amountChf computed from the mark. + it('computes the ccxt base leg amountChf from the mark when tx.amountChf is null', async () => { + markMap.set(70, [{ created: new Date('2026-01-01'), priceChf: 90000 }]); // BTC mark + mockBatch([ + exchangeTx({ + id: 19, + exchange: ExchangeName.BINANCE, + type: ExchangeTxType.TRADE, + symbol: 'USDT/BTC', + side: 'buy', + order: 'O-19', + amount: 1000, + amountChf: null, // → markValue(Binance/USDT 0.9, 1000) = 900 + cost: 0.01, + feeAmountChf: 0, + }), + ]); + await consumer.process(); + + const base = booked[0].legs.find((l) => l.account.name === 'Binance/USDT'); + expect(base.amountChf).toBe(900); // mark 0.9 × 1000 + expect(base.needsMark).toBe(false); + expect(cents(booked[0].legs)).toBe(0); + }); + + // tradeSpec Scrypt `spreadChf !== 0` FALSE side: feeAmountChf == 0 → NO spread leg pushed; the quote leg is the + // plug that closes the tx (only base + quote legs). + it('books a Scrypt Trade with feeAmountChf 0 → no spread leg, the quote leg is the plug', async () => { + mockBatch([ + exchangeTx({ + id: 20, + exchange: ExchangeName.SCRYPT, + type: ExchangeTxType.TRADE, + symbol: 'USDT/CHF', + side: 'buy', + order: 'O-20', + amount: 1000, + amountChf: 900, + cost: 905, + feeAmountChf: 0, // spreadChf 0 → no spread leg + }), + ]); + await consumer.process(); + + const legs = booked[0].legs; + expect(legs.some((l) => l.account.name?.includes('spread-Scrypt'))).toBe(false); // no spread leg + expect(legs).toHaveLength(2); // base + quote plug only + const quote = legs.find((l) => l.account.name === 'Scrypt/CHF'); + expect(quote.amountChf).toBe(-900); // plug = −base = −900 (closes the tx) + expect(cents(legs)).toBe(0); + }); + + // tradeSpec ccxt `feeChf != null` FALSE side: feeAmountChf == null → no separate venue fee leg; the mark-based + // quote-spread plug still closes the cents (here base 905 vs quote 900 → 5 CHF residual surfaces as the plug). + it('books a Binance Trade with feeAmountChf null → no venue fee leg, mark-based quote-spread plug closes cents', async () => { + markMap.set(70, [{ created: new Date('2026-01-01'), priceChf: 90000 }]); // BTC mark → quote 0.01 × 90000 = 900 + mockBatch([ + exchangeTx({ + id: 21, + exchange: ExchangeName.BINANCE, + type: ExchangeTxType.TRADE, + symbol: 'USDT/BTC', + side: 'buy', + order: 'O-21', + amount: 1000, + amountChf: 905, // base 905 vs quote 900 → 5 CHF residual + cost: 0.01, + feeAmountChf: null, // feeChf == null → fee-leg branch skipped + }), + ]); + await consumer.process(); + + const spread = booked[0].legs.filter((l) => l.account.name?.includes('spread-Binance')); + expect(spread).toHaveLength(1); // only the mark-based quote-spread plug, no separate venue fee leg + expect(cents(booked[0].legs)).toBe(0); + }); + + // tradeSpec `quoteAmount !== 0` FALSE (Scrypt): cost 0 → quoteAmount 0 → quotePrice null on the plug quote leg. + it('books a Scrypt Trade with cost 0 → quote leg amount 0 and priceChf null', async () => { + mockBatch([ + exchangeTx({ + id: 22, + exchange: ExchangeName.SCRYPT, + type: ExchangeTxType.TRADE, + symbol: 'USDT/CHF', + side: 'buy', + order: 'O-22', + amount: 1000, + amountChf: 900, + cost: 0, // quoteAmount 0 → quotePrice null + feeAmountChf: 0, + }), + ]); + await consumer.process(); + + const quote = booked[0].legs.find((l) => l.account.name === 'Scrypt/CHF'); + expect(quote.amount).toBe(-0); // quoteAmount = isBuy ? -cost : +cost = -0 + expect(quote.priceChf).toBeNull(); // quoteAmount 0 → no price + expect(quote.amountChf).toBe(-900); // plug = −base + expect(cents(booked[0].legs)).toBe(0); + }); + + // F4: a Scrypt trade whose base leg is UNVALUED (amountChf null AND no historical mark) but has a youngest mark → the + // base is bridged BEFORE the quote plug is formed, so the quote plug reflects the REAL base value (not the phantom 0 + // that would misvalue the quote custody account by the full base). The quote plug inherits needsMark (provisional + // basis) → the mark-to-market job re-marks the quote account once the base is re-marked. + it('bridges an unvalued Scrypt base leg before the quote plug and flags the plug needsMark (F4)', async () => { + accounts.set('Scrypt/XYZ', account('Scrypt/XYZ', AccountType.ASSET, 'XYZ', 998)); // no historical mark for 998 + jest.spyOn(markService, 'getLatestMark').mockResolvedValue(0.9); // youngest available mark for the base asset + mockBatch([ + exchangeTx({ + id: 25, + exchange: ExchangeName.SCRYPT, + type: ExchangeTxType.TRADE, + symbol: 'XYZ/CHF', + side: 'buy', + order: 'O-25', + amount: 1000, + amountChf: null, // base unvalued (no persisted CHF) and 998 has no mark at the booking date + cost: 905, + feeAmountChf: 0, + }), + ]); + await consumer.process(); + + const legs = booked[0].legs; + const base = legs.find((l) => l.account.name === 'Scrypt/XYZ'); + const quote = legs.find((l) => l.account.name === 'Scrypt/CHF'); + expect(base.amountChf).toBe(900); // bridged: 0.9 × 1000 (NOT undefined / a phantom 0) + expect(base.needsMark).toBe(true); // stays true → mark-to-market re-marks the base later + expect(quote.amountChf).toBe(-900); // the quote plug reflects the REAL bridged base value, not −0 + expect(quote.needsMark).toBe(true); // base was bridged → the quote plug is provisional → re-marked too + expect(cents(legs)).toBe(0); + }); + + // F4: a Scrypt trade whose base is feedless (no mark anywhere) AND carries a market spread → the mixed set (valued + // spread leg + unvalued base) cannot balance and the bridge finds nothing → the row DEFERS instead of silently + // plugging the full base value into the quote custody leg. + it('defers a Scrypt trade when the base is feedless and there is a market spread (F4)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + accounts.set('Scrypt/XYZ', account('Scrypt/XYZ', AccountType.ASSET, 'XYZ', 998)); // no mark; getLatestMark undefined + mockBatch([ + exchangeTx({ + id: 26, + exchange: ExchangeName.SCRYPT, + type: ExchangeTxType.TRADE, + symbol: 'XYZ/CHF', + side: 'buy', + order: 'O-26', + amount: 1000, + amountChf: null, + cost: 905, + feeAmountChf: 5, // a market spread → the valued spread leg makes the unvalued-base set mixed-unbalanceable + }), + ]); + await consumer.process(); + + expect(booked[0]).toBeUndefined(); // deferred (feedless base + spread → mixed, no bridge) + expect(setSpy).not.toHaveBeenCalled(); // watermark NOT advanced + }); + + // depositChf `tx.amount ?` FALSE side: amount 0 (falsy) with amountChf set → priceChf null, amountChf persisted. + it('sets the Deposit priceChf null when amount is 0 (depositChf amount-falsy branch)', async () => { + mockBatch([ + exchangeTx({ id: 23, type: ExchangeTxType.DEPOSIT, currency: 'EUR', amount: 0, amountChf: 0, txId: '0xzero' }), + ]); + await consumer.process(); + + const asset = booked[0].legs.find((l) => l.account.name === 'Scrypt/EUR'); + expect(asset.priceChf).toBeNull(); // amount 0 → no price + expect(asset.amountChf).toBe(0); // persisted amountChf + expect(asset.needsMark).toBe(false); + }); + + // parseSymbol `!tx.side` sub-branch: a Trade with a VALID symbol but side null → undefined → SUSPENSE + // (distinct from the 'no symbol/side' both-null and the 'malformed symbol' wrong-part-count cases). + it('routes a Trade with a valid symbol but no side to SUSPENSE (parseSymbol !tx.side guard)', async () => { + mockBatch([ + exchangeTx({ id: 24, type: ExchangeTxType.TRADE, symbol: 'USDT/CHF', side: null, amount: 100, amountChf: 90 }), + ]); + await consumer.process(); + + expect(booked[0].legs.some((l) => l.account.name === 'SUSPENSE/Scrypt-trade-unattributed')).toBe(true); + expect(cents(booked[0].legs)).toBe(0); + }); + + // processForward/buildSpec `tx.externalCreated ?? tx.created` (L79 preload + L125 bookingDate): a forward row with + // externalCreated NULL → both the mark-preload window AND the booking/value date fall back to `created`. + it('uses tx.created as the booking date when externalCreated is null (forward preload + buildSpec fallback)', async () => { + const created = new Date('2026-03-15T10:00:00Z'); + mockBatch([ + exchangeTx({ + id: 30, + type: ExchangeTxType.DEPOSIT, + currency: 'EUR', + amount: 100, + amountChf: 95, + txId: '0xcreated', + externalCreated: null, + created, + }), + ]); + await consumer.process(); + + expect(booked[0].bookingDate).toEqual(created); // externalCreated null → created fallback + expect(booked[0].valueDate).toEqual(created); + const asset = booked[0].legs.find((l) => l.account.name === 'Scrypt/EUR'); + expect(asset.amountChf).toBe(95); + }); + + // reconcileBooking `tx.externalCreated ?? tx.created` (both operands): a content-change ok row with externalCreated + // NULL → the recomputed spec uses `created` as the booking date, and the reconcile mark-preload uses a + // daysBefore(2, created) lookback lower bound (so getMarkAt finds the latest mark at-or-before the row timestamp). + it('reconciles a content-change row with null externalCreated using tx.created (reverse+rebook)', async () => { + const created = new Date('2026-04-20T08:00:00Z'); + const preloadSpy = jest.spyOn(markService, 'preload'); + const rebookSpy = jest.spyOn(bookingService, 'reverseAndRebookIfChanged').mockResolvedValue(true); + const reverseSpy = jest.spyOn(bookingService, 'reverseActiveIfBooked').mockResolvedValue(false); + const changed = exchangeTx({ + id: 31, + type: ExchangeTxType.DEPOSIT, + currency: 'EUR', + amount: 100, + amountChf: 95, + txId: '0x', + status: 'ok', + externalCreated: null, + created, + }); + mockContentChange([], [changed]); + await consumer.process(); + + expect(rebookSpy).toHaveBeenCalledTimes(1); + expect(rebookSpy.mock.calls[0][0].sourceId).toBe('31'); + expect(rebookSpy.mock.calls[0][0].bookingDate).toEqual(created); // externalCreated null → created + expect(reverseSpy).not.toHaveBeenCalled(); + // forward batch empty → the only preload call is the reconcile one: [daysBefore(2, created), created] + const lastPreload = preloadSpy.mock.calls[preloadSpy.mock.calls.length - 1]; + expect(lastPreload[0]).toEqual(Util.daysBefore(2, created)); + expect(lastPreload[1]).toEqual(created); + }); + + // withdrawalSpec asset leg `chf.amountChf != null ? -chf.amountChf : undefined` UNDEFINED side (L174): a Withdrawal + // whose amountChf is null AND whose asset has no mark → both legs carry amountChf undefined + needsMark. + it('flags a Withdrawal needsMark with undefined amountChf when amountChf is null and no mark exists', async () => { + accounts.set('Scrypt/ZZZ', account('Scrypt/ZZZ', AccountType.ASSET, 'ZZZ', 997)); // assetId set but no mark for 997 + mockBatch([ + exchangeTx({ id: 32, type: ExchangeTxType.WITHDRAWAL, currency: 'ZZZ', amount: 1000, amountChf: null }), + ]); + await consumer.process(); + + const asset = booked[0].legs.find((l) => l.account.name === 'Scrypt/ZZZ'); + expect(asset.amount).toBe(-1000); // withdrawal reduces the exchange asset + expect(asset.amountChf).toBeUndefined(); // amountChf null + no mark → undefined (not -null) + expect(asset.needsMark).toBe(true); + const counter = booked[0].legs.find((l) => l.account.name === 'SUSPENSE/Scrypt-deposit-unrouted/ZZZ'); + expect(counter.amountChf).toBeUndefined(); + expect(counter.needsMark).toBe(true); + }); + + // tradeSpec unattributed `tx.amountChf ?? 0` NULL side (L204): an unattributable trade (no symbol/side) whose + // amountChf is null → the SUSPENSE legs both book at 0 (one +0, one −0, cents close). + it('books the unattributed SUSPENSE legs at 0 when an unresolvable trade has null amountChf', async () => { + mockBatch([ + exchangeTx({ id: 33, type: ExchangeTxType.TRADE, symbol: null, side: null, amount: 50, amountChf: null }), + ]); + await consumer.process(); + + const legs = booked[0].legs.filter((l) => l.account.name === 'SUSPENSE/Scrypt-trade-unattributed'); + expect(legs).toHaveLength(2); + expect(legs[0].amountChf).toBe(0); // tx.amountChf ?? 0 → 0 + expect(legs[1].amountChf).toBe(-0); // −0 (Object.is-sensitive) + expect(cents(legs)).toBe(0); + }); + + // tradeSpec base leg NO baseChf + Scrypt plug reduce (L221 cond null, L222 cond undefined, L434 assetId-null + // markValue undefined, L237 plug reduce `l.amountChf ?? 0`): a Scrypt trade whose BASE account has assetId null and + // amountChf null → baseChf undefined → priceChf null, amountChf undefined, needsMark; the plug reduce treats the + // base leg's undefined amountChf as 0. + it('flags a Scrypt base leg needsMark (priceChf null, amountChf undefined) when the base account has no assetId', async () => { + accounts.set('Scrypt/NOID', account('Scrypt/NOID', AccountType.ASSET, 'NOID')); // assetId undefined → no mark path + mockBatch([ + exchangeTx({ + id: 34, + exchange: ExchangeName.SCRYPT, + type: ExchangeTxType.TRADE, + symbol: 'NOID/CHF', + side: 'buy', + order: 'O-34', + amount: 1000, + amountChf: null, // → markValue(assetId null) → undefined → baseChf undefined + cost: 905, + feeAmountChf: 0, + }), + ]); + await consumer.process(); + + const base = booked[0].legs.find((l) => l.account.name === 'Scrypt/NOID'); + expect(base.priceChf).toBeNull(); // baseChf null → priceChf null (L221 cond null) + expect(base.amountChf).toBeUndefined(); // baseChf null → amountChf undefined (L222 cond undefined) + expect(base.needsMark).toBe(true); + const quote = booked[0].legs.find((l) => l.account.name === 'Scrypt/CHF'); + expect(quote.amount).toBe(-905); // isBuy ? -cost : +cost + expect(quote.amountChf).toBe(-0); // plug = −(base amountChf ?? 0) = −0 (L237 null side) + }); + + // tradeSpec `tx.amount || 1` fallback (L221 binary-expr): a Scrypt trade with amount 0 but a persisted amountChf → + // baseChf != null so the priceChf division uses the `|| 1` denominator (price = abs(baseChf)/1), not a divide-by-0. + it('uses the amount-|| -1 fallback for the base priceChf when tx.amount is 0', async () => { + mockBatch([ + exchangeTx({ + id: 35, + exchange: ExchangeName.SCRYPT, + type: ExchangeTxType.TRADE, + symbol: 'USDT/CHF', + side: 'buy', + order: 'O-35', + amount: 0, // falsy → `tx.amount || 1` denominator = 1 + amountChf: 900, // baseChf != null → priceChf = round(abs(900)/1, 8) + cost: 905, + feeAmountChf: 0, + }), + ]); + await consumer.process(); + + const base = booked[0].legs.find((l) => l.account.name === 'Scrypt/USDT'); + expect(base.amount).toBe(0); // isBuy ? +0 + expect(base.priceChf).toBe(900); // abs(900) / (0 || 1) = 900 (no NaN/Infinity) + expect(base.amountChf).toBe(900); + expect(base.needsMark).toBe(false); + }); + + // tradeSpec `tx.cost ?? 0` NULL side (L226): a Scrypt trade with cost null → cost 0 → quoteAmount 0, quotePrice null, + // quote leg amountChf = plug (−base). Distinct from id 22 which sets cost: 0 (the left operand of `?? `). + // A4 — a settled ('ok') trade with NO cost (quote amount) is a data error: `cost ?? 0` would book a zero-quote trade + // (silently). Fail loud instead → failure-isolation leaves the watermark and retries. + it('fails loud on an ok Trade with a null cost (no silent zero-quote, A4)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + mockBatch([ + exchangeTx({ + id: 36, + exchange: ExchangeName.SCRYPT, + type: ExchangeTxType.TRADE, + symbol: 'USDT/CHF', + side: 'buy', + order: 'O-36', + amount: 1000, + amountChf: 900, + cost: null, // 'ok' trade with no cost → A4 throws (never a silent 0-quote) + feeAmountChf: 0, + }), + ]); + await consumer.process(); + + expect(booked[0]).toBeUndefined(); // nothing booked (fail-loud) + expect(setSpy).not.toHaveBeenCalled(); // watermark NOT advanced → retry + }); + + // tradeSpec ccxt quote `Math.abs(quoteChf)/Math.abs(cost || 1)` `cost || 1` fallback (L247): a Binance trade with + // cost 0 but a quote MARK present → quoteChf = mark × |0| = 0 (a number, not null) → priceChf = abs(0)/(0||1) = 0, + // NOT 0/0 = NaN. Asserting priceChf === 0 (finite) is load-bearing for the `|| 1` denominator. + it('uses the cost-|| -1 fallback for the ccxt quote priceChf when cost is 0', async () => { + markMap.set(70, [{ created: new Date('2026-01-01'), priceChf: 90000 }]); // BTC mark + mockBatch([ + exchangeTx({ + id: 37, + exchange: ExchangeName.BINANCE, + type: ExchangeTxType.TRADE, + symbol: 'USDT/BTC', + side: 'buy', + order: 'O-37', + amount: 1000, + amountChf: 900, + cost: 0, // quoteChf = markValue(BTC, 0) = 0 (number); priceChf = abs(0)/(0||1) = 0 + feeAmountChf: 0, + }), + ]); + await consumer.process(); + + const quote = booked[0].legs.find((l) => l.account.name === 'Binance/BTC'); + expect(quote.amount).toBe(-0); // isBuy ? -cost = -0 + expect(quote.priceChf).toBe(0); // finite 0 via the `|| 1` denominator (NOT NaN) + expect(quote.needsMark).toBe(false); // quoteChf is 0, not null + expect(cents(booked[0].legs)).toBe(0); + }); + + // tradeSpec no-order branch (L266 fill-index `?? 0`, L268 sourceId, L269 sourceType, L274 seq): a TRADE with NO + // `order` → not in the (empty) fill-index map → seq 0, sourceType 'exchange_tx', sourceId = tx.id. + it('books a Trade with no order under sourceType exchange_tx / sourceId tx.id / seq 0', async () => { + mockBatch([ + exchangeTx({ + id: 38, + exchange: ExchangeName.SCRYPT, + type: ExchangeTxType.TRADE, + symbol: 'USDT/CHF', + side: 'buy', + order: null, // no order → no-order identifiers + amount: 1000, + amountChf: 900, + cost: 905, + feeAmountChf: 0, + }), + ]); + await consumer.process(); + + expect(booked[0].sourceType).toBe('exchange_tx'); // no order → SOURCE_TYPE (L269) + expect(booked[0].sourceId).toBe('38'); // no order → tx.id (L268) + expect(booked[0].seq).toBe(0); // no order → seq 0 (L266 `?? 0` + L274) + expect(cents(booked[0].legs)).toBe(0); + }); + + // hasBankRouteMatch `Math.abs(b.amount ?? 0)` NULL side (L355): a matched bank_tx with amount NULL is treated as 0 + // in the route-match filter. A second bank_tx with the matching amount makes `.some` true → TRANSIT route, proving + // the null row was tolerated (not a throw) before the matching row was reached. + it('tolerates a null bank_tx amount in the route-match filter and still routes via the matching bank_tx (R2)', async () => { + jest + .spyOn(bankTxRepo, 'find') + .mockResolvedValue([ + Object.assign(new BankTx(), { amount: null }), + Object.assign(new BankTx(), { amount: 1000 }), + ] as BankTx[]); + mockBatch([exchangeTx({ id: 39, type: ExchangeTxType.DEPOSIT, currency: 'EUR', amount: 1000, amountChf: 950 })]); + await consumer.process(); + + const legs = booked[0].legs; + expect(legs.find((l) => l.account.name === 'TRANSIT/bank↔Scrypt/EUR')).toBeDefined(); // matched despite null row + expect(legs.some((l) => l.account.name?.startsWith('SUSPENSE/'))).toBe(false); + expect(cents(legs)).toBe(0); + }); +}); diff --git a/src/subdomains/core/accounting/services/consumers/__tests__/ledger-watermark.helper.spec.ts b/src/subdomains/core/accounting/services/consumers/__tests__/ledger-watermark.helper.spec.ts new file mode 100644 index 0000000000..a61a086b8b --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/__tests__/ledger-watermark.helper.spec.ts @@ -0,0 +1,150 @@ +import { createMock } from '@golevelup/ts-jest'; +import { ConfigService } from 'src/config/config'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { FindOperator, Repository } from 'typeorm'; +import { getCutoverBoundary, LedgerWatermark, runContentChangeScan } from '../ledger-watermark.helper'; + +interface Row { + id: number; + updated: Date; +} + +/** + * Focused tests for the §4.12 content-change scan watermark — specifically the combined (updated, id) cursor that + * eliminates the same-`updated` boundary skip (Major design-accounting point 3). The scan pages by (updated, id) so + * that >batchSize rows sharing one millisecond `updated` are all eventually scanned across runs and never dropped. + */ +describe('runContentChangeScan combined (updated, id) cursor', () => { + let settingService: SettingService; + let written: LedgerWatermark[]; + + // extracts the combined-cursor params the helper passes via the Raw FindOperator on `updated` + function cursorOf(where: any): { scan: Date; scanId: number } { + const op = where.updated as FindOperator; + const params = (op as any)._objectLiteralParameters as { lcsScan: Date; lcsScanId: number }; + return { scan: new Date(params.lcsScan), scanId: params.lcsScanId }; + } + + // a repo whose find() applies the SAME combined-cursor + (updated ASC, id ASC) ordering + take limit a real DB does + function repoOver(rows: Row[], batchSize: number): Repository { + const repo = createMock>(); + jest.spyOn(repo, 'find').mockImplementation((opts: any) => { + const { scan, scanId } = cursorOf(opts.where); + const matched = rows + .filter( + (r) => r.updated.getTime() > scan.getTime() || (r.updated.getTime() === scan.getTime() && r.id > scanId), + ) + .sort((a, b) => a.updated.getTime() - b.updated.getTime() || a.id - b.id) + .slice(0, batchSize); + return Promise.resolve(matched as any); + }); + return repo; + } + + beforeEach(() => { + written = []; + settingService = createMock(); + jest.spyOn(settingService, 'set').mockImplementation((_key: string, raw: string) => { + const p = JSON.parse(raw); + written.push({ + lastProcessedId: p.lastProcessedId, + lastReversalScan: new Date(p.lastReversalScan), + lastReversalScanId: p.lastReversalScanId, + }); + return Promise.resolve(); + }); + process.env.LEDGER_BACKFILL_BATCH_SIZE = '2'; + new ConfigService(); // set the Config singleton with batchSize=2 + }); + + afterEach(() => { + delete process.env.LEDGER_BACKFILL_BATCH_SIZE; + new ConfigService(); // restore default batchSize + }); + + it('scans EVERY row of a same-`updated` group larger than the batch across runs (no boundary skip)', async () => { + // 5 rows, ALL sharing ONE millisecond `updated`, batchSize=2 → the group straddles every batch boundary. An + // `updated`-only watermark would advance to the shared `updated` after batch 1 and PERMANENTLY drop rows 3..5 + // (they share that `updated`). The combined cursor pages by id within the group → all 5 are scanned. + const t = new Date('2026-06-01T00:00:00.000Z'); + const rows: Row[] = [1, 2, 3, 4, 5].map((id) => ({ id, updated: t })); + const booked: number[] = []; + + let wm: LedgerWatermark = { lastProcessedId: 0, lastReversalScan: new Date(0), lastReversalScanId: 0 }; + const repo = repoOver(rows, 2); + for (let run = 0; run < 5; run++) { + await runContentChangeScan(settingService, 'test', wm, repo, {}, async (r: Row) => { + booked.push(r.id); + }); + if (written.length) wm = written[written.length - 1]; // carry the advanced cursor into the next run + } + + expect([...new Set(booked)].sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5]); // every row scanned, none skipped + expect(wm.lastReversalScan.getTime()).toBe(t.getTime()); // cursor at the shared `updated` + expect(wm.lastReversalScanId).toBe(5); // …with the id-tiebreak at the last row → the group is exhausted + }); + + it('does not re-advance once the whole same-`updated` group is exhausted (idempotent terminal state)', async () => { + const t = new Date('2026-06-01T00:00:00.000Z'); + const rows: Row[] = [1, 2].map((id) => ({ id, updated: t })); + const repo = repoOver(rows, 2); + const wm: LedgerWatermark = { lastProcessedId: 0, lastReversalScan: t, lastReversalScanId: 2 }; // already past both + const booked: number[] = []; + + await runContentChangeScan(settingService, 'test', wm, repo, {}, async (r: Row) => { + booked.push(r.id); + }); + + expect(booked).toEqual([]); // nothing left past the cursor + expect(written).toHaveLength(0); // watermark not touched + }); + + it('holds the cursor at the last good row when a row fails (self-healing retry, §4.12 Minor R12-2)', async () => { + const t1 = new Date('2026-06-01T00:00:00.000Z'); + const t2 = new Date('2026-06-01T00:00:01.000Z'); + const rows: Row[] = [ + { id: 1, updated: t1 }, + { id: 2, updated: t2 }, + ]; + const repo = repoOver(rows, 2); + const wm: LedgerWatermark = { lastProcessedId: 0, lastReversalScan: new Date(0), lastReversalScanId: 0 }; + + await runContentChangeScan(settingService, 'test', wm, repo, {}, async (r: Row) => { + if (r.id === 2) throw new Error('boom'); + }); + + // row 1 committed → cursor advances to (t1, 1); row 2 failed → NOT advanced past it (re-scanned next run) + expect(written).toHaveLength(1); + expect(written[0].lastReversalScan.getTime()).toBe(t1.getTime()); + expect(written[0].lastReversalScanId).toBe(1); + }); +}); + +/** + * §6.3 getCutoverBoundary — the set-only-if-unset reader counterpart of setCutoverBoundary: an unset key must read as + * undefined (raw `get`, not `getObj`) so the cutover pins the boundary exactly once and a retry reuses it verbatim. + */ +describe('getCutoverBoundary', () => { + it('returns undefined when no boundary is pinned yet (raw setting unset)', async () => { + const settingService = createMock(); + jest.spyOn(settingService, 'get').mockResolvedValue(undefined); + + await expect(getCutoverBoundary(settingService, 'crypto_input')).resolves.toBeUndefined(); + }); + + it('parses the pinned JSON boundary for the requested source', async () => { + const settingService = createMock(); + jest + .spyOn(settingService, 'get') + .mockImplementation((key: string) => + Promise.resolve( + key === 'ledgerCutoverBoundary.crypto_input' ? JSON.stringify({ boundaryId: 100, holeIds: [50] }) : undefined, + ), + ); + + await expect(getCutoverBoundary(settingService, 'crypto_input')).resolves.toEqual({ + boundaryId: 100, + holeIds: [50], + }); + }); +}); diff --git a/src/subdomains/core/accounting/services/consumers/__tests__/liquidity-mgmt.consumer.spec.ts b/src/subdomains/core/accounting/services/consumers/__tests__/liquidity-mgmt.consumer.spec.ts new file mode 100644 index 0000000000..d720df45c6 --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/__tests__/liquidity-mgmt.consumer.spec.ts @@ -0,0 +1,358 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { TestUtil } from 'src/shared/utils/test.util'; +import { LiquidityManagementOrder } from 'src/subdomains/core/liquidity-management/entities/liquidity-management-order.entity'; +import { + LiquidityManagementOrderStatus, + LiquidityManagementSystem, +} from 'src/subdomains/core/liquidity-management/enums'; +import { Repository } from 'typeorm'; +import { AccountType, LedgerAccount } from '../../../entities/ledger-account.entity'; +import { createCustomLedgerAccount } from '../../../entities/__mocks__/ledger-account.entity.mock'; +import { LedgerAccountService } from '../../ledger-account.service'; +import { LedgerBookingService, LedgerLegInput, LedgerTxInput } from '../../ledger-booking.service'; +import { LedgerMarkCache, LedgerMarkService } from '../../ledger-mark.service'; +import { LiquidityMgmtConsumer } from '../liquidity-mgmt.consumer'; + +const ZCHF_ASSET_ID = 401; // target asset (stable, mark ≈ 1) + +function account(name: string, type: AccountType, currency: string, assetId?: number): LedgerAccount { + return createCustomLedgerAccount({ id: Math.floor(Math.random() * 1e6), name, type, currency, assetId } as any); +} + +// builds a Complete LM order with an eager action (system/command) + pipeline.rule.targetAsset (bridge target) +function lmOrder(values: { + id: number; + system: LiquidityManagementSystem; + command: string; + outputAmount?: number; + targetAssetId?: number; + targetDexName?: string; +}): LiquidityManagementOrder { + return Object.assign(new LiquidityManagementOrder(), { + id: values.id, + updated: new Date('2026-06-07T00:00:00Z'), + status: LiquidityManagementOrderStatus.COMPLETE, + outputAmount: values.outputAmount, + action: { system: values.system, command: values.command }, + pipeline: { + rule: { + targetAsset: + values.targetAssetId != null + ? { id: values.targetAssetId, name: 'ZCHF', dexName: values.targetDexName ?? 'ZCHF' } + : undefined, + }, + }, + }) as LiquidityManagementOrder; +} + +describe('LiquidityMgmtConsumer', () => { + let consumer: LiquidityMgmtConsumer; + let bookingService: LedgerBookingService; + let accountService: LedgerAccountService; + let markService: LedgerMarkService; + let settingService: SettingService; + let orderRepo: Repository; + + let booked: LedgerTxInput[]; + let accounts: Map; + let nextSeqValue: number; + + const zchfWallet = account('Frankencoin/ZCHF', AccountType.ASSET, 'ZCHF', ZCHF_ASSET_ID); + + const markMap = new Map([[ZCHF_ASSET_ID, [{ created: new Date('2026-01-01'), priceChf: 1 }]]]); + + beforeEach(async () => { + booked = []; + nextSeqValue = 0; + accounts = new Map(); + + bookingService = createMock(); + accountService = createMock(); + markService = createMock(); + settingService = createMock(); + orderRepo = createMock>(); + + jest.spyOn(bookingService, 'bookTx').mockImplementation((input: LedgerTxInput) => { + booked.push(input); + return Promise.resolve({} as any); + }); + jest.spyOn(bookingService, 'nextSeq').mockImplementation(() => Promise.resolve(nextSeqValue)); + + jest.spyOn(accountService, 'findByAssetId').mockResolvedValue(zchfWallet); + jest + .spyOn(accountService, 'findOrCreate') + .mockImplementation((name: string, type: AccountType, currency: string) => { + const existing = accounts.get(name); + if (existing) return Promise.resolve(existing); + const acc = account(name, type, currency); + accounts.set(name, acc); + return Promise.resolve(acc); + }); + + jest.spyOn(markService, 'preload').mockResolvedValue(new LedgerMarkCache(markMap)); + jest.spyOn(markService, 'getLatestMark').mockResolvedValue(undefined); // B5: no bridge by default (tests opt in) + jest.spyOn(settingService, 'getObj').mockResolvedValue(undefined); + jest.spyOn(settingService, 'set').mockResolvedValue(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TestUtil.provideConfig(), + LiquidityMgmtConsumer, + { provide: LedgerBookingService, useValue: bookingService }, + { provide: LedgerAccountService, useValue: accountService }, + { provide: LedgerMarkService, useValue: markService }, + { provide: SettingService, useValue: settingService }, + { provide: getRepositoryToken(LiquidityManagementOrder), useValue: orderRepo }, + ], + }).compile(); + + consumer = module.get(LiquidityMgmtConsumer); + }); + + const cents = (legs: LedgerLegInput[]) => legs.reduce((s, l) => s + Math.round((l.amountChf ?? 0) * 100), 0); + // forward id-scan returns the rows; the §4.12 content-change scan (where has `updated`, not `id`) returns [] so the + // forward path is asserted in isolation (its late-settling coverage is exercised for payout_order/exchange_tx). + const mockBatch = (rows: LiquidityManagementOrder[]) => + jest + .spyOn(orderRepo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [] : rows)); + const leg = (tx: LedgerTxInput, name: string) => tx.legs.find((l) => l.account.name === name); + + it('is defined', () => { + expect(consumer).toBeDefined(); + }); + + // §4.8 branch 4 — bridge: Dr ASSET/wallet-target / Cr TRANSIT/bridge/{ccy}, both same mark → Σ CHF = 0, no plug + it('books a *Bridge order as a TRANSIT/bridge movement (branch 4)', async () => { + mockBatch([ + lmOrder({ + id: 10, + system: LiquidityManagementSystem.ARBITRUM_L2_BRIDGE, + command: 'deposit', + outputAmount: 1000, + targetAssetId: ZCHF_ASSET_ID, + }), + ]); + await consumer.process(); + + expect(booked).toHaveLength(1); + const tx = booked[0]; + expect(tx.sourceType).toBe('liquidity_management_order'); + expect(tx.sourceId).toBe('10'); // stable entity PK, NOT correlationId (Minor R8-5) + const wallet = leg(tx, 'Frankencoin/ZCHF'); + const transit = leg(tx, 'TRANSIT/bridge/ZCHF'); + expect(wallet.amount).toBe(1000); + expect(wallet.amountChf).toBe(1000); // mark 1 × 1000 + expect(transit.amount).toBe(-1000); + expect(transit.amountChf).toBe(-1000); + expect(cents(tx.legs)).toBe(0); // single currency, same mark → no spread plug needed + }); + + // §4.8 branch 4 — dEURO bridge-in: system=dEURO (otherwise exchange) + command='bridge-in' → BOOK (not skip) + it('books a dEURO bridge-in order (branch 4, NOT skipped as exchange)', async () => { + mockBatch([ + lmOrder({ + id: 11, + system: LiquidityManagementSystem.DEURO, + command: 'bridge-in', + outputAmount: 500, + targetAssetId: ZCHF_ASSET_ID, + }), + ]); + await consumer.process(); + expect(booked).toHaveLength(1); + expect(leg(booked[0], 'TRANSIT/bridge/ZCHF')).toBeDefined(); + }); + + // §4.8 currencyOf (line 182 name fallback): a target asset with dexName null → the TRANSIT/bridge currency uses + // asset.name. Build the order inline so dexName is genuinely null (the factory's `?? 'ZCHF'` would mask it). + it('uses the target asset NAME for the bridge currency when dexName is null (currencyOf fallback)', async () => { + const order = Object.assign(new LiquidityManagementOrder(), { + id: 12, + updated: new Date('2026-06-07T00:00:00Z'), + status: LiquidityManagementOrderStatus.COMPLETE, + outputAmount: 1000, + action: { system: LiquidityManagementSystem.ARBITRUM_L2_BRIDGE, command: 'deposit' }, + pipeline: { rule: { targetAsset: { id: ZCHF_ASSET_ID, name: 'ZCHF', dexName: null } } }, + }) as LiquidityManagementOrder; + mockBatch([order]); + + await consumer.process(); + + expect(leg(booked[0], 'TRANSIT/bridge/ZCHF')).toBeDefined(); // currency = asset.name (dexName null fallback) + }); + + // §4.8 assetAccount throw (line 187): a bridge whose target asset has no CoA ledger account → throws → + // failure-isolation (watermark unchanged). + it('stops the batch when the bridge target asset has no ledger account (CoA bootstrap, line 187)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + jest.spyOn(accountService, 'findByAssetId').mockResolvedValue(undefined); + mockBatch([ + lmOrder({ + id: 13, + system: LiquidityManagementSystem.ARBITRUM_L2_BRIDGE, + command: 'deposit', + outputAmount: 1000, + targetAssetId: ZCHF_ASSET_ID, + }), + ]); + + await consumer.process(); + + expect(booked).toHaveLength(0); + expect(setSpy).not.toHaveBeenCalled(); + }); + + // §4.8 branch 1 DEDUP: exchange-routed → SKIP (exchange_tx authoritative). The same transfer must NOT be booked + // by the LM consumer — the exchange_tx consumer owns it (no double booking across the matrix). + it.each([ + LiquidityManagementSystem.BINANCE, + LiquidityManagementSystem.MEXC, + LiquidityManagementSystem.SCRYPT, + LiquidityManagementSystem.KRAKEN, + LiquidityManagementSystem.XT, + LiquidityManagementSystem.FRANKENCOIN, + LiquidityManagementSystem.DEURO, + LiquidityManagementSystem.JUICE, + ])('skips %s exchange-routed orders (branch 1, exchange_tx authoritative)', async (system) => { + mockBatch([lmOrder({ id: 20, system, command: 'withdraw', outputAmount: 100, targetAssetId: ZCHF_ASSET_ID })]); + await consumer.process(); + expect(booked).toHaveLength(0); // dedup: exchange_tx books this movement, not the LM consumer + }); + + // §4.8 branch 2 DEDUP: DfxDex purchase/sell → SKIP (liquidity_order dex authoritative, §4.8a). The same on-chain + // swap must NOT be booked by both the LM consumer AND the LiquidityOrderDex consumer. + it.each(['purchase', 'sell'])('skips DfxDex %s orders (branch 2, liquidity_order dex authoritative)', async (cmd) => { + mockBatch([ + lmOrder({ + id: 21, + system: LiquidityManagementSystem.DFX_DEX, + command: cmd, + outputAmount: 100, + targetAssetId: ZCHF_ASSET_ID, + }), + ]); + await consumer.process(); + expect(booked).toHaveLength(0); // dedup: §4.8a books this swap, not the LM consumer + }); + + // §4.8 branch 3 DEDUP: DfxDex withdraw → SKIP (target deposit exchange_tx authoritative) + it('skips DfxDex withdraw orders (branch 3, target deposit exchange_tx authoritative)', async () => { + mockBatch([ + lmOrder({ + id: 22, + system: LiquidityManagementSystem.DFX_DEX, + command: 'withdraw', + outputAmount: 100, + targetAssetId: ZCHF_ASSET_ID, + }), + ]); + await consumer.process(); + expect(booked).toHaveLength(0); + }); + + // §4.8 idempotency: an already-booked bridge order (nextSeq > 0) is not re-booked + it('is idempotent: skips an already-booked bridge order (re-run, nextSeq > 0)', async () => { + nextSeqValue = 1; + mockBatch([ + lmOrder({ + id: 23, + system: LiquidityManagementSystem.BASE_L2_BRIDGE, + command: 'deposit', + outputAmount: 100, + targetAssetId: ZCHF_ASSET_ID, + }), + ]); + await consumer.process(); + expect(booked).toHaveLength(0); + }); + + // §4.8 missing target mark → needsMark on both legs, no silent priceChf=0 + it('flags both bridge legs needsMark when the target mark is missing', async () => { + jest.spyOn(markService, 'preload').mockResolvedValue(new LedgerMarkCache(new Map())); + mockBatch([ + lmOrder({ + id: 24, + system: LiquidityManagementSystem.LAYERZERO_BRIDGE, + command: 'deposit', + outputAmount: 100, + targetAssetId: ZCHF_ASSET_ID, + }), + ]); + await consumer.process(); + const tx = booked[0]; + expect(leg(tx, 'Frankencoin/ZCHF').needsMark).toBe(true); + expect(leg(tx, 'Frankencoin/ZCHF').amountChf).toBeUndefined(); + expect(leg(tx, 'TRANSIT/bridge/ZCHF').needsMark).toBe(true); + }); + + // §4.8 bridge with no target/amount → skip + log, no booking + it('skips a bridge order without a target asset or output amount', async () => { + mockBatch([ + lmOrder({ id: 25, system: LiquidityManagementSystem.BOLTZ, command: 'deposit', outputAmount: undefined }), + ]); + await consumer.process(); + expect(booked).toHaveLength(0); + }); + + it('advances the watermark after a successful batch', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + mockBatch([ + lmOrder({ + id: 26, + system: LiquidityManagementSystem.POLYGON_L2_BRIDGE, + command: 'deposit', + outputAmount: 100, + targetAssetId: ZCHF_ASSET_ID, + }), + ]); + await consumer.process(); + const written = JSON.parse(setSpy.mock.calls[0][1]); + expect(written.lastProcessedId).toBe(26); + }); + + // a skipped (non-booking) batch still advances the watermark so skipped rows are not re-scanned forever + it('advances the watermark even when the batch only contains skipped (non-bridge) orders', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + mockBatch([lmOrder({ id: 27, system: LiquidityManagementSystem.BINANCE, command: 'withdraw', outputAmount: 100 })]); + await consumer.process(); + const written = JSON.parse(setSpy.mock.calls[0][1]); + expect(written.lastProcessedId).toBe(27); + }); + + it('no-ops on an empty batch', async () => { + mockBatch([]); + await consumer.process(); + expect(booked).toHaveLength(0); + }); + + // §6.3 covered-by-cutover-opening guard: a bridge liquidity_management_order already Complete at the cutover snapshot + // is in the aggregate ASSET opening; a post-cutover `updated` bump re-selects it in the content-change scan, but its + // seq0 must NOT be re-booked (double-count). id 42 <= boundary 100, not a hole → covered. + it('does NOT re-book a pre-cutover-settled liquidity_management_order re-selected by the content-change scan (covered)', async () => { + jest + .spyOn(settingService, 'getObj') + .mockImplementation((key: string) => + Promise.resolve( + key === 'ledgerCutoverBoundary.liquidity_management_order' ? { boundaryId: 100, holeIds: [] } : undefined, + ), + ); + const covered = lmOrder({ + id: 42, + system: LiquidityManagementSystem.ARBITRUM_L2_BRIDGE, + command: 'deposit', + outputAmount: 1000, + targetAssetId: ZCHF_ASSET_ID, + }); // Complete bridge → would book if not covered + jest + .spyOn(orderRepo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [covered] : [])); + + await consumer.process(); + + expect(booked).toHaveLength(0); // covered → no fresh seq0 + }); +}); diff --git a/src/subdomains/core/accounting/services/consumers/__tests__/liquidity-order-dex.consumer.spec.ts b/src/subdomains/core/accounting/services/consumers/__tests__/liquidity-order-dex.consumer.spec.ts new file mode 100644 index 0000000000..1b10616ce0 --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/__tests__/liquidity-order-dex.consumer.spec.ts @@ -0,0 +1,438 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { TestUtil } from 'src/shared/utils/test.util'; +import { + LiquidityOrder, + LiquidityOrderContext, + LiquidityOrderType, +} from 'src/subdomains/supporting/dex/entities/liquidity-order.entity'; +import { In, IsNull, Not, Repository } from 'typeorm'; +import { AccountType, LedgerAccount } from '../../../entities/ledger-account.entity'; +import { createCustomLedgerAccount } from '../../../entities/__mocks__/ledger-account.entity.mock'; +import { LedgerAccountService } from '../../ledger-account.service'; +import { LedgerBookingService, LedgerLegInput, LedgerTxInput } from '../../ledger-booking.service'; +import { LedgerMarkCache, LedgerMarkService } from '../../ledger-mark.service'; +import { LiquidityOrderDexConsumer } from '../liquidity-order-dex.consumer'; + +const USDC_ASSET_ID = 601; // swapAsset (mark = 0.95) +const EURC_ASSET_ID = 602; // targetAsset (mark = 1.05) +const ETH_ASSET_ID = 603; // distinct gas fee asset (mark = 2000) + +function account(name: string, type: AccountType, currency: string, assetId?: number): LedgerAccount { + return createCustomLedgerAccount({ id: Math.floor(Math.random() * 1e6), name, type, currency, assetId } as any); +} + +function liquidityOrder(values: Record): LiquidityOrder { + return Object.assign(new LiquidityOrder(), { + id: 1, + updated: new Date('2026-06-07T00:00:00Z'), + context: LiquidityOrderContext.LIQUIDITY_MANAGEMENT, + type: LiquidityOrderType.PURCHASE, + correlationId: '123177', + txId: '0xdead', + targetAsset: { id: EURC_ASSET_ID, uniqueName: 'Ethereum/EURC' }, + targetAmount: 1000, + swapAsset: { id: USDC_ASSET_ID, uniqueName: 'Ethereum/USDC' }, + swapAmount: 1050, + ...values, + }); +} + +describe('LiquidityOrderDexConsumer', () => { + let consumer: LiquidityOrderDexConsumer; + let bookingService: LedgerBookingService; + let accountService: LedgerAccountService; + let markService: LedgerMarkService; + let settingService: SettingService; + let liquidityOrderRepo: Repository; + + let booked: LedgerTxInput[]; + let accounts: Map; + let nextSeqValue: number; + + const usdcWallet = account('Ethereum/USDC', AccountType.ASSET, 'USDC', USDC_ASSET_ID); + const eurcWallet = account('Ethereum/EURC', AccountType.ASSET, 'EURC', EURC_ASSET_ID); + const ethWallet = account('Ethereum/ETH', AccountType.ASSET, 'ETH', ETH_ASSET_ID); + + // EURC mark = 1.05 (target); USDC mark = 0.95 (swap); ETH mark = 2000 (gas fee) + const markMap = new Map([ + [EURC_ASSET_ID, [{ created: new Date('2026-01-01'), priceChf: 1.05 }]], + [USDC_ASSET_ID, [{ created: new Date('2026-01-01'), priceChf: 0.95 }]], + [ETH_ASSET_ID, [{ created: new Date('2026-01-01'), priceChf: 2000 }]], + ]); + + beforeEach(async () => { + booked = []; + nextSeqValue = 0; + accounts = new Map(); + + bookingService = createMock(); + accountService = createMock(); + markService = createMock(); + settingService = createMock(); + liquidityOrderRepo = createMock>(); + + jest.spyOn(bookingService, 'bookTx').mockImplementation((input: LedgerTxInput) => { + booked.push(input); + return Promise.resolve({} as any); + }); + jest.spyOn(bookingService, 'nextSeq').mockImplementation(() => Promise.resolve(nextSeqValue)); + + jest.spyOn(accountService, 'findByAssetId').mockImplementation((assetId: number) => { + const wallet = assetId === EURC_ASSET_ID ? eurcWallet : assetId === ETH_ASSET_ID ? ethWallet : usdcWallet; + return Promise.resolve(wallet); + }); + jest + .spyOn(accountService, 'findOrCreate') + .mockImplementation((name: string, type: AccountType, currency: string) => { + const existing = accounts.get(name); + if (existing) return Promise.resolve(existing); + const acc = account(name, type, currency); + accounts.set(name, acc); + return Promise.resolve(acc); + }); + + jest.spyOn(markService, 'preload').mockResolvedValue(new LedgerMarkCache(markMap)); + jest.spyOn(markService, 'getLatestMark').mockResolvedValue(undefined); // B5: no bridge by default (tests opt in) + jest.spyOn(settingService, 'getObj').mockResolvedValue(undefined); + jest.spyOn(settingService, 'set').mockResolvedValue(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TestUtil.provideConfig(), + LiquidityOrderDexConsumer, + { provide: LedgerBookingService, useValue: bookingService }, + { provide: LedgerAccountService, useValue: accountService }, + { provide: LedgerMarkService, useValue: markService }, + { provide: SettingService, useValue: settingService }, + { provide: getRepositoryToken(LiquidityOrder), useValue: liquidityOrderRepo }, + ], + }).compile(); + + consumer = module.get(LiquidityOrderDexConsumer); + }); + + const cents = (legs: LedgerLegInput[]) => legs.reduce((s, l) => s + Math.round((l.amountChf ?? 0) * 100), 0); + // forward id-scan returns the rows; the §4.12 content-change scan (where has `updated`, not `id`) returns [] so the + // forward path is asserted in isolation (its late-settling coverage is exercised for payout_order/exchange_tx). + const mockBatch = (rows: LiquidityOrder[]) => + jest + .spyOn(liquidityOrderRepo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [] : rows)); + const leg = (tx: LedgerTxInput, name: string) => tx.legs.find((l) => l.account.name === name); + + it('is defined', () => { + expect(consumer).toBeDefined(); + }); + + // §4.8a — DfxDex swap: Dr ASSET/target (mark) / Cr ASSET/swap (mark) + spread-DfxDex plug (no fee). Σ CHF = 0. + it('books a DfxDex swap with two mark legs and a spread-DfxDex plug', async () => { + mockBatch([liquidityOrder({ id: 10 })]); + await consumer.process(); + + expect(booked).toHaveLength(1); + const tx = booked[0]; + expect(tx.sourceType).toBe('liquidity_order'); + expect(tx.sourceId).toBe('LiquidityManagement:123177:10'); // '::' (Minor R6-8) + + const target = leg(tx, 'Ethereum/EURC'); + const swap = leg(tx, 'Ethereum/USDC'); + expect(target.amount).toBe(1000); + expect(target.amountChf).toBe(1050); // 1000 × 1.05 + expect(swap.amount).toBe(-1050); + expect(swap.amountChf).toBe(-997.5); // 1050 × 0.95 + // residual = −(1050 − 997.5) = −52.5 → EXPENSE/spread-DfxDex (NOT ROUNDING, Blocker R5-1) + expect(leg(tx, 'EXPENSE/spread-DfxDex').amountChf).toBe(-52.5); + expect(leg(tx, 'ROUNDING')).toBeUndefined(); + expect(cents(tx.legs)).toBe(0); + }); + + // §4.8a Major R7-1 case 3: fee asset is a THIRD asset (gas) → its own Cr ASSET/{feeAsset} native leg + + // EXPENSE/network-fee CHF leg; the native fee leaves ASSET/ETH, NOT the swap/target asset + it('books a third-asset (gas) fee against ASSET/{feeAsset} + EXPENSE/network-fee (Major R7-1 case 3)', async () => { + mockBatch([ + liquidityOrder({ id: 11, feeAsset: { id: ETH_ASSET_ID, uniqueName: 'Ethereum/ETH' }, feeAmount: 0.01 }), + ]); + await consumer.process(); + + const tx = booked[0]; + expect(leg(tx, 'Ethereum/ETH').amount).toBe(-0.01); // native gas leg against ETH + expect(leg(tx, 'Ethereum/ETH').amountChf).toBe(-20); // 0.01 × 2000 + expect(leg(tx, 'EXPENSE/network-fee').amountChf).toBe(20); // mark × feeAmount, CHF-only + expect(leg(tx, 'Ethereum/EURC').amount).toBe(1000); // target leg unchanged + expect(leg(tx, 'Ethereum/USDC').amount).toBe(-1050); // swap leg unchanged + expect(cents(tx.legs)).toBe(0); + }); + + // §4.8a Major R7-1 case 1: feeAsset == swapAsset → folds into the existing Cr ASSET/swap leg (no own leg) + it('folds a swap-asset fee into the existing Cr ASSET/swap leg (Major R7-1 case 1)', async () => { + mockBatch([liquidityOrder({ id: 12, feeAsset: { id: USDC_ASSET_ID, uniqueName: 'Ethereum/USDC' }, feeAmount: 5 })]); + await consumer.process(); + + const tx = booked[0]; + const swapLegs = tx.legs.filter((l) => l.account.name === 'Ethereum/USDC'); + expect(swapLegs).toHaveLength(1); // single combined leg, no separate fee leg + expect(swapLegs[0].amount).toBe(-1055); // −1050 − 5 folded native + expect(leg(tx, 'EXPENSE/network-fee').amountChf).toBe(4.75); // 5 × 0.95 + expect(cents(tx.legs)).toBe(0); + }); + + // §4.8a Major R7-1 case 2: feeAsset == targetAsset → reduces the existing Dr ASSET/target leg (fee leaves target) + it('reduces the Dr ASSET/target leg by a target-asset fee (Major R7-1 case 2)', async () => { + mockBatch([liquidityOrder({ id: 13, feeAsset: { id: EURC_ASSET_ID, uniqueName: 'Ethereum/EURC' }, feeAmount: 4 })]); + await consumer.process(); + + const tx = booked[0]; + const targetLegs = tx.legs.filter((l) => l.account.name === 'Ethereum/EURC'); + expect(targetLegs).toHaveLength(1); + expect(targetLegs[0].amount).toBe(996); // 1000 − 4 (fee leaves the target) + expect(leg(tx, 'EXPENSE/network-fee').amountChf).toBe(4.2); // 4 × 1.05 + expect(cents(tx.legs)).toBe(0); + }); + + // §4.8a Major R2-5 null-strategy: no feeAsset/feeAmount → no fee leg at all + it('books no fee leg when feeAsset/feeAmount are absent (null strategy)', async () => { + mockBatch([liquidityOrder({ id: 14, feeAsset: undefined, feeAmount: undefined })]); + await consumer.process(); + expect(leg(booked[0], 'EXPENSE/network-fee')).toBeUndefined(); + }); + + // §4.8a missing mark → ASSET leg needsMark, plug skipped (no silent plug without a mark) + // Major B5 — no mark ANYWHERE for the target (EURC) leg: the mixed swap (unvalued EURC + valued USDC) cannot balance + // and the bridge finds no mark → the row DEFERS (nothing booked, watermark unchanged), never a silent plug. + it('defers a swap when a target/swap ASSET leg has no mark anywhere (mixed tx, B5)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + jest.spyOn(markService, 'preload').mockResolvedValue( + new LedgerMarkCache(new Map([[USDC_ASSET_ID, [{ created: new Date('2026-01-01'), priceChf: 0.95 }]]])), // EURC missing + ); + mockBatch([liquidityOrder({ id: 15 })]); + await consumer.process(); + + expect(booked[0]).toBeUndefined(); // deferred: unbalanceable mixed swap, no EURC bridge + expect(setSpy).not.toHaveBeenCalled(); // watermark NOT advanced + }); + + // §4.8a invalid swap (target/swap amount null) → skip + it('skips an order with a null target/swap amount', async () => { + mockBatch([liquidityOrder({ id: 16, targetAmount: null })]); + await consumer.process(); + expect(booked).toHaveLength(0); + }); + + // §4.8a idempotency / adversarial dedup: re-running over the same '::' key must NOT + // double-book the swap — uniqueness rests on the ledger UNIQUE(sourceType,sourceId,seq) (Minor R6-8) + it('is idempotent on the :: key (re-run, nextSeq > 0)', async () => { + nextSeqValue = 1; + mockBatch([liquidityOrder({ id: 17 })]); + await consumer.process(); + expect(booked).toHaveLength(0); + }); + + // Minor R6-8 row-uniqueness: (context, correlationId) is NOT unique — dex.service finds plural rows per tuple. + // Two settled swaps sharing the SAME (context, correlationId) must BOTH book because the row id is part of the + // sourceId; the previous ':' key made the second row collide on the ledger UNIQUE and the + // consumer silently dropped it (ledger under-booked). + it('books BOTH settled rows that share the same (context, correlationId) tuple', async () => { + // realistic per-sourceId dedup: nextSeq > 0 only for a sourceId that already booked + jest + .spyOn(bookingService, 'nextSeq') + .mockImplementation((_st, sourceId: string) => + Promise.resolve(booked.some((t) => t.sourceId === sourceId) ? 1 : 0), + ); + mockBatch([liquidityOrder({ id: 30, correlationId: '999' }), liquidityOrder({ id: 31, correlationId: '999' })]); + + await consumer.process(); + + expect(booked).toHaveLength(2); + expect(booked.map((t) => t.sourceId).sort()).toEqual(['LiquidityManagement:999:30', 'LiquidityManagement:999:31']); + }); + + // §4.8a DEDUP query: the query excludes Reservation rows and txId IS NULL — the consumer never sees the rows + // that §4.9 (trading_order) owns or that are pure execution detail. Assert the WHERE clause. + it('selects only Purchase/Sell rows with txId IS NOT NULL in booked contexts (no Reservation, no Trading swap)', async () => { + const findSpy = mockBatch([]); + await consumer.process(); + + // the forward id-scan is the call carrying `where.id` (the §4.12 content-change scan carries `where.updated`) + const forwardCall = findSpy.mock.calls.find((c) => (c[0] as any).where?.id != null); + const where = forwardCall[0].where as Record; + // txId IS NOT NULL — must match the source filter EXACTLY: Not(IsNull()), a FindOperator wrapping an IsNull + // operator. The old `Not(expect.anything())` was a tautology that would also pass for Not(MoreThan(0)) etc. — it + // never actually pinned the IS-NOT-NULL semantics. Not(IsNull()) deep-equals the source-built operator. + expect(where.txId).toEqual(Not(IsNull())); + // type IN (Purchase, Sell) — Reservation excluded (the Trading-context arb swap lives on trading_order.txId) + expect(where.type).toEqual(In([LiquidityOrderType.PURCHASE, LiquidityOrderType.SELL])); + // context IN (LiquidityManagement, BuyCrypto, Trading) — Return/Manual/RefPayout excluded (payout_order owns those) + expect(where.context).toEqual( + In([LiquidityOrderContext.LIQUIDITY_MANAGEMENT, LiquidityOrderContext.BUY_CRYPTO, LiquidityOrderContext.TRADING]), + ); + }); + + it('advances the watermark after a successful batch', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + mockBatch([liquidityOrder({ id: 18 })]); + await consumer.process(); + const written = JSON.parse(setSpy.mock.calls[0][1]); + expect(written.lastProcessedId).toBe(18); + }); + + it('no-ops on an empty batch', async () => { + mockBatch([]); + await consumer.process(); + expect(booked).toHaveLength(0); + }); + + // §4.8a spread plug sign: a residual ≥ 0 books INCOME/spread-DfxDex (the opposite branch of the EXPENSE case above). + // target mark 1.05 < swap mark such that Σ(legs) < 0 → residual = −Σ > 0 → INCOME. Use swap mark < target so the + // swap Cr (negative) is smaller in magnitude than the target Dr → sum positive → residual negative... so to force a + // POSITIVE residual we need Σ legs < 0: target Dr (+) smaller than swap Cr magnitude. swapAmount 1050 × 0.95 = 997.5; + // target 900 × 1.05 = 945 → Σ = 945 − 997.5 = −52.5 → residual +52.5 → INCOME/spread-DfxDex. + it('books a positive spread residual to INCOME/spread-DfxDex (opposite sign branch)', async () => { + mockBatch([liquidityOrder({ id: 19, targetAmount: 900 })]); + await consumer.process(); + + const tx = booked[0]; + expect(leg(tx, 'Ethereum/EURC').amountChf).toBe(945); // 900 × 1.05 + expect(leg(tx, 'Ethereum/USDC').amountChf).toBe(-997.5); // 1050 × 0.95 + expect(leg(tx, 'INCOME/spread-DfxDex').amountChf).toBe(52.5); // residual = −(945 − 997.5) = +52.5 ≥ 0 → INCOME + expect(leg(tx, 'EXPENSE/spread-DfxDex')).toBeUndefined(); + expect(cents(tx.legs)).toBe(0); + }); + + // §4-header failure-isolation: a booking error stops the batch and leaves the watermark unchanged (retry next run) + it('stops the batch and leaves the watermark on a booking error (failure-isolation)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + jest.spyOn(bookingService, 'bookTx').mockRejectedValue(new Error('db down')); + mockBatch([liquidityOrder({ id: 20 })]); + + await consumer.process(); + + expect(booked).toHaveLength(0); + expect(setSpy).not.toHaveBeenCalled(); // throw → break before advancing → watermark stays + }); + + // §4.8a Major R7-1 case 3 with NO mark for the third (gas) fee asset → the network-fee CHF leg is needsMark and the + // fee Cr ASSET leg carries amountChf undefined (lines 155/176-177 undefined sides + 217 feeChf ?? 0). Because a leg + // needsMark, appendSpreadPlug books no plug. + // Major B5 — a third-asset (gas) fee with no mark ANYWHERE: the network-fee EXPENSE leg is CHF-derived from the missing + // fee mark (amountChf undefined, no assetId to bridge), so the swap cannot be balanced/plugged and DEFERS — the fee + // value is NEVER misclassified into a phantom spread plug. + it('defers a swap when a distinct gas-fee asset has no mark anywhere (B5)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + jest.spyOn(markService, 'preload').mockResolvedValue( + new LedgerMarkCache( + new Map([ + [EURC_ASSET_ID, [{ created: new Date('2026-01-01'), priceChf: 1.05 }]], + [USDC_ASSET_ID, [{ created: new Date('2026-01-01'), priceChf: 0.95 }]], + // ETH (fee asset) deliberately absent → no fee mark + ]), + ), + ); + mockBatch([ + liquidityOrder({ id: 21, feeAsset: { id: ETH_ASSET_ID, uniqueName: 'Ethereum/ETH' }, feeAmount: 0.01 }), + ]); + await consumer.process(); + + expect(booked[0]).toBeUndefined(); // deferred: the CHF-derived network-fee leg cannot be bridged → no phantom plug + expect(setSpy).not.toHaveBeenCalled(); // watermark NOT advanced + }); + + // §4.8a Major R7-1 case 1 (feeAsset == swapAsset) with NO fee mark → addToLeg `needsMark` true side (lines 163/223): + // the swap leg's native grows by the fee but its CHF stays unmovable and the leg becomes needsMark. + // Major B5 — a folded swap-asset fee where the swap asset (USDC) has no mark ANYWHERE: the combined swap leg is + // unvalued and the network-fee EXPENSE leg is CHF-derived from the missing mark → the mixed swap DEFERS. + it('defers a folded swap-asset-fee swap when the swap asset has no mark anywhere (B5)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + jest.spyOn(markService, 'preload').mockResolvedValue( + new LedgerMarkCache(new Map([[EURC_ASSET_ID, [{ created: new Date('2026-01-01'), priceChf: 1.05 }]]])), // USDC absent + ); + mockBatch([liquidityOrder({ id: 22, feeAsset: { id: USDC_ASSET_ID, uniqueName: 'Ethereum/USDC' }, feeAmount: 5 })]); + await consumer.process(); + + expect(booked[0]).toBeUndefined(); // deferred: unvalued combined swap leg on a mixed tx + expect(setSpy).not.toHaveBeenCalled(); // watermark NOT advanced + }); + + // F2: a distinct gas-fee asset with NO historical mark at the swap date but a CURRENT mark (first fed AFTER the swap). + // The CHF EXPENSE/network-fee leg is derived from the SAME bridged mark as its ASSET/{feeAsset} counter-leg, so both + // move in lockstep and the swap balances — instead of bridging only the asset side and wedging the row head-of-line + // (asymmetric Σ = feeChf > tolerance). needsMark stays true → the mark-to-market job re-marks the basis later. + it('bridges a no-historical-mark gas fee via the current mark so the swap balances (F2)', async () => { + jest.spyOn(markService, 'preload').mockResolvedValue( + new LedgerMarkCache( + new Map([ + [EURC_ASSET_ID, [{ created: new Date('2026-01-01'), priceChf: 1.05 }]], + [USDC_ASSET_ID, [{ created: new Date('2026-01-01'), priceChf: 0.95 }]], + // ETH (fee asset) has NO historical mark at the swap date … + ]), + ), + ); + jest + .spyOn(markService, 'getLatestMark') + .mockImplementation((id: number) => Promise.resolve(id === ETH_ASSET_ID ? 2000 : undefined)); // … but a current mark exists + + mockBatch([ + liquidityOrder({ id: 25, feeAsset: { id: ETH_ASSET_ID, uniqueName: 'Ethereum/ETH' }, feeAmount: 0.01 }), + ]); + await consumer.process(); + + expect(booked).toHaveLength(1); + const tx = booked[0]; + const feeExpense = leg(tx, 'EXPENSE/network-fee'); + const ethLeg = leg(tx, 'Ethereum/ETH'); + expect(feeExpense.amountChf).toBe(20); // bridged 0.01 × 2000 + expect(feeExpense.needsMark).toBe(true); // provisional → re-marked later + expect(ethLeg.amountChf).toBe(-20); // ASSET counter-leg bridged with the SAME mark → symmetric + expect(ethLeg.needsMark).toBe(true); + expect(cents(tx.legs)).toBe(0); // balances (was a wrong defer / head-of-line wedge pre-fix) + }); + + // §4.8a appendSpreadPlug sub-cent branch (lines 187-188): a swap whose two mark legs net to 0 (within tolerance) → + // NO spread plug appended. EURC 1000 × 1.05 = 1050; USDC swapAmount chosen so 0.95 × amount = 1050 → amount ≈ 1105.26 + // is not clean; instead use equal marks via a swap where target CHF == swap CHF exactly. + it('appends no spread plug when the two mark legs net to zero (sub-cent, lines 187-188)', async () => { + // target 1000 × 1.05 = 1050; swap 1105.263158 × 0.95 = 1050.00 → Σ ≈ 0 (within the 2-cent tolerance) + mockBatch([liquidityOrder({ id: 23, swapAmount: 1105.263158 })]); + await consumer.process(); + + const tx = booked[0]; + expect(leg(tx, 'Ethereum/EURC').amountChf).toBe(1050); + expect(leg(tx, 'Ethereum/USDC').amountChf).toBe(-1050); // round(1105.263158 × 0.95, 2) = 1050.00 + expect(leg(tx, 'EXPENSE/spread-DfxDex')).toBeUndefined(); // residual 0 → sub-cent → no plug + expect(leg(tx, 'INCOME/spread-DfxDex')).toBeUndefined(); + expect(cents(tx.legs)).toBe(0); + }); + + // §4.8a assetAccount throw (line 234): an asset with no CoA ledger account → throws → failure-isolation. + it('stops the batch and leaves the watermark when an asset has no ledger account (line 234)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + jest.spyOn(accountService, 'findByAssetId').mockResolvedValue(undefined); + mockBatch([liquidityOrder({ id: 24 })]); + + await consumer.process(); + + expect(booked).toHaveLength(0); + expect(setSpy).not.toHaveBeenCalled(); + }); + + // §6.3 covered-by-cutover-opening guard (boundary keyed by liquidity_order.id): a swap already settled at the cutover + // snapshot is in the aggregate ASSET opening; a post-cutover `updated` bump re-selects it in the content-change scan, + // but its seq0 must NOT be re-booked (double-count). id 42 <= boundary 100, not a hole → covered. + it('does NOT re-book a pre-cutover-settled liquidity_order re-selected by the content-change scan (covered)', async () => { + jest + .spyOn(settingService, 'getObj') + .mockImplementation((key: string) => + Promise.resolve(key === 'ledgerCutoverBoundary.liquidity_order' ? { boundaryId: 100, holeIds: [] } : undefined), + ); + const covered = liquidityOrder({ id: 42 }); // txId + booked context + Purchase → passes the settled filter + jest + .spyOn(liquidityOrderRepo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [covered] : [])); + + await consumer.process(); + + expect(booked).toHaveLength(0); // covered → no fresh seq0 + }); +}); diff --git a/src/subdomains/core/accounting/services/consumers/__tests__/payout-order.consumer.spec.ts b/src/subdomains/core/accounting/services/consumers/__tests__/payout-order.consumer.spec.ts new file mode 100644 index 0000000000..91c1233582 --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/__tests__/payout-order.consumer.spec.ts @@ -0,0 +1,943 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { TestUtil } from 'src/shared/utils/test.util'; +import { RefReward } from 'src/subdomains/core/referral/reward/ref-reward.entity'; +import { BuyCrypto } from 'src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity'; +import { BuyFiat } from 'src/subdomains/core/sell-crypto/process/buy-fiat.entity'; +import { + PayoutOrder, + PayoutOrderContext, + PayoutOrderStatus, +} from 'src/subdomains/supporting/payout/entities/payout-order.entity'; +import { Repository } from 'typeorm'; +import { LedgerLeg } from '../../../entities/ledger-leg.entity'; +import { LedgerTx } from '../../../entities/ledger-tx.entity'; +import { AccountType, LedgerAccount } from '../../../entities/ledger-account.entity'; +import { createCustomLedgerAccount } from '../../../entities/__mocks__/ledger-account.entity.mock'; +import { LedgerAccountService } from '../../ledger-account.service'; +import { LedgerBookingService, LedgerLegInput, LedgerTxInput } from '../../ledger-booking.service'; +import { LedgerMarkCache, LedgerMarkService } from '../../ledger-mark.service'; +import { PayoutOrderConsumer } from '../payout-order.consumer'; + +const BTC_ASSET_ID = 301; // payout asset (volatile) +const ETH_ASSET_ID = 302; // distinct gas fee asset + +function payoutOrder(values: Record): PayoutOrder { + return Object.assign(new PayoutOrder(), { + id: 1, + updated: new Date('2026-06-07T00:00:00Z'), + status: PayoutOrderStatus.COMPLETE, + context: PayoutOrderContext.BUY_CRYPTO, + correlationId: '0', + amount: 1, + asset: { id: BTC_ASSET_ID, uniqueName: 'Bitcoin/BTC' }, + ...values, + }); +} + +function account(name: string, type: AccountType, currency: string, assetId?: number): LedgerAccount { + return createCustomLedgerAccount({ id: Math.floor(Math.random() * 1e6), name, type, currency, assetId } as any); +} + +describe('PayoutOrderConsumer', () => { + let consumer: PayoutOrderConsumer; + let bookingService: LedgerBookingService; + let accountService: LedgerAccountService; + let markService: LedgerMarkService; + let settingService: SettingService; + let payoutOrderRepo: Repository; + let refRewardRepo: Repository; + let buyCryptoRepo: Repository; + let buyFiatRepo: Repository; + let ledgerTxRepo: Repository; + + let booked: LedgerTxInput[]; + let accounts: Map; + let nextSeqValue: number; + + const btcWallet = account('Bitcoin/BTC', AccountType.ASSET, 'BTC', BTC_ASSET_ID); + const ethWallet = account('Ethereum/ETH', AccountType.ASSET, 'ETH', ETH_ASSET_ID); + + // BTC mark = 50000 (settlement); ETH mark = 2000 + const markMap = new Map([ + [BTC_ASSET_ID, [{ created: new Date('2026-01-01'), priceChf: 50000 }]], + [ETH_ASSET_ID, [{ created: new Date('2026-01-01'), priceChf: 2000 }]], + ]); + + beforeEach(async () => { + booked = []; + nextSeqValue = 0; + accounts = new Map([ + ['Bitcoin/BTC', btcWallet], + ['Ethereum/ETH', ethWallet], + ]); + + bookingService = createMock(); + accountService = createMock(); + markService = createMock(); + settingService = createMock(); + payoutOrderRepo = createMock>(); + refRewardRepo = createMock>(); + buyCryptoRepo = createMock>(); + buyFiatRepo = createMock>(); + ledgerTxRepo = createMock>(); + + // by default no cutover opening exists → the owed-Dr falls back to the completion CHF (§4.5) + jest.spyOn(ledgerTxRepo, 'findOne').mockResolvedValue(null); + jest.spyOn(settingService, 'get').mockResolvedValue(undefined); + + jest.spyOn(bookingService, 'bookTx').mockImplementation((input: LedgerTxInput) => { + booked.push(input); + return Promise.resolve({} as any); + }); + jest.spyOn(bookingService, 'nextSeq').mockImplementation(() => Promise.resolve(nextSeqValue)); + + jest.spyOn(accountService, 'findByAssetId').mockImplementation((assetId: number) => { + const wallet = assetId === ETH_ASSET_ID ? ethWallet : btcWallet; + return Promise.resolve(wallet); + }); + jest + .spyOn(accountService, 'findOrCreate') + .mockImplementation((name: string, type: AccountType, currency: string) => { + const existing = accounts.get(name); + if (existing) return Promise.resolve(existing); + const acc = account(name, type, currency); + accounts.set(name, acc); + return Promise.resolve(acc); + }); + + jest.spyOn(markService, 'preload').mockResolvedValue(new LedgerMarkCache(markMap)); + jest.spyOn(markService, 'getLatestMark').mockResolvedValue(undefined); // B5: no bridge by default (tests opt in) + jest.spyOn(settingService, 'getObj').mockResolvedValue(undefined); + jest.spyOn(settingService, 'set').mockResolvedValue(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TestUtil.provideConfig(), + PayoutOrderConsumer, + { provide: LedgerBookingService, useValue: bookingService }, + { provide: LedgerAccountService, useValue: accountService }, + { provide: LedgerMarkService, useValue: markService }, + { provide: SettingService, useValue: settingService }, + { provide: getRepositoryToken(PayoutOrder), useValue: payoutOrderRepo }, + { provide: getRepositoryToken(RefReward), useValue: refRewardRepo }, + { provide: getRepositoryToken(BuyCrypto), useValue: buyCryptoRepo }, + { provide: getRepositoryToken(BuyFiat), useValue: buyFiatRepo }, + { provide: getRepositoryToken(LedgerTx), useValue: ledgerTxRepo }, + ], + }).compile(); + + consumer = module.get(PayoutOrderConsumer); + }); + + const cents = (legs: LedgerLegInput[]) => legs.reduce((s, l) => s + Math.round((l.amountChf ?? 0) * 100), 0); + // forward id-scan returns the rows; the §4.12 content-change scan (where has `updated`, not `id`) returns [] so the + // forward path is asserted in isolation (its late-settling coverage has dedicated two-run tests below). + const mockBatch = (rows: PayoutOrder[]) => + jest + .spyOn(payoutOrderRepo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [] : rows)); + const leg = (tx: LedgerTxInput, name: string) => tx.legs.find((l) => l.account.name === name); + + it('is defined', () => { + expect(consumer).toBeDefined(); + }); + + // §4.5 BuyCrypto: Dr LIABILITY/buyCrypto-owed (completion CHF) / Cr ASSET/wallet (settlement mark) + fee + + // fx-revaluation plug for the completion↔settlement drift (Blocker R2-2) + it('books a BuyCrypto payout: owed = completion CHF, wallet = settlement mark, drift → fx plug', async () => { + jest.spyOn(buyCryptoRepo, 'findOneBy').mockResolvedValue({ amountInChf: 49500, totalFeeAmountChf: 100 } as any); // completion owed = 49400 + mockBatch([ + payoutOrder({ + id: 10, + context: PayoutOrderContext.BUY_CRYPTO, + correlationId: '777', + amount: 1, + asset: { id: BTC_ASSET_ID, uniqueName: 'Bitcoin/BTC' }, + preparationFeeAsset: { id: ETH_ASSET_ID, uniqueName: 'Ethereum/ETH' }, + preparationFeeAmount: 0.001, + preparationFeeAmountChf: 2, + payoutFeeAsset: { id: ETH_ASSET_ID, uniqueName: 'Ethereum/ETH' }, + payoutFeeAmount: 0.0005, + payoutFeeAmountChf: 1, + }), + ]); + await consumer.process(); + + const tx = booked[0]; + const owed = leg(tx, 'LIABILITY/buyCrypto-owed'); + const wallet = leg(tx, 'Bitcoin/BTC'); + const networkFee = leg(tx, 'EXPENSE/network-fee'); + const eth = leg(tx, 'Ethereum/ETH'); + expect(owed.amountChf).toBe(49400); // completion CHF (amountInChf − totalFeeAmountChf) → closes owed to 0 + expect(wallet.amountChf).toBe(-50000); // settlement mark × 1 BTC + expect(networkFee.amountChf).toBe(3); // (2 + 1) additive, NOT the NaN-prone getter + expect(eth.amountChf).toBe(-3); // native fee against ETH (0.0015 × 2000), not BTC (Major R7-1) + expect(cents(tx.legs)).toBe(0); // fx plug closes 49400 − 50000 + 3 − 3 = −600 sum → +600 residual + const plug = leg(tx, 'INCOME/fx-revaluation'); + expect(plug.amountChf).toBe(600); // residual = −(sum) = +600 ≥ 0 → INCOME/fx-revaluation + }); + + // §4.5 Major R6-1: a cutover-straddling owed-row debits the cutover OPENING CHF anchor (NOT the completion CHF), + // so owed closes cent-exact to 0 and the opening↔settlement mark drift lands in the fx plug. + it('books a cutover-straddling BuyCryptoReturn against the cutover opening CHF, not the completion CHF', async () => { + // opening = 48000 (outputAmount × mark@snapshot); completion (if it were used) = 49400 → distinct on purpose + const cutoverLogId = '1557344'; + jest.spyOn(settingService, 'get').mockResolvedValue(cutoverLogId); + jest.spyOn(buyCryptoRepo, 'findOneBy').mockResolvedValue({ amountInChf: 49500, totalFeeAmountChf: 100 } as any); + jest.spyOn(ledgerTxRepo, 'findOne').mockImplementation(({ where }: any) => { + if (where?.sourceId === `${cutoverLogId}:buy_crypto-owed:790`) { + const owedAccount = account('LIABILITY/buyCrypto-owed', AccountType.LIABILITY, 'CHF'); + const openingLeg = Object.assign(new LedgerLeg(), { account: owedAccount, amountChf: -48000 }); + return Promise.resolve(Object.assign(new LedgerTx(), { legs: [openingLeg] })); + } + return Promise.resolve(null); + }); + mockBatch([ + payoutOrder({ + id: 20, + context: PayoutOrderContext.BUY_CRYPTO_RETURN, + correlationId: '790', + amount: 1, + asset: { id: BTC_ASSET_ID, uniqueName: 'Bitcoin/BTC' }, + preparationFeeAmountChf: 0, + payoutFeeAmountChf: 0, + }), + ]); + await consumer.process(); + + const tx = booked[0]; + const owed = leg(tx, 'LIABILITY/buyCrypto-owed'); + expect(owed.amountChf).toBe(48000); // the cutover opening CHF anchor (NOT the 49400 completion CHF) + expect(leg(tx, 'Bitcoin/BTC').amountChf).toBe(-50000); // settlement mark × 1 BTC + expect(cents(tx.legs)).toBe(0); // owed 48000 − 50000 = −2000 → +2000 fx plug closes the opening↔settlement drift + expect(leg(tx, 'INCOME/fx-revaluation').amountChf).toBe(2000); + }); + + // §4.5 NaN-guard: only one fee field filled → additive ?? 0, not feeAmountChf getter (Major R2-5) + it('uses additive (a ?? 0) + (b ?? 0) for the network fee, never the NaN-prone getter', async () => { + jest.spyOn(buyCryptoRepo, 'findOneBy').mockResolvedValue({ amountInChf: 50000, totalFeeAmountChf: 0 } as any); + mockBatch([ + payoutOrder({ + id: 11, + context: PayoutOrderContext.BUY_CRYPTO, + correlationId: '778', + amount: 1, + preparationFeeAmountChf: null, // only payout fee filled → getter would yield NaN + payoutFeeAsset: { id: ETH_ASSET_ID, uniqueName: 'Ethereum/ETH' }, + payoutFeeAmount: 0.001, + payoutFeeAmountChf: 2, + }), + ]); + await consumer.process(); + + const networkFee = leg(booked[0], 'EXPENSE/network-fee'); + expect(networkFee.amountChf).toBe(2); // 0 + 2, not NaN + expect(cents(booked[0].legs)).toBe(0); + }); + + // §4.5 LN / no fee: networkFeeChf === 0 → no fee leg at all + it('books no network-fee leg when both fee fields are null/zero (LN, D15 C.e)', async () => { + jest.spyOn(buyCryptoRepo, 'findOneBy').mockResolvedValue({ amountInChf: 50000, totalFeeAmountChf: 0 } as any); + mockBatch([ + payoutOrder({ + id: 12, + context: PayoutOrderContext.BUY_CRYPTO, + correlationId: '779', + amount: 1, + preparationFeeAmountChf: null, + payoutFeeAmountChf: null, + }), + ]); + await consumer.process(); + expect(leg(booked[0], 'EXPENSE/network-fee')).toBeUndefined(); + }); + + // §4.5 RefPayout: Dr EXPENSE/refReward (= ref_reward.amountInChf via correlationId join) / Cr ASSET/wallet + // deterministic (priceChf = amountInChf/amount), no plug on the main leg (Blocker R2-3) + it('books a RefPayout against EXPENSE/refReward deterministically (no main-leg plug)', async () => { + jest.spyOn(refRewardRepo, 'findOneBy').mockResolvedValue({ id: 55, amountInChf: 25 } as any); + mockBatch([ + payoutOrder({ + id: 13, + context: PayoutOrderContext.REF_PAYOUT, + correlationId: '55', + amount: 100, // 100 native units → priceChf = 0.25 + asset: { id: BTC_ASSET_ID, uniqueName: 'Bitcoin/BTC' }, + payoutFeeAmountChf: 0, // RefPayout fee empirically sub-cent + preparationFeeAmountChf: 0, + }), + ]); + await consumer.process(); + + const tx = booked[0]; + const refExpense = leg(tx, 'EXPENSE/refReward'); + const wallet = leg(tx, 'Bitcoin/BTC'); + expect(refExpense.amountChf).toBe(25); // = ref_reward.amountInChf + expect(wallet.amountChf).toBe(-25); // cent-exact gegengleich → no fx plug + expect(wallet.priceChf).toBeCloseTo(0.25, 8); // amountInChf/amount, derived display value (Minor R7-5) + expect(leg(tx, 'EXPENSE/fx-revaluation')).toBeUndefined(); + expect(leg(tx, 'INCOME/fx-revaluation')).toBeUndefined(); + expect(cents(tx.legs)).toBe(0); + }); + + // §4.5 RefPayout amount≈0 guard: priceChf = amountInChf/amount would be NaN/Infinity (Minor R6-6) + it('guards RefPayout against amount≈0 (skips to avoid NaN priceChf)', async () => { + jest.spyOn(refRewardRepo, 'findOneBy').mockResolvedValue({ id: 55, amountInChf: 25 } as any); + mockBatch([payoutOrder({ id: 14, context: PayoutOrderContext.REF_PAYOUT, correlationId: '55', amount: 0 })]); + await consumer.process(); + expect(booked).toHaveLength(0); + }); + + // §4.5 Major R7-1: native fee against a DISTINCT fee asset gets its own ASSET/{feeAsset} Cr leg + it('books the native fee against the FEE asset, not the payout asset (Major R7-1)', async () => { + jest.spyOn(buyCryptoRepo, 'findOneBy').mockResolvedValue({ amountInChf: 50000, totalFeeAmountChf: 0 } as any); + mockBatch([ + payoutOrder({ + id: 15, + context: PayoutOrderContext.BUY_CRYPTO, + correlationId: '780', + amount: 1, + asset: { id: BTC_ASSET_ID, uniqueName: 'Bitcoin/BTC' }, + payoutFeeAsset: { id: ETH_ASSET_ID, uniqueName: 'Ethereum/ETH' }, + payoutFeeAmount: 0.002, + payoutFeeAmountChf: 4, + preparationFeeAmountChf: 0, + }), + ]); + await consumer.process(); + + const tx = booked[0]; + expect(leg(tx, 'Ethereum/ETH').amount).toBe(-0.002); // native against ETH + expect(leg(tx, 'Ethereum/ETH').amountChf).toBe(-4); // 0.002 × 2000 + expect(leg(tx, 'Bitcoin/BTC').amount).toBe(-1); // BTC leg only the payout amount, NOT amount + fee + expect(cents(tx.legs)).toBe(0); + }); + + // §4.5 fee-asset == payout-asset: folds into the same wallet Cr leg with a mixed effective priceChf (Minor R13-3) + it('folds a payout-asset fee into the wallet Cr leg (feeAsset == payoutAsset)', async () => { + jest.spyOn(buyCryptoRepo, 'findOneBy').mockResolvedValue({ amountInChf: 50000, totalFeeAmountChf: 0 } as any); + mockBatch([ + payoutOrder({ + id: 16, + context: PayoutOrderContext.BUY_CRYPTO, + correlationId: '781', + amount: 1, + asset: { id: BTC_ASSET_ID, uniqueName: 'Bitcoin/BTC' }, + payoutFeeAsset: { id: BTC_ASSET_ID, uniqueName: 'Bitcoin/BTC' }, // same as payout asset + payoutFeeAmount: 0.0001, + payoutFeeAmountChf: 5, + preparationFeeAmountChf: 0, + }), + ]); + await consumer.process(); + + const tx = booked[0]; + const btcLegs = tx.legs.filter((l) => l.account.name === 'Bitcoin/BTC'); + expect(btcLegs).toHaveLength(1); // single combined leg (no separate ETH-style leg) + expect(btcLegs[0].amount).toBe(-1.0001); // amount + fee folded native + expect(cents(tx.legs)).toBe(0); + }); + + // §4.5 missing wallet mark → needsMark, plug stays open, no silent priceChf=0 + // Major B5 — no mark ANYWHERE for the payout (wallet) asset: the mixed tx (unvalued wallet + valued owed completion CHF) + // cannot balance and the bridge finds no mark → the row DEFERS (nothing booked, watermark unchanged). + it('defers when the payout-asset mark is missing everywhere (mixed tx cannot balance, B5)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + jest.spyOn(buyCryptoRepo, 'findOneBy').mockResolvedValue({ amountInChf: 50000, totalFeeAmountChf: 0 } as any); + jest + .spyOn(accountService, 'findByAssetId') + .mockResolvedValue(account('Unknown/XYZ', AccountType.ASSET, 'XYZ', 999)); + mockBatch([ + payoutOrder({ + id: 17, + context: PayoutOrderContext.BUY_CRYPTO, + correlationId: '782', + amount: 1, + asset: { id: 999, uniqueName: 'Unknown/XYZ' }, // no mark + preparationFeeAmountChf: 0, + payoutFeeAmountChf: 0, + }), + ]); + await consumer.process(); + + expect(booked[0]).toBeUndefined(); // nothing booked (deferred) + expect(setSpy).not.toHaveBeenCalled(); // watermark NOT advanced + }); + + // Major B5 bridge — a missing HISTORICAL payout-asset mark but a present current mark: the wallet leg is valued with + // the youngest available mark (bridge), needsMark STAYS true so the mark-to-market job corrects the basis later, and + // the tx balances via the fx-revaluation plug (bridged wallet value vs the owed completion CHF). + it('bridges a missing historical payout-asset mark and books balanced, needsMark stays true (B5)', async () => { + jest.spyOn(markService, 'getLatestMark').mockResolvedValue(49000); // youngest available mark for asset 999 + jest.spyOn(buyCryptoRepo, 'findOneBy').mockResolvedValue({ amountInChf: 50000, totalFeeAmountChf: 0 } as any); + jest + .spyOn(accountService, 'findByAssetId') + .mockResolvedValue(account('Unknown/XYZ', AccountType.ASSET, 'XYZ', 999)); + mockBatch([ + payoutOrder({ + id: 17, + context: PayoutOrderContext.BUY_CRYPTO, + correlationId: '782', + amount: 1, + asset: { id: 999, uniqueName: 'Unknown/XYZ' }, + preparationFeeAmountChf: 0, + payoutFeeAmountChf: 0, + }), + ]); + await consumer.process(); + + const wallet = leg(booked[0], 'Unknown/XYZ'); + expect(wallet.amountChf).toBe(-49000); // bridged Cr wallet: −(49000 × 1) + expect(wallet.needsMark).toBe(true); // stays true → mark-to-market re-marks later + const cents = booked[0].legs.reduce((s, l) => s + Math.round((l.amountChf ?? 0) * 100), 0); + expect(cents).toBe(0); // balances (owed +50000, wallet −49000, fx-revaluation plug −1000) + }); + + // §4.5 assetAccount throw (line 372): a distinct fee asset with no CoA ledger account → throws → failure-isolation + // (watermark unchanged). The wallet (BTC) resolves fine; the ETH fee asset returns undefined → throw. + it('stops the batch when a distinct fee asset has no ledger account (CoA bootstrap, line 372)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + jest.spyOn(buyCryptoRepo, 'findOneBy').mockResolvedValue({ amountInChf: 50000, totalFeeAmountChf: 0 } as any); + jest.spyOn(accountService, 'findByAssetId').mockImplementation((assetId: number) => { + if (assetId === ETH_ASSET_ID) return Promise.resolve(undefined); // fee asset not in the CoA → throw + return Promise.resolve(btcWallet); + }); + mockBatch([ + payoutOrder({ + id: 37, + context: PayoutOrderContext.BUY_CRYPTO, + correlationId: '950', + amount: 1, + asset: { id: BTC_ASSET_ID, uniqueName: 'Bitcoin/BTC' }, + payoutFeeAsset: { id: ETH_ASSET_ID, uniqueName: 'Ethereum/ETH' }, // distinct fee asset → its own leg → throws + payoutFeeAmount: 0.001, + payoutFeeAmountChf: 2, + preparationFeeAmountChf: 0, + }), + ]); + + await consumer.process(); + + expect(booked).toHaveLength(0); + expect(setSpy).not.toHaveBeenCalled(); + }); + + it('is idempotent: skips an already-booked payout (re-run, nextSeq > 0)', async () => { + nextSeqValue = 1; + jest.spyOn(buyCryptoRepo, 'findOneBy').mockResolvedValue({ amountInChf: 50000, totalFeeAmountChf: 0 } as any); + mockBatch([payoutOrder({ id: 18, context: PayoutOrderContext.BUY_CRYPTO, correlationId: '783', amount: 1 })]); + await consumer.process(); + expect(booked).toHaveLength(0); + }); + + it('advances the watermark after a successful batch', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + jest.spyOn(buyCryptoRepo, 'findOneBy').mockResolvedValue({ amountInChf: 50000, totalFeeAmountChf: 0 } as any); + mockBatch([ + payoutOrder({ + id: 19, + context: PayoutOrderContext.BUY_CRYPTO, + correlationId: '784', + amount: 1, + preparationFeeAmountChf: 0, + payoutFeeAmountChf: 0, + }), + ]); + await consumer.process(); + const written = JSON.parse(setSpy.mock.calls[0][1]); + expect(written.lastProcessedId).toBe(19); + }); + + it('no-ops on an empty batch', async () => { + mockBatch([]); + await consumer.process(); + expect(booked).toHaveLength(0); + }); + + // --- C1: LATE-SETTLING FORWARD COVERAGE (content-change scan) --- // + + // C1: a payout that is NOT yet Complete when the forward id-watermark advances OVER it (a later, higher-id row does + // settle) would be forward-unreachable once it completes. The status-agnostic content-change scan must forward-book + // it on the run after it settles — the row is NOT lost. Two runs: run 1 advances the watermark past the unsettled + // id; the row completes; run 2's forward id-scan no longer returns it, but the content-change scan does and books it. + it('forward-books a payout that settled AFTER the id-watermark advanced over it (C1, two runs)', async () => { + // realistic idempotency: a sourceId already booked reports nextSeq > 0, so re-selecting an already-booked row in + // the content-change scan is a no-op (does not double-book) + jest + .spyOn(bookingService, 'nextSeq') + .mockImplementation((_st, sourceId: string) => + Promise.resolve(booked.some((t) => t.sourceId === sourceId) ? 1 : 0), + ); + jest.spyOn(buyCryptoRepo, 'findOneBy').mockResolvedValue({ amountInChf: 50000, totalFeeAmountChf: 0 } as any); + + const settled = payoutOrder({ + id: 61, + correlationId: '901', + amount: 1, + preparationFeeAmountChf: 0, + payoutFeeAmountChf: 0, + }); + const lateBefore = payoutOrder({ + id: 60, + correlationId: '900', + amount: 1, + status: PayoutOrderStatus.CREATED, + preparationFeeAmountChf: 0, + payoutFeeAmountChf: 0, + }); + const lateAfter = payoutOrder({ + id: 60, + correlationId: '900', + amount: 1, + status: PayoutOrderStatus.COMPLETE, + preparationFeeAmountChf: 0, + payoutFeeAmountChf: 0, + }); + + // RUN 1: forward returns only the Complete row 61 (id-watermark jumps to 61, over the still-Created id 60); the + // content-change scan sees both, skips row 60 (not yet Complete) and no-ops row 61 (already booked). + jest + .spyOn(payoutOrderRepo, 'find') + .mockImplementation(({ where }: any) => + Promise.resolve(where?.updated != null ? [lateBefore, settled] : [settled]), + ); + await consumer.process(); + expect(booked.map((b) => b.sourceId)).toEqual(['61']); // row 60 not booked (still Created) + + // RUN 2: row 60 has since completed. The forward id-scan (id > 61) no longer returns it — forward-unreachable. + // The content-change scan re-selects it (updated bump) and books it because it now satisfies the settled filter. + jest + .spyOn(payoutOrderRepo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [lateAfter] : [])); + await consumer.process(); + expect(booked.map((b) => b.sourceId).sort()).toEqual(['60', '61']); // row 60 finally booked on the second run + }); + + // --- ERROR / SKIP / FALLBACK BRANCHES --- // + + // §4.5 book guard: amount≈0 → skip (avoids NaN priceChf), watermark still advances past it (not an error) + it('skips a payout with amount≈0 and still advances the watermark past it', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + mockBatch([payoutOrder({ id: 30, context: PayoutOrderContext.BUY_CRYPTO, correlationId: '800', amount: 0 })]); + await consumer.process(); + + expect(booked).toHaveLength(0); // amount≈0 → no tx + expect(JSON.parse(setSpy.mock.calls[0][1]).lastProcessedId).toBe(30); // skip is not a failure → watermark advances + }); + + // §4.5 book guard: a payout_order with no asset throws → failure-isolation: watermark NOT advanced, retry next run + it('stops the batch on a payout with no asset (failure-isolation, watermark unchanged)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + mockBatch([payoutOrder({ id: 31, context: PayoutOrderContext.BUY_CRYPTO, correlationId: '801', asset: null })]); + await consumer.process(); + + expect(booked).toHaveLength(0); + expect(setSpy).not.toHaveBeenCalled(); // throw → break before advancing → watermark stays + }); + + // §4.5 failure-isolation: first row books, second throws (no asset) → watermark advances ONLY to the first + it('advances the watermark to the last successful row and stops on the failing one', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + jest.spyOn(buyCryptoRepo, 'findOneBy').mockResolvedValue({ amountInChf: 50000, totalFeeAmountChf: 0 } as any); + mockBatch([ + payoutOrder({ + id: 40, + context: PayoutOrderContext.BUY_CRYPTO, + correlationId: '810', + amount: 1, + preparationFeeAmountChf: 0, + payoutFeeAmountChf: 0, + }), + payoutOrder({ id: 41, context: PayoutOrderContext.BUY_CRYPTO, correlationId: '811', asset: null }), // throws + ]); + await consumer.process(); + + expect(booked).toHaveLength(1); // only row 40 booked + expect(JSON.parse(setSpy.mock.calls[0][1]).lastProcessedId).toBe(40); // NOT 41 + }); + + // §4.5 RefPayout with no resolvable ref_reward.amountInChf → counter undefined → skip (no tx), watermark advances + it('skips a RefPayout whose ref_reward has no amountInChf (counter undefined)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + jest.spyOn(refRewardRepo, 'findOneBy').mockResolvedValue(null); // no ref_reward found + mockBatch([payoutOrder({ id: 32, context: PayoutOrderContext.REF_PAYOUT, correlationId: '802', amount: 10 })]); + await consumer.process(); + + expect(booked).toHaveLength(0); + expect(JSON.parse(setSpy.mock.calls[0][1]).lastProcessedId).toBe(32); // skip (not error) → advance + }); + + // §4.5 liabilityCounter defensive guard: an unmapped context value (bad DB data, not one of the 5 enum members and + // not RefPayout) has no LIABILITY_BUCKET entry → liabilityCounter logs + returns undefined → the row is skipped. + it('skips a payout with an unmapped context (defensive bucket guard)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + mockBatch([ + payoutOrder({ id: 33, context: 'UnknownContext' as PayoutOrderContext, correlationId: '803', amount: 1 }), + ]); + await consumer.process(); + + expect(booked).toHaveLength(0); + expect(JSON.parse(setSpy.mock.calls[0][1]).lastProcessedId).toBe(33); // defensive skip → watermark still advances + }); + + // §4.5 owedCompletionChf: a non-integer correlationId (e.g. a network-start-fee marker) → mark fallback, NOT a + // product lookup. The owed-Dr falls back to the settlement mark × amount. + it('falls back to the settlement mark when the correlationId is non-integer (no product lookup)', async () => { + const findSpy = jest.spyOn(buyCryptoRepo, 'findOneBy'); + mockBatch([ + payoutOrder({ + id: 34, + context: PayoutOrderContext.BUY_CRYPTO, + correlationId: 'network-start-fee', // non-integer → owedCompletionChf returns undefined → mark fallback + amount: 1, + asset: { id: BTC_ASSET_ID, uniqueName: 'Bitcoin/BTC' }, + preparationFeeAmountChf: 0, + payoutFeeAmountChf: 0, + }), + ]); + await consumer.process(); + + const owed = leg(booked[0], 'LIABILITY/buyCrypto-owed'); + expect(findSpy).not.toHaveBeenCalled(); // non-integer correlationId never queries the product repo + expect(owed.amountChf).toBe(50000); // settlement mark × 1 BTC (the completion fallback) + expect(cents(booked[0].legs)).toBe(0); + }); + + // §4.5 BUY_FIAT_RETURN owed completion via the buyFiat repo (not buyCrypto), LIABILITY/buyFiat-owed bucket + it('books a BuyFiatReturn against LIABILITY/buyFiat-owed using the buyFiat completion CHF', async () => { + jest.spyOn(buyFiatRepo, 'findOneBy').mockResolvedValue({ amountInChf: 5000, totalFeeAmountChf: 50 } as any); // 4950 + mockBatch([ + payoutOrder({ + id: 35, + context: PayoutOrderContext.BUY_FIAT_RETURN, + correlationId: '900', + amount: 0.1, + asset: { id: BTC_ASSET_ID, uniqueName: 'Bitcoin/BTC' }, + preparationFeeAmountChf: 0, + payoutFeeAmountChf: 0, + }), + ]); + await consumer.process(); + + const owed = leg(booked[0], 'LIABILITY/buyFiat-owed'); + expect(owed.amountChf).toBe(4950); // buyFiat amountInChf − totalFeeAmountChf + expect(leg(booked[0], 'Bitcoin/BTC').amountChf).toBe(-5000); // settlement mark 50000 × 0.1 + expect(cents(booked[0].legs)).toBe(0); + }); + + // §4.5 withFxPlug needsMark short-circuit: when the wallet leg needsMark (no mark) the plug is NOT booked even + // though the CHF cents don't balance — the mark-to-market job revalues it later (§5.1 stage 3, no silent plug). + // Major B5 — never hand an unbalanceable set to bookTx: a needsMark wallet leg with no bridge on a mixed tx defers + // (owed-Dr 50000 completion + unvalued wallet → cannot net) rather than booking a silent phantom plug. + it('defers a mixed tx with a needsMark wallet leg and no bridge (no silent plug, B5)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + jest.spyOn(buyCryptoRepo, 'findOneBy').mockResolvedValue({ amountInChf: 50000, totalFeeAmountChf: 0 } as any); + jest + .spyOn(accountService, 'findByAssetId') + .mockResolvedValue(account('Unknown/XYZ', AccountType.ASSET, 'XYZ', 999)); // no mark → needsMark + mockBatch([ + payoutOrder({ + id: 36, + context: PayoutOrderContext.BUY_CRYPTO, + correlationId: '901', + amount: 1, + asset: { id: 999, uniqueName: 'Unknown/XYZ' }, + preparationFeeAmountChf: 0, + payoutFeeAmountChf: 0, + }), + ]); + await consumer.process(); + + expect(booked[0]).toBeUndefined(); // deferred: no booking, no silent phantom plug + expect(setSpy).not.toHaveBeenCalled(); // watermark NOT advanced + }); + + // §4.5 F6 fail-loud: a liability row where BOTH the completion CHF (non-integer correlationId → no product lookup) AND + // the settlement CHF (no historical mark for the payout asset) are undefined would book an unvalued needsMark leg on + // the assetId-less LIABILITY (CHF-denominated → the mark-to-market job can NEVER revalue it). When the youngest-mark + // bridge ALSO finds nothing (truly feedless), the consumer FAILS LOUD WITH an alarm (nothing booked, watermark + // unchanged) rather than a never-revaluable phantom leg that, mixed with a bridged wallet leg, would wedge every run. + it('fails loud (alarm) when the owed leg has no completion, no settlement, and a feedless payout asset (F6)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + const errorSpy = jest.spyOn((consumer as any).logger, 'error'); + const findSpy = jest.spyOn(buyCryptoRepo, 'findOneBy'); + // no mark anywhere: getMarkAt undefined (asset 999 not in markMap) AND getLatestMark undefined (default) → no bridge + jest + .spyOn(accountService, 'findByAssetId') + .mockResolvedValue(account('Unknown/XYZ', AccountType.ASSET, 'XYZ', 999)); + mockBatch([ + payoutOrder({ + id: 50, + context: PayoutOrderContext.BUY_CRYPTO, + correlationId: 'no-product', // non-integer → no product completion AND no cutover opening match + amount: 1, + asset: { id: 999, uniqueName: 'Unknown/XYZ' }, // feedless + preparationFeeAmountChf: 0, + payoutFeeAmountChf: 0, + }), + ]); + await consumer.process(); + + expect(findSpy).not.toHaveBeenCalled(); // non-integer correlationId → never queries the product repo + expect(booked).toHaveLength(0); // deferred: nothing booked (no unvalued LIABILITY leg on an assetId-less account) + expect(setSpy).not.toHaveBeenCalled(); // watermark NOT advanced → retry next run + expect(errorSpy.mock.calls.some((c) => /feedless/i.test(String(c[0])))).toBe(true); // F6 alarm logged + }); + + // §4.5 F6 bridge: no completion AND no historical settlement mark BUT a youngest mark exists → the settlement (and + // thus BOTH the owed LIABILITY leg and the wallet leg) is bridged from that mark so the tx balances now; the wallet + // ASSET leg keeps needsMark → the mark-to-market job corrects its basis later. No wedge, no unvalued liability leg. + it('bridges the owed leg from the latest mark when completion and historical settlement are missing (F6)', async () => { + jest.spyOn(markService, 'getLatestMark').mockResolvedValue(49000); // youngest available mark for asset 999 + jest + .spyOn(accountService, 'findByAssetId') + .mockResolvedValue(account('Unknown/XYZ', AccountType.ASSET, 'XYZ', 999)); + mockBatch([ + payoutOrder({ + id: 50, + context: PayoutOrderContext.BUY_CRYPTO, + correlationId: 'no-product', // non-integer → no product completion AND no cutover opening + amount: 1, + asset: { id: 999, uniqueName: 'Unknown/XYZ' }, // no historical mark, but getLatestMark bridges it + preparationFeeAmountChf: 0, + payoutFeeAmountChf: 0, + }), + ]); + await consumer.process(); + + const owed = leg(booked[0], 'LIABILITY/buyCrypto-owed'); + const wallet = leg(booked[0], 'Unknown/XYZ'); + expect(owed.amountChf).toBe(49000); // bridged settlement (no completion anchor) → owed leg valued + expect(owed.needsMark).toBe(false); // CHF-denominated liability: valued now, never a mark-to-market candidate + expect(wallet.amountChf).toBe(-49000); // wallet valued from the same bridge + expect(wallet.needsMark).toBe(true); // stays true → mark-to-market re-marks the wallet ASSET basis later + expect(cents(booked[0].legs)).toBe(0); // balances (owed +49000, wallet −49000) + }); + + // §4.5 completionChf (line 246): a product whose amountInChf is null → completion undefined → mark fallback. + it('falls back to the settlement mark when the product amountInChf is null (completion undefined)', async () => { + const findSpy = jest + .spyOn(buyCryptoRepo, 'findOneBy') + .mockResolvedValue({ amountInChf: null, totalFeeAmountChf: 5 } as any); // amountInChf null → completion undefined + mockBatch([ + payoutOrder({ + id: 51, + context: PayoutOrderContext.BUY_CRYPTO, + correlationId: '850', // integer → product IS looked up + amount: 1, + asset: { id: BTC_ASSET_ID, uniqueName: 'Bitcoin/BTC' }, + preparationFeeAmountChf: 0, + payoutFeeAmountChf: 0, + }), + ]); + await consumer.process(); + + const owed = leg(booked[0], 'LIABILITY/buyCrypto-owed'); + expect(findSpy).toHaveBeenCalled(); // integer correlationId → the product repo IS queried + expect(owed.amountChf).toBe(50000); // completion undefined → settlement mark × 1 BTC fallback + expect(leg(booked[0], 'Bitcoin/BTC').amountChf).toBe(-50000); + expect(cents(booked[0].legs)).toBe(0); + }); + + // §4.5 completionChf (line 247): totalFeeAmountChf null → (totalFeeAmountChf ?? 0) takes the 0 side → completion = + // amountInChf − 0. + it('computes the completion CHF as amountInChf − 0 when totalFeeAmountChf is null', async () => { + jest.spyOn(buyCryptoRepo, 'findOneBy').mockResolvedValue({ amountInChf: 49000, totalFeeAmountChf: null } as any); + mockBatch([ + payoutOrder({ + id: 52, + context: PayoutOrderContext.BUY_CRYPTO, + correlationId: '851', + amount: 1, + asset: { id: BTC_ASSET_ID, uniqueName: 'Bitcoin/BTC' }, + preparationFeeAmountChf: 0, + payoutFeeAmountChf: 0, + }), + ]); + await consumer.process(); + + const owed = leg(booked[0], 'LIABILITY/buyCrypto-owed'); + expect(owed.amountChf).toBe(49000); // amountInChf − (null ?? 0) + expect(leg(booked[0], 'Bitcoin/BTC').amountChf).toBe(-50000); // settlement mark + expect(leg(booked[0], 'INCOME/fx-revaluation').amountChf).toBe(1000); // 49000 − 50000 = −1000 → +1000 INCOME plug + expect(cents(booked[0].legs)).toBe(0); + }); + + // §4.5 cutoverOwedOpeningChf (line 256): MANUAL context has a LIABILITY_BUCKET (manual-debt) but NO CUTOVER_OWED_MARKER + // entry → cutoverOwedOpeningChf returns undefined immediately; MANUAL also has no product completion (non-integer + // correlationId) → the owed-Dr falls back to the settlement mark, booked against LIABILITY/manual-debt. + it('books a MANUAL payout against LIABILITY/manual-debt at the settlement mark (no cutover owed marker)', async () => { + const findSpy = jest.spyOn(buyCryptoRepo, 'findOneBy'); + jest.spyOn(settingService, 'get').mockResolvedValue('1234567'); // a cutover logId exists, but MANUAL has no marker + mockBatch([ + payoutOrder({ + id: 53, + context: PayoutOrderContext.MANUAL, + correlationId: 'manual-ref', // non-integer → no product completion either + amount: 1, + asset: { id: BTC_ASSET_ID, uniqueName: 'Bitcoin/BTC' }, + preparationFeeAmountChf: 0, + payoutFeeAmountChf: 0, + }), + ]); + await consumer.process(); + + const owed = leg(booked[0], 'LIABILITY/manual-debt'); + expect(owed).toBeDefined(); // booked against the MANUAL bucket + expect(findSpy).not.toHaveBeenCalled(); // non-integer correlationId → no product lookup + expect(owed.amountChf).toBe(50000); // settlement mark × 1 BTC (no cutover opening, no completion) + expect(leg(booked[0], 'Bitcoin/BTC').amountChf).toBe(-50000); + expect(cents(booked[0].legs)).toBe(0); + }); + + // §4.5 cutoverOwedOpeningChf (line 270): a cutover opening tx IS found but its owed leg amountChf is null → + // cutoverOwedOpeningChf returns undefined → the owed-Dr falls back to the §4.6/§4.7 completion CHF (not the opening). + it('falls back to the completion CHF when the matched cutover opening leg amountChf is null', async () => { + const cutoverLogId = '999000'; + jest.spyOn(settingService, 'get').mockResolvedValue(cutoverLogId); + jest.spyOn(buyCryptoRepo, 'findOneBy').mockResolvedValue({ amountInChf: 49000, totalFeeAmountChf: 0 } as any); // 49000 + jest.spyOn(ledgerTxRepo, 'findOne').mockImplementation(({ where }: any) => { + if (where?.sourceId === `${cutoverLogId}:buy_crypto-owed:790`) { + const owedAccount = account('LIABILITY/buyCrypto-owed', AccountType.LIABILITY, 'CHF'); + const openingLeg = Object.assign(new LedgerLeg(), { account: owedAccount, amountChf: null }); // null amountChf + return Promise.resolve(Object.assign(new LedgerTx(), { legs: [openingLeg] })); + } + return Promise.resolve(null); + }); + mockBatch([ + payoutOrder({ + id: 54, + context: PayoutOrderContext.BUY_CRYPTO_RETURN, + correlationId: '790', + amount: 1, + asset: { id: BTC_ASSET_ID, uniqueName: 'Bitcoin/BTC' }, + preparationFeeAmountChf: 0, + payoutFeeAmountChf: 0, + }), + ]); + await consumer.process(); + + const owed = leg(booked[0], 'LIABILITY/buyCrypto-owed'); + expect(owed.amountChf).toBe(49000); // opening leg amountChf null → undefined → completion CHF fallback + expect(leg(booked[0], 'Bitcoin/BTC').amountChf).toBe(-50000); // settlement mark + expect(leg(booked[0], 'INCOME/fx-revaluation').amountChf).toBe(1000); // 49000 − 50000 = −1000 → +1000 plug + expect(cents(booked[0].legs)).toBe(0); + }); + + // §4.5 appendDistinctFeeLegs (lines 299-306): a DISTINCT fee asset (≠ payout asset) with NO mark → its native Cr + // leg carries amountChf undefined + needsMark true + priceChf null; because a leg needsMark, withFxPlug books no plug. + // Major B5 — a distinct fee asset with no mark ANYWHERE: the tx is mixed (valued wallet/owed/network-fee + unvalued + // fee-asset leg) and the bridge finds no fee-asset mark → the row DEFERS instead of a silent phantom plug. + it('defers when a distinct fee asset has no mark anywhere (mixed tx, B5)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + jest.spyOn(buyCryptoRepo, 'findOneBy').mockResolvedValue({ amountInChf: 50000, totalFeeAmountChf: 0 } as any); + jest.spyOn(accountService, 'findByAssetId').mockImplementation((assetId: number) => { + if (assetId === 888) return Promise.resolve(account('NoMark/NOM', AccountType.ASSET, 'NOM', 888)); + return Promise.resolve(btcWallet); + }); + mockBatch([ + payoutOrder({ + id: 55, + context: PayoutOrderContext.BUY_CRYPTO, + correlationId: '860', + amount: 1, + asset: { id: BTC_ASSET_ID, uniqueName: 'Bitcoin/BTC' }, // payout asset has a mark + payoutFeeAsset: { id: 888, uniqueName: 'NoMark/NOM' }, // distinct fee asset, NO mark + payoutFeeAmount: 0.003, + payoutFeeAmountChf: 5, // networkFeeChf 5 > 0 → fee legs are appended + preparationFeeAmountChf: 0, + }), + ]); + await consumer.process(); + + expect(booked[0]).toBeUndefined(); // deferred: mixed tx with an unbridgeable fee asset + expect(setSpy).not.toHaveBeenCalled(); // watermark NOT advanced + }); + + // §4.5 payoutAssetFeeNative (line 318): a preparationFee in the payout asset itself folds into the wallet Cr leg + // (native + mark-based CHF), distinct from the payoutFee fold (line 319) covered above. + it('folds a payout-asset PREPARATION fee into the wallet Cr leg (preparationFeeAsset == payoutAsset)', async () => { + jest.spyOn(buyCryptoRepo, 'findOneBy').mockResolvedValue({ amountInChf: 49000, totalFeeAmountChf: 0 } as any); + mockBatch([ + payoutOrder({ + id: 56, + context: PayoutOrderContext.BUY_CRYPTO, + correlationId: '861', + amount: 1, + asset: { id: BTC_ASSET_ID, uniqueName: 'Bitcoin/BTC' }, + preparationFeeAsset: { id: BTC_ASSET_ID, uniqueName: 'Bitcoin/BTC' }, // prep fee == payout asset → folds in + preparationFeeAmount: 0.0002, + preparationFeeAmountChf: 10, + payoutFeeAmountChf: 0, + }), + ]); + await consumer.process(); + + const tx = booked[0]; + const btcLegs = tx.legs.filter((l) => l.account.name === 'Bitcoin/BTC'); + expect(btcLegs).toHaveLength(1); // prep fee folded into the single wallet leg, no separate fee-asset leg + expect(btcLegs[0].amount).toBe(-1.0002); // amount + prep fee folded native (line 318) + expect(btcLegs[0].amountChf).toBe(-50010); // settlement 50000 + folded fee 50000 × 0.0002 = 10 + expect(leg(tx, 'EXPENSE/network-fee').amountChf).toBe(10); // prep fee CHF still booked as the network-fee expense + expect(leg(tx, 'INCOME/fx-revaluation').amountChf).toBe(1000); // 49000 − 50010 + 10 = −1000 → +1000 plug + expect(cents(tx.legs)).toBe(0); + }); + + // §4.5 payoutAssetFeeNative (line 325): the payout asset has NO mark → the folded payout-asset fee CHF takes the 0 + // side (mark != null ? ... : 0) and the fold flags needsMark; mainChf undefined → wallet leg needsMark → no plug. + // Major B5 — a folded payout-asset fee where the payout asset has no mark ANYWHERE: the single (folded) wallet leg is + // unvalued and the owed completion CHF is valued → mixed tx, no bridge → the row DEFERS. + it('defers a folded-fee tx when the payout asset has no mark anywhere (B5)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + jest.spyOn(buyCryptoRepo, 'findOneBy').mockResolvedValue({ amountInChf: 49000, totalFeeAmountChf: 0 } as any); + jest + .spyOn(accountService, 'findByAssetId') + .mockResolvedValue(account('Unknown/XYZ', AccountType.ASSET, 'XYZ', 999)); // no mark + mockBatch([ + payoutOrder({ + id: 57, + context: PayoutOrderContext.BUY_CRYPTO, + correlationId: '862', + amount: 1, + asset: { id: 999, uniqueName: 'Unknown/XYZ' }, // no mark + preparationFeeAsset: { id: 999, uniqueName: 'Unknown/XYZ' }, // fee in the (unmarked) payout asset → folds in + preparationFeeAmount: 0.0002, + preparationFeeAmountChf: 0, // networkFeeChf 0 → no separate fee leg + payoutFeeAmountChf: 0, + }), + ]); + await consumer.process(); + + expect(booked[0]).toBeUndefined(); // deferred: unvalued folded wallet leg vs valued owed completion CHF + expect(setSpy).not.toHaveBeenCalled(); // watermark NOT advanced + }); + + // §4.5 withFxPlug (lines 351/355): a constellation with Σ legs > 0 → residual < 0 → EXPENSE/fx-revaluation (the + // negative residual side, complementing the INCOME side hit by the BuyCrypto payout test). completion > settlement. + it('books an EXPENSE/fx-revaluation plug when the residual is negative (completion > settlement)', async () => { + jest.spyOn(buyCryptoRepo, 'findOneBy').mockResolvedValue({ amountInChf: 51000, totalFeeAmountChf: 0 } as any); // 51000 + mockBatch([ + payoutOrder({ + id: 58, + context: PayoutOrderContext.BUY_CRYPTO, + correlationId: '863', + amount: 1, + asset: { id: BTC_ASSET_ID, uniqueName: 'Bitcoin/BTC' }, + preparationFeeAmountChf: 0, + payoutFeeAmountChf: 0, + }), + ]); + await consumer.process(); + + const tx = booked[0]; + expect(leg(tx, 'LIABILITY/buyCrypto-owed').amountChf).toBe(51000); // completion CHF + expect(leg(tx, 'Bitcoin/BTC').amountChf).toBe(-50000); // settlement mark + const plug = leg(tx, 'EXPENSE/fx-revaluation'); + expect(plug.amountChf).toBe(-1000); // Σ = 51000 − 50000 = +1000 → residual −1000 → EXPENSE/fx-revaluation + expect(leg(tx, 'INCOME/fx-revaluation')).toBeUndefined(); + expect(cents(tx.legs)).toBe(0); + }); +}); diff --git a/src/subdomains/core/accounting/services/consumers/__tests__/trading-order.consumer.spec.ts b/src/subdomains/core/accounting/services/consumers/__tests__/trading-order.consumer.spec.ts new file mode 100644 index 0000000000..a38036b8b4 --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/__tests__/trading-order.consumer.spec.ts @@ -0,0 +1,345 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { TestUtil } from 'src/shared/utils/test.util'; +import { TradingOrder } from 'src/subdomains/core/trading/entities/trading-order.entity'; +import { TradingOrderStatus } from 'src/subdomains/core/trading/enums'; +import { Repository } from 'typeorm'; +import { AccountType, LedgerAccount } from '../../../entities/ledger-account.entity'; +import { createCustomLedgerAccount } from '../../../entities/__mocks__/ledger-account.entity.mock'; +import { LedgerAccountService } from '../../ledger-account.service'; +import { LedgerBookingService, LedgerLegInput, LedgerTxInput } from '../../ledger-booking.service'; +import { LedgerMarkCache, LedgerMarkService } from '../../ledger-mark.service'; +import { TradingOrderConsumer } from '../trading-order.consumer'; + +const USDT_ASSET_ID = 501; // assetIn (mark = 0.9) +const TOKEN_ASSET_ID = 502; // assetOut (mark = 1.05) + +function account(name: string, type: AccountType, currency: string, assetId?: number): LedgerAccount { + return createCustomLedgerAccount({ id: Math.floor(Math.random() * 1e6), name, type, currency, assetId } as any); +} + +function tradingOrder(values: Record): TradingOrder { + return Object.assign(new TradingOrder(), { + id: 1, + updated: new Date('2026-06-07T00:00:00Z'), + status: TradingOrderStatus.COMPLETE, + txId: '0xabc', + assetIn: { id: USDT_ASSET_ID, uniqueName: 'Ethereum/USDT' }, + assetOut: { id: TOKEN_ASSET_ID, uniqueName: 'Ethereum/TOKEN' }, + amountIn: 967, + amountOut: 836, + txFeeAmountChf: 1, + swapFeeAmountChf: 2, + profitChf: 3, + ...values, + }); +} + +describe('TradingOrderConsumer', () => { + let consumer: TradingOrderConsumer; + let bookingService: LedgerBookingService; + let accountService: LedgerAccountService; + let markService: LedgerMarkService; + let settingService: SettingService; + let tradingOrderRepo: Repository; + + let booked: LedgerTxInput[]; + let accounts: Map; + let nextSeqValue: number; + + const usdtWallet = account('Ethereum/USDT', AccountType.ASSET, 'USDT', USDT_ASSET_ID); + const tokenWallet = account('Ethereum/TOKEN', AccountType.ASSET, 'TOKEN', TOKEN_ASSET_ID); + + // USDT mark = 0.9 → in CHF 967 × 0.9 = 870.30; TOKEN mark = 1.05 → out CHF 836 × 1.05 = 877.80 + const markMap = new Map([ + [USDT_ASSET_ID, [{ created: new Date('2026-01-01'), priceChf: 0.9 }]], + [TOKEN_ASSET_ID, [{ created: new Date('2026-01-01'), priceChf: 1.05 }]], + ]); + + beforeEach(async () => { + booked = []; + nextSeqValue = 0; + accounts = new Map(); + + bookingService = createMock(); + accountService = createMock(); + markService = createMock(); + settingService = createMock(); + tradingOrderRepo = createMock>(); + + jest.spyOn(bookingService, 'bookTx').mockImplementation((input: LedgerTxInput) => { + booked.push(input); + return Promise.resolve({} as any); + }); + jest.spyOn(bookingService, 'nextSeq').mockImplementation(() => Promise.resolve(nextSeqValue)); + + jest.spyOn(accountService, 'findByAssetId').mockImplementation((assetId: number) => { + const wallet = assetId === TOKEN_ASSET_ID ? tokenWallet : usdtWallet; + return Promise.resolve(wallet); + }); + jest + .spyOn(accountService, 'findOrCreate') + .mockImplementation((name: string, type: AccountType, currency: string) => { + const existing = accounts.get(name); + if (existing) return Promise.resolve(existing); + const acc = account(name, type, currency); + accounts.set(name, acc); + return Promise.resolve(acc); + }); + + jest.spyOn(markService, 'preload').mockResolvedValue(new LedgerMarkCache(markMap)); + jest.spyOn(markService, 'getLatestMark').mockResolvedValue(undefined); // B5: no bridge by default (tests opt in) + jest.spyOn(settingService, 'getObj').mockResolvedValue(undefined); + jest.spyOn(settingService, 'set').mockResolvedValue(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TestUtil.provideConfig(), + TradingOrderConsumer, + { provide: LedgerBookingService, useValue: bookingService }, + { provide: LedgerAccountService, useValue: accountService }, + { provide: LedgerMarkService, useValue: markService }, + { provide: SettingService, useValue: settingService }, + { provide: getRepositoryToken(TradingOrder), useValue: tradingOrderRepo }, + ], + }).compile(); + + consumer = module.get(TradingOrderConsumer); + }); + + const cents = (legs: LedgerLegInput[]) => legs.reduce((s, l) => s + Math.round((l.amountChf ?? 0) * 100), 0); + // forward id-scan returns the rows; the §4.12 content-change scan (where has `updated`, not `id`) returns [] so the + // forward path is asserted in isolation (its late-settling coverage is exercised for payout_order/exchange_tx). + const mockBatch = (rows: TradingOrder[]) => + jest + .spyOn(tradingOrderRepo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [] : rows)); + const leg = (tx: LedgerTxInput, name: string) => tx.legs.find((l) => l.account.name === name); + + it('is defined', () => { + expect(consumer).toBeDefined(); + }); + + // §4.9 — full arbitrage swap: ASSET out (markOut) / ASSET in (markIn) + network-fee + spread-DfxDex + INCOME/ + // trading + spread-arbitrage plug for the mark residual. Σ CHF = 0. + it('books an arbitrage swap with mark legs, persisted fee/profit legs, and a spread-arbitrage plug', async () => { + mockBatch([tradingOrder({ id: 10 })]); + await consumer.process(); + + expect(booked).toHaveLength(1); + const tx = booked[0]; + expect(tx.sourceType).toBe('trading_order'); + expect(tx.sourceId).toBe('10'); + + const out = leg(tx, 'Ethereum/TOKEN'); + const inLeg = leg(tx, 'Ethereum/USDT'); + expect(out.amount).toBe(836); + expect(out.amountChf).toBe(877.8); // 836 × 1.05 + expect(inLeg.amount).toBe(-967); + expect(inLeg.amountChf).toBe(-870.3); // 967 × 0.9 + + expect(leg(tx, 'EXPENSE/network-fee').amountChf).toBe(1); // txFeeAmountChf + expect(leg(tx, 'EXPENSE/spread-DfxDex').amountChf).toBe(2); // swapFeeAmountChf → swap venue spread + expect(leg(tx, 'INCOME/trading').amountChf).toBe(-3); // profitChf (Cr) + + // residual = −(877.8 − 870.3 + 1 + 2 − 3) = −7.5 → < 0 → EXPENSE/spread-arbitrage + expect(leg(tx, 'EXPENSE/spread-arbitrage').amountChf).toBe(-7.5); + expect(cents(tx.legs)).toBe(0); + }); + + // §4.9 — residual > 0 → INCOME/spread-arbitrage (sign-aware plug) + it('books an INCOME/spread-arbitrage plug when the mark residual is positive', async () => { + // out CHF 877.80, in CHF 870.30, no fees/profit → residual = −(877.8 − 870.3) = −7.5 (EXPENSE); + // make out smaller than in so residual is positive + mockBatch([ + tradingOrder({ + id: 11, + amountOut: 800, // 800 × 1.05 = 840 + amountIn: 967, // 967 × 0.9 = 870.30 + txFeeAmountChf: 0, + swapFeeAmountChf: 0, + profitChf: 0, + }), + ]); + await consumer.process(); + const tx = booked[0]; + // residual = −(840 − 870.30 + 0) = +30.30 → INCOME/spread-arbitrage + expect(leg(tx, 'INCOME/spread-arbitrage').amountChf).toBe(30.3); + expect(cents(tx.legs)).toBe(0); + }); + + // §4.9 Major R2-5 null-strategy: nullable persisted fee/profit fields → leg entirely omitted (no ?? 0) + it('omits a fee/profit leg when its persisted field is null (no ?? 0 default)', async () => { + mockBatch([tradingOrder({ id: 12, txFeeAmountChf: null, swapFeeAmountChf: null, profitChf: null })]); + await consumer.process(); + const tx = booked[0]; + expect(leg(tx, 'EXPENSE/network-fee')).toBeUndefined(); + expect(leg(tx, 'EXPENSE/spread-DfxDex')).toBeUndefined(); + expect(leg(tx, 'INCOME/trading')).toBeUndefined(); + // the whole valuation residual now lands in the arbitrage plug; Σ CHF still 0 + expect(cents(tx.legs)).toBe(0); + }); + + // §4.9 — a zero-valued profit still books an INCOME/trading leg (0 != null), Σ CHF unaffected + it('books a profitChf=0 leg (0 is a real value, not absent)', async () => { + mockBatch([tradingOrder({ id: 13, profitChf: 0 })]); + await consumer.process(); + expect(leg(booked[0], 'INCOME/trading')).toBeDefined(); + expect(leg(booked[0], 'INCOME/trading').amountChf).toBe(-0); + }); + + // §4.9 Blocker R1-3: the structural valuation spread is NEVER forced into ROUNDING — it goes to spread-arbitrage. + // A large mark residual (> 2 cent cap) would throw on the rounding cap if mis-routed to ROUNDING. + it('routes a >2-cent mark residual to spread-arbitrage, never ROUNDING (Blocker R1-3)', async () => { + mockBatch([tradingOrder({ id: 14 })]); // residual −7.50 = 750 cents >> 2-cent cap + await consumer.process(); + const plug = leg(booked[0], 'EXPENSE/spread-arbitrage'); + expect(plug).toBeDefined(); + expect(Math.abs(plug.amountChf)).toBeGreaterThan(0.02); // well above the ROUNDING cap + expect(leg(booked[0], 'ROUNDING')).toBeUndefined(); + }); + + // §4.9 appendArbitragePlug sub-cent branch (line 127): when Σ legs nets to within the rounding tolerance, NO + // spread-arbitrage plug is appended (the booking-service ROUNDING leg closes the sub-cent rest). Construct a swap + // where out CHF == in CHF + fees exactly so the residual is 0. + it('appends NO spread-arbitrage plug when the mark residual is sub-cent', async () => { + // out 900 × 1.05 = 945; in 1050 × 0.9 = 945 → Σ asset = 0; no fees/profit → residual 0 → sub-cent → no plug. + mockBatch([ + tradingOrder({ + id: 19, + amountOut: 900, + amountIn: 1050, + txFeeAmountChf: null, + swapFeeAmountChf: null, + profitChf: null, + }), + ]); + await consumer.process(); + const tx = booked[0]; + expect(leg(tx, 'Ethereum/TOKEN').amountChf).toBe(945); // 900 × 1.05 + expect(leg(tx, 'Ethereum/USDT').amountChf).toBe(-945); // 1050 × 0.9 + expect(leg(tx, 'EXPENSE/spread-arbitrage')).toBeUndefined(); // residual 0 → sub-cent → no plug + expect(leg(tx, 'INCOME/spread-arbitrage')).toBeUndefined(); + expect(cents(tx.legs)).toBe(0); + }); + + // §4.9 missing mark → ASSET leg needsMark, plug stays open (no silent plug without a mark) + // Major B5 — no mark ANYWHERE for the TOKEN leg: the mixed swap (valued USDT + fee/profit legs, unvalued TOKEN) cannot + // balance and the bridge finds no mark → the row DEFERS (nothing booked, watermark unchanged), never an unbalanceable + // set handed to bookTx. + it('defers (no booking, watermark unchanged) when an ASSET leg has no mark anywhere (B5)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + jest.spyOn(markService, 'preload').mockResolvedValue( + new LedgerMarkCache(new Map([[USDT_ASSET_ID, [{ created: new Date('2026-01-01'), priceChf: 0.9 }]]])), // TOKEN missing + ); + // markService.getLatestMark → undefined (auto-mock) → no bridge → resolveLegsOrDefer throws → failure-isolation + mockBatch([tradingOrder({ id: 15 })]); + await consumer.process(); + + expect(booked[0]).toBeUndefined(); // nothing booked (deferred) + expect(setSpy).not.toHaveBeenCalled(); // watermark NOT advanced → retry next run + }); + + // Major B5 bridge — a missing HISTORICAL TOKEN mark but a present current mark: the TOKEN leg is valued with the + // youngest available mark (bridge), needsMark STAYS true so the mark-to-market job corrects the basis later, and the + // swap balances via the spread-arbitrage plug — no wedge. + it('bridges a missing historical mark with the latest available mark and books balanced, needsMark stays true (B5)', async () => { + jest.spyOn(markService, 'preload').mockResolvedValue( + new LedgerMarkCache(new Map([[USDT_ASSET_ID, [{ created: new Date('2026-01-01'), priceChf: 0.9 }]]])), // TOKEN missing + ); + jest.spyOn(markService, 'getLatestMark').mockResolvedValue(1.05); // youngest available TOKEN mark + mockBatch([tradingOrder({ id: 15 })]); + await consumer.process(); + + const tx = booked[0]; + expect(leg(tx, 'Ethereum/TOKEN').amountChf).toBe(877.8); // bridged: 1.05 × 836 + expect(leg(tx, 'Ethereum/TOKEN').needsMark).toBe(true); // stays true → mark-to-market re-marks later + // balanced: +877.80 (out) − 870.30 (in) + 1 + 2 − 3 = +7.50 residual → EXPENSE/spread-arbitrage plug + expect(leg(tx, 'EXPENSE/spread-arbitrage').amountChf).toBe(-7.5); + const cents = tx.legs.reduce((s, l) => s + Math.round((l.amountChf ?? 0) * 100), 0); + expect(cents).toBe(0); + }); + + // §4.9 assetAccount throw (line 163): an asset with no CoA ledger account → throws → failure-isolation (watermark + // unchanged, nothing booked). + it('stops the batch and leaves the watermark when an asset has no ledger account (CoA bootstrap missing)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + jest.spyOn(accountService, 'findByAssetId').mockResolvedValue(undefined); // no CoA account for any asset + mockBatch([tradingOrder({ id: 20 })]); + + await consumer.process(); + + expect(booked).toHaveLength(0); + expect(setSpy).not.toHaveBeenCalled(); // throw → break before advancing + }); + + // §4.9 invalid swap: amountIn/amountOut null → skip (not booked) + it('skips an order with a null swap amount', async () => { + mockBatch([tradingOrder({ id: 16, amountOut: null })]); + await consumer.process(); + expect(booked).toHaveLength(0); + }); + + // §4.9 idempotency: an already-booked order (nextSeq > 0) is not re-booked. Adversarial dedup: re-running the + // consumer over the same trading_order must NOT double-book the swap. + it('is idempotent: skips an already-booked order (re-run, nextSeq > 0)', async () => { + nextSeqValue = 1; + mockBatch([tradingOrder({ id: 17 })]); + await consumer.process(); + expect(booked).toHaveLength(0); + }); + + it('advances the watermark after a successful batch', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + mockBatch([tradingOrder({ id: 18 })]); + await consumer.process(); + const written = JSON.parse(setSpy.mock.calls[0][1]); + expect(written.lastProcessedId).toBe(18); + }); + + it('no-ops on an empty batch', async () => { + mockBatch([]); + await consumer.process(); + expect(booked).toHaveLength(0); + }); + + // §4-header failure-isolation (catch in the batch loop): a booking error stops the batch, logs, and leaves the + // watermark unchanged so the row is retried next run. The first row books; the second throws → watermark = first id. + it('stops the batch on a booking error and advances the watermark only to the last success (failure-isolation)', async () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + const errSpy = jest.spyOn(consumer['logger'], 'error'); + jest.spyOn(bookingService, 'bookTx').mockImplementation((input: LedgerTxInput) => { + if (input.sourceId === '41') return Promise.reject(new Error('db down')); + booked.push(input); + return Promise.resolve({} as any); + }); + mockBatch([tradingOrder({ id: 40 }), tradingOrder({ id: 41 })]); + + await consumer.process(); + + expect(booked.map((b) => b.sourceId)).toEqual(['40']); // only the first booked + expect(errSpy).toHaveBeenCalledWith('Failed to book trading_order 41:', expect.any(Error)); + expect(JSON.parse(setSpy.mock.calls[0][1]).lastProcessedId).toBe(40); // NOT 41 → retry next run + }); + + // §6.3 covered-by-cutover-opening guard: a trading_order already settled (Complete + txId) at the cutover snapshot is + // in the aggregate ASSET opening; a post-cutover `updated` bump re-selects it in the content-change scan, but its seq0 + // must NOT be re-booked (double-count). id 42 <= boundary 100, not a hole → covered. + it('does NOT re-book a pre-cutover-settled trading_order re-selected by the content-change scan (covered)', async () => { + jest + .spyOn(settingService, 'getObj') + .mockImplementation((key: string) => + Promise.resolve(key === 'ledgerCutoverBoundary.trading_order' ? { boundaryId: 100, holeIds: [] } : undefined), + ); + const covered = tradingOrder({ id: 42 }); // Complete + txId → passes the settled filter, would book if not covered + jest + .spyOn(tradingOrderRepo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [covered] : [])); + + await consumer.process(); + + expect(booked).toHaveLength(0); // covered → no fresh seq0 + }); +}); diff --git a/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts b/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts new file mode 100644 index 0000000000..71e8c47d3d --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts @@ -0,0 +1,742 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Config } from 'src/config/config'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { Util } from 'src/shared/utils/util'; +import { BankTx, BankTxIndicator, BankTxType } from 'src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity'; +import { BankTxRepeat } from 'src/subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.entity'; +import { BankTxReturn } from 'src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.entity'; +import { Bank } from 'src/subdomains/supporting/bank/bank/bank.entity'; +import { IsNull, LessThan, MoreThan, Not, Repository } from 'typeorm'; +import { AccountType, LedgerAccount } from '../../entities/ledger-account.entity'; +import { LedgerLeg } from '../../entities/ledger-leg.entity'; +import { LedgerTx } from '../../entities/ledger-tx.entity'; +import { LedgerAccountService } from '../ledger-account.service'; +import { LedgerBookingService, LedgerLegInput, LedgerTxInput } from '../ledger-booking.service'; +import { LedgerMarkCache, LedgerMarkService } from '../ledger-mark.service'; +import { resolveLegsOrDefer } from './ledger-mark-bridge.helper'; +import { getLedgerWatermark, runContentChangeScan, setLedgerWatermark } from './ledger-watermark.helper'; + +const SOURCE_TYPE = 'bank_tx'; +const CUTOVER_SOURCE = 'cutover'; +const CUTOVER_LOG_ID_KEY = 'ledgerCutoverLogId'; +const BUY_CRYPTO_OWED = 'LIABILITY/buyCrypto-owed'; +const CHF = 'CHF'; +const LIABILITY_PREFIX = 'LIABILITY/'; + +// bank-side exchange route segment per type (§3.3 {ex}); SCB route is created lazily (§3.3 "new routes lazily") +const EXCHANGE_ROUTE: Partial> = { + [BankTxType.KRAKEN]: 'Kraken', + [BankTxType.SCRYPT]: 'Scrypt', + [BankTxType.SCB]: 'SCB', +}; + +interface BankContext { + asset?: Asset; // the bank's currency asset (EUR/CHF); undefined → untracked bank → SUSPENSE + currency: string; + bankName?: string; + tracked: boolean; + // the asset id whose FinancialDataLog mark values the bank/SUSPENSE leg (§4.2a). For a tracked bank it IS + // `asset.id`; for an UNTRACKED bank (Raiffeisen etc.) it is a representative same-currency tracked-bank asset id + // (the EUR mark is identical across all EUR bank assets) so the SUSPENSE leg is EUR-mark-valued and the §4.2a plug + // stays a small valuation residual instead of a full-value phantom. undefined → no mark → needsMark, no silent plug. + markAssetId?: number; +} + +/** + * Books all 19 BankTxType constellations (§4.2 + §4.2a). Pure observer: reads bank_tx (+ Bank for the + * accountIban→bank.asset lookup), writes only ledger_*. + * + * Non-CHF bank accounts get a 3-leg fx-revaluation plug (§4.2a Major R11-1); CHF accounts collapse to 2-leg. + * BUY_FIAT rows are skipped entirely (settlement booked by the BuyFiat consumer seq3, Blocker R4-1). + */ +@Injectable() +export class BankTxConsumer { + private readonly logger = new DfxLogger(BankTxConsumer); + + constructor( + private readonly settingService: SettingService, + private readonly bookingService: LedgerBookingService, + private readonly accountService: LedgerAccountService, + private readonly markService: LedgerMarkService, + @InjectRepository(BankTx) private readonly bankTxRepo: Repository, + @InjectRepository(Bank) private readonly bankRepo: Repository, + @InjectRepository(LedgerTx) private readonly ledgerTxRepo: Repository, + // read-only: resolve a chargeback bank_tx → its original BANK_TX_RETURN/REPEAT opening row (§4.2 opening-CHF anchor) + @InjectRepository(BankTxReturn) private readonly bankTxReturnRepo: Repository, + @InjectRepository(BankTxRepeat) private readonly bankTxRepeatRepo: Repository, + ) {} + + async process(): Promise { + const watermark = (await getLedgerWatermark(this.settingService, SOURCE_TYPE)) ?? { + lastProcessedId: 0, + lastReversalScan: new Date(0), + }; + + await this.processForward(watermark); + + // content-change scan (§4.12): re-classification of an already-booked bank_tx (the §4.12 textbook example + // GSHEET→BUY_CRYPTO via updateInternal; also amount / creditDebitIndicator changes, §4.2 reversal triggers, and + // a reset()→PENDING) recomputes the seq0 legs and, if they differ beyond the §4.12 tolerances, reverses the + // active tx + re-books the corrected legs. Runs ALSO when the forward batch is empty. Re-read the watermark in + // case the forward batch advanced lastProcessedId above. + const afterForward = (await getLedgerWatermark(this.settingService, SOURCE_TYPE)) ?? watermark; + await runContentChangeScan( + this.settingService, + SOURCE_TYPE, + afterForward, + this.bankTxRepo, + { buyCrypto: true }, + async (tx: BankTx) => { + const t = tx.bookingDate ?? tx.created; + await this.reconcileBooking( + tx, + // lookback so getMarkAt finds the latest mark at-or-before the row timestamp + await this.markService.preload(Util.daysBefore(2, t), t), + ); + }, + ); + } + + private async processForward(watermark: { lastProcessedId: number; lastReversalScan: Date }): Promise { + // DBIT only after 5 min (analog assignTransactions); settlement = bookingDate ?? created (§4.2) + const batch = await this.bankTxRepo.find({ + where: { id: MoreThan(watermark.lastProcessedId), created: LessThan(Util.minutesBefore(5)) }, + relations: { buyCrypto: true }, + order: { id: 'ASC' }, + take: Config.ledger.backfillBatchSize, + }); + if (!batch.length) return; + + // settlement can predate `created` (bookingDate ?? created feeds getMarkAt); anchor the mark lookback on the + // earliest settlement so getMarkAt finds a mark at-or-before every row's bookingDate (else the earliest row wedges) + const from = Util.daysBefore(2, new Date(Math.min(...batch.map((tx) => (tx.bookingDate ?? tx.created).getTime())))); + const to = Util.maxObj(batch, 'created').created; + const marks = await this.markService.preload(from, to); + + let lastProcessedId = watermark.lastProcessedId; + for (const tx of batch) { + try { + await this.book(tx, marks); + lastProcessedId = tx.id; + } catch (e) { + this.logger.error(`Failed to book bank_tx ${tx.id}:`, e); + break; // failure-isolation: leave watermark unchanged, retry next run (§4-header) + } + } + + if (lastProcessedId > watermark.lastProcessedId) { + await setLedgerWatermark(this.settingService, SOURCE_TYPE, { ...watermark, lastProcessedId }); + } + } + + private async book(tx: BankTx, marks: LedgerMarkCache): Promise { + const input = await this.buildSeq0Input(tx, marks); + if (!input) return; // skipped type (BUY_FIAT / TEST_FIAT_FIAT) + + await this.bookingService.bookTx(input); + } + + // §4.12 — recompute the seq0 legs for an already-booked row; reverse + re-book only if a trigger field changed + // (type/amount/creditDebitIndicator, §4.2). The booking service compares against the active tx within the §4.12 + // float tolerances and no-ops when nothing changed (idempotent re-scan). A row that is no longer bookable (type + // became BUY_FIAT/TEST_FIAT_FIAT) is reversed flat (its active legs inverted, no re-book). + private async reconcileBooking(tx: BankTx, marks: LedgerMarkCache): Promise { + const input = await this.buildSeq0Input(tx, marks); + if (!input) { + await this.bookingService.reverseActiveIfBooked(SOURCE_TYPE, `${tx.id}`, 0); // type became skip → flat reversal + return; + } + + await this.bookingService.reverseAndRebookIfChanged(input); + } + + // builds the seq0 LedgerTxInput for a bank_tx, or undefined for a skipped type (BUY_FIAT / TEST_FIAT_FIAT) + private async buildSeq0Input(tx: BankTx, marks: LedgerMarkCache): Promise { + const bookingDate = tx.bookingDate ?? tx.created; + const valueDate = tx.valueDate ?? bookingDate; + const ctx = await this.bankContext(tx); + const isCredit = tx.creditDebitIndicator === BankTxIndicator.CREDIT; + + const legs = await this.buildLegs(tx, ctx, bookingDate, marks, isCredit); + if (!legs) return undefined; // skipped type (BUY_FIAT / TEST_FIAT_FIAT) + + return { sourceType: SOURCE_TYPE, sourceId: `${tx.id}`, seq: 0, bookingDate, valueDate, legs }; + } + + private async buildLegs( + tx: BankTx, + ctx: BankContext, + bookingDate: Date, + marks: LedgerMarkCache, + isCredit: boolean, + ): Promise { + switch (tx.type) { + case BankTxType.BUY_FIAT: + // BUY_FIAT settlement booked by buy_fiat consumer seq3 (this row IS fiatOutput.bankTx); skip to avoid + // double-debiting ASSET/bank (Blocker R4-1) + return undefined; + + case BankTxType.TEST_FIAT_FIAT: + return undefined; // mapper=null → not booked (§4.2) + + case BankTxType.BUY_CRYPTO: + return this.buyCryptoLegs(tx, ctx, bookingDate, marks); + + case BankTxType.BUY_CRYPTO_RETURN: + return this.buyCryptoReturnLegs(tx, ctx, bookingDate, marks); + + case BankTxType.KRAKEN: + case BankTxType.SCRYPT: + case BankTxType.SCB: + return this.exchangeTransitLegs(tx, ctx, bookingDate, marks, isCredit); + + case BankTxType.INTERNAL: + return this.transferLegs(tx, ctx, bookingDate, marks, isCredit, `TRANSIT/bank↔bank/${ctx.currency}`); + + case BankTxType.FIAT_FIAT: + // single-row FX-credit (Muster 1): open side held in TRANSIT/internal-fx, mark-to-market job catches drift + return this.transferLegs(tx, ctx, bookingDate, marks, isCredit, `TRANSIT/internal-fx/${ctx.currency}`); + + case BankTxType.BANK_ACCOUNT_FEE: + return this.bankAccountFeeLegs(tx, ctx, bookingDate, marks); + + case BankTxType.EXTRAORDINARY_EXPENSES: + return this.expenseLegs(tx, ctx, bookingDate, marks, 'extraordinary'); + + case BankTxType.BANK_TX_RETURN: + return this.liabilityCreditLegs(tx, ctx, bookingDate, marks, 'bankTx-return'); + + case BankTxType.BANK_TX_RETURN_CHARGEBACK: + return this.chargebackLegs(tx, ctx, bookingDate, marks, 'bankTx-return'); + + case BankTxType.BANK_TX_REPEAT: + return this.liabilityCreditLegs(tx, ctx, bookingDate, marks, 'bankTx-repeat'); + + case BankTxType.BANK_TX_REPEAT_CHARGEBACK: + return this.liabilityDebitLegs(tx, ctx, bookingDate, marks, 'bankTx-repeat'); + + case BankTxType.CHECKOUT_LTD: + return this.checkoutLtdLegs(tx, ctx, bookingDate, marks); + + case BankTxType.GSHEET: + case BankTxType.PENDING: + return isCredit + ? this.liabilityCreditLegs(tx, ctx, bookingDate, marks, 'unattributed') + : this.suspenseLegs(tx, ctx, bookingDate, marks, isCredit); + + case BankTxType.UNKNOWN: + return this.suspenseLegs(tx, ctx, bookingDate, marks, isCredit); + + default: + this.logger.error(`Unhandled bank_tx type ${tx.type} on bank_tx ${tx.id}`); + return undefined; + } + } + + // --- TYPE LEG BUILDERS --- // + + // §4.2/§4.2a — BUY_CRYPTO CRDT: Dr ASSET/bank (or SUSPENSE/untracked) / Cr LIABILITY/buyCrypto-received + fx-plug + private async buyCryptoLegs( + tx: BankTx, + ctx: BankContext, + bookingDate: Date, + marks: LedgerMarkCache, + ): Promise { + const amountInChf = tx.buyCrypto?.amountInChf; // received-Cr base anchor (Major R4-4) + if (amountInChf == null) throw new Error(`bank_tx ${tx.id} BUY_CRYPTO without buyCrypto.amountInChf`); + + const bank = this.bankAssetLeg(ctx, +tx.amount, bookingDate, marks, await this.bankAccount(ctx)); // mark-consistent + const received = this.namedLeg(await this.liability('buyCrypto-received'), -amountInChf); + + return this.withFxPlug([bank, received], `bank_tx ${tx.id} buy_crypto`); + } + + // §4.2a — BUY_CRYPTO_RETURN DBIT: Dr LIABILITY/buyCrypto-owed (completion/opening CHF) / Cr ASSET/bank (EUR-mark × + // amount) + fx-plug. The owed-Dr MUST carry the SAME CHF the owed was OPENED with (§4.6 seq1 completion + // `amountInChf − totalFeeAmountChf`, or the cutover opening for a straddling row) — NOT the bank-mark return value + // (that would make withFxPlug always net to 0 → no plug → the Completion↔Return mark drift stays phantom on owed, + // owed never closes; Major R2-2-symmetry). + private async buyCryptoReturnLegs( + tx: BankTx, + ctx: BankContext, + bookingDate: Date, + marks: LedgerMarkCache, + ): Promise { + const bank = this.bankAssetLeg(ctx, -tx.amount, bookingDate, marks, await this.bankAccount(ctx)); + const owedChf = await this.buyCryptoOwedChf(tx); + const owed = this.namedLeg(await this.liability('buyCrypto-owed'), owedChf); + + // owed-Dr (completion/opening CHF) + bank-Cr (EUR-mark) → withFxPlug routes the mark/valuation drift to + // EXPENSE/INCOME fx-revaluation, owed closes cent-exact to 0 (§4.2a). CHF account → drift 0 → 2-leg, no plug. + return this.withFxPlug([owed, bank], `bank_tx ${tx.id} buy_crypto_return`); + } + + // the CHF the buyCrypto-owed was opened with: §4.6 completion (amountInChf − totalFeeAmountChf), or — for a + // cutover-straddling buy_crypto whose owed was opened by the cutover (§6.1 per-row marker) — the opening CHF. + private async buyCryptoOwedChf(tx: BankTx): Promise { + const openingChf = await this.cutoverOwedOpeningChf(tx.buyCrypto?.id); + if (openingChf != null) return openingChf; // cutover-straddling: debit the exact opening CHF anchor + + const amountInChf = tx.buyCrypto?.amountInChf; + if (amountInChf == null) throw new Error(`bank_tx ${tx.id} BUY_CRYPTO_RETURN without buyCrypto.amountInChf`); + return Util.round(amountInChf - (tx.buyCrypto?.totalFeeAmountChf ?? 0), 2); // completion CHF (additive null-strategy) + } + + // looks up the cutover per-row owed-opening leg CHF (§6.1 marker `${snapshotLogId}:buy_crypto-owed:${id}`); the + // prefix is the snapshot logId persisted in ledgerCutoverLogId. Returns undefined for a regular post-cutover row. + private async cutoverOwedOpeningChf(buyCryptoId: number | undefined): Promise { + if (buyCryptoId == null) return undefined; + const cutoverLogId = await this.settingService.get(CUTOVER_LOG_ID_KEY); + if (cutoverLogId == null) return undefined; + + const opening = await this.ledgerTxRepo.findOne({ + where: { sourceType: CUTOVER_SOURCE, sourceId: `${cutoverLogId}:buy_crypto-owed:${buyCryptoId}` }, + relations: { legs: { account: true } }, + }); + const leg = opening?.legs?.find((l: LedgerLeg) => l.account?.name === BUY_CRYPTO_OWED); + if (leg?.amountChf == null) return undefined; + + return Util.round(-leg.amountChf, 2); // the opening Cr leg is −openingChf → owed-Dr debits +openingChf + } + + // §4.2 KRAKEN/SCRYPT/SCB: Dr/Cr ASSET/bank ↔ TRANSIT/bank↔{ex}/{ccy} + private async exchangeTransitLegs( + tx: BankTx, + ctx: BankContext, + bookingDate: Date, + marks: LedgerMarkCache, + isCredit: boolean, + ): Promise { + return this.transferLegs( + tx, + ctx, + bookingDate, + marks, + isCredit, + `TRANSIT/bank↔${EXCHANGE_ROUTE[tx.type]}/${ctx.currency}`, + ); + } + + // bank ASSET ↔ TRANSIT same-currency transfer; both legs carry the same native+CHF, opposite signs + private async transferLegs( + tx: BankTx, + ctx: BankContext, + bookingDate: Date, + marks: LedgerMarkCache, + isCredit: boolean, + counterName: string, + ): Promise { + const bank = this.bankAssetLeg( + ctx, + isCredit ? +tx.amount : -tx.amount, + bookingDate, + marks, + await this.bankAccount(ctx), + ); + const counter = await this.accountService.findOrCreate(counterName, AccountType.TRANSIT, ctx.currency); + + return [ + bank, + { + account: counter, + amount: -bank.amount, + priceChf: bank.priceChf, + amountChf: bank.amountChf != null ? -bank.amountChf : undefined, + needsMark: bank.needsMark, + }, + ]; + } + + // §4.2 BANK_ACCOUNT_FEE: Dr EXPENSE/bank-fee (chargeAmountChf Pricing) / Cr ASSET/bank (EUR-mark × chargeAmount) + private async bankAccountFeeLegs( + tx: BankTx, + ctx: BankContext, + bookingDate: Date, + marks: LedgerMarkCache, + ): Promise { + const charge = tx.chargeAmount ?? tx.amount; + const bank = this.bankAssetLeg(ctx, -charge, bookingDate, marks, await this.bankAccount(ctx)); + const expenseChf = tx.chargeAmountChf ?? -(bank.amountChf ?? 0); // Pricing anchor + const expense = this.namedLeg(await this.expense('bank-fee'), expenseChf); + + return this.withFxPlug([expense, bank], `bank_tx ${tx.id} bank_fee`); // ≤2c → ROUNDING, >2c → fx-revaluation (§4.2-Note B-10) + } + + // §4.2 EXTRAORDINARY_EXPENSES: Dr EXPENSE/{name} / Cr ASSET/bank + private async expenseLegs( + tx: BankTx, + ctx: BankContext, + bookingDate: Date, + marks: LedgerMarkCache, + name: string, + ): Promise { + const bank = this.bankAssetLeg(ctx, -tx.amount, bookingDate, marks, await this.bankAccount(ctx)); + const expense = this.namedLeg(await this.expense(name), -(bank.amountChf ?? 0)); + + return [expense, bank]; + } + + // §4.2 BANK_TX_RETURN/BANK_TX_REPEAT/unattributed CRDT: Dr ASSET/bank (EUR-mark) / Cr LIABILITY/{bucket}, 2-leg. + // The bank-EUR ASSET leg is EUR-mark-valued (mark-consistent with the §7 feed); the CHF-denominated LIABILITY + // (§3.4 currency=CHF, assetId=NULL) carries the SAME CHF (EUR-Mark × amount) → both legs share one CHF source, the + // tx closes 2-leg with no FX plug. The liability balance is a FIXED CHF value and does NOT drift while open (it has + // no native FX exposure on the ledger account) — the §4.2-Note "mark-to-market corrects the EUR↔CHF drift" is a + // no-op for this CHF-stable balance (the mark-to-market job only re-marks asset-backed accounts, §5.3); any + // EUR-mark mismatch surfaces only at the chargeback/settlement leg as a residual (plugged there, §4.2-Note B-15). + private async liabilityCreditLegs( + tx: BankTx, + ctx: BankContext, + bookingDate: Date, + marks: LedgerMarkCache, + bucket: string, + ): Promise { + const bank = this.bankAssetLeg(ctx, +tx.amount, bookingDate, marks, await this.bankAccount(ctx)); + const liability = this.namedLeg(await this.liability(bucket), -(bank.amountChf ?? 0)); + + return [bank, liability]; + } + + // §4.2 BANK_TX_REPEAT_CHARGEBACK DBIT: Dr LIABILITY/{bucket} (opening CHF) / Cr ASSET/bank (EUR-mark) + fx-plug. + // The owed/repeat LIABILITY was OPENED at the EUR-mark of the BANK_TX_REPEAT credit (§4.2 line 357 + Minor R11-4) — + // its account is CHF-denominated (§3.4) and does NOT drift while open (the mark-to-market job only re-marks + // asset-backed accounts, §5.3). If the chargeback debits the liability with the chargeback-time bank-mark instead of + // the opening CHF, the EUR↔CHF mark drift between credit and chargeback stays as a PHANTOM on bankTx-repeat and the + // liability never closes to 0. Anchor the LIABILITY-Dr on the opening CHF; the bank↔opening mark drift goes to + // fx-revaluation via withFxPlug (the residual the §4.2 comment above said surfaces "plugged there", line 372). + private async liabilityDebitLegs( + tx: BankTx, + ctx: BankContext, + bookingDate: Date, + marks: LedgerMarkCache, + bucket: string, + ): Promise { + const bank = this.bankAssetLeg(ctx, -tx.amount, bookingDate, marks, await this.bankAccount(ctx)); + const liabilityChf = await this.chargebackOpeningChf(tx, bucket, [bank]); + const liability = this.namedLeg(await this.liability(bucket), liabilityChf); + + // opening-CHF Dr + EUR-mark bank Cr → withFxPlug routes the mark/valuation drift to fx-revaluation, liability + // closes cent-exact to 0. CHF account / no opening found → drift 0 → 2-leg, no plug (no behaviour change). + return this.withFxPlug([liability, bank], `bank_tx ${tx.id} liability_debit`); + } + + // §4.2 BANK_TX_RETURN_CHARGEBACK DBIT: Dr LIABILITY/{bucket} (opening CHF) / Cr ASSET/bank (EUR-mark) + // (+ EXPENSE/bank-fee chargeAmountChf). LIABILITY-Dr = the CHF the BANK_TX_RETURN credit OPENED the liability with + // (§4.2 line 356 + B-15), NOT the chargeback-time close value. Closing on the close value (−Σ(bank+fee)) makes the + // plug structurally always net to 0 → the EUR-mark drift between return and chargeback stays a phantom on + // bankTx-return and the liability never closes. Anchor on the opening CHF; the residual (opening-CHF ↔ bank-EUR-mark + // ↔ chargeAmountChf-Pricing) goes via withFxPlug to fx-revaluation (>2c) or ROUNDING (≤2c), §4.2-Note B-15. + private async chargebackLegs( + tx: BankTx, + ctx: BankContext, + bookingDate: Date, + marks: LedgerMarkCache, + bucket: string, + ): Promise { + const bank = this.bankAssetLeg(ctx, -tx.amount, bookingDate, marks, await this.bankAccount(ctx)); + const others: LedgerLegInput[] = [bank]; + + const feeChf = tx.chargeAmountChf; + if (feeChf != null && feeChf !== 0) others.push(this.namedLeg(await this.expense('bank-fee'), feeChf)); // Pricing anchor + + const liabilityChf = await this.chargebackOpeningChf(tx, bucket, others); + const legs: LedgerLegInput[] = [this.namedLeg(await this.liability(bucket), liabilityChf), ...others]; + + return this.withFxPlug(legs, `bank_tx ${tx.id} chargeback`); // ≤2c → ROUNDING, >2c → fx-revaluation + } + + // the CHF the LIABILITY/{bucket} was OPENED with (§4.2 line 356/357, B-15): looks up the original BANK_TX_RETURN / + // BANK_TX_REPEAT opening row (via BankTxReturn/BankTxRepeat.chargebackBankTx → its `.bankTx`), then the CHF on the + // bank_tx seq0 ledger opening leg for this {bucket}. For a cutover-straddling return/repeat (opened pre-cutover, + // chargeback post-cutover) the opening is NOT a bank_tx seq0 tx but the cutover per-row opening (§6.1 Major + // design-accounting, marker `:bank_tx-return|repeat:`) → check that too before the fallback. + // Falls back to closing against the OTHER legs (bank [+ fee]) only when no opening at all is found (an untracked + // chain / opening older than the 90d cutover lookback) — the prior self-balancing 2-leg behaviour. + private async chargebackOpeningChf(tx: BankTx, bucket: string, others: LedgerLegInput[]): Promise { + const openingBankTxId = await this.openingBankTxId(tx); + if (openingBankTxId != null) { + const openingChf = await this.openingLiabilityLegChf(openingBankTxId, bucket); + if (openingChf != null) return openingChf; + + const cutoverChf = await this.cutoverOpeningLiabilityChf(openingBankTxId, bucket); + if (cutoverChf != null) return cutoverChf; + } + + return -others.reduce((s, l) => s + (l.amountChf ?? 0), 0); // fallback: liability = −Σ(other legs) → 2-leg balance + } + + // resolves a chargeback bank_tx → the id of its original BANK_TX_RETURN/BANK_TX_REPEAT opening bank_tx. The link is + // BankTxReturn.chargebackBankTx / BankTxRepeat.chargebackBankTx === this chargeback row; the opening row is `.bankTx`. + private async openingBankTxId(chargebackTx: BankTx): Promise { + const ret = await this.bankTxReturnRepo.findOne({ + where: { chargebackBankTx: { id: chargebackTx.id } }, + relations: { bankTx: true }, + }); + if (ret?.bankTx?.id != null) return ret.bankTx.id; + + const rep = await this.bankTxRepeatRepo.findOne({ + where: { chargebackBankTx: { id: chargebackTx.id } }, + relations: { bankTx: true }, + }); + return rep?.bankTx?.id; + } + + // the CHF on the {bucket} LIABILITY leg of the opening bank_tx's seq0 ledger_tx (= |opening Cr leg|, the value the + // BANK_TX_RETURN/REPEAT credit opened the liability with). Returns undefined when the opening was never booked. + private async openingLiabilityLegChf(openingBankTxId: number, bucket: string): Promise { + const opening = await this.ledgerTxRepo.findOne({ + where: { sourceType: SOURCE_TYPE, sourceId: `${openingBankTxId}`, seq: 0 }, + relations: { legs: { account: true } }, + }); + const leg = opening?.legs?.find((l: LedgerLeg) => l.account?.name === `${LIABILITY_PREFIX}${bucket}`); + if (leg?.amountChf == null) return undefined; + + return Util.round(-leg.amountChf, 2); // opening Cr leg is −openingChf → chargeback Dr debits +openingChf + } + + // the CHF on the {bucket} LIABILITY leg of the CUTOVER per-row opening for this return/repeat (§6.1 Major + // design-accounting, marker `:bank_tx-return|repeat:`). A pre-cutover open BANK_TX_RETURN/ + // REPEAT (chargebackBankTx IS NULL at the snapshot) was opened by the cutover, NOT the bank_tx consumer, so its + // anchor lives under sourceType='cutover'. Returns undefined for a regular post-cutover chargeback (no cutover marker). + private async cutoverOpeningLiabilityChf(openingBankTxId: number, bucket: string): Promise { + const cutoverLogId = await this.settingService.get(CUTOVER_LOG_ID_KEY); + if (cutoverLogId == null) return undefined; + + const marker = bucket === 'bankTx-repeat' ? 'bank_tx-repeat' : 'bank_tx-return'; + const opening = await this.ledgerTxRepo.findOne({ + where: { sourceType: CUTOVER_SOURCE, sourceId: `${cutoverLogId}:${marker}:${openingBankTxId}` }, + relations: { legs: { account: true } }, + }); + const leg = opening?.legs?.find((l: LedgerLeg) => l.account?.name === `${LIABILITY_PREFIX}${bucket}`); + if (leg?.amountChf == null) return undefined; + + return Util.round(-leg.amountChf, 2); // cutover Cr leg is −openingChf → chargeback Dr debits +openingChf + } + + // §4.2 CHECKOUT_LTD CRDT: Dr ASSET/bank (net) + Dr EXPENSE/acquirer-fee (feeChf) / Cr ASSET/Checkout (gross). + // Convention (F2): the Checkout/{ccy} custody account carries native GROSS in CARD currency on every leg and + // amountChf = the corresponding gross CHF → amountChf ≈ mark × amount holds per leg AND on the account aggregate, + // so the mark-to-market job flushes only genuine FX drift. Booking the NET native against the GROSS CHF (pre-fix) + // broke that by exactly the acquirer fee → the mark-to-market job "corrected" it as a phantom */fx-revaluation + // posting and the margin report counted the fee twice (executionCosts AND fxPnl). + // The fee's card-currency native (feeNative) is resolved via a documented ladder — no silent assumption: + // 1. feeChf === 0 → feeNative = 0. This covers "no fee" AND "fee not yet CHF-priced" (chargeAmount set but + // chargeAmountChf still null): the leg then temporarily books net-as-gross; the later CHF-pricing bumps + // `updated` → the content-change scan reverses+rebooks the corrected legs (self-healing, §4.12); + // 2. chargeAmount persisted in the custody account's currency (chargeCurrency non-null and equal to the + // bank/settlement currency the custody account is denominated in) → use it directly; + // 3. bank mark available → feeNative = feeChf / bank.priceChf — the fee is only persisted in CHF, so its + // card-currency native is derived via the SAME mark that values the net settlement leg → amountChf ≈ + // mark × amount holds on the custody leg by construction; + // 4. else THROW (fail-loud): a non-zero fee with neither a same-currency chargeAmount nor a mark cannot be + // booked mark-consistently; the consumer's failure-isolation retries the row on the next run. + // No-mark case (bank.amountChf == null, feeNative resolved via ladder 1–2): the composed legs are routed through + // withFxPlug (F1) — a MIXED CHECKOUT_LTD (needsMark bank/custody ASSET legs + a CHF-valued acquirer-fee leg) is NEVER + // handed raw to bookTx: its Σ = feeChf ≠ 0 would trip the CHF-imbalance guard in appendRoundingLeg and wedge every + // later bank_tx head-of-line. Both ASSET legs carry an assetId → resolveLegsOrDefer bridges them with the youngest + // available mark so the tx balances (needsMark stays true, the mark-to-market job revalues the gross later); the + // fee-vs-mark valuation residual routes to fx-revaluation. A truly feedless asset (no bridge) defers the row. + private async checkoutLtdLegs( + tx: BankTx, + ctx: BankContext, + bookingDate: Date, + marks: LedgerMarkCache, + ): Promise { + const bank = this.bankAssetLeg(ctx, +tx.amount, bookingDate, marks, await this.bankAccount(ctx)); // net + const feeChf = tx.chargeAmountChf ?? 0; + const netChf = bank.amountChf; + const legs: LedgerLegInput[] = [bank]; + + if (feeChf !== 0) legs.push(this.namedLeg(await this.expense('acquirer-fee'), feeChf)); + + // fee native in the settlement (card) currency, resolved via the F2 ladder documented above + let feeNative: number; + if (feeChf === 0) { + feeNative = 0; + } else if (tx.chargeAmount != null && tx.chargeCurrency != null && tx.chargeCurrency === ctx.currency) { + // ladder 2: fee persisted in the custody account's currency (ctx.currency, the unit of every native amount on + // Checkout/{ccy}) — use it directly. The explicit non-null guard keeps a null==null match from smuggling an + // unverified unit in; a mismatching/unknown fee currency falls through to the mark bridge (ladder 3). + feeNative = tx.chargeAmount; + } else if (bank.priceChf != null) { + feeNative = Util.round(feeChf / bank.priceChf, 8); // ladder 3: CHF fee bridged via the settlement mark + } else { + throw new Error( + `bank_tx ${tx.id} CHECKOUT_LTD: non-zero acquirer fee (${feeChf} CHF) with neither a same-currency ` + + `chargeAmount nor a mark — the Checkout custody leg cannot be booked mark-consistently`, + ); + } + + // gross Checkout custody Cr-leg: native gross = net + feeNative (CARD currency, F2 convention); no own *Chf → + // CHF = net + fee (both CHF-known), else needsMark (Minor R3-5) + const grossChf = netChf != null ? netChf + feeChf : undefined; + legs.push({ + account: await this.checkoutAccount(ctx.currency), + amount: -Util.round(tx.amount + feeNative, 8), + priceChf: bank.priceChf, + amountChf: grossChf != null ? -grossChf : undefined, + needsMark: grossChf == null, + }); + + // F1: route through the shared bridge/plug path — with a historical mark the legs already net to 0 (no plug); a + // no-historical-mark row bridges the two ASSET legs via getLatestMark so the fee-carrying MIXED tx balances instead + // of wedging bookTx, and a feedless asset defers cleanly (§4.2a Major B5). The F2 gross convention stays intact: + // resolveLegsOrDefer only fills the needsMark legs' amountChf (native untouched, needsMark stays true). + return this.withFxPlug(legs, `bank_tx ${tx.id} checkout_ltd`); + } + + // §4.2 GSHEET/PENDING DBIT + UNKNOWN: Dr/Cr SUSPENSE ↔ ASSET/bank (both mark, §4.2-Note) + private async suspenseLegs( + tx: BankTx, + ctx: BankContext, + bookingDate: Date, + marks: LedgerMarkCache, + isCredit: boolean, + ): Promise { + const bank = this.bankAssetLeg( + ctx, + isCredit ? +tx.amount : -tx.amount, + bookingDate, + marks, + await this.bankAccount(ctx), + ); + const suspense = await this.accountService.findOrCreate('SUSPENSE', AccountType.SUSPENSE, CHF); + + return [ + bank, + { + account: suspense, + amount: -bank.amount, + priceChf: bank.priceChf, + amountChf: bank.amountChf != null ? -bank.amountChf : undefined, + needsMark: bank.needsMark, + }, + ]; + } + + // --- LEG/ACCOUNT HELPERS --- // + + // the bank ASSET leg native+CHF (mark-consistent). `account` is pre-resolved (ASSET/bank or SUSPENSE/untracked). + // The mark is resolved via ctx.markAssetId — for a tracked bank that is asset.id, for an UNTRACKED bank a + // representative same-currency tracked-bank asset id (§4.2a Raiffeisen-untracked variant) so the SUSPENSE leg is + // EUR-mark-valued and not a needsMark hole that would let withFxPlug create a full-value phantom plug. + private bankAssetLeg( + ctx: BankContext, + signedAmount: number, + bookingDate: Date, + marks: LedgerMarkCache, + account: LedgerAccount, + ): LedgerLegInput { + const mark = + ctx.currency === CHF ? 1 : ctx.markAssetId != null ? marks.getMarkAt(ctx.markAssetId, bookingDate) : undefined; + const amountChf = mark != null ? Util.round(mark * signedAmount, 2) : undefined; + + return { account, amount: signedAmount, priceChf: mark ?? null, amountChf, needsMark: amountChf == null }; + } + + // CHF-denominated counter leg (LIABILITY/INCOME/EXPENSE): native amount == CHF amount, priceChf = 1 + private namedLeg(account: LedgerAccount, amountChf: number): LedgerLegInput { + return { account, amount: amountChf, priceChf: 1, amountChf }; + } + + // appends an EXPENSE/INCOME fx-revaluation plug for a remaining CHF residual > tolerance (§4.2a); sub-cent → + // the booking-service ROUNDING leg closes it (no plug created). + // Major B5: an unmarked non-CHF bank leg is first bridged with the youngest available mark (resolveLegsOrDefer) so a + // mixed tx (bank-ASSET leg + CHF-anchored liability/expense leg) balances — needsMark stays true, the mark-to-market + // job corrects the basis later. A truly feedless bank asset (no bridge) defers the row instead of handing an + // unbalanceable set to bookTx at the fixed historical bookingDate (the §4.2a fix already resolves the EUR mark for + // untracked banks, so the bridge/defer only fires when the mark is truly absent everywhere). + private async withFxPlug(legs: LedgerLegInput[], ref: string): Promise { + if (!(await resolveLegsOrDefer(legs, this.markService, this.logger, ref))) return legs; + + const sumCents = legs.reduce((s, l) => s + Math.round(Util.round(l.amountChf ?? 0, 2) * 100), 0); + if (Math.abs(sumCents) <= Config.ledger.roundingToleranceCents) return legs; + + const residualChf = Util.round(-sumCents / 100, 2); + const account = residualChf >= 0 ? await this.income('fx-revaluation') : await this.expense('fx-revaluation'); + legs.push(this.namedLeg(account, residualChf)); + + return legs; + } + + // --- ACCOUNT RESOLUTION --- // + + private async bankAccount(ctx: BankContext): Promise { + if (ctx.tracked && ctx.asset) { + const account = await this.accountService.findByAssetId(ctx.asset.id); + if (!account) throw new Error(`Ledger account for asset ${ctx.asset.id} not found (CoA bootstrap missing)`); + return account; + } + + // untracked bank → SUSPENSE/untracked-bank-{name}-{ccy} (§4.2/§1.6 generic rule, not a Raiffeisen hardcode) + return this.accountService.findOrCreate( + `SUSPENSE/untracked-bank-${ctx.bankName ?? 'unknown'}-${ctx.currency}`, + AccountType.SUSPENSE, + ctx.currency, + ); + } + + private async checkoutAccount(currency: string): Promise { + // Checkout custody asset account (id 270/271/311 exist as asset rows, §1.1); resolved by name + const account = await this.accountService.findByName(`Checkout/${currency}`); + if (!account) throw new Error(`Ledger account Checkout/${currency} not found (CoA bootstrap missing)`); + return account; + } + + private liability(qualifier: string): Promise { + return this.accountService.findOrCreate(`LIABILITY/${qualifier}`, AccountType.LIABILITY, CHF); + } + + private expense(qualifier: string): Promise { + return this.accountService.findOrCreate(`EXPENSE/${qualifier}`, AccountType.EXPENSE, CHF); + } + + private income(qualifier: string): Promise { + return this.accountService.findOrCreate(`INCOME/${qualifier}`, AccountType.INCOME, CHF); + } + + // resolves the per accountIban bank → asset/currency/tracked state (§4.2/§1.6 generic untracked-bank rule) + private async bankContext(tx: BankTx): Promise { + if (tx.accountIban) { + const bank = await this.bankRepo.findOne({ where: { iban: tx.accountIban }, relations: { asset: true } }); + if (bank) { + const tracked = bank.asset != null; + return { + asset: bank.asset, + currency: bank.currency, + bankName: bank.name, + tracked, + // tracked → mark via its own asset; untracked → a representative same-currency tracked-bank asset (§4.2a) + markAssetId: tracked ? bank.asset.id : await this.currencyMarkAssetId(bank.currency), + }; + } + } + + const currency = tx.currency ?? CHF; // no bank match → untracked, currency from the tx + return { + asset: undefined, + currency, + bankName: tx.bankName ?? 'unknown', + tracked: false, + markAssetId: await this.currencyMarkAssetId(currency), // §4.2a: value the SUSPENSE leg with the currency mark + }; + } + + // a representative tracked-bank asset id for `currency` whose FinancialDataLog mark values an UNTRACKED-bank leg + // (§4.2a Raiffeisen-untracked variant). The EUR mark is identical across all EUR bank assets, so any tracked bank + // of the same currency supplies it; CHF needs no mark (priceChf=1). Returns undefined when no tracked bank of that + // currency exists → the leg stays needsMark and withFxPlug books no silent plug (§5.1 stage 3). + private async currencyMarkAssetId(currency: string): Promise { + if (currency === CHF) return undefined; // CHF leg is valued 1:1, no mark lookup needed + + const trackedBank = await this.bankRepo.findOne({ + where: { currency, asset: { id: Not(IsNull()) } }, + relations: { asset: true }, + order: { id: 'ASC' }, // deterministic representative pick + }); + + return trackedBank?.asset?.id; + } +} diff --git a/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts b/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts new file mode 100644 index 0000000000..63fa35f04f --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts @@ -0,0 +1,458 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Config } from 'src/config/config'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { Util } from 'src/shared/utils/util'; +import { BuyCrypto } from 'src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity'; +import { MoreThan, Repository } from 'typeorm'; +import { AccountType, LedgerAccount } from '../../entities/ledger-account.entity'; +import { LedgerLeg } from '../../entities/ledger-leg.entity'; +import { LedgerTx } from '../../entities/ledger-tx.entity'; +import { LedgerAccountService } from '../ledger-account.service'; +import { LedgerBookingService, LedgerLegInput, LedgerTxInput } from '../ledger-booking.service'; +import { + getLedgerWatermark, + isUnpricedAtCutover, + runContentChangeScan, + setLedgerWatermark, +} from './ledger-watermark.helper'; + +const SOURCE_TYPE = 'buy_crypto'; +const CRYPTO_INPUT_SOURCE = 'crypto_input'; +const BANK_TX_SOURCE = 'bank_tx'; +const CUTOVER_SOURCE = 'cutover'; +const CUTOVER_LOG_ID_KEY = 'ledgerCutoverLogId'; +const CUTOVER_SNAPSHOT_DATE_KEY = 'ledgerCutoverSnapshotDate'; +const CHF = 'CHF'; +const PAYMENT_LINK = 'LIABILITY/paymentLink'; + +/** + * Books the buy_crypto completion chain (§4.6, D14 A). Pure observer: reads buy_crypto (+ ledger_tx for the + * seq0/opening gate), writes only ledger_*. + * + * It does NOT book the crypto-input leg (CryptoInput consumer is the single booker, §4.1) — only the Card input + * (Checkout) at seq0, and at seq1 the cent-exact 4-leg completion tx: fee against `received` + reclassification + * received→owed. It skips actualPayoutFeeAmount (network fee booked by the payout_order consumer, §4.5). + * Owed-straddling rows (per-row cutover owed opening, §6.1) are skipped entirely — the reclassification is anchored + * in that opening and the payout_order consumer (§4.5) closes owed against it. + */ +@Injectable() +export class BuyCryptoConsumer { + private readonly logger = new DfxLogger(BuyCryptoConsumer); + + constructor( + private readonly settingService: SettingService, + private readonly bookingService: LedgerBookingService, + private readonly accountService: LedgerAccountService, + @InjectRepository(BuyCrypto) private readonly buyCryptoRepo: Repository, + @InjectRepository(LedgerTx) private readonly ledgerTxRepo: Repository, + ) {} + + async process(): Promise { + const watermark = (await getLedgerWatermark(this.settingService, SOURCE_TYPE)) ?? { + lastProcessedId: 0, + lastReversalScan: new Date(0), + }; + + await this.processForward(watermark); + + // content-change scan (§4.12 / §6.3): catches late-settling cutover-straddling rows (id <= watermark, completion + // set post-cutover) the forward id-scan skips — runs ALSO when the forward batch is empty. The booker is + // idempotent (per-seq alreadyBooked), so a row in both scans is booked once. Re-read the watermark in case the + // forward batch advanced lastProcessedId above. + const afterForward = (await getLedgerWatermark(this.settingService, SOURCE_TYPE)) ?? watermark; + await runContentChangeScan( + this.settingService, + SOURCE_TYPE, + afterForward, + this.buyCryptoRepo, + { checkoutTx: true, cryptoInput: { paymentLinkPayment: true }, bankTx: true }, + async (bc: BuyCrypto) => { + // forward book() FIRST: append any newly-settled seqs / forward-book a late-settling row the id-watermark + // skipped. A gate-blocked run returns false → distinguish a pre-cutover-settled row (value in the aggregate + // opening, C2) → SKIP+advance, from a genuinely post-cutover row whose received opening is not yet booked → + // THROW (cursor stays put, retry). Returning here for a gate-blocked row ALSO avoids resolving+creating the + // value-coupled accounts below for a never-booked row (no phantom LIABILITY/buyCrypto-owed). + if (!(await this.book(bc))) { + if (await this.preCutoverSettled(bc)) return; + // F2: an open row unpriced at the cutover (amountInChf NULL) never got a per-row received opening — its gross + // is in the aggregate ASSET opening (the Checkout collateral feed). Its received gate can NEVER open, so + // SKIP+advance with an ERROR alarm instead of throwing forever (head-of-line wedge); buildCardInputSeq0 also + // skips it so the gross is not double-booked once the row is priced. Manual re-anchoring is traced by the alarm. + if (await isUnpricedAtCutover(this.settingService, SOURCE_TYPE, bc.id)) { + this.logger.error( + `buy_crypto ${bc.id} was unpriced at the cutover (value in the aggregate opening) — skipping its per-row chain (F2 alarm)`, + ); + return; + } + throw new Error(`buy_crypto ${bc.id} content-change scan gate-blocked — retry next run (§4.7 G-a)`); + } + + // §6.1 owed-straddling: this consumer booked NOTHING for such a row (the reclassification is anchored in the + // cutover owed opening, closed by the payout_order consumer) — building the reverse/rebook chain below would + // findOrCreate phantom accounts for a never-booked row. Mirrors the buy-fiat callback's owed-straddling skip. + if (await this.hasCutoverOwedOpening(bc.id)) return; + + // then §4.12 + M3: on a content change, reverse+rebook the value-coupled Card-input seq0 / completion seq1 chain + // (both carry the amountInChf base). Reversing ONLY seq0 while seq1 is booked would leave `received` non-zero + // (the shared liability never closes to 0); the chain method reverses the whole ACTIVE chain atomically and + // no-ops when nothing changed (incl. the seqs book() just forward-booked). buildCardInputSeq0 is undefined for a + // non-Card / pre-cutover-settled row and buildCompletionSeq1 for a not-yet-complete row → each drops out. + const chain = (await Promise.all([this.buildCardInputSeq0(bc), this.buildCompletionSeq1(bc)])).filter( + (i): i is LedgerTxInput => i != null, + ); + if (chain.length) await this.bookingService.reverseAndRebookChainIfChanged(chain); + }, + ); + } + + private async processForward(watermark: { lastProcessedId: number; lastReversalScan: Date }): Promise { + const batch = await this.buyCryptoRepo.find({ + where: { id: MoreThan(watermark.lastProcessedId) }, + relations: { checkoutTx: true, cryptoInput: { paymentLinkPayment: true }, bankTx: true }, + order: { id: 'ASC' }, + take: Config.ledger.backfillBatchSize, + }); + if (!batch.length) return; + + let lastProcessedId = watermark.lastProcessedId; + for (const bc of batch) { + try { + const advance = await this.book(bc); + // a gate-blocked seq1 must NOT advance the watermark past this row (retry next run, §4.7 G-a) + if (!advance) break; + lastProcessedId = bc.id; + } catch (e) { + this.logger.error(`Failed to book buy_crypto ${bc.id}:`, e); + break; // failure-isolation: leave watermark unchanged, retry next run (§4-header) + } + } + + if (lastProcessedId > watermark.lastProcessedId) { + await setLedgerWatermark(this.settingService, SOURCE_TYPE, { ...watermark, lastProcessedId }); + } + } + + // returns false when seq1 is gate-blocked (received not yet opened) → the caller must not advance past this row + private async book(bc: BuyCrypto): Promise { + // §4.6/§6.1 owed-straddling: the cutover booked this row's per-row owed opening and the payout_order consumer + // (§4.5) closes owed against exactly that anchor — this consumer must book NEITHER seq0 NOR seq1. Booking them + // would wedge a bank/crypto-funded row (receivedOpened never true → the content-change scan throws forever) or + // double-count a Card row (seq0+seq1 on top of the owed opening) → skip both and advance the watermark. + if (await this.hasCutoverOwedOpening(bc.id)) return true; + + await this.bookCardInput(bc); // seq0 (Card only; bank/crypto inputs have their own single booker) + + if (!bc.isComplete) return true; // completion not settled yet — nothing more to do, advance + if (await this.alreadyBooked(bc.id, 1)) return true; // seq1 already booked + + // gate (§4.6/§4.7 G-a/G-b): seq1 is bookable only once `received` has been opened + if (!(await this.receivedOpened(bc))) return false; + + await this.bookCompletion(bc); // seq1 + return true; + } + + // §4.6 seq0 — Card input only: Dr ASSET/Checkout/{ccy} (native = card-currency GROSS, amountChf = gross CHF) / + // Cr LIABILITY/buyCrypto-received (= amountInChf). Convention (F2): Checkout/{ccy} carries native gross in card + // currency on EVERY leg with amountChf = the corresponding gross CHF → amountChf ≈ mark × amount holds per leg and + // on the account aggregate, so the mark-to-market job books only genuine FX drift, never a phantom. + // Bank input → BankTx consumer; crypto input → CryptoInput consumer (§4.1 single booker). + private async bookCardInput(bc: BuyCrypto): Promise { + if (await this.alreadyBooked(bc.id, 0)) return; + + const input = await this.buildCardInputSeq0(bc); + if (input) await this.bookingService.bookTx(input); + } + + // builds the seq0 Card-input LedgerTxInput, or undefined for a non-Card input / missing amountInChf (the bank / + // crypto inputs are booked by their own single booker, §4.1) + private async buildCardInputSeq0(bc: BuyCrypto): Promise { + if (!bc.checkoutTx) return undefined; // not a Card input → seq0 not this consumer's job + if (bc.amountInChf == null) return undefined; + // F2: a Card row unpriced at the cutover (amountInChf NULL then) already has its card-currency gross in the + // aggregate ASSET opening (the Checkout collateral feed) — once it is priced post-cutover, re-booking the forward + // seq0 would re-debit that gross on Checkout/{ccy} a SECOND time (double-count). The pinned id suppresses the seq0. + if (await isUnpricedAtCutover(this.settingService, SOURCE_TYPE, bc.id)) return undefined; + // §6.3: a Card row already settled at the cutover (outputDate ≤ snapshot) is captured by the aggregate opening + // (openAssets) — re-booking its seq0 Checkout inflow would double-count. Only post-cutover Card rows get a + // forward seq0. + if (await this.settledBeforeCutover(bc)) return undefined; + // §6.1 (Major B1): an OPEN-at-cutover Card row has BOTH its card-currency gross in the aggregate ASSET opening + // (openAssets → liquidityBalance.total carries the Checkout.com collateral feed of auto-captured card charges) AND + // its received LIABILITY opened by the cutover per-row marker `${snapshotLogId}:buy_crypto:${id}`. Re-booking the + // forward seq0 would re-debit that gross on Checkout/{ccy} a second time (permanent phantom). Skip when the marker + // exists — the completion seq1 closes received against the cutover opening; only a post-cutover Card row (no + // marker) gets a forward seq0 as its own opening. + if (await this.hasCutoverReceivedOpening(bc.id)) return undefined; + + // FAIL-LOUD (F2): a priced Card row (amountInChf set) without its card-currency gross (inputReferenceAmount — + // the CHECKOUT branch of pendingInputAmount() carries the card gross there) is a data error. Booking native 0 or + // a CHF native on the card-currency Checkout/{ccy} custody account would silently corrupt the account's native + // unit — throw instead; the consumer's failure-isolation retries the row on the next run. + if (bc.inputReferenceAmount == null || bc.inputReferenceAmount === 0) { + throw new Error(`buy_crypto ${bc.id} Card input has amountInChf but no card-currency inputReferenceAmount`); + } + + const checkout = await this.checkoutAccount(bc.checkoutTx.currency); + const received = await this.liability('buyCrypto-received'); + + return { + sourceType: SOURCE_TYPE, + sourceId: `${bc.id}`, + seq: 0, + bookingDate: bc.created, + valueDate: bc.created, + legs: [ + // custody Dr: native = card-currency gross; priceChf = the implied CHF-per-card-unit conversion of THIS row + // (amountInChf / inputReferenceAmount) → amountChf = priceChf × amount holds exactly on the leg (F2) + { + account: checkout, + amount: bc.inputReferenceAmount, + priceChf: Util.round(bc.amountInChf / bc.inputReferenceAmount, 8), + amountChf: bc.amountInChf, + needsMark: false, + }, + this.chfLeg(received, -bc.amountInChf), // LIABILITY is CHF-denominated: native == CHF (correct as is) + ], + }; + } + + /** + * §4.6 seq1 — cent-exact 4-leg completion tx (Major R4-3): (a) Fee against `received` + * Dr received +totalFeeAmountChf / Cr INCOME/fee-{buyCrypto|paymentLink} −totalFeeAmountChf; (b) reclassification + * Dr received +(amountInChf−totalFeeAmountChf) / Cr buyCrypto-owed. After seq1 `received` = 0, `owed` = + * −(amountInChf−totalFeeAmountChf) (cleared later by the payout_order consumer, §4.5). + */ + private async bookCompletion(bc: BuyCrypto): Promise { + const input = await this.buildCompletionSeq1(bc); + if (input) await this.bookingService.bookTx(input); + } + + // builds the seq1 completion LedgerTxInput, or undefined for a not-yet-complete row (nothing to book / no chain link) + private async buildCompletionSeq1(bc: BuyCrypto): Promise { + if (!bc.isComplete) return undefined; + if (bc.amountInChf == null) throw new Error(`buy_crypto ${bc.id} is complete but amountInChf is null`); + + const fee = bc.totalFeeAmountChf ?? 0; // additive null-strategy (§5.1): missing fee = 0 + const reclassChf = Util.round(bc.amountInChf - fee, 2); + const owed = await this.liability('buyCrypto-owed'); + + // §4.6 F3: a paymentLink-funded buy_crypto's crypto_input seq0 (§4.4 isPayment) credited LIABILITY/paymentLink — + // NOT buyCrypto-received. Clearing buyCrypto-received here (which was never credited for this row) drifts both + // liabilities apart unbounded (equity-neutral, parity-blind). Clear paymentLink against the seq0 opening value + // instead. Only when that seq0 opening exists; a cutover-straddling paymentLink row (no seq0, received opened as + // buyCrypto-received by the cutover) falls through to the received clearing below (unchanged, no drift). + if (bc.paymentLinkPayment) { + const openingChf = await this.paymentLinkOpeningChf(bc); + if (openingChf != null) return this.buildPaymentLinkCompletionSeq1(bc, fee, reclassChf, owed, openingChf); + } + + const received = await this.liability('buyCrypto-received'); + // paymentLink-linked → the fee is INCOME/fee-paymentLink (§4.6); else INCOME/fee-buyCrypto + const feeIncome = await this.income(bc.paymentLinkPayment ? 'fee-paymentLink' : 'fee-buyCrypto'); + + const legs: LedgerLegInput[] = [ + this.chfLeg(received, fee), // (a) Dr received +fee + this.chfLeg(feeIncome, -fee), // Cr INCOME −fee + this.chfLeg(received, reclassChf), // (b) Dr received +(amountInChf−fee) + this.chfLeg(owed, -reclassChf), // Cr owed −(amountInChf−fee) + ]; + + return { + sourceType: SOURCE_TYPE, + sourceId: `${bc.id}`, + seq: 1, + bookingDate: bc.outputDate ?? bc.updated, + valueDate: bc.outputDate ?? bc.updated, + legs, + }; + } + + /** + * §4.6 F3 paymentLink completion — debits LIABILITY/paymentLink by exactly the seq0 opening value (Mark×amount, the + * gross crypto received) so paymentLink closes cent-exact to 0: (a) fee → INCOME/fee-paymentLink; (b) reclassification + * → buyCrypto-owed (= amountInChf − fee, closed later by the payout_order consumer); (c) the venue-sell-spread + * (opening − amountInChf) → the fx-revaluation plug (valuation drift, NOT income). Total paymentLink debit = + * fee + reclass + venueSpread = openingChf → closes the seq0 −openingChf credit to 0. Mirrors buy-fiat bookPaymentLinkFee. + */ + private async buildPaymentLinkCompletionSeq1( + bc: BuyCrypto, + fee: number, + reclassChf: number, + owed: LedgerAccount, + openingChf: number, + ): Promise { + const paymentLink = await this.paymentLinkAccount(); + const feeIncome = await this.income('fee-paymentLink'); + const venueSpread = Util.round(openingChf - (bc.amountInChf ?? 0), 2); // crypto received value vs the amountInChf base + + const legs: LedgerLegInput[] = [ + this.chfLeg(paymentLink, fee), // (a) Dr paymentLink +fee + this.chfLeg(feeIncome, -fee), // Cr INCOME/fee-paymentLink −fee + this.chfLeg(paymentLink, reclassChf), // (b) Dr paymentLink +(amountInChf−fee) + this.chfLeg(owed, -reclassChf), // Cr owed −(amountInChf−fee) + ]; + if (venueSpread !== 0) { + legs.push(this.chfLeg(paymentLink, venueSpread)); // (c) Dr paymentLink +venueSpread → total debit reaches openingChf + const fx = venueSpread >= 0 ? await this.income('fx-revaluation') : await this.expense('fx-revaluation'); + legs.push(this.chfLeg(fx, -venueSpread)); + } + + return { + sourceType: SOURCE_TYPE, + sourceId: `${bc.id}`, + seq: 1, + bookingDate: bc.outputDate ?? bc.updated, + valueDate: bc.outputDate ?? bc.updated, + legs, + }; + } + + // §4.6 F3 — the crypto_input seq0 paymentLink opening CHF (= −leg.amountChf) for a paymentLink-funded buy_crypto, or + // undefined when no seq0 paymentLink leg exists (a cutover-straddling row financed pre-cutover). Mirrors the buy-fiat + // paymentLinkOpeningChf gate/value. + private async paymentLinkOpeningChf(bc: BuyCrypto): Promise { + const cryptoInputId = bc.cryptoInput?.id; + if (cryptoInputId == null) return undefined; + + const seq0 = await this.ledgerTxRepo.findOne({ + where: { sourceType: CRYPTO_INPUT_SOURCE, sourceId: `${cryptoInputId}`, seq: 0 }, + relations: { legs: { account: true } }, + }); + const leg = seq0?.legs?.find((l: LedgerLeg) => l.account?.name === PAYMENT_LINK); + if (leg?.amountChf == null) return undefined; + + return Util.round(-leg.amountChf, 2); // seq0 Cr leg is −Mark×amount → opening value is its absolute CHF + } + + // --- GATE (§4.6/§4.7 G-a/G-b) --- // + + // received is opened either by the seq0 CryptoInput ledger_tx (G-a, post-cutover) or by the cutover opening + // (G-b, cutover-straddling — the pre-cutover-settled crypto_input never gets a seq0 ledger_tx) + private async receivedOpened(bc: BuyCrypto): Promise { + // Card input (§6.1 Major B1): received is opened by EITHER the cutover per-row marker (G-b, an OPEN-at-cutover Card + // row whose forward seq0 is skipped — its gross is already in the aggregate opening) OR this consumer's own forward + // seq0 (G-a analog, a post-cutover Card row). A row already settled at the cutover (outputDate ≤ snapshot) has + // NEITHER — its value is fully in the aggregate opening and no per-row marker exists → gate stays closed, so seq1 + // never Dr's received without a matching −opening (mirrors the non-Card pre-cutover-settled path). + if (bc.checkoutTx) { + if (await this.settledBeforeCutover(bc)) return false; // pre-cutover-settled → aggregate opening, no marker/seq0 + if (await this.hasCutoverReceivedOpening(bc.id)) return true; // G-b: open-at-cutover cutover received opening + return this.alreadyBooked(bc.id, 0); // G-a analog: this consumer's forward Card seq0 opened received + } + + // Bank input: the BankTx consumer opens buyCrypto-received as the bank_tx seq0 booking (§4.2 BUY_CRYPTO legs). + // Gate on an ACTIVE bank_tx seq0 tx for this row's funding bank_tx — per-seq via hasActiveTxAt (walks the §4.12 + // reversal chain, so a content-change reversal/re-book of that opening still resolves), analogous to G-a. Falls + // through to the G-b cutover marker below for a cutover-straddling bank-funded row (opened by the cutover, not + // by a bank_tx seq0). + if (bc.bankTx && (await this.bookingService.hasActiveTxAt(BANK_TX_SOURCE, `${bc.bankTx.id}`, 0))) return true; + + const cryptoInputId = bc.cryptoInput?.id; + if ( + cryptoInputId != null && + (await this.ledgerTxRepo.existsBy({ sourceType: CRYPTO_INPUT_SOURCE, sourceId: `${cryptoInputId}`, seq: 0 })) + ) { + return true; // G-a + } + + // G-b: cutover opening on buyCrypto-received for this buy_crypto.id (synthetic seq0 marker, §6.1). + return this.hasCutoverReceivedOpening(bc.id); + } + + // the full cutover per-row received marker sourceId (§6.1 / §4.7 G-b): `${snapshotLogId}:buy_crypto:${id}`. + // The prefix is the snapshot logId, persisted in ledgerCutoverLogId by the cutover (§6.3 step 5). + private async cutoverReceivedSourceId(buyCryptoId: number): Promise { + const cutoverLogId = await this.settingService.get(CUTOVER_LOG_ID_KEY); + return cutoverLogId != null ? `${cutoverLogId}:buy_crypto:${buyCryptoId}` : undefined; + } + + // §6.1 / §4.7 G-b: true iff the cutover booked the per-row received opening `${snapshotLogId}:buy_crypto:${id}` for + // this row (open-at-cutover: its gross is already in the aggregate opening, so buildCardInputSeq0 is skipped and this + // marker is the sole `received` opening). The snapshot logId prefix must be resolved from ledgerCutoverLogId — an + // exact `:buy_crypto:${id}` match would NEVER hit (missing prefix → G-b dead, Blocker R4-2). Absent logId (cutover + // not run) → no opening to match. + private async hasCutoverReceivedOpening(buyCryptoId: number): Promise { + const cutoverSourceId = await this.cutoverReceivedSourceId(buyCryptoId); + if (cutoverSourceId == null) return false; + return this.ledgerTxRepo.existsBy({ sourceType: CUTOVER_SOURCE, sourceId: cutoverSourceId }); + } + + // §6.1: true iff the cutover booked the per-row owed opening `${snapshotLogId}:buy_crypto-owed:${id}` for this row + // (owed-straddling: outputAmount set, not complete at the snapshot). The reclassification is anchored in that + // opening and the payout_order consumer (§4.5) closes owed against it — this consumer books nothing for such a row. + private async hasCutoverOwedOpening(buyCryptoId: number): Promise { + const cutoverLogId = await this.settingService.get(CUTOVER_LOG_ID_KEY); + if (cutoverLogId == null) return false; + return this.ledgerTxRepo.existsBy({ + sourceType: CUTOVER_SOURCE, + sourceId: `${cutoverLogId}:buy_crypto-owed:${buyCryptoId}`, + }); + } + + // §6.3: true iff this Card row's completion is at/before the cutover snapshot → its value is already in the aggregate + // opening (openAssets), so its seq0/seq1 must NOT be (re-)booked. The completion marker is `outputDate`: the immutable + // payout timestamp set exactly once when the buy-crypto completes. Unlike `updated` (which a post-cutover flag change — + // chargeback/mail — pushes past the snapshot; that is the very Scenario-B trigger) `outputDate` still reflects whether + // the row had settled at the cutover. Non-Card rows are handled by the natural G-a/G-b gate → this guards Card only. + private async settledBeforeCutover(bc: BuyCrypto): Promise { + return !!bc.checkoutTx && (await this.preCutoverSettled(bc)); // Card-only variant of the general predicate below + } + + // C2: true iff this row's settlement (outputDate) is at/before the cutover snapshot → its value is already in the + // aggregate opening (openAssets/openLiabilities), NEVER a per-row cutover marker (openBuyCrypto* opens ONLY rows + // still OPEN at the snapshot, whose outputDate is null or > snapshot). Used by the content-change scan to SKIP+advance + // a forever-gate-blocked pre-cutover-settled row instead of throwing — the "no marker" half of §6.1 is implied here + // because being gate-blocked already means receivedOpened found neither a G-a seq0 nor a G-b marker. + private async preCutoverSettled(bc: BuyCrypto): Promise { + if (!bc.isComplete || bc.outputDate == null) return false; + + const snapshotDate = await this.cutoverSnapshotDate(); + return snapshotDate != null && bc.outputDate <= snapshotDate; + } + + // the pinned cutover snapshot date (§6.3), exported by the cutover (ledger-cutover.service step 4). Absent → cutover + // not run yet → no pre-cutover classification applies (forward-only booking, the pre-cutover default). + private async cutoverSnapshotDate(): Promise { + const iso = await this.settingService.get(CUTOVER_SNAPSHOT_DATE_KEY); + return iso != null ? new Date(iso) : undefined; + } + + // --- HELPERS --- // + + private chfLeg(account: LedgerAccount, amountChf: number): LedgerLegInput { + return { account, amount: amountChf, priceChf: 1, amountChf }; + } + + // §4.12 (R3): a seq is "already booked" iff an ACTIVE (not reversed-without-rebook) tx exists AT this seq — NOT + // `nextSeq > seq`. After a content-change reversal of seq0 (reversal seq=N, re-book seq=N+1) MAX(seq) jumps past 1, + // so `nextSeq > 1` would wrongly report the completion as booked and it would never run → buyCrypto-received never + // reclassifies to owed. hasActiveTxAt walks the reversal chain of the ORIGINAL at this exact seq. + private async alreadyBooked(id: number, seq: number): Promise { + return this.bookingService.hasActiveTxAt(SOURCE_TYPE, `${id}`, seq); + } + + private async checkoutAccount(currency: string): Promise { + const account = await this.accountService.findByName(`Checkout/${currency}`); + if (!account) throw new Error(`Ledger account Checkout/${currency} not found (CoA bootstrap missing)`); + return account; + } + + private liability(qualifier: string): Promise { + return this.accountService.findOrCreate(`LIABILITY/${qualifier}`, AccountType.LIABILITY, CHF); + } + + private paymentLinkAccount(): Promise { + return this.accountService.findOrCreate(PAYMENT_LINK, AccountType.LIABILITY, CHF); + } + + private income(qualifier: string): Promise { + return this.accountService.findOrCreate(`INCOME/${qualifier}`, AccountType.INCOME, CHF); + } + + private expense(qualifier: string): Promise { + return this.accountService.findOrCreate(`EXPENSE/${qualifier}`, AccountType.EXPENSE, CHF); + } +} diff --git a/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts b/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts new file mode 100644 index 0000000000..d9b1106d25 --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts @@ -0,0 +1,590 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Config } from 'src/config/config'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { Util } from 'src/shared/utils/util'; +import { BuyFiat } from 'src/subdomains/core/sell-crypto/process/buy-fiat.entity'; +import { MoreThan, Repository } from 'typeorm'; +import { AccountType, LedgerAccount } from '../../entities/ledger-account.entity'; +import { LedgerLeg } from '../../entities/ledger-leg.entity'; +import { LedgerTx } from '../../entities/ledger-tx.entity'; +import { LedgerAccountService } from '../ledger-account.service'; +import { LedgerBookingService, LedgerLegInput, LedgerTxInput } from '../ledger-booking.service'; +import { LedgerMarkCache, LedgerMarkService } from '../ledger-mark.service'; +import { resolveLegsOrDefer } from './ledger-mark-bridge.helper'; +import { + getLedgerWatermark, + isUnpricedAtCutover, + runContentChangeScan, + setLedgerWatermark, +} from './ledger-watermark.helper'; + +const SOURCE_TYPE = 'buy_fiat'; +const CRYPTO_INPUT_SOURCE = 'crypto_input'; +const CUTOVER_SOURCE = 'cutover'; +const CUTOVER_LOG_ID_KEY = 'ledgerCutoverLogId'; +const CUTOVER_SNAPSHOT_DATE_KEY = 'ledgerCutoverSnapshotDate'; +const CHF = 'CHF'; +const PAYMENT_LINK = 'LIABILITY/paymentLink'; +const BUY_FIAT_OWED = 'LIABILITY/buyFiat-owed'; + +/** + * The Class-1 core consumer (§4.7 + §4.7a + §4.7b, D04 §2 / D13 C). Pure observer: reads buy_fiat (+ ledger_tx + * for the seq0/opening gate), writes only ledger_*. + * + * It does NOT book the crypto-input leg (CryptoInput consumer is the single booker, §4.1). Two settlement paths, + * chosen by `cryptoInput.paymentLinkPayment IS NOT NULL`: + * (I) regular sell — fee + received→owed reclassification → TRANSIT (Class-1 hold) → bank-ASSET (+FX residual); + * (II) paymentLink merchant payout (§4.7b) — clears LIABILITY/paymentLink via fee + transmit/booked split. + * The owed/paymentLink liability holds until the bank bookingDate (Class-1, reproduces #3871 by construction). + */ +@Injectable() +export class BuyFiatConsumer { + private readonly logger = new DfxLogger(BuyFiatConsumer); + + constructor( + private readonly settingService: SettingService, + private readonly bookingService: LedgerBookingService, + private readonly accountService: LedgerAccountService, + private readonly markService: LedgerMarkService, + @InjectRepository(BuyFiat) private readonly buyFiatRepo: Repository, + @InjectRepository(LedgerTx) private readonly ledgerTxRepo: Repository, + ) {} + + async process(): Promise { + const watermark = (await getLedgerWatermark(this.settingService, SOURCE_TYPE)) ?? { + lastProcessedId: 0, + lastReversalScan: new Date(0), + }; + + await this.processForward(watermark); + + // content-change scan (§4.12 / §6.3): catches late-settling cutover-straddling rows (id <= watermark, settlement + // set post-cutover) the forward id-scan skips — runs ALSO when the forward batch is empty. The booker is + // idempotent (per-seq alreadyBooked), so a row in both scans is booked once. Re-read the watermark in case the + // forward batch advanced lastProcessedId above. + const afterForward = (await getLedgerWatermark(this.settingService, SOURCE_TYPE)) ?? watermark; + await runContentChangeScan( + this.settingService, + SOURCE_TYPE, + afterForward, + this.buyFiatRepo, + { cryptoInput: { paymentLinkPayment: true }, fiatOutput: { bankTx: true, bank: { asset: true } } }, + async (bf: BuyFiat) => { + const marks = await this.preloadMarks([bf]); + + // forward book() FIRST: append any newly-settled seqs / forward-book a late-settling row. A gate-blocked run + // returns false → distinguish a pre-cutover-settled row (value in the aggregate opening, C2) → SKIP+advance, + // from a genuinely post-cutover row whose received/paymentLink opening is not yet booked → THROW (cursor stays + // put, retry). Returning here for a gate-blocked row ALSO avoids resolving+creating the value-coupled accounts + // below for a never-booked row (no phantom owed/transit/bank accounts). + if (!(await this.book(bf, marks))) { + if (await this.preCutoverSettled(bf)) return; + // F2: an open row unpriced at the cutover (amountInChf NULL) never got a per-row received/paymentLink opening + // — its value is in the aggregate ASSET opening. Its received gate can NEVER open, so SKIP+advance with an + // ERROR alarm (manual reconciliation) instead of throwing forever (head-of-line wedge). The per-row chain + // begins only once the row is priced AND manually re-anchored — traceable via this alarm. + if (await isUnpricedAtCutover(this.settingService, SOURCE_TYPE, bf.id)) { + this.logger.error( + `buy_fiat ${bf.id} was unpriced at the cutover (value in the aggregate opening) — skipping its per-row chain (F2 alarm)`, + ); + return; + } + throw new Error(`buy_fiat ${bf.id} content-change scan gate-blocked — retry next run (§4.7 G-a)`); + } + + // then §4.12 + M3 + M4: on a content change, reverse+rebook the value-coupled regular-sell chain (reclassification + // seq1 / transmit seq2 / settlement seq3 all carry owedChf). Reversing ONLY seq1 while seq2/seq3 are booked leaves + // owed on the OLD value and it never closes to 0 (liability closure breaks) — the chain method reverses the + // whole ACTIVE chain atomically and no-ops when nothing changed (incl. the seqs book() just booked). Gated on + // outputAmount != null (M4: an unpriced row is "not yet settled", not an error → no seq1 to reverse). Skipped for + // owed-straddling rows (reclassification anchored in the cutover opening) and the paymentLink path (own seq chain). + const owedOpeningChf = await this.cutoverOwedOpeningChf(bf.id); + if (owedOpeningChf == null && !bf.cryptoInput?.paymentLinkPayment && bf.outputAmount != null) { + const chain = ( + await Promise.all([ + this.buildReclassificationSeq1(bf), + this.buildTransmitSeq2(bf, owedOpeningChf), + this.buildSettlementSeq3(bf, marks, owedOpeningChf), + ]) + ).filter((i): i is LedgerTxInput => i != null); + if (chain.length) await this.bookingService.reverseAndRebookChainIfChanged(chain); + } + }, + ); + } + + private async processForward(watermark: { lastProcessedId: number; lastReversalScan: Date }): Promise { + const batch = await this.buyFiatRepo.find({ + where: { id: MoreThan(watermark.lastProcessedId) }, + relations: { cryptoInput: { paymentLinkPayment: true }, fiatOutput: { bankTx: true, bank: { asset: true } } }, + order: { id: 'ASC' }, + take: Config.ledger.backfillBatchSize, + }); + if (!batch.length) return; + + const marks = await this.preloadMarks(batch); + + let lastProcessedId = watermark.lastProcessedId; + for (const bf of batch) { + try { + const advance = await this.book(bf, marks); + // a gate-blocked seq1 must NOT advance the watermark past this row (retry next run, §4.7 G-a) + if (!advance) break; + lastProcessedId = bf.id; + } catch (e) { + this.logger.error(`Failed to book buy_fiat ${bf.id}:`, e); + break; // failure-isolation: leave watermark unchanged, retry next run (§4-header) + } + } + + if (lastProcessedId > watermark.lastProcessedId) { + await setLedgerWatermark(this.settingService, SOURCE_TYPE, { ...watermark, lastProcessedId }); + } + } + + private async preloadMarks(batch: BuyFiat[]): Promise { + // B6: the seq3 settlement lookup uses `bankTx.bookingDate ?? bankTx.created` (buildSettlementSeq3), so the preload + // window MUST include the same fallback — otherwise a row whose bankTx has no bookingDate (settled via `created`) + // could fall outside the preloaded span and getMarkAt would miss its mark. + const dates = batch.flatMap((bf) => + [bf.updated, bf.fiatOutput?.bankTx?.bookingDate ?? bf.fiatOutput?.bankTx?.created].filter((d): d is Date => !!d), + ); + const times = dates.map((d) => d.getTime()); + // lookback so getMarkAt finds the latest mark at-or-before the earliest row timestamp + return this.markService.preload(Util.daysBefore(2, new Date(Math.min(...times))), new Date(Math.max(...times))); + } + + // returns false when seq1 is gate-blocked (received/paymentLink not yet opened) → caller stops advancing + private async book(bf: BuyFiat, marks: LedgerMarkCache): Promise { + return bf.cryptoInput?.paymentLinkPayment ? this.bookPaymentLink(bf, marks) : this.bookRegular(bf, marks); + } + + // --- (I) REGULAR SELL (§4.7 / §4.7a) --- // + + private async bookRegular(bf: BuyFiat, marks: LedgerMarkCache): Promise { + // §4.7a/§6.1 owed-straddling: a pre-cutover open buy_fiat (outputAmount set) had its received→owed reclassification + // run BEFORE the cutover; the cutover re-opened owed via the per-row marker `:buy_fiat-owed:`. There is + // no seq1 chain from this run → the seq1 gate (received seq0) would NEVER open and block seq2/seq3 forever + // (Blocker R6-1). Detect the owed-opening marker and skip seq1; seq2/seq3 settle owed (opening-CHF anchor) to 0. + const owedOpeningChf = await this.cutoverOwedOpeningChf(bf.id); + + // seq1 (fee + reclassification) — only once outputAmount is set AND received is opened (gate G-a/G-b). + // Skipped entirely for owed-straddling rows (reclassification already booked pre-cutover, anchored in the opening). + if (owedOpeningChf == null && bf.outputAmount != null && !(await this.alreadyBooked(bf.id, 1))) { + if (!(await this.receivedOpened(bf))) return false; + await this.bookReclassification(bf); + } + + // seq2 (transmit, Class-1 hold) — on fiatOutput.isTransmittedDate + if (bf.fiatOutput?.isTransmittedDate && !(await this.alreadyBooked(bf.id, 2))) { + await this.bookTransmit(bf, owedOpeningChf); + } + + // seq3 (booked) — on complete() (= fiatOutput.bankTx booked), at bank_tx.bookingDate + if (bf.fiatOutput?.bankTx && !(await this.alreadyBooked(bf.id, 3))) { + await this.bookSettlement(bf, marks, owedOpeningChf); + } + + return true; + } + + // §4.7 seq1 — 4-leg: (a) Dr received +fee / Cr INCOME/fee-buyFiat −fee; (b) Dr received +(amountInChf−fee) / + // Cr buyFiat-owed −(amountInChf−fee). After seq1 received = 0, owed = −(amountInChf−fee). + private async bookReclassification(bf: BuyFiat): Promise { + const input = await this.buildReclassificationSeq1(bf); + if (input) await this.bookingService.bookTx(input); + } + + // builds the seq1 reclassification LedgerTxInput for the REGULAR sell path (undefined for paymentLink / no anchor) + private async buildReclassificationSeq1(bf: BuyFiat): Promise { + if (bf.cryptoInput?.paymentLinkPayment) return undefined; // paymentLink path has its own seq1 (venue spread) + if (bf.amountInChf == null) throw new Error(`buy_fiat ${bf.id} has outputAmount but amountInChf is null`); + + const fee = bf.totalFeeAmountChf ?? 0; // additive null-strategy (§5.1) + const reclassChf = Util.round(bf.amountInChf - fee, 2); + + const received = await this.liability('buyFiat-received'); + const owed = await this.liability('buyFiat-owed'); + const feeIncome = await this.income('fee-buyFiat'); + + return { + sourceType: SOURCE_TYPE, + sourceId: `${bf.id}`, + seq: 1, + bookingDate: bf.cryptoInput.updated, + valueDate: bf.cryptoInput.updated, + legs: [ + this.chfLeg(received, fee), + this.chfLeg(feeIncome, -fee), + this.chfLeg(received, reclassChf), + this.chfLeg(owed, -reclassChf), + ], + }; + } + + // §4.7 seq2 — transmit: Dr buyFiat-owed +owed_chf / Cr TRANSIT/payout/{ccy} −owed_chf (Class-1 hold). + // For an owed-straddling row owedOpeningChf is the cutover opening-CHF anchor (§4.7a/§6.1), so the owed-Dr debits + // the exact value the opening Cr leg credited → owed closes cent-exact to 0. + private async bookTransmit(bf: BuyFiat, owedOpeningChf?: number): Promise { + const input = await this.buildTransmitSeq2(bf, owedOpeningChf); + if (input) await this.bookingService.bookTx(input); + } + + // builds the seq2 transmit LedgerTxInput for the REGULAR sell path, or undefined when not yet transmitted (no chain + // link). owedChf is the value-coupled anchor (reclassification CHF, or the cutover opening-CHF for a straddling row). + private async buildTransmitSeq2(bf: BuyFiat, owedOpeningChf?: number): Promise { + if (!bf.fiatOutput?.isTransmittedDate) return undefined; + + const owedChf = this.owedChf(bf, owedOpeningChf); + const owed = await this.liability('buyFiat-owed'); + const transit = await this.transit(this.outputCurrency(bf)); + + return { + sourceType: SOURCE_TYPE, + sourceId: `${bf.id}`, + seq: 2, + bookingDate: bf.fiatOutput.isTransmittedDate, + valueDate: bf.fiatOutput.isTransmittedDate, + legs: [this.chfLeg(owed, owedChf), this.chfLeg(transit, -owedChf)], + }; + } + + // §4.7 seq3 — booked: Dr TRANSIT/payout +owed_chf / Cr ASSET/bank −(outputAmount × mark) (+ §4.7a FX-P&L leg + // for non-CHF output). Settlement = bank_tx.bookingDate (NOT isTransmittedDate — Class 1). + private async bookSettlement(bf: BuyFiat, marks: LedgerMarkCache, owedOpeningChf?: number): Promise { + const input = await this.buildSettlementSeq3(bf, marks, owedOpeningChf); + if (input) await this.bookingService.bookTx(input); + } + + // builds the seq3 settlement LedgerTxInput (regular + paymentLink share it), or undefined when the bank_tx is not yet + // booked (no chain link). owedChf is the value-coupled anchor (reclassification CHF / cutover opening / paymentLink). + private async buildSettlementSeq3( + bf: BuyFiat, + marks: LedgerMarkCache, + owedOpeningChf?: number, + ): Promise { + if (!bf.fiatOutput?.bankTx) return undefined; + + const bookingDate = bf.fiatOutput.bankTx.bookingDate ?? bf.fiatOutput.bankTx.created; + const owedChf = this.owedChf(bf, owedOpeningChf); + + const transit = await this.transit(this.outputCurrency(bf)); + const bankLeg = await this.bankCrLeg(bf, bookingDate, marks); + + // §4.7a FX-P&L leg = −(Σ CHF) → INCOME/EXPENSE fx-revaluation (EUR drift between reclassification and booking) + const legs: LedgerLegInput[] = [this.chfLeg(transit, owedChf), bankLeg]; + await this.appendFxResidual(legs, `buy_fiat ${bf.id} seq3`); + + return { + sourceType: SOURCE_TYPE, + sourceId: `${bf.id}`, + seq: 3, + bookingDate, + valueDate: bf.fiatOutput.bankTx.valueDate ?? bookingDate, + legs, + }; + } + + // --- (II) PAYMENTLINK MERCHANT PAYOUT (§4.7b) --- // + + private async bookPaymentLink(bf: BuyFiat, marks: LedgerMarkCache): Promise { + // seq1 (fee realization + venue-spread plug) — once outputAmount set AND the seq0 paymentLink opening exists + if (bf.outputAmount != null && !(await this.alreadyBooked(bf.id, 1))) { + const opening = await this.paymentLinkOpeningChf(bf); + if (opening == null) return false; // gate: CryptoInput consumer has not opened paymentLink yet (§4.7b) + await this.bookPaymentLinkFee(bf, opening); + } + + if (bf.fiatOutput?.isTransmittedDate && !(await this.alreadyBooked(bf.id, 2))) { + await this.bookPaymentLinkTransmit(bf); + } + + if (bf.fiatOutput?.bankTx && !(await this.alreadyBooked(bf.id, 3))) { + await this.bookSettlement(bf, marks); // identical Class-1 transmit/booked split as the regular path + } + + return true; + } + + /** + * §4.7b seq1 — debits LIABILITY/paymentLink down to exactly −outputAmount_chf. The (totalFeeAmountChf + + * paymentLinkFeeAmount_chf) portion is realized as INCOME/fee-paymentLink (BOTH DFX fee shares, Minor R9-5); + * the remaining venue-sell-spread (opening Mark×amount − outputAmount_chf − fee) goes into the fx-revaluation + * plug — NOT INCOME (it is FX/valuation drift, §1.11/§7.6). + */ + private async bookPaymentLinkFee(bf: BuyFiat, openingChf: number): Promise { + const totalFee = bf.totalFeeAmountChf ?? 0; + // paymentLinkFeeAmount is NOT a persisted column (local var in setPaymentLinkPayment buy-fiat.entity.ts:392); + // reconstruct as outputReferenceAmount − outputAmount (both persisted :199/:206), the closing-consistent value + const plFeeNative = Util.round((bf.outputReferenceAmount ?? 0) - (bf.outputAmount ?? 0), 8); + const plFeeChf = Util.round(plFeeNative * this.owedReferenceRate(bf), 2); + const feeChf = Util.round(totalFee + plFeeChf, 2); + + const outputChf = this.owedChf(bf); // outputAmount × reclassification-mark + const venueSpread = Util.round(openingChf - outputChf - feeChf, 2); // the real crypto↔fiat sell-spread + + const paymentLink = await this.paymentLinkAccount(); + const feeIncome = await this.income('fee-paymentLink'); + + const legs: LedgerLegInput[] = [ + this.chfLeg(paymentLink, feeChf), // Dr paymentLink +fee + this.chfLeg(feeIncome, -feeChf), // Cr INCOME/fee-paymentLink −fee + ]; + if (venueSpread !== 0) { + legs.push(this.chfLeg(paymentLink, venueSpread)); // Dr paymentLink +venueSpread → reaches −outputChf + const fx = venueSpread >= 0 ? await this.income('fx-revaluation') : await this.expense('fx-revaluation'); + legs.push(this.chfLeg(fx, -venueSpread)); + } + + await this.bookingService.bookTx({ + sourceType: SOURCE_TYPE, + sourceId: `${bf.id}`, + seq: 1, + bookingDate: bf.cryptoInput.updated, + valueDate: bf.cryptoInput.updated, + legs, + }); + } + + // §4.7b seq2 — transmit: Dr paymentLink +outputAmount_chf / Cr TRANSIT/payout/{ccy} → paymentLink reaches 0 + private async bookPaymentLinkTransmit(bf: BuyFiat): Promise { + const outputChf = this.owedChf(bf); + const paymentLink = await this.paymentLinkAccount(); + const transit = await this.transit(this.outputCurrency(bf)); + + await this.bookingService.bookTx({ + sourceType: SOURCE_TYPE, + sourceId: `${bf.id}`, + seq: 2, + bookingDate: bf.fiatOutput.isTransmittedDate, + valueDate: bf.fiatOutput.isTransmittedDate, + legs: [this.chfLeg(paymentLink, outputChf), this.chfLeg(transit, -outputChf)], + }); + } + + // --- SHARED HELPERS --- // + + // the bank-ASSET Cr leg of seq3: −outputAmount native, CHF = mark × outputAmount (mark-consistent, §7) + private async bankCrLeg(bf: BuyFiat, bookingDate: Date, marks: LedgerMarkCache): Promise { + const bankAsset = bf.fiatOutput?.bank?.asset; + if (!bankAsset) throw new Error(`buy_fiat ${bf.id} fiatOutput has no bank.asset (untracked output bank)`); + const account = await this.assetAccount(bankAsset.id); + + // A5 fail-loud (consistency with :182/:351): a settled row (bankTx booked) with a NULL outputAmount is a data error + // — `?? 0` would book a 0-native/0-CHF bank leg and silently drop the settlement value. Throw (retry) instead. + if (bf.outputAmount == null) { + throw new Error(`buy_fiat ${bf.id} settlement without outputAmount — cannot value the bank-ASSET leg`); + } + const outputAmount = bf.outputAmount; + const mark = this.outputMark(bf, bookingDate, marks); + const chf = mark != null ? Util.round(mark * outputAmount, 2) : undefined; + + return { + account, + amount: -outputAmount, + priceChf: mark ?? null, + amountChf: chf != null ? -chf : undefined, + needsMark: chf == null, + }; + } + + // §4.7a — appends the FX-P&L leg = −(Σ CHF) for the EUR/output drift; CHF output → drift 0 → no leg. + // Major B5: an unmarked bank-ASSET leg is first bridged with the youngest available mark (resolveLegsOrDefer) so this + // mixed tx (TRANSIT CHF leg + bank-ASSET leg) balances — needsMark stays true, the mark-to-market job corrects the + // basis later. A truly feedless output asset (no bridge) defers the row instead of handing an unbalanceable set to + // bookTx at the fixed historical bank bookingDate. + private async appendFxResidual(legs: LedgerLegInput[], ref: string): Promise { + if (!(await resolveLegsOrDefer(legs, this.markService, this.logger, ref))) return; + + const sumCents = legs.reduce((s, l) => s + Math.round(Util.round(l.amountChf ?? 0, 2) * 100), 0); + if (Math.abs(sumCents) <= Config.ledger.roundingToleranceCents) return; // sub-cent → ROUNDING + + const residualChf = Util.round(-sumCents / 100, 2); + const account = residualChf >= 0 ? await this.income('fx-revaluation') : await this.expense('fx-revaluation'); + legs.push(this.chfLeg(account, residualChf)); + } + + // owed_chf = the reclassification CHF (amountInChf − totalFeeAmountChf), the value seq1 credited to owed. + // For paymentLink it is the net merchant fiat output_chf = outputAmount × reclassification-mark. + // For an owed-straddling row (§4.7a/§6.1) it is the cutover opening-CHF anchor (= −leg.amountChf of the opening), + // so transmit/booked debit owed by exactly the opening value → owed closes cent-exact to 0 and the mark drift + // Opening↔Settlement lands in the §4.7a FX-P&L leg (appendFxResidual), not as a phantom on owed (Blocker R6-1). + private owedChf(bf: BuyFiat, owedOpeningChf?: number): number { + if (owedOpeningChf != null) return owedOpeningChf; + if (bf.cryptoInput?.paymentLinkPayment) { + // A5 fail-loud (consistency with :182/:351): a paymentLink row being settled with a NULL outputAmount is a data + // error — `?? 0` would zero the merchant fiat output and misvalue the paymentLink clear. Throw (retry) instead. + if (bf.outputAmount == null) { + throw new Error( + `buy_fiat ${bf.id} paymentLink owedChf without outputAmount — cannot value the merchant payout`, + ); + } + return Util.round(bf.outputAmount * this.owedReferenceRate(bf), 2); + } + return Util.round((bf.amountInChf ?? 0) - (bf.totalFeeAmountChf ?? 0), 2); + } + + // CHF-per-output-unit at the reclassification mark, derived from the persisted reference (outputReferenceAmount + // is the fiat reference, amountInChf its CHF value) — a deterministic ratio, NOT a market mark lookup + private owedReferenceRate(bf: BuyFiat): number { + const ref = bf.outputReferenceAmount; + if (ref == null || ref === 0 || bf.amountInChf == null) { + // CHF output has a 1:1 rate that needs no reference; a non-CHF output without one cannot be valued — fail loud + // rather than silently returning 0, which would zero owedChf/plFeeChf and misclassify the whole value into the + // fx-revaluation plug (§5.1 no silent fallback). + if (this.outputCurrency(bf) === CHF) return 1; + throw new Error( + `buy_fiat ${bf.id} owedReferenceRate: missing outputReferenceAmount/amountInChf for non-CHF output ${this.outputCurrency( + bf, + )} — refusing a 0-fallback that would misclassify the value into the fx-revaluation plug`, + ); + } + return Util.round(bf.amountInChf / ref, 8); + } + + // the output-currency mark for the bank-ASSET leg: CHF → 1, else getMarkAt(bank.asset, bookingDate) + private outputMark(bf: BuyFiat, bookingDate: Date, marks: LedgerMarkCache): number | undefined { + if (this.outputCurrency(bf) === CHF) return 1; + const bankAssetId = bf.fiatOutput?.bank?.asset?.id; + return bankAssetId != null ? marks.getMarkAt(bankAssetId, bookingDate) : undefined; + } + + private outputCurrency(bf: BuyFiat): string { + return bf.outputAsset?.name ?? bf.fiatOutput?.currency ?? CHF; + } + + // --- GATE (§4.7 G-a/G-b) --- // + + // received is opened by the seq0 CryptoInput ledger_tx (G-a) or the cutover opening (G-b, cutover-straddling) + private async receivedOpened(bf: BuyFiat): Promise { + const cryptoInputId = bf.cryptoInput?.id; + if ( + cryptoInputId != null && + (await this.ledgerTxRepo.existsBy({ sourceType: CRYPTO_INPUT_SOURCE, sourceId: `${cryptoInputId}`, seq: 0 })) + ) { + return true; // G-a + } + + // G-b: cutover opening on buyFiat-received for this buy_fiat.id (synthetic seq0 marker, §6.1). The cutover + // writes `${snapshotLogId}:buy_fiat:${id}`, so the prefix must be resolved from ledgerCutoverLogId — an exact + // `:buy_fiat:${id}` match would NEVER hit (the snapshot logId prefix is missing → G-b dead, Blocker R4-2). + const cutoverSourceId = await this.cutoverReceivedSourceId(bf.id); + if (cutoverSourceId == null) return false; // cutover not run yet → no opening to match + return this.ledgerTxRepo.existsBy({ sourceType: CUTOVER_SOURCE, sourceId: cutoverSourceId }); + } + + // the full cutover per-row received marker sourceId (§6.1 / §4.7 G-b): `${snapshotLogId}:buy_fiat:${id}`. + // The prefix is the snapshot logId, persisted in ledgerCutoverLogId by the cutover (§6.3 step 5). + private async cutoverReceivedSourceId(buyFiatId: number): Promise { + const cutoverLogId = await this.settingService.get(CUTOVER_LOG_ID_KEY); + return cutoverLogId != null ? `${cutoverLogId}:buy_fiat:${buyFiatId}` : undefined; + } + + // C2: true iff this row's settlement (outputDate, the immutable completion timestamp) is at/before the cutover + // snapshot → its value is already in the aggregate opening, NEVER a per-row cutover marker (the cutover opens ONLY + // rows still OPEN at the snapshot, whose outputDate is null or > snapshot). The content-change scan uses it to + // SKIP+advance a forever-gate-blocked pre-cutover-settled row instead of throwing (which would wedge the scan). The + // "no marker" half of §6.1 is implied — being gate-blocked already means neither a G-a seq0 nor a G-b/owed/paymentLink + // opening was found for this row. + private async preCutoverSettled(bf: BuyFiat): Promise { + if (bf.outputDate == null) return false; + + const iso = await this.settingService.get(CUTOVER_SNAPSHOT_DATE_KEY); + return iso != null && bf.outputDate <= new Date(iso); + } + + // §4.7a/§6.1 — looks up the cutover per-row owed-opening leg CHF (marker `${snapshotLogId}:buy_fiat-owed:${id}`); + // the prefix is the snapshot logId persisted in ledgerCutoverLogId. Returns undefined for a regular post-cutover + // row (no owed opening). Mirrors bank-tx.consumer.ts cutoverOwedOpeningChf (Major R6-1). + private async cutoverOwedOpeningChf(buyFiatId: number): Promise { + const cutoverLogId = await this.settingService.get(CUTOVER_LOG_ID_KEY); + if (cutoverLogId == null) return undefined; + + const opening = await this.ledgerTxRepo.findOne({ + where: { sourceType: CUTOVER_SOURCE, sourceId: `${cutoverLogId}:buy_fiat-owed:${buyFiatId}` }, + relations: { legs: { account: true } }, + }); + const leg = opening?.legs?.find((l: LedgerLeg) => l.account?.name === BUY_FIAT_OWED); + if (leg?.amountChf == null) return undefined; + + return Util.round(-leg.amountChf, 2); // the opening Cr leg is −openingChf → owed-Dr debits +openingChf + } + + // §4.7b gate — returns the paymentLink opening CHF (= −leg.amountChf) or undefined if not yet opened. Two openers: + // (G-a) the CryptoInput seq0 (post-cutover paymentLink row, §4.4 isPayment); + // (G-b/F1) the cutover per-row marker `${snapshotLogId}:buy_fiat-paymentLink:${id}` for a cutover-straddling + // paymentLink row whose financing crypto_input settled pre-cutover (no seq0). Without G-b the gate would NEVER open + // and the content-change scan would wedge on the straddling row forever. + private async paymentLinkOpeningChf(bf: BuyFiat): Promise { + const cryptoInputId = bf.cryptoInput?.id; + if (cryptoInputId != null) { + const seq0 = await this.ledgerTxRepo.findOne({ + where: { sourceType: CRYPTO_INPUT_SOURCE, sourceId: `${cryptoInputId}`, seq: 0 }, + relations: { legs: { account: true } }, + }); + const leg = seq0?.legs?.find((l: LedgerLeg) => l.account?.name === PAYMENT_LINK); + if (leg?.amountChf != null) return Util.round(-leg.amountChf, 2); // seq0 Cr leg is −Mark×amount → absolute CHF + } + + return this.cutoverPaymentLinkOpeningChf(bf.id); // G-b/F1: cutover-straddling paymentLink row + } + + // §6.1/F1 — the cutover per-row paymentLink opening CHF (marker `${snapshotLogId}:buy_fiat-paymentLink:${id}`); the + // prefix is the snapshot logId persisted in ledgerCutoverLogId. Returns the opening anchor (= −leg.amountChf) so the + // §4.7b seq1/seq2 debit LIABILITY/paymentLink by exactly the cutover opening → paymentLink closes cent-exact to 0. + private async cutoverPaymentLinkOpeningChf(buyFiatId: number): Promise { + const cutoverLogId = await this.settingService.get(CUTOVER_LOG_ID_KEY); + if (cutoverLogId == null) return undefined; + + const opening = await this.ledgerTxRepo.findOne({ + where: { sourceType: CUTOVER_SOURCE, sourceId: `${cutoverLogId}:buy_fiat-paymentLink:${buyFiatId}` }, + relations: { legs: { account: true } }, + }); + const leg = opening?.legs?.find((l: LedgerLeg) => l.account?.name === PAYMENT_LINK); + if (leg?.amountChf == null) return undefined; + + return Util.round(-leg.amountChf, 2); + } + + // --- ACCOUNT/LEG HELPERS --- // + + private chfLeg(account: LedgerAccount, amountChf: number): LedgerLegInput { + return { account, amount: amountChf, priceChf: 1, amountChf }; + } + + // §4.12 (R3): a seq is "already booked" iff an ACTIVE (not reversed-without-rebook) tx exists AT this seq — NOT + // `nextSeq > seq`. After a content-change reversal of seq1 (reversal seq=N, re-book seq=N+1) MAX(seq) jumps past + // 2/3, so `nextSeq > 2/3` would wrongly report transmit/booked as booked and they would never run → buyFiat-owed + // never closes. hasActiveTxAt walks the reversal chain of the ORIGINAL at this exact seq. + private async alreadyBooked(id: number, seq: number): Promise { + return this.bookingService.hasActiveTxAt(SOURCE_TYPE, `${id}`, seq); + } + + private async assetAccount(assetId: number): Promise { + const account = await this.accountService.findByAssetId(assetId); + if (!account) throw new Error(`Ledger account for asset ${assetId} not found (CoA bootstrap missing)`); + return account; + } + + private transit(currency: string): Promise { + return this.accountService.findOrCreate(`TRANSIT/payout/${currency}`, AccountType.TRANSIT, currency); + } + + private paymentLinkAccount(): Promise { + return this.accountService.findOrCreate(PAYMENT_LINK, AccountType.LIABILITY, CHF); + } + + private liability(qualifier: string): Promise { + return this.accountService.findOrCreate(`LIABILITY/${qualifier}`, AccountType.LIABILITY, CHF); + } + + private income(qualifier: string): Promise { + return this.accountService.findOrCreate(`INCOME/${qualifier}`, AccountType.INCOME, CHF); + } + + private expense(qualifier: string): Promise { + return this.accountService.findOrCreate(`EXPENSE/${qualifier}`, AccountType.EXPENSE, CHF); + } +} diff --git a/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts b/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts new file mode 100644 index 0000000000..ebbec01668 --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts @@ -0,0 +1,298 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Config } from 'src/config/config'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { Util } from 'src/shared/utils/util'; +import { CryptoInput, CryptoInputSettledStatus } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; +import { In, MoreThan, Repository } from 'typeorm'; +import { AccountType, LedgerAccount } from '../../entities/ledger-account.entity'; +import { LedgerAccountService } from '../ledger-account.service'; +import { LedgerBookingService, LedgerLegInput, LedgerTxInput } from '../ledger-booking.service'; +import { LedgerMarkCache, LedgerMarkService } from '../ledger-mark.service'; +import { resolveLegsOrDefer } from './ledger-mark-bridge.helper'; +import { + getLedgerWatermark, + isCoveredByCutoverOpening, + runContentChangeScan, + setLedgerWatermark, +} from './ledger-watermark.helper'; + +const SOURCE_TYPE = 'crypto_input'; +const CHF = 'CHF'; + +/** + * The ONLY booker of the crypto-input leg (Single-Booker §4.1 Blocker R1-1) + the standalone forward-fee + * booker (§4.4). Pure observer: reads crypto_input (+ buyFiat/buyCrypto for the amountInChf base anchor), + * writes only ledger_*. + * + * seq0 (buyFiat/buyCrypto-swap): 3-leg with an amountInChf-anchored received-Cr leg + fx-revaluation plug + * (§4.4a Blocker R7-1) — so the later completion clear closes `received` cent-exact. isPayment (paymentLink): + * 2-leg, mark-based (no per-input amountInChf anchor, @ManyToOne, Minor R10-4). seq1: forward fee only. + */ +@Injectable() +export class CryptoInputConsumer { + private readonly logger = new DfxLogger(CryptoInputConsumer); + + constructor( + private readonly settingService: SettingService, + private readonly bookingService: LedgerBookingService, + private readonly accountService: LedgerAccountService, + private readonly markService: LedgerMarkService, + @InjectRepository(CryptoInput) private readonly cryptoInputRepo: Repository, + ) {} + + async process(): Promise { + const watermark = (await getLedgerWatermark(this.settingService, SOURCE_TYPE)) ?? { + lastProcessedId: 0, + lastReversalScan: new Date(0), + }; + + await this.processForward(watermark); + + // content-change scan (§4.12): an amount change (or buyFiat/buyCrypto re-link) on an already-booked crypto_input + // recomputes the seq0 input leg and, if it differs beyond the §4.12 tolerances, reverses the active tx + re-books + // the corrected legs. Runs ALSO when the forward batch is empty. Re-read the watermark in case the forward batch + // advanced lastProcessedId above. + const afterForward = (await getLedgerWatermark(this.settingService, SOURCE_TYPE)) ?? watermark; + await runContentChangeScan( + this.settingService, + SOURCE_TYPE, + afterForward, + this.cryptoInputRepo, + { buyFiat: true, buyCrypto: true }, + async (ci: CryptoInput) => { + // lookback so getMarkAt finds the latest mark at-or-before the row timestamp + const marks = await this.markService.preload(Util.daysBefore(2, ci.updated), ci.updated); + // C1: forward-book a late-settling row the id-watermark skipped (settled AFTER the watermark advanced over it). + // Gated on the SAME settled-status filter as the forward scan; book() is idempotent (per-seq hasActiveTxAt), so + // an already-booked row is a no-op. A not-yet-settled row is left (its settle bump on `updated` re-selects it). + // §6.3 covered-by-cutover-opening guard: a row already SETTLED at the cutover snapshot has its value in the + // aggregate ASSET opening — its `updated` bump post-cutover re-selects it here, but its seq0 must NOT be + // (re-)booked (that would double-count the ASSET + book a phantom liability). An open-at-cutover hole or a + // post-boundary row is NOT covered → it still books its seq0 fresh exactly once. + if ( + CryptoInputSettledStatus.includes(ci.status) && + !(await isCoveredByCutoverOpening(this.settingService, SOURCE_TYPE, ci.id)) + ) + await this.book(ci, marks); + // §4.12 content-change: an amount / buyFiat-buyCrypto re-link on an already-booked seq0 → reverse + re-book the + // corrected legs (a no-op when nothing changed, incl. the row just forward-booked above). + const input = await this.buildSeq0Input(ci, ci.updated, marks); + if (input) await this.bookingService.reverseAndRebookIfChanged(input); + }, + ); + } + + private async processForward(watermark: { lastProcessedId: number; lastReversalScan: Date }): Promise { + // settled-status filter (§4.4 — NOT isConfirmed, Major R2-3); txType=PAYMENT is included via status + const batch = await this.cryptoInputRepo.find({ + where: { id: MoreThan(watermark.lastProcessedId), status: In(CryptoInputSettledStatus) }, + relations: { buyFiat: true, buyCrypto: true }, + order: { id: 'ASC' }, + take: Config.ledger.backfillBatchSize, + }); + if (!batch.length) return; + + const times = batch.map((ci) => ci.updated.getTime()); + const marks = await this.markService.preload( + // lookback so getMarkAt finds the latest mark at-or-before the earliest row timestamp + Util.daysBefore(2, new Date(Math.min(...times))), + new Date(Math.max(...times)), + ); + + let lastProcessedId = watermark.lastProcessedId; + for (const ci of batch) { + try { + await this.book(ci, marks); + lastProcessedId = ci.id; + } catch (e) { + this.logger.error(`Failed to book crypto_input ${ci.id}:`, e); + break; // failure-isolation: leave watermark unchanged, retry next run (§4-header) + } + } + + if (lastProcessedId > watermark.lastProcessedId) { + await setLedgerWatermark(this.settingService, SOURCE_TYPE, { ...watermark, lastProcessedId }); + } + } + + private async book(ci: CryptoInput, marks: LedgerMarkCache): Promise { + const bookingDate = ci.updated; + + await this.bookInput(ci, bookingDate, marks); // seq0 + await this.bookForwardFee(ci, bookingDate, marks); // seq1 (only if outTxId + forwardFeeAmountChf) + } + + // seq0 — the crypto-input leg (§4.4/§4.4a) + private async bookInput(ci: CryptoInput, bookingDate: Date, marks: LedgerMarkCache): Promise { + if (await this.alreadyBooked(ci.id, 0)) return; // idempotent: don't re-open after a re-run + + const input = await this.buildSeq0Input(ci, bookingDate, marks); + if (input) await this.bookingService.bookTx(input); + } + + // builds the seq0 input LedgerTxInput (§4.4/§4.4a) or undefined when the row is not bookable (no anchor) + private async buildSeq0Input( + ci: CryptoInput, + bookingDate: Date, + marks: LedgerMarkCache, + ): Promise { + const wallet = await this.walletAsset(ci); + const mark = wallet.assetId != null ? marks.getMarkAt(wallet.assetId, bookingDate) : undefined; + const assetChf = mark != null ? Util.round(mark * ci.amount, 2) : undefined; + + const assetLeg: LedgerLegInput = { + account: wallet, + amount: +ci.amount, + priceChf: mark ?? null, + amountChf: assetChf, + needsMark: assetChf == null, + }; + + if (ci.isPayment) { + // §4.4 paymentLink: 2-leg, mark-based (no per-input amountInChf anchor — @ManyToOne, Minor R10-4). F5: the + // paymentLink LIABILITY is CHF-denominated (assetId=NULL), so the mark-to-market job (assetId IS NOT NULL) can + // NEVER revalue it — it MUST be booked with a CHF value NOW. Value the wallet leg (historical mark, else the §5.2 + // B5 youngest-mark bridge; needsMark stays true so the mark-to-market job corrects the wallet ASSET basis later), + // then mirror that (bridged) CHF onto the paymentLink leg. A truly feedless wallet asset (no mark anywhere) DEFERS + // the row (fail-loud) rather than booking an unvalued merchant liability that would read null forever downstream + // (§4.7b/F3 paymentLinkOpeningChf → permanent buy-fiat/buy-crypto wedge). + const paymentLink = await this.liability('paymentLink'); + await resolveLegsOrDefer([assetLeg], this.markService, this.logger, `crypto_input ${ci.id} paymentLink seq0`); + if (assetLeg.amountChf == null) { + throw new Error( + `crypto_input ${ci.id} paymentLink seq0: wallet asset has no mark in any FinancialDataLog (feedless) — ` + + `deferring (retry once the mark feed has the asset); never booking an unvalued liability on the CHF paymentLink account`, + ); + } + const chf = assetLeg.amountChf; + + return { + sourceType: SOURCE_TYPE, + sourceId: `${ci.id}`, + seq: 0, + bookingDate, + valueDate: bookingDate, + legs: [assetLeg, { account: paymentLink, amount: -chf, priceChf: 1, amountChf: -chf, needsMark: false }], + }; + } + + // buyFiat / buyCrypto-swap: 3-leg, amountInChf-anchored received-Cr leg + fx-revaluation plug (§4.4a) + const product = this.productAnchor(ci); + if (!product) { + this.logger.error(`crypto_input ${ci.id} has neither buyFiat/buyCrypto nor isPayment — skip seq0`); + return undefined; + } + + const received = await this.liability(`${product.bucket}-received`); + const legs: LedgerLegInput[] = [ + assetLeg, + { account: received, amount: -product.amountInChf, priceChf: 1, amountChf: -product.amountInChf }, + ]; + await this.appendFxPlug(legs, await this.fxAccounts(), `crypto_input ${ci.id} seq0`); + + return { sourceType: SOURCE_TYPE, sourceId: `${ci.id}`, seq: 0, bookingDate, valueDate: bookingDate, legs }; + } + + // seq1 — standalone forward fee (§4.4): Dr EXPENSE/network-fee / Cr ASSET/{asset.uniqueName}. + // The fee's priceChf is derived from the persisted forwardFeeAmountChf/forwardFeeAmount pair, not the cache. + private async bookForwardFee(ci: CryptoInput, bookingDate: Date, marks: LedgerMarkCache): Promise { + if (!ci.outTxId || ci.forwardFeeAmountChf == null) return; // null fee → no leg (null strategy §5.1) + if (await this.alreadyBooked(ci.id, 1)) return; + + const wallet = await this.walletAsset(ci); + const feeChf = ci.forwardFeeAmountChf; + let feeNative = ci.forwardFeeAmount; + let mark = feeNative ? Util.round(feeChf / feeNative, 8) : null; + + // B7: the wallet leg is NATIVE crypto (the fee left the wallet). When forwardFeeAmount (the native fee) is missing, + // `-(feeNative ?? feeChf)` would book the CHF value as native units on the crypto wallet — a silent unit corruption. + // Instead derive the native from feeChf via the wallet mark (historical, else the B5 latest-mark bridge); if no mark + // exists at all, fail loud (retry) rather than book CHF as native. NEVER a CHF value in a native amount. + if (feeNative == null) { + const derivedMark = + (wallet.assetId != null ? marks.getMarkAt(wallet.assetId, bookingDate) : undefined) ?? + (wallet.assetId != null ? await this.markService.getLatestMark(wallet.assetId) : undefined); + if (derivedMark == null || derivedMark === 0) { + throw new Error( + `crypto_input ${ci.id} forward fee: forwardFeeAmountChf set but forwardFeeAmount missing and no mark to ` + + `derive the native fee — refusing to book a CHF value as native units`, + ); + } + feeNative = Util.round(feeChf / derivedMark, 8); + mark = derivedMark; + } + + const networkFee = await this.expense('network-fee'); + await this.bookingService.bookTx({ + sourceType: SOURCE_TYPE, + sourceId: `${ci.id}`, + seq: 1, + bookingDate, + valueDate: bookingDate, + legs: [ + { account: networkFee, amount: feeChf, priceChf: 1, amountChf: feeChf }, + { account: wallet, amount: -feeNative, priceChf: mark, amountChf: -feeChf }, + ], + }); + } + + // --- HELPERS --- // + + // appends an EXPENSE/INCOME fx-revaluation plug for the seq0 valuation residual amountInChf − mark×amount (§4.4a); + // sub-cent → the booking-service ROUNDING leg closes it. + // Major B5: an unmarked asset leg is first bridged with the youngest available mark (resolveLegsOrDefer) so this mixed + // tx (crypto asset leg + CHF-anchored received leg) balances — needsMark stays true, the mark-to-market job corrects + // the basis later. A truly feedless asset (no bridge) defers the row instead of handing an unbalanceable set to bookTx. + private async appendFxPlug( + legs: LedgerLegInput[], + fx: { income: LedgerAccount; expense: LedgerAccount }, + ref: string, + ): Promise { + if (!(await resolveLegsOrDefer(legs, this.markService, this.logger, ref))) return; + + const sumCents = legs.reduce((s, l) => s + Math.round(Util.round(l.amountChf ?? 0, 2) * 100), 0); + if (Math.abs(sumCents) <= Config.ledger.roundingToleranceCents) return; + + const residualChf = Util.round(-sumCents / 100, 2); + const account = residualChf >= 0 ? fx.income : fx.expense; + legs.push({ account, amount: residualChf, priceChf: 1, amountChf: residualChf }); + } + + private productAnchor(ci: CryptoInput): { bucket: string; amountInChf: number } | undefined { + if (ci.buyFiat?.amountInChf != null) return { bucket: 'buyFiat', amountInChf: ci.buyFiat.amountInChf }; + if (ci.buyCrypto?.amountInChf != null) return { bucket: 'buyCrypto', amountInChf: ci.buyCrypto.amountInChf }; + return undefined; + } + + // §4.12 (R3): per-seq gate via the ACTIVE booking AT this seq — NOT `nextSeq > seq`. crypto_input is multi-seq + // (seq0 input, seq1 forward-fee) and reverses seq0 in its content-change scan; the reversal/re-book live in the + // reserved correction range (≥1_000_000, §4.12), so a `nextSeq > 1` gate would wrongly report the forward-fee seq1 + // as booked after a seq0 reversal and strand it. hasActiveTxAt walks the reversal chain of the original at this seq. + private async alreadyBooked(id: number, seq: number): Promise { + return this.bookingService.hasActiveTxAt(SOURCE_TYPE, `${id}`, seq); + } + + private async walletAsset(ci: CryptoInput): Promise { + if (!ci.asset) throw new Error(`crypto_input ${ci.id} has no asset`); + const account = await this.accountService.findByAssetId(ci.asset.id); + if (!account) throw new Error(`Ledger account for asset ${ci.asset.id} not found (CoA bootstrap missing)`); + return account; + } + + private liability(qualifier: string): Promise { + return this.accountService.findOrCreate(`LIABILITY/${qualifier}`, AccountType.LIABILITY, CHF); + } + + private expense(qualifier: string): Promise { + return this.accountService.findOrCreate(`EXPENSE/${qualifier}`, AccountType.EXPENSE, CHF); + } + + private async fxAccounts(): Promise<{ income: LedgerAccount; expense: LedgerAccount }> { + return { + income: await this.accountService.findOrCreate('INCOME/fx-revaluation', AccountType.INCOME, CHF), + expense: await this.accountService.findOrCreate('EXPENSE/fx-revaluation', AccountType.EXPENSE, CHF), + }; + } +} diff --git a/src/subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts b/src/subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts new file mode 100644 index 0000000000..368431246e --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts @@ -0,0 +1,526 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Config } from 'src/config/config'; +import { ExchangeTx, ExchangeTxType } from 'src/integration/exchange/entities/exchange-tx.entity'; +import { ExchangeName } from 'src/integration/exchange/enums/exchange.enum'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { Util } from 'src/shared/utils/util'; +import { BankTx, BankTxIndicator, BankTxType } from 'src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity'; +import { Between, In, MoreThan, Repository } from 'typeorm'; +import { AccountType, LedgerAccount } from '../../entities/ledger-account.entity'; +import { LedgerLeg } from '../../entities/ledger-leg.entity'; +import { LedgerLegRepository } from '../../repositories/ledger-leg.repository'; +import { LedgerAccountService } from '../ledger-account.service'; +import { LedgerBookingService, LedgerLegInput, LedgerTxInput } from '../ledger-booking.service'; +import { LedgerMarkCache, LedgerMarkService } from '../ledger-mark.service'; +import { resolveLegsOrDefer } from './ledger-mark-bridge.helper'; +import { + getLedgerWatermark, + isCoveredByCutoverOpening, + runContentChangeScan, + setLedgerWatermark, +} from './ledger-watermark.helper'; + +const OK_STATUS = 'ok'; +const SOURCE_TYPE = 'exchange_tx'; +const TRADE_SOURCE_TYPE = 'ExchangeTrade'; +const RAIFFEISEN_SUSPENSE = 'SUSPENSE/untracked-bank-Raiffeisen-EUR'; +const SWEEP_MATCH_DAYS = 5; // ≤5d amount/date window (§4.3b, reuse findSenderReceiverPair logic, D13 A.4) + +/** + * Books exchange_tx Deposit/Withdrawal (route-disambiguated, §4.3a/§4.3b) and Trade (venue-spread-disambiguated, + * §4.3) — ONE @DfxCron method, ONE flag (Minor R8-1): deposits/withdrawals first, then trades. Pure observer: + * reads exchange_tx (+ bank_tx for route matching, + ledger legs for the Raiffeisen sweep), writes only ledger_*. + * + * Only status='ok' (eliminates Class 2). Trade seq = batch-stable, re-run-idempotent fill_index (Blocker R1-7). + */ +@Injectable() +export class ExchangeTxConsumer { + private readonly logger = new DfxLogger(ExchangeTxConsumer); + + constructor( + private readonly settingService: SettingService, + private readonly bookingService: LedgerBookingService, + private readonly accountService: LedgerAccountService, + private readonly markService: LedgerMarkService, + @InjectRepository(ExchangeTx) private readonly exchangeTxRepo: Repository, + @InjectRepository(BankTx) private readonly bankTxRepo: Repository, + private readonly ledgerLegRepository: LedgerLegRepository, + ) {} + + async process(): Promise { + const watermark = (await getLedgerWatermark(this.settingService, SOURCE_TYPE)) ?? { + lastProcessedId: 0, + lastReversalScan: new Date(0), + }; + + await this.processForward(watermark); + + // content-change scan (§4.3 reversal-trigger): the forward scan filters status='ok', so a row that was booked as + // 'ok' and LATER flips to failed/canceled is never re-selected forward (Class-2 elimination would break — the + // invalid booking stays). This scan selects by `updated > lastReversalScan` (status-agnostic) and, per row: + // - status != 'ok' → flat-reverse the active booking (the trade/deposit became invalid → "nothing booked"); + // - status == 'ok' with an amount/fee/route change → reverse + re-book the corrected legs (§4.12). + // Runs ALSO when the forward batch is empty. Re-read the watermark in case the forward batch advanced it. + const afterForward = (await getLedgerWatermark(this.settingService, SOURCE_TYPE)) ?? watermark; + await runContentChangeScan( + this.settingService, + SOURCE_TYPE, + afterForward, + this.exchangeTxRepo, + {}, + async (tx: ExchangeTx) => this.reconcileBooking(tx), + ); + } + + private async processForward(watermark: { lastProcessedId: number; lastReversalScan: Date }): Promise { + const batch = await this.exchangeTxRepo.find({ + where: { id: MoreThan(watermark.lastProcessedId), status: OK_STATUS }, + order: { id: 'ASC' }, + take: Config.ledger.backfillBatchSize, + }); + if (!batch.length) return; + + const times = batch.map((tx) => (tx.externalCreated ?? tx.created).getTime()); + const marks = await this.markService.preload( + // lookback so getMarkAt finds the latest mark at-or-before the earliest row timestamp + Util.daysBefore(2, new Date(Math.min(...times))), + new Date(Math.max(...times)), + ); + const fillIndexMap = await this.buildFillIndexMap(batch); + + let lastProcessedId = watermark.lastProcessedId; + for (const tx of batch) { + try { + const spec = await this.buildSpec(tx, marks, fillIndexMap); + if (spec) await this.bookingService.bookTx(spec); + lastProcessedId = tx.id; + } catch (e) { + this.logger.error(`Failed to book exchange_tx ${tx.id}:`, e); + break; // failure-isolation: leave watermark unchanged, retry next run (§4-header) + } + } + + if (lastProcessedId > watermark.lastProcessedId) { + await setLedgerWatermark(this.settingService, SOURCE_TYPE, { ...watermark, lastProcessedId }); + } + } + + // §4.3 content-change reconcile: a row whose status flipped away from 'ok' is flat-reversed (the booking became + // invalid); an 'ok' row whose amount/fee/route changed is reversed + re-booked with the corrected legs (§4.12). + private async reconcileBooking(tx: ExchangeTx): Promise { + const t = tx.externalCreated ?? tx.created; + // lookback so getMarkAt finds the latest mark at-or-before the row timestamp + const marks = await this.markService.preload(Util.daysBefore(2, t), t); + const fillIndexMap = await this.buildFillIndexMap([tx]); + const spec = await this.buildSpec(tx, marks, fillIndexMap); + if (!spec) return; // unbookable type → nothing to correct + + if (tx.status !== OK_STATUS) { + // ok→failed/canceled: the previously-booked tx must be removed (flat reversal, no re-book) — the row's + // identifiers are recomputed verbatim from buildSpec so the reversal targets exactly the original booking. + await this.bookingService.reverseActiveIfBooked(spec.sourceType, spec.sourceId, spec.seq); + return; + } + + // status 'ok': first correct an already-active booking whose amount/fee/route changed (§4.12). + if (await this.bookingService.reverseAndRebookIfChanged(spec)) return; + + // C1: reverseAndRebookIfChanged found nothing to correct — either the booking is active-and-unchanged, or the row + // never got a forward booking because it turned 'ok' only AFTER the id-watermark advanced over it (the forward + // scan filters status='ok'). Forward-book it iff NO tx has EVER been booked at this exact (sourceType, sourceId, + // seq): a late-settling row → book; an active-unchanged / flat-reversed one → skip (bookTx would collide UNIQUE). + // §6.3 covered-by-cutover-opening guard (keyed on the exchange_tx row id, not the composite trade sourceId): a row + // already 'ok' (settled) at the cutover snapshot has its value in the aggregate ASSET opening — re-booking its seq0 + // would double-count. A hole / post-boundary row is NOT covered → it still books fresh. + if ( + !(await this.bookingService.hasAnyTxAt(spec.sourceType, spec.sourceId, spec.seq)) && + !(await isCoveredByCutoverOpening(this.settingService, SOURCE_TYPE, tx.id)) + ) { + await this.bookingService.bookTx(spec); + } + } + + // builds the LedgerTxInput spec for an exchange_tx (or undefined for an unhandled type) — shared by the forward + // booker and the content-change reconcile so reversal targets the exact (sourceType, sourceId, seq) it was booked at. + private async buildSpec( + tx: ExchangeTx, + marks: LedgerMarkCache, + fillIndexMap: Map, + ): Promise { + const bookingDate = tx.externalCreated ?? tx.created; + + switch (tx.type) { + case ExchangeTxType.DEPOSIT: + return this.depositSpec(tx, bookingDate, marks); + case ExchangeTxType.WITHDRAWAL: + return this.withdrawalSpec(tx, bookingDate, marks); + case ExchangeTxType.TRADE: + return this.tradeSpec(tx, bookingDate, marks, fillIndexMap); + default: + this.logger.error(`Unhandled exchange_tx type ${tx.type} on exchange_tx ${tx.id}`); + return undefined; + } + } + + // §4.3/§4.3a — Deposit: Dr ASSET/{exchange}/{ccy} / Cr {routeCounterAccount} + private async depositSpec(tx: ExchangeTx, bookingDate: Date, marks: LedgerMarkCache): Promise { + const asset = await this.exchangeAsset(tx); + const chf = this.depositChf(tx, asset, bookingDate, marks); + const counter = await this.routeCounterAccount(tx, bookingDate); + + const assetLeg: LedgerLegInput = { + account: asset, + amount: +tx.amount, + priceChf: chf.priceChf, + amountChf: chf.amountChf, + needsMark: chf.needsMark, + }; + const counterLeg: LedgerLegInput = { + account: counter, + amount: -tx.amount, + priceChf: chf.priceChf, + amountChf: chf.amountChf != null ? -chf.amountChf : undefined, + needsMark: chf.needsMark, + }; + + return this.singleSpec(tx, bookingDate, [assetLeg, counterLeg]); + } + + // §4.3/§4.3a — Withdrawal: Dr {routeCounterAccount} / Cr ASSET/{exchange}/{ccy} (mirror) + private async withdrawalSpec(tx: ExchangeTx, bookingDate: Date, marks: LedgerMarkCache): Promise { + const asset = await this.exchangeAsset(tx); + const chf = this.depositChf(tx, asset, bookingDate, marks); + const counter = await this.routeCounterAccount(tx, bookingDate); + + const assetLeg: LedgerLegInput = { + account: asset, + amount: -tx.amount, + priceChf: chf.priceChf, + amountChf: chf.amountChf != null ? -chf.amountChf : undefined, + needsMark: chf.needsMark, + }; + const counterLeg: LedgerLegInput = { + account: counter, + amount: +tx.amount, + priceChf: chf.priceChf, + amountChf: chf.amountChf, + needsMark: chf.needsMark, + }; + + return this.singleSpec(tx, bookingDate, [counterLeg, assetLeg]); + } + + // §4.3 — Trade: Dr ASSET/{exchange}/{base} / Cr ASSET/{exchange}/{quote} + spread + (ccxt) fee leg + private async tradeSpec( + tx: ExchangeTx, + bookingDate: Date, + marks: LedgerMarkCache, + fillIndexMap: Map, + ): Promise { + const parsed = this.parseSymbol(tx); + if (!parsed) { + // unattributable trade → SUSPENSE rest + alarm (§4.3, not silently dropped) + const suspense = await this.accountService.findOrCreate( + `SUSPENSE/${tx.exchange}-trade-unattributed`, + AccountType.SUSPENSE, + 'CHF', + ); + this.logger.error(`exchange_tx ${tx.id} trade has no resolvable symbol/side → SUSPENSE`); + const chf = tx.amountChf ?? 0; + return this.singleSpec(tx, bookingDate, [ + { account: suspense, amount: chf, priceChf: 1, amountChf: chf }, + { account: suspense, amount: -chf, priceChf: 1, amountChf: -chf }, + ]); + } + + const { base, quote, isBuy } = parsed; + const baseAccount = await this.exchangeAssetByCcy(tx.exchange, base); + const quoteAccount = await this.exchangeAssetByCcy(tx.exchange, quote); + + // base leg: +amount on buy / −amount on sell; CHF = persisted amountChf (stage 1) ?? mark + const baseAmount = isBuy ? +tx.amount : -tx.amount; + const baseChf = tx.amountChf ?? this.markValue(baseAccount, tx.amount, bookingDate, marks); + const baseLeg: LedgerLegInput = { + account: baseAccount, + amount: baseAmount, + priceChf: baseChf != null ? Util.round(Math.abs(baseChf) / Math.abs(tx.amount || 1), 8) : null, + amountChf: baseChf != null ? (isBuy ? baseChf : -baseChf) : undefined, + needsMark: baseChf == null, + }; + + // A4: a settled ('ok') trade with NO cost (quote amount) is a data error — `cost ?? 0` would book a zero-quote trade + // and plug the FULL base value into spread (a phantom). Fail loud for an 'ok' trade; a non-'ok' row keeps `?? 0` + // because its spec is only used to target the flat reversal (sourceId/seq), never booked. + if (tx.cost == null && tx.status === OK_STATUS) { + throw new Error( + `exchange_tx ${tx.id} is an 'ok' trade with no cost (quote amount) — refusing to book a zero-quote`, + ); + } + const cost = tx.cost ?? 0; + const quoteAmount = isBuy ? -cost : +cost; + + const legs: LedgerLegInput[] = [baseLeg]; + const isMarketSpreadFee = tx.exchange === ExchangeName.SCRYPT; // Scrypt feeAmountChf IS the market spread (§4.3) + + if (isMarketSpreadFee) { + // Scrypt: ONE persisted spread leg = feeAmountChf (sign-aware), quote leg as plug (§4.3 variant i) + const spreadChf = tx.feeAmountChf ?? 0; + if (spreadChf !== 0) legs.push(await this.spreadLeg(tx.exchange, spreadChf)); + + // F4: resolve/bridge the base (and any unvalued asset) leg BEFORE forming the quote plug — exactly like the ccxt + // branch. Otherwise an unvalued base leg is silently counted as 0 CHF and the quote plug absorbs the FULL base + // value (the quote custody account misvalued by the whole base). resolveLegsOrDefer bridges the base with the + // youngest available mark (needsMark stays true); a truly feedless base defers the row (throw) instead of misbooking. + await resolveLegsOrDefer(legs, this.markService, this.logger, `exchange_tx ${tx.id} trade`); + + const plugChf = -legs.reduce((s, l) => s + (l.amountChf ?? 0), 0); // quote leg closes the tx (base now valued) + const quotePrice = quoteAmount !== 0 ? Util.round(Math.abs(plugChf) / Math.abs(quoteAmount), 8) : null; + legs.push({ + account: quoteAccount, + amount: quoteAmount, + priceChf: quotePrice, + amountChf: plugChf, + // a bridged (provisional) base makes the quote plug provisional too → let the mark-to-market job re-mark the + // quote account; with a real historical base mark the plug is the intended cost basis and needs no re-mark (F4) + needsMark: legs.some((l) => l.needsMark), + }); + } else { + // ccxt (Binance/MEXC/Kraken): quote leg with its OWN mark (not plug) + separate venue fee leg + a + // mark-based quote-spread plug leg that absorbs the base↔quote mark residual (§4.3, two distinct legs) + const quoteChf = this.markValue(quoteAccount, cost, bookingDate, marks); + legs.push({ + account: quoteAccount, + amount: quoteAmount, + priceChf: quoteChf != null ? Util.round(Math.abs(quoteChf) / Math.abs(cost || 1), 8) : null, + amountChf: quoteChf != null ? (isBuy ? -quoteChf : +quoteChf) : undefined, + needsMark: quoteChf == null, + }); + + const feeChf = tx.feeAmountChf; // separate venue fee (real ccxt fee, sign-aware) + if (feeChf != null && feeChf !== 0) legs.push(await this.spreadLeg(tx.exchange, feeChf)); + + // mark-based quote-spread leg: closes the base↔quote mark residual to 0 (sign-aware). Major B5: an ASSET leg + // without a historical mark is first bridged with the youngest available mark (resolveLegsOrDefer) so the trade + // balances — needsMark stays true, the mark-to-market job corrects the basis later. A truly feedless asset (no + // bridge) defers the row instead of handing an unbalanceable set to bookTx. + if (await resolveLegsOrDefer(legs, this.markService, this.logger, `exchange_tx ${tx.id} trade`)) { + const residualChf = -legs.reduce((s, l) => s + (l.amountChf ?? 0), 0); + const residualCents = Math.round(Util.round(residualChf, 2) * 100); + if (Math.abs(residualCents) > Config.ledger.roundingToleranceCents) { + legs.push(await this.spreadLeg(tx.exchange, residualChf)); + } + } + } + + const seq = fillIndexMap.get(tx.id) ?? 0; + const order = tx.order; + // Major B3: the composite trade sourceId MUST carry the exchange prefix `${exchange}:${order}`, matching the + // `${exchange}|${order}` fill-ranking key (buildFillIndexMap). Without it, the SAME order string on two different + // exchanges collapses to one sourceId → two distinct fills claim the same (sourceType, sourceId, seq) → UNIQUE + // collision on the second booker → permanent wedge. `${tx.id}` stays the fallback for an order-less trade. + const sourceId = order ? `${tx.exchange}:${order}` : `${tx.id}`; + const sourceType = order ? TRADE_SOURCE_TYPE : SOURCE_TYPE; + + return { + sourceType, + sourceId, + seq: order ? seq : 0, + bookingDate, + valueDate: bookingDate, + legs, + }; + } + + // --- ROUTE DISAMBIGUATION (§4.3a/§4.3b) --- // + + // determines the route-passing counter account of a deposit/withdrawal (read-only, §4.3a) + private async routeCounterAccount(tx: ExchangeTx, bookingDate: Date): Promise { + const ex = tx.exchange; + const ccy = tx.currency ?? tx.asset; + + // (R1) Raiffeisen sweep: Scrypt/EUR deposit matched against an open SUSPENSE/untracked-bank-Raiffeisen-EUR post + if (tx.type === ExchangeTxType.DEPOSIT && ex === ExchangeName.SCRYPT && ccy === 'EUR') { + const sweep = await this.matchRaiffeisenSweep(tx.amount, bookingDate); + if (sweep === 'match') { + return this.accountService.findOrCreate(RAIFFEISEN_SUSPENSE, AccountType.SUSPENSE, 'EUR'); + } + if (sweep === 'ambiguous') { + // two equal-amount open posts → leave in SUSPENSE + alarm, no guessing (§4.3b) + this.logger.error(`exchange_tx ${tx.id} Scrypt/EUR deposit ambiguous Raiffeisen sweep match → SUSPENSE`); + return this.accountService.findOrCreate(RAIFFEISEN_SUSPENSE, AccountType.SUSPENSE, 'EUR'); + } + } + + // (R2) bank→exchange: matching bank_tx KRAKEN/SCRYPT/SCB on the same route → TRANSIT/bank↔{ex}/{ccy} + if (await this.hasBankRouteMatch(tx, bookingDate)) { + return this.accountService.findOrCreate(`TRANSIT/bank↔${ex}/${ccy}`, AccountType.TRANSIT, ccy); + } + + // (R3) wallet→exchange: txId present (on-chain reference) → TRANSIT/wallet↔{ex}/{ccy} + if (tx.txId) { + return this.accountService.findOrCreate(`TRANSIT/wallet↔${ex}/${ccy}`, AccountType.TRANSIT, ccy); + } + + // (R4) no route determinable → SUSPENSE/{exchange}-deposit-unrouted/{ccy} + alarm (visible, not hidden) + this.logger.error(`exchange_tx ${tx.id} deposit/withdrawal unrouted → SUSPENSE/${ex}-deposit-unrouted/${ccy}`); + return this.accountService.findOrCreate(`SUSPENSE/${ex}-deposit-unrouted/${ccy}`, AccountType.SUSPENSE, ccy); + } + + // ≤5d amount/date window match against open Raiffeisen SUSPENSE posts (§4.3b) + private async matchRaiffeisenSweep(amount: number, bookingDate: Date): Promise<'match' | 'ambiguous' | 'none'> { + const account = await this.accountService.findByName(RAIFFEISEN_SUSPENSE); + if (!account) return 'none'; + + const from = Util.daysBefore(SWEEP_MATCH_DAYS, bookingDate); + const posts = await this.ledgerLegRepository.find({ + where: { account: { id: account.id }, tx: { bookingDate: Between(from, bookingDate) } }, + relations: { tx: true, account: true }, + }); + + // open Raiffeisen credits opened the post as a Dr (+amount, value entered SUSPENSE) for the sweep amount + const matches = posts.filter( + (p: LedgerLeg) => Math.abs(Math.abs(p.amount) - Math.abs(amount)) < 1e-8 && p.amount > 0, + ); + if (matches.length === 1) return 'match'; + if (matches.length > 1) return 'ambiguous'; + return 'none'; + } + + // read-only: a bank_tx KRAKEN/SCRYPT/SCB on the same currency within a date window (§4.3a-R2 rebuilt) + private async hasBankRouteMatch(tx: ExchangeTx, bookingDate: Date): Promise { + const ccy = tx.currency ?? tx.asset; + if (!ccy) return false; + + const indicator = tx.type === ExchangeTxType.DEPOSIT ? BankTxIndicator.DEBIT : BankTxIndicator.CREDIT; + const from = Util.daysBefore(SWEEP_MATCH_DAYS, bookingDate); + const to = Util.daysAfter(SWEEP_MATCH_DAYS, bookingDate); + + const matches = await this.bankTxRepo.find({ + where: { + type: In([BankTxType.KRAKEN, BankTxType.SCRYPT, BankTxType.SCB]), + currency: ccy, + creditDebitIndicator: indicator, + bookingDate: Between(from, to), + }, + take: 5, + }); + + return matches.some((b) => Math.abs(Math.abs(b.amount ?? 0) - Math.abs(tx.amount)) < 0.005); + } + + // --- FILL-INDEX (§4.3 Blocker R1-7, bounded per batch, Major R2-4) --- // + + // pre-computes the deterministic 0-based fill rank per (exchange, order) once per batch, in-memory + private async buildFillIndexMap(batch: ExchangeTx[]): Promise> { + const map = new Map(); + const orderKeys = new Set(); + for (const tx of batch) { + if (tx.type === ExchangeTxType.TRADE && tx.order) orderKeys.add(`${tx.exchange}|${tx.order}`); + } + if (!orderKeys.size) return map; + + const orders = [...orderKeys].map((k) => k.split('|')[1]); + // Major B4: rank over ALL fills of the order, status-AGNOSTIC (no status='ok' filter). Dense ranking over only the + // current 'ok' set is unstable: when a sibling fill flips ok→failed it drops out of the set and every later + // survivor's rank shifts down by one → the survivor's seq changes → its active booking is stranded at the old seq + // (cross-fill corruption) or its correction is silently discarded. `id`/`order` are immutable, so ranking over all + // fills gives each fill a permanent id-ordered slot; a failed fill keeps its slot (its booking is flat-reversed by + // reconcileBooking, which does not need the slot freed) → survivors never move. + const existing = await this.exchangeTxRepo.find({ + where: { type: ExchangeTxType.TRADE, order: In(orders) }, + select: { id: true, exchange: true, order: true }, + }); + + // merge the batch rows over the query result: the ranking is status-agnostic (all fills of the order, any status), + // so a fill that flipped away from 'ok' still appears and keeps its id-ordered slot; the merge only refreshes each + // batch row's (exchange, order) from the freshest in-memory copy (and covers a fill not yet visible to the read). + // Ranking is by id and order-preserving → a fill's rank is immutable, so the reversal targets the right seq (§4.3). + const merged = new Map(); + for (const e of existing) merged.set(e.id, e); + for (const tx of batch) { + if (tx.type === ExchangeTxType.TRADE && tx.order) + merged.set(tx.id, { id: tx.id, exchange: tx.exchange, order: tx.order }); + } + + const byKey = Util.groupByAccessor<{ id: number; exchange: string; order?: string }, string>( + [...merged.values()], + (e) => `${e.exchange}|${e.order}`, + ); + for (const rows of byKey.values()) { + rows.sort((a, b) => a.id - b.id); + rows.forEach((row, idx) => map.set(row.id, idx)); + } + + return map; + } + + // --- HELPERS --- // + + private singleSpec(tx: ExchangeTx, bookingDate: Date, legs: LedgerLegInput[]): LedgerTxInput { + return { + sourceType: SOURCE_TYPE, + sourceId: `${tx.id}`, + seq: 0, + bookingDate, + valueDate: bookingDate, + legs, + }; + } + + // §4.3 amountChf null fallback (Minor R9-4): persisted amountChf (stage 1) ?? mark × amount (stage 2) ?? needsMark + private depositChf( + tx: ExchangeTx, + asset: LedgerAccount, + bookingDate: Date, + marks: LedgerMarkCache, + ): { priceChf: number | null; amountChf?: number; needsMark: boolean } { + if (tx.amountChf != null) { + const priceChf = tx.amount ? Util.round(tx.amountChf / tx.amount, 8) : null; + return { priceChf, amountChf: tx.amountChf, needsMark: false }; + } + + const mark = asset.assetId != null ? marks.getMarkAt(asset.assetId, bookingDate) : undefined; + if (mark != null) return { priceChf: mark, amountChf: Util.round(mark * tx.amount, 2), needsMark: false }; + + return { priceChf: null, amountChf: undefined, needsMark: true }; + } + + private markValue( + asset: LedgerAccount, + amount: number, + bookingDate: Date, + marks: LedgerMarkCache, + ): number | undefined { + const mark = asset.assetId != null ? marks.getMarkAt(asset.assetId, bookingDate) : undefined; + return mark != null ? Util.round(mark * Math.abs(amount), 2) : undefined; + } + + // sign-aware spread leg: feeAmountChf > 0 → EXPENSE/spread-{exchange}; < 0 (maker rebate) → INCOME/spread-{exchange} + private async spreadLeg(exchange: ExchangeName, feeAmountChf: number): Promise { + const type = feeAmountChf > 0 ? AccountType.EXPENSE : AccountType.INCOME; + const prefix = feeAmountChf > 0 ? 'EXPENSE' : 'INCOME'; + const account = await this.accountService.findOrCreate(`${prefix}/spread-${exchange}`, type, 'CHF'); + return { account, amount: feeAmountChf, priceChf: 1, amountChf: feeAmountChf }; + } + + private async exchangeAsset(tx: ExchangeTx): Promise { + return this.exchangeAssetByCcy(tx.exchange, tx.currency ?? tx.asset); + } + + private async exchangeAssetByCcy(exchange: ExchangeName, ccy?: string): Promise { + if (!ccy) throw new Error(`exchange_tx asset/currency missing for ${exchange}`); + const account = await this.accountService.findByName(`${exchange}/${ccy}`); + if (!account) throw new Error(`Ledger account ${exchange}/${ccy} not found (CoA bootstrap missing)`); + return account; + } + + // trade base/quote via symbol+side (not null-pair); reuse dashboard-reconciliation parse logic (rebuilt, §4.3) + private parseSymbol(tx: ExchangeTx): { base: string; quote: string; isBuy: boolean } | undefined { + if (!tx.symbol || !tx.side) return undefined; + const parts = tx.symbol.split('/'); + if (parts.length !== 2 || !parts[0] || !parts[1]) return undefined; + return { base: parts[0], quote: parts[1], isBuy: tx.side.toLowerCase() === 'buy' }; + } +} diff --git a/src/subdomains/core/accounting/services/consumers/ledger-mark-bridge.helper.ts b/src/subdomains/core/accounting/services/consumers/ledger-mark-bridge.helper.ts new file mode 100644 index 0000000000..b5b5061544 --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/ledger-mark-bridge.helper.ts @@ -0,0 +1,59 @@ +import { Config } from 'src/config/config'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { Util } from 'src/shared/utils/util'; +import { LedgerLegInput } from '../ledger-booking.service'; +import { LedgerMarkService } from '../ledger-mark.service'; + +/** + * §5.1/§5.2 Major B5 — resolves the needsMark (amountChf == null) asset legs of a to-be-booked tx before the residual + * plug, so a MIXED tx (needsMark leg + CHF-valued legs) never wedges the whole source at a fixed historical bookingDate. + * + * For each unvalued asset-backed leg it bridges the missing historical mark with the youngest available mark + * (LedgerMarkService.getLatestMark, latest ≤ now) — the leg is then CHF-valued so the tx can balance, but needsMark + * STAYS true, so the daily mark-to-market job (§5.3) revalues the account to the real rate later. Returns: + * - true → every leg is now CHF-valued → the caller closes the residual with its site-specific fx/spread plug; + * - false → an unvalued leg remains but the valued legs already net to ~0 (an all-native transfer) → no plug; + * - THROWS → an unvalued (feedless) leg remains on a MIXED, unbalanceable tx: NEVER hand such a set to bookTx (it + * would throw the CHF-imbalance programming-error mid-booking, or worse book a wrong value). The row is DEFERRED — + * the caller's failure-isolation leaves the watermark and retries; a feedless asset is a genuine data state, not a + * price-timing gap (only then is a hard block correct, §fail-closed). + */ +export async function resolveLegsOrDefer( + legs: LedgerLegInput[], + markService: LedgerMarkService, + logger: DfxLogger, + ref: string, +): Promise { + for (const leg of legs) { + if (leg.amountChf != null) continue; // already valued (persisted amountChf or a historical mark) + const assetId = leg.account.assetId; + const bridge = assetId != null ? await markService.getLatestMark(assetId) : undefined; + if (bridge == null) continue; // no mark anywhere for this asset (or a CHF-denominated leg) → handled below + + // bridge the missing historical mark: leg.amount is already signed, so amountChf carries the sign. needsMark stays + // true → the mark-to-market job re-marks the account to the real rate later (the bridge is only a provisional basis). + leg.priceChf = bridge; + leg.amountChf = Util.round(bridge * leg.amount, 2); + leg.needsMark = true; + logger.warn( + `${ref}: no mark at the booking date for asset ${assetId} — bridged with the latest available mark ${bridge} ` + + `(needsMark stays true; the mark-to-market job corrects the basis to the real rate)`, + ); + } + + if (!legs.some((l) => l.amountChf == null)) return true; // all valued → caller plugs the residual + + // an unvalued (feedless) leg remains. If the valued legs already net to ~0 it is an all-native transfer → no plug. + const valuedCents = legs.reduce( + (sum, l) => sum + (l.amountChf != null ? Math.round(Util.round(l.amountChf, 2) * 100) : 0), + 0, + ); + if (Math.abs(valuedCents) > Config.ledger.roundingToleranceCents) { + throw new Error( + `${ref}: an asset leg has no mark in any FinancialDataLog (feedless) and the tx is mixed with CHF-valued legs — ` + + `deferring (retry once the mark feed has the asset); an unbalanceable leg set is never handed to bookTx`, + ); + } + + return false; // valued legs balance → all-native transfer, nothing to plug +} diff --git a/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts b/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts new file mode 100644 index 0000000000..31534a5cbf --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts @@ -0,0 +1,259 @@ +import { Config } from 'src/config/config'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { FindOptionsOrder, FindOptionsRelations, FindOptionsWhere, Raw, Repository } from 'typeorm'; + +// per-source checkpoint (§11.3): id-watermark + content-change scan cursor. +// The content-change cursor is the COMBINED (updated, id) pair (§4.12): `lastReversalScan` alone cannot paginate +// within one millisecond — when >batchSize rows share a single `updated` (a bulk update of one tx), a timestamp-only +// watermark either drops the rows beyond the batch (advance over the group) or never progresses (cap below the group). +// `lastReversalScanId` is the id of the last row scanned AT `lastReversalScan`, so the next select resumes inside the +// group via `updated > scan OR (updated = scan AND id > scanId)`. +export interface LedgerWatermark { + lastProcessedId: number; + lastReversalScan: Date; + lastReversalScanId?: number; // id within the lastReversalScan group (combined cursor); absent → 0 (group start) +} + +const WATERMARK_KEY_PREFIX = 'ledgerWatermark.'; + +const CUTOVER_BOUNDARY_KEY_PREFIX = 'ledgerCutoverBoundary.'; + +const CUTOVER_UNPRICED_KEY_PREFIX = 'ledgerCutoverUnpricedIds.'; + +export interface LedgerCutoverBoundary { + boundaryId: number; // MAX(id) settled at the snapshot; immutable (the watermark's lastProcessedId drifts, this does not) + holeIds: number[]; // ids <= boundaryId OPEN at the snapshot (value NOT in the aggregate) → their forward seq0 IS their opening +} + +/** + * Reads the per-source watermark (§11.3). Stored as a JSON string under `ledgerWatermark.` and read + * via `getObj`. Returns undefined when no watermark exists yet (the cutover initialises it before the gate opens). + */ +export async function getLedgerWatermark( + settingService: SettingService, + source: string, +): Promise { + const raw = await settingService.getObj<{ + lastProcessedId: number; + lastReversalScan: string; + lastReversalScanId?: number; + }>(`${WATERMARK_KEY_PREFIX}${source}`); + if (!raw) return undefined; + + return { + lastProcessedId: raw.lastProcessedId, + lastReversalScan: new Date(raw.lastReversalScan), + lastReversalScanId: raw.lastReversalScanId ?? 0, + }; +} + +/** + * Writes the per-source watermark (§11.3) — exclusively via `settingService.set` (never `setObj`/`settingRepo`; + * §4.10 R2-exception-a). The watermark is only advanced after a successful batch (§4-header failure-isolation). + */ +export async function setLedgerWatermark( + settingService: SettingService, + source: string, + watermark: LedgerWatermark, +): Promise { + await settingService.set( + `${WATERMARK_KEY_PREFIX}${source}`, + JSON.stringify({ + lastProcessedId: watermark.lastProcessedId, + lastReversalScan: watermark.lastReversalScan.toISOString(), + lastReversalScanId: watermark.lastReversalScanId ?? 0, + }), + ); +} + +/** + * Writes the per-source cutover boundary (§6.3) — exclusively via `settingService.set` (never `setObj`/`settingRepo`; + * §4.10 R2-exception-a), pinned ONCE, up front in the cutover's watermark-init step (BEFORE any opening is booked), + * set-only-if-unset together with the watermark: a fail-loud retry reuses the pinned value verbatim and never + * overwrites it. `boundaryId` is the immutable MAX(id) of rows settled at the snapshot; the watermark's + * `lastReversalScan`/`lastProcessedId` drift forward as the forward scan advances — this copy does NOT. `holeIds` are + * the ids ≤ boundaryId that were OPEN at the snapshot: their value is NOT in the aggregate opening (the cutover + * deliberately excludes the pending bucket), so their forward seq0 IS their opening and must be booked exactly once + * when they settle post-cutover. + */ +export async function setCutoverBoundary( + settingService: SettingService, + source: string, + boundary: LedgerCutoverBoundary, +): Promise { + await settingService.set( + `${CUTOVER_BOUNDARY_KEY_PREFIX}${source}`, + JSON.stringify({ boundaryId: boundary.boundaryId, holeIds: boundary.holeIds }), + ); +} + +/** + * Reads the per-source cutover boundary (§6.3) — the counterpart to `setCutoverBoundary`, reading the raw setting via + * `get` (NOT `getObj`) so it returns undefined when unset. Used by the cutover to make the boundary set-only-if-unset: + * a fail-loud retry reuses the first-run boundary verbatim instead of recomputing it against a drifted DB (where an + * `updated` bump between runs would flip a settled-at-snapshot row's classification → double-booked forward seq0). + */ +export async function getCutoverBoundary( + settingService: SettingService, + source: string, +): Promise { + const raw = await settingService.get(`${CUTOVER_BOUNDARY_KEY_PREFIX}${source}`); + if (raw == null) return undefined; + const parsed = JSON.parse(raw) as { boundaryId: number; holeIds: number[] }; + return { boundaryId: parsed.boundaryId, holeIds: parsed.holeIds }; +} + +/** + * Cutover-opening membership guard (§6.3): forward-book a row's seq0 IFF its value is NOT already in the cutover + * aggregate opening. Opening-membership is a fact FIXED at snapshot time; the cutover pins it per source as an + * immutable boundary (`setCutoverBoundary`) — set-only-if-unset BEFORE any opening on the FIRST cutover run and never + * overwritten on a retry, which is what makes it immutable — so it can be reconstructed later even for a source with + * no immutable settlement timestamp (e.g. crypto_input, whose `updated` gets bumped post-cutover — "settled at the + * snapshot" cannot be re-derived after the fact, so it is persisted at cutover time): + * - `boundaryId` = MAX(id) of rows SETTLED at the snapshot (= the watermark's initial `lastProcessedId`; the watermark + * drifts forward as the forward scan advances, THIS copy does not). + * - `holeIds` = ids ≤ boundaryId that were OPEN (NOT settled) at the snapshot AND created within OPEN_ROW_LOOKBACK_DAYS + * — their value is NOT in the aggregate (the cutover deliberately excludes the pending bucket), so their forward + * seq0 IS their opening and MUST be booked exactly once when they settle post-cutover. + * Covered (→ SKIP the fresh forward seq0) iff: the cutover ran AND `id ≤ boundaryId` AND `id ∉ holeIds`. Rows with + * `id > boundaryId` and recorded holes forward-book normally. HORIZON CAVEAT: a row unsettled at the snapshot but older + * than OPEN_ROW_LOOKBACK_DAYS is treated as terminal (never recorded as a hole) → it is reported covered and its + * post-cutover seq0 is suppressed — consistent with the cutover's existing 90-day open-row horizon + * (OPEN_ROW_LOOKBACK_DAYS, used by openLiabilities), which assumes a >90d-old open row is abandoned and carries no + * opening. + * The three early-returns are documented invariant branches, NOT silent fallbacks: a missing boundary means the + * cutover has not run yet (pre-cutover default — the forward path is the only booker); `id > boundaryId` means the row + * settled after the snapshot (its value is not in the aggregate → book); the hole check is the exact open-at-snapshot + * carve-out. Each is a correct branch of the invariant and must be kept. + */ +export async function isCoveredByCutoverOpening( + settingService: SettingService, + source: string, + rowId: number, +): Promise { + const raw = await settingService.getObj<{ boundaryId: number; holeIds: number[] }>( + `${CUTOVER_BOUNDARY_KEY_PREFIX}${source}`, + ); + if (!raw) return false; // cutover not run → forward-book normally (pre-cutover default) + if (rowId > raw.boundaryId) return false; // post-boundary → not in the aggregate → book + return !raw.holeIds.includes(rowId); // id <= boundary: covered unless it was an open-at-cutover hole +} + +/** + * Writes the per-source unpriced-at-cutover id list (§6.1 F2) — exclusively via `settingService.set` (never + * `setObj`/`settingRepo`; §4.10 R2-exception-a). A row OPEN at the snapshot whose `amountInChf` was NULL could get NO + * per-row received/owed/paymentLink opening (the CHF anchor is missing) — its value stays in the aggregate ASSET + * opening (the input/collateral feed). These ids are pinned so the forward consumer keys on them to SKIP+advance (with + * an ERROR alarm) instead of wedging on the never-opened received gate, and to suppress the Card seq0 that would + * otherwise double-book the gross once the row is priced post-cutover. Pinned set-only-if-unset, BEFORE the ready flag. + */ +export async function setCutoverUnpricedIds( + settingService: SettingService, + source: string, + ids: number[], +): Promise { + await settingService.set(`${CUTOVER_UNPRICED_KEY_PREFIX}${source}`, JSON.stringify(ids)); +} + +/** + * Reads the raw per-source unpriced-at-cutover setting (§6.1 F2) via `get` (NOT `getObj`) so it returns undefined when + * unset — the counterpart the cutover uses to make the pin set-only-if-unset (a fail-loud retry reuses the first-run + * list verbatim, mirroring `getCutoverBoundary`). + */ +export async function getCutoverUnpricedIdsRaw( + settingService: SettingService, + source: string, +): Promise { + return (await settingService.get(`${CUTOVER_UNPRICED_KEY_PREFIX}${source}`)) ?? undefined; +} + +/** + * True iff `rowId` was pinned as unpriced at the cutover (§6.1 F2) — its value is in the aggregate ASSET opening, no + * per-row liability opening exists, so the forward consumer must SKIP+advance (never wedge on the closed received gate) + * and never (re-)book its Card seq0. Absent list (cutover not run / no unpriced rows) → false (the normal path). + */ +export async function isUnpricedAtCutover( + settingService: SettingService, + source: string, + rowId: number, +): Promise { + const raw = await settingService.getObj(`${CUTOVER_UNPRICED_KEY_PREFIX}${source}`); + return Array.isArray(raw) && raw.includes(rowId); +} + +const contentChangeLogger = new DfxLogger('LedgerContentChangeScan'); + +/** + * Content-change scan (§4.12 / §6.3 Late-settling-Block). The forward `id > lastProcessedId` watermark misses two + * row classes whose `id` is already <= lastProcessedId but whose state changes AFTER the cutover/last batch: + * - **late-settling cutover-straddling rows** (§6.3): a pre-cutover open buy_fiat/buy_crypto whose settlement is set + * post-cutover — its id sits at/below the cutover-initialised watermark, so the forward scan never re-selects it, + * yet its (append-only) seq1/2/3 must still be booked once `outputAmount`/`isComplete` are set; + * - **content changes** to already-processed rows (the consumer's idempotent booker re-runs and books only the new + * seqs / no-ops the existing ones). + * + * This scan selects rows past the COMBINED (updated, id) cursor `(lastReversalScan, lastReversalScanId)` — i.e. + * `updated > scan OR (updated = scan AND id > scanId)` — ordered by (updated ASC, id ASC), runs the SAME idempotent + * forward `book(row)` per row, and advances the cursor to the last committed row ONLY after a clean run (§4.12 Minor + * R12-2: a failed booking leaves the cursor at the last good row → the failed row is re-scanned next run, self-healing + * retry — no correction is ever lost). The booker stays idempotent via its per-seq `alreadyBooked`/`nextSeq` guard, so + * a row that is both in the forward batch and the content-change scan is booked exactly once. + * + * The combined cursor (not `updated`-only) is required to paginate WITHIN one millisecond: if >batchSize rows share a + * single `updated` (a bulk update of one tx), an `updated`-only watermark would either advance over the group (dropping + * the same-`updated` rows beyond the batch — their booking permanently lost) or never progress (a full single-`updated` + * batch could never be passed). Resuming at `(updated, id)` walks the group row-by-row across runs → no row is skipped + * and progress is guaranteed even when one `updated` group exceeds the batch size. + */ +export async function runContentChangeScan( + settingService: SettingService, + source: string, + watermark: LedgerWatermark, + repo: Repository, + scanRelations: FindOptionsRelations, + book: (row: T) => Promise, +): Promise { + const batchSize = Config.ledger.backfillBatchSize; + const scan = watermark.lastReversalScan; + const scanId = watermark.lastReversalScanId ?? 0; + + // combined (updated, id) cursor: `updated > scan OR (updated = scan AND id > scanId)`. Expressed via `Raw` so the + // id-tiebreak lives in the same WHERE the ORM builds. `col` is the fully-aliased `updated` column (e.g. + // `"ExchangeTx"."updated"`); the `id` reference reuses that alias so it stays unambiguous when relations are joined. + const changed = await repo.find({ + where: { + updated: Raw( + (col) => { + const idCol = col.replace(/(["`]?)updated\1\s*$/, (_m, q) => `${q}id${q}`); + return `(${col} > :lcsScan OR (${col} = :lcsScan AND ${idCol} > :lcsScanId))`; + }, + { lcsScan: scan, lcsScanId: scanId }, + ), + } as FindOptionsWhere, + relations: scanRelations, + order: { updated: 'ASC', id: 'ASC' } as FindOptionsOrder, + take: batchSize, + }); + if (!changed.length) return; + + let cursor: { updated: Date; id: number } | undefined; + for (const row of changed) { + try { + await book(row); + cursor = { updated: row.updated, id: row.id }; // advance only past rows whose (idempotent) re-book committed + } catch (e) { + contentChangeLogger.error(`Content-change scan failed on ${source} ${row.id}:`, e); + break; // leave the failed row + the rest for the next run → self-healing retry (§4.12 Minor R12-2) + } + } + + // advance the persisted cursor when at least one row committed and the cursor actually moved past the old position + if (cursor && (cursor.updated.getTime() > scan.getTime() || cursor.id > scanId)) { + await setLedgerWatermark(settingService, source, { + ...watermark, + lastReversalScan: cursor.updated, + lastReversalScanId: cursor.id, + }); + } +} diff --git a/src/subdomains/core/accounting/services/consumers/liquidity-mgmt.consumer.ts b/src/subdomains/core/accounting/services/consumers/liquidity-mgmt.consumer.ts new file mode 100644 index 0000000000..7cfc0db6cd --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/liquidity-mgmt.consumer.ts @@ -0,0 +1,232 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Config } from 'src/config/config'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { Util } from 'src/shared/utils/util'; +import { LiquidityManagementOrder } from 'src/subdomains/core/liquidity-management/entities/liquidity-management-order.entity'; +import { + LiquidityManagementBridges, + LiquidityManagementExchanges, + LiquidityManagementOrderStatus, + LiquidityManagementSystem, +} from 'src/subdomains/core/liquidity-management/enums'; +import { MoreThan, Repository } from 'typeorm'; +import { AccountType, LedgerAccount } from '../../entities/ledger-account.entity'; +import { LedgerAccountService } from '../ledger-account.service'; +import { LedgerBookingService, LedgerLegInput } from '../ledger-booking.service'; +import { LedgerMarkCache, LedgerMarkService } from '../ledger-mark.service'; +import { + getLedgerWatermark, + isCoveredByCutoverOpening, + runContentChangeScan, + setLedgerWatermark, +} from './ledger-watermark.helper'; + +const SOURCE_TYPE = 'liquidity_management_order'; +const BRIDGE_IN_COMMAND = 'bridge-in'; // dEURO bridge-in (§4.8 branch 4, system=dEURO) + +/** + * §4.8 LiquidityMgmt consumer (D14 B.5/B.6). Pure observer: reads liquidity_management_order (+ its eager action), + * writes only ledger_*. It books ONLY bridge / external-record-less transfers — every other movement is booked by + * a more specific authoritative consumer, so this consumer skips it after a read-only cross-check (no external + * call). Each skip branch carries a mandatory comment naming the authoritative source + the correlationId join. + * + * The four cross-check branches (§4.1 matrix): + * (1) action.system ∈ exchange systems → SKIP (exchange_tx authoritative) + * (2) action.system='DfxDex' && command∈{purchase,sell} → SKIP (liquidity_order (dex) authoritative, §4.8a) + * (3) action.system='DfxDex' && command='withdraw' → SKIP (target deposit exchange_tx authoritative) + * (4) action.system ∈ bridge systems (*Bridge / dEURO bridge-in) → BOOK as a TRANSIT/bridge movement + */ +@Injectable() +export class LiquidityMgmtConsumer { + private readonly logger = new DfxLogger(LiquidityMgmtConsumer); + + constructor( + private readonly settingService: SettingService, + private readonly bookingService: LedgerBookingService, + private readonly accountService: LedgerAccountService, + private readonly markService: LedgerMarkService, + @InjectRepository(LiquidityManagementOrder) + private readonly liquidityManagementOrderRepo: Repository, + ) {} + + async process(): Promise { + const watermark = (await getLedgerWatermark(this.settingService, SOURCE_TYPE)) ?? { + lastProcessedId: 0, + lastReversalScan: new Date(0), + }; + + await this.processForward(watermark); + + // content-change scan (§4.12 / C1): the forward id-scan filters status='Complete' and advances lastProcessedId + // OVER not-yet-Complete ids; a bridge order that completes AFTER the watermark passed it is forward-unreachable. + // The status-agnostic (updated, id)-cursor scan re-selects it and forward-books it idempotently (book()/bookBridge + // gate on alreadyBooked) once it satisfies the settled filter. Re-read the watermark in case the forward batch + // advanced lastProcessedId above. + const afterForward = (await getLedgerWatermark(this.settingService, SOURCE_TYPE)) ?? watermark; + await runContentChangeScan( + this.settingService, + SOURCE_TYPE, + afterForward, + this.liquidityManagementOrderRepo, + {}, + async (order: LiquidityManagementOrder) => { + // honour the forward settled-filter: only a status='Complete' order is bookable; a not-yet-Complete row is + // left (cursor advances; its completion bumps `updated` → re-selected). book() is idempotent (alreadyBooked). + if (order.status !== LiquidityManagementOrderStatus.COMPLETE) return; + // §6.3 covered-by-cutover-opening guard: a bridge order already Complete at the cutover snapshot is in the + // aggregate ASSET opening — its `updated` bump post-cutover re-selects it here, but re-booking its seq0 would + // double-count. A hole / post-boundary row is NOT covered → it still books fresh. + if (await isCoveredByCutoverOpening(this.settingService, SOURCE_TYPE, order.id)) return; + await this.book(order, await this.preloadMarks([order])); + }, + ); + } + + private async processForward(watermark: { lastProcessedId: number; lastReversalScan: Date }): Promise { + const batch = await this.liquidityManagementOrderRepo.find({ + where: { id: MoreThan(watermark.lastProcessedId), status: LiquidityManagementOrderStatus.COMPLETE }, + order: { id: 'ASC' }, + take: Config.ledger.backfillBatchSize, + }); + if (!batch.length) return; + + const marks = await this.preloadMarks(batch); + + let lastProcessedId = watermark.lastProcessedId; + for (const order of batch) { + try { + await this.book(order, marks); + lastProcessedId = order.id; + } catch (e) { + this.logger.error(`Failed to book liquidity_management_order ${order.id}:`, e); + break; // failure-isolation: leave watermark unchanged, retry next run (§4-header) + } + } + + if (lastProcessedId > watermark.lastProcessedId) { + await setLedgerWatermark(this.settingService, SOURCE_TYPE, { ...watermark, lastProcessedId }); + } + } + + // mark cache with a 2-day lookback so getMarkAt finds the latest mark at-or-before the earliest row's `updated` + private async preloadMarks(orders: LiquidityManagementOrder[]): Promise { + const times = orders.map((o) => o.updated.getTime()); + return this.markService.preload(Util.daysBefore(2, new Date(Math.min(...times))), new Date(Math.max(...times))); + } + + /** + * Cross-check (read-only, no external call) THEN book only the bridge branch. The skip branches are evidence + * for why the value-moving leg is booked elsewhere — the consumer must NOT call any lifecycle/strategy method + * (those carry pricing/external side effects, §4.10). + */ + private async book(order: LiquidityManagementOrder, marks: LedgerMarkCache): Promise { + const system = order.action?.system; + const command = order.action?.command; + + // (4) bridge systems (*Bridge / dEURO bridge-in) → BOOK. Checked FIRST because the dEURO bridge-in command is + // the one exception to the §4.8 branch-1 exchange skip: system=dEURO is otherwise an exchange (branch 1), but the + // bridge-in command is a bridge hop with NO external settlement record → the liquidity_management_order is its + // own authoritative evidence (D14 §B.5). Resolving branch 4 before branch 1 keeps the dEURO bridge-in bookable. + if (this.isBridgeSystem(system, command)) return this.bookBridge(order, marks); + + // (1) exchange-routed transfers/trades (Binance/MEXC/Scrypt/Kraken/XT/Frankencoin/dEURO/Juice) → SKIP. + // The exchange_tx Deposit/Withdrawal/Trade row is the authoritative settlement record (D14 §B.5); the + // exchange_tx consumer books it. correlationId(exchange_tx) = LM-order-id (read-only, no double booking). + if (this.isExchangeSystem(system)) return; + + if (system === LiquidityManagementSystem.DFX_DEX) { + // (2) DfxDex purchase/sell → SKIP. The liquidity_order (dex) row (txId + feeAmount) is the only authoritative + // on-chain settlement record (D14 §B.2 proved exchange_tx empty for DfxDex purchase/sell) → booked by the + // LiquidityOrderDex consumer (§4.8a). Join key: liquidity_order.correlationId == liquidity_management_order.id. + if (command === 'purchase' || command === 'sell') return; + + // (3) DfxDex withdraw → SKIP. The target exchange_tx Deposit is authoritative (LM correlationId = deposit + // txId, D14 §B.6) → booked by the exchange_tx consumer. The LM withdraw row is execution detail only. + if (command === 'withdraw') return; + } + + // anything else: not a value-moving settlement this consumer owns → skip + log (visible, not silent). + this.logger.verbose(`liquidity_management_order ${order.id} (system=${system}, command=${command}) → skip`); + } + + /** + * §4.8 branch 4 — bridge / dEURO bridge-in. The value crosses the bridge as a single asset (same value on two + * chains); the bridge hop is held by a TRANSIT/bridge/{ccy} account between the two mirror legs. Booking: + * `Dr ASSET/wallet-target / Cr TRANSIT/bridge/{ccy}` (the arriving target side; the mirror sending side closes + * the TRANSIT account when its own order settles). Both legs are mark-valued in the SAME mark → Σ CHF = 0, no + * plug (one currency, L-01). + */ + private async bookBridge(order: LiquidityManagementOrder, marks: LedgerMarkCache): Promise { + if (await this.alreadyBooked(order.id)) return; // idempotent re-run + + const targetAsset = order.pipeline?.rule?.targetAsset; + const amount = order.outputAmount; + if (!targetAsset || amount == null || amount === 0) { + this.logger.error(`liquidity_management_order ${order.id} bridge has no target asset / output amount — skip`); + return; + } + + const bookingDate = order.updated; + const ccy = this.currencyOf(targetAsset); + + const wallet = await this.assetAccount(targetAsset); + const transit = await this.accountService.findOrCreate(`TRANSIT/bridge/${ccy}`, AccountType.TRANSIT, ccy); + + // both legs carry the SAME mark (one currency) → Σ CHF closes to 0 without a spread plug + const mark = marks.getMarkAt(targetAsset.id, bookingDate); + const chf = mark != null ? Util.round(mark * amount, 2) : undefined; + const needsMark = chf == null; + + const legs: LedgerLegInput[] = [ + { account: wallet, amount, priceChf: mark ?? null, amountChf: chf, needsMark }, + { + account: transit, + amount: -amount, + priceChf: mark ?? null, + amountChf: chf != null ? -chf : undefined, + needsMark, + }, + ]; + + await this.bookingService.bookTx({ + sourceType: SOURCE_TYPE, + // sourceId = the stable entity PK (NOT correlationId, which mutates across hops — §4.8 Minor R8-5) + sourceId: `${order.id}`, + seq: 0, + bookingDate, + valueDate: bookingDate, + legs, + }); + } + + // --- HELPERS --- // + + // exchange-routed systems (Binance/MEXC/Scrypt/Kraken/XT/Frankencoin/dEURO/Juice); exchange_tx is authoritative + private isExchangeSystem(system?: LiquidityManagementSystem): boolean { + return system != null && LiquidityManagementExchanges.includes(system); + } + + // bridge systems: the *Bridge family (incl. Boltz) plus the dEURO bridge-in command (system=dEURO is otherwise + // an exchange system; the bridge-in command is the only dEURO path this consumer books, §4.8 branch 4) + private isBridgeSystem(system?: LiquidityManagementSystem, command?: string): boolean { + if (system != null && LiquidityManagementBridges.includes(system)) return true; + return system === LiquidityManagementSystem.DEURO && command === BRIDGE_IN_COMMAND; + } + + private currencyOf(asset: Asset): string { + return asset.dexName ?? asset.name; + } + + private async assetAccount(asset: Asset): Promise { + const account = await this.accountService.findByAssetId(asset.id); + if (!account) throw new Error(`Ledger account for asset ${asset.id} not found (CoA bootstrap missing)`); + return account; + } + + private async alreadyBooked(id: number): Promise { + return (await this.bookingService.nextSeq(SOURCE_TYPE, `${id}`)) > 0; + } +} diff --git a/src/subdomains/core/accounting/services/consumers/liquidity-order-dex.consumer.ts b/src/subdomains/core/accounting/services/consumers/liquidity-order-dex.consumer.ts new file mode 100644 index 0000000000..9486f66584 --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/liquidity-order-dex.consumer.ts @@ -0,0 +1,302 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Config } from 'src/config/config'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { Util } from 'src/shared/utils/util'; +import { + LiquidityOrder, + LiquidityOrderContext, + LiquidityOrderType, +} from 'src/subdomains/supporting/dex/entities/liquidity-order.entity'; +import { In, IsNull, MoreThan, Not, Repository } from 'typeorm'; +import { AccountType, LedgerAccount } from '../../entities/ledger-account.entity'; +import { LedgerAccountService } from '../ledger-account.service'; +import { LedgerBookingService, LedgerLegInput } from '../ledger-booking.service'; +import { LedgerMarkCache, LedgerMarkService } from '../ledger-mark.service'; +import { resolveLegsOrDefer } from './ledger-mark-bridge.helper'; +import { + getLedgerWatermark, + isCoveredByCutoverOpening, + runContentChangeScan, + setLedgerWatermark, +} from './ledger-watermark.helper'; + +const SOURCE_TYPE = 'liquidity_order'; +const BOOKED_TYPES = [LiquidityOrderType.PURCHASE, LiquidityOrderType.SELL]; +const CHF = 'CHF'; +const DEX = 'DfxDex'; + +// the LM consumer (§4.8 branch 2) skips DfxDex purchase/sell on these contexts because THIS consumer books them. +// BuyFiatReturn/BuyCryptoReturn/Manual/RefPayout are excluded — their value-moving payout runs via the +// payout_order consumer (§4.5); their liquidity_order is purchase detail of the parent only (D10 §D.1). +const BOOKED_CONTEXTS = [ + LiquidityOrderContext.LIQUIDITY_MANAGEMENT, + LiquidityOrderContext.BUY_CRYPTO, + LiquidityOrderContext.TRADING, +]; + +/** + * §4.8a LiquidityOrderDex consumer (new, D14 §B.3/§B.6, D04 §7.2/§7.3; Blocker R5-1). Authoritative for DfxDex + * purchase/sell ON-CHAIN swaps (txId IS NOT NULL). Pure observer: reads liquidity_order (dex subdomain), writes + * only ledger_*. + * + * liquidity_order has NO *Chf field (targetAmount/swapAmount/feeAmount all native, D04 §0.2) → both ASSET legs + + * the feeAmount leg are stage-2 mark-valued; the CHF residual of two independently mark-valued legs is a real + * venue/valuation spread (NOT rounding) → a dedicated EXPENSE/INCOME spread-DfxDex plug leg, never ROUNDING + * (§1.15/§1.11). The source @Index([context, correlationId]) is NOT unique (dex.service finds plural rows per + * tuple), so the ledger sourceId carries the row id (`::`) to stay row-unique; the + * ledger UNIQUE(sourceType,sourceId,seq) then backstops idempotency (Minor R6-8). + */ +@Injectable() +export class LiquidityOrderDexConsumer { + private readonly logger = new DfxLogger(LiquidityOrderDexConsumer); + + constructor( + private readonly settingService: SettingService, + private readonly bookingService: LedgerBookingService, + private readonly accountService: LedgerAccountService, + private readonly markService: LedgerMarkService, + @InjectRepository(LiquidityOrder) private readonly liquidityOrderRepo: Repository, + ) {} + + async process(): Promise { + const watermark = (await getLedgerWatermark(this.settingService, SOURCE_TYPE)) ?? { + lastProcessedId: 0, + lastReversalScan: new Date(0), + }; + + await this.processForward(watermark); + + // content-change scan (§4.12 / C1): the forward id-scan filters txId IS NOT NULL + context + Purchase/Sell and + // advances lastProcessedId OVER not-yet-settled ids (a Reservation row that later gets a txId, a context/type not + // yet matching); such a row settling AFTER the watermark passed it is forward-unreachable. The status-agnostic + // (updated, id)-cursor scan re-selects it and forward-books it idempotently (book() gates on alreadyBooked) once + // it satisfies the settled filter. Re-read the watermark in case the forward batch advanced lastProcessedId above. + const afterForward = (await getLedgerWatermark(this.settingService, SOURCE_TYPE)) ?? watermark; + await runContentChangeScan( + this.settingService, + SOURCE_TYPE, + afterForward, + this.liquidityOrderRepo, + {}, + async (order: LiquidityOrder) => { + // honour the forward settled-filter (txId set + booked context + Purchase/Sell); a row not yet matching it is + // left (cursor advances; a later settle bumps `updated` → re-selected). book() is idempotent (per-sourceId + // nextSeq gate). + if (order.txId == null || !BOOKED_CONTEXTS.includes(order.context) || !BOOKED_TYPES.includes(order.type)) + return; + // §6.3 covered-by-cutover-opening guard (boundary keyed by liquidity_order.id): a swap already settled at the + // cutover snapshot is in the aggregate ASSET opening — its `updated` bump post-cutover re-selects it here, but + // re-booking its seq0 would double-count. A hole / post-boundary row is NOT covered → it still books fresh. + if (await isCoveredByCutoverOpening(this.settingService, SOURCE_TYPE, order.id)) return; + await this.book(order, await this.preloadMarks([order])); + }, + ); + } + + private async processForward(watermark: { lastProcessedId: number; lastReversalScan: Date }): Promise { + // type IN ('Purchase','Sell') AND txId IS NOT NULL excludes Reservation rows (no on-chain settlement, D10 §D.1). + // context='Trading' liquidity_orders are exclusively type=Reservation (no own swap txId); the arb swap is booked + // solely via trading_order.txId (§4.9). The type IN ('Purchase','Sell') AND txId IS NOT NULL filter excludes + // them — no double booking with the trading_order consumer. + const batch = await this.liquidityOrderRepo.find({ + where: { + id: MoreThan(watermark.lastProcessedId), + txId: Not(IsNull()), + context: In(BOOKED_CONTEXTS), + type: In(BOOKED_TYPES), + }, + order: { id: 'ASC' }, + take: Config.ledger.backfillBatchSize, + }); + if (!batch.length) return; + + const marks = await this.preloadMarks(batch); + + let lastProcessedId = watermark.lastProcessedId; + for (const order of batch) { + try { + await this.book(order, marks); + lastProcessedId = order.id; + } catch (e) { + this.logger.error(`Failed to book liquidity_order ${order.id}:`, e); + break; // failure-isolation: leave watermark unchanged, retry next run (§4-header) + } + } + + if (lastProcessedId > watermark.lastProcessedId) { + await setLedgerWatermark(this.settingService, SOURCE_TYPE, { ...watermark, lastProcessedId }); + } + } + + // mark cache with a 2-day lookback so getMarkAt finds the latest mark at-or-before the earliest row's `updated` + private async preloadMarks(orders: LiquidityOrder[]): Promise { + const times = orders.map((o) => o.updated.getTime()); + return this.markService.preload(Util.daysBefore(2, new Date(Math.min(...times))), new Date(Math.max(...times))); + } + + /** + * §4.8a booking: Dr ASSET/{targetAsset} (mark) / Cr ASSET/{swapAsset} (mark) + EXPENSE/network-fee (feeAmount, + * mark, against ASSET/{feeAsset}) + EXPENSE/INCOME spread-DfxDex = PLUG (the mark residual / venue spread). + */ + private async book(order: LiquidityOrder, marks: LedgerMarkCache): Promise { + if (await this.alreadyBooked(order)) return; // idempotent re-run (§4.8a) + + const { targetAsset, swapAsset, targetAmount, swapAmount } = order; + if (!targetAsset || !swapAsset || targetAmount == null || swapAmount == null) { + this.logger.error(`liquidity_order ${order.id} has no valid swap (target/swap asset/amount missing) — skip`); + return; + } + + const bookingDate = order.updated; + + // both ASSET legs always via stage-2 mark (no *Chf field, §5.1); missing mark → needsMark, plug stays open + const targetLeg = this.assetLeg( + await this.assetAccount(targetAsset), + targetAsset, + +targetAmount, + bookingDate, + marks, + ); + const swapLeg = this.assetLeg(await this.assetAccount(swapAsset), swapAsset, -swapAmount, bookingDate, marks); + + const legs: LedgerLegInput[] = [targetLeg, swapLeg]; + await this.appendFeeLegs(order, bookingDate, marks, targetLeg, swapLeg, legs); + await this.appendSpreadPlug(legs, `liquidity_order ${order.id}`); + + await this.bookingService.bookTx({ + sourceType: SOURCE_TYPE, + // sourceId = '::' — the row id makes it unique because the source + // @Index([context, correlationId]) is NOT unique (plural rows per tuple); the ledger UNIQUE backstops it (R6-8) + sourceId: this.sourceId(order), + seq: 0, + bookingDate, + valueDate: bookingDate, + legs, + }); + } + + /** + * §4.8a fee leg (Major R7-1 fee-asset disambiguation + Major R2-5 null-strategy). feeAmount is native in + * feeAsset → the network-fee EXPENSE CHF runs over getMarkAt(feeAsset); the native counter reduces + * ASSET/{feeAsset}, NEVER blindly the swap/target asset. Three explicit cases. + */ + private async appendFeeLegs( + order: LiquidityOrder, + bookingDate: Date, + marks: LedgerMarkCache, + targetLeg: LedgerLegInput, + swapLeg: LedgerLegInput, + legs: LedgerLegInput[], + ): Promise { + const { feeAsset, feeAmount, targetAsset, swapAsset } = order; + if (!feeAsset || feeAmount == null || feeAmount === 0) return; // null strategy §5.1: no fee → no fee leg + + // Major B5 (F2): the CHF EXPENSE/network-fee leg has no assetId, so resolveLegsOrDefer cannot bridge it — but it + // WOULD bridge the fee's ASSET counter-leg (own leg or the folded swap/target leg) with getLatestMark. Bridging one + // side while the other stays unvalued nets to the fee value → the swap wedges head-of-line (fee mark first fed AFTER + // the swap date). Derive feeChf here from the SAME bridged mark the asset counter-leg uses (getLatestMark, same + // asset) so both sides move in lockstep and Σ balances. needsMark stays true whenever the HISTORICAL mark was + // absent → the leg is provisional and the mark-to-market job re-marks it later. A truly feedless fee asset (no + // bridge either) leaves feeChf undefined → both fee legs stay unvalued → resolveLegsOrDefer defers the row. + const historicalMark = marks.getMarkAt(feeAsset.id, bookingDate); + const mark = historicalMark ?? (await this.markService.getLatestMark(feeAsset.id)); + const feeChf = mark != null ? Util.round(mark * feeAmount, 2) : undefined; + const feeNeedsMark = historicalMark == null; + + // EXPENSE/network-fee (CHF-only) closes the CHF cross-asset side; the native fee leaves ASSET/{feeAsset} + legs.push(this.networkFeeLeg(await this.expense('network-fee'), feeChf, feeNeedsMark)); + + if (feeAsset.id === swapAsset.id) { + // feeAsset == swapAsset: no own Cr leg — increase the existing Cr ASSET/swap leg by feeAmount (native + CHF) + this.addToLeg(swapLeg, -feeAmount, feeChf != null ? -feeChf : undefined, feeNeedsMark); + return; + } + if (feeAsset.id === targetAsset.id) { + // feeAsset == targetAsset: reduce the existing Dr ASSET/target leg by feeAmount (the fee leaves the target) + this.addToLeg(targetLeg, -feeAmount, feeChf != null ? -feeChf : undefined, feeNeedsMark); + return; + } + + // a THIRD asset (the typical case: native EVM gas ≠ swap/target): its own Cr ASSET/{feeAsset} native leg + legs.push({ + account: await this.assetAccount(feeAsset), + amount: -feeAmount, + priceChf: mark ?? null, + amountChf: feeChf != null ? -feeChf : undefined, + needsMark: feeNeedsMark, + }); + } + + // appends an EXPENSE/INCOME spread-DfxDex plug for the CHF residual; sub-cent → ROUNDING (booking service). + // Major B5: an ASSET/fee leg without a historical mark is first bridged with the youngest available mark + // (resolveLegsOrDefer) so the swap balances — needsMark stays true, the mark-to-market job corrects the basis later. + // A truly feedless swap/fee asset (no bridge) defers the row instead of handing an unbalanceable set to bookTx. + private async appendSpreadPlug(legs: LedgerLegInput[], ref: string): Promise { + if (!(await resolveLegsOrDefer(legs, this.markService, this.logger, ref))) return; + + const sumCents = legs.reduce((s, l) => s + Math.round(Util.round(l.amountChf ?? 0, 2) * 100), 0); + if (Math.abs(sumCents) <= Config.ledger.roundingToleranceCents) return; + + const residualChf = Util.round(-sumCents / 100, 2); + const account = residualChf >= 0 ? await this.income(`spread-${DEX}`) : await this.expense(`spread-${DEX}`); + legs.push({ account, amount: residualChf, priceChf: 1, amountChf: residualChf }); + } + + // --- LEG BUILDERS --- // + + private assetLeg( + account: LedgerAccount, + asset: Asset, + amount: number, + bookingDate: Date, + marks: LedgerMarkCache, + ): LedgerLegInput { + const mark = marks.getMarkAt(asset.id, bookingDate); + const chf = mark != null ? Util.round(mark * Math.abs(amount), 2) : undefined; + return { + account, + amount, + priceChf: mark ?? null, + amountChf: chf != null ? (amount >= 0 ? chf : -chf) : undefined, + needsMark: chf == null, + }; + } + + // CHF-only EXPENSE/network-fee leg (native side is the ASSET/{feeAsset} leg) + private networkFeeLeg(account: LedgerAccount, feeChf: number | undefined, needsMark: boolean): LedgerLegInput { + return { account, amount: feeChf ?? 0, priceChf: 1, amountChf: feeChf, needsMark }; + } + + private addToLeg(leg: LedgerLegInput, nativeDelta: number, chfDelta: number | undefined, needsMark: boolean): void { + leg.amount = Util.round(leg.amount + nativeDelta, 8); + if (chfDelta != null && leg.amountChf != null) leg.amountChf = Util.round(leg.amountChf + chfDelta, 2); + if (needsMark || chfDelta == null) leg.needsMark = true; + } + + // --- HELPERS --- // + + private sourceId(order: LiquidityOrder): string { + return `${order.context}:${order.correlationId}:${order.id}`; + } + + private async assetAccount(asset: Asset): Promise { + const account = await this.accountService.findByAssetId(asset.id); + if (!account) throw new Error(`Ledger account for asset ${asset.id} not found (CoA bootstrap missing)`); + return account; + } + + private expense(qualifier: string): Promise { + return this.accountService.findOrCreate(`EXPENSE/${qualifier}`, AccountType.EXPENSE, CHF); + } + + private income(qualifier: string): Promise { + return this.accountService.findOrCreate(`INCOME/${qualifier}`, AccountType.INCOME, CHF); + } + + private async alreadyBooked(order: LiquidityOrder): Promise { + return (await this.bookingService.nextSeq(SOURCE_TYPE, this.sourceId(order))) > 0; + } +} diff --git a/src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts b/src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts new file mode 100644 index 0000000000..65f5f3026c --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts @@ -0,0 +1,445 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Config } from 'src/config/config'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { Util } from 'src/shared/utils/util'; +import { BuyCrypto } from 'src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity'; +import { RefReward } from 'src/subdomains/core/referral/reward/ref-reward.entity'; +import { BuyFiat } from 'src/subdomains/core/sell-crypto/process/buy-fiat.entity'; +import { + PayoutOrder, + PayoutOrderContext, + PayoutOrderStatus, +} from 'src/subdomains/supporting/payout/entities/payout-order.entity'; +import { MoreThan, Repository } from 'typeorm'; +import { AccountType, LedgerAccount } from '../../entities/ledger-account.entity'; +import { LedgerLeg } from '../../entities/ledger-leg.entity'; +import { LedgerTx } from '../../entities/ledger-tx.entity'; +import { LedgerAccountService } from '../ledger-account.service'; +import { LedgerBookingService, LedgerLegInput } from '../ledger-booking.service'; +import { LedgerMarkCache, LedgerMarkService } from '../ledger-mark.service'; +import { resolveLegsOrDefer } from './ledger-mark-bridge.helper'; +import { getLedgerWatermark, runContentChangeScan, setLedgerWatermark } from './ledger-watermark.helper'; + +const SOURCE_TYPE = 'payout_order'; +const CUTOVER_SOURCE = 'cutover'; +const CUTOVER_LOG_ID_KEY = 'ledgerCutoverLogId'; +const CHF = 'CHF'; +const AMOUNT_NULL_GUARD = 1e-12; + +// LIABILITY bucket per context (§4.5 context-Branch); RefPayout uses an EXPENSE counter instead +const LIABILITY_BUCKET: Partial> = { + [PayoutOrderContext.BUY_CRYPTO]: 'buyCrypto-owed', + [PayoutOrderContext.BUY_CRYPTO_RETURN]: 'buyCrypto-owed', + [PayoutOrderContext.BUY_FIAT_RETURN]: 'buyFiat-owed', + [PayoutOrderContext.MANUAL]: 'manual-debt', +}; + +// cutover per-row owed-opening marker product prefix per context (§6.1; snake_case, matches the cutover marker) +const CUTOVER_OWED_MARKER: Partial> = { + [PayoutOrderContext.BUY_CRYPTO]: 'buy_crypto-owed', + [PayoutOrderContext.BUY_CRYPTO_RETURN]: 'buy_crypto-owed', + [PayoutOrderContext.BUY_FIAT_RETURN]: 'buy_fiat-owed', +}; + +/** + * The ONLY booker of all payout network fees (§4.5/§1.7, all contexts). Pure observer: reads payout_order + * (+ ref_reward for the RefPayout correlationId join), writes only ledger_*. + * + * payout_order has no *Chf for the main amount → the wallet-ASSET Cr leg is stage-2 mark-valued and the + * completion↔settlement mark drift is absorbed by an EXPENSE/INCOME fx-revaluation plug (Blocker R2-2). The + * Dr counter branches on context: LIABILITY/{bucket} (BuyCrypto/Return/Manual) vs EXPENSE/refReward (RefPayout, + * deterministic main leg priceChf = amountInChf/amount → no plug on the main leg, Minor R7-5). Native fee legs + * go against the FEE asset (Major R7-1); a fee in the payout asset itself folds into the wallet leg. + */ +@Injectable() +export class PayoutOrderConsumer { + private readonly logger = new DfxLogger(PayoutOrderConsumer); + + constructor( + private readonly settingService: SettingService, + private readonly bookingService: LedgerBookingService, + private readonly accountService: LedgerAccountService, + private readonly markService: LedgerMarkService, + @InjectRepository(PayoutOrder) private readonly payoutOrderRepo: Repository, + @InjectRepository(RefReward) private readonly refRewardRepo: Repository, + // read-only — the LIABILITY-Dr leg carries the owed completion CHF (amountInChf − totalFeeAmountChf, §4.5 + // "CHF from completion"); the WP1 repo summary lists only PayoutOrder/RefReward, but the §4.5 body requires + // the completion CHF, derivable only from the linked product → these two read-repos are needed (deviation + // documented). correlationId == product.id (buy-crypto-out.service.ts:142 / buy-fiat.service.ts:283). + @InjectRepository(BuyCrypto) private readonly buyCryptoRepo: Repository, + @InjectRepository(BuyFiat) private readonly buyFiatRepo: Repository, + // read-only — the cutover per-row owed-opening lookup (§4.5 Major R6-1), analog to the bank-tx consumer + @InjectRepository(LedgerTx) private readonly ledgerTxRepo: Repository, + ) {} + + async process(): Promise { + const watermark = (await getLedgerWatermark(this.settingService, SOURCE_TYPE)) ?? { + lastProcessedId: 0, + lastReversalScan: new Date(0), + }; + + await this.processForward(watermark); + + // content-change scan (§4.12 / C1): the forward id-scan filters status='Complete' and advances lastProcessedId + // OVER not-yet-Complete ids; a row that completes AFTER the watermark passed it is forward-unreachable. The + // status-agnostic (updated, id)-cursor scan re-selects it and forward-books it idempotently (book() gates on + // alreadyBooked) once it satisfies the settled filter. Runs ALSO when the forward batch is empty. Re-read the + // watermark in case the forward batch advanced lastProcessedId above. + const afterForward = (await getLedgerWatermark(this.settingService, SOURCE_TYPE)) ?? watermark; + await runContentChangeScan( + this.settingService, + SOURCE_TYPE, + afterForward, + this.payoutOrderRepo, + {}, + async (order: PayoutOrder) => { + // honour the forward settled-filter: only a status='Complete' row is bookable. A not-yet-Complete row is left + // (cursor advances; its later completion bumps `updated` → re-selected). book() is idempotent (alreadyBooked). + if (order.status !== PayoutOrderStatus.COMPLETE) return; + await this.book(order, await this.preloadMarks([order])); + }, + ); + } + + private async processForward(watermark: { lastProcessedId: number; lastReversalScan: Date }): Promise { + // settlement = status='Complete' (per-chain complete() transition, all chains, §4.5) + const batch = await this.payoutOrderRepo.find({ + where: { id: MoreThan(watermark.lastProcessedId), status: PayoutOrderStatus.COMPLETE }, + order: { id: 'ASC' }, + take: Config.ledger.backfillBatchSize, + }); + if (!batch.length) return; + + const marks = await this.preloadMarks(batch); + + let lastProcessedId = watermark.lastProcessedId; + for (const order of batch) { + try { + await this.book(order, marks); + lastProcessedId = order.id; + } catch (e) { + this.logger.error(`Failed to book payout_order ${order.id}:`, e); + break; // failure-isolation: leave watermark unchanged, retry next run (§4-header) + } + } + + if (lastProcessedId > watermark.lastProcessedId) { + await setLedgerWatermark(this.settingService, SOURCE_TYPE, { ...watermark, lastProcessedId }); + } + } + + // mark cache with a 2-day lookback so getMarkAt finds the latest mark at-or-before the earliest row's `updated` + private async preloadMarks(orders: PayoutOrder[]): Promise { + const times = orders.map((o) => o.updated.getTime()); + return this.markService.preload(Util.daysBefore(2, new Date(Math.min(...times))), new Date(Math.max(...times))); + } + + private async book(order: PayoutOrder, marks: LedgerMarkCache): Promise { + if (await this.alreadyBooked(order.id)) return; // idempotent re-run + if (!order.asset) throw new Error(`payout_order ${order.id} has no asset`); + if (Math.abs(order.amount) < AMOUNT_NULL_GUARD) { + this.logger.error(`payout_order ${order.id} has amount≈0 — skip (avoids NaN priceChf, Minor R6-6)`); + return; + } + + const bookingDate = order.updated; + const counter = + order.context === PayoutOrderContext.REF_PAYOUT + ? await this.refRewardCounter(order) + : await this.liabilityCounter(order, bookingDate, marks); + if (!counter) return; + + // the wallet-ASSET Cr leg + the payout-asset fee folded into it (Major R7-1 / Minor R13-3) + const wallet = await this.assetAccount(order.asset); + const walletLeg = this.walletCrLeg(order, counter, marks, bookingDate, wallet); + + const legs: LedgerLegInput[] = [counter.leg, walletLeg]; + await this.appendDistinctFeeLegs(order, bookingDate, marks, legs); + + await this.bookingService.bookTx({ + sourceType: SOURCE_TYPE, + sourceId: `${order.id}`, + seq: 0, + bookingDate, + valueDate: bookingDate, + legs: await this.withFxPlug(legs, `payout_order ${order.id}`), + }); + } + + /** + * The wallet-ASSET Cr leg. The main payout amount is mark-valued (liability contexts) or amountInChf-anchored + * (RefPayout, deterministic). A fee hop in the payout asset itself folds in here (native + mark-based CHF), + * making the combined leg's priceChf a mixed effective rate (NOT a market mark, Minor R7-5 / R13-3). + */ + private walletCrLeg( + order: PayoutOrder, + counter: PayoutCounter, + marks: LedgerMarkCache, + bookingDate: Date, + wallet: LedgerAccount, + ): LedgerLegInput { + const fee = this.payoutAssetFeeNative(order, marks, bookingDate); + + const native = Util.round(order.amount + fee.amount, 8); + const mainChf = counter.mainChf; // mark × amount (liability) or amountInChf (RefPayout) + const chf = mainChf != null ? Util.round(mainChf + fee.chf, 2) : undefined; + const needsMark = counter.needsMark || (fee.amount !== 0 && fee.needsMark); + + return { + account: wallet, + amount: -native, + priceChf: chf != null && Math.abs(native) >= AMOUNT_NULL_GUARD ? Util.round(chf / native, 8) : null, + amountChf: chf != null ? -chf : undefined, + needsMark, + }; + } + + // §4.5 RefPayout Dr leg: EXPENSE/refReward = ref_reward.amountInChf (deterministic main-leg CHF, no plug) + private async refRewardCounter(order: PayoutOrder): Promise { + // payout_order.correlationId = ref_reward.id (string) for RefPayout (ref-reward-out.service.ts:73) + const reward = await this.refRewardRepo.findOneBy({ id: +order.correlationId }); + const amountInChf = reward?.amountInChf; + if (amountInChf == null) { + this.logger.error(`payout_order ${order.id} RefPayout has no ref_reward.amountInChf — skip`); + return undefined; + } + + return { leg: this.namedLeg(await this.expense('refReward'), amountInChf), mainChf: amountInChf, needsMark: false }; + } + + /** + * §4.5 BuyCrypto/BuyCryptoReturn/BuyFiatReturn/Manual Dr leg: LIABILITY/{bucket}. The Dr CHF is, in order: + * (1) the cutover OPENING CHF for a cutover-straddling owed-row (§6.1 per-row marker `:{buy_crypto|buy_fiat} + * -owed:`, = outputAmount × mark@snapshot, Major R6-1) — for a row opened pre-cutover there is no + * completion CHF from this run; (2) else the owed COMPLETION value (amountInChf − totalFeeAmountChf of the linked + * product, the value §4.6/§4.7 seq1 credited to owed). Either way `owed` closes cent-exact to 0; the wallet Cr leg + * is the settlement mark × amount, and the opening/completion↔settlement drift is taken by the fx-revaluation plug + * (Blocker R2-2). If neither resolves (non-numeric correlationId / Manual / not found), fall back to the settlement + * mark (defensive). + */ + private async liabilityCounter( + order: PayoutOrder, + bookingDate: Date, + marks: LedgerMarkCache, + ): Promise { + const bucket = LIABILITY_BUCKET[order.context]; + if (!bucket) { + this.logger.error(`payout_order ${order.id} has unhandled context ${order.context} — skip`); + return undefined; + } + + // §4.5 Major R6-1: a cutover-straddling owed-row was opened by the cutover (§6.1 per-row marker) at the opening + // CHF (outputAmount × mark@snapshot), NOT the completion CHF — debit that exact opening anchor so owed closes to 0. + // For a regular post-cutover row no opening exists → fall back to the §4.6/§4.7 seq1 completion CHF. + const openingChf = await this.cutoverOwedOpeningChf(order); + const completionChf = openingChf ?? (await this.owedCompletionChf(order)); // persisted owed value (§4.5) + const mark = marks.getMarkAt(order.asset.id, bookingDate); + let settlementChf = mark != null ? Util.round(mark * order.amount, 2) : undefined; + + // F6: with NO completion anchor AND no historical settlement mark, the LIABILITY-Dr leg is CHF-denominated + // (assetId=NULL) → the mark-to-market job can NEVER revalue it. Booking it unvalued (needsMark), mixed with the + // bridged wallet leg, would DEFER every run (mixed-unbalanceable → withFxPlug throws → watermark wedge). Bridge the + // settlement from the youngest available mark so BOTH legs are valued now (the wallet ASSET leg keeps needsMark → + // the daily mark-to-market job corrects its basis). A truly feedless payout asset fails loud WITH an alarm — never + // a never-revaluable unvalued needsMark leg on an assetId-less liability. + if (completionChf == null && settlementChf == null) { + const bridgeMark = await this.markService.getLatestMark(order.asset.id); + if (bridgeMark == null) { + this.logger.error( + `payout_order ${order.id} owed leg unvaluable: no completion CHF and payout asset ${order.asset.id} is feedless (no mark anywhere) — refusing an unvalued LIABILITY leg on an assetId-less account`, + ); + throw new Error( + `payout_order ${order.id} owed LIABILITY leg has no completion/settlement CHF and the payout asset is feedless — deferring (retry once the mark feed has the asset)`, + ); + } + settlementChf = Util.round(bridgeMark * order.amount, 2); + } + + // Dr LIABILITY = completion CHF (closes owed to 0); mainChf = settlement mark × amount (values the wallet leg + // → the drift completion↔settlement lands in the fx plug) + const liabilityChf = completionChf ?? settlementChf; + // the wallet leg re-marks whenever the historical settlement mark was missing/bridged (assetId != null → the daily + // mark-to-market job corrects its basis); the CHF-denominated LIABILITY leg is never a mark-to-market candidate + const needsMark = mark == null; + + return { + leg: { + account: await this.liability(bucket), + amount: liabilityChf ?? 0, + priceChf: 1, + amountChf: liabilityChf, + needsMark: liabilityChf == null, + }, + mainChf: settlementChf, + needsMark, + }; + } + + // the owed completion CHF (amountInChf − totalFeeAmountChf) of the linked product (§4.5). correlationId == + // product.id; BuyCrypto/BuyCryptoReturn → buy_crypto, BuyFiatReturn → buy_fiat. undefined → mark fallback. + private async owedCompletionChf(order: PayoutOrder): Promise { + const id = +order.correlationId; + if (!Number.isInteger(id)) return undefined; // e.g. network-start-fee correlationId → mark fallback + + if (order.context === PayoutOrderContext.BUY_FIAT_RETURN) { + const bf = await this.buyFiatRepo.findOneBy({ id }); + return this.completionChf(bf?.amountInChf, bf?.totalFeeAmountChf); + } + + const bc = await this.buyCryptoRepo.findOneBy({ id }); + return this.completionChf(bc?.amountInChf, bc?.totalFeeAmountChf); + } + + private completionChf(amountInChf?: number, totalFeeAmountChf?: number): number | undefined { + if (amountInChf == null) return undefined; + return Util.round(amountInChf - (totalFeeAmountChf ?? 0), 2); + } + + // §4.5 Major R6-1 — looks up the cutover per-row owed-opening leg CHF (marker `:{buy_crypto-owed| + // buy_fiat-owed}:`); the prefix is the snapshot logId persisted in ledgerCutoverLogId. Returns the + // opening anchor (= −leg.amountChf) so the owed-Dr matches the cutover opening exactly, or undefined for a regular + // post-cutover row / a context without an owed opening (Manual/RefPayout). Mirrors bank-tx.consumer.ts. + private async cutoverOwedOpeningChf(order: PayoutOrder): Promise { + const marker = CUTOVER_OWED_MARKER[order.context]; + if (!marker) return undefined; // Manual / unmapped context → no per-row owed opening + + const id = +order.correlationId; + if (!Number.isInteger(id)) return undefined; // non-numeric correlationId → no per-row opening to match + + const cutoverLogId = await this.settingService.get(CUTOVER_LOG_ID_KEY); + if (cutoverLogId == null) return undefined; + + const accountName = `LIABILITY/${LIABILITY_BUCKET[order.context]}`; + const opening = await this.ledgerTxRepo.findOne({ + where: { sourceType: CUTOVER_SOURCE, sourceId: `${cutoverLogId}:${marker}:${id}` }, + relations: { legs: { account: true } }, + }); + const leg = opening?.legs?.find((l: LedgerLeg) => l.account?.name === accountName); + if (leg?.amountChf == null) return undefined; + + return Util.round(-leg.amountChf, 2); // the opening Cr leg is −openingChf → owed-Dr debits +openingChf + } + + /** + * §4.5 network fee (D14 A.2, Major R2-5 null-strategy + Major R7-1 fee-asset disambiguation). Adds the + * EXPENSE/network-fee CHF leg = (preparationFeeAmountChf ?? 0) + (payoutFeeAmountChf ?? 0) (additive, NOT the + * NaN-prone feeAmountChf getter) + one native Cr leg per DISTINCT fee asset (≠ payout asset; the payout-asset + * fee was already folded into the wallet leg). networkFeeChf === 0 → no fee leg at all. + */ + private async appendDistinctFeeLegs( + order: PayoutOrder, + bookingDate: Date, + marks: LedgerMarkCache, + legs: LedgerLegInput[], + ): Promise { + const feeChf = this.networkFeeChf(order); + if (feeChf === 0) return; // LN (Fee=0) or both null → no fee leg (null strategy §5.1) + + legs.push(this.namedLeg(await this.expense('network-fee'), feeChf)); + + const feeByAsset = new Map(); + this.addFeeNative(feeByAsset, order.preparationFeeAsset, order.preparationFeeAmount); + this.addFeeNative(feeByAsset, order.payoutFeeAsset, order.payoutFeeAmount); + + for (const { asset, amount } of feeByAsset.values()) { + if (asset.id === order.asset.id) continue; // payout-asset fee already folded into the wallet leg + const mark = marks.getMarkAt(asset.id, bookingDate); + const chf = mark != null ? Util.round(mark * amount, 2) : undefined; + legs.push({ + account: await this.assetAccount(asset), + amount: -amount, + priceChf: mark ?? null, + amountChf: chf != null ? -chf : undefined, + needsMark: chf == null, + }); + } + } + + // native fee amount whose asset == payout asset (folded into the wallet leg); CHF share is mark-based, the + // persisted-vs-mark residual closes via the fx/ROUNDING plug + private payoutAssetFeeNative( + order: PayoutOrder, + marks: LedgerMarkCache, + bookingDate: Date, + ): { amount: number; chf: number; needsMark: boolean } { + let amount = 0; + if (order.preparationFeeAsset?.id === order.asset.id) amount += order.preparationFeeAmount ?? 0; + if (order.payoutFeeAsset?.id === order.asset.id) amount += order.payoutFeeAmount ?? 0; + if (amount === 0) return { amount: 0, chf: 0, needsMark: false }; + + const mark = marks.getMarkAt(order.asset.id, bookingDate); + return { + amount: Util.round(amount, 8), + chf: mark != null ? Util.round(mark * amount, 2) : 0, + needsMark: mark == null, + }; + } + + private addFeeNative(map: Map, asset?: Asset, amount?: number): void { + if (!asset || amount == null || amount === 0) return; + const existing = map.get(asset.id); + if (existing) existing.amount = Util.round(existing.amount + amount, 8); + else map.set(asset.id, { asset, amount }); + } + + // (preparationFeeAmountChf ?? 0) + (payoutFeeAmountChf ?? 0) — additive, direct (NOT the NaN-prone getter) + private networkFeeChf(order: PayoutOrder): number { + return Util.round((order.preparationFeeAmountChf ?? 0) + (order.payoutFeeAmountChf ?? 0), 2); + } + + // --- HELPERS --- // + + // appends an EXPENSE/INCOME fx-revaluation plug for the CHF residual > tolerance (§4.5); sub-cent → ROUNDING. + // Major B5: an unmarked fee/wallet asset leg is first bridged with the youngest available mark (resolveLegsOrDefer) so + // a mixed tx balances — needsMark stays true, the mark-to-market job corrects the basis later. A truly feedless fee + // asset (no bridge) defers the row instead of handing an unbalanceable set to bookTx at the fixed historical date. + private async withFxPlug(legs: LedgerLegInput[], ref: string): Promise { + if (!(await resolveLegsOrDefer(legs, this.markService, this.logger, ref))) return legs; + + const sumCents = legs.reduce((s, l) => s + Math.round(Util.round(l.amountChf ?? 0, 2) * 100), 0); + if (Math.abs(sumCents) <= Config.ledger.roundingToleranceCents) return legs; + + const residualChf = Util.round(-sumCents / 100, 2); + const account = residualChf >= 0 ? await this.income('fx-revaluation') : await this.expense('fx-revaluation'); + legs.push(this.namedLeg(account, residualChf)); + + return legs; + } + + // CHF-denominated counter leg: native amount == CHF amount, priceChf = 1 + private namedLeg(account: LedgerAccount, amountChf: number): LedgerLegInput { + return { account, amount: amountChf, priceChf: 1, amountChf }; + } + + private async alreadyBooked(id: number): Promise { + return (await this.bookingService.nextSeq(SOURCE_TYPE, `${id}`)) > 0; + } + + private async assetAccount(asset: Asset): Promise { + const account = await this.accountService.findByAssetId(asset.id); + if (!account) throw new Error(`Ledger account for asset ${asset.id} not found (CoA bootstrap missing)`); + return account; + } + + private liability(qualifier: string): Promise { + return this.accountService.findOrCreate(`LIABILITY/${qualifier}`, AccountType.LIABILITY, CHF); + } + + private expense(qualifier: string): Promise { + return this.accountService.findOrCreate(`EXPENSE/${qualifier}`, AccountType.EXPENSE, CHF); + } + + private income(qualifier: string): Promise { + return this.accountService.findOrCreate(`INCOME/${qualifier}`, AccountType.INCOME, CHF); + } +} + +// the Dr counter leg (LIABILITY/{bucket} or EXPENSE/refReward) + the main-leg CHF used to value the wallet Cr leg +interface PayoutCounter { + leg: LedgerLegInput; + mainChf?: number; // mark × amount (liability) or amountInChf (RefPayout); undefined → wallet leg needsMark + needsMark: boolean; +} diff --git a/src/subdomains/core/accounting/services/consumers/trading-order.consumer.ts b/src/subdomains/core/accounting/services/consumers/trading-order.consumer.ts new file mode 100644 index 0000000000..07333ee256 --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/trading-order.consumer.ts @@ -0,0 +1,219 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Config } from 'src/config/config'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { Util } from 'src/shared/utils/util'; +import { TradingOrder } from 'src/subdomains/core/trading/entities/trading-order.entity'; +import { TradingOrderStatus } from 'src/subdomains/core/trading/enums'; +import { IsNull, MoreThan, Not, Repository } from 'typeorm'; +import { AccountType, LedgerAccount } from '../../entities/ledger-account.entity'; +import { LedgerAccountService } from '../ledger-account.service'; +import { LedgerBookingService, LedgerLegInput } from '../ledger-booking.service'; +import { LedgerMarkCache, LedgerMarkService } from '../ledger-mark.service'; +import { resolveLegsOrDefer } from './ledger-mark-bridge.helper'; +import { + getLedgerWatermark, + isCoveredByCutoverOpening, + runContentChangeScan, + setLedgerWatermark, +} from './ledger-watermark.helper'; + +const SOURCE_TYPE = 'trading_order'; +const CHF = 'CHF'; +// trading orders are DfxDex on-chain pool swaps (trading-order.service swapPool) → the swap-venue spread is DfxDex +const SWAP_VENUE = 'DfxDex'; + +/** + * §4.9 TradingOrder consumer (new, D10 D, D14 B.4; Blocker R1-3). Books DFX arbitrage swaps. Pure observer: reads + * trading_order (assetIn/assetOut eager-loaded), writes only ledger_*. No cross-check (D14 B.4: trading_order.txId + * ∉ liquidity_order, no overlap with §4.8a). + * + * trading_order.amountIn/amountOut are native only (no *Chf) → both ASSET legs are stage-2 mark-valued; the CHF + * residual against the persisted fee/profit legs is a real valuation spread (NOT rounding) → a dedicated + * EXPENSE/INCOME spread-arbitrage plug leg, never ROUNDING (§1.15/§1.11/Blocker R1-3). + */ +@Injectable() +export class TradingOrderConsumer { + private readonly logger = new DfxLogger(TradingOrderConsumer); + + constructor( + private readonly settingService: SettingService, + private readonly bookingService: LedgerBookingService, + private readonly accountService: LedgerAccountService, + private readonly markService: LedgerMarkService, + @InjectRepository(TradingOrder) private readonly tradingOrderRepo: Repository, + ) {} + + async process(): Promise { + const watermark = (await getLedgerWatermark(this.settingService, SOURCE_TYPE)) ?? { + lastProcessedId: 0, + lastReversalScan: new Date(0), + }; + + await this.processForward(watermark); + + // content-change scan (§4.12 / C1): the forward id-scan filters status='Complete' AND txId IS NOT NULL and + // advances lastProcessedId OVER not-yet-settled ids; a swap that settles AFTER the watermark passed it is + // forward-unreachable. The status-agnostic (updated, id)-cursor scan re-selects it and forward-books it + // idempotently (book() gates on alreadyBooked) once it satisfies the settled filter. Re-read the watermark in + // case the forward batch advanced lastProcessedId above. + const afterForward = (await getLedgerWatermark(this.settingService, SOURCE_TYPE)) ?? watermark; + await runContentChangeScan( + this.settingService, + SOURCE_TYPE, + afterForward, + this.tradingOrderRepo, + {}, + async (order: TradingOrder) => { + // honour the forward settled-filter: only a Complete swap with a txId is bookable; a not-yet-settled row is + // left (cursor advances; its settle-bump on `updated` re-selects it). book() is idempotent (alreadyBooked). + if (order.status !== TradingOrderStatus.COMPLETE || order.txId == null) return; + // §6.3 covered-by-cutover-opening guard: a swap already settled at the cutover snapshot is in the aggregate + // ASSET opening — its `updated` bump post-cutover re-selects it here, but re-booking its seq0 would + // double-count. A hole / post-boundary row is NOT covered → it still books fresh. + if (await isCoveredByCutoverOpening(this.settingService, SOURCE_TYPE, order.id)) return; + await this.book(order, await this.preloadMarks([order])); + }, + ); + } + + private async processForward(watermark: { lastProcessedId: number; lastReversalScan: Date }): Promise { + // only settled swaps: status='Complete' AND txId IS NOT NULL (§4.9) + const batch = await this.tradingOrderRepo.find({ + where: { id: MoreThan(watermark.lastProcessedId), status: TradingOrderStatus.COMPLETE, txId: Not(IsNull()) }, + order: { id: 'ASC' }, + take: Config.ledger.backfillBatchSize, + }); + if (!batch.length) return; + + const marks = await this.preloadMarks(batch); + + let lastProcessedId = watermark.lastProcessedId; + for (const order of batch) { + try { + await this.book(order, marks); + lastProcessedId = order.id; + } catch (e) { + this.logger.error(`Failed to book trading_order ${order.id}:`, e); + break; // failure-isolation: leave watermark unchanged, retry next run (§4-header) + } + } + + if (lastProcessedId > watermark.lastProcessedId) { + await setLedgerWatermark(this.settingService, SOURCE_TYPE, { ...watermark, lastProcessedId }); + } + } + + // mark cache with a 2-day lookback so getMarkAt finds the latest mark at-or-before the earliest row's `updated` + private async preloadMarks(orders: TradingOrder[]): Promise { + const times = orders.map((o) => o.updated.getTime()); + return this.markService.preload(Util.daysBefore(2, new Date(Math.min(...times))), new Date(Math.max(...times))); + } + + /** + * §4.9 booking: Dr ASSET/{assetOut} (markOut) / Cr ASSET/{assetIn} (markIn) + EXPENSE/network-fee (txFeeAmountChf) + * + EXPENSE/spread-{venue} (swapFeeAmountChf) + INCOME/trading (profitChf) + EXPENSE/INCOME spread-arbitrage = + * PLUG (the mark residual). The persisted fee/profit legs are booked only when their field != null (Major R2-5 + * null-strategy, no ?? 0 default for a real number). + */ + private async book(order: TradingOrder, marks: LedgerMarkCache): Promise { + if (await this.alreadyBooked(order.id)) return; // idempotent re-run (§4.9) + + const { assetIn, assetOut, amountIn, amountOut } = order; + if (!assetIn || !assetOut || amountIn == null || amountOut == null) { + this.logger.error(`trading_order ${order.id} has no valid swap (amountIn/amountOut missing) — skip`); + return; + } + + const bookingDate = order.updated; + + // both ASSET legs always via stage-2 mark (no *Chf field, §5.1); missing mark → needsMark, plug stays open + const outLeg = this.assetLeg(await this.assetAccount(assetOut), assetOut, +amountOut, bookingDate, marks); + const inLeg = this.assetLeg(await this.assetAccount(assetIn), assetIn, -amountIn, bookingDate, marks); + + const legs: LedgerLegInput[] = [outLeg, inLeg]; + + // persisted CHF-only fee/profit legs (Major R2-5: book only when field != null, never ?? 0) + if (order.txFeeAmountChf != null) legs.push(this.chfLeg(await this.expense('network-fee'), order.txFeeAmountChf)); + if (order.swapFeeAmountChf != null) + legs.push(this.chfLeg(await this.expense(`spread-${SWAP_VENUE}`), order.swapFeeAmountChf)); + if (order.profitChf != null) legs.push(this.chfLeg(await this.income('trading'), -order.profitChf)); + + await this.appendArbitragePlug(legs, `trading_order ${order.id}`); + + await this.bookingService.bookTx({ + sourceType: SOURCE_TYPE, + sourceId: `${order.id}`, + seq: 0, + bookingDate, + valueDate: bookingDate, + legs, + }); + } + + /** + * The spread-arbitrage plug absorbs the residual between the mark-valued ASSET legs and the persisted fee/profit + * legs so Σ CHF closes to 0 (Blocker R1-3, NOT ROUNDING). Residual > 0 → INCOME/spread-arbitrage; < 0 → + * EXPENSE/spread-arbitrage. Major B5: an ASSET leg without a historical mark is first bridged with the youngest + * available mark (resolveLegsOrDefer) so the swap balances — needsMark stays true, the mark-to-market job corrects the + * basis later. A truly feedless swap asset (no bridge) defers the row instead of handing an unbalanceable set to + * bookTx. Sub-cent rest → ROUNDING (booking service). + */ + private async appendArbitragePlug(legs: LedgerLegInput[], ref: string): Promise { + if (!(await resolveLegsOrDefer(legs, this.markService, this.logger, ref))) return; + + const sumCents = legs.reduce((s, l) => s + Math.round(Util.round(l.amountChf ?? 0, 2) * 100), 0); + if (Math.abs(sumCents) <= Config.ledger.roundingToleranceCents) return; + + const residualChf = Util.round(-sumCents / 100, 2); + const account = residualChf >= 0 ? await this.income('spread-arbitrage') : await this.expense('spread-arbitrage'); + legs.push(this.chfLeg(account, residualChf)); + } + + // --- LEG BUILDERS --- // + + private assetLeg( + account: LedgerAccount, + asset: Asset, + amount: number, + bookingDate: Date, + marks: LedgerMarkCache, + ): LedgerLegInput { + const mark = marks.getMarkAt(asset.id, bookingDate); + const chf = mark != null ? Util.round(mark * Math.abs(amount), 2) : undefined; + return { + account, + amount, + priceChf: mark ?? null, + amountChf: chf != null ? (amount >= 0 ? chf : -chf) : undefined, + needsMark: chf == null, + }; + } + + // CHF-denominated leg: native amount == CHF amount, priceChf = 1 + private chfLeg(account: LedgerAccount, amountChf: number): LedgerLegInput { + return { account, amount: amountChf, priceChf: 1, amountChf }; + } + + // --- HELPERS --- // + + private async assetAccount(asset: Asset): Promise { + const account = await this.accountService.findByAssetId(asset.id); + if (!account) throw new Error(`Ledger account for asset ${asset.id} not found (CoA bootstrap missing)`); + return account; + } + + private expense(qualifier: string): Promise { + return this.accountService.findOrCreate(`EXPENSE/${qualifier}`, AccountType.EXPENSE, CHF); + } + + private income(qualifier: string): Promise { + return this.accountService.findOrCreate(`INCOME/${qualifier}`, AccountType.INCOME, CHF); + } + + private async alreadyBooked(id: number): Promise { + return (await this.bookingService.nextSeq(SOURCE_TYPE, `${id}`)) > 0; + } +} diff --git a/src/subdomains/core/accounting/services/ledger-account.service.ts b/src/subdomains/core/accounting/services/ledger-account.service.ts new file mode 100644 index 0000000000..ea17eb6c57 --- /dev/null +++ b/src/subdomains/core/accounting/services/ledger-account.service.ts @@ -0,0 +1,56 @@ +import { Injectable } from '@nestjs/common'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { AccountType, LedgerAccount } from '../entities/ledger-account.entity'; +import { LedgerAccountRepository } from '../repositories/ledger-account.repository'; + +@Injectable() +export class LedgerAccountService { + constructor(private readonly ledgerAccountRepository: LedgerAccountRepository) {} + + // accounts are resolved character-exact by name (§1.5) + async findByName(name: string): Promise { + return (await this.ledgerAccountRepository.findOneBy({ name })) ?? undefined; + } + + async findByAssetId(assetId: number): Promise { + return (await this.ledgerAccountRepository.findOneBy({ asset: { id: assetId } })) ?? undefined; + } + + // bootstrap core mechanism (§3): lookup-by-name, create-if-missing. The find→create→save is NOT atomic, so a + // concurrent first-time create of the same lazily-created account (two consumer crons hitting the same TRANSIT + // route) makes the loser's save hit the UNIQUE(name) constraint. Catch exactly that (SQLSTATE 23505), reload the + // row the winner committed and return it → a true idempotent no-op. Any other error propagates unchanged. + async findOrCreate( + name: string, + type: AccountType, + currency: string, + assetId?: number, + active = true, + ): Promise { + const existing = await this.findByName(name); + if (existing) return existing; + + const account = this.ledgerAccountRepository.create({ + name, + type, + currency, + active, + asset: assetId != null ? ({ id: assetId } as Asset) : undefined, + }); + + try { + return await this.ledgerAccountRepository.save(account); + } catch (e) { + if (!this.isUniqueViolation(e)) throw e; // not the race → surface every other error unchanged + + const created = await this.findByName(name); // the concurrent winner's row + if (!created) throw e; // 23505 but the row is not findable → not the expected name race; surface the original + return created; + } + } + + // Postgres unique_violation (SQLSTATE 23505): a concurrent caller already committed the account row. + private isUniqueViolation(error: unknown): boolean { + return (error as { code?: string })?.code === '23505'; + } +} diff --git a/src/subdomains/core/accounting/services/ledger-booking-job.service.ts b/src/subdomains/core/accounting/services/ledger-booking-job.service.ts new file mode 100644 index 0000000000..95a439ca0e --- /dev/null +++ b/src/subdomains/core/accounting/services/ledger-booking-job.service.ts @@ -0,0 +1,105 @@ +import { Injectable } from '@nestjs/common'; +import { CronExpression } from '@nestjs/schedule'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { Process } from 'src/shared/services/process.service'; +import { DfxCron } from 'src/shared/utils/cron'; +import { BankTxConsumer } from './consumers/bank-tx.consumer'; +import { BuyCryptoConsumer } from './consumers/buy-crypto.consumer'; +import { BuyFiatConsumer } from './consumers/buy-fiat.consumer'; +import { CryptoInputConsumer } from './consumers/crypto-input.consumer'; +import { ExchangeTxConsumer } from './consumers/exchange-tx.consumer'; +import { LiquidityMgmtConsumer } from './consumers/liquidity-mgmt.consumer'; +import { LiquidityOrderDexConsumer } from './consumers/liquidity-order-dex.consumer'; +import { PayoutOrderConsumer } from './consumers/payout-order.consumer'; +import { TradingOrderConsumer } from './consumers/trading-order.consumer'; + +// watermark helpers live in a consumer-free file to keep the job-service↔consumer import graph acyclic (§11.3) +export { getLedgerWatermark, LedgerWatermark, setLedgerWatermark } from './consumers/ledger-watermark.helper'; + +const CUTOVER_LOG_ID_KEY = 'ledgerCutoverLogId'; + +/** + * Holds the shared cutover-gate (§4-header Blocker R1-6) and registers the @DfxCron wrappers for the consumers. + * Each booking consumer is one @DfxCron method with its own Process.LEDGER_BOOKING_* kill-switch (Hard + * Constraint #5). Every wrapper guards on `isLedgerReady()` (no-op until the cutover set `ledgerCutoverLogId`) + * and is failure-isolated by the lock layer (`dfx-cron.service` lock try/catch). Further stages register their + * own consumers (PayoutOrder/BuyCrypto/BuyFiat/LiquidityMgmt/TradingOrder/LiquidityOrderDex) here. + */ +@Injectable() +export class LedgerBookingJobService { + constructor( + private readonly settingService: SettingService, + private readonly bankTxConsumer: BankTxConsumer, + private readonly exchangeTxConsumer: ExchangeTxConsumer, + private readonly cryptoInputConsumer: CryptoInputConsumer, + private readonly payoutOrderConsumer: PayoutOrderConsumer, + private readonly buyCryptoConsumer: BuyCryptoConsumer, + private readonly buyFiatConsumer: BuyFiatConsumer, + private readonly liquidityMgmtConsumer: LiquidityMgmtConsumer, + private readonly liquidityOrderDexConsumer: LiquidityOrderDexConsumer, + private readonly tradingOrderConsumer: TradingOrderConsumer, + ) {} + + // cutover-gate (Blocker R1-6): no consumer books before bootstrap+opening set the ready marker + async isLedgerReady(): Promise { + return (await this.settingService.get(CUTOVER_LOG_ID_KEY)) != null; + } + + @DfxCron(CronExpression.EVERY_MINUTE, { process: Process.LEDGER_BOOKING_BANK_TX, timeout: 1800 }) + async runBankTx(): Promise { + if (!(await this.isLedgerReady())) return; + await this.bankTxConsumer.process(); + } + + // ExchangeTx + ExchangeTrade are ONE @DfxCron method → one flag (Minor R8-1): deposit/withdrawal then trade + @DfxCron(CronExpression.EVERY_MINUTE, { process: Process.LEDGER_BOOKING_EXCHANGE_TX, timeout: 1800 }) + async runExchangeTx(): Promise { + if (!(await this.isLedgerReady())) return; + await this.exchangeTxConsumer.process(); + } + + @DfxCron(CronExpression.EVERY_MINUTE, { process: Process.LEDGER_BOOKING_CRYPTO_INPUT, timeout: 1800 }) + async runCryptoInput(): Promise { + if (!(await this.isLedgerReady())) return; + await this.cryptoInputConsumer.process(); + } + + @DfxCron(CronExpression.EVERY_MINUTE, { process: Process.LEDGER_BOOKING_PAYOUT, timeout: 1800 }) + async runPayoutOrder(): Promise { + if (!(await this.isLedgerReady())) return; + await this.payoutOrderConsumer.process(); + } + + @DfxCron(CronExpression.EVERY_MINUTE, { process: Process.LEDGER_BOOKING_BUY_CRYPTO, timeout: 1800 }) + async runBuyCrypto(): Promise { + if (!(await this.isLedgerReady())) return; + await this.buyCryptoConsumer.process(); + } + + @DfxCron(CronExpression.EVERY_MINUTE, { process: Process.LEDGER_BOOKING_BUY_FIAT, timeout: 1800 }) + async runBuyFiat(): Promise { + if (!(await this.isLedgerReady())) return; + await this.buyFiatConsumer.process(); + } + + // §4.8 — bridge-only (skips exchange/DfxDex movements booked by their authoritative consumers) + @DfxCron(CronExpression.EVERY_MINUTE, { process: Process.LEDGER_BOOKING_LIQUIDITY_MANAGEMENT, timeout: 1800 }) + async runLiquidityMgmt(): Promise { + if (!(await this.isLedgerReady())) return; + await this.liquidityMgmtConsumer.process(); + } + + // §4.8a — DfxDex purchase/sell on-chain swaps (own flag, Hard Constraint #5) + @DfxCron(CronExpression.EVERY_MINUTE, { process: Process.LEDGER_BOOKING_LIQUIDITY_ORDER, timeout: 1800 }) + async runLiquidityOrderDex(): Promise { + if (!(await this.isLedgerReady())) return; + await this.liquidityOrderDexConsumer.process(); + } + + // §4.9 — arbitrage swaps (own flag, Hard Constraint #5) + @DfxCron(CronExpression.EVERY_MINUTE, { process: Process.LEDGER_BOOKING_TRADING_ORDER, timeout: 1800 }) + async runTradingOrder(): Promise { + if (!(await this.isLedgerReady())) return; + await this.tradingOrderConsumer.process(); + } +} diff --git a/src/subdomains/core/accounting/services/ledger-booking.service.ts b/src/subdomains/core/accounting/services/ledger-booking.service.ts new file mode 100644 index 0000000000..5307533266 --- /dev/null +++ b/src/subdomains/core/accounting/services/ledger-booking.service.ts @@ -0,0 +1,395 @@ +import { Injectable } from '@nestjs/common'; +import { Config } from 'src/config/config'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { Util } from 'src/shared/utils/util'; +import { DataSource, EntityManager } from 'typeorm'; +import { AccountType, LedgerAccount } from '../entities/ledger-account.entity'; +import { LedgerLeg } from '../entities/ledger-leg.entity'; +import { LedgerTx } from '../entities/ledger-tx.entity'; +import { LedgerAccountService } from './ledger-account.service'; + +export interface LedgerLegInput { + account: LedgerAccount; + amount: number; // native, signed (Dr = +, Cr = −) + priceChf?: number; + amountChf?: number; + needsMark?: boolean; +} + +export interface LedgerTxInput { + sourceType: string; + sourceId: string; + seq: number; + bookingDate: Date; + valueDate?: Date; + description?: string; + legs: LedgerLegInput[]; + reversalOf?: LedgerTx; +} + +const NATIVE_BALANCE_TOLERANCE = 1e-8; +const ROUNDING_ACCOUNT_NAME = 'ROUNDING'; + +// §4.12 "eigener seq-Namespace" (D15 C.b line 72): reversal/re-book tx live in the SAME (sourceType, sourceId) but in +// a seq RANGE reserved ABOVE every forward fixed seq (the highest forward seq is buy_fiat seq3). Without this, a +// reversal of an EARLY forward seq before the LATER forward seqs are booked (e.g. buy_fiat seq1 content-change before +// transmit/booked) would take MAX(seq)+1 = the index of a not-yet-booked forward seq → the later forward booking then +// hits a UNIQUE collision (Major R3, multi-seq sources). Anchoring corrections at this base keeps them monotonic and +// collision-free with the forward seqs while staying in the same namespace. +const CORRECTION_SEQ_BASE = 1_000_000; + +@Injectable() +export class LedgerBookingService { + private readonly logger = new DfxLogger(LedgerBookingService); + + constructor( + private readonly dataSource: DataSource, + private readonly ledgerAccountService: LedgerAccountService, + ) {} + + /** + * Books one atomic ledger_tx (§4 header). Computes amountChfCents per leg + amountChfSum, appends a + * sub-cent ROUNDING leg, enforces the single per-tx invariant amountChfSum = 0 (CHF cross-asset), and + * writes ledger_tx + ledger_leg atomically. Native balance is NOT a per-tx invariant (§2.3 Major R9-2) — + * only a sanity-check for pure same-asset transfers. + */ + async bookTx(input: LedgerTxInput): Promise { + return this.dataSource.transaction((manager) => this.bookTxWithManager(manager, input)); + } + + // core booking body, transaction-scoped: all reads/writes go through `manager` so the caller controls atomicity + // (§4.12: reversal + re-book must live in ONE transaction). Behaviour is identical to the public bookTx. + private async bookTxWithManager(manager: EntityManager, input: LedgerTxInput): Promise { + const legs = input.legs.map((leg) => this.prepareLeg(leg)); + + await this.appendRoundingLeg(legs); + this.checkNativeBalance(legs); + + const amountChfSum = legs.reduce((sum, leg) => sum + leg.amountChfCents, 0); + + const tx = manager.create(LedgerTx, { + sourceType: input.sourceType, + sourceId: input.sourceId, + seq: input.seq, + bookingDate: input.bookingDate, + valueDate: input.valueDate ?? input.bookingDate, + description: input.description, + reversalOf: input.reversalOf, + amountChfSum, + }); + const savedTx = await manager.save(LedgerTx, tx); // ledger-allowlist + + const entities = legs.map((leg) => manager.create(LedgerLeg, { ...leg, tx: savedTx })); + await manager.save(LedgerLeg, entities); // ledger-allowlist + + return savedTx; + } + + /** + * Reversal/re-book (§4.12, append-only). Reversal-tx with reversalOf = original, inverted legs, next free + * seq in the (sourceType, sourceId) namespace; the original stays untouched. + */ + async reverseTx(original: LedgerTx): Promise { + return this.dataSource.transaction((manager) => this.reverseTxWithManager(manager, original)); + } + + // transaction-scoped reversal: the correction seq is allocated via the SAME `manager` so it sees any uncommitted + // sibling booking in the caller's transaction (§4.12 one-transaction reversal + re-book). + private async reverseTxWithManager(manager: EntityManager, original: LedgerTx): Promise { + const nextSeq = await this.nextCorrectionSeqWithManager(manager, original.sourceType, original.sourceId); + + return this.bookTxWithManager(manager, { + sourceType: original.sourceType, + sourceId: original.sourceId, + seq: nextSeq, + bookingDate: original.bookingDate, + valueDate: original.valueDate, + description: original.description, + reversalOf: original, + legs: original.legs.map((leg) => ({ + account: leg.account, + amount: -leg.amount, + priceChf: leg.priceChf, + amountChf: leg.amountChf != null ? -leg.amountChf : undefined, + needsMark: leg.needsMark, + })), + }); + } + + /** + * §4.12 reversal/re-book cycle for a content-change on a booked source row. Loads the currently ACTIVE + * (not-yet-reversed) booking tx for `(sourceType, sourceId)`; if the freshly-computed `legs` differ from its legs + * beyond the §4.12 float tolerances (amount 1e-8, amountChf 0.005, priceChf 1e-6 — no reversal merely for a mark + * drift), it runs the verbatim cycle: (1) Reversal-Tx (seq=nextSeq, reversalOf=active, inverted legs); (2) + * Re-Book-Tx (seq=nextSeq+1, reversalOf=NULL, the corrected legs). The original/active tx stays append-only + * untouched (§4.12 Z.802/809). A UNIQUE conflict rolls back the one correction tx and surfaces to the caller's + * try/catch → the content-change watermark is NOT advanced → self-healing retry next run (§4.12 Minor R12-2). + * Returns true when a correction was booked, false when nothing changed (idempotent re-scan). + */ + async reverseAndRebookIfChanged(input: LedgerTxInput): Promise { + const active = await this.activeTx(input.sourceType, input.sourceId, input.seq); + if (!active) return false; // nothing booked yet at this seq → forward booker handles it (no reversal) + + const fresh = input.legs.map((leg) => this.prepareLeg(leg)); + await this.appendRoundingLeg(fresh); + if (!this.legsDiffer(active.legs, fresh)) return false; // unchanged within §4.12 tolerances → no-op + + // §4.12: reversal + re-book in ONE transaction so a crash between them cannot leave a flat-reversal state (the + // next run would then collide on the original forward seq — a UNIQUE loop). The re-book seq is allocated via the + // SAME `manager` so it sees the uncommitted reversal → reversal.seq + 1, collision-free inside the transaction. + await this.dataSource.transaction(async (manager) => { + // (1) reversal-tx (reversalOf = the active original, inverted legs) + await this.reverseTxWithManager(manager, active); + + // (2) re-book-tx (reversalOf = NULL — a new valid booking) with the corrected legs, next free CORRECTION seq + // (above the forward range, so it never collides with a not-yet-booked forward seq of a multi-seq source, R3) + const reSeq = await this.nextCorrectionSeqWithManager(manager, input.sourceType, input.sourceId); + await this.bookTxWithManager(manager, { ...input, seq: reSeq, reversalOf: undefined }); + }); + + return true; + } + + /** + * §4.12 value-coupled chain reversal (Major M3). Several forward seqs of one source row can share a value (buy_fiat + * reclassification seq1 / transmit seq2 / settlement seq3 all carry owedChf; buy_crypto Card input seq0 / completion + * seq1 both carry the amountInChf base). A content-change that reverses+rebooks ONLY the first seq leaves the later, + * already-booked seqs on the OLD value → the shared liability (owed/received) never closes to 0 (liability closure + * breaks). This reverses the WHOLE currently-active chain and re-books every input atomically, but ONLY when at least + * one input differs from its active booking (idempotent re-scan otherwise). `inputs` are the value-coupled seqs in + * ascending seq order; each participates only if it currently has an active booking (a later seq not yet settled has + * none → left to the forward path, which books it fresh at the new value). Each (reversal, re-book) pair is written + * adjacently — the re-book lands at reversal.seq+1 because its correction seq is allocated AFTER its own reversal via + * the same `manager` — so the §4.12 activeTx adjacency walk still resolves every corrected seq. Returns true when a + * correction was booked. + */ + async reverseAndRebookChainIfChanged(inputs: LedgerTxInput[]): Promise { + const active = await Promise.all(inputs.map((i) => this.activeTx(i.sourceType, i.sourceId, i.seq))); + const chain = inputs + .map((input, i) => ({ input, original: active[i] })) + .filter((p): p is { input: LedgerTxInput; original: LedgerTx } => p.original != null); + if (!chain.length) return false; // nothing active yet → the forward booker owns these seqs + + const changed = await Promise.all( + chain.map(async ({ input, original }) => { + const fresh = input.legs.map((leg) => this.prepareLeg(leg)); + await this.appendRoundingLeg(fresh); + return this.legsDiffer(original.legs, fresh); + }), + ); + if (!changed.some(Boolean)) return false; // every active seq within §4.12 tolerances → no-op + + // reverse + re-book the whole chain in ONE transaction; the re-book of each seq is allocated AFTER its own reversal + // (through `manager`) → it lands at reversal.seq+1, keeping the activeTx adjacency walk intact for every seq. + await this.dataSource.transaction(async (manager) => { + for (const { input, original } of chain) { + await this.reverseTxWithManager(manager, original); + const reSeq = await this.nextCorrectionSeqWithManager(manager, input.sourceType, input.sourceId); + await this.bookTxWithManager(manager, { ...input, seq: reSeq, reversalOf: undefined }); + } + }); + + return true; + } + + /** + * §4.12 flat reversal: if `(sourceType, sourceId)` has an active booking (forward seq = `originalSeq`) but the + * source row is no longer bookable (e.g. its type changed to a skipped type), reverse the active tx and do NOT + * re-book — the corrected state is "nothing booked". Returns true when a reversal was booked, false otherwise. + */ + async reverseActiveIfBooked(sourceType: string, sourceId: string, originalSeq: number): Promise { + const active = await this.activeTx(sourceType, sourceId, originalSeq); + if (!active) return false; + + await this.reverseTx(active); + return true; + } + + /** + * True iff `(sourceType, sourceId)` has an ACTIVE (not-yet-reversed-without-rebook) booking whose forward original + * sits at `originalSeq`. The multi-seq consumers (buy_fiat seq1/2/3, buy_crypto seq0/1) MUST gate per-seq on THIS, + * not on `nextSeq(...) > seq`: after a §4.12 content-change reversal of an earlier seq (reversal seq=N, re-book + * seq=N+1), `MAX(seq)` jumps past the later seqs' indices so `nextSeq > laterSeq` reads true even though those later + * seqs were never booked → they would be skipped forever and their liabilities never close (Class-1 break, R3). + */ + async hasActiveTxAt(sourceType: string, sourceId: string, originalSeq: number): Promise { + return (await this.activeTx(sourceType, sourceId, originalSeq)) != null; + } + + /** + * True iff ANY tx has EVER been booked at this exact `(sourceType, sourceId, seq)` — active, reversed, OR a + * reversal/re-book. The content-change scan uses it to decide whether a status-filtered row it re-selected (by the + * `updated` cursor, not the id-watermark) was ever forward-booked: a late-settling row the id-watermark skipped has + * NONE, so it is booked forward (C1); a flat-reversed original DOES have one, so the forward book is skipped (an + * append-only bookTx at the original seq would collide on the UNIQUE constraint). Unlike hasActiveTxAt this does not + * walk the reversal chain — mere existence at the seq is the signal. + */ + async hasAnyTxAt(sourceType: string, sourceId: string, seq: number): Promise { + return this.dataSource.getRepository(LedgerTx).existsBy({ sourceType, sourceId, seq }); + } + + /** + * The currently active (correction-effective) booking tx that descends from the ORIGINAL forward booking at + * `originalSeq` (the seq the row was first booked at — e.g. bank_tx seq0, buy_fiat reclassification seq1). Follows + * the §4.12 reversal chain SPECIFIC to that original (NOT just the highest live seq — a multi-seq source row, e.g. + * buy_fiat seq1/2/3, has several independent originals and reversing the wrong one would corrupt an unrelated leg). + * + * Walk: start at the original (seq=originalSeq, reversalOfId NULL). If it is reversed (some reversal's reversalOfId + * points at it), its corrected re-book is the booking (reversalOfId NULL) at EXACTLY reversal.seq + 1 (§4.12 Z.809 + * order: reversal at seq=N, re-book at seq=N+1); advance to it and repeat. Requiring adjacency (not merely "smallest + * seq above") makes a flat reversal (reversed, no re-book) — whose N+1 slot is empty — resolve to undefined instead + * of grabbing an unrelated correction tx across the seq gap. When the current booking is NOT reversed it is the live + * correction → return it. + */ + private async activeTx(sourceType: string, sourceId: string, originalSeq: number): Promise { + const all = await this.dataSource.getRepository(LedgerTx).find({ + where: { sourceType, sourceId }, + relations: { legs: { account: true } }, + order: { seq: 'ASC' }, + }); + if (!all.length) return undefined; + + let current = all.find((tx) => tx.seq === originalSeq && tx.reversalOfId == null); + if (!current) return undefined; // no original forward booking at this seq → nothing to correct (§4.12) + + for (;;) { + const reversal = all.find((tx) => tx.reversalOfId === current!.id); + if (!reversal) return current; // current booking is live (not reversed) → the active correction + + // the corrected re-book = the real booking (reversalOf NULL) at EXACTLY reversal.seq + 1 (§4.12 Z.809 order: + // reversal at seq=N, re-book at seq=N+1). Requiring adjacency avoids grabbing a FOREIGN correction tx across a + // seq gap left by a flat reversal (no re-book) — that gap must resolve to "nothing booked", not the next tx. + const rebook = all.find((tx) => tx.reversalOfId == null && tx.seq === reversal.seq + 1); + if (!rebook) return undefined; // flat reversal (no adjacent re-book) → nothing booked now + current = rebook; + } + } + + // §4.12 content-change comparison: legs differ iff the fresh leg multiset cannot be matched 1:1 against the + // existing one with EVERY field within its float tolerance (amount 1e-8, amountChf 0.005, priceChf 1e-6 — no + // reversal merely for a sub-tolerance mark drift). Greedy multiset match (legs may repeat the same account, e.g. + // the buyFiat seq1 reclassification has two `received` legs) — pairwise tolerances, NOT bucketed (boundary-safe). + private legsDiffer(existing: LedgerLeg[], fresh: LedgerLeg[]): boolean { + if (existing.length !== fresh.length) return true; + + const within = (a: number | undefined | null, b: number | undefined | null, tol: number): boolean => { + if (a == null && b == null) return true; + if (a == null || b == null) return false; + return Math.abs(a - b) <= tol; + }; + const matches = (e: LedgerLeg, f: LedgerLeg): boolean => + (e.account?.id ?? e.account?.name) === (f.account?.id ?? f.account?.name) && + within(e.amount, f.amount, 1e-8) && + within(e.amountChf, f.amountChf, 0.005) && + within(e.priceChf, f.priceChf, 1e-6); + + const unmatched = [...existing]; + for (const f of fresh) { + const i = unmatched.findIndex((e) => matches(e, f)); + if (i < 0) return true; // a fresh leg has no tolerance-equal partner → content changed + unmatched.splice(i, 1); + } + return false; + } + + // monotonic, collision-free seq allocation in the (sourceType, sourceId) namespace (§4.12) — reads the committed + // state via the DataSource's own connection + async nextSeq(sourceType: string, sourceId: string): Promise { + return this.nextSeqFrom(this.dataSource, sourceType, sourceId); + } + + // monotonic, collision-free seq for a reversal/re-book tx — ALWAYS in the reserved correction range (§4.12 "eigener + // seq-Namespace"), so it never lands on a forward fixed seq (0–3) of a multi-seq source that is not yet booked (R3). + async nextCorrectionSeq(sourceType: string, sourceId: string): Promise { + return Math.max(await this.nextSeq(sourceType, sourceId), CORRECTION_SEQ_BASE); + } + + // manager-scoped correction seq: reads through the transaction `manager` so it sees an uncommitted sibling booking + // (the reversal just written in the same §4.12 transaction) → reversal.seq + 1, collision-free. + private async nextCorrectionSeqWithManager( + manager: EntityManager, + sourceType: string, + sourceId: string, + ): Promise { + return Math.max(await this.nextSeqFrom(manager, sourceType, sourceId), CORRECTION_SEQ_BASE); + } + + // `runner` is the DataSource (its own connection, committed state) or the transaction EntityManager (sees the + // uncommitted sibling booking) — an explicit choice by the caller, NOT a silent fallback. Both expose + // getRepository(...).createQueryBuilder(...) with the same shape. + private async nextSeqFrom(runner: DataSource | EntityManager, sourceType: string, sourceId: string): Promise { + const { max } = await runner + .getRepository(LedgerTx) + .createQueryBuilder('tx') + .select('MAX(tx.seq)', 'max') + .where('tx.sourceType = :sourceType', { sourceType }) + .andWhere('tx.sourceId = :sourceId', { sourceId }) + .getRawOne<{ max: number | null }>(); + + return (max ?? -1) + 1; + } + + private prepareLeg(leg: LedgerLegInput): LedgerLeg { + const amount = Util.round(leg.amount, 8); // 8-decimal native display/rounding convention (§2.3) + const amountChf = leg.amountChf != null ? Util.round(leg.amountChf, 2) : undefined; + const amountChfCents = Math.round(Util.round(amountChf ?? 0, 2) * 100); + + return Object.assign(new LedgerLeg(), { + account: leg.account, + amount, + priceChf: leg.priceChf ?? null, + amountChf: amountChf ?? null, + amountChfCents, + needsMark: leg.needsMark ?? false, + }); + } + + // Σ amountChfCents must close to 0; a sub-cent rest is closed by a ROUNDING leg; > tolerance → throw + private async appendRoundingLeg(legs: LedgerLeg[]): Promise { + const sum = legs.reduce((acc, leg) => acc + leg.amountChfCents, 0); + if (sum === 0) return; + + if (Math.abs(sum) > Config.ledger.roundingToleranceCents) { + throw new Error( + `Ledger tx CHF imbalance of ${sum} cents exceeds rounding tolerance ${Config.ledger.roundingToleranceCents} (programming error — structural valuation spreads must be plugged before booking)`, + ); + } + + const roundingAccount = await this.ledgerAccountService.findByName(ROUNDING_ACCOUNT_NAME); + if (!roundingAccount) throw new Error(`Ledger account ${ROUNDING_ACCOUNT_NAME} not found (CoA bootstrap missing)`); + + legs.push( + Object.assign(new LedgerLeg(), { + account: roundingAccount, + amount: 0, + priceChf: null, + amountChf: Util.round(-sum / 100, 2), + amountChfCents: -sum, + needsMark: false, + }), + ); + } + + /** + * Native balance is corrected per-asset against the feed (§7), NOT enforced per-tx. The only sanity-check + * is the class of pure same-asset transfers (all legs ASSET/TRANSIT of the SAME currency): then Σ amount + * per currency must be 0. A leg on any non-ASSET/TRANSIT account makes the native one-sidedness correct + * (value-boundary booking) → no native check (§2.3 Major R9-2). + */ + private checkNativeBalance(legs: LedgerLeg[]): void { + const onlyAssetTransit = legs.every( + (leg) => leg.account.type === AccountType.ASSET || leg.account.type === AccountType.TRANSIT, + ); + if (!onlyAssetTransit) return; + + const byCurrency = Util.groupByAccessor(legs, (leg) => leg.account.currency); + for (const [currency, currencyLegs] of byCurrency.entries()) { + const nativeSum = currencyLegs.reduce((acc, leg) => acc + leg.amount, 0); + if (Math.abs(nativeSum) > NATIVE_BALANCE_TOLERANCE) { + this.logger.error( + `Ledger same-asset transfer native imbalance for currency ${currency}: ${nativeSum} (programming error)`, + ); + } + } + } +} diff --git a/src/subdomains/core/accounting/services/ledger-bootstrap.service.ts b/src/subdomains/core/accounting/services/ledger-bootstrap.service.ts new file mode 100644 index 0000000000..ffbcba07ef --- /dev/null +++ b/src/subdomains/core/accounting/services/ledger-bootstrap.service.ts @@ -0,0 +1,147 @@ +import { Injectable } from '@nestjs/common'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { LiquidityManagementBalanceService } from 'src/subdomains/core/liquidity-management/services/liquidity-management-balance.service'; +import { AccountType } from '../entities/ledger-account.entity'; +import { LedgerAccountService } from './ledger-account.service'; + +@Injectable() +export class LedgerBootstrapService { + // §3.4 — single authoritative bootstrap name lists (character-exact) + private static readonly LIABILITY_ACCOUNTS = [ + 'buyFiat-owed', + 'buyFiat-received', + 'buyCrypto-owed', + 'buyCrypto-received', + 'refReward', + 'paymentLink', + 'bankTx-return', + 'bankTx-repeat', + 'unattributed', + 'manual-debt', + ]; + + // INCOME spread-{venue} symmetric to the EXPENSE side (venue maker rebates, §4.3 Major R12-2) + private static readonly INCOME_ACCOUNTS = [ + 'fee-buyCrypto', + 'fee-buyFiat', + 'fee-paymentLink', + 'trading', + 'spread-Binance', + 'spread-Scrypt', + 'spread-MEXC', + 'spread-XT', + 'spread-Kraken', + 'spread-arbitrage', + 'spread-DfxDex', + 'fx-revaluation', + ]; + + private static readonly EXPENSE_ACCOUNTS = [ + 'spread-Binance', + 'spread-Scrypt', + 'spread-MEXC', + 'spread-XT', + 'spread-Kraken', + 'spread-arbitrage', + 'spread-DfxDex', + 'network-fee', + 'bank-fee', + 'extraordinary', + 'refReward', + 'acquirer-fee', + 'fx-revaluation', + ]; + + private static readonly EQUITY_ACCOUNTS = ['opening-balance', 'retained-earnings']; + + // §3.3 — canonical direction-neutral TRANSIT routes (predefined fix-list, line 266) + private static readonly TRANSIT_ACCOUNTS: { route: string; currency: string }[] = [ + { route: 'bank↔Scrypt/EUR', currency: 'EUR' }, + { route: 'bank↔Scrypt/CHF', currency: 'CHF' }, + { route: 'bank↔Kraken/EUR', currency: 'EUR' }, + { route: 'bank↔bank/EUR', currency: 'EUR' }, + { route: 'bank↔bank/CHF', currency: 'CHF' }, + { route: 'wallet↔Binance/USDT', currency: 'USDT' }, + { route: 'wallet↔Binance/ETH', currency: 'ETH' }, + { route: 'wallet↔Binance/BTC', currency: 'BTC' }, + { route: 'wallet↔MEXC/USDT', currency: 'USDT' }, + { route: 'wallet↔MEXC/ETH', currency: 'ETH' }, + { route: 'wallet↔MEXC/BTC', currency: 'BTC' }, + { route: 'wallet↔Scrypt/USDT', currency: 'USDT' }, + { route: 'wallet↔Scrypt/ETH', currency: 'ETH' }, + { route: 'wallet↔Scrypt/BTC', currency: 'BTC' }, + { route: 'payout/CHF', currency: 'CHF' }, + { route: 'payout/EUR', currency: 'EUR' }, + { route: 'internal-fx/CHF', currency: 'CHF' }, + { route: 'internal-fx/EUR', currency: 'EUR' }, + { route: 'bridge/EUR', currency: 'EUR' }, + { route: 'bridge/CHF', currency: 'CHF' }, + ]; + + constructor( + private readonly ledgerAccountService: LedgerAccountService, + private readonly assetService: AssetService, + private readonly liquidityManagementBalanceService: LiquidityManagementBalanceService, + ) {} + + // idempotent CoA bootstrap (§3); findOrCreate per account, re-run no-op on UNIQUE(name) + async bootstrap(): Promise { + await this.bootstrapAssetAccounts(); + await this.bootstrapTransitAccounts(); + await this.bootstrapNamedAccounts(); + } + + // §3.2 — ASSET accounts from asset rows + private async bootstrapAssetAccounts(): Promise { + const assets = await this.assetService.getAssetsWith({ balance: true, bank: true }); + const feedAssetIds = new Set((await this.liquidityManagementBalanceService.getBalances()).map((b) => b.asset?.id)); + + const coaAssets = assets.filter((a) => this.isCoaAsset(a, feedAssetIds)); + + for (const asset of coaAssets) { + // non-null fallback for currency (currency is NOT NULL, dexName is nullable) — §3.2 Minor R7-8 + await this.ledgerAccountService.findOrCreate( + asset.uniqueName, + AccountType.ASSET, + asset.dexName ?? asset.name, + asset.id, + asset.isActive, + ); + } + } + + // §3.2 selection: Custody assets PLUS on-chain wallet assets present in liquidity_balance, MINUS CUSTOM/PRESALE + private isCoaAsset(asset: Asset, feedAssetIds: Set): boolean { + if (asset.type === AssetType.CUSTOM || asset.type === AssetType.PRESALE) return false; + return asset.type === AssetType.CUSTODY || feedAssetIds.has(asset.id); + } + + // §3.3 — TRANSIT fix-list (direction-neutral); new routes created lazily by consumers + private async bootstrapTransitAccounts(): Promise { + for (const { route, currency } of LedgerBootstrapService.TRANSIT_ACCOUNTS) { + await this.ledgerAccountService.findOrCreate(`TRANSIT/${route}`, AccountType.TRANSIT, currency); + } + } + + // §3.4 — LIABILITY / INCOME / EXPENSE / EQUITY / ROUNDING / SUSPENSE + private async bootstrapNamedAccounts(): Promise { + for (const name of LedgerBootstrapService.LIABILITY_ACCOUNTS) { + await this.ledgerAccountService.findOrCreate(`LIABILITY/${name}`, AccountType.LIABILITY, 'CHF'); + } + for (const name of LedgerBootstrapService.INCOME_ACCOUNTS) { + await this.ledgerAccountService.findOrCreate(`INCOME/${name}`, AccountType.INCOME, 'CHF'); + } + for (const name of LedgerBootstrapService.EXPENSE_ACCOUNTS) { + await this.ledgerAccountService.findOrCreate(`EXPENSE/${name}`, AccountType.EXPENSE, 'CHF'); + } + for (const name of LedgerBootstrapService.EQUITY_ACCOUNTS) { + await this.ledgerAccountService.findOrCreate(`EQUITY/${name}`, AccountType.EQUITY, 'CHF'); + } + + await this.ledgerAccountService.findOrCreate('ROUNDING', AccountType.ROUNDING, 'CHF'); + await this.ledgerAccountService.findOrCreate('SUSPENSE', AccountType.SUSPENSE, 'CHF'); + // Raiffeisen untracked-bank SUSPENSE leg is EUR-native (§1.6/§4.2) + await this.ledgerAccountService.findOrCreate('SUSPENSE/untracked-bank-Raiffeisen-EUR', AccountType.SUSPENSE, 'EUR'); + } +} diff --git a/src/subdomains/core/accounting/services/ledger-cutover.service.ts b/src/subdomains/core/accounting/services/ledger-cutover.service.ts new file mode 100644 index 0000000000..c5e18dabe0 --- /dev/null +++ b/src/subdomains/core/accounting/services/ledger-cutover.service.ts @@ -0,0 +1,1030 @@ +import { Injectable } from '@nestjs/common'; +import { CronExpression } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { ExchangeTx } from 'src/integration/exchange/entities/exchange-tx.entity'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { Process } from 'src/shared/services/process.service'; +import { DfxCron } from 'src/shared/utils/cron'; +import { Util } from 'src/shared/utils/util'; +import { BuyCrypto } from 'src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity'; +import { LiquidityManagementOrder } from 'src/subdomains/core/liquidity-management/entities/liquidity-management-order.entity'; +import { LiquidityManagementOrderStatus } from 'src/subdomains/core/liquidity-management/enums'; +import { BuyFiat } from 'src/subdomains/core/sell-crypto/process/buy-fiat.entity'; +import { TradingOrder } from 'src/subdomains/core/trading/entities/trading-order.entity'; +import { TradingOrderStatus } from 'src/subdomains/core/trading/enums'; +import { BankTx, BankTxIndicator, BankTxType } from 'src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity'; +import { BankTxRepeat } from 'src/subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.entity'; +import { BankTxReturn } from 'src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.entity'; +import { Bank } from 'src/subdomains/supporting/bank/bank/bank.entity'; +import { + LiquidityOrder, + LiquidityOrderContext, + LiquidityOrderType, +} from 'src/subdomains/supporting/dex/entities/liquidity-order.entity'; +import { FinanceLog, ManualLogPosition } from 'src/subdomains/supporting/log/dto/log.dto'; +import { Log } from 'src/subdomains/supporting/log/log.entity'; +import { LogService } from 'src/subdomains/supporting/log/log.service'; +import { CryptoInput, CryptoInputSettledStatus } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; +import { PayoutOrder, PayoutOrderStatus } from 'src/subdomains/supporting/payout/entities/payout-order.entity'; +import { Between, In, IsNull, Repository, SelectQueryBuilder } from 'typeorm'; +import { AccountType, LedgerAccount } from '../entities/ledger-account.entity'; +import { LedgerAccountService } from './ledger-account.service'; +import { LedgerBookingService, LedgerLegInput } from './ledger-booking.service'; +import { LedgerBootstrapService } from './ledger-bootstrap.service'; +import { LedgerMarkCache, LedgerMarkService } from './ledger-mark.service'; +import { + getCutoverBoundary, + getCutoverUnpricedIdsRaw, + isCoveredByCutoverOpening, + setCutoverBoundary, + setCutoverUnpricedIds, +} from './consumers/ledger-watermark.helper'; + +const CUTOVER_LOG_ID_KEY = 'ledgerCutoverLogId'; +// pinned at the very first cutover step (before any opening is booked); makes the snapshot stable across a re-run +// after a partial crash so every per-row opening sourceId (`:buy_fiat:`, …) stays identical and the +// alreadyBooked UNIQUE backstop catches the collision → no double-counted openings (Major design-accounting, R3-1). +const CUTOVER_SNAPSHOT_LOG_ID_KEY = 'ledgerCutoverSnapshotLogId'; +// the pinned snapshot's created-date, exported so a forward consumer can classify a row as pre-cutover-settled +// (its value already in the aggregate opening, §6.1) vs open/post-cutover (§6.3). The buy_crypto Card gate reads it: +// a completed Card row whose outputDate ≤ this date was already captured by openAssets → its seq0/seq1 must NOT be +// (re-)booked. Stable across re-runs because snapshotDate derives from the pinned snapshot (Major design-accounting R3-1). +const CUTOVER_SNAPSHOT_DATE_KEY = 'ledgerCutoverSnapshotDate'; +const WATERMARK_KEY_PREFIX = 'ledgerWatermark.'; +const SOURCE_TYPE = 'cutover'; +const CHF = 'CHF'; +const OPEN_ROW_LOOKBACK_DAYS = 90; // only targeted liabilities from rows created > cutover − 90d (§6.1) +// §6.1: unattributed bank_tx credits the LogJob carries as a liability and the forward consumer routes to +// LIABILITY/unattributed (bank-tx.consumer.ts GSHEET/PENDING CRDT). NULL-type credits fall in here too (default-unmapped). +const UNATTRIBUTED_TYPES = [BankTxType.GSHEET, BankTxType.PENDING, BankTxType.UNKNOWN]; + +@Injectable() +export class LedgerCutoverService { + private readonly logger = new DfxLogger(LedgerCutoverService); + + constructor( + private readonly settingService: SettingService, + private readonly logService: LogService, + private readonly bootstrapService: LedgerBootstrapService, + private readonly bookingService: LedgerBookingService, + private readonly accountService: LedgerAccountService, + private readonly markService: LedgerMarkService, + @InjectRepository(BuyFiat) private readonly buyFiatRepo: Repository, + @InjectRepository(BuyCrypto) private readonly buyCryptoRepo: Repository, + @InjectRepository(BankTx) private readonly bankTxRepo: Repository, + @InjectRepository(Bank) private readonly bankRepo: Repository, + // read-only: open the targeted BANK_TX_RETURN/REPEAT liabilities (chargebackBankTx IS NULL) per §6.1 (Major + // design-accounting) — the cutover anchor a post-cutover chargeback (§4.2 BANK_TX_*_CHARGEBACK) clears against + @InjectRepository(BankTxReturn) private readonly bankTxReturnRepo: Repository, + @InjectRepository(BankTxRepeat) private readonly bankTxRepeatRepo: Repository, + @InjectRepository(CryptoInput) private readonly cryptoInputRepo: Repository, + @InjectRepository(ExchangeTx) private readonly exchangeTxRepo: Repository, + @InjectRepository(PayoutOrder) private readonly payoutOrderRepo: Repository, + @InjectRepository(LiquidityManagementOrder) + private readonly liquidityManagementOrderRepo: Repository, + @InjectRepository(TradingOrder) private readonly tradingOrderRepo: Repository, + @InjectRepository(LiquidityOrder) private readonly liquidityOrderRepo: Repository, + ) {} + + /** + * One-time cutover (§6, Blocker R13). Runs as @DfxCron (Major R2-6 — NOT onModuleInit: an awaited async + * onModuleInit would block the app boot on every pod/instance and a throw would prevent boot). Process flag + * is only effective via @DfxCron (dfx-cron.service lock layer). The whole opening sequence is failure-isolated: + * a crash never breaks the boot/cron run, leaves `ledgerCutoverLogId` unset → all consumers no-op (§4 gate). + * The cron no-ops immediately once the flag is set, so it effectively runs once and is otherwise idle. + */ + @DfxCron(CronExpression.EVERY_5_MINUTES, { process: Process.LEDGER_CUTOVER }) + async run(): Promise { + if ((await this.settingService.get(CUTOVER_LOG_ID_KEY)) != null) return; // primary guard: already cut over → no-op + + await this.cutover(); + } + + // locked cutover run, fixed order (§6.3 Blocker R1-6/R3-1) + private async cutover(): Promise { + // (1) CoA bootstrap (idempotent, findOrCreate per account) + await this.bootstrapService.bootstrap(); + + // (2) snapshot logId = newest valid FinancialDataLog ≤ cutoff date (now), PINNED on first run so a crash-then-retry + // reuses the exact same logId (stable opening sourceIds → idempotent re-run, Major design-accounting R3-1) + const snapshot = await this.pinnedSnapshot(); + if (!snapshot) throw new Error('No valid FinancialDataLog snapshot available for cutover'); + + const finance = this.parseFinance(snapshot.message); + if (!finance) throw new Error(`FinancialDataLog #${snapshot.id} message is not parseable`); + + const snapshotDate = snapshot.created; + + // (3) pin consumer watermarks + cutover boundaries (set-only-if-unset) BEFORE any opening — only rows settled + // at/before the snapshot (Blocker R3-1). Pinned up front so a fail-loud opening below (missing mark → throw, the + // ready flag stays unset, the cron retries hours later) can never trigger a recompute of the `updated`-keyed + // settled predicates against a drifted DB (see initWatermarks). + await this.initWatermarks(snapshotDate); + + const marks = await this.markService.preload(Util.daysBefore(2, snapshotDate), snapshotDate); + const equity = await this.equityAccount(); + + // (4) ASSET openings → LIABILITY openings → Manual openings (TRANSIT stays 0) + await this.openAssets(finance, snapshot, snapshotDate, equity); + await this.openLiabilities(snapshot, snapshotDate, marks, equity); + await this.openManualDebt(finance, snapshot, snapshotDate, equity); + + // export the pinned snapshot date BEFORE the ready-marker, so any consumer that sees the cutover as done can + // already classify a pre-cutover-settled row (covered by the aggregate opening). NOT the watermark's + // lastReversalScan — that drifts forward as the content-change scan advances, so it is not a stable snapshot date. + await this.settingService.set(CUTOVER_SNAPSHOT_DATE_KEY, snapshotDate.toISOString()); + + // (5) LAST: set the "ledger ready" marker the §4 gate reads (auditable: value = used logId) + await this.settingService.set(CUTOVER_LOG_ID_KEY, `${snapshot.id}`); + this.logger.info(`Ledger cutover complete from FinancialDataLog #${snapshot.id}`); + } + + // --- SNAPSHOT --- // + + // §6.3 + Major design-accounting (R3-1): the snapshot logId is PINNED at the first cutover step and reused on every + // re-run. WHY: the per-row openings commit each in their own dataSource.transaction (§6.2), NOT in one atomic + // cutover tx; if the cutover crashes after some openings but before the ledgerCutoverLogId flag is set, the flag + // stays unset and the cron retries. Without a pin, the retry re-selects `maxObj(valid,'created')` over a window that + // has drifted (now moved on, ~2284 new FinancialDataLogs/day) → a DIFFERENT logId → DIFFERENT opening sourceIds + // (`:buy_fiat:`) → alreadyBooked finds no collision → ALL openings are booked AGAIN (Equity ~2×, + // Acceptance #3 broken). Pinning the logId once keeps the snapshot stable so the re-run hits the UNIQUE/alreadyBooked + // backstop on every already-booked opening and re-books nothing. + private async pinnedSnapshot(): Promise { + const pinned = await this.settingService.get(CUTOVER_SNAPSHOT_LOG_ID_KEY); + if (pinned != null) { + // a previous (partial) run already chose the snapshot — reuse the exact logId so all sourceIds stay stable + const log = await this.logService.getLog(+pinned); + if (!log) throw new Error(`Pinned cutover snapshot FinancialDataLog #${pinned} no longer exists`); + return log; + } + + const snapshot = await this.selectSnapshot(); + if (!snapshot) return undefined; + + // pin BEFORE booking any opening (set-only-if-unset: re-read guards a concurrent pin, the chosen logId wins and + // the runner that read it first proceeds; the openings' UNIQUE backstop keeps a parallel run idempotent anyway). + if ((await this.settingService.get(CUTOVER_SNAPSHOT_LOG_ID_KEY)) == null) { + await this.settingService.set(CUTOVER_SNAPSHOT_LOG_ID_KEY, `${snapshot.id}`); + } + const repinned = await this.settingService.get(CUTOVER_SNAPSHOT_LOG_ID_KEY); + return repinned != null && +repinned !== snapshot.id ? this.logService.getLog(+repinned) : snapshot; + } + + // §6.3: newest valid=true FinancialDataLog ≤ cutoff date. Bounded read (last 2 days) then pick latest ≤ now. + private async selectSnapshot(): Promise { + const now = new Date(); + const candidates = await this.logService.getFinancialLogs(Util.daysBefore(2, now)); + const valid = candidates.filter((l) => l.created.getTime() <= now.getTime()); + + return valid.length ? Util.maxObj(valid, 'created') : undefined; + } + + private parseFinance(message: string): FinanceLog | undefined { + try { + return JSON.parse(message) as FinanceLog; + } catch { + return undefined; + } + } + + // --- ASSET OPENINGS (§6.1) --- // + + // ASSET opening from persisted balances (never plusBalance.total — pending phantoms). Feedless/placeholder → 0. + private async openAssets( + finance: FinanceLog, + snapshot: Log, + snapshotDate: Date, + equity: LedgerAccount, + ): Promise { + for (const [assetIdKey, assetLog] of Object.entries(finance.assets)) { + const assetId = +assetIdKey; + const account = await this.accountService.findByAssetId(assetId); + if (!account) continue; // asset not in the CoA (CUSTOM/PRESALE/feedless-without-row) → no opening + + const native = this.assetOpeningAmount(assetLog); + if (Math.abs(native) <= 1e-8) continue; // feedless/placeholder/zero → opening 0, no leg + + const priceChf = Number.isFinite(assetLog.priceChf) ? assetLog.priceChf : undefined; + const amountChf = priceChf != null ? Util.round(priceChf * native, 2) : undefined; + + // Major B2: key each ASSET opening on its OWN sourceId `:asset:` at seq 0 (the same per-row-marker + // pattern as the received/owed/manual-debt openings), NOT one running seq over a single `` sourceId. A + // running seq is unstable across a fail-loud retry: an asset skipped on the first run (no CoA account) that gets + // bootstrapped in between shifts every subsequent seq → alreadyBooked (nextSeq > seq) then evaluates the WRONG + // seq and either re-books an already-booked asset (double) or skips a not-yet-booked one (loss). Keying each asset + // independently makes alreadyBooked exact per asset → a retry books exactly the missing assets, no seq drift. + await this.bookOpening( + 0, + `${snapshot.id}:asset:${assetId}`, + `Opening balance for asset #${assetId} from FinancialDataLog #${snapshot.id}`, + snapshotDate, + { + account, + amount: native, + priceChf: priceChf ?? null, + amountChf, + needsMark: amountChf == null, // permanently feedless → native, mark-to-market revalues later (§5.1 stage 3) + }, + equity, + ); + } + } + + // §6.1: liquidityBalance.total + paymentDepositBalance + manualLiqPosition + custom.total — never plusBalance.total + private assetOpeningAmount(assetLog: FinanceLog['assets'][string]): number { + const liquidity = assetLog.plusBalance?.liquidity; + const liquidityBalance = liquidity?.liquidityBalance?.total ?? 0; + + // placeholder feed (amount=1.0) → opening 0, never reconcile (§7.1) + if (liquidityBalance === 1.0) return 0; + + return ( + liquidityBalance + + (liquidity?.paymentDepositBalance ?? 0) + + (liquidity?.manualLiqPosition ?? 0) + + (assetLog.plusBalance?.custom?.total ?? 0) + ); + } + + // --- LIABILITY OPENINGS (§6.1, per-row for received/owed) --- // + + private async openLiabilities( + snapshot: Log, + snapshotDate: Date, + marks: LedgerMarkCache, + equity: LedgerAccount, + ): Promise { + const lookback = Util.daysBefore(OPEN_ROW_LOOKBACK_DAYS, snapshotDate); + // load every bank keyed by IBAN ONCE (§6.1) — openBankTxReturn/Repeat/Unattributed value each row's bank leg + // against this map instead of a per-row bankRepo.findOne (N+1) + const bankByIban = await this.bankByIban(); + + // F2: openBuyFiat/CryptoReceived+Owed return the ids of open rows they could NOT value into a per-row opening + // (amountInChf NULL → no CHF anchor). Pin them per source (set-only-if-unset) so the forward consumer skips+advances + // (with an alarm) instead of wedging, and the Card seq0 does not double-book the gross once the row is priced. + const unpricedBuyFiat = [ + ...(await this.openBuyFiatReceived(snapshot, snapshotDate, lookback, equity)), + ...(await this.openBuyFiatOwed(snapshot, snapshotDate, lookback, marks, equity)), + ]; + await this.pinUnpricedIds('buy_fiat', unpricedBuyFiat); + + const unpricedBuyCrypto = await this.openBuyCryptoReceived(snapshot, snapshotDate, lookback, equity); + await this.openBuyCryptoOwed(snapshot, snapshotDate, lookback, marks, equity); + await this.pinUnpricedIds('buy_crypto', unpricedBuyCrypto); + // §6.1 (Major design-accounting): the BANK_TX_RETURN/REPEAT + unattributed liabilities. A pre-cutover open + // return/repeat whose chargeback settles post-cutover (§4.2 BANK_TX_*_CHARGEBACK) finds its opening-CHF anchor + // here; without it the chargeback's −Σ(other legs) fallback leaves the liability phantom-negative (never on 0). + await this.openBankTxReturn(snapshot, snapshotDate, lookback, marks, equity, bankByIban); + await this.openBankTxRepeat(snapshot, snapshotDate, lookback, marks, equity, bankByIban); + await this.openUnattributed(snapshot, snapshotDate, lookback, marks, equity, bankByIban); + } + + // buyFiat-received: open rows with outputAmount NULL → CHF = amountInChf (Minor R3-6); per-row seq0-marker (R4-2). + // Returns the ids of rows with a NULL amountInChf (no CHF anchor) so the caller pins them as unpriced-at-cutover (F2). + // G-a exclusivity (Major): the per-row received/paymentLink opening is mutually exclusive with the forward + // crypto_input seq0. A row whose funding crypto_input is NOT covered by the pinned cutover opening + // (isCoveredByCutoverOpening=false, keyed on the immutable at-snapshot crypto_input boundary — NOT the mutable live + // status) is skipped here — its forward seq0 is the SINGLE received/paymentLink opener; opening it per-row too would + // double-credit the bucket (permanent phantom, no alarm — the cutover and crypto_input sourceId namespaces are + // disjoint, so no UNIQUE backstop catches it). A covered input keeps the per-row opening (its seq0 is suppressed as + // covered-by-cutover, so the per-row opening is then the sole opener). Card-/bank-funded rows have cryptoInput=null → unchanged. + private async openBuyFiatReceived( + snapshot: Log, + date: Date, + lookback: Date, + equity: LedgerAccount, + ): Promise { + const rows = await this.buyFiatRepo.find({ + where: { isComplete: false, outputAmount: IsNull(), created: Between(lookback, date) }, + // F1: load paymentLinkPayment to route a paymentLink-funded row to its OWN paymentLink opening instead of + // buyFiat-received — the forward bookPaymentLink path clears LIABILITY/paymentLink and would NEVER consume a + // buyFiat-received/-owed opening (permanent content-scan wedge), and its opening would land in the wrong bucket. + // cryptoInput.id is read below (G-a) to check coverage against the pinned at-snapshot cutover boundary + // (isCoveredByCutoverOpening) — an input NOT covered has its forward seq0 as the sole opener, so the per-row + // opening is skipped. + relations: { cryptoInput: { paymentLinkPayment: true } }, + }); + const received = await this.liability('buyFiat-received'); + const paymentLink = await this.liability('paymentLink'); + const unpriced: number[] = []; + + for (const row of rows) { + if (row.amountInChf == null) { + unpriced.push(row.id); // F2: no CHF anchor → pin; forward SKIPs+advances (alarm), value stays in the aggregate + continue; + } + // G-a: the per-row opening must be EXACTLY complementary to the forward crypto_input seq0 suppression — both key on + // the SAME pinned at-snapshot boundary (isCoveredByCutoverOpening), NEVER on the mutable live status. A settlement + // inside the fail-loud retry window would otherwise let BOTH the forward seq0 (live-settled + not covered) and this + // per-row opening credit received → a permanent double-credit. Open here IFF the input is covered (its value is in + // the aggregate opening and its seq0 is suppressed); otherwise the forward seq0 is the single opener. + if ( + row.cryptoInput && + !(await isCoveredByCutoverOpening(this.settingService, 'crypto_input', row.cryptoInput.id)) + ) + continue; + await this.openBuyFiatRow(snapshot, date, row, received, paymentLink, equity); + } + + return unpriced; + } + + // buyFiat-owed: open rows with outputAmount NOT NULL → CHF = outputAmount × mark(outputAsset-Fiat ≤ snapshot) (R6-1). + // Returns the ids of paymentLink rows with a NULL amountInChf (no paymentLink anchor) so the caller pins them (F2). + private async openBuyFiatOwed( + snapshot: Log, + date: Date, + lookback: Date, + marks: LedgerMarkCache, + equity: LedgerAccount, + ): Promise { + const rows = await this.buyFiatRepo.find({ + where: { isComplete: false, created: Between(lookback, date) }, + // F1: load paymentLinkPayment to detect a paymentLink row (its opening goes to LIABILITY/paymentLink, not -owed). + // cryptoInput.id is read below (G-a) to check coverage against the pinned cutover boundary (isCoveredByCutoverOpening). + relations: { outputAsset: true, cryptoInput: { paymentLinkPayment: true } }, + }); + const owed = await this.liability('buyFiat-owed'); + const paymentLink = await this.liability('paymentLink'); + const unpriced: number[] = []; + + for (const row of rows) { + if (row.outputAmount == null) continue; + + // G-a: the per-row opening (paymentLink or owed) must be EXACTLY complementary to the forward crypto_input seq0 + // suppression — both key on the SAME pinned at-snapshot boundary (isCoveredByCutoverOpening), NEVER on the mutable + // live status. A settlement inside the fail-loud retry window would otherwise let BOTH the forward seq0 + // (live-settled + not covered) and this per-row opening credit the bucket → a permanent double-credit. Open here + // IFF the input is covered (its value is in the aggregate opening and its seq0 is suppressed); otherwise the + // forward seq0 is the single opener. Card-/bank-funded rows have cryptoInput=null → the guard does not fire. + if ( + row.cryptoInput && + !(await isCoveredByCutoverOpening(this.settingService, 'crypto_input', row.cryptoInput.id)) + ) + continue; + + // F1: a paymentLink-funded owed-straddling row → book the paymentLink opening at the gross (amountInChf), NOT + // buyFiat-owed. amountInChf NULL → pin as unpriced (F2), never a paymentLink opening on a missing anchor. + if (row.cryptoInput?.paymentLinkPayment != null) { + if (row.amountInChf == null) { + unpriced.push(row.id); + continue; + } + await this.bookReceivedOwedOpening( + date, + `${snapshot.id}:buy_fiat-paymentLink:${row.id}`, + `Opening buyFiat paymentLink from open buy_fiat #${row.id}`, + paymentLink, + row.amountInChf, + equity, + ); + continue; + } + + // outputAsset is a Fiat; CHF-output → mark 1, foreign-currency output → fiat-mark ≤ snapshot + const fiatMark = row.outputAsset?.name === CHF ? 1 : this.fiatMark(row.outputAsset?.id, date, marks); + const amountChf = fiatMark != null ? Util.round(row.outputAmount * fiatMark, 2) : undefined; + + // missing fiat-mark → amountChf undefined → bookReceivedOwedOpening throws (m6 fail-loud): the forward path can + // NEVER supply this opening — buy-fiat bookRegular (§4.7a) skips seq1 for an owed-straddling row and anchors + // seq2/seq3 on exactly this opening, so a missing opening would gate-block the row in the content-change scan + // forever. A missing mark must abort the cutover run (already-booked openings are skipped idempotently on the + // retry once the mark feed is available), never a silent skip. + await this.bookReceivedOwedOpening( + date, + `${snapshot.id}:buy_fiat-owed:${row.id}`, + `Opening buyFiat-owed from open buy_fiat #${row.id}`, + owed, + amountChf, + equity, + ); + } + + return unpriced; + } + + // §4.7b/§6.1 (F1): one open buyFiat received-row opening. A paymentLink-funded row (cryptoInput.paymentLinkPayment) + // gets a per-row LIABILITY/paymentLink opening `${logId}:buy_fiat-paymentLink:${id}` at the gross (amountInChf) — the + // forward bookPaymentLink path clears it via fee + venue-spread + transmit. A regular row gets the buyFiat-received + // opening. Both carry the same amountInChf CHF value; only the target liability bucket + marker differ. + private async openBuyFiatRow( + snapshot: Log, + date: Date, + row: BuyFiat, + received: LedgerAccount, + paymentLink: LedgerAccount, + equity: LedgerAccount, + ): Promise { + const isPaymentLink = row.cryptoInput?.paymentLinkPayment != null; + await this.bookReceivedOwedOpening( + date, + isPaymentLink ? `${snapshot.id}:buy_fiat-paymentLink:${row.id}` : `${snapshot.id}:buy_fiat:${row.id}`, + isPaymentLink + ? `Opening buyFiat paymentLink from open buy_fiat #${row.id}` + : `Opening buyFiat-received from open buy_fiat #${row.id}`, + isPaymentLink ? paymentLink : received, + row.amountInChf, + equity, + ); + } + + // buyCrypto-received: open rows with outputAmount NULL → CHF = amountInChf (Minor R2-7); per-row seq0-marker (R4-2). + // §6.1 (Major B1): Card inputs (checkoutTx != null) are INCLUDED, symmetrically to bank/crypto-funded open rows. An + // open Card row's card-currency GROSS is ALREADY in the aggregate ASSET opening — openAssets books + // liquidityBalance.total, which carries the Checkout.com collateral feed: a card charge is auto-captured at payment + // and sits in that feed until the CHECKOUT_LTD settlement. So the ASSET side is covered by the aggregate opening + // (exactly as a bank balance funds a bank-funded open row) and THIS per-row opening covers the received LIABILITY + // side; the completion seq1 later closes received against it. The forward Card seq0 + // (buy-crypto.consumer.buildCardInputSeq0) is gated to SKIP when this marker exists — WITHOUT both the per-row + // opening and the skip, the forward seq0 would re-debit the gross on Checkout/{ccy} a SECOND time (permanent phantom + // on Checkout/{ccy}, the pre-fix double-count). + // G-a exclusivity (Major): a crypto-funded open row (cryptoInput != null) whose funding input is NOT covered by the + // pinned cutover opening (isCoveredByCutoverOpening=false, keyed on the immutable at-snapshot crypto_input boundary — + // NOT the mutable live status) is EXCLUDED here — its forward crypto_input seq0 is the SOLE + // buyCrypto-received opener; opening it per-row too would double-credit buyCrypto-received (permanent phantom, no + // alarm — the cutover and crypto_input sourceId namespaces are disjoint, so no UNIQUE backstop catches it, and + // isCoveredByCutoverOpening only knows the crypto_input boundary). A covered input keeps the per-row opening (its + // seq0 is suppressed as covered-by-cutover, so the per-row opening is then the sole opener). Card-/bank-funded rows + // (cryptoInput=null) are unaffected — Card: buildCardInputSeq0 is skipped by hasCutoverReceivedOpening; bank: its + // funding bank_tx seq0 is suppressed by the immutable-bookingDate watermark. + private async openBuyCryptoReceived( + snapshot: Log, + date: Date, + lookback: Date, + equity: LedgerAccount, + ): Promise { + const rows = await this.buyCryptoRepo.find({ + where: { isComplete: false, outputAmount: IsNull(), created: Between(lookback, date) }, + // G-a: load cryptoInput to decide the received opener — a crypto-funded row whose input is not covered by the + // pinned cutover opening (isCoveredByCutoverOpening) is skipped (its forward seq0 opens received); a Card/bank-funded + // row has cryptoInput=null. + relations: { cryptoInput: true }, + }); + const liability = await this.liability('buyCrypto-received'); + const unpriced: number[] = []; + + for (const row of rows) { + if (row.amountInChf == null) { + // F2: no CHF anchor → pin; the forward buildCardInputSeq0 SKIPs (its gross is in the aggregate opening via the + // Checkout collateral feed, re-booking would double-count) and the completion scan skips+advances (no wedge). + unpriced.push(row.id); + continue; + } + // G-a: the per-row opening must be EXACTLY complementary to the forward crypto_input seq0 suppression — both key on + // the SAME pinned at-snapshot boundary (isCoveredByCutoverOpening), NEVER on the mutable live status. A settlement + // inside the fail-loud retry window would otherwise let BOTH the forward seq0 (live-settled + not covered) and this + // per-row opening credit received → a permanent double-credit. Open here IFF the input is covered (its value is in + // the aggregate opening and its seq0 is suppressed); otherwise the forward seq0 is the single opener. + if ( + row.cryptoInput && + !(await isCoveredByCutoverOpening(this.settingService, 'crypto_input', row.cryptoInput.id)) + ) + continue; + await this.bookReceivedOwedOpening( + date, + `${snapshot.id}:buy_crypto:${row.id}`, + `Opening buyCrypto-received from open buy_crypto #${row.id}`, + liability, + row.amountInChf, + equity, + ); + } + + return unpriced; + } + + // §6.1 F2: pin the open rows the cutover could not value into a per-row opening (amountInChf NULL) — set-only-if-unset + // (like the boundary/watermark) so a fail-loud retry reuses the run-1 list verbatim and never overwrites it. An empty + // list is never pinned (isUnpricedAtCutover defaults to false). Called BEFORE the ready flag → the forward consumer + // sees it as soon as it starts. + private async pinUnpricedIds(source: string, ids: number[]): Promise { + if (!ids.length) return; // no unpriced rows → no pin (forward defaults to the normal path) + if ((await getCutoverUnpricedIdsRaw(this.settingService, source)) != null) return; // already pinned → reuse verbatim + await setCutoverUnpricedIds(this.settingService, source, ids); + } + + // buyCrypto-owed: open rows with outputAmount NOT NULL → CHF = outputAmount × getMarkAt(outputAsset ≤ snapshot) (R6-1) + private async openBuyCryptoOwed( + snapshot: Log, + date: Date, + lookback: Date, + marks: LedgerMarkCache, + equity: LedgerAccount, + ): Promise { + const rows = await this.buyCryptoRepo.find({ + where: { isComplete: false, created: Between(lookback, date) }, + // G-a: load cryptoInput to decide the owed opener — a crypto-funded row whose input is not covered by the pinned + // cutover opening (isCoveredByCutoverOpening) is skipped (its forward crypto_input seq0 opens received and the + // completion seq1 closes it; a per-row owed opening here would make that seq1 SKIP via hasCutoverOwedOpening → + // orphaned received phantom); a Card/bank-funded row has cryptoInput=null. + relations: { outputAsset: true, cryptoInput: true }, + }); + const liability = await this.liability('buyCrypto-owed'); + + for (const row of rows) { + if (row.outputAmount == null) continue; + + // G-a: the per-row owed opening must be EXACTLY complementary to the forward crypto_input seq0/seq1 handling — key + // on the SAME pinned at-snapshot boundary (isCoveredByCutoverOpening), NEVER on the mutable live status. In the + // [snapshot→pin] retry window an `updated` bump (FORWARD_CONFIRMED→COMPLETED) can leave an input NOT covered while + // its forward seq0 already opens buyCrypto-received; booking a per-row owed opening here would make the forward + // completion seq1 SKIP via hasCutoverOwedOpening → the received leg never closes (orphaned received phantom, no + // UNIQUE backstop — the cutover and crypto_input sourceId namespaces are disjoint). Open here IFF the input is + // covered (its value is in the aggregate opening and its seq0 is suppressed); otherwise the forward seq0/seq1 chain + // is the sole handler. Card-/bank-funded rows have cryptoInput=null → the guard does not fire. + if ( + row.cryptoInput && + !(await isCoveredByCutoverOpening(this.settingService, 'crypto_input', row.cryptoInput.id)) + ) + continue; + + const mark = row.outputAsset?.id != null ? marks.getMarkAt(row.outputAsset.id, date) : undefined; + const amountChf = mark != null ? Util.round(row.outputAmount * mark, 2) : undefined; + + // feedless outputAsset → amountChf undefined → bookReceivedOwedOpening throws (m6 fail-loud): a CHF owed + // opening booked with native 0 can never be revalued, so a missing mark must roll back the cutover, not silently + // drop the value. + await this.bookReceivedOwedOpening( + date, + `${snapshot.id}:buy_crypto-owed:${row.id}`, + `Opening buyCrypto-owed from open buy_crypto #${row.id}`, + liability, + amountChf, + equity, + ); + } + } + + // --- BANK_TX_RETURN / BANK_TX_REPEAT / UNATTRIBUTED OPENINGS (§6.1, Major design-accounting) --- // + + // §6.1: open BANK_TX_RETURN liabilities (`chargebackBankTx IS NULL` → still open) per source-row, CHF-valued = + // pendingInputAmount(bankAsset) × mark(bankAsset ≤ snapshot) so it matches the forward consumer's `EUR-mark × amount` + // credit (bank-tx.consumer.ts liabilityCreditLegs) and the post-cutover chargeback's opening-CHF anchor (§4.2 B-15). + // Per-row sourceId marker `:bank_tx-return:` lets the chargeback consumer find this opening leg + // (analog the owed marker) → bankTx-return closes cent-exact to 0 instead of staying phantom-negative. + private async openBankTxReturn( + snapshot: Log, + date: Date, + lookback: Date, + marks: LedgerMarkCache, + equity: LedgerAccount, + bankByIban: Map, + ): Promise { + const rows = await this.bankTxReturnRepo.find({ + where: { chargebackBankTx: IsNull(), created: Between(lookback, date) }, + relations: { bankTx: true }, + }); + const liability = await this.liability('bankTx-return'); + + for (const row of rows) { + await this.openOpenLiabilityRow( + snapshot, + date, + marks, + equity, + liability, + 'bank_tx-return', + row.bankTx, + bankByIban, + ); + } + } + + // §6.1: same as openBankTxReturn for BANK_TX_REPEAT (`chargebackBankTx IS NULL`), marker `:bank_tx-repeat:` + private async openBankTxRepeat( + snapshot: Log, + date: Date, + lookback: Date, + marks: LedgerMarkCache, + equity: LedgerAccount, + bankByIban: Map, + ): Promise { + const rows = await this.bankTxRepeatRepo.find({ + where: { chargebackBankTx: IsNull(), created: Between(lookback, date) }, + relations: { bankTx: true }, + }); + const liability = await this.liability('bankTx-repeat'); + + for (const row of rows) { + await this.openOpenLiabilityRow( + snapshot, + date, + marks, + equity, + liability, + 'bank_tx-repeat', + row.bankTx, + bankByIban, + ); + } + } + + // one per-row return/repeat opening: Cr LIABILITY/{bucket} / Dr EQUITY at CHF = amount × bankMark (≤ snapshot). + // CHF bank → mark 1; non-CHF (EUR) → EUR-mark; feedless/no-bank-match → needsMark (mark-to-market values later). + private async openOpenLiabilityRow( + snapshot: Log, + date: Date, + marks: LedgerMarkCache, + equity: LedgerAccount, + liability: LedgerAccount, + marker: string, + bankTx: BankTx | undefined, + bankByIban: Map, + ): Promise { + if (bankTx?.amount == null) return; // no underlying bank_tx amount → nothing to anchor + + const { mark } = this.bankMark(bankTx, date, marks, bankByIban); + const amountChf = mark != null ? Util.round(bankTx.amount * mark, 2) : undefined; + + // feedless / no-bank-match → amountChf undefined → bookReceivedOwedOpening throws (m6 fail-loud): a CHF + // return/repeat opening booked with native 0 is never revalued, so a missing mark rolls back the cutover. + await this.bookReceivedOwedOpening( + date, + `${snapshot.id}:${marker}:${bankTx.id}`, + `Opening ${marker} from open bank_tx #${bankTx.id}`, + liability, + amountChf, + equity, + ); + } + + // §6.1: aggregated LIABILITY/unattributed opening from still-open unattributed bank_tx credits (type NULL/Pending/ + // Unknown/GSheet, CRDT). CHF-valued = Σ(amount × bankMark) so it matches the forward consumer's `EUR-mark × amount` + // credit (bank-tx.consumer.ts liabilityCreditLegs 'unattributed'). Aggregated (no per-row marker): there is no + // chargeback-clearing path that resolves a single unattributed row — the balance is carried like the LogJob does. + private async openUnattributed( + snapshot: Log, + date: Date, + lookback: Date, + marks: LedgerMarkCache, + equity: LedgerAccount, + bankByIban: Map, + ): Promise { + // §6.1: type NULL/Pending/Unknown/GSheet credits → the unattributed bucket (one query, two where-branches ORed + // for the NULL type) + const credit = { creditDebitIndicator: BankTxIndicator.CREDIT, created: Between(lookback, date) }; + const rows = await this.bankTxRepo.find({ + where: [ + { ...credit, type: In(UNATTRIBUTED_TYPES) }, + { ...credit, type: IsNull() }, + ], + }); + + let amountChf = 0; + let needsMark = false; + for (const row of rows) { + if (row.amount == null) continue; + const { mark } = this.bankMark(row, date, marks, bankByIban); + if (mark == null) { + needsMark = true; // a feedless/unmatched credit cannot be valued now → mark-to-market values the rest later + continue; + } + amountChf += Util.round(row.amount * mark, 2); + } + + if (Math.abs(amountChf) <= 1e-8 && !needsMark) return; // no open unattributed credits → no opening + + const liability = await this.liability('unattributed'); + // a feedless/unmatched credit leaves the aggregate unvaluable → amountChf undefined → bookReceivedOwedOpening + // throws (m6 fail-loud): the CHF unattributed bucket is booked with native 0 and can never be revalued, so a + // missing mark rolls back the cutover rather than dropping the value into a stale zero-opening. + await this.bookReceivedOwedOpening( + date, + `${snapshot.id}:unattributed`, + `Opening unattributed from open bank_tx credits as of FinancialDataLog #${snapshot.id}`, + liability, + needsMark ? undefined : Util.round(amountChf, 2), + equity, + ); + } + + // every bank keyed by IBAN, loaded ONCE per cutover (§6.1) so the per-row bank leg is valued from an in-memory map + // instead of a per-row bankRepo.findOne (N+1). Banks without an IBAN are skipped (no accountIban can match them). + private async bankByIban(): Promise> { + const banks = await this.bankRepo.find({ relations: { asset: true } }); + return new Map(banks.filter((b) => b.iban != null).map((b) => [b.iban, b])); + } + + // the bank's currency asset + its CHF mark (≤ snapshot) for a bank_tx (via accountIban → Bank.asset, §4.2/§1.6). + // CHF bank → mark 1; EUR bank → EUR-mark from the cache; no bank match / feedless → mark undefined (caller needsMark). + private bankMark( + bankTx: BankTx, + date: Date, + marks: LedgerMarkCache, + bankByIban: Map, + ): { asset?: Asset; mark: number | undefined } { + const bank = bankTx.accountIban ? bankByIban.get(bankTx.accountIban) : undefined; + if (bank?.currency === CHF || bankTx.currency === CHF) return { asset: bank?.asset, mark: 1 }; + const asset = bank?.asset; + return { asset, mark: asset?.id != null ? marks.getMarkAt(asset.id, date) : undefined }; + } + + // --- MANUAL OPENING (§6.1 D15 C.f) --- // + + // Only the debt side as a separate manual-opening leg: Dr EQUITY/opening-balance / Cr LIABILITY/manual-debt. + // The liq side is already part of the ASSET-opening sum (manualLiqPosition) → never double-counted (Minor R6-5). + private async openManualDebt( + finance: FinanceLog, + snapshot: Log, + snapshotDate: Date, + equity: LedgerAccount, + ): Promise { + const debts = await this.settingService.getObj('balanceLogDebtPositions', []); + if (!debts?.length) return; + + const manualDebt = await this.liability('manual-debt'); + for (const position of debts) { + if (!position?.value) continue; + + const rawPrice = finance.assets[position.assetId]?.priceChf; + const priceChf = Number.isFinite(rawPrice) ? rawPrice : undefined; + const amountChf = priceChf != null ? Util.round(priceChf * position.value, 2) : undefined; + + // feedless asset (no priceChf in the snapshot) → amountChf undefined → bookReceivedOwedOpening throws (m6 + // fail-loud): the manual-debt LIABILITY is CHF-denominated with NO assetId, so the mark-to-market job can NEVER + // revalue it — a missing price must abort the cutover run (already-booked openings are skipped idempotently on + // the retry once the price feed is available), not silently drop the CHF value or book native units on a CHF + // account. + await this.bookReceivedOwedOpening( + snapshotDate, + `${snapshot.id}:manual-debt:${position.assetId}`, + `Opening manual-debt for asset #${position.assetId} from FinancialDataLog #${snapshot.id}`, + manualDebt, + amountChf, + equity, + ); + } + } + + // --- WATERMARK INIT (§6.3 step 3, Blocker R3-1) --- // + + // pins each ledgerWatermark. to MAX(id) of pre-cutover settled rows + lastReversalScan = snapshotDate, and + // (guard sources) the cutover boundary — ONCE, up front (BEFORE any opening), set-only-if-unset: a retry after a + // fail-loud opening reuses the pinned values verbatim, so the forward consumers never re-book a row whose settlement + // the opening already covers (no double-count). ALL nine consumer sources MUST be initialised here (§6.3 Z.910-917, + // Blocker R3-1) — a missing watermark would default the consumer to lastProcessedId:0 → WHERE id>0 full-history + // backfill (Hard Constraint #4 + ASSET double-count vs the openAssets openings, §6.1). The settled-filter per source + // is exactly the §4.x consumer filter (§6.3 Z.917). + private async initWatermarks(snapshotDate: Date): Promise { + // per-source settled filters (§4.x / §6.3 Z.917) — each extracted into a named const so the SAME predicate is + // reused for BOTH the MAX(id) boundary and the open-hole id query. Filter identity guarantees consistent + // classification only WITHIN one computation; across a fail-loud retry it holds ONLY because boundary+watermark + // are pinned set-only-if-unset on the FIRST run, BEFORE any opening (loop below). Four guard sources are keyed on + // the mutable `updated` (exchange_tx on the immutable externalCreated): an `updated` bump between runs would + // otherwise flip a settled-at-snapshot row's classification — recorded as a NEW hole, or dropped from a shrunken + // boundary — and its forward seq0 would double-book the aggregate opening (ASSET double-count + phantom liability). + const ciFilter = (qb: SelectQueryBuilder) => + qb.andWhere('e.status IN (:...ciStatus)', { ciStatus: CryptoInputSettledStatus }); + const poFilter = (qb: SelectQueryBuilder) => + qb.andWhere('e.status = :poStatus', { poStatus: PayoutOrderStatus.COMPLETE }); + const etFilter = (qb: SelectQueryBuilder) => qb.andWhere('e.status = :etStatus', { etStatus: 'ok' }); + const lmFilter = (qb: SelectQueryBuilder) => + qb.andWhere('e.status = :lmStatus', { lmStatus: LiquidityManagementOrderStatus.COMPLETE }); + const toFilter = (qb: SelectQueryBuilder) => + qb.andWhere('e.status = :toStatus', { toStatus: TradingOrderStatus.COMPLETE }).andWhere('e.txId IS NOT NULL'); + const loFilter = (qb: SelectQueryBuilder) => + qb + .andWhere('e.txId IS NOT NULL') + .andWhere('e.context IN (:...loContexts)', { + loContexts: [ + LiquidityOrderContext.LIQUIDITY_MANAGEMENT, + LiquidityOrderContext.BUY_CRYPTO, + LiquidityOrderContext.TRADING, + ], + }) + .andWhere('e.type IN (:...loTypes)', { + loTypes: [LiquidityOrderType.PURCHASE, LiquidityOrderType.SELL], + }); + + // The 5 guard sources ALSO persist a cutover boundary (boundaryId + holeIds) via `holeIds`; bank_tx/payout_order/ + // buy_crypto/buy_fiat keep watermark-only (no holeIds). The watermark value is unchanged for every source. + const sources: { + source: string; + maxId: () => Promise; + holeIds?: (boundaryId: number) => Promise; + }[] = [ + { source: 'bank_tx', maxId: () => this.maxSettledId(this.bankTxRepo, 'bookingDate', snapshotDate) }, + // §4.4 — crypto_input: status ∈ CryptoInputSettledStatus + updated <= snapshot (§6.3 Z.917) + { + source: 'crypto_input', + maxId: () => this.maxSettledId(this.cryptoInputRepo, 'updated', snapshotDate, ciFilter), + holeIds: (b) => this.openHoleIds(this.cryptoInputRepo, b, snapshotDate, 'updated', ciFilter), + }, + // §4.5 — payout_order: status='Complete' + updated <= snapshot (§6.3 Z.917) + { + source: 'payout_order', + maxId: () => this.maxSettledId(this.payoutOrderRepo, 'updated', snapshotDate, poFilter), + }, + // §4.3 — exchange_tx: status='ok' + (externalCreated ?? created) <= snapshot (§6.3 Z.917) + { + source: 'exchange_tx', + maxId: () => this.maxSettledId(this.exchangeTxRepo, 'externalCreated', snapshotDate, etFilter), + holeIds: (b) => this.openHoleIds(this.exchangeTxRepo, b, snapshotDate, 'externalCreated', etFilter), + }, + { source: 'buy_crypto', maxId: () => this.maxSettledId(this.buyCryptoRepo, 'updated', snapshotDate) }, + { source: 'buy_fiat', maxId: () => this.maxSettledId(this.buyFiatRepo, 'updated', snapshotDate) }, + // §4.8 — liquidity_management_order: status='Complete' + updated <= snapshot + { + source: 'liquidity_management_order', + maxId: () => this.maxSettledId(this.liquidityManagementOrderRepo, 'updated', snapshotDate, lmFilter), + holeIds: (b) => this.openHoleIds(this.liquidityManagementOrderRepo, b, snapshotDate, 'updated', lmFilter), + }, + // §4.9 — trading_order: status='Complete' AND txId IS NOT NULL + updated <= snapshot + { + source: 'trading_order', + maxId: () => this.maxSettledId(this.tradingOrderRepo, 'updated', snapshotDate, toFilter), + holeIds: (b) => this.openHoleIds(this.tradingOrderRepo, b, snapshotDate, 'updated', toFilter), + }, + // §4.8a — liquidity_order: txId IS NOT NULL AND context IN (...) AND type IN ('Purchase','Sell') + updated <= snapshot + { + source: 'liquidity_order', + maxId: () => this.maxSettledId(this.liquidityOrderRepo, 'updated', snapshotDate, loFilter), + holeIds: (b) => this.openHoleIds(this.liquidityOrderRepo, b, snapshotDate, 'updated', loFilter), + }, + ]; + + // set-only-if-unset (§6.3): recomputing on a retry would re-evaluate the settled predicates against a drifted DB — + // a guard-source row settled at the snapshot whose `updated` was bumped in [snapshot → retry] falls out of the + // predicate (shrunken boundary or a new hole) and its forward seq0 double-books the aggregate opening. ACCEPTED + // RESIDUAL: a tiny window remains between the snapshot's `created` timestamp and each source's pin below — + // a guard-source row settled at-or-before the snapshot whose `updated` is bumped inside that window is still + // misclassified. Once a source is pinned its window is closed for good; only a crash INSIDE this pin step + // (a transient infrastructure error — no mark-feed dependency exists here, unlike the openings) leaves the + // not-yet-pinned sources to be computed on the retry, so the exposure stays bounded to pin-step execution + // latency (seconds per attempt), unlike the pre-fix ordering where every fail-loud OPENING (missing mark, + // potentially hours until the feed recovers) re-opened the full recompute window for ALL sources → + // deliberately not closed with a heavier snapshot/lock mechanism. + for (const { source, maxId, holeIds } of sources) { + const watermarkPinned = (await this.settingService.get(`${WATERMARK_KEY_PREFIX}${source}`)) != null; + const pinnedBoundary = holeIds ? await getCutoverBoundary(this.settingService, source) : undefined; + + if (watermarkPinned && (!holeIds || pinnedBoundary)) continue; // fully pinned on a prior run → reuse verbatim + + const boundaryId = pinnedBoundary ? pinnedBoundary.boundaryId : await maxId(); + + // boundary FIRST, then the watermark derived from it: a crash between the two writes leaves the boundary + // pinned and the retry re-derives the watermark from it → lastProcessedId == boundaryId holds across retries + if (holeIds && !pinnedBoundary) { + await setCutoverBoundary(this.settingService, source, { boundaryId, holeIds: await holeIds(boundaryId) }); + } + if (!watermarkPinned) { + await this.setWatermark(source, boundaryId, snapshotDate); // lastProcessedId derived from the (pinned) boundary + } + } + } + + // MAX(id) of rows whose settlement date ≤ snapshot AND that match the per-consumer settled filter (§4.x / §6.3 + // Z.917). The optional `filter` appends the consumer-specific settled-status predicates (e.g. status='Complete', + // txId IS NOT NULL) so the watermark = "highest pre-cutover row whose settlement the opening already covers". + private async maxSettledId( + repo: Repository, + dateColumn: string, + snapshotDate: Date, + filter?: (qb: SelectQueryBuilder) => SelectQueryBuilder, + ): Promise { + let qb = repo + .createQueryBuilder('e') + .select('MAX(e.id)', 'max') + .where(`COALESCE(e.${dateColumn}, e.created) <= :date`, { date: snapshotDate }); + + if (filter) qb = filter(qb); // appends the per-consumer settled-status predicates via .andWhere (all ANDed) + + const { max } = (await qb.getRawOne<{ max: number | null }>()) ?? { max: null }; + + return max ?? 0; + } + + // §6.3 — ids <= boundaryId that were OPEN (not settled) at the snapshot and created within OPEN_ROW_LOOKBACK_DAYS. + // = (recent ids <= boundary) MINUS (recent SETTLED ids <= boundary), reusing the exact per-source settled filter for + // a consistent classification WITHIN this computation. Across runs the classification is frozen because the RESULT + // is pinned once (set-only-if-unset, initWatermarks) — the filter alone is not time-invariant: an `updated` bump + // between runs would flip a settled-at-snapshot row into a hole, re-booking it post-cutover → double-count. + // A >OPEN_ROW_LOOKBACK_DAYS-old unsettled row is treated as terminal (excluded), consistent with openLiabilities. + private async openHoleIds( + repo: Repository, + boundaryId: number, + snapshotDate: Date, + dateColumn: string, + filter?: (qb: SelectQueryBuilder) => SelectQueryBuilder, + ): Promise { + if (boundaryId <= 0) return []; // boundary 0 = nothing settled at the snapshot → no id <= boundary → no holes + const cutoff = Util.daysBefore(OPEN_ROW_LOOKBACK_DAYS, snapshotDate); + const allRecent = await this.idsUpToBoundary(repo, boundaryId, cutoff); + const settledRecent = await this.idsUpToBoundary(repo, boundaryId, cutoff, snapshotDate, dateColumn, filter); + const settled = new Set(settledRecent); + return allRecent.filter((id) => !settled.has(id)); + } + + // ids <= boundaryId created after `cutoff`. With `snapshotDate`+`dateColumn`(+filter) it additionally restricts to + // rows SETTLED at the snapshot (the per-source predicate), mirroring maxSettledId's `COALESCE(e., e.created) + // <= :date` verbatim so there is no new Postgres-quoting divergence from that method (alias `e`, `e.id` selected). + private async idsUpToBoundary( + repo: Repository, + boundaryId: number, + cutoff: Date, + snapshotDate?: Date, + dateColumn?: string, + filter?: (qb: SelectQueryBuilder) => SelectQueryBuilder, + ): Promise { + let qb = repo + .createQueryBuilder('e') + .select('e.id', 'id') + .where('e.id <= :boundaryId', { boundaryId }) + .andWhere('e.created > :cutoff', { cutoff }); + if (snapshotDate && dateColumn) + qb = qb.andWhere(`COALESCE(e.${dateColumn}, e.created) <= :snap`, { snap: snapshotDate }); + if (filter) qb = filter(qb); + const rows = await qb.getRawMany<{ id: number }>(); + return rows.map((r) => +r.id); + } + + private async setWatermark(source: string, lastProcessedId: number, snapshotDate: Date): Promise { + await this.settingService.set( + `${WATERMARK_KEY_PREFIX}${source}`, + JSON.stringify({ lastProcessedId, lastReversalScan: snapshotDate.toISOString() }), + ); + } + + // --- BOOKING HELPERS --- // + + // a single 2-leg opening tx (account leg + EQUITY counter-leg) → balances by construction in CHF (§6.2) + private async bookOpening( + seq: number, + sourceId: string, + description: string, + bookingDate: Date, + accountLeg: LedgerLegInput, + equity: LedgerAccount, + ): Promise { + if (await this.alreadyBooked(sourceId, seq)) return; // re-run idempotent (UNIQUE backstop, Setting primary guard) + + const counterChf = accountLeg.amountChf != null ? -accountLeg.amountChf : undefined; + await this.bookingService.bookTx({ + sourceType: SOURCE_TYPE, + sourceId, + seq, + bookingDate, + valueDate: bookingDate, + description, + legs: [ + accountLeg, + { + account: equity, + amount: counterChf ?? 0, + priceChf: 1, + amountChf: counterChf, + needsMark: accountLeg.needsMark, + }, + ], + }); + } + + // per-row received/owed opening (seq=0): Cr LIABILITY/{…} / Dr EQUITY/opening-balance, CHF-valued (§6.3 R4-2/R6-1) + private async bookReceivedOwedOpening( + bookingDate: Date, + sourceId: string, + description: string, + liability: LedgerAccount, + amountChf: number | undefined, + equity: LedgerAccount, + ): Promise { + // m6 fail-loud: a received/owed/unattributed/manual-debt opening lives on a CHF-denominated LIABILITY + // (assetId=NULL) and would be booked with native 0, so the mark-to-market job (assetId IS NOT NULL, native≠0) can + // NEVER revalue it. Booking it with amountChf=undefined would silently drop the liability's value forever. If the + // required mark is missing, throw: the whole cutover rolls back, the ledger-ready flag stays unset, and the next + // cron run retries once the mark feed is available. Never a stale zero-opening. + if (amountChf == null) { + throw new Error( + `Cutover opening ${sourceId} without a mark would silently drop the value (CHF liability, never revalued by mark-to-market) — retry when the mark feed is available`, + ); + } + + await this.bookOpening( + 0, + sourceId, + description, + bookingDate, + { account: liability, amount: -amountChf, priceChf: 1, amountChf: -amountChf, needsMark: false }, + equity, + ); + } + + private async alreadyBooked(sourceId: string, seq: number): Promise { + return (await this.bookingService.nextSeq(SOURCE_TYPE, sourceId)) > seq; + } + + // foreign-fiat mark from the asset mark cache (priceChf of the fiat asset ≤ snapshot) + private fiatMark(assetId: number | undefined, date: Date, marks: LedgerMarkCache): number | undefined { + return assetId != null ? marks.getMarkAt(assetId, date) : undefined; + } + + private liability(qualifier: string): Promise { + return this.accountService.findOrCreate(`LIABILITY/${qualifier}`, AccountType.LIABILITY, CHF); + } + + private equityAccount(): Promise { + return this.accountService.findOrCreate('EQUITY/opening-balance', AccountType.EQUITY, CHF); + } +} diff --git a/src/subdomains/core/accounting/services/ledger-mark-to-market.service.ts b/src/subdomains/core/accounting/services/ledger-mark-to-market.service.ts new file mode 100644 index 0000000000..473b5febdc --- /dev/null +++ b/src/subdomains/core/accounting/services/ledger-mark-to-market.service.ts @@ -0,0 +1,205 @@ +import { Injectable } from '@nestjs/common'; +import { CronExpression } from '@nestjs/schedule'; +import { Config } from 'src/config/config'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { Process } from 'src/shared/services/process.service'; +import { DfxCron } from 'src/shared/utils/cron'; +import { Util } from 'src/shared/utils/util'; +import { In } from 'typeorm'; +import { AccountType, LedgerAccount } from '../entities/ledger-account.entity'; +import { LedgerAccountRepository } from '../repositories/ledger-account.repository'; +import { LedgerLegRepository } from '../repositories/ledger-leg.repository'; +import { LedgerAccountService } from './ledger-account.service'; +import { LedgerBookingJobService } from './ledger-booking-job.service'; +import { LedgerBookingService, LedgerLegInput } from './ledger-booking.service'; +import { LedgerMarkCache, LedgerMarkService } from './ledger-mark.service'; + +const SOURCE_TYPE = 'mark_to_market'; +const CHF = 'CHF'; + +interface AccountBalance { + accountId: number; + nativeBalance: number; + chfBalance: number; // current Σ amountChf (signed, null legs treated as 0) +} + +/** + * Daily mark-to-market (§5.3). Re-values open ASSET/LIABILITY accounts (+ accounts holding needsMark=true legs) + * to the current FinancialDataLog mark. Append-only: a revaluation-tx supplies the CHF re-valuation (the original + * needsMark leg is never mutated). Native is unchanged (amount=0 on the FX leg) — only the CHF basis moves; Σ CHF = 0. + * + * Runs off-peak at 04:00; the reconciliation job (§7) runs 1h later (05:00) so it compares against same-day + * revalued accounts (Minor R13-8). Paginated over the whole open-account universe in Config.ledger.backfillBatchSize + * windows by id-watermark (no full-scan AND no truncation, analog reconciliation §7.0, §5.3 Minor R1-2). + */ +@Injectable() +export class LedgerMarkToMarketService { + private readonly logger = new DfxLogger(LedgerMarkToMarketService); + + constructor( + private readonly jobService: LedgerBookingJobService, + private readonly bookingService: LedgerBookingService, + private readonly accountService: LedgerAccountService, + private readonly markService: LedgerMarkService, + private readonly ledgerAccountRepository: LedgerAccountRepository, + private readonly ledgerLegRepository: LedgerLegRepository, + ) {} + + @DfxCron(CronExpression.EVERY_DAY_AT_4AM, { process: Process.LEDGER_MARK_TO_MARKET }) + async run(): Promise { + if (!(await this.jobService.isLedgerReady())) return; // cutover-gate (Blocker R1-6) applies here too + + await this.markToMarket(); + } + + private async markToMarket(): Promise { + const now = new Date(); + + // §5.3 (Major, analog reconciliation §7.0): paginate the open ASSET/LIABILITY candidate universe by id-watermark + // — NOT a single truncated `.limit(batchSize)` (which would silently never re-mark accounts beyond the first + // batchSize once the asset universe grows past it → permanently stale CHF valuation + skewed equity parity §7.6). + const batchSize = Config.ledger.backfillBatchSize; + const firstPage = await this.selectCandidates(0, batchSize); + if (!firstPage.ids.length) return; // no open candidates → no-op (skip the mark preload + fx setup) + + const marks = await this.markService.preload(Util.daysBefore(2, now), now); + const dayIndex = this.dayIndex(now); + const fx = await this.fxAccounts(); + + let page = firstPage; + for (;;) { + for (const account of page.accounts) { + try { + await this.revalue(account, marks, now, dayIndex, fx); + } catch (e) { + this.logger.error(`Failed to mark-to-market ledger account ${account.id}:`, e); + // failure-isolation: one account failing must not abort the others (each tx is atomic) + } + } + + if (page.ids.length < batchSize) break; // last (partial) page → exhausted + page = await this.selectCandidates(page.maxId, batchSize); // next page by candidate-id watermark + if (!page.ids.length) break; + } + } + + /** + * §5.3 step 1: open ASSET/LIABILITY accounts (balance ≠ 0) PLUS accounts holding needsMark=true legs, one + * id-watermark page (accountId > lastId, ASC, limit batchSize) — the caller loops until exhausted (§7.0). + * + * The `assetId IS NOT NULL` filter is deliberate and load-bearing: ONLY asset-backed accounts carry a native + * (non-CHF) exposure that can drift against CHF and thus needs re-marking against the FinancialDataLog mark. The + * CHF-denominated LIABILITY buckets `LIABILITY/bankTx-return`/`-repeat`/`unattributed` (§3.4: `currency=CHF`, + * `assetId=NULL`) are opened by the BankTx consumer at `EUR-Mark × amount` (a fixed CHF value) and carry NO native + * FX exposure on the ledger account — their CHF balance is constant and cannot drift, so there is nothing for a + * re-mark to correct. The §4.2-Note phrase "the EUR↔CHF drift … is corrected once by the mark-to-market job + * (FX-Muster 1)" is therefore a no-op for these CHF-stable liabilities: any value mismatch surfaces only at the + * chargeback/settlement leg as a residual (plugged there via withFxPlug, §4.2-Note B-15), never as a wandering + * open-balance drift. Including them here (assetId=NULL) would re-mark against a missing asset → no mark → no-op + * anyway; the filter keeps the candidate set bounded to the accounts a re-mark can actually move. + */ + private async selectCandidates( + lastId: number, + batchSize: number, + ): Promise<{ ids: number[]; maxId: number; accounts: LedgerAccount[] }> { + const ids = await this.ledgerLegRepository + .createQueryBuilder('leg') + .innerJoin('leg.account', 'account') + .select('leg.accountId', 'accountId') + .where('account.type IN (:...types)', { types: [AccountType.ASSET, AccountType.LIABILITY] }) + .andWhere('account.assetId IS NOT NULL') // only asset-backed accounts carry a native exposure that can drift + .andWhere('leg.accountId > :lastId', { lastId }) // id-watermark: paginate the whole candidate universe (§7.0) + .groupBy('leg.accountId') + .having('ABS(SUM(leg.amount)) > :tol OR BOOL_OR(leg.needsMark) = true', { tol: 1e-8 }) + .orderBy('leg.accountId', 'ASC') + .limit(batchSize) + .getRawMany<{ accountId: number }>() + .then((rows) => rows.map((r) => r.accountId)); + + if (!ids.length) return { ids, maxId: lastId, accounts: [] }; + + // findBy returns no guaranteed order; sort by id ASC so the caller's id-watermark advances monotonically + const accounts = (await this.ledgerAccountRepository.findBy({ id: In(ids) })).sort((a, b) => a.id - b.id); + + return { ids, maxId: Math.max(...ids), accounts }; + } + + // one revaluation-tx per open account per day: ASSET/LIABILITY leg (amount=0, amountChf=diff) / fx-revaluation + private async revalue( + account: LedgerAccount, + marks: LedgerMarkCache, + bookingDate: Date, + dayIndex: number, + fx: { income: LedgerAccount; expense: LedgerAccount }, + ): Promise { + if (account.assetId == null) return; + + const mark = marks.getMarkAt(account.assetId, bookingDate); + if (mark == null) return; // still feedless → leave needsMark legs as-is, no phantom revaluation (§5.2 Minor R5-5) + + const balance = await this.accountBalance(account.id); + if (Math.abs(balance.nativeBalance) <= 1e-8) return; // closed → nothing to revalue + + const newChf = Util.round(mark * balance.nativeBalance, 2); + const diffChf = Util.round(newChf - balance.chfBalance, 2); + if (Math.abs(diffChf) < 0.01) return; // sub-cent → nothing to book + + if (await this.alreadyBooked(account.id, dayIndex)) return; // idempotent re-run on the same day + + const fxAccount = diffChf >= 0 ? fx.income : fx.expense; + const legs: LedgerLegInput[] = [ + { account, amount: 0, priceChf: mark, amountChf: diffChf, needsMark: false }, // CHF re-valuation only, native=0 + { account: fxAccount, amount: -diffChf, priceChf: 1, amountChf: -diffChf, needsMark: false }, + ]; + + await this.bookingService.bookTx({ + sourceType: SOURCE_TYPE, + sourceId: `${account.id}`, + seq: dayIndex, + bookingDate, + valueDate: bookingDate, + description: `Mark-to-market revaluation of ${account.name}`, + legs, + }); + } + + // current Σ amount (native) and Σ amountChf (null treated as 0) for the account + private async accountBalance(accountId: number): Promise { + const raw = await this.ledgerLegRepository + .createQueryBuilder('leg') + .select('SUM(leg.amount)', 'native') + .addSelect('SUM(COALESCE(leg.amountChf, 0))', 'chf') + .where('leg.accountId = :accountId', { accountId }) + .getRawOne<{ native: string | null; chf: string | null }>(); + + return { + accountId, + nativeBalance: Util.round(+(raw?.native ?? 0), 8), + chfBalance: Util.round(+(raw?.chf ?? 0), 2), + }; + } + + // a stable day discriminant for seq (one revaluation-tx per account per day); UTC day number since epoch + private dayIndex(date: Date): number { + return Math.floor(date.getTime() / (24 * 60 * 60 * 1000)); + } + + private async alreadyBooked(accountId: number, dayIndex: number): Promise { + const count = await this.ledgerLegRepository + .createQueryBuilder('leg') + .innerJoin('leg.tx', 'tx') + .where('tx.sourceType = :sourceType', { sourceType: SOURCE_TYPE }) + .andWhere('tx.sourceId = :sourceId', { sourceId: `${accountId}` }) + .andWhere('tx.seq = :seq', { seq: dayIndex }) + .getCount(); + + return count > 0; + } + + private async fxAccounts(): Promise<{ income: LedgerAccount; expense: LedgerAccount }> { + return { + income: await this.accountService.findOrCreate('INCOME/fx-revaluation', AccountType.INCOME, CHF), + expense: await this.accountService.findOrCreate('EXPENSE/fx-revaluation', AccountType.EXPENSE, CHF), + }; + } +} diff --git a/src/subdomains/core/accounting/services/ledger-mark.service.ts b/src/subdomains/core/accounting/services/ledger-mark.service.ts new file mode 100644 index 0000000000..f891a0f286 --- /dev/null +++ b/src/subdomains/core/accounting/services/ledger-mark.service.ts @@ -0,0 +1,167 @@ +import { Injectable } from '@nestjs/common'; +import { Config } from 'src/config/config'; +import { Util } from 'src/shared/utils/util'; +import { FinanceLog } from 'src/subdomains/supporting/log/dto/log.dto'; +import { Log } from 'src/subdomains/supporting/log/log.entity'; +import { LogService } from 'src/subdomains/supporting/log/log.service'; + +interface MarkPoint { + created: Date; + priceChf: number; +} + +// Major B5 bridge: bounded recent-log window scanned for the youngest available mark (latest ≤ now) per asset, and a +// short memoization TTL so a wedge-heavy batch (many rows missing a historical mark) does not re-query the feed per row. +const LATEST_MARK_LOOKBACK_DAYS = 5; +const LATEST_MARK_TTL_MS = 5 * 60 * 1000; + +/** + * Per-run mark cache (§5.2). Holds `Map` (each list sorted ascending by `created`) + * and resolves `getMarkAt(assetId, bookingDate)` = latest mark ≤ bookingDate via binary search. + * + * Two distinct "no mark" cases both return undefined (Caller sets needsMark=true, never priceChf=0): + * (1) no log row ≤ bookingDate; (2) a log row exists but its assets JSON lacks the assetId (§5.2 Minor R5-5). + */ +export class LedgerMarkCache { + constructor(private readonly marks: Map) {} + + // never feed a derived display priceChf into this comparison (§4.5 Minor R7-5) + getMarkAt(assetId: number, bookingDate: Date): number | undefined { + const points = this.marks.get(assetId); + if (!points?.length) return undefined; + + // binary search: latest point with created <= bookingDate + let lo = 0; + let hi = points.length - 1; + let result: MarkPoint | undefined; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (points[mid].created.getTime() <= bookingDate.getTime()) { + result = points[mid]; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + + return result?.priceChf; + } +} + +@Injectable() +export class LedgerMarkService { + constructor(private readonly logService: LogService) {} + + // memoized youngest-mark-per-asset map (≤ now) for the B5 bridge; refreshed at most once per LATEST_MARK_TTL_MS + private latestMarks?: { map: Map; loadedAt: number }; + + /** + * §5.2 Major B5 bridge — the youngest available mark for an asset (latest FinancialDataLog priceChf ≤ now), from a + * bounded recent-log read. Used ONLY as the documented fallback when the per-batch cache has no mark AT a historical + * bookingDate: the leg is booked with this provisional CHF value so a mixed tx balances, needsMark stays true, and + * the daily mark-to-market job corrects the basis to the real rate. Returns undefined ONLY when the asset has NO + * finite priceChf in any recent log (never fed / feedless) — the caller then DEFERS the row (skip without advancing + * the watermark) rather than book a wrong value: a feedless asset is a genuine data state, not a price-timing gap. + */ + async getLatestMark(assetId: number): Promise { + return (await this.getLatestMarks()).get(assetId); + } + + // bounded, memoized youngest-mark map (≤ now). Ascending by created → the last finite write per asset wins → youngest. + private async getLatestMarks(): Promise> { + const now = Date.now(); + if (this.latestMarks && now - this.latestMarks.loadedAt < LATEST_MARK_TTL_MS) return this.latestMarks.map; + + const asOf = new Date(now); + const rows = ( + await this.logService.getFinancialLogs(Util.daysBefore(LATEST_MARK_LOOKBACK_DAYS, asOf), true) + ).filter((r) => r.created.getTime() <= asOf.getTime()); + + const map = new Map(); + for (const row of rows) { + const assets = this.parseAssets(row.message); + if (!assets) continue; + for (const [assetIdKey, assetLog] of Object.entries(assets)) { + if (Number.isFinite(assetLog?.priceChf)) map.set(+assetIdKey, assetLog.priceChf); + } + } + + this.latestMarks = { map, loadedAt: now }; + return map; + } + + /** + * Bounded preload (§5.2, Hard Constraint #4): always limited by (batchStartDate, to) and maxRows. + * Order is fixed — dailySample decision FIRST (avoids loading the full minute-tick), THEN upper-bound + * trimming, THEN the maxRows pagination backstop. + */ + async preload(batchStartDate: Date, to: Date): Promise { + const spanDays = Util.daysDiff(batchStartDate, to); + const dailySample = spanDays > Config.ledger.markPreloadDailySampleThresholdDays; + + let rows = await this.logService.getFinancialLogs(batchStartDate, dailySample); + rows = rows.filter((r) => r.created.getTime() <= to.getTime()); + + if (rows.length > Config.ledger.markPreloadMaxRows) { + rows = await this.paginate(batchStartDate, to, dailySample); + } + + return new LedgerMarkCache(this.buildMarkMap(rows)); + } + + // created-continuation windows; never load everything into one heap (§5.2 step 3) + private async paginate(batchStartDate: Date, to: Date, dailySample: boolean): Promise { + const result: Log[] = []; + let windowStart = batchStartDate; + + while (windowStart.getTime() <= to.getTime()) { + const window = (await this.logService.getFinancialLogs(windowStart, dailySample)).filter( + (r) => r.created.getTime() <= to.getTime(), + ); + if (!window.length) break; + + result.push(...window); + const lastCreated = window[window.length - 1].created; + if (window.length < Config.ledger.markPreloadMaxRows || lastCreated.getTime() <= windowStart.getTime()) break; + + windowStart = new Date(lastCreated.getTime() + 1); + } + + return result; + } + + private buildMarkMap(rows: Log[]): Map { + const marks = new Map(); + + for (const row of rows) { + // tolerate parse/shape issues defensively — never throw, mirrors log-job getJsonValue + const assets = this.parseAssets(row.message); + if (!assets) continue; + + for (const [assetIdKey, assetLog] of Object.entries(assets)) { + const priceChf = assetLog?.priceChf; + if (!Number.isFinite(priceChf)) continue; + + const assetId = +assetIdKey; + const points = marks.get(assetId) ?? []; + points.push({ created: row.created, priceChf }); + marks.set(assetId, points); + } + } + + // rows arrive ascending by created (getFinancialLogs order); keep lists sorted for binary search + for (const points of marks.values()) { + points.sort((a, b) => a.created.getTime() - b.created.getTime()); + } + + return marks; + } + + private parseAssets(message: string): FinanceLog['assets'] | undefined { + try { + return (JSON.parse(message) as FinanceLog).assets; + } catch { + return undefined; + } + } +} diff --git a/src/subdomains/core/accounting/services/ledger-query.service.ts b/src/subdomains/core/accounting/services/ledger-query.service.ts new file mode 100644 index 0000000000..422381698f --- /dev/null +++ b/src/subdomains/core/accounting/services/ledger-query.service.ts @@ -0,0 +1,653 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { Config } from 'src/config/config'; +import { Util } from 'src/shared/utils/util'; +import { LiquidityBalance } from 'src/subdomains/core/liquidity-management/entities/liquidity-balance.entity'; +import { LiquidityManagementBalanceService } from 'src/subdomains/core/liquidity-management/services/liquidity-management-balance.service'; +import { FinanceLog } from 'src/subdomains/supporting/log/dto/log.dto'; +import { LogService } from 'src/subdomains/supporting/log/log.service'; +import { In } from 'typeorm'; +import { + EquityComparisonDto, + EquityComparisonPeriodDto, + EquityDecompositionDto, + MarginPeriodDto, + MarginResponseDto, +} from '../dto/ledger-margin.dto'; +import { + LedgerAccountBalanceDto, + LedgerAccountsResponseDto, + LedgerLegEntryDto, + LedgerLegsResponseDto, + LedgerReconStatus, +} from '../dto/ledger-account.dto'; +import { + AccountBalance, + AccountReconResult, + AccountReconSnapshot, + LedgerDtoMapper, + SuspenseLegRow, +} from '../dto/ledger-dto.mapper'; +import { + AccountReconResultDto, + LedgerFeedStaleness, + LedgerReconResultStatus, + ReconStatusResponseDto, + SuspenseResponseDto, +} from '../dto/ledger-reconciliation.dto'; +import { AccountType, LedgerAccount } from '../entities/ledger-account.entity'; +import { LedgerLeg } from '../entities/ledger-leg.entity'; +import { LedgerAccountRepository } from '../repositories/ledger-account.repository'; +import { LedgerLegRepository } from '../repositories/ledger-leg.repository'; +import { LedgerMarkCache, LedgerMarkService } from './ledger-mark.service'; +import { FeedStatus, LedgerReconciliationService } from './ledger-reconciliation.service'; + +const LEGS_PAGE_SIZE = 100; +const MARK_TO_MARKET_SOURCE = 'mark_to_market'; + +// §7.6 (perf): server-side cap on the equity-comparison range. `from` is mandatory (rejects a full-history scan) and +// the window [from, now] must stay within this many days so the endpoint's cost is bounded (~1 quarter of daily logs). +const MAX_EQUITY_COMPARISON_RANGE_DAYS = 92; + +// running cumulative of the four equity-comparison components at a given UTC day (YYYY-MM-DD), §7.6 +interface EquityDayAgg { + day: string; + equity: number; // Σ amountChf over the balance-account types + transit: number; // Σ amountChf on TRANSIT accounts (Class-2 phantom) + stale: number; // Σ amountChf of mark_to_market legs on currently-unverified accounts (Class-3) + spread: number; // Σ amountChf on EXPENSE accounts excl. refReward/extraordinary/fx-revaluation (Class-6) +} + +/** + * Read-only query layer for the ADMIN ledger endpoints (§8). Pure observer: it only reads from ledger_* plus the + * whitelisted feed read (LiquidityManagementBalanceService.getBalances, §7.0/§4.10), the persisted mark read + * (LedgerMarkService.preload, §5.2 — to value the native journal↔feed diff in CHF, §7) and the read-only LogService + * (FinancialDataLog time-series for the equity comparison). It reuses LedgerReconciliationService.classifyFeed for + * the staleness classification so the API view matches the daily reconciliation run. No pricing-service injection, + * no external calls, no writes. + */ +@Injectable() +export class LedgerQueryService { + constructor( + private readonly ledgerAccountRepository: LedgerAccountRepository, + private readonly ledgerLegRepository: LedgerLegRepository, + private readonly reconciliationService: LedgerReconciliationService, + private readonly liquidityManagementBalanceService: LiquidityManagementBalanceService, + private readonly markService: LedgerMarkService, + private readonly logService: LogService, + ) {} + + // --- GET ledger/accounts (balance list) --- // + + async getAccounts(from?: Date, to?: Date): Promise { + const now = new Date(); + const period = this.resolvePeriod(from, to, now); + + // ASSET-account recon snapshot for the list (against the persisted feed, §7) — feed read once for all accounts. + // §7 (unit fix): marks read ONCE per request (same 2-day window as the reconciliation job) to value the native + // journal↔feed diff in CHF before the CHF-tolerance check (see mapReconStatus) + const [accounts, balances, feedByAssetId, marks] = await Promise.all([ + this.ledgerAccountRepository.find({ relations: { asset: { bank: true } } }), + this.balancesByAccount(period.to), + this.feedByAssetId(), + this.markService.preload(Util.daysBefore(2, now), now), + ]); + + const accountDtos: LedgerAccountBalanceDto[] = accounts.map((account) => { + const balance: AccountBalance = { + account, + balanceNative: balances.get(account.id)?.native ?? 0, + balanceChf: balances.get(account.id)?.chf ?? 0, + }; + const recon = this.reconSnapshot(account, balance.balanceNative, feedByAssetId, marks, now); + return LedgerDtoMapper.mapAccountBalance(balance, recon); + }); + + return { period: LedgerDtoMapper.mapPeriod(period.from, period.to), accounts: accountDtos }; + } + + // --- GET ledger/accounts/:accountId/legs (T-account legs, paginated §8) --- // + + async getAccountDetail(accountId: number, from?: Date, to?: Date, page = 0): Promise { + const now = new Date(); + const period = this.resolvePeriod(from, to, now); + + const account = await this.ledgerAccountRepository.findOneBy({ id: accountId }); + if (!account) throw new NotFoundException('Ledger account not found'); + + // opening balance = signed native Σ of all legs booked strictly before the period start + const [openingBalance, periodNative] = await Promise.all([ + this.nativeBalanceBefore(accountId, period.from), + this.nativeBalanceInPeriod(accountId, period.from, period.to), + ]); + const closingBalance = Util.round(openingBalance + periodNative, 8); + + const [legs, total] = await this.ledgerLegRepository + .createQueryBuilder('leg') + .innerJoinAndSelect('leg.tx', 'tx') + .where('leg.accountId = :accountId', { accountId }) + .andWhere('tx.bookingDate >= :from', { from: period.from }) + .andWhere('tx.bookingDate <= :to', { to: period.to }) + .orderBy('tx.bookingDate', 'DESC') + .addOrderBy('leg.id', 'DESC') + .skip(page * LEGS_PAGE_SIZE) + .take(LEGS_PAGE_SIZE) + .getManyAndCount(); + + const legDtos = await this.mapLegsWithCounterAccount(legs, accountId); + + return { + accountId: account.id, + accountName: account.name, + currency: account.currency, + period: LedgerDtoMapper.mapPeriod(period.from, period.to), + openingBalance, + closingBalance, + legs: legDtos, + total, + }; + } + + // --- GET ledger/reconciliation (recon status) --- // + + async getReconStatus(): Promise { + const now = new Date(); + + // §7 (unit fix): marks read ONCE per request (same 2-day window as the reconciliation job) — see mapReconStatus + const [accounts, feedByAssetId, balances, marks] = await Promise.all([ + this.ledgerAccountRepository.find({ + where: { type: AccountType.ASSET, active: true }, + relations: { asset: { bank: true } }, + }), + this.feedByAssetId(), + this.nativeBalanceByAccount(), + this.markService.preload(Util.daysBefore(2, now), now), + ]); + + const results: AccountReconResultDto[] = []; + for (const account of accounts) { + if (account.assetId == null) continue; + + const ledgerBalance = balances.get(account.id) ?? 0; + const result = this.reconResult(account, ledgerBalance, feedByAssetId, marks, now); + results.push(LedgerDtoMapper.mapReconResult(result)); + } + + return { runAt: now.toISOString(), accounts: results }; + } + + // --- GET ledger/suspense --- // + + async getSuspense(): Promise { + const now = new Date(); + + const legs = await this.ledgerLegRepository + .createQueryBuilder('leg') + .innerJoinAndSelect('leg.tx', 'tx') + .innerJoinAndSelect('leg.account', 'account') + .where('account.type = :type', { type: AccountType.SUSPENSE }) + .orderBy('tx.bookingDate', 'ASC') + .getMany(); + + const rows: SuspenseLegRow[] = legs.map((leg) => ({ + leg, + bookingDate: leg.tx.bookingDate, + age: Util.daysDiff(leg.tx.bookingDate, now), + })); + + const totalChf = Util.round(Util.sum(legs.map((l) => l.amountChf ?? 0)), 2); + + return { totalChf, legs: rows.map((row) => LedgerDtoMapper.mapSuspenseLeg(row)) }; + } + + // --- GET ledger/margin (realized-margin report §1.11/§7.6) --- // + + async getMargin(from?: Date, to?: Date, dailySample = true): Promise { + const now = new Date(); + const period = this.resolvePeriod(from, to, now); + + const buckets = await this.marginBuckets(period.from, period.to, dailySample); + + const periods: MarginPeriodDto[] = buckets.map((b) => ({ + date: b.date, + feeIncome: b.feeIncome, + executionCosts: b.executionCosts, + otherOpex: b.otherOpex, + realizedMargin: Util.round(b.feeIncome - b.executionCosts, 2), + fxPnl: b.fxPnl, + })); + + return { + periods, + totalFeeIncome: Util.round(Util.sumObjValue(periods, 'feeIncome'), 2), + totalExecutionCosts: Util.round(Util.sumObjValue(periods, 'executionCosts'), 2), + totalOtherOpex: Util.round(Util.sumObjValue(periods, 'otherOpex'), 2), + totalRealizedMargin: Util.round(Util.sumObjValue(periods, 'realizedMargin'), 2), + }; + } + + // --- GET ledger/equity-comparison (§7.6) --- // + + async getEquityComparison(from?: Date, dailySample = true): Promise { + const now = new Date(); + + // §7.6 (perf a): `from` is mandatory and the range is capped — the old code scanned the ENTIRE ledger history per + // log period when `from` was omitted. Fail loud (BadRequest) rather than silently run an unbounded full scan. + if (from == null) throw new BadRequestException('from is required'); + if (Util.daysDiff(from, now) > MAX_EQUITY_COMPARISON_RANGE_DAYS) + throw new BadRequestException(`Range from-now must not exceed ${MAX_EQUITY_COMPARISON_RANGE_DAYS} days`); + + const logs = await this.logService.getFinancialLogs(from, dailySample); + if (!logs.length) return { periods: [] }; + + // §7.6 (perf b): the unverified-account set (incl. the feed read) is period-independent → compute it ONCE for the + // whole comparison instead of re-reading the entire feed inside every log iteration (was N feed reads). + const unverifiedAccountIds = await this.unverifiedAccountIds(); + + // §7.6 (perf c): all four per-period aggregates come from ONE day-grouped ledger_leg pass, turned into running + // cumulative totals in memory — instead of four cumulative SUM queries per log period (was O(periods) full scans). + const lastPeriod = logs[logs.length - 1].created; + const cumulative = await this.cumulativeEquityByDay(lastPeriod, unverifiedAccountIds); + + const periods: EquityComparisonPeriodDto[] = []; + for (const log of logs) { + const finance = this.parseFinance(log.message); + const financialDataLogTotal = finance?.balancesTotal?.totalBalanceChf; + if (financialDataLogTotal == null) continue; + + const at = this.cumulativeAt(cumulative, log.created); + const journalEquity = Util.round(at.equity, 2); + const difference = Util.round(journalEquity - financialDataLogTotal, 2); + const decomposition: EquityDecompositionDto = { + transitPhantom: Util.round(at.transit, 2), + staleFeed: Util.round(at.stale, 2), + spreadFees: Util.round(at.spread, 2), + other: Util.round(difference - (at.transit + at.stale + at.spread), 2), + }; + + periods.push({ + date: log.created.toISOString(), + journalEquity, + financialDataLogTotal, + difference, + decomposition, + }); + } + + return { periods }; + } + + // --- BALANCE AGGREGATION HELPERS --- // + + // signed native + chf balance per account up to `to` (closing balance over all legs booked ≤ to) + private async balancesByAccount(to: Date): Promise> { + const raw = await this.ledgerLegRepository + .createQueryBuilder('leg') + .innerJoin('leg.tx', 'tx') + .select('leg.accountId', 'accountId') + .addSelect('SUM(leg.amount)', 'native') + .addSelect('SUM(COALESCE(leg.amountChf, 0))', 'chf') + .where('tx.bookingDate <= :to', { to }) + .groupBy('leg.accountId') + .getRawMany<{ accountId: number; native: string; chf: string }>(); + + return new Map(raw.map((r) => [+r.accountId, { native: Util.round(+r.native, 8), chf: Util.round(+r.chf, 2) }])); + } + + private async nativeBalanceBefore(accountId: number, from: Date): Promise { + const raw = await this.ledgerLegRepository + .createQueryBuilder('leg') + .innerJoin('leg.tx', 'tx') + .select('SUM(leg.amount)', 'native') + .where('leg.accountId = :accountId', { accountId }) + .andWhere('tx.bookingDate < :from', { from }) + .getRawOne<{ native: string | null }>(); + + return Util.round(+(raw?.native ?? 0), 8); + } + + private async nativeBalanceInPeriod(accountId: number, from: Date, to: Date): Promise { + const raw = await this.ledgerLegRepository + .createQueryBuilder('leg') + .innerJoin('leg.tx', 'tx') + .select('SUM(leg.amount)', 'native') + .where('leg.accountId = :accountId', { accountId }) + .andWhere('tx.bookingDate >= :from', { from }) + .andWhere('tx.bookingDate <= :to', { to }) + .getRawOne<{ native: string | null }>(); + + return Util.round(+(raw?.native ?? 0), 8); + } + + // all native journal balances in ONE GROUP-BY pass (Σ leg.amount per account, all-time) for getReconStatus. + // alias.property refs (leg.accountId, SUM(leg.amount)) are auto-quoted by TypeORM — no bare camelCase column. + // Unlike balancesByAccount this joins no tx and applies no bookingDate filter → the all-time journal balance. + private async nativeBalanceByAccount(): Promise> { + const raw = await this.ledgerLegRepository + .createQueryBuilder('leg') + .select('leg.accountId', 'accountId') + .addSelect('SUM(leg.amount)', 'native') + .groupBy('leg.accountId') + .getRawMany<{ accountId: number; native: string }>(); + + return new Map(raw.map((r) => [+r.accountId, Util.round(+r.native, 8)])); + } + + // for each leg, the counter account = the other account when the tx is a clean 2-leg booking + private async mapLegsWithCounterAccount(legs: LedgerLeg[], accountId: number): Promise { + const txIds = Array.from(new Set(legs.map((l) => l.txId))); + const counterByTxId = await this.counterAccountByTxId(txIds, accountId); + + return legs.map((leg) => + LedgerDtoMapper.mapLegEntry(leg, leg.tx.bookingDate, leg.tx.valueDate, counterByTxId.get(leg.txId)), + ); + } + + // counter account: the single other account of a 2-leg tx (undefined for ≥3-leg txs — no single counter party) + private async counterAccountByTxId(txIds: number[], accountId: number): Promise> { + if (!txIds.length) return new Map(); + + const legs = await this.ledgerLegRepository.find({ + where: { tx: { id: In(txIds) } }, + relations: { account: true, tx: true }, + }); + + const byTx = Util.groupBy(legs, 'txId'); + const result = new Map(); + for (const [txId, txLegs] of byTx.entries()) { + const counterparts = txLegs.filter((l) => l.accountId !== accountId); + if (counterparts.length === 1) result.set(txId, counterparts[0].account); + } + + return result; + } + + // --- RECONCILIATION HELPERS (reuse LedgerReconciliationService.classifyFeed) --- // + + private async feedByAssetId(): Promise> { + const feed = await this.liquidityManagementBalanceService.getBalances(); + return new Map(feed.filter((b) => b.asset?.id != null).map((b) => [b.asset.id, b])); + } + + // ASSET-account recon snapshot for the balance list (non-ASSET accounts carry no feed → no snapshot) + private reconSnapshot( + account: LedgerAccount, + ledgerBalance: number, + feedByAssetId: Map, + marks: LedgerMarkCache, + now: Date, + ): AccountReconSnapshot | undefined { + if (account.type !== AccountType.ASSET || account.assetId == null) return undefined; + + const result = this.reconResult(account, ledgerBalance, feedByAssetId, marks, now); + return { + reconStatus: this.toReconStatus(result.status), + reconDiff: result.difference, + lastVerified: result.staleness === LedgerFeedStaleness.FRESH ? result.feedTimestamp : undefined, + }; + } + + // the list-view reconStatus (LedgerReconStatus) derived from the recon result status (LedgerReconResultStatus). + // These are DISTINCT enums: the list has no SUSPENSE_ALARM member, so a suspense alarm surfaces as `diff` here. + private toReconStatus(status: LedgerReconResultStatus): LedgerReconStatus { + switch (status) { + case LedgerReconResultStatus.SUSPENSE_ALARM: + return LedgerReconStatus.DIFF; // suspense alarm surfaces as diff in the list + case LedgerReconResultStatus.DIFF: + return LedgerReconStatus.DIFF; + case LedgerReconResultStatus.STALE: + return LedgerReconStatus.STALE; + case LedgerReconResultStatus.UNVERIFIED: + return LedgerReconStatus.UNVERIFIED; + case LedgerReconResultStatus.OK: + return LedgerReconStatus.OK; + } + } + + private reconResult( + account: LedgerAccount, + ledgerBalance: number, + feedByAssetId: Map, + marks: LedgerMarkCache, + now: Date, + ): AccountReconResult { + const balance = account.assetId != null ? feedByAssetId.get(account.assetId) : undefined; + const classification = this.reconciliationService.classifyFeed(balance, account, now); + + const externalFeedBalance = balance?.amount ?? 0; + const difference = Util.round(ledgerBalance - externalFeedBalance, 8); + const feedTimestamp = balance?.updated; + const feedAge = feedTimestamp ? Util.hoursDiff(feedTimestamp, now) : undefined; + + // §7 unit fix: the tolerance check is in CHF, so value the native diff via the account's mark (see mapReconStatus) + const mark = account.assetId != null ? marks.getMarkAt(account.assetId, now) : undefined; + + const staleness = this.mapStaleness(classification.status); + const status = this.mapReconStatus(classification.status, difference, mark); + + return { account, ledgerBalance, externalFeedBalance, difference, feedTimestamp, feedAge, staleness, status }; + } + + private mapStaleness(status: FeedStatus): LedgerFeedStaleness { + switch (status) { + case FeedStatus.FRESH: + return LedgerFeedStaleness.FRESH; + case FeedStatus.STALE: + return LedgerFeedStaleness.STALE; + case FeedStatus.PLACEHOLDER: + return LedgerFeedStaleness.PLACEHOLDER; + case FeedStatus.NO_FEED: + return LedgerFeedStaleness.MISSING; + } + } + + private mapReconStatus(status: FeedStatus, difference: number, mark: number | undefined): LedgerReconResultStatus { + if (status === FeedStatus.STALE) return LedgerReconResultStatus.STALE; + if (status !== FeedStatus.FRESH) return LedgerReconResultStatus.UNVERIFIED; // placeholder / no-feed (§7.2) + + // §7 unit fix: value the native journal↔feed diff in CHF (× mark) before the CHF-tolerance check; no mark → + // unverified (same as the job), NEVER silently ok (that would mask a real discrepancy). + if (mark == null) return LedgerReconResultStatus.UNVERIFIED; + const diffChf = Util.round(difference * mark, 2); + return Math.abs(diffChf) <= Config.ledger.reconciliationToleranceChf + ? LedgerReconResultStatus.OK + : LedgerReconResultStatus.DIFF; + } + + // --- MARGIN HELPERS (§1.11/§7.6) --- // + + private async marginBuckets( + from: Date, + to: Date, + dailySample: boolean, + ): Promise<{ date: string; feeIncome: number; executionCosts: number; otherOpex: number; fxPnl: number }[]> { + // spread-* glob ALWAYS combined with the account TYPE (Minor R12-4): INCOME→feeIncome, EXPENSE→executionCosts. + const bucketExpr = dailySample ? 'CAST(tx.bookingDate AS DATE)' : `'all'`; + const rows = await this.ledgerLegRepository + .createQueryBuilder('leg') + .innerJoin('leg.tx', 'tx') + .innerJoin('leg.account', 'account') + .select(bucketExpr, 'bucket') + .addSelect('account.type', 'type') + .addSelect('account.name', 'name') + .addSelect('SUM(COALESCE(leg.amountChf, 0))', 'chf') + .where('tx.bookingDate >= :from', { from }) + .andWhere('tx.bookingDate <= :to', { to }) + .andWhere('account.type IN (:...types)', { types: [AccountType.INCOME, AccountType.EXPENSE] }) + .groupBy(bucketExpr) + .addGroupBy('account.type') + .addGroupBy('account.name') + .getRawMany<{ bucket: string; type: AccountType; name: string; chf: string }>(); + + const byBucket = new Map(); + for (const row of rows) { + const bucket = this.bucketKey(row.bucket); + const acc = byBucket.get(bucket) ?? { feeIncome: 0, executionCosts: 0, otherOpex: 0, fxPnl: 0 }; + + // signed Σ amountChf per account: INCOME accounts carry Cr (negative) balances, EXPENSE Dr (positive). + // The report exposes positive magnitudes, so income contributes −chf and expense +chf. + const chf = +row.chf; + if (this.isFxRevaluation(row.name)) { + acc.fxPnl += -chf; // */fx-revaluation: net INCOME(−) − EXPENSE(+) → positive = net FX gain + } else if (row.type === AccountType.INCOME) { + acc.feeIncome += -chf; // INCOME/fee-*, INCOME/trading, INCOME/spread-* (type-filtered, Minor R12-4) + } else if (this.isOtherOpex(row.name)) { + acc.otherOpex += chf; // EXPENSE/refReward + EXPENSE/extraordinary (Major R7-2) + } else { + acc.executionCosts += chf; // EXPENSE/spread-*, network-fee, bank-fee, acquirer-fee (type-filtered) + } + + byBucket.set(bucket, acc); + } + + return Array.from(byBucket.entries()) + .map(([date, v]) => ({ + date, + feeIncome: Util.round(v.feeIncome, 2), + executionCosts: Util.round(v.executionCosts, 2), + otherOpex: Util.round(v.otherOpex, 2), + fxPnl: Util.round(v.fxPnl, 2), + })) + .sort((a, b) => a.date.localeCompare(b.date)); + } + + private isFxRevaluation(name: string): boolean { + return name.endsWith('/fx-revaluation'); + } + + private isOtherOpex(name: string): boolean { + return name === 'EXPENSE/refReward' || name === 'EXPENSE/extraordinary'; + } + + // --- EQUITY-COMPARISON HELPERS (§7.6) --- // + + // §7.6 (perf c): ONE day-grouped pass over ledger_leg → the four equity components per day (bookingDate ≤ `to`), + // accumulated into a running cumulative in memory. Same four components as the former per-period helpers + // (journalEquity over the balance-account types; transitPhantom = TRANSIT; staleFeed = mark_to_market legs on the + // currently-unverified accounts; spreadFees = EXPENSE minus refReward/extraordinary/fx-revaluation). PG-quoting: + // alias.property refs (`tx.bookingDate`, `account.type`, `leg.amountChf`) are auto-quoted by TypeORM — no camelCase + // column is referenced bare in the raw CASE expressions. + private async cumulativeEquityByDay(to: Date, unverifiedAccountIds: number[]): Promise { + const equityTypes = [ + AccountType.ASSET, + AccountType.TRANSIT, + AccountType.LIABILITY, + AccountType.SUSPENSE, + AccountType.ROUNDING, + ]; + const excludedSpreadNames = ['EXPENSE/refReward', 'EXPENSE/extraordinary', 'EXPENSE/fx-revaluation']; + + const qb = this.ledgerLegRepository + .createQueryBuilder('leg') + .innerJoin('leg.tx', 'tx') + .innerJoin('leg.account', 'account') + .select('CAST(tx.bookingDate AS DATE)', 'day') + .addSelect( + 'SUM(CASE WHEN account.type IN (:...equityTypes) THEN COALESCE(leg.amountChf, 0) ELSE 0 END)', + 'equity', + ) + .addSelect('SUM(CASE WHEN account.type = :transitType THEN COALESCE(leg.amountChf, 0) ELSE 0 END)', 'transit') + .addSelect( + 'SUM(CASE WHEN account.type = :expenseType AND account.name NOT IN (:...excludedSpreadNames) THEN COALESCE(leg.amountChf, 0) ELSE 0 END)', + 'spread', + ); + + const params: Record = { + to, + equityTypes, + transitType: AccountType.TRANSIT, + expenseType: AccountType.EXPENSE, + excludedSpreadNames, + scanTypes: [...equityTypes, AccountType.EXPENSE], // bound the scan to types that feed any component + }; + + // Class-3 stale-feed only exists when at least one account is unverified (SQL `IN ()` is invalid → literal 0) + if (unverifiedAccountIds.length) { + qb.addSelect( + 'SUM(CASE WHEN tx.sourceType = :markSource AND leg.accountId IN (:...unverifiedAccountIds) THEN COALESCE(leg.amountChf, 0) ELSE 0 END)', + 'stale', + ); + params.markSource = MARK_TO_MARKET_SOURCE; + params.unverifiedAccountIds = unverifiedAccountIds; + } else { + qb.addSelect('0', 'stale'); + } + + const rows = await qb + .where('tx.bookingDate <= :to') + .andWhere('account.type IN (:...scanTypes)') + .groupBy('CAST(tx.bookingDate AS DATE)') + .setParameters(params) + .getRawMany<{ day: string; equity: string; transit: string; stale: string; spread: string }>(); + + // ascending by day, running cumulative — each period reads the cumulative as of its own day (cumulativeAt) + let equity = 0; + let transit = 0; + let stale = 0; + let spread = 0; + + return rows + .map((r) => ({ + day: this.bucketKey(r.day), + equity: +r.equity, + transit: +r.transit, + stale: +r.stale, + spread: +r.spread, + })) + .sort((a, b) => a.day.localeCompare(b.day)) + .map((r) => { + equity += r.equity; + transit += r.transit; + stale += r.stale; + spread += r.spread; + return { day: r.day, equity, transit, stale, spread }; + }); + } + + // cumulative component totals as of `at` = the latest day-bucket with day ≤ at's UTC day (all-0 if none precedes it) + private cumulativeAt(cumulative: EquityDayAgg[], at: Date): EquityDayAgg { + const dayKey = at.toISOString().slice(0, 10); + let result: EquityDayAgg = { day: dayKey, equity: 0, transit: 0, stale: 0, spread: 0 }; + for (const c of cumulative) { + if (c.day <= dayKey) result = c; + else break; + } + return result; + } + + private async unverifiedAccountIds(): Promise { + const now = new Date(); + const accounts = await this.ledgerAccountRepository.find({ + where: { type: AccountType.ASSET, active: true }, + relations: { asset: { bank: true } }, + }); + const feedByAssetId = await this.feedByAssetId(); + + return accounts + .filter((account) => { + if (account.assetId == null) return false; + const status = this.reconciliationService.classifyFeed(feedByAssetId.get(account.assetId), account, now).status; + return status !== FeedStatus.FRESH && status !== FeedStatus.PLACEHOLDER; + }) + .map((account) => account.id); + } + + // --- SHARED HELPERS --- // + + private resolvePeriod(from: Date | undefined, to: Date | undefined, now: Date): { from: Date; to: Date } { + return { from: from ?? new Date(0), to: to ?? now }; + } + + private bucketKey(bucket: string): string { + // CAST(... AS DATE) returns a Date/string per driver; normalise to a YYYY-MM-DD day key + if (bucket === 'all') return 'all'; + return new Date(bucket).toISOString().slice(0, 10); + } + + private parseFinance(message: string): FinanceLog | undefined { + try { + return JSON.parse(message) as FinanceLog; + } catch { + return undefined; + } + } +} diff --git a/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts b/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts new file mode 100644 index 0000000000..2fff5327bf --- /dev/null +++ b/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts @@ -0,0 +1,491 @@ +import { Injectable } from '@nestjs/common'; +import { CronExpression } from '@nestjs/schedule'; +import { Config } from 'src/config/config'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { Process } from 'src/shared/services/process.service'; +import { DfxCron } from 'src/shared/utils/cron'; +import { Util } from 'src/shared/utils/util'; +import { LiquidityBalance } from 'src/subdomains/core/liquidity-management/entities/liquidity-balance.entity'; +import { LiquidityManagementBalanceService } from 'src/subdomains/core/liquidity-management/services/liquidity-management-balance.service'; +import { RefRewardService } from 'src/subdomains/core/referral/reward/services/ref-reward.service'; +import { FinanceLog } from 'src/subdomains/supporting/log/dto/log.dto'; +import { LogService } from 'src/subdomains/supporting/log/log.service'; +import { MailContext, MailType } from 'src/subdomains/supporting/notification/enums'; +import { MailRequest } from 'src/subdomains/supporting/notification/interfaces'; +import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; +import { MoreThan } from 'typeorm'; +import { AccountType, LedgerAccount } from '../entities/ledger-account.entity'; +import { LedgerAccountRepository } from '../repositories/ledger-account.repository'; +import { LedgerLegRepository } from '../repositories/ledger-leg.repository'; +import { LedgerBookingJobService } from './ledger-booking-job.service'; +import { LedgerMarkCache, LedgerMarkService } from './ledger-mark.service'; + +const PLACEHOLDER_AMOUNT = 1.0; // Scrypt/EUR, Base/ZCHF placeholder feed → never reconcile (§7.1) + +// §7.6: median window for the equity-parity baseline. The FinancialDataLog carries transient ±snapshot-skew spikes +// (see BalancesTotal, case 4); comparing against the median of the last few VALID snapshots absorbs a single spike. +const EQUITY_PARITY_MEDIAN_SAMPLE = 5; + +export enum FeedStatus { + PLACEHOLDER = 'Placeholder', + FRESH = 'Fresh', + STALE = 'Stale', + NO_FEED = 'NoFeed', +} + +// §7.1 objective custody criteria — mirrors the (unexported) EXCHANGE_BLOCKCHAINS/BANK_BLOCKCHAINS lists in +// src/subdomains/supporting/dashboard/dashboard-reconciliation.service.ts (blockchain.enum.ts groups); keep in sync. +const EXCHANGE_BLOCKCHAINS: Blockchain[] = [Blockchain.KRAKEN, Blockchain.BINANCE, Blockchain.XT, Blockchain.MEXC]; +const BANK_BLOCKCHAINS: Blockchain[] = [ + Blockchain.MAERKI_BAUMANN, + Blockchain.OLKYPAY, + Blockchain.OLKY_FROZEN, + Blockchain.CHECKOUT, + Blockchain.SUMIXX, + Blockchain.YAPEAL, +]; + +// §7.1 custody classification → staleness threshold (hours) +export enum CustodyClass { + BANK_ACTIVE = 'BankActive', + BANK_DEAD = 'BankDead', + ON_CHAIN_ACTIVE = 'OnChainActive', + ON_CHAIN_INACTIVE = 'OnChainInactive', + EXCHANGE_ACTIVE = 'ExchangeActive', +} + +const STALENESS_THRESHOLD_HOURS: Record = { + [CustodyClass.BANK_ACTIVE]: 96, // SEPA banks + [CustodyClass.BANK_DEAD]: 7 * 24, // 7d once, then unverified + [CustodyClass.ON_CHAIN_ACTIVE]: 4, + [CustodyClass.ON_CHAIN_INACTIVE]: 24, + [CustodyClass.EXCHANGE_ACTIVE]: 4, +}; + +export interface FeedClassification { + status: FeedStatus; + custodyClass: CustodyClass; + thresholdHours: number; +} + +/** + * Daily reconciliation (§7). Compares the journal balance (Σ ledger_leg.amount per ASSET account) against the + * persisted feed (liquidity_balance.amount via getBalances — NEVER a fresh API call, §7.0). Pure observer: the + * only non-ledger_* write is the sanctioned notification-write via NotificationService.sendMail (§7.5/Major R12-1). + * + * Runs off-peak at 05:00 — 1h AFTER the mark-to-market job (§5.3, 04:00) so it compares against same-day + * revalued accounts (Minor R13-8). Staleness drives unverified status + suppressed alarms (§7.2/§7.3); transit-age + * (§7.4), suspense (§7.5) and equity-parity (§7.6) alarms follow. + */ +@Injectable() +export class LedgerReconciliationService { + private readonly logger = new DfxLogger(LedgerReconciliationService); + + constructor( + private readonly jobService: LedgerBookingJobService, + private readonly settingService: SettingService, + private readonly logService: LogService, + private readonly notificationService: NotificationService, + private readonly liquidityManagementBalanceService: LiquidityManagementBalanceService, + private readonly ledgerAccountRepository: LedgerAccountRepository, + private readonly ledgerLegRepository: LedgerLegRepository, + private readonly markService: LedgerMarkService, + private readonly refRewardService: RefRewardService, + ) {} + + @DfxCron(CronExpression.EVERY_DAY_AT_5AM, { process: Process.LEDGER_RECONCILIATION }) + async run(): Promise { + if (!(await this.jobService.isLedgerReady())) return; // cutover-gate (Blocker R1-6) + + await this.reconcile(); + } + + private async reconcile(): Promise { + const now = new Date(); + + // §7.0: feed read ONCE per run, held in-memory for all batches (never per-batch, Minor R13-2) + // §7 (unit fix): marks loaded ONCE per run (analog mark-to-market §5.2, same 2-day window). Used to value the + // native journal↔feed diff in CHF before comparing against the CHF tolerance (see reconcileFreshAsset). + // §7.0: all native journal balances in ONE GROUP-BY pass (analog the feed read) — avoids a per-account query + // inside the batched reconcileFreshAsset loop (was O(accounts) SUM queries). + const [feed, marks, balances] = await Promise.all([ + this.liquidityManagementBalanceService.getBalances(), + this.markService.preload(Util.daysBefore(2, now), now), + this.nativeBalanceByAccount(), + ]); + + await this.reconcileAssets(feed, marks, balances, now); + await this.checkTransitAge(now); + await this.checkSuspense(); + await this.checkEquityParity(now); + } + + // --- ASSET RECONCILIATION (§7.1/§7.2/§7.3) --- // + + private async reconcileAssets( + feed: LiquidityBalance[], + marks: LedgerMarkCache, + balances: Map, + now: Date, + ): Promise { + const feedByAssetId = new Map(feed.filter((b) => b.asset?.id != null).map((b) => [b.asset.id, b])); + + const unverified: string[] = []; + + // §7.0 (Minor R13-2): paginate the ASSET-account universe in backfillBatchSize windows by id-watermark — the + // feed (loaded once in reconcile()) stays in-memory for ALL batches. "batch-limited" means a batched ITERATION + // over EVERY account, NOT a truncation to the first batchSize accounts (which would silently never reconcile + // accounts 101+ once the asset universe grows past the batch size — a monitoring blind spot, MAJOR). + const batchSize = Config.ledger.backfillBatchSize; + let lastId = 0; + for (;;) { + const assetAccounts = await this.ledgerAccountRepository.find({ + where: { type: AccountType.ASSET, active: true, id: MoreThan(lastId) }, + relations: { asset: { bank: true } }, + order: { id: 'ASC' }, + take: batchSize, + }); + if (!assetAccounts.length) break; + + for (const account of assetAccounts) { + if (account.assetId == null) continue; + + const balance = feedByAssetId.get(account.assetId); + const classification = this.classifyFeed(balance, account, now); + + // placeholder (amount=1.0): skip reconciliation, log warning, no diff alarm (§7.1) + if (classification.status === FeedStatus.PLACEHOLDER) { + this.logger.verbose(`Skipping reconciliation for ${account.name}: placeholder feed (amount=1.0)`); + continue; + } + + if (classification.status !== FeedStatus.FRESH) { + unverified.push(`${account.name} (${classification.status}, ${classification.custodyClass})`); + continue; // unverified → no per-asset diff alarm, aggregated below (§7.2/§7.3) + } + + // §7 (unit fix): the native journal↔feed diff MUST be valued in CHF (× the current mark) before it is + // compared against the CHF-denominated tolerance — a native tolerance is ~52'000× too loose for BTC and far + // too tight for meme-coins. No mark available → treat the account as unverified (same as the staleness path), + // NEVER silently value the diff at 0 (that would mask a real discrepancy). + const mark = marks.getMarkAt(account.assetId, now); + if (mark == null) { + unverified.push(`${account.name} (no-mark, ${classification.custodyClass})`); + continue; + } + + await this.reconcileFreshAsset(account, balance, mark, balances, now); + } + + lastId = assetAccounts[assetAccounts.length - 1].id; + if (assetAccounts.length < batchSize) break; // last (partial) page → exhausted + } + + // §7.3: one aggregated "Unverified Accounts" alarm per day (no per-asset spam) + if (unverified.length) { + await this.sendAlarm( + MailContext.LEDGER_RECONCILIATION, + 'Ledger unverified accounts', + [`${unverified.length} account(s) without a fresh feed:`, ...unverified], + `ledger-unverified-${this.dayKey(now)}`, + ); + } + } + + // §7.1 staleness classification incl. placeholder rule + classifyFeed(balance: LiquidityBalance | undefined, account: LedgerAccount, now: Date): FeedClassification { + const custodyClass = this.classifyCustody(account.asset); + const thresholdHours = STALENESS_THRESHOLD_HOURS[custodyClass]; + + if (!balance || balance.amount == null) { + return { status: FeedStatus.NO_FEED, custodyClass, thresholdHours }; + } + if (balance.amount === PLACEHOLDER_AMOUNT) { + return { status: FeedStatus.PLACEHOLDER, custodyClass, thresholdHours }; + } + + const ageHours = Util.hoursDiff(balance.updated, now); + return { + status: ageHours > thresholdHours ? FeedStatus.STALE : FeedStatus.FRESH, + custodyClass, + thresholdHours, + }; + } + + // §7.1 custody-type → class, on objective criteria only: OLKY_FROZEN is the frozen/dead-bank marker (dead-bank + // feeds never refresh → 7d once, then permanently unverified); a `bank` relation or a bank blockchain → SEPA/bank + // threshold; the four exchange blockchains → exchange threshold; anything else (incl. payment-provider blockchains) + // is on-chain — blockchain is non-nullable (default BITCOIN), so only a missing asset yields ON_CHAIN_INACTIVE. + // EXCHANGE_ORDER_DRIVEN and EXCHANGE_FEEDLESS were removed: no code-level criterion distinguishes an order-driven + // exchange feed (all configured exchanges refresh every minute, ungated) and a feedless exchange asset is exactly + // the generic NO_FEED path. + private classifyCustody(asset: Asset | undefined): CustodyClass { + if (!asset) return CustodyClass.ON_CHAIN_INACTIVE; + if (asset.blockchain === Blockchain.OLKY_FROZEN) return CustodyClass.BANK_DEAD; // dead wins over a stale bank link + if (asset.bank || BANK_BLOCKCHAINS.includes(asset.blockchain)) return CustodyClass.BANK_ACTIVE; + if (EXCHANGE_BLOCKCHAINS.includes(asset.blockchain)) return CustodyClass.EXCHANGE_ACTIVE; + return CustodyClass.ON_CHAIN_ACTIVE; + } + + // §7: compare journal balance vs feed within tolerance; on diff → alarm (the journal stays authoritative, observer). + // The diff is native (leg.amount ≡ liquidity_balance.amount) and is valued at `mark` (CHF per native unit) so it is + // compared against the CHF-denominated tolerance in the SAME unit (§7 unit fix). + private async reconcileFreshAsset( + account: LedgerAccount, + balance: LiquidityBalance, + mark: number, + balances: Map, + now: Date, + ): Promise { + const journal = balances.get(account.id) ?? 0; + const feedAmount = balance.amount ?? 0; + const diff = Util.round(journal - feedAmount, 8); + const diffChf = Util.round(diff * mark, 2); + + if (Math.abs(diffChf) <= Config.ledger.reconciliationToleranceChf) return; // within CHF tolerance → balanced + + await this.sendAlarm( + MailContext.LEDGER_RECONCILIATION, + 'Ledger reconciliation diff', + [ + `${account.name}: journal ${journal} vs feed ${feedAmount} (diff ${diff} native, ${diffChf} CHF @ mark ${mark})`, + ], + `ledger-recon-${account.id}-${this.dayKey(now)}`, + ); + } + + // --- TRANSIT-AGE (§7.4) --- // + + // transit account with balance ≠ 0 older than route threshold → alarm. The age is the age of the CURRENT open + // residual, NOT MIN(bookingDate) over all legs: a churning route (opens+closes repeatedly, e.g. TRANSIT/payout) + // would otherwise report its very first leg ever, so a freshly re-opened residual reads as ancient and alarms every + // day (alert fatigue). It is derived from the last zero-crossing of the account's cumulative balance — the moment the + // balance last left 0 and stayed ≠ 0 (see openResidualSince). + private async checkTransitAge(now: Date): Promise { + // the open (non-zero) transit accounts; the id feeds the per-account cumulation below + const open = await this.ledgerLegRepository + .createQueryBuilder('leg') + .innerJoin('leg.account', 'account') + .select('account.id', 'id') + .addSelect('account.name', 'name') + .addSelect('SUM(leg.amount)', 'native') + .where('account.type = :type', { type: AccountType.TRANSIT }) + .groupBy('account.id') + .addGroupBy('account.name') + .having('ABS(SUM(leg.amount)) > :tol', { tol: 1e-8 }) + .getRawMany<{ id: number; name: string; native: string }>(); + if (!open.length) return; + + const thresholdDays = Config.ledger.transitAlarmThresholdDays; + const aged: { name: string; native: string; since: Date }[] = []; + for (const account of open) { + const since = await this.openResidualSince(+account.id); + if (since && Util.daysDiff(since, now) > thresholdDays) { + aged.push({ name: account.name, native: account.native, since }); + } + } + if (!aged.length) return; + + await this.sendAlarm( + MailContext.LEDGER_TRANSIT_OVERDUE, + 'Ledger transit overdue', + aged.map((t) => `${t.name}: balance ${t.native} open since ${t.since.toISOString()}`), + `ledger-transit-${this.dayKey(now)}`, + ); + } + + // §7.4 — the bookingDate since which the account's cumulative native balance has been continuously ≠ 0 (the opening + // of the CURRENT open residual), or undefined if it nets back to 0. Derived by an in-memory chronological cumulation + // over the account's legs: cheap because it runs ONLY for the few transit accounts the HAVING pre-filtered to a + // non-zero balance (a QueryBuilder running-sum zero-crossing would be disproportionate for so few rows). bookingDate + // lives on ledger_tx (NOT ledger_leg) → the query joins leg.tx and reads tx.bookingDate (alias.property is auto-quoted + // by TypeORM; a bare leg.bookingDate is a non-existent column that would crash the run on real PG). Ties on + // bookingDate are broken by leg.id so the crossing point is deterministic. + private async openResidualSince(accountId: number): Promise { + const legs = await this.ledgerLegRepository + .createQueryBuilder('leg') + .innerJoin('leg.tx', 'tx') + .innerJoin('leg.account', 'account') + .select('leg.amount', 'amount') + .addSelect('tx.bookingDate', 'bookingDate') + .where('account.id = :accountId', { accountId }) + .orderBy('tx.bookingDate', 'ASC') + .addOrderBy('leg.id', 'ASC') + .getRawMany<{ amount: string; bookingDate: string | Date }>(); + + const tol = 1e-8; + let cumulative = 0; + let openSince: Date | undefined; + for (const leg of legs) { + const wasZero = Math.abs(cumulative) <= tol; + cumulative = Util.round(cumulative + +leg.amount, 8); + if (Math.abs(cumulative) <= tol) { + openSince = undefined; // residual closed → the current non-zero run ended + } else if (wasZero) { + openSince = new Date(leg.bookingDate); // balance just left zero → a new open run starts at this leg + } + // !wasZero && still ≠ 0 → the same run continues, keep openSince + } + + return openSince; + } + + // --- SUSPENSE (§7.5) --- // + + // each SUSPENSE account with a balance ≠ 0 above its threshold → alarm + private async checkSuspense(): Promise { + const suspense = await this.ledgerLegRepository + .createQueryBuilder('leg') + .innerJoin('leg.account', 'account') + .select('account.name', 'name') + .addSelect('SUM(COALESCE(leg.amountChf, 0))', 'chf') + .where('account.type = :type', { type: AccountType.SUSPENSE }) + .groupBy('account.id') + .addGroupBy('account.name') + .having('ABS(SUM(COALESCE(leg.amountChf, 0))) > :tol', { tol: 1e-8 }) + .getRawMany<{ name: string; chf: string }>(); + if (!suspense.length) return; + + const genericThreshold = +(await this.settingService.get('ledgerSuspenseThresholdChf', '0')); + const unroutedThreshold = +(await this.settingService.get('ledgerUnroutedDepositThresholdChf', '0')); + + const alarms = suspense.filter((s) => { + const threshold = s.name.includes('deposit-unrouted') ? unroutedThreshold : genericThreshold; + return Math.abs(+s.chf) > threshold; + }); + if (!alarms.length) return; + + await this.sendAlarm( + MailContext.LEDGER_SUSPENSE, + 'Ledger suspense balance', + alarms.map((s) => `${s.name}: ${s.chf} CHF`), + ); + } + + // --- EQUITY PARITY (§7.6) --- // + + // §7.6 global completeness net: every balance change ≥ threshold must be attributable to an event. journalEquity = + // signed Σ over all balance accounts (ASSET+/TRANSIT+/LIABILITY−/SUSPENSE/ROUNDING), no leading minus (Major R8-1) + // → positive, sign-consistent with totalBalanceChf. It is compared against a robust FinancialDataLog baseline and, + // above a runtime threshold, raises an alarm (day-key suppressed like the peer checks). Two corrections make the + // comparison honest: + // (1) Median baseline: the FinancialDataLog carries transient ±snapshot-skew spikes (BalancesTotal case 4). We + // compare against the MEDIAN of the last EQUITY_PARITY_MEDIAN_SAMPLE VALID snapshots, not the single latest + // one, so a lone spike can never trip the alarm. + // (2) RefCredit baseline (Finding 3): develop accrues the open referral-credit liability into totalBalanceChf + // (accrual basis) while the ledger books ref rewards cash basis (only at payout) → a permanent definitional + // gap. We fold the open RefCredit liability explicitly into the baseline and report every component so the + // remaining difference stays explainable, instead of adding an accrual consumer to the ledger. + private async checkEquityParity(now: Date): Promise { + const journalEquity = await this.journalEquity(); + + const snapshots = await this.logService.getLatestValidFinancialLogs(EQUITY_PARITY_MEDIAN_SAMPLE); + const totals = snapshots + .map((s) => this.parseFinance(s.message)?.balancesTotal?.totalBalanceChf) + .filter((t): t is number => t != null); + if (!totals.length) return; // no valid snapshot yet → nothing to compare against + + const medianTotalChf = Util.round(this.median(totals), 2); + const openRefCreditChf = Util.round((await this.refRewardService.getOpenRefCreditLiability()).amountChf, 2); + + // fold the accrual-only RefCredit liability into the baseline so cash-basis ledger vs accrual-basis log align + const adjustedDifference = Util.round(journalEquity - (medianTotalChf + openRefCreditChf), 2); + + this.logger.info( + `Ledger equity parity: journalEquity ${journalEquity} vs medianTotalBalanceChf ${medianTotalChf} + ` + + `openRefCreditChf ${openRefCreditChf} (adjustedDifference ${adjustedDifference}, median of ${totals.length} snapshots)`, + ); + + const threshold = +(await this.settingService.get('ledgerEquityParityThresholdChf', '0')); + if (Math.abs(adjustedDifference) <= threshold) return; // within threshold → attributable → no alarm + + await this.sendAlarm( + MailContext.LEDGER_EQUITY_PARITY, + 'Ledger equity parity breach', + [ + `journalEquity ${journalEquity} CHF`, + `medianTotalBalanceChf ${medianTotalChf} CHF (median of ${totals.length} valid snapshots)`, + `openRefCreditChf ${openRefCreditChf} CHF`, + `adjustedDifference ${adjustedDifference} CHF (threshold ${threshold} CHF)`, + ], + `ledger-equity-parity-${this.dayKey(now)}`, + ); + } + + // median of a non-empty list (even count → mean of the two middle values); the caller guards against an empty list + private median(values: number[]): number { + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]; + } + + // signed Σ amountChf over the balance-account types (Dr +, Cr − already in the leg sign convention §2.3) + private async journalEquity(): Promise { + const raw = await this.ledgerLegRepository + .createQueryBuilder('leg') + .innerJoin('leg.account', 'account') + .select('SUM(COALESCE(leg.amountChf, 0))', 'chf') + .where('account.type IN (:...types)', { + types: [ + AccountType.ASSET, + AccountType.TRANSIT, + AccountType.LIABILITY, + AccountType.SUSPENSE, + AccountType.ROUNDING, + ], + }) + .getRawOne<{ chf: string | null }>(); + + return Util.round(+(raw?.chf ?? 0), 2); + } + + // --- HELPERS --- // + + // all native journal balances in ONE GROUP-BY pass (Σ leg.amount per account). alias.property refs + // (leg.accountId, SUM(leg.amount)) are auto-quoted by TypeORM — no bare camelCase column. Unfiltered all-legs + // aggregate (same semantics as the former per-account journalNativeBalance) → an all-time journal balance. + private async nativeBalanceByAccount(): Promise> { + const raw = await this.ledgerLegRepository + .createQueryBuilder('leg') + .select('leg.accountId', 'accountId') + .addSelect('SUM(leg.amount)', 'native') + .groupBy('leg.accountId') + .getRawMany<{ accountId: number; native: string }>(); + + return new Map(raw.map((r) => [+r.accountId, Util.round(+r.native, 8)])); + } + + // every ledger alarm goes ONLY through NotificationService.sendMail → sanctioned notification-write (Major R12-1). + // correlationId enables NotificationService suppression (one alarm per key/day) — §7.3 alarm suppression. + private async sendAlarm( + context: MailContext, + subject: string, + errors: string[], + correlationId?: string, + ): Promise { + const request: MailRequest = { + type: MailType.ERROR_MONITORING, + context, + input: { subject, errors }, + correlationId, + options: correlationId ? { suppressRecurring: true } : undefined, + }; + + await this.notificationService.sendMail(request); + } + + private parseFinance(message: string): FinanceLog | undefined { + try { + return JSON.parse(message) as FinanceLog; + } catch { + return undefined; + } + } + + private dayKey(date: Date): string { + return date.toISOString().slice(0, 10); + } +} diff --git a/src/subdomains/core/core.module.ts b/src/subdomains/core/core.module.ts index dc25b9a779..009e06519c 100644 --- a/src/subdomains/core/core.module.ts +++ b/src/subdomains/core/core.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { AccountingModule } from './accounting/accounting.module'; import { BuyCryptoModule } from './buy-crypto/buy-crypto.module'; import { CustodyModule } from './custody/custody.module'; import { FaucetRequestModule } from './faucet-request/faucet-request.module'; @@ -15,6 +16,7 @@ import { TransactionUtilModule } from './transaction/transaction-util.module'; @Module({ imports: [ + AccountingModule, BuyCryptoModule, HistoryModule, MonitoringModule, diff --git a/src/subdomains/core/referral/referral.module.ts b/src/subdomains/core/referral/referral.module.ts index 265bc34db0..a59ed14302 100644 --- a/src/subdomains/core/referral/referral.module.ts +++ b/src/subdomains/core/referral/referral.module.ts @@ -33,7 +33,7 @@ import { RefRewardService } from './reward/services/ref-reward.service'; NotificationModule, PricingModule, forwardRef(() => TransactionModule), - LiquidityManagementModule, + forwardRef(() => LiquidityManagementModule), ], controllers: [RefController, RefRewardController], providers: [ diff --git a/src/subdomains/generic/gs/__tests__/gs.service.spec.ts b/src/subdomains/generic/gs/__tests__/gs.service.spec.ts index 3989ce8a91..0797d0d60a 100644 --- a/src/subdomains/generic/gs/__tests__/gs.service.spec.ts +++ b/src/subdomains/generic/gs/__tests__/gs.service.spec.ts @@ -122,6 +122,20 @@ describe('GsService', () => { expect(params).toEqual([]); }); + it('runs a basic SELECT against an allowed table (aktionariat_registration)', async () => { + const q = spyQuery([{ id: 1 }]); + const dto: DebugQueryDto = { + table: 'aktionariat_registration', + select: [{ kind: 'column', column: 'id' }], + limit: 10, + }; + + await service.executeDebugQuery(dto, 'tester'); + + expect(q).toHaveBeenCalledTimes(1); + expect(q.mock.calls[0][0]).toContain('FROM "aktionariat_registration"'); + }); + it('rejects an unknown table', async () => { const dto = { table: 'pg_catalog_pg_roles', @@ -390,6 +404,12 @@ describe('GsService', () => { // rejects any query (this it.each row covers a future re-add of just `userDataId` // because we also assert the table itself is rejected — see the test below). ['mros', 'userDataId'], + // aktionariat_registration — email is PII; signature/signedPayload/kycData are + // secrets / free-form JSON carrying personal data + ['aktionariat_registration', 'email'], + ['aktionariat_registration', 'signature'], + ['aktionariat_registration', 'signedPayload'], + ['aktionariat_registration', 'kycData'], ])('rejects sensitive column %s.%s', async (table, column) => { const dto = { table, select: [{ kind: 'column' as const, column }], limit: 10 }; await expect(service.executeDebugQuery(dto, 'tester')).rejects.toThrow(BadRequestException); diff --git a/src/subdomains/generic/gs/dto/gs.dto.ts b/src/subdomains/generic/gs/dto/gs.dto.ts index 9b3eee4459..2104120afe 100644 --- a/src/subdomains/generic/gs/dto/gs.dto.ts +++ b/src/subdomains/generic/gs/dto/gs.dto.ts @@ -66,6 +66,25 @@ export const DebugAllowedColumns: Record = { 'slaveId', ], }, + aktionariat_registration: { + // RealUnit Aktionariat share-register registrations (one row per wallet). Exposes the + // registration lifecycle and the email-confirmation latch for support forensics. No PII: + // `email` is excluded (mail), `signature` / `signedPayload` / `kycData` are excluded + // (secrets / free-form JSON carrying personal data). + columns: [ + 'id', + 'created', + 'updated', + 'active', + 'confirmedDate', + 'forwardedToAktionariatDate', + 'registrationDate', + 'requiresEmailConfirmation', + 'status', + 'userId', + 'walletAddress', + ], + }, asset: { // No `ikna` — it's the sole entry in `GsRestrictedColumns`, which `/gs/db` masks to // `[RESTRICTED]` for everyone except SUPER_ADMIN. The structured `/gs/debug` endpoint diff --git a/src/subdomains/supporting/bank-tx/bank-tx/__tests__/bank-tx.service.spec.ts b/src/subdomains/supporting/bank-tx/bank-tx/__tests__/bank-tx.service.spec.ts new file mode 100644 index 0000000000..a48b323daf --- /dev/null +++ b/src/subdomains/supporting/bank-tx/bank-tx/__tests__/bank-tx.service.spec.ts @@ -0,0 +1,185 @@ +import { createMock } from '@golevelup/ts-jest'; +import { ConfigService } from 'src/config/config'; +import { OlkypayService } from 'src/integration/bank/services/olkypay.service'; +import { YapealService } from 'src/integration/bank/services/yapeal.service'; +import { createCustomPrice } from 'src/integration/exchange/dto/__mocks__/price.dto.mock'; +import { createCustomFiat } from 'src/shared/models/fiat/__mocks__/fiat.entity.mock'; +import { FiatService } from 'src/shared/models/fiat/fiat.service'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { BuyCryptoService } from 'src/subdomains/core/buy-crypto/process/services/buy-crypto.service'; +import { BuyService } from 'src/subdomains/core/buy-crypto/routes/buy/buy.service'; +import { BankDataService } from 'src/subdomains/generic/user/models/bank-data/bank-data.service'; +import { BankService } from 'src/subdomains/supporting/bank/bank/bank.service'; +import { VirtualIbanService } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban.service'; +import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; +import { SpecialExternalAccountService } from 'src/subdomains/supporting/payment/services/special-external-account.service'; +import { TransactionNotificationService } from 'src/subdomains/supporting/payment/services/transaction-notification.service'; +import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; +import { PricingService } from 'src/subdomains/supporting/pricing/services/pricing.service'; +import { BankTxRepeatService } from '../../bank-tx-repeat/bank-tx-repeat.service'; +import { BankTxReturnService } from '../../bank-tx-return/bank-tx-return.service'; +import { BankTxIndicator } from '../entities/bank-tx.entity'; +import { BankTxBatchRepository } from '../repositories/bank-tx-batch.repository'; +import { BankTxRepository } from '../repositories/bank-tx.repository'; +import { BankTxFrickService } from '../services/bank-tx-frick.service'; +import { BankTxService } from '../services/bank-tx.service'; +import { SepaParser } from '../services/sepa-parser.service'; + +// one raw aggregate row as returned by the GROUP BY currency, creditDebitIndicator query +interface FeeAggregate { + currency: string; + creditDebitIndicator: BankTxIndicator; + amount: string; // Postgres returns SUM() as a string +} + +describe('BankTxService', () => { + let service: BankTxService; + + let bankTxRepo: BankTxRepository; + let pricingService: PricingService; + let fiatService: FiatService; + + let qb: any; + + const from = new Date('2026-07-01'); + + // Distinct per-currency CHF prices. Price.convert divides the amount by the price + // (getPrice(fiat, CHF).convert(x) = x / price), so a wrong currency->price mapping or an + // inverted (multiply) direction produces a different total and turns these tests red. + const chfPriceByCurrency: Record = { EUR: 0.8, USD: 2 }; + + beforeAll(() => { + new ConfigService(); // sets module-level Config (defaultVolumeDecimal read by the CHF conversion) + }); + + beforeEach(() => { + jest.clearAllMocks(); + + bankTxRepo = createMock(); + pricingService = createMock(); + fiatService = createMock(); + + // one chainable query builder mock serves both queries: the legacy chargeAmountChf sum + // (getRawOne) and the per-currency/direction BankAccountFee aggregation (getRawMany). + qb = { + select: jest.fn(() => qb), + addSelect: jest.fn(() => qb), + where: jest.fn(() => qb), + andWhere: jest.fn(() => qb), + groupBy: jest.fn(() => qb), + addGroupBy: jest.fn(() => qb), + getRawOne: jest.fn(), + getRawMany: jest.fn(), + }; + (bankTxRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb); + + (fiatService.getFiatByName as jest.Mock).mockImplementation((name: string) => createCustomFiat({ name })); + + service = new BankTxService( + bankTxRepo, + createMock(), + createMock(), + createMock(), + createMock(), + createMock(), + createMock(), + createMock(), + createMock(), + createMock(), + createMock(), + createMock(), + createMock(), + createMock(), + createMock(), + createMock(), + createMock(), + createMock(), + pricingService, + fiatService, + ); + }); + + function mockLegacyFee(fee: number | null): void { + qb.getRawOne.mockResolvedValue({ fee }); + } + + function mockFeeAggregates(rows: FeeAggregate[]): void { + qb.getRawMany.mockResolvedValue(rows); + } + + // per-currency prices from chfPriceByCurrency (distinct so mapping/direction errors are caught) + function mockChfPrices(): void { + (pricingService.getPrice as jest.Mock).mockImplementation(async (fiat: { name: string }) => + createCustomPrice({ source: fiat.name, target: 'CHF', price: chfPriceByCurrency[fiat.name] }), + ); + } + + it('converts each DBIT currency aggregate with its own price and sums the result', async () => { + mockLegacyFee(null); + mockFeeAggregates([ + { currency: 'EUR', creditDebitIndicator: BankTxIndicator.DEBIT, amount: '100' }, + { currency: 'USD', creditDebitIndicator: BankTxIndicator.DEBIT, amount: '200' }, + ]); + mockChfPrices(); + + // EUR 100 / 0.8 = 125 CHF; USD 200 / 2 = 100 CHF; total 225 CHF + // (a multiply direction -> 80 + 400 = 480; a swapped currency->price -> 125 + 250 = 375) + await expect(service.getBankTxFee(from)).resolves.toBe(225); + + expect(fiatService.getFiatByName).toHaveBeenCalledWith('EUR'); + expect(fiatService.getFiatByName).toHaveBeenCalledWith('USD'); + expect(pricingService.getPrice).toHaveBeenCalledTimes(2); + }); + + it('nets a CRDT refund against a DBIT fee in the same currency before converting', async () => { + mockLegacyFee(null); + mockFeeAggregates([ + { currency: 'EUR', creditDebitIndicator: BankTxIndicator.DEBIT, amount: '100' }, + { currency: 'EUR', creditDebitIndicator: BankTxIndicator.CREDIT, amount: '30' }, + ]); + mockChfPrices(); + + // net EUR 70 / 0.8 = 87.5 CHF (converted once, after netting) + await expect(service.getBankTxFee(from)).resolves.toBe(87.5); + + expect(pricingService.getPrice).toHaveBeenCalledTimes(1); + }); + + it('adds the legacy chargeAmountChf sum on top of the converted BankAccountFee aggregates', async () => { + mockLegacyFee(5); + mockFeeAggregates([{ currency: 'EUR', creditDebitIndicator: BankTxIndicator.DEBIT, amount: '100' }]); + mockChfPrices(); + + // legacy 5 + EUR 100 / 0.8 (125) = 130 + await expect(service.getBankTxFee(from)).resolves.toBe(130); + }); + + it('rounds the assembled total to the default volume decimals', async () => { + mockLegacyFee(0.1); + mockFeeAggregates([{ currency: 'EUR', creditDebitIndicator: BankTxIndicator.DEBIT, amount: '0.2' }]); + // price 1 so the converted amount is exactly 0.2 and only the final sum needs rounding + (pricingService.getPrice as jest.Mock).mockResolvedValue( + createCustomPrice({ source: 'EUR', target: 'CHF', price: 1 }), + ); + + // legacy 0.1 + EUR 0.2 = 0.30000000000000004 in float -> must be rounded to 0.3 + await expect(service.getBankTxFee(from)).resolves.toBe(0.3); + }); + + it('fails loud when the price is unavailable', async () => { + mockLegacyFee(null); + mockFeeAggregates([{ currency: 'EUR', creditDebitIndicator: BankTxIndicator.DEBIT, amount: '100' }]); + (pricingService.getPrice as jest.Mock).mockRejectedValue(new Error('No valid price')); + + await expect(service.getBankTxFee(from)).rejects.toThrow(); + }); + + it('returns only the legacy sum when there are no BankAccountFee aggregates', async () => { + mockLegacyFee(5); + mockFeeAggregates([]); + + await expect(service.getBankTxFee(from)).resolves.toBe(5); + + expect(pricingService.getPrice).not.toHaveBeenCalled(); + }); +}); diff --git a/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts b/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts index 64c7c74838..1cb4cf61d4 100644 --- a/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts +++ b/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts @@ -9,7 +9,9 @@ import { } from '@nestjs/common'; import { CronExpression } from '@nestjs/schedule'; import { Observable, Subject } from 'rxjs'; +import { Config } from 'src/config/config'; import { YapealService } from 'src/integration/bank/services/yapeal.service'; +import { FiatService } from 'src/shared/models/fiat/fiat.service'; import { SettingService } from 'src/shared/models/setting/setting.service'; import { DfxLogger } from 'src/shared/services/dfx-logger'; import { Process } from 'src/shared/services/process.service'; @@ -26,6 +28,11 @@ import { MailContext, MailType } from 'src/subdomains/supporting/notification/en import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; import { SpecialExternalAccount } from 'src/subdomains/supporting/payment/entities/special-external-account.entity'; import { TransactionNotificationService } from 'src/subdomains/supporting/payment/services/transaction-notification.service'; +import { + PriceCurrency, + PriceValidity, + PricingService, +} from 'src/subdomains/supporting/pricing/services/pricing.service'; import { DeepPartial, FindOptionsRelations, @@ -114,6 +121,8 @@ export class BankTxService implements OnModuleInit { private readonly virtualIbanService: VirtualIbanService, @Inject(forwardRef(() => TransactionNotificationService)) private readonly transactionNotificationService: TransactionNotificationService, + private readonly pricingService: PricingService, + private readonly fiatService: FiatService, ) {} onModuleInit() { @@ -430,6 +439,10 @@ export class BankTxService implements OnModuleInit { ]); } + // Bank fees come from two sources: legacy per-tx charges (chargeAmountChf) plus dedicated BankAccountFee rows. + // Charge fields ceased with the bank migration in Dec 2025; fees arrive as dedicated BankAccountFee rows since, + // carrying the amount in the account currency (no amountChf column), so they are aggregated per currency and + // converted to CHF. async getBankTxFee(from: Date): Promise { const { fee } = await this.bankTxRepo .createQueryBuilder('bankTx') @@ -437,7 +450,51 @@ export class BankTxService implements OnModuleInit { .where('bankTx.created >= :from', { from }) .getRawOne<{ fee: number }>(); - return fee ?? 0; + let totalFeeChf = fee ?? 0; + + // Aggregate the dedicated BankAccountFee rows in the database (summed per currency and direction) + // rather than hydrating every row: this runs every minute from the financial-log cron and the table + // grows continuously. Column references use the alias.property form so TypeORM quotes the camelCase + // identifiers correctly for Postgres. + const feeAggregates = await this.bankTxRepo + .createQueryBuilder('bankTx') + .select('bankTx.currency', 'currency') + .addSelect('bankTx.creditDebitIndicator', 'creditDebitIndicator') + .addSelect('SUM(bankTx.amount)', 'amount') + .where('bankTx.type = :type', { type: BankTxType.BANK_ACCOUNT_FEE }) + .andWhere('bankTx.created >= :from', { from }) + .groupBy('bankTx.currency') + .addGroupBy('bankTx.creditDebitIndicator') + .getRawMany<{ currency: string; creditDebitIndicator: string; amount: string }>(); + + const amountByCurrency = new Map(); + for (const row of feeAggregates) { + const groupAmount = Number(row.amount); + + let signedAmount: number; + if (row.creditDebitIndicator === BankTxIndicator.DEBIT) { + signedAmount = groupAmount; + } else if (row.creditDebitIndicator === BankTxIndicator.CREDIT) { + signedAmount = -groupAmount; + } else { + this.logger.error( + `BankAccountFee aggregate (currency ${row.currency}) has unexpected creditDebitIndicator ${row.creditDebitIndicator}, skipping`, + ); + continue; + } + + amountByCurrency.set(row.currency, (amountByCurrency.get(row.currency) ?? 0) + signedAmount); + } + + for (const [currency, amount] of amountByCurrency) { + if (!amount) continue; + + const fiat = await this.fiatService.getFiatByName(currency); + const price = await this.pricingService.getPrice(fiat, PriceCurrency.CHF, PriceValidity.ANY); + totalFeeChf += price.convert(amount, Config.defaultVolumeDecimal); + } + + return Util.round(totalFeeChf, Config.defaultVolumeDecimal); } async getRecentBankToBankTx(fromIban: string, toIban: string): Promise { diff --git a/src/subdomains/supporting/dashboard/__tests__/dashboard-financial.service.spec.ts b/src/subdomains/supporting/dashboard/__tests__/dashboard-financial.service.spec.ts new file mode 100644 index 0000000000..b54ea2fe7e --- /dev/null +++ b/src/subdomains/supporting/dashboard/__tests__/dashboard-financial.service.spec.ts @@ -0,0 +1,76 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { RefRewardService } from 'src/subdomains/core/referral/reward/services/ref-reward.service'; +import { Log } from '../../log/log.entity'; +import { LogService } from '../../log/log.service'; +import { DashboardFinancialService } from '../dashboard-financial.service'; + +describe('DashboardFinancialService', () => { + let service: DashboardFinancialService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DashboardFinancialService, + { provide: LogService, useValue: createMock() }, + { provide: AssetService, useValue: createMock() }, + { provide: RefRewardService, useValue: createMock() }, + ], + }).compile(); + + service = module.get(DashboardFinancialService); + }); + + function mapEntry(changes: unknown) { + const log = { created: new Date(), message: JSON.stringify({ changes }) } as Log; + return (service as any).mapChangesLogToEntry(log); + } + + it('maps the Scrypt and MEXC minus blocks from a new change log', () => { + const entry = mapEntry({ + minus: { + scrypt: { total: 229.67, withdraw: 0, trading: 229.67 }, + mexc: { total: 23.22, withdraw: 5, trading: 18.22 }, + }, + }); + + expect(entry.minus.scrypt).toEqual({ total: 229.67, withdraw: 0, trading: 229.67 }); + expect(entry.minus.mexc).toEqual({ total: 23.22, withdraw: 5, trading: 18.22 }); + }); + + it('defaults Scrypt and MEXC to zero for a historical log without those keys (backward compatibility)', () => { + const entry = mapEntry({ + minus: { + bank: 10, + kraken: { total: 5, withdraw: 2, trading: 3 }, + binance: { total: 7, withdraw: 1, trading: 6 }, + }, + }); + + expect(entry.minus.scrypt).toEqual({ total: 0, withdraw: 0, trading: 0 }); + expect(entry.minus.mexc).toEqual({ total: 0, withdraw: 0, trading: 0 }); + expect(entry.minus.binance).toEqual({ total: 7, withdraw: 1, trading: 6 }); + }); + + describe('mapLogToEntry (fxPnlChf exposure)', () => { + const logWith = (balancesTotal: object): Log => + ({ created: new Date('2026-07-14T00:00:00Z'), message: JSON.stringify({ balancesTotal }) }) as Log; + + it('exposes the fxPnlChf written into the log entry, preserving a negative value', () => { + const entry = service['mapLogToEntry']( + logWith({ totalBalanceChf: 100, plusBalanceChf: 100, minusBalanceChf: 0, fxPnlChf: -3245 }), + ); + + expect(entry?.fxPnlChf).toBe(-3245); + }); + + it('defaults historical entries logged before fxPnlChf existed to 0', () => { + const entry = service['mapLogToEntry']( + logWith({ totalBalanceChf: 100, plusBalanceChf: 100, minusBalanceChf: 0 }), + ); + + expect(entry?.fxPnlChf).toBe(0); + }); + }); +}); diff --git a/src/subdomains/supporting/dashboard/dashboard-financial.service.ts b/src/subdomains/supporting/dashboard/dashboard-financial.service.ts index 4099ddac96..b3c3e4b8bf 100644 --- a/src/subdomains/supporting/dashboard/dashboard-financial.service.ts +++ b/src/subdomains/supporting/dashboard/dashboard-financial.service.ts @@ -89,6 +89,16 @@ export class DashboardFinancialService { withdraw: changes.minus?.binance?.withdraw ?? 0, trading: changes.minus?.binance?.trading ?? 0, }, + scrypt: { + total: changes.minus?.scrypt?.total ?? 0, + withdraw: changes.minus?.scrypt?.withdraw ?? 0, + trading: changes.minus?.scrypt?.trading ?? 0, + }, + mexc: { + total: changes.minus?.mexc?.total ?? 0, + withdraw: changes.minus?.mexc?.withdraw ?? 0, + trading: changes.minus?.mexc?.trading ?? 0, + }, blockchain: { total: changes.minus?.blockchain?.total ?? 0, txIn: changes.minus?.blockchain?.tx?.in ?? 0, @@ -245,6 +255,7 @@ export class DashboardFinancialService { totalBalanceChf: financeLog.balancesTotal?.totalBalanceChf ?? 0, plusBalanceChf: financeLog.balancesTotal?.plusBalanceChf ?? 0, minusBalanceChf: financeLog.balancesTotal?.minusBalanceChf ?? 0, + fxPnlChf: financeLog.balancesTotal?.fxPnlChf ?? 0, btcPriceChf, balancesByType, }; diff --git a/src/subdomains/supporting/dashboard/dto/financial-log.dto.ts b/src/subdomains/supporting/dashboard/dto/financial-log.dto.ts index c0e07f7224..e344a9e69b 100644 --- a/src/subdomains/supporting/dashboard/dto/financial-log.dto.ts +++ b/src/subdomains/supporting/dashboard/dto/financial-log.dto.ts @@ -3,6 +3,9 @@ export class FinancialLogEntryDto { totalBalanceChf: number; plusBalanceChf: number; minusBalanceChf: number; + // per-interval price effect (FX P&L) of the open positions vs. the previous snapshot; 0 for entries + // logged before this field existed (see BalancesTotal.fxPnlChf). + fxPnlChf: number; btcPriceChf: number; balancesByType: Record; } @@ -27,6 +30,8 @@ export class FinancialChangesEntryDto { kraken: { total: number; withdraw: number; trading: number }; ref: { total: number; amount: number; fee: number }; binance: { total: number; withdraw: number; trading: number }; + scrypt: { total: number; withdraw: number; trading: number }; + mexc: { total: number; withdraw: number; trading: number }; blockchain: { total: number; txIn: number; txOut: number; trading: number }; }; } diff --git a/src/subdomains/supporting/log/__tests__/log-job.service.spec.ts b/src/subdomains/supporting/log/__tests__/log-job.service.spec.ts index 36c52f9054..17ff623d18 100644 --- a/src/subdomains/supporting/log/__tests__/log-job.service.spec.ts +++ b/src/subdomains/supporting/log/__tests__/log-job.service.spec.ts @@ -2,6 +2,8 @@ import { createMock } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { BlockchainRegistryService } from 'src/integration/blockchain/shared/services/blockchain-registry.service'; import { createCustomExchangeTx } from 'src/integration/exchange/dto/__mocks__/exchange-tx.entity.mock'; +import { ExchangeTxType } from 'src/integration/exchange/entities/exchange-tx.entity'; +import { ExchangeName } from 'src/integration/exchange/enums/exchange.enum'; import { ExchangeTxService } from 'src/integration/exchange/services/exchange-tx.service'; import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; import { Asset } from 'src/shared/models/asset/asset.entity'; @@ -118,6 +120,101 @@ describe('LogJobService', () => { expect(service).toBeDefined(); }); + describe('getChangeLog (Scrypt & MEXC exchange fees)', () => { + function setupEmpty() { + jest.spyOn(buyFiatService, 'getBuyFiat').mockResolvedValue([] as any); + jest.spyOn(buyCryptoService, 'getBuyCrypto').mockResolvedValue([] as any); + jest.spyOn(tradingOrderService, 'getTradingOrderYield').mockResolvedValue({ fee: 0, profit: 0 } as any); + jest.spyOn(payoutService, 'getPayoutOrders').mockResolvedValue([] as any); + jest.spyOn(bankTxService, 'getBankTxFee').mockResolvedValue(0 as any); + jest.spyOn(payInService, 'getPayInFee').mockResolvedValue(0 as any); + jest.spyOn(refRewardService, 'getRefRewardVolume').mockResolvedValue(0 as any); + } + + it('sums Scrypt and MEXC trade+withdrawal fees (sign-aware, incl. rebates) into minus and total', async () => { + setupEmpty(); + jest.spyOn(exchangeTxService, 'getExchangeTx').mockResolvedValue([ + // Scrypt: trade 240 minus a 20 rebate = 220 net trading, plus a 10 withdrawal -> total 230 + createCustomExchangeTx({ exchange: ExchangeName.SCRYPT, type: ExchangeTxType.TRADE, feeAmountChf: 240 }), + createCustomExchangeTx({ exchange: ExchangeName.SCRYPT, type: ExchangeTxType.TRADE, feeAmountChf: -20 }), + createCustomExchangeTx({ exchange: ExchangeName.SCRYPT, type: ExchangeTxType.WITHDRAWAL, feeAmountChf: 10 }), + // MEXC: trade 18 + withdrawal 5 -> total 23 + createCustomExchangeTx({ exchange: ExchangeName.MEXC, type: ExchangeTxType.TRADE, feeAmountChf: 18 }), + createCustomExchangeTx({ exchange: ExchangeName.MEXC, type: ExchangeTxType.WITHDRAWAL, feeAmountChf: 5 }), + ] as any); + + const result = await (service as any).getChangeLog(); + + expect(result.minus.scrypt).toEqual({ total: 230, withdraw: 10, trading: 220 }); + expect(result.minus.mexc).toEqual({ total: 23, withdraw: 5, trading: 18 }); + expect(result.minus.total).toBe(253); + expect(result.total).toBe(-253); + }); + + it('omits the Scrypt and MEXC blocks when there are no such exchange fees', async () => { + setupEmpty(); + jest.spyOn(exchangeTxService, 'getExchangeTx').mockResolvedValue([] as any); + + const result = await (service as any).getChangeLog(); + + expect(result.minus.scrypt).toBeUndefined(); + expect(result.minus.mexc).toBeUndefined(); + }); + + it('flows the bank tx fee into minus.bank and the totals', async () => { + setupEmpty(); + jest.spyOn(exchangeTxService, 'getExchangeTx').mockResolvedValue([] as any); + jest.spyOn(bankTxService, 'getBankTxFee').mockResolvedValue(4196 as any); + + const result = await (service as any).getChangeLog(); + + expect(result.minus.bank).toBe(4196); + expect(result.minus.total).toBe(4196); + expect(result.total).toBe(-4196); + }); + }); + + describe('FinancialChangesLog isolation (reporting failure must not arm the equity safety mode)', () => { + // a healthy, finite book comfortably above the minimum -> the equity path leaves safety mode off + function setup() { + jest.spyOn(service as any, 'getTradingLog').mockResolvedValue({}); + jest.spyOn(service as any, 'getAssetLog').mockResolvedValue({}); + jest + .spyOn(service as any, 'getBalancesByFinancialType') + .mockReturnValue({ EUR: { plusBalance: 5000, plusBalanceChf: 5000, minusBalance: 0, minusBalanceChf: 0 } }); + jest.spyOn(assetService, 'getAssetsWith').mockResolvedValue([] as any); + jest.spyOn(settingService, 'getObj').mockResolvedValue(100 as any); + jest.spyOn(refRewardService, 'getOpenRefCreditLiability').mockResolvedValue({ amountEur: 0, amountChf: 0 }); + jest + .spyOn(logService, 'maxEntity') + .mockResolvedValue({ message: JSON.stringify({ balancesTotal: { totalBalanceChf: 5000 } }) } as any); + return jest.spyOn(logService, 'create').mockResolvedValue({} as any); + } + + it('writes the FinancialDataLog, keeps safety mode off, logs the error and omits the changes entry when getChangeLog throws', async () => { + const errorSpy = jest.spyOn(service['logger'], 'error'); + const createSpy = setup(); + // a transient reporting-price failure while building the changes log + jest.spyOn(service as any, 'getChangeLog').mockRejectedValue(new Error('No valid price')); + + await service.saveTradingLog(); + + // the equity path ran and still persisted the FinancialDataLog entry for this minute + const dataLog = createSpy.mock.calls.find(([dto]) => dto.subsystem === 'FinancialDataLog'); + expect(dataLog).toBeDefined(); + + // the reporting-price failure did NOT arm the equity safety mode: the healthy book set it to false + // (and the outer catch, which would set it true, never ran) + expect(processService.setSafetyModeActive).toHaveBeenCalledWith(false); + expect(processService.setSafetyModeActive).not.toHaveBeenCalledWith(true); + + // the failure is logged and this minute's changes entry is omitted (absence, not a wrong value) + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('financial changes log'), expect.any(Error)); + const changesLog = createSpy.mock.calls.find(([dto]) => dto.subsystem === 'FinancialChangesLog'); + expect(changesLog).toBeUndefined(); + }); + }); + describe('saveTradingLog (referral-credit liability)', () => { // a positive base book so the referral-credit assertions work on round numbers const baseBuckets = () => ({ @@ -225,6 +322,142 @@ describe('LogJobService', () => { }); }); + describe('getFxPnlChf (per-interval price effect of open positions)', () => { + // a short BTC leg and a long USD leg, priced up and down respectively, plus a non-financialType asset + // whose price also moves and must therefore be excluded from the FX total. + const assets = [ + createCustomAsset({ id: 1, financialType: 'BTC' }), + createCustomAsset({ id: 2, financialType: 'USD' }), + createCustomAsset({ id: 3 }), // no financialType -> excluded + ]; + + it('sums previous net position times the CHF price change, only over financialType assets', () => { + const prev = { + assets: { + 1: { plusBalance: { total: 0 }, minusBalance: { total: 1.7 }, priceChf: 50000 }, // net short 1.7 BTC + 2: { plusBalance: { total: 250000 }, minusBalance: { total: 0 }, priceChf: 0.9 }, // net long 250k USD + 3: { plusBalance: { total: 1000 }, minusBalance: { total: 0 }, priceChf: 10 }, // excluded (no financialType) + }, + } as any; + const assetLog = { + 1: { priceChf: 51000 }, // +1000 -> short leg marks down -1700 + 2: { priceChf: 0.88 }, // -0.02 -> long leg marks down -5000 + 3: { priceChf: 1000 }, // large move, but must not count + } as any; + + // -1.7 * (51000 - 50000) + 250000 * (0.88 - 0.90) = -1700 + -5000 = -6700; asset 3 filtered out + expect(service['getFxPnlChf'](prev, assetLog, assets)).toBeCloseTo(-6700, 4); + }); + + it('returns undefined when there is no predecessor snapshot to diff against (first entry)', () => { + expect(service['getFxPnlChf'](undefined, {} as any, assets)).toBeUndefined(); + // a predecessor log without an assets map is equally unusable as a reference point + expect(service['getFxPnlChf']({ balancesTotal: {} } as any, {} as any, assets)).toBeUndefined(); + }); + + it('ignores positions absent from either snapshot (new and closed positions carry no price effect)', () => { + const prev = { + assets: { + 1: { plusBalance: { total: 2 }, minusBalance: { total: 0 }, priceChf: 50000 }, // present both sides + 2: { plusBalance: { total: 100 }, minusBalance: { total: 0 }, priceChf: 10 }, // closed: gone from current + }, + } as any; + const assetLog = { + 1: { priceChf: 50100 }, // +100 -> 2 * 100 = 200 + 4: { priceChf: 999 }, // new position: not in prev -> ignored + } as any; + const withNewAndClosed = [ + createCustomAsset({ id: 1, financialType: 'BTC' }), + createCustomAsset({ id: 2, financialType: 'BTC' }), // closed since the previous snapshot + createCustomAsset({ id: 4, financialType: 'BTC' }), // newly entered + ]; + + // only asset 1 is in both snapshots: 2 * (50100 - 50000) = 200 + expect(service['getFxPnlChf'](prev, assetLog, withNewAndClosed)).toBeCloseTo(200, 4); + }); + + it('skips an asset without a usable price on either side', () => { + const prev = { + assets: { + 1: { plusBalance: { total: 2 }, minusBalance: { total: 0 }, priceChf: undefined }, // no previous price + 2: { plusBalance: { total: 2 }, minusBalance: { total: 0 }, priceChf: 100 }, + }, + } as any; + const assetLog = { + 1: { priceChf: 100 }, + 2: { priceChf: undefined }, // no current price + } as any; + const twoAssets = [ + createCustomAsset({ id: 1, financialType: 'BTC' }), + createCustomAsset({ id: 2, financialType: 'BTC' }), + ]; + + expect(service['getFxPnlChf'](prev, assetLog, twoAssets)).toBe(0); + }); + }); + + describe('saveTradingLog (FX P&L component)', () => { + function setupFxPnl(params: { assets: Asset[]; assetLog: Record; lastMessage: object }) { + jest.spyOn(service as any, 'getTradingLog').mockResolvedValue({}); + jest.spyOn(service as any, 'getAssetLog').mockResolvedValue(params.assetLog); + // mock the bucket aggregation so the FX assertion is isolated from balance summation; a healthy + // finite total keeps safety mode and the valid flag out of the way. + jest.spyOn(service as any, 'getBalancesByFinancialType').mockReturnValue({ + BTC: { plusBalance: 0, plusBalanceChf: 200000, minusBalance: 0, minusBalanceChf: 0 }, + }); + jest.spyOn(service as any, 'getChangeLog').mockResolvedValue({}); + jest.spyOn(assetService, 'getAssetsWith').mockResolvedValue(params.assets as any); + jest.spyOn(settingService, 'getObj').mockResolvedValue(100 as any); + jest.spyOn(refRewardService, 'getOpenRefCreditLiability').mockResolvedValue({ amountEur: 0, amountChf: 0 }); + jest + .spyOn(logService, 'maxEntity') + .mockResolvedValue({ created: new Date(), message: JSON.stringify(params.lastMessage) } as any); + return jest.spyOn(logService, 'create').mockResolvedValue({} as any); + } + + function financialLog(createSpy: jest.SpyInstance) { + const call = createSpy.mock.calls.find(([dto]) => dto.subsystem === 'FinancialDataLog'); + return JSON.parse(call[0].message); + } + + it('writes the price effect of the open positions since the previous snapshot into balancesTotal', async () => { + const createSpy = setupFxPnl({ + assets: [ + createCustomAsset({ id: 1, financialType: 'BTC' }), + createCustomAsset({ id: 2, financialType: 'USD' }), + ], + assetLog: { + 1: { priceChf: 51000, plusBalance: { total: 0 }, minusBalance: { total: 1.7 } }, + 2: { priceChf: 0.88, plusBalance: { total: 250000 }, minusBalance: { total: 0 } }, + }, + lastMessage: { + balancesTotal: { totalBalanceChf: 200000 }, + assets: { + 1: { plusBalance: { total: 0 }, minusBalance: { total: 1.7 }, priceChf: 50000 }, + 2: { plusBalance: { total: 250000 }, minusBalance: { total: 0 }, priceChf: 0.9 }, + }, + }, + }); + + await service.saveTradingLog(); + + // -1.7*(51000-50000) + 250000*(0.88-0.90) = -1700 + -5000 = -6700 (rounded to 2 dp, negative preserved) + expect(financialLog(createSpy).balancesTotal.fxPnlChf).toBe(-6700); + }); + + it('omits fxPnlChf when the previous snapshot has no assets to diff against (first entry)', async () => { + const createSpy = setupFxPnl({ + assets: [createCustomAsset({ id: 1, financialType: 'BTC' })], + assetLog: { 1: { priceChf: 51000, plusBalance: { total: 1 }, minusBalance: { total: 0 } } }, + lastMessage: { balancesTotal: { totalBalanceChf: 200000 } }, // no assets map -> no reference point + }); + + await service.saveTradingLog(); + + expect(financialLog(createSpy).balancesTotal).not.toHaveProperty('fxPnlChf'); + }); + }); + describe('safety mode (fail closed on non-finite total)', () => { function setup(buckets: Record, minTotalBalanceChf: number) { jest.spyOn(service as any, 'getTradingLog').mockResolvedValue({}); diff --git a/src/subdomains/supporting/log/dto/log.dto.ts b/src/subdomains/supporting/log/dto/log.dto.ts index 17f97f0f4e..cd6e6f2106 100644 --- a/src/subdomains/supporting/log/dto/log.dto.ts +++ b/src/subdomains/supporting/log/dto/log.dto.ts @@ -60,6 +60,13 @@ export interface BalancesTotal { plusBalanceChf: number; minusBalanceChf: number; totalBalanceChf: number; + /** + * Per-interval price effect (FX P&L) of the open positions vs. the previous snapshot: the sum over + * assets of each one's previous net position times the change in its CHF price. Customer flow is + * balance-neutral, so `ΔtotalBalanceChf ≈ transactional yield + fxPnlChf + errors`. Absent (not 0) on + * the first entry, which has no previous snapshot to diff against. + */ + fxPnlChf?: number; } export interface BalancesByFinancialType { @@ -183,6 +190,8 @@ type ChangeMinusBalance = { bank?: number; kraken?: ChangeExchangeBalance; binance?: ChangeExchangeBalance; + scrypt?: ChangeExchangeBalance; + mexc?: ChangeExchangeBalance; blockchain?: ChangeBlockchainBalance; ref?: ChangeRefBalance; }; diff --git a/src/subdomains/supporting/log/log-job.service.ts b/src/subdomains/supporting/log/log-job.service.ts index 8d24283582..6f2b365869 100644 --- a/src/subdomains/supporting/log/log-job.service.ts +++ b/src/subdomains/supporting/log/log-job.service.ts @@ -129,9 +129,6 @@ export class LogJobService { const refCreditLiability = await this.getRefCreditLiability(); if (refCreditLiability) balancesByFinancialType[REF_CREDIT_FINANCIAL_TYPE] = refCreditLiability; - // changes - const changeLog = await this.getChangeLog(); - // total balances — customer flow is balance-neutral, so totalBalanceChf moves only on // operating profit, FX, or an error/realised loss (see BalancesTotal). Hence the guardrails below. const plusBalanceChf = Util.sumObjValue(Object.values(balancesByFinancialType), 'plusBalanceChf'); @@ -160,7 +157,13 @@ export class LogJobService { await this.processService.setSafetyModeActive(safetyModeActive); const lastLog = await this.logService.maxEntity('LogService', 'FinancialDataLog', LogSeverity.INFO, true); - const lastTotalBalance = (JSON.parse(lastLog.message) as FinanceLog).balancesTotal.totalBalanceChf; + const lastFinanceLog = JSON.parse(lastLog.message) as FinanceLog; + const lastTotalBalance = lastFinanceLog.balancesTotal.totalBalanceChf; + + // price effect (FX P&L) of the open positions since the previous snapshot; undefined on the first + // entry (no reference point). Pure arithmetic over already-parsed data (see getFxPnlChf), so it is + // deliberately left outside any try/catch — it cannot throw and a wrapping catch would only mask a bug. + const fxPnlChf = this.getFxPnlChf(lastFinanceLog, assetLog, assets); await this.logService.create({ system: 'LogService', @@ -177,6 +180,10 @@ export class LogJobService { plusBalanceChf: this.getJsonValue(plusBalanceChf, AmountType.FIAT, true, true), minusBalanceChf: this.getJsonValue(minusBalanceChf, AmountType.FIAT, true, true), totalBalanceChf: this.getJsonValue(totalBalanceChf, AmountType.FIAT, true, true), + // per-interval price effect vs. the previous snapshot, rounded like its neighbours (FIAT, + // returnNegativeValue so a negative drift stays numeric). Left undefined when there is no + // predecessor to diff against; JSON.stringify then drops the key (absence, not a false 0). + fxPnlChf: fxPnlChf === undefined ? undefined : this.getJsonValue(fxPnlChf, AmountType.FIAT, true, true), }, }), // jump vs. the last VALID entry (lastLog above), not the direct predecessor; must be @@ -190,14 +197,24 @@ export class LogJobService { category: null, }); - await this.logService.create({ - system: 'LogService', - subsystem: 'FinancialChangesLog', - severity: LogSeverity.INFO, - message: JSON.stringify({ changes: changeLog }), - valid: null, - category: null, - }); + // The changeLog feeds only the informative FinancialChangesLog and is independent of the equity + // path above, so it runs in its own try/catch: a reporting-price failure must not arm the equity + // safety mode; the equity path above has already run and set it correctly. On failure we log the + // error and omit this minute's changes entry (absence instead of a wrong value). + try { + const changeLog = await this.getChangeLog(); + + await this.logService.create({ + system: 'LogService', + subsystem: 'FinancialChangesLog', + severity: LogSeverity.INFO, + message: JSON.stringify({ changes: changeLog }), + valid: null, + category: null, + }); + } catch (e) { + this.logger.error("Failed to build the financial changes log; skipping this minute's changes entry", e); + } } catch (e) { await this.processService.setSafetyModeActive(true); throw e; @@ -240,6 +257,40 @@ export class LogJobService { }, {}); } + // Per-interval price effect (FX P&L) of the open book: for each asset that carried a net position in the + // previous snapshot, its previous net (plusBalance.total − minusBalance.total) times the change in its CHF + // price since then. Equity drifts on open positions while orders are in flight (case 2 in BalancesTotal); + // this isolates that FX component so ΔtotalBalanceChf can be split into transactional yield vs. FX vs. errors. + // + // Only assets with a financialType are counted (same filter as getBalancesByFinancialType). A position + // absent from EITHER snapshot contributes no price effect: new positions enter the book via flows (which + // are balance-neutral), not via FX, and a closed position has no mark left to move. Assets lacking a usable + // price on either side are likewise skipped, as no price effect is derivable. + // + // Returns undefined when there is no predecessor snapshot, so the caller omits the field instead of writing + // a false 0 for the very first entry. This is pure arithmetic over already-parsed data (the predecessor + // FinanceLog and the freshly built assetLog), so it cannot throw and deliberately carries no try/catch — a + // wrapping catch would only mask a logic error while adding nothing to the resilience of the log write. + private getFxPnlChf(prevFinanceLog: FinanceLog | undefined, assetLog: AssetLog, assets: Asset[]): number | undefined { + const prevAssets = prevFinanceLog?.assets; + if (!prevAssets) return undefined; + + return assets + .filter((a) => a.financialType) + .reduce((sum, asset) => { + const prev = prevAssets[asset.id]; + const now = assetLog[asset.id]; + if (!prev || !now) return sum; + + const pPrev = prev.priceChf; + const pNow = now.priceChf; + if (pPrev == null || pNow == null) return sum; + + const netPrev = (prev.plusBalance?.total ?? 0) - (prev.minusBalance?.total ?? 0); + return sum + netPrev * (pNow - pPrev); + }, 0); + } + // open referral-credit liability (EUR-denominated), booked as a synthetic financialType bucket so it // flows into minusBalanceChf/totalBalanceChf and reconciles like any other liability. Returns // undefined when nothing is owed, so no empty bucket is written. @@ -1073,6 +1124,18 @@ export class LogJobService { const binanceTxTradingFee = this.getFeeAmount( exchangeTx.filter((e) => e.exchange === ExchangeName.BINANCE && e.type === ExchangeTxType.TRADE), ); + const scryptTxWithdrawFee = this.getFeeAmount( + exchangeTx.filter((e) => e.exchange === ExchangeName.SCRYPT && e.type === ExchangeTxType.WITHDRAWAL), + ); + const scryptTxTradingFee = this.getFeeAmount( + exchangeTx.filter((e) => e.exchange === ExchangeName.SCRYPT && e.type === ExchangeTxType.TRADE), + ); + const mexcTxWithdrawFee = this.getFeeAmount( + exchangeTx.filter((e) => e.exchange === ExchangeName.MEXC && e.type === ExchangeTxType.WITHDRAWAL), + ); + const mexcTxTradingFee = this.getFeeAmount( + exchangeTx.filter((e) => e.exchange === ExchangeName.MEXC && e.type === ExchangeTxType.TRADE), + ); const cryptoInputFee = await this.payInService.getPayInFee(firstDayOfMonth); const refRewards = await this.refRewardService.getRefRewardVolume(firstDayOfMonth); const payoutOrderRefFee = this.getFeeAmount( @@ -1082,6 +1145,8 @@ export class LogJobService { const totalKrakenFee = krakenTxWithdrawFee + krakenTxTradingFee; const totalBinanceFee = binanceTxWithdrawFee + binanceTxTradingFee; + const totalScryptFee = scryptTxWithdrawFee + scryptTxTradingFee; + const totalMexcFee = mexcTxWithdrawFee + mexcTxTradingFee; const totalRefReward = refRewards + payoutOrderRefFee; const totalTxFee = cryptoInputFee + payoutOrderFee; @@ -1089,7 +1154,14 @@ export class LogJobService { // total amounts const totalPlus = buyCryptoFee + buyFiatFee + paymentLinkFee + tradingOrderProfit; - const totalMinus = bankTxFee + totalKrakenFee + totalBinanceFee + totalRefReward + totalBlockchainFee; + const totalMinus = + bankTxFee + + totalKrakenFee + + totalBinanceFee + + totalScryptFee + + totalMexcFee + + totalRefReward + + totalBlockchainFee; return { total: totalPlus - totalMinus, @@ -1117,6 +1189,20 @@ export class LogJobService { trading: binanceTxTradingFee || undefined, } : undefined, + scrypt: totalScryptFee + ? { + total: totalScryptFee, + withdraw: scryptTxWithdrawFee || undefined, + trading: scryptTxTradingFee || undefined, + } + : undefined, + mexc: totalMexcFee + ? { + total: totalMexcFee, + withdraw: mexcTxWithdrawFee || undefined, + trading: mexcTxTradingFee || undefined, + } + : undefined, blockchain: totalBlockchainFee ? { total: totalBlockchainFee, diff --git a/src/subdomains/supporting/log/log.repository.ts b/src/subdomains/supporting/log/log.repository.ts index a9d566cd86..630b1d03a8 100644 --- a/src/subdomains/supporting/log/log.repository.ts +++ b/src/subdomains/supporting/log/log.repository.ts @@ -61,6 +61,17 @@ export class LogRepository extends BaseRepository { }); } + // The last `count` VALID FinancialDataLog snapshots, newest first. Used by the ledger equity-parity check to build + // a median baseline that is robust against the transient ±snapshot-skew spikes the FinancialDataLog carries + // (valid=false spikes are already excluded here); see BalancesTotal (log.dto.ts, case 4). + async getLatestValidFinancialLogs(count: number): Promise { + return this.find({ + where: { system: 'LogService', subsystem: 'FinancialDataLog', severity: LogSeverity.INFO, valid: true }, + order: { id: 'DESC' }, + take: count, + }); + } + async getLatestFinancialChangesLog(): Promise { return this.findOne({ where: { system: 'LogService', subsystem: 'FinancialChangesLog', severity: LogSeverity.INFO }, diff --git a/src/subdomains/supporting/log/log.service.ts b/src/subdomains/supporting/log/log.service.ts index 50593dd256..897fcb1bab 100644 --- a/src/subdomains/supporting/log/log.service.ts +++ b/src/subdomains/supporting/log/log.service.ts @@ -51,6 +51,10 @@ export class LogService { return { affected }; } + async getLog(id: number): Promise { + return this.logRepo.findOneBy({ id }); + } + async maxEntity(system: string, subsystem: string, severity: LogSeverity, valid?: boolean): Promise { return this.logRepo.findOne({ where: { system, subsystem, severity, valid }, order: { id: 'DESC' } }); } @@ -63,6 +67,10 @@ export class LogService { return this.logRepo.getLatestFinancialLog(); } + async getLatestValidFinancialLogs(count: number): Promise { + return this.logRepo.getLatestValidFinancialLogs(count); + } + async getLatestFinancialChangesLog(): Promise { return this.logRepo.getLatestFinancialChangesLog(); } diff --git a/src/subdomains/supporting/notification/enums/index.ts b/src/subdomains/supporting/notification/enums/index.ts index e5facffbd5..acdeea9179 100644 --- a/src/subdomains/supporting/notification/enums/index.ts +++ b/src/subdomains/supporting/notification/enums/index.ts @@ -53,6 +53,10 @@ export enum MailContext { EMAIL_VERIFICATION = 'EmailVerification', RECOMMENDATION_MAIL = 'RecommendationMail', RECOMMENDATION_CONFIRMATION = 'RecommendationConfirmation', + LEDGER_RECONCILIATION = 'LedgerReconciliation', + LEDGER_SUSPENSE = 'LedgerSuspense', + LEDGER_TRANSIT_OVERDUE = 'LedgerTransitOverdue', + LEDGER_EQUITY_PARITY = 'LedgerEquityParity', } export enum MailContextType { @@ -110,4 +114,8 @@ export const MailContextTypeMapper: { [MailContext.PAYOUT]: null, [MailContext.PRICING]: null, [MailContext.LIQUIDITY_MANAGEMENT]: null, + [MailContext.LEDGER_RECONCILIATION]: null, + [MailContext.LEDGER_SUSPENSE]: null, + [MailContext.LEDGER_TRANSIT_OVERDUE]: null, + [MailContext.LEDGER_EQUITY_PARITY]: null, }; diff --git a/src/subdomains/supporting/payment/dto/transaction-helper/quote-error.enum.ts b/src/subdomains/supporting/payment/dto/transaction-helper/quote-error.enum.ts index 1264d0cf91..c601db1857 100644 --- a/src/subdomains/supporting/payment/dto/transaction-helper/quote-error.enum.ts +++ b/src/subdomains/supporting/payment/dto/transaction-helper/quote-error.enum.ts @@ -14,6 +14,7 @@ export enum QuoteError { RECOMMENDATION_REQUIRED = 'RecommendationRequired', EMAIL_REQUIRED = 'EmailRequired', PRIMARY_EMAIL_REQUIRED = 'PrimaryEmailRequired', + PRIMARY_EMAIL_NOT_CONFIRMED = 'PrimaryEmailNotConfirmed', COUNTRY_NOT_ALLOWED = 'CountryNotAllowed', ASSET_UNSUPPORTED = 'AssetUnsupported', CURRENCY_UNSUPPORTED = 'CurrencyUnsupported', diff --git a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts index db7ad7f339..30d3969a55 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts @@ -528,8 +528,18 @@ describe('RealUnitService', () => { userData: { mail, kycLevel: KycLevel.LEVEL_30 }, }); + const buildRegistration = (overrides: Partial = {}): AktionariatRegistration => + ({ + requiresEmailConfirmation: false, + confirmedDate: undefined, + ...overrides, + }) as AktionariatRegistration; + beforeEach(() => { - jest.spyOn(service, 'hasRegistrationForWallet').mockResolvedValue(true); + jest.spyOn(service as any, 'findRegistration').mockResolvedValue({ + registration: buildRegistration(), + isForCurrentWallet: true, + }); jest.spyOn(service, 'getRealuAsset').mockResolvedValue(realuAsset); jest.spyOn(service as any, 'generatePaymentRequest').mockReturnValue('MOCK-QR'); fiatService.getFiatByName.mockResolvedValue({ name: 'CHF' } as any); @@ -547,7 +557,26 @@ describe('RealUnitService', () => { expect((service as any).generatePaymentRequest).not.toHaveBeenCalled(); }); - it('leaves a valid quote untouched when the user has a primary email', async () => { + it('prefers PrimaryEmailRequired over PrimaryEmailNotConfirmed when the email is missing AND unconfirmed', async () => { + (service as any).findRegistration.mockResolvedValue({ + registration: buildRegistration({ requiresEmailConfirmation: true, confirmedDate: undefined }), + isForCurrentWallet: true, + }); + buyService.toPaymentInfoDto.mockResolvedValue(buildBuyPaymentInfo({ isValid: true, error: undefined })); + + const result = await service.getPaymentInfo(buildUser(undefined), { amount: 100 }); + + expect(result.isValid).toBe(false); + expect(result.error).toBe(QuoteError.PRIMARY_EMAIL_REQUIRED); + expect(result.paymentRequest).toBeUndefined(); + }); + + it('returns a valid quote when an email-confirmation registration has a confirmedDate', async () => { + const confirmedDate = new Date('2026-06-01T00:00:00.000Z'); + (service as any).findRegistration.mockResolvedValue({ + registration: buildRegistration({ requiresEmailConfirmation: true, confirmedDate }), + isForCurrentWallet: true, + }); buyService.toPaymentInfoDto.mockResolvedValue(buildBuyPaymentInfo({ isValid: true, error: undefined })); const result = await service.getPaymentInfo(buildUser('max@example.com'), { amount: 100 }); @@ -557,6 +586,34 @@ describe('RealUnitService', () => { expect(result.paymentRequest).toBe('MOCK-QR'); }); + it('returns a valid quote for a grandfathered registration without a confirmedDate', async () => { + (service as any).findRegistration.mockResolvedValue({ + registration: buildRegistration({ requiresEmailConfirmation: false, confirmedDate: undefined }), + isForCurrentWallet: true, + }); + buyService.toPaymentInfoDto.mockResolvedValue(buildBuyPaymentInfo({ isValid: true, error: undefined })); + + const result = await service.getPaymentInfo(buildUser('max@example.com'), { amount: 100 }); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('surfaces an unconfirmed registration email and withholds the payment request', async () => { + (service as any).findRegistration.mockResolvedValue({ + registration: buildRegistration({ requiresEmailConfirmation: true, confirmedDate: undefined }), + isForCurrentWallet: true, + }); + buyService.toPaymentInfoDto.mockResolvedValue(buildBuyPaymentInfo({ isValid: true, error: undefined })); + + const result = await service.getPaymentInfo(buildUser('max@example.com'), { amount: 100 }); + + expect(result.isValid).toBe(false); + expect(result.error).toBe(QuoteError.PRIMARY_EMAIL_NOT_CONFIRMED); + expect(result.paymentRequest).toBeUndefined(); + expect((service as any).generatePaymentRequest).not.toHaveBeenCalled(); + }); + it('passes a pre-existing quote error through unchanged when the user has a primary email', async () => { buyService.toPaymentInfoDto.mockResolvedValue( buildBuyPaymentInfo({ isValid: false, error: QuoteError.AMOUNT_TOO_LOW }), @@ -570,6 +627,10 @@ describe('RealUnitService', () => { }); it('prefers a harder pre-existing quote error over the missing-primary-email signal', async () => { + (service as any).findRegistration.mockResolvedValue({ + registration: buildRegistration({ requiresEmailConfirmation: true, confirmedDate: undefined }), + isForCurrentWallet: true, + }); buyService.toPaymentInfoDto.mockResolvedValue( buildBuyPaymentInfo({ isValid: false, error: QuoteError.AMOUNT_TOO_LOW }), ); @@ -582,7 +643,7 @@ describe('RealUnitService', () => { }); it('rejects the buy with RegistrationRequiredException when the wallet is not RealUnit-registered', async () => { - jest.spyOn(service, 'hasRegistrationForWallet').mockResolvedValue(false); + (service as any).findRegistration.mockResolvedValue({ registration: undefined, isForCurrentWallet: false }); await expect(service.getPaymentInfo(buildUser('max@example.com'), { amount: 100 })).rejects.toBeInstanceOf( RegistrationRequiredException, @@ -2553,6 +2614,8 @@ describe('RealUnitService', () => { expect(result.confirmedAddresses).toEqual([walletA]); expect(result.confirmedDate).toBeInstanceOf(Date); expect(httpService.getRaw).not.toHaveBeenCalled(); + const audit = (logService.create as jest.Mock).mock.calls.find((c) => c[0].category === 'ServerCall')[0]; + expect(audit.severity).toBe(LogSeverity.INFO); // the latch is set on the active registration row and saved through the transactional manager expect(aktionariatTxManager.save).toHaveBeenCalledWith( expect.objectContaining({ active: true, confirmedDate: expect.any(Date) }), @@ -2620,8 +2683,8 @@ describe('RealUnitService', () => { const result = await service.confirmAktionariat({ email, code, user }, rawRequest); - // the call still resolves (the code was valid at Aktionariat) and returns an empty confirmed list - expect(result.status).toBe(RealUnitAktionariatConfirmationStatus.CONFIRMED); + // the call still resolves, but reports that no local registration could be confirmed + expect(result.status).toBe(RealUnitAktionariatConfirmationStatus.CONFIRMED_NO_REGISTRATION); expect(result.confirmedAddresses).toEqual([]); expect(result.confirmedDate).toBeUndefined(); // NO registration was touched — no latch transaction ran @@ -2636,7 +2699,9 @@ describe('RealUnitService', () => { expect(msg.walletAddresses).toEqual([]); expect(msg.email).toBe(email); expect(msg.user).toBe(user); - expect((service as any).logger.warn).toHaveBeenCalled(); + const audit = (logService.create as jest.Mock).mock.calls.find((c) => c[0].category === 'ServerCall')[0]; + expect(audit.severity).toBe(LogSeverity.ERROR); + expect((service as any).logger.error).toHaveBeenCalled(); }); it('masks an email without an @ sign without crashing', async () => { @@ -2644,7 +2709,7 @@ describe('RealUnitService', () => { const result = await service.confirmAktionariat({ email: 'no-at-sign', code, user }, rawRequest); - expect(result.status).toBe(RealUnitAktionariatConfirmationStatus.CONFIRMED); + expect(result.status).toBe(RealUnitAktionariatConfirmationStatus.CONFIRMED_NO_REGISTRATION); }); it('calls the real Aktionariat endpoint and maps a 2xx to confirmed', async () => { diff --git a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts index 0714f724d9..cb79fcf70b 100644 --- a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts +++ b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts @@ -835,8 +835,9 @@ export class RealUnitController { 'Public endpoint called from realunit.app/confirm-aktionariat when the user opens the email link. ' + 'Server-side confirms the connection at Aktionariat using the provided code (which acts as the auth ' + 'token) and documents the outcome per RealUnit-registered wallet. Returns the mapped state: ' + - '`confirmed` (Aktionariat accepted), `invalid` (link invalid/expired), or `unavailable` (Aktionariat ' + - 'unreachable — retry later).', + '`confirmed` (Aktionariat accepted), `confirmed_no_registration` (Aktionariat accepted the email, but ' + + 'no RealUnit registration matched it — a permanent outcome, not a retry candidate), `invalid` (link ' + + 'invalid/expired), or `unavailable` (Aktionariat unreachable — retry later).', }) @ApiOkResponse({ type: RealUnitConfirmAktionariatDto }) async confirmAktionariat( diff --git a/src/subdomains/supporting/realunit/dto/realunit-confirm-aktionariat.dto.ts b/src/subdomains/supporting/realunit/dto/realunit-confirm-aktionariat.dto.ts index 1c05e1cfa2..665d8d3ad8 100644 --- a/src/subdomains/supporting/realunit/dto/realunit-confirm-aktionariat.dto.ts +++ b/src/subdomains/supporting/realunit/dto/realunit-confirm-aktionariat.dto.ts @@ -6,6 +6,10 @@ import { Util } from 'src/shared/utils/util'; export enum RealUnitAktionariatConfirmationStatus { // Aktionariat accepted the confirmation (HTTP 2xx). CONFIRMED = 'confirmed', + // Aktionariat accepted the confirmation (HTTP 2xx) but no local RealUnit registration matched the + // confirmed email — nothing could be unlocked on this side. The client should treat this as a hard + // failure, not a retry candidate. + CONFIRMED_NO_REGISTRATION = 'confirmed_no_registration', // The link is invalid or expired — Aktionariat rejected the code (HTTP 4xx). INVALID = 'invalid', // Aktionariat could not be reached or errored (HTTP 5xx / network / timeout). The client should diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index a989c1b195..bd6fc21326 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -510,9 +510,11 @@ export class RealUnitService { const currencyName = dto.currency ?? 'CHF'; // 1. Registration required - if (!(await this.hasRegistrationForWallet(userData, user.address))) { + const { registration, isForCurrentWallet } = await this.findRegistration(userData, user.address); + if (!isForCurrentWallet || !registration) { throw new RegistrationRequiredException(undefined, KycContext.REALUNIT_BUY); } + const { emailConfirmed } = this.resolveEmailConfirmation(registration); // 2. KYC Level check - Level 30 required for all RealUnit purchases const currency = await this.fiatService.getFiatByName(currencyName); @@ -542,15 +544,22 @@ export class RealUnitService { }), ); - // 5. Primary-email pre-tap gate: Aktionariat rejects the buy confirm when the user has no primary - // email. Surface it here as a pre-tap signal (isValid/error) so the client can route to the mail - // capture before tapping confirm, instead of bouncing off the reactive 400 in confirmBuy (which - // stays as a fail-closed backstop for the case the email disappears after this call). An existing - // quote error takes precedence — it may be a harder block (country/nationality/AML/limit) that no - // amount of email capture can resolve, so the mail gate only fills the error when none is present. + // 5. Primary-email and confirmation pre-tap gate: Aktionariat rejects the buy confirm when the user has + // no primary email or the registration email is still unconfirmed. Surface both here as pre-tap signals + // (isValid/error) so the client can route to mail capture or confirmation before tapping confirm, instead + // of bouncing off the reactive 400 in confirmBuy (which stays as a fail-closed backstop for the case the + // email disappears after this call). An existing quote error takes precedence — it may be a harder block + // (country/nationality/AML/limit) that no amount of email capture or confirmation can resolve, so the + // email gates only fill the error when none is present. const hasPrimaryEmail = !!userData.mail; - const isValid = buyPaymentInfo.isValid && hasPrimaryEmail; - const error = buyPaymentInfo.error ?? (hasPrimaryEmail ? undefined : QuoteError.PRIMARY_EMAIL_REQUIRED); + const isValid = buyPaymentInfo.isValid && hasPrimaryEmail && emailConfirmed; + const error = + buyPaymentInfo.error ?? + (!hasPrimaryEmail + ? QuoteError.PRIMARY_EMAIL_REQUIRED + : !emailConfirmed + ? QuoteError.PRIMARY_EMAIL_NOT_CONFIRMED + : undefined); // 6. Override recipient info with RealUnit company address const { bank: realunitBank, address: realunitAddress } = GetConfig().blockchain.realunit; @@ -2138,8 +2147,8 @@ export class RealUnitService { /** * Confirms an Aktionariat email connection from the public confirm-aktionariat endpoint. The * `code` is the authentication token; no api-key is sent. The Aktionariat response is mapped to - * three states (confirmed / invalid / unavailable) and the outcome is documented per registered - * wallet address. + * four states (confirmed / confirmed_no_registration / invalid / unavailable) and the outcome is + * documented per registered wallet address. * * ASSUMPTION: the Aktionariat confirmation is keyed on the email and therefore applies to ALL * wallets that were RealUnit-registered under that email (the aktionariat_registration rows resolved by @@ -2160,12 +2169,22 @@ export class RealUnitService { this.logger.info( `Resolved ${walletAddresses.length} RealUnit wallet(s) for ${maskedEmail}: ${walletAddresses.join(', ')}`, ); - } else { - this.logger.warn(`No RealUnit registration wallet found for ${maskedEmail}`); } const { httpStatus, responseBody, error } = await this.callAktionariatConfirm(email, code, user); - const status = this.mapConfirmationStatus(httpStatus); + const mappedStatus = this.mapConfirmationStatus(httpStatus); + const noRegistrationMatch = + !walletAddresses.length && mappedStatus === RealUnitAktionariatConfirmationStatus.CONFIRMED; + const status = noRegistrationMatch ? RealUnitAktionariatConfirmationStatus.CONFIRMED_NO_REGISTRATION : mappedStatus; + + if (!walletAddresses.length) { + const message = `No RealUnit registration wallet found for ${maskedEmail}`; + if (noRegistrationMatch) { + this.logger.error(message); + } else { + this.logger.warn(message); + } + } this.logger.info( `Aktionariat confirmation for ${maskedEmail} mapped to '${status}' (httpStatus: ${httpStatus ?? 'none'})`, @@ -2173,7 +2192,7 @@ export class RealUnitService { const confirmed = status === RealUnitAktionariatConfirmationStatus.CONFIRMED; // Severity of the DB audit row: a confirmed call is INFO, an invalid/expired link is a benign WARNING, - // an unavailable Aktionariat is an ERROR (a system fault to alert on). + // and every other outcome is an ERROR. const logSeverity = status === RealUnitAktionariatConfirmationStatus.CONFIRMED ? LogSeverity.INFO