From 4d15281135b1e1dea1a4cd4afb1655f1a05b76a8 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 12 Jun 2026 08:24:55 +0200 Subject: [PATCH 01/25] feat(accounting): add append-only double-entry ledger (monitoring-only) - new ledger_account/ledger_tx/ledger_leg tables (new-tables-only migration, integer-cent balance check, append-only with explicit reversals) - chart-of-accounts bootstrap and idempotent booking engine with consumers for bank, exchange, payin, payout, buy/sell, liquidity and trading sources - valuation from already-persisted CHF fields and snapshot marks, no new external or pricing calls (static isolation gate + DB write isolation test) - one-time cutover opening, daily mark-to-market and per-account reconciliation with staleness guards, transit-age and suspense alarms - read-only ADMIN endpoints for account balances, ledger detail, reconciliation status, suspense and realized-margin reporting - all crons gated by dedicated process flags (kill switch); existing financial log and safety module remain unchanged and authoritative --- migration/1781235331368-AddLedgerTables.js | 77 +++ scripts/ledger-isolation-gate.js | 85 +++ scripts/ledger-isolation-gate.sh | 70 +++ src/config/config.ts | 10 + src/shared/services/process.service.ts | 13 + .../core/accounting/accounting.module.ts | 95 +++ .../controllers/ledger.controller.ts | 75 +++ .../dto/__tests__/ledger-dto.mapper.spec.ts | 184 ++++++ .../core/accounting/dto/ledger-account.dto.ts | 53 ++ .../core/accounting/dto/ledger-dto.mapper.ts | 111 ++++ .../core/accounting/dto/ledger-margin.dto.ts | 44 ++ .../core/accounting/dto/ledger-query.dto.ts | 61 ++ .../dto/ledger-reconciliation.dto.ts | 37 ++ .../__mocks__/ledger-account.entity.mock.ts | 17 + .../__mocks__/ledger-leg.entity.mock.ts | 16 + .../__mocks__/ledger-tx.entity.mock.ts | 19 + .../entities/ledger-account.entity.ts | 39 ++ .../accounting/entities/ledger-leg.entity.ts | 41 ++ .../accounting/entities/ledger-tx.entity.ts | 44 ++ .../repositories/ledger-account.repository.ts | 11 + .../repositories/ledger-leg.repository.ts | 11 + .../repositories/ledger-tx.repository.ts | 11 + .../db-write-isolation.integration.spec.ts | 287 +++++++++ .../evidence-week.integration.spec.ts | 471 +++++++++++++++ .../__tests__/integration/in-memory-ledger.ts | 220 +++++++ .../integration/isolation-gate.spec.ts | 125 ++++ .../staleness-cutover.integration.spec.ts | 304 ++++++++++ .../__tests__/ledger-account.service.spec.ts | 63 ++ .../ledger-booking-job.service.spec.ts | 177 ++++++ .../__tests__/ledger-booking.service.spec.ts | 234 ++++++++ .../ledger-bootstrap.service.spec.ts | 189 ++++++ .../__tests__/ledger-cutover.service.spec.ts | 350 +++++++++++ .../ledger-mark-to-market.service.spec.ts | 205 +++++++ .../__tests__/ledger-mark.service.spec.ts | 110 ++++ .../__tests__/ledger-query.service.spec.ts | 445 ++++++++++++++ .../ledger-reconciliation.service.spec.ts | 295 +++++++++ .../__tests__/bank-tx.consumer.spec.ts | 458 ++++++++++++++ .../__tests__/buy-crypto.consumer.spec.ts | 194 ++++++ .../__tests__/buy-fiat.consumer.spec.ts | 361 +++++++++++ .../__tests__/crypto-input.consumer.spec.ts | 289 +++++++++ .../__tests__/exchange-tx.consumer.spec.ts | 340 +++++++++++ .../__tests__/liquidity-mgmt.consumer.spec.ts | 286 +++++++++ .../liquidity-order-dex.consumer.spec.ts | 257 ++++++++ .../__tests__/payout-order.consumer.spec.ts | 348 +++++++++++ .../__tests__/trading-order.consumer.spec.ts | 241 ++++++++ .../services/consumers/bank-tx.consumer.ts | 472 +++++++++++++++ .../services/consumers/buy-crypto.consumer.ts | 193 ++++++ .../services/consumers/buy-fiat.consumer.ts | 382 ++++++++++++ .../consumers/crypto-input.consumer.ts | 212 +++++++ .../consumers/exchange-tx.consumer.ts | 408 +++++++++++++ .../consumers/ledger-watermark.helper.ts | 43 ++ .../consumers/liquidity-mgmt.consumer.ts | 194 ++++++ .../consumers/liquidity-order-dex.consumer.ts | 249 ++++++++ .../consumers/payout-order.consumer.ts | 344 +++++++++++ .../consumers/trading-order.consumer.ts | 178 ++++++ .../services/ledger-account.service.ts | 40 ++ .../services/ledger-booking-job.service.ts | 108 ++++ .../services/ledger-booking.service.ts | 177 ++++++ .../services/ledger-bootstrap.service.ts | 147 +++++ .../services/ledger-cutover.service.ts | 460 ++++++++++++++ .../services/ledger-mark-to-market.service.ts | 177 ++++++ .../services/ledger-mark.service.ts | 124 ++++ .../services/ledger-query.service.ts | 566 ++++++++++++++++++ .../services/ledger-reconciliation.service.ts | 342 +++++++++++ src/subdomains/core/core.module.ts | 2 + .../supporting/notification/enums/index.ts | 6 + 66 files changed, 12197 insertions(+) create mode 100644 migration/1781235331368-AddLedgerTables.js create mode 100644 scripts/ledger-isolation-gate.js create mode 100755 scripts/ledger-isolation-gate.sh create mode 100644 src/subdomains/core/accounting/accounting.module.ts create mode 100644 src/subdomains/core/accounting/controllers/ledger.controller.ts create mode 100644 src/subdomains/core/accounting/dto/__tests__/ledger-dto.mapper.spec.ts create mode 100644 src/subdomains/core/accounting/dto/ledger-account.dto.ts create mode 100644 src/subdomains/core/accounting/dto/ledger-dto.mapper.ts create mode 100644 src/subdomains/core/accounting/dto/ledger-margin.dto.ts create mode 100644 src/subdomains/core/accounting/dto/ledger-query.dto.ts create mode 100644 src/subdomains/core/accounting/dto/ledger-reconciliation.dto.ts create mode 100644 src/subdomains/core/accounting/entities/__mocks__/ledger-account.entity.mock.ts create mode 100644 src/subdomains/core/accounting/entities/__mocks__/ledger-leg.entity.mock.ts create mode 100644 src/subdomains/core/accounting/entities/__mocks__/ledger-tx.entity.mock.ts create mode 100644 src/subdomains/core/accounting/entities/ledger-account.entity.ts create mode 100644 src/subdomains/core/accounting/entities/ledger-leg.entity.ts create mode 100644 src/subdomains/core/accounting/entities/ledger-tx.entity.ts create mode 100644 src/subdomains/core/accounting/repositories/ledger-account.repository.ts create mode 100644 src/subdomains/core/accounting/repositories/ledger-leg.repository.ts create mode 100644 src/subdomains/core/accounting/repositories/ledger-tx.repository.ts create mode 100644 src/subdomains/core/accounting/services/__tests__/integration/db-write-isolation.integration.spec.ts create mode 100644 src/subdomains/core/accounting/services/__tests__/integration/evidence-week.integration.spec.ts create mode 100644 src/subdomains/core/accounting/services/__tests__/integration/in-memory-ledger.ts create mode 100644 src/subdomains/core/accounting/services/__tests__/integration/isolation-gate.spec.ts create mode 100644 src/subdomains/core/accounting/services/__tests__/integration/staleness-cutover.integration.spec.ts create mode 100644 src/subdomains/core/accounting/services/__tests__/ledger-account.service.spec.ts create mode 100644 src/subdomains/core/accounting/services/__tests__/ledger-booking-job.service.spec.ts create mode 100644 src/subdomains/core/accounting/services/__tests__/ledger-booking.service.spec.ts create mode 100644 src/subdomains/core/accounting/services/__tests__/ledger-bootstrap.service.spec.ts create mode 100644 src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts create mode 100644 src/subdomains/core/accounting/services/__tests__/ledger-mark-to-market.service.spec.ts create mode 100644 src/subdomains/core/accounting/services/__tests__/ledger-mark.service.spec.ts create mode 100644 src/subdomains/core/accounting/services/__tests__/ledger-query.service.spec.ts create mode 100644 src/subdomains/core/accounting/services/__tests__/ledger-reconciliation.service.spec.ts create mode 100644 src/subdomains/core/accounting/services/consumers/__tests__/bank-tx.consumer.spec.ts create mode 100644 src/subdomains/core/accounting/services/consumers/__tests__/buy-crypto.consumer.spec.ts create mode 100644 src/subdomains/core/accounting/services/consumers/__tests__/buy-fiat.consumer.spec.ts create mode 100644 src/subdomains/core/accounting/services/consumers/__tests__/crypto-input.consumer.spec.ts create mode 100644 src/subdomains/core/accounting/services/consumers/__tests__/exchange-tx.consumer.spec.ts create mode 100644 src/subdomains/core/accounting/services/consumers/__tests__/liquidity-mgmt.consumer.spec.ts create mode 100644 src/subdomains/core/accounting/services/consumers/__tests__/liquidity-order-dex.consumer.spec.ts create mode 100644 src/subdomains/core/accounting/services/consumers/__tests__/payout-order.consumer.spec.ts create mode 100644 src/subdomains/core/accounting/services/consumers/__tests__/trading-order.consumer.spec.ts create mode 100644 src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts create mode 100644 src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts create mode 100644 src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts create mode 100644 src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts create mode 100644 src/subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts create mode 100644 src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts create mode 100644 src/subdomains/core/accounting/services/consumers/liquidity-mgmt.consumer.ts create mode 100644 src/subdomains/core/accounting/services/consumers/liquidity-order-dex.consumer.ts create mode 100644 src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts create mode 100644 src/subdomains/core/accounting/services/consumers/trading-order.consumer.ts create mode 100644 src/subdomains/core/accounting/services/ledger-account.service.ts create mode 100644 src/subdomains/core/accounting/services/ledger-booking-job.service.ts create mode 100644 src/subdomains/core/accounting/services/ledger-booking.service.ts create mode 100644 src/subdomains/core/accounting/services/ledger-bootstrap.service.ts create mode 100644 src/subdomains/core/accounting/services/ledger-cutover.service.ts create mode 100644 src/subdomains/core/accounting/services/ledger-mark-to-market.service.ts create mode 100644 src/subdomains/core/accounting/services/ledger-mark.service.ts create mode 100644 src/subdomains/core/accounting/services/ledger-query.service.ts create mode 100644 src/subdomains/core/accounting/services/ledger-reconciliation.service.ts diff --git a/migration/1781235331368-AddLedgerTables.js b/migration/1781235331368-AddLedgerTables.js new file mode 100644 index 0000000000..7422e1e24d --- /dev/null +++ b/migration/1781235331368-AddLedgerTables.js @@ -0,0 +1,77 @@ +/** + * @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 `integer` (never bigint → JS string), 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" integer NOT NULL DEFAULT 0, "reversalOfId" integer, CONSTRAINT "UQ_86a66bea626f9a32e1d26a7b136" UNIQUE ("sourceType", "sourceId", "seq"), CONSTRAINT "CHK_dcc2c4dd65621661cdd1f0b370" 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" integer 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/scripts/ledger-isolation-gate.js b/scripts/ledger-isolation-gate.js new file mode 100644 index 0000000000..dff13b21c0 --- /dev/null +++ b/scripts/ledger-isolation-gate.js @@ -0,0 +1,85 @@ +#!/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'; + +// the 4-block forbidden pattern (§4.10) — kept char-for-char equivalent to the shell PATTERN +const PATTERN = new RegExp( + [ + 'pricingService|PricingService|getPrice\\(|getPriceAt|priceProvider|CoinGecko|HttpService', + '\\brefreshBalances\\(|\\brefreshBankBalance|\\bhasPendingOrders|integration\\.getBalances|integration\\.hasPendingOrders|BankAdapter|balanceIntegrationFactory|LiquidityBalanceIntegrationFactory', + '\\blogService\\.(create|update)\\(|\\bsettingService\\.(setObj|updateProcess|addIpToBlacklist|deleteIpFromBlacklist)\\(', + '\\.complete\\(|checkOrderCompletion|syncExchanges|doPayout|checkPayoutCompletionData|triggerWebhook|calculateSpreadFee', + 'balanceRepo\\.(update|save|insert|delete|remove)\\(|\\b(?!ledger)\\w*Repo(sitory)?\\.(update|save|insert|delete|remove)\\(', + '\\bmanager\\.(save|insert|update|delete|remove|upsert|softDelete|softRemove|recover)\\(|\\bmanager\\.query\\(', + ].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); +} + +for (const file of files) { + const lines = fs.readFileSync(file, 'utf8').split('\n'); + lines.forEach((line, i) => { + if (PATTERN.test(line) && !line.includes(ALLOWLIST_MARKER)) { + 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..c8f59a3b9f --- /dev/null +++ b/scripts/ledger-isolation-gate.sh @@ -0,0 +1,70 @@ +#!/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 Block-4b 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. +# +# 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)" + +# the 4-block forbidden pattern (§4.10); \b word-boundary anchors keep the method tokens injection-name-independent +PATTERN='pricingService|PricingService|getPrice\(|getPriceAt|priceProvider|CoinGecko|HttpService' +PATTERN+='|\brefreshBalances\(|\brefreshBankBalance|\bhasPendingOrders|integration\.getBalances|integration\.hasPendingOrders|BankAdapter|balanceIntegrationFactory|LiquidityBalanceIntegrationFactory' +PATTERN+='|\blogService\.(create|update)\(|\bsettingService\.(setObj|updateProcess|addIpToBlacklist|deleteIpFromBlacklist)\(' +PATTERN+='|\.complete\(|checkOrderCompletion|syncExchanges|doPayout|checkPayoutCompletionData|triggerWebhook|calculateSpreadFee' +PATTERN+='|balanceRepo\.(update|save|insert|delete|remove)\(|\b(?!ledger)\w*Repo(sitory)?\.(update|save|insert|delete|remove)\(' +PATTERN+='|\bmanager\.(save|insert|update|delete|remove|upsert|softDelete|softRemove|recover)\(|\bmanager\.query\(' + +# 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__') + +run_grep() { # $1 = grep binary + "$1" -rPn "${EXCLUDES[@]}" "$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="$(rg --pcre2 -n -g '*.ts' -g '!*.spec.ts' -g '!__tests__' -g '!__mocks__' "$PATTERN" "$TARGET_DIR" 2>/dev/null | grep -v 'ledger-allowlist')" +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 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 54ea8ebc99..0e065700e1 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -115,6 +115,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 90e2ae883f..85d9d663ca 100644 --- a/src/shared/services/process.service.ts +++ b/src/shared/services/process.service.ts @@ -96,6 +96,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_LIQ_MGMT = 'LedgerBookingLiqMgmt', + 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/accounting.module.ts b/src/subdomains/core/accounting/accounting.module.ts new file mode 100644 index 0000000000..9d0bc70fb1 --- /dev/null +++ b/src/subdomains/core/accounting/accounting.module.ts @@ -0,0 +1,95 @@ +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 { 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 { Bank } from 'src/subdomains/supporting/bank/bank/bank.entity'; +import { BankTx } from 'src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.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) + ]), + 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) + ], + 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/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..61f6de5b13 --- /dev/null +++ b/src/subdomains/core/accounting/dto/__tests__/ledger-dto.mapper.spec.ts @@ -0,0 +1,184 @@ +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 { + AccountBalance, + AccountReconResult, + AccountReconSnapshot, + LedgerDtoMapper, + SuspenseLegRow, +} from '../ledger-dto.mapper'; + +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: '68310', + 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: 'ok', + reconDiff: 0.4, + lastVerified: new Date('2026-06-10T05:00:00.000Z'), + }; + + const dto = LedgerDtoMapper.mapAccountBalance(balance, recon); + + expect(dto.reconStatus).toBe('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('68310'); + 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: 'fresh', + status: '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: 'fresh', + status: '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: 'missing', + status: 'unverified', + }); + + expect(dto.feedTimestamp).toBeUndefined(); + expect(dto.feedAge).toBeUndefined(); + expect(dto.staleness).toBe('missing'); + }); + }); + + describe('mapSuspenseLeg', () => { + it('maps a suspense leg with its account currency and age', () => { + const account = createCustomLedgerAccount({ + id: 3, + name: 'SUSPENSE/untracked-bank-Raiffeisen-EUR', + type: AccountType.SUSPENSE, + currency: 'EUR', + }); + const entry = leg({ id: 2, amount: 600000, amountChf: 580000, 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(600000); + expect(dto.amountChf).toBe(580000); + expect(dto.age).toBe(12); + expect(dto.bookingDate).toBe('2026-06-07T00:00:00.000Z'); + }); + }); +}); 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..4465398a1f --- /dev/null +++ b/src/subdomains/core/accounting/dto/ledger-account.dto.ts @@ -0,0 +1,53 @@ +import { AccountType } from '../entities/ledger-account.entity'; + +export type LedgerReconStatus = 'ok' | 'diff' | 'stale' | 'unverified' | '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..031199731a --- /dev/null +++ b/src/subdomains/core/accounting/dto/ledger-reconciliation.dto.ts @@ -0,0 +1,37 @@ +export type LedgerFeedStaleness = 'fresh' | 'stale' | 'missing' | 'placeholder'; +export type LedgerReconResultStatus = 'ok' | 'diff' | 'stale' | 'unverified' | 'suspense_alarm'; + +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/ledger-account.entity.ts b/src/subdomains/core/accounting/entities/ledger-account.entity.ts new file mode 100644 index 0000000000..84fd858a2f --- /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, eager:false) + @Index() + @ManyToOne(() => Asset, { nullable: true, eager: false }) + @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-leg.entity.ts b/src/subdomains/core/accounting/entities/ledger-leg.entity.ts new file mode 100644 index 0000000000..d0c7571cb3 --- /dev/null +++ b/src/subdomains/core/accounting/entities/ledger-leg.entity.ts @@ -0,0 +1,41 @@ +import { IEntity } from 'src/shared/models/entity'; +import { Column, Entity, Index, JoinColumn, ManyToOne, RelationId } from 'typeorm'; +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, eager: false }) + @JoinColumn() + tx: LedgerTx; + + @RelationId((leg: LedgerLeg) => leg.tx) + txId: number; + + @Index() + @ManyToOne(() => LedgerAccount, { nullable: false, eager: 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 integer, never bigint → JS string, see §2-header) + @Column({ type: 'int', default: 0 }) + 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..f556b389fc --- /dev/null +++ b/src/subdomains/core/accounting/entities/ledger-tx.entity.ts @@ -0,0 +1,44 @@ +import { IEntity } from 'src/shared/models/entity'; +import { Check, Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, RelationId, Unique } from 'typeorm'; +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, eager: false }) + @JoinColumn() + reversalOf?: LedgerTx; + + @RelationId((tx: LedgerTx) => tx.reversalOf) + reversalOfId?: number; + + // integer cents (PostgreSQL integer, never bigint → JS string, see §2-header); always 0 per tx + @Column({ type: 'int', default: 0 }) + amountChfSum: number; + + @OneToMany(() => LedgerLeg, (leg) => leg.tx) + legs: LedgerLeg[]; +} 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/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..4735285323 --- /dev/null +++ b/src/subdomains/core/accounting/services/__tests__/integration/db-write-isolation.integration.spec.ts @@ -0,0 +1,287 @@ +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 { 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'] 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-Ausnahme-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; + + 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)); + }); + + // a source-table repository whose ALL write methods are spied on (must never be called by the consumer) + function readOnlyRepo(rows: T[]): { repo: Repository; writeSpies: jest.SpyInstance[] } { + const repo = createMock>(); + jest.spyOn(repo, 'find').mockResolvedValue(rows as any); + const writeSpies = WRITE_METHODS.map((m) => jest.spyOn(repo as any, m)); + return { repo, writeSpies }; + } + + function settingService(): { service: SettingService; setKeys: string[] } { + const service = createMock(); + const setKeys: string[] = []; + jest.spyOn(service, 'getObj').mockResolvedValue(undefined); + 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(); + + 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); + + // NO write method of the crypto_input / buy_fiat source repos was ever called (strict read-only observer) + for (const spy of [...ciSrc.writeSpies, ...bfSrc.writeSpies]) { + expect(spy).not.toHaveBeenCalled(); + } + + // the ONLY non-ledger_* writes are the two ledger watermark settings (sanctioned R2-Ausnahme-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'); + + // 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, + ); + + 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, 'getLatestFinancialLog').mockResolvedValue(undefined); + 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, + ); + + 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..14a85ed62e --- /dev/null +++ b/src/subdomains/core/accounting/services/__tests__/integration/evidence-week.integration.spec.ts @@ -0,0 +1,471 @@ +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 { 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 Integrationstests = 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 tripel, 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, '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'), + 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, + 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 + function bankTxConsumer(rows: BankTx[], banks: Bank[] = []): BankTxConsumer { + const bankTxRepo = createMock>(); + jest.spyOn(bankTxRepo, 'find').mockResolvedValue(rows); + const bankRepo = createMock>(); + jest + .spyOn(bankRepo, 'findOne') + .mockImplementation(({ where }: any) => Promise.resolve(banks.find((b) => b.iban === where.iban) ?? null)); + return new BankTxConsumer( + settingService(), + ledger.bookingService, + ledger.accountService, + markService, + bankTxRepo, + bankRepo, + ); + } + + // 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) + // 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 GENAU 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(); + + // GENAU 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, the exchange sweep pushes it back down', async () => { + // generic untracked-bank rule (no Bank row matches the iban) → SUSPENSE/untracked-bank-{name}-{ccy} (§4.2/§1.6). + // The credit's value lands in SUSPENSE as the native EUR custody amount (the SUSPENSE account has no asset row, + // so the consumer cannot mark-value it — the CHF side flows to fx-revaluation; the unambiguous Class-4 evidence + // is the NATIVE SUSPENSE balance + the fully-counted received liability). The exchange deposit then sweeps it. + const credit = bankTx({ + id: 1, + type: BankTxType.BUY_CRYPTO, + creditDebitIndicator: BankTxIndicator.CREDIT, + currency: 'EUR', + amount: 1000, + bankName: 'Raiffeisen', + accountIban: 'SYNTH-UNTRACKED-IBAN', + buyCrypto: { amountInChf: 950 } as any, + }); + 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('LIABILITY/buyCrypto-received')).toBe(-950); // Cr received fully counted (Class-4 fix) + + // 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'); // integer type, never a bigint string (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); + }); +}); 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..b79520035b --- /dev/null +++ b/src/subdomains/core/accounting/services/__tests__/integration/in-memory-ledger.ts @@ -0,0 +1,220 @@ +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 countBy/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>(); + + jest.spyOn(repo, 'countBy').mockImplementation((where: any) => { + return Promise.resolve( + 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), + ).length, + ); + }); + + 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; + } + + 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) => + entity === LedgerTx ? Object.assign(new LedgerTx(), p) : 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 = 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; + }); + return (arg as (m: EntityManager) => unknown)(manager) as any; + }); + + jest.spyOn(dataSource, 'getRepository').mockReturnValue({ + createQueryBuilder: () => this.nextSeqQueryBuilder(), + } 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..9d242815c4 --- /dev/null +++ b/src/subdomains/core/accounting/services/__tests__/integration/isolation-gate.spec.ts @@ -0,0 +1,125 @@ +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 });' }, + ]; + + 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);', + }, + ]; + + 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 + }); +}); 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..a3fd1ad283 --- /dev/null +++ b/src/subdomains/core/accounting/services/__tests__/integration/staleness-cutover.integration.spec.ts @@ -0,0 +1,304 @@ +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 { LiquidityManagementBalanceService } from 'src/subdomains/core/liquidity-management/services/liquidity-management-balance.service'; +import { BuyFiat } from 'src/subdomains/core/sell-crypto/process/buy-fiat.entity'; +import { BankTx } from 'src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.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 (journalNativeBalance getRawOne) + + 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, 'getLatestFinancialLog').mockResolvedValue(undefined); + + 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 }, + ], + }).compile(); + + service = module.get(LedgerReconciliationService); + }); + + 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; + 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; + 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 + jest + .spyOn(settingService, 'get') + .mockImplementation((key: string) => + Promise.resolve(key === 'ledgerCutoverLogId' ? (cutoverFlag as any) : '0'), + ); + jest.spyOn(settingService, 'set').mockImplementation((key: string, value: string) => { + if (key === 'ledgerCutoverLogId') cutoverFlag = value; + return Promise.resolve(); + }); + jest.spyOn(settingService, 'getObj').mockResolvedValue([] as any); + + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog()]); + + // 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, 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(CryptoInput), useValue: emptyRepo() }, + { provide: getRepositoryToken(ExchangeTx), useValue: emptyRepo() }, + { provide: getRepositoryToken(PayoutOrder), 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) + }); + }); +}); 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..19c4b7ac74 --- /dev/null +++ b/src/subdomains/core/accounting/services/__tests__/ledger-account.service.spec.ts @@ -0,0 +1,63 @@ +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 + }); + + 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 } }); + }); +}); 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..a8545182c5 --- /dev/null +++ b/src/subdomains/core/accounting/services/__tests__/ledger-booking-job.service.spec.ts @@ -0,0 +1,177 @@ +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_LIQ_MGMT, + 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' } 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'); + }); + + 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-Ausnahme-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'), + }); + expect(setSpy).toHaveBeenCalledWith( + 'ledgerWatermark.crypto_input', + JSON.stringify({ lastProcessedId: 7, lastReversalScan: '2026-06-02T00:00:00.000Z' }), + ); + }); + }); +}); 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..8b15eaac88 --- /dev/null +++ b/src/subdomains/core/accounting/services/__tests__/ledger-booking.service.spec.ts @@ -0,0 +1,234 @@ +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; + }); + 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(); + }); + + 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'); // integer type guarantee (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(2); // MAX(seq)=1 → next 2 (§4.12 monotone) + expect(reversal.reversalOf).toBe(original); + expect(reversal.amountChfSum).toBe(0); + expect(savedLegs.map((l) => l.amountChfCents)).toEqual([-5000000, 5000000]); // inverted + }); +}); 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..6754f638b0 --- /dev/null +++ b/src/subdomains/core/accounting/services/__tests__/ledger-bootstrap.service.spec.ts @@ -0,0 +1,189 @@ +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 — findOrCreate per account (re-run no-op via UNIQUE(name))', async () => { + jest.spyOn(assetService, 'getAssetsWith').mockResolvedValue([]); + jest.spyOn(liquidityManagementBalanceService, 'getBalances').mockResolvedValue([]); + + await service.bootstrap(); + const firstRunCount = (ledgerAccountService.findOrCreate as jest.Mock).mock.calls.length; + + // findOrCreate guarantees no duplicate names; a re-run resolves existing accounts + expect(firstRunCount).toBeGreaterThan(0); + expect(new Set(created.map((c) => c.name)).size).toBe(created.length); // no duplicate names + }); +}); 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..4c10ecb218 --- /dev/null +++ b/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts @@ -0,0 +1,350 @@ +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 { 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 { BankTx } from 'src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity'; +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 { 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 cryptoInputRepo: Repository; + let exchangeTxRepo: Repository; + let payoutOrderRepo: 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, + }); + } + + beforeEach(async () => { + booked = []; + nextSeqByKey = new Map(); + + settingService = createMock(); + logService = createMock(); + bootstrapService = createMock(); + bookingService = createMock(); + accountService = createMock(); + markService = createMock(); + buyFiatRepo = createMock>(); + buyCryptoRepo = createMock>(); + bankTxRepo = createMock>(); + cryptoInputRepo = createMock>(); + exchangeTxRepo = createMock>(); + payoutOrderRepo = 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(settingService, 'getObj').mockResolvedValue([] as any); + jest.spyOn(markService, 'preload').mockResolvedValue(new LedgerMarkCache(new Map())); + + // watermark MAX(id) query builder stub + const maxQb: any = { + select: () => maxQb, + where: () => maxQb, + getRawOne: () => Promise.resolve({ max: 0 }), + }; + for (const repo of [bankTxRepo, cryptoInputRepo, exchangeTxRepo, payoutOrderRepo, buyCryptoRepo, buyFiatRepo]) { + 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(CryptoInput), useValue: cryptoInputRepo }, + { provide: getRepositoryToken(ExchangeTx), useValue: exchangeTxRepo }, + { provide: getRepositoryToken(PayoutOrder), useValue: payoutOrderRepo }, + ], + }).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); + }); + }); + + describe('failure-isolation (§6.3)', () => { + it('catches 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()).resolves.toBeUndefined(); + + 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) + }); + + 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 + }); + + it('opens buyCrypto-owed CHF = outputAmount × getMarkAt(outputAsset), needsMark when feedless (R6-1)', 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 })]); + } + return Promise.resolve([]); + }); + + await service.run(); + + const owedTx = booked.find((b) => b.sourceId === '1557344:buy_crypto-owed:50'); + expect(owedTx).toBeDefined(); + const liabilityLeg = owedTx.legs.find((l) => l.account.type === AccountType.LIABILITY); + expect(liabilityLeg.needsMark).toBe(true); // no mark for asset 999 → mark-to-market values later + }); + }); + + 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'); + + // 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'); + }); + }); +}); 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..1e5e9d0f98 --- /dev/null +++ b/src/subdomains/core/accounting/services/__tests__/ledger-mark-to-market.service.spec.ts @@ -0,0 +1,205 @@ +import { createMock } from '@golevelup/ts-jest'; +import { CronExpression } from '@nestjs/schedule'; +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 { 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 + 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 + function legQb(): any { + const qb: any = {}; + const chain = () => qb; + qb.innerJoin = chain; + qb.select = chain; + qb.addSelect = chain; + qb.where = chain; + qb.andWhere = chain; + qb.groupBy = chain; + qb.having = chain; + qb.orderBy = chain; + qb.limit = chain; + qb.getRawMany = () => Promise.resolve((legStub.candidateIds ?? []).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(); + }); + + 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(); + }); +}); 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..78031c69a5 --- /dev/null +++ b/src/subdomains/core/accounting/services/__tests__/ledger-mark.service.spec.ts @@ -0,0 +1,110 @@ +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(); + }); + + it('returns the priceChf of the latest mark ≤ bookingDate (Stufe 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 (Stufe 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 + }); +}); 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..18434109a5 --- /dev/null +++ b/src/subdomains/core/accounting/services/__tests__/ledger-query.service.spec.ts @@ -0,0 +1,445 @@ +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 { 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 { 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 logService: LogService; + + let qbStub: LegQbStub; + + 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 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; + } + + beforeEach(async () => { + qbStub = {}; + + ledgerAccountRepository = createMock(); + ledgerLegRepository = createMock(); + reconciliationService = createMock(); + liquidityManagementBalanceService = 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([]); + 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: 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('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('diff'); // diff 50 > tolerance 1 + expect(res.accounts[0].reconDiff).toBe(50); + }); + + 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); + }); + }); + + 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; + }); + // journalNativeBalance getRawOne → 100 for all (fresh: diff 0 → ok) + qbStub.rawOne = {}; + jest.spyOn(service as any, 'journalNativeBalance').mockResolvedValue(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('fresh'); + expect(byId.get(1).status).toBe('ok'); + expect(byId.get(2).staleness).toBe('stale'); + expect(byId.get(2).status).toBe('stale'); + expect(byId.get(3).staleness).toBe('missing'); + expect(byId.get(3).status).toBe('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); + }); + }); + + describe('getSuspense', () => { + it('sums chf and maps each suspense leg with its age', async () => { + const account = createCustomLedgerAccount({ + id: 3, + name: 'SUSPENSE/untracked-bank-Raiffeisen-EUR', + type: AccountType.SUSPENSE, + currency: 'EUR', + }); + const legA = makeLeg({ id: 1, amount: 600000, amountChf: 580000, 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(580950); + 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 + }); + }); + + describe('getEquityComparison', () => { + 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 }, + }), + }); + } + + it('computes journalEquity, difference and the four-bucket decomposition (other = residual)', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([financeLog('2026-06-10T00:00:00.000Z', 20000)]); + jest.spyOn(service as any, 'journalEquityAt').mockResolvedValue(19000); + jest.spyOn(service as any, 'transitPhantom').mockResolvedValue(-500); + jest.spyOn(service as any, 'staleFeed').mockResolvedValue(-200); + jest.spyOn(service as any, 'spreadFees').mockResolvedValue(-100); + + const res = await service.getEquityComparison(undefined, 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(), message: '{ not json' }); + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([broken]); + + const res = await service.getEquityComparison(); + + expect(res.periods).toHaveLength(0); + }); + }); + + describe('getAccountDetail', () => { + it('returns an empty shell for an unknown account', async () => { + jest.spyOn(ledgerAccountRepository, 'findOneBy').mockResolvedValue(null); + + const res = await service.getAccountDetail(999); + + expect(res.legs).toHaveLength(0); + expect(res.total).toBe(0); + expect(res.openingBalance).toBe(0); + }); + + 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'); + }); + }); +}); 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..fe416bb87c --- /dev/null +++ b/src/subdomains/core/accounting/services/__tests__/ledger-reconciliation.service.spec.ts @@ -0,0 +1,295 @@ +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 { 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 { FeedStatus, LedgerReconciliationService } from '../ledger-reconciliation.service'; + +interface LegQueryStub { + native?: string; // journalNativeBalance getRawOne + equityChf?: string; // journalEquity getRawOne + transit?: { name: string; native: string; oldest: Date }[]; + 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 mails: MailRequest[]; + let legStub: LegQueryStub; + + 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 }); + } + + function financeLog(totalBalanceChf: number): Log { + return Object.assign(new Log(), { + id: 1, + created: new Date('2026-06-11T00:00:00Z'), + 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.groupBy = chain; + qb.addGroupBy = chain; + qb.having = chain; + qb.getRawMany = () => { + const selects = qb._selects.join(' '); + if (selects.includes('bookingDate')) return Promise.resolve(legStub.transit ?? []); // checkTransitAge (MIN bookingDate) + return Promise.resolve(legStub.suspense ?? []); // checkSuspense + }; + 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(); + + 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'); + + 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 }, + ], + }).compile(); + + service = module.get(LedgerReconciliationService); + }); + + 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, 'getLatestFinancialLog').mockResolvedValue(financeLog(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('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); + }); + }); + + 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 = [{ name: 'TRANSIT/payout/CHF', native: '14851.5', oldest: oldDate }]; + + await service.run(); + + expect(mails.some((m) => m.context === MailContext.LEDGER_TRANSIT_OVERDUE)).toBe(true); + }); + + 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); + }); + }); + + describe('equity parity (§7.6)', () => { + it('computes journalEquity as the signed balance-account sum and logs the difference (no leading minus, R8-1)', async () => { + jest.spyOn(logService, 'getLatestFinancialLog').mockResolvedValue(financeLog(16000)); + legStub.equityChf = '16050'; // journalEquity query disambiguated by its 'account.type IN' where clause + const logSpy = jest.spyOn(service['logger'], 'info'); + + await service.run(); + + const parityLog = logSpy.mock.calls.find((c) => c[0].includes('equity parity')); + expect(parityLog).toBeDefined(); + // journalEquity positive (16050), difference = 16050 − 16000 = 50, sign-consistent with totalBalanceChf + expect(parityLog[0]).toContain('journalEquity 16050'); + expect(parityLog[0]).toContain('difference 50'); + }); + }); +}); 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..af69254802 --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/__tests__/bank-tx.consumer.spec.ts @@ -0,0 +1,458 @@ +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 { 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 { 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 { 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'), + 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 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>(); + + 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(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 }, + ], + }).compile(); + + consumer = module.get(BankTxConsumer); + }); + + function mockBatch(rows: BankTx[], bank?: Partial): void { + jest.spyOn(bankTxRepo, 'find').mockResolvedValue(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 }), + ); + 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 against SUSPENSE/untracked-bank-Raiffeisen-EUR', async () => { + 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; + // untracked → no EUR mark via asset → SUSPENSE leg needsMark; the received anchor remains; plug absorbs + expect(legs[0].account.name).toBe('SUSPENSE/untracked-bank-Raiffeisen-EUR'); + expect(legs[0].needsMark).toBe(true); + expect(legs[1].account.name).toBe('LIABILITY/buyCrypto-received'); + 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); + }); + + it('books CHECKOUT_LTD CRDT: ASSET/bank netto + EXPENSE/acquirer-fee / ASSET/Checkout brutto (CHF cross-asset)', 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)); // brutto = netto + fee (Minor R3-5) + expect(cents(legs)).toBe(0); + }); + + 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(); + }); +}); 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..24ab09bada --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/__tests__/buy-crypto.consumer.spec.ts @@ -0,0 +1,194 @@ +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 { 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 { 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 gateOpen: boolean; // simulates a seq0 crypto_input ledger_tx existing + + beforeEach(async () => { + booked = []; + nextSeqValue = 0; + 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); + return Promise.resolve({} as any); + }); + jest.spyOn(bookingService, 'nextSeq').mockImplementation(() => Promise.resolve(nextSeqValue)); + + 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: countBy returns 1 when the gate is open (seq0 crypto_input / cutover marker exists) + jest.spyOn(ledgerTxRepo, 'countBy').mockImplementation(() => Promise.resolve(gateOpen ? 1 : 0)); + + jest.spyOn(settingService, 'getObj').mockResolvedValue(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); + const mockBatch = (rows: BuyCrypto[]) => jest.spyOn(buyCryptoRepo, 'find').mockResolvedValue(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 / Cr LIABILITY/buyCrypto-received (= amountInChf) + it('books a Card input seq0 against Checkout custody (Bank/crypto inputs are NOT booked here)', async () => { + mockBatch([buyCrypto({ id: 1, amountInChf: 1000, checkoutTx: { currency: 'EUR' } as any })]); + await consumer.process(); + + const tx = seq(0); + expect(leg(tx, 'Checkout/EUR').amountChf).toBe(1000); + expect(leg(tx, 'LIABILITY/buyCrypto-received').amountChf).toBe(-1000); + expect(cents(tx.legs)).toBe(0); + }); + + 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); + }); + + // §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 already booked (re-run, nextSeq > 1)', async () => { + nextSeqValue = 2; + mockBatch([buyCrypto({ id: 8, amountInChf: 1000, totalFeeAmountChf: 10, isComplete: true })]); + await consumer.process(); + expect(seq(1)).toBeUndefined(); + }); + + 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); + }); +}); 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..c666b92af6 --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/__tests__/buy-fiat.consumer.spec.ts @@ -0,0 +1,361 @@ +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 gateCount: number; // countBy result (received/cutover gate) + let seq0PaymentLinkChf: number | undefined; // the seq0 paymentLink opening leg amountChf (negative) + + 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; + gateCount = 1; + seq0PaymentLinkChf = 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); + return Promise.resolve({} as any); + }); + jest.spyOn(bookingService, 'nextSeq').mockImplementation(() => Promise.resolve(nextSeqValue)); + + 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, 'countBy').mockImplementation(() => Promise.resolve(gateCount)); + jest.spyOn(ledgerTxRepo, 'findOne').mockImplementation(() => { + 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(settingService, 'getObj').mockResolvedValue(undefined); + 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); + const mockBatch = (rows: BuyFiat[]) => jest.spyOn(buyFiatRepo, 'find').mockResolvedValue(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(); + }); + + // §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.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 () => { + gateCount = 0; // 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 + const s1 = seq(1); + const feeIncome = leg(s1, 'INCOME/fee-paymentLink'); + expect(feeIncome).toBeDefined(); + expect(feeIncome.amountChf).toBeLessThan(0); // negative = INCOME credit + + // 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 GENAU um 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, nextSeq > 3)', async () => { + nextSeqValue = 4; + 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); + }); + + 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); + }); +}); 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..3d64df2fd0 --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/__tests__/crypto-input.consumer.spec.ts @@ -0,0 +1,289 @@ +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 { 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; + + 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; + 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); + return Promise.resolve({} as any); + }); + jest.spyOn(bookingService, 'nextSeq').mockImplementation(() => Promise.resolve(nextSeqValue)); + + 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(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); + const mockBatch = (rows: CryptoInput[]) => jest.spyOn(cryptoInputRepo, 'find').mockResolvedValue(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) — no mark: ASSET leg needsMark, plug stays open, no silent priceChf=0 + it('flags the ASSET leg needsMark when no mark exists (no silent priceChf=0)', async () => { + 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)); + await consumer.process(); + + const seq0 = booked.find((b) => b.seq === 0); + const assetLeg = seq0.legs.find((l) => l.account.name === 'Unknown/XYZ'); + expect(assetLeg.needsMark).toBe(true); + expect(assetLeg.amountChf).toBeUndefined(); + expect(cents(seq0.legs)).toBe(0); // received −50000 + plug +50000 balances; mark-to-market revalues later + }); + + 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-Strategie)', 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 already booked (re-run, nextSeq > 0)', async () => { + nextSeqValue = 1; // seq0 already exists + 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); + }); +}); 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..567a6818ef --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/__tests__/exchange-tx.consumer.spec.ts @@ -0,0 +1,340 @@ +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 { 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'), + 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(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 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); + }); + } + + 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(); + expect(booked[0].legs.some((l) => l.account.name === 'SUSPENSE/untracked-bank-Raiffeisen-EUR')).toBe(true); + }); + + 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'); + // 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('O-1'); + }); + + 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')).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 === 'O-9')).toBe(true); + }); + + 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); + }); +}); 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..7ae2d4969b --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/__tests__/liquidity-mgmt.consumer.spec.ts @@ -0,0 +1,286 @@ +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(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); + const mockBatch = (rows: LiquidityManagementOrder[]) => jest.spyOn(orderRepo, 'find').mockResolvedValue(rows); + const leg = (tx: LedgerTxInput, name: string) => tx.legs.find((l) => l.account.name === name); + + it('is defined', () => { + expect(consumer).toBeDefined(); + }); + + // §4.8 Zweig 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 (Zweig 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 Zweig 4 — dEURO bridge-in: system=dEURO (otherwise exchange) + command='bridge-in' → BOOK (not skip) + it('books a dEURO bridge-in order (Zweig 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 Zweig 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 (Zweig 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 Zweig 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 (Zweig 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 Zweig 3 DEDUP: DfxDex withdraw → SKIP (target deposit exchange_tx authoritative) + it('skips DfxDex withdraw orders (Zweig 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); + }); +}); 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..88a6c9f800 --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/__tests__/liquidity-order-dex.consumer.spec.ts @@ -0,0 +1,257 @@ +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, 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(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); + const mockBatch = (rows: LiquidityOrder[]) => jest.spyOn(liquidityOrderRepo, 'find').mockResolvedValue(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'); // ':' (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-Strategie)', 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) + it('flags the ASSET leg needsMark and skips the spread plug when a mark is missing', async () => { + 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(); + const tx = booked[0]; + expect(leg(tx, 'Ethereum/EURC').needsMark).toBe(true); + expect(leg(tx, 'Ethereum/EURC').amountChf).toBeUndefined(); + expect(leg(tx, 'EXPENSE/spread-DfxDex')).toBeUndefined(); + expect(leg(tx, 'INCOME/spread-DfxDex')).toBeUndefined(); + }); + + // §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); + }); + + // §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(); + + expect(findSpy).toHaveBeenCalledTimes(1); + const where = findSpy.mock.calls[0][0].where as Record; + // txId IS NOT NULL (Not(IsNull())) — excludes Reservation rows without an on-chain settlement + expect(where.txId).toEqual(Not(expect.anything())); + // 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); + }); +}); 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..5e1186539b --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/__tests__/payout-order.consumer.spec.ts @@ -0,0 +1,348 @@ +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 { 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 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>(); + + 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(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 }, + ], + }).compile(); + + consumer = module.get(PayoutOrderConsumer); + }); + + const cents = (legs: LedgerLegInput[]) => legs.reduce((s, l) => s + Math.round((l.amountChf ?? 0) * 100), 0); + const mockBatch = (rows: PayoutOrder[]) => jest.spyOn(payoutOrderRepo, 'find').mockResolvedValue(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 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 gegen 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 + it('flags the wallet leg needsMark when the payout-asset mark is missing', async () => { + 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(); + + const wallet = leg(booked[0], 'Unknown/XYZ'); + expect(wallet.needsMark).toBe(true); + expect(wallet.amountChf).toBeUndefined(); + }); + + 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); + }); +}); 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..31ed7928b5 --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/__tests__/trading-order.consumer.spec.ts @@ -0,0 +1,241 @@ +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(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); + const mockBatch = (rows: TradingOrder[]) => jest.spyOn(tradingOrderRepo, 'find').mockResolvedValue(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 missing mark → ASSET leg needsMark, plug stays open (no silent plug without a mark) + it('flags the ASSET leg needsMark and skips the plug when a mark is missing', async () => { + jest.spyOn(markService, 'preload').mockResolvedValue( + new LedgerMarkCache(new Map([[USDT_ASSET_ID, [{ created: new Date('2026-01-01'), priceChf: 0.9 }]]])), // TOKEN missing + ); + mockBatch([tradingOrder({ id: 15 })]); + await consumer.process(); + const tx = booked[0]; + expect(leg(tx, 'Ethereum/TOKEN').needsMark).toBe(true); + expect(leg(tx, 'Ethereum/TOKEN').amountChf).toBeUndefined(); + expect(leg(tx, 'EXPENSE/spread-arbitrage')).toBeUndefined(); // no silent plug without a mark + expect(leg(tx, 'INCOME/spread-arbitrage')).toBeUndefined(); + }); + + // §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); + }); +}); 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..126014cc9d --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts @@ -0,0 +1,472 @@ +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 { 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 { LessThan, 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, setLedgerWatermark } from './ledger-watermark.helper'; + +const SOURCE_TYPE = 'bank_tx'; +const CHF = 'CHF'; + +// bank-side exchange route segment per type (§3.3 {ex}); SCB route is created lazily (§3.3 "neue Routen lazy") +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; +} + +/** + * 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, + ) {} + + async process(): Promise { + const watermark = (await getLedgerWatermark(this.settingService, SOURCE_TYPE)) ?? { + lastProcessedId: 0, + lastReversalScan: new Date(0), + }; + + // 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; + + const from = Util.minObj(batch, 'created').created; + 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 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; // skipped type (BUY_FIAT / TEST_FIAT_FIAT) + + await this.bookingService.bookTx({ + 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]); + } + + // §4.2a — BUY_CRYPTO_RETURN DBIT: Dr LIABILITY/buyCrypto-owed / Cr ASSET/bank + fx-plug + 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)); + // owed-Dr CHF = completion/opening value; the mark-consistent return value is the deterministic reference + const owed = this.namedLeg(await this.liability('buyCrypto-owed'), -(bank.amountChf ?? 0)); + + return this.withFxPlug([owed, bank]); + } + + // §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]); // ≤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 CRDT: Dr ASSET/bank / Cr LIABILITY/{bucket} (both mark, 2-leg) + 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} / Cr ASSET/bank + 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 liability = this.namedLeg(await this.liability(bucket), -(bank.amountChf ?? 0)); + + return [liability, bank]; + } + + // §4.2 BANK_TX_RETURN_CHARGEBACK DBIT: Dr LIABILITY/{bucket} / Cr ASSET/bank (+ EXPENSE/bank-fee chargeAmountChf) + 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 legs: LedgerLegInput[] = [bank]; + + const feeChf = tx.chargeAmountChf; + if (feeChf != null && feeChf !== 0) legs.push(this.namedLeg(await this.expense('bank-fee'), feeChf)); // Pricing anchor + + // LIABILITY-Dr = opening CHF; closes against bank + fee, residual via plug (§4.2-Note B-15) + const liabilityChf = -legs.reduce((s, l) => s + (l.amountChf ?? 0), 0); + legs.unshift(this.namedLeg(await this.liability(bucket), liabilityChf)); + + return this.withFxPlug(legs); // ≤2c → ROUNDING, >2c → fx-revaluation + } + + // §4.2 CHECKOUT_LTD CRDT: Dr ASSET/bank (netto) + Dr EXPENSE/acquirer-fee / Cr ASSET/Checkout (brutto), CHF-only + 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)); // netto + 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)); + + // brutto Checkout custody Cr-leg: no own *Chf → CHF = netto + 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: -tx.amount, + priceChf: null, + amountChf: grossChf != null ? -grossChf : undefined, + needsMark: grossChf == null, + }); + + return legs; + } + + // §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) + private bankAssetLeg( + ctx: BankContext, + signedAmount: number, + bookingDate: Date, + marks: LedgerMarkCache, + account: LedgerAccount, + ): LedgerLegInput { + const mark = ctx.currency === CHF ? 1 : ctx.asset ? marks.getMarkAt(ctx.asset.id, 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) + private async withFxPlug(legs: LedgerLegInput[]): Promise { + 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) return { asset: bank.asset, currency: bank.currency, bankName: bank.name, tracked: bank.asset != null }; + } + + const currency = tx.currency ?? CHF; // no bank match → untracked, currency from the tx + return { asset: undefined, currency, bankName: tx.bankName ?? 'unknown', tracked: false }; + } +} 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..56d1270d0b --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts @@ -0,0 +1,193 @@ +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 { LedgerTx } from '../../entities/ledger-tx.entity'; +import { LedgerAccountService } from '../ledger-account.service'; +import { LedgerBookingService, LedgerLegInput } from '../ledger-booking.service'; +import { getLedgerWatermark, setLedgerWatermark } from './ledger-watermark.helper'; + +const SOURCE_TYPE = 'buy_crypto'; +const CRYPTO_INPUT_SOURCE = 'crypto_input'; +const CUTOVER_SOURCE = 'cutover'; +const CHF = 'CHF'; + +/** + * 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). + */ +@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), + }; + + const batch = await this.buyCryptoRepo.find({ + where: { id: MoreThan(watermark.lastProcessedId) }, + relations: { checkoutTx: true, cryptoInput: { paymentLinkPayment: 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 { + 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} / Cr LIABILITY/buyCrypto-received (= amountInChf). + // Bank input → BankTx consumer; crypto input → CryptoInput consumer (§4.1 single booker). + private async bookCardInput(bc: BuyCrypto): Promise { + if (!bc.checkoutTx) return; // not a Card input → seq0 not this consumer's job + if (bc.amountInChf == null) return; + if (await this.alreadyBooked(bc.id, 0)) return; + + const checkout = await this.checkoutAccount(bc.checkoutTx.currency); + const received = await this.liability('buyCrypto-received'); + + await this.bookingService.bookTx({ + sourceType: SOURCE_TYPE, + sourceId: `${bc.id}`, + seq: 0, + bookingDate: bc.created, + valueDate: bc.created, + legs: [this.chfLeg(checkout, bc.amountInChf), this.chfLeg(received, -bc.amountInChf)], + }); + } + + /** + * §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 { + 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 received = await this.liability('buyCrypto-received'); + const owed = await this.liability('buyCrypto-owed'); + // 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) + ]; + + await this.bookingService.bookTx({ + sourceType: SOURCE_TYPE, + sourceId: `${bc.id}`, + seq: 1, + bookingDate: bc.outputDate ?? bc.updated, + valueDate: bc.outputDate ?? bc.updated, + legs, + }); + } + + // --- 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 { + if (bc.checkoutTx) return true; // Card input opened received via this consumer's own seq0 + + const cryptoInputId = bc.cryptoInput?.id; + if (cryptoInputId != null) { + const ga = await this.ledgerTxRepo.countBy({ + sourceType: CRYPTO_INPUT_SOURCE, + sourceId: `${cryptoInputId}`, + seq: 0, + }); + if (ga > 0) return true; // G-a + } + + // G-b: cutover opening on buyCrypto-received for this buy_crypto.id (synthetic seq0 marker, §6.1) + const gb = await this.ledgerTxRepo.countBy({ + sourceType: CUTOVER_SOURCE, + sourceId: `${this.cutoverReceivedSourceId(bc.id)}`, + }); + return gb > 0; + } + + // the cutover per-row received marker sourceId suffix (§6.1 / §4.7 G-b); the prefix is the snapshot logId + private cutoverReceivedSourceId(buyCryptoId: number): string { + return `:buy_crypto:${buyCryptoId}`; + } + + // --- HELPERS --- // + + private chfLeg(account: LedgerAccount, amountChf: number): LedgerLegInput { + return { account, amount: amountChf, priceChf: 1, amountChf }; + } + + private async alreadyBooked(id: number, seq: number): Promise { + return (await this.bookingService.nextSeq(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 income(qualifier: string): Promise { + return this.accountService.findOrCreate(`INCOME/${qualifier}`, AccountType.INCOME, 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..726b84cac3 --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts @@ -0,0 +1,382 @@ +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 } from '../ledger-booking.service'; +import { LedgerMarkCache, LedgerMarkService } from '../ledger-mark.service'; +import { getLedgerWatermark, setLedgerWatermark } from './ledger-watermark.helper'; + +const SOURCE_TYPE = 'buy_fiat'; +const CRYPTO_INPUT_SOURCE = 'crypto_input'; +const CUTOVER_SOURCE = 'cutover'; +const CHF = 'CHF'; +const PAYMENT_LINK = 'LIABILITY/paymentLink'; + +/** + * The Class-1-Kern 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), + }; + + const batch = await this.buyFiatRepo.find({ + where: { id: MoreThan(watermark.lastProcessedId) }, + relations: { cryptoInput: { paymentLinkPayment: true }, fiatOutput: { bankTx: 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 { + const dates = batch.flatMap((bf) => [bf.updated, bf.fiatOutput?.bankTx?.bookingDate].filter((d): d is Date => !!d)); + const times = dates.map((d) => d.getTime()); + return this.markService.preload(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 { + // seq1 (fee + reclassification) — only once outputAmount is set AND received is opened (gate G-a/G-b) + if (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); + } + + // 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); + } + + 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 { + 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'); + + await this.bookingService.bookTx({ + 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) + private async bookTransmit(bf: BuyFiat): Promise { + const owedChf = this.owedChf(bf); + const owed = await this.liability('buyFiat-owed'); + 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(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): Promise { + const bookingDate = bf.fiatOutput.bankTx.bookingDate ?? bf.fiatOutput.bankTx.created; + const owedChf = this.owedChf(bf); + + 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); + + await this.bookingService.bookTx({ + 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 schluss-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 Krypto↔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); + + const outputAmount = bf.outputAmount ?? 0; + 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 + private async appendFxResidual(legs: LedgerLegInput[]): Promise { + 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. + private owedChf(bf: BuyFiat): number { + if (bf.cryptoInput?.paymentLinkPayment) return Util.round((bf.outputAmount ?? 0) * 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) return this.outputCurrency(bf) === CHF ? 1 : 0; + 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) { + const ga = await this.ledgerTxRepo.countBy({ + sourceType: CRYPTO_INPUT_SOURCE, + sourceId: `${cryptoInputId}`, + seq: 0, + }); + if (ga > 0) return true; // G-a + } + + // G-b: cutover opening on buyFiat-received for this buy_fiat.id (synthetic seq0 marker, §6.1) + const gb = await this.ledgerTxRepo.countBy({ sourceType: CUTOVER_SOURCE, sourceId: `:buy_fiat:${bf.id}` }); + return gb > 0; + } + + // §4.7b gate — returns the seq0 paymentLink opening CHF (= −leg.amountChf) or undefined if not yet opened + private async paymentLinkOpeningChf(bf: BuyFiat): Promise { + const cryptoInputId = bf.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 + } + + // --- ACCOUNT/LEG HELPERS --- // + + private chfLeg(account: LedgerAccount, amountChf: number): LedgerLegInput { + return { account, amount: amountChf, priceChf: 1, amountChf }; + } + + private async alreadyBooked(id: number, seq: number): Promise { + return (await this.bookingService.nextSeq(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..a730ae39bc --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts @@ -0,0 +1,212 @@ +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 } from '../ledger-booking.service'; +import { LedgerMarkCache, LedgerMarkService } from '../ledger-mark.service'; +import { getLedgerWatermark, setLedgerWatermark } from './ledger-watermark.helper'; + +const SOURCE_TYPE = 'crypto_input'; +const CHF = 'CHF'; + +/** + * The EINZIGE 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), + }; + + // 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(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); // 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 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) { + // paymentLink: 2-leg, mark-based (no per-input amountInChf anchor — @ManyToOne, Minor R10-4) + const paymentLink = await this.liability('paymentLink'); + await this.bookingService.bookTx({ + sourceType: SOURCE_TYPE, + sourceId: `${ci.id}`, + seq: 0, + bookingDate, + valueDate: bookingDate, + legs: [ + assetLeg, + { + account: paymentLink, + amount: -(assetChf ?? 0), + priceChf: 1, + amountChf: assetChf != null ? -assetChf : undefined, + needsMark: assetChf == null, + }, + ], + }); + return; + } + + // 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; + } + + const received = await this.liability(`${product.bucket}-received`); + const legs: LedgerLegInput[] = [ + assetLeg, + { account: received, amount: -product.amountInChf, priceChf: 1, amountChf: -product.amountInChf }, + ]; + this.appendFxPlug(legs, await this.fxAccounts()); + + await this.bookingService.bookTx({ + 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): Promise { + if (!ci.outTxId || ci.forwardFeeAmountChf == null) return; // null fee → no leg (Null-Strategie §5.1) + if (await this.alreadyBooked(ci.id, 1)) return; + + const wallet = await this.walletAsset(ci); + const feeChf = ci.forwardFeeAmountChf; + const feeNative = ci.forwardFeeAmount; + const mark = feeChf != null && feeNative ? Util.round(feeChf / feeNative, 8) : null; + 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 ?? feeChf), 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 + private appendFxPlug(legs: LedgerLegInput[], fx: { income: LedgerAccount; expense: LedgerAccount }): void { + 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; + } + + private async alreadyBooked(id: number, seq: number): Promise { + return (await this.bookingService.nextSeq(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..dd3688dc28 --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts @@ -0,0 +1,408 @@ +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 { ExchangeName } from 'src/integration/exchange/enums/exchange.enum'; +import { ExchangeTx, ExchangeTxType } from 'src/integration/exchange/entities/exchange-tx.entity'; +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 } from '../ledger-booking.service'; +import { LedgerMarkCache, LedgerMarkService } from '../ledger-mark.service'; +import { getLedgerWatermark, setLedgerWatermark } from './ledger-watermark.helper'; + +const OK_STATUS = 'ok'; +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 source = 'exchange_tx'; + const watermark = (await getLedgerWatermark(this.settingService, source)) ?? { + lastProcessedId: 0, + lastReversalScan: new Date(0), + }; + + 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(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 { + await this.book(tx, marks, fillIndexMap); + 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, { ...watermark, lastProcessedId }); + } + } + + private async book(tx: ExchangeTx, marks: LedgerMarkCache, fillIndexMap: Map): Promise { + const bookingDate = tx.externalCreated ?? tx.created; + + switch (tx.type) { + case ExchangeTxType.DEPOSIT: + return this.bookDeposit(tx, bookingDate, marks); + case ExchangeTxType.WITHDRAWAL: + return this.bookWithdrawal(tx, bookingDate, marks); + case ExchangeTxType.TRADE: + return this.bookTrade(tx, bookingDate, marks, fillIndexMap); + default: + this.logger.error(`Unhandled exchange_tx type ${tx.type} on exchange_tx ${tx.id}`); + } + } + + // §4.3/§4.3a — Deposit: Dr ASSET/{exchange}/{ccy} / Cr {routeCounterAccount} + private async bookDeposit(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, + }; + + await this.bookSingle(tx, bookingDate, [assetLeg, counterLeg]); + } + + // §4.3/§4.3a — Withdrawal: Dr {routeCounterAccount} / Cr ASSET/{exchange}/{ccy} (mirror) + private async bookWithdrawal(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, + }; + + await this.bookSingle(tx, bookingDate, [counterLeg, assetLeg]); + } + + // §4.3 — Trade: Dr ASSET/{exchange}/{base} / Cr ASSET/{exchange}/{quote} + spread + (ccxt) fee leg + private async bookTrade( + 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; + await this.bookSingle(tx, bookingDate, [ + { account: suspense, amount: chf, priceChf: 1, amountChf: chf }, + { account: suspense, amount: -chf, priceChf: 1, amountChf: -chf }, + ]); + return; + } + + 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 (Stufe 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, + }; + + 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)); + + const plugChf = -legs.reduce((s, l) => s + (l.amountChf ?? 0), 0); // quote leg closes the tx + const quotePrice = quoteAmount !== 0 ? Util.round(Math.abs(plugChf) / Math.abs(quoteAmount), 8) : null; + legs.push({ account: quoteAccount, amount: quoteAmount, priceChf: quotePrice, amountChf: plugChf }); + } 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). Skip when a leg + // still needsMark (no silent plug, §4.3) — mark-to-market revalues then. + if (!legs.some((l) => l.needsMark)) { + 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; + const sourceId = order ? `${order}` : `${tx.id}`; + const sourceType = order ? 'ExchangeTrade' : 'exchange_tx'; + + await this.bookingService.bookTx({ + 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 (rein lesend, §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'; + } + + // rein lesend: a bank_tx KRAKEN/SCRYPT/SCB on the same currency within a date window (§4.3a-R2 nachgebaut) + 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]); + const existing = await this.exchangeTxRepo.find({ + where: { type: ExchangeTxType.TRADE, status: OK_STATUS, order: In(orders) }, + select: { id: true, exchange: true, order: true }, + }); + + const byKey = Util.groupByAccessor<{ id: number; exchange: string; order?: string }, string>( + existing, + (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 async bookSingle(tx: ExchangeTx, bookingDate: Date, legs: LedgerLegInput[]): Promise { + await this.bookingService.bookTx({ + sourceType: 'exchange_tx', + sourceId: `${tx.id}`, + seq: 0, + bookingDate, + valueDate: bookingDate, + legs, + }); + } + + // §4.3 amountChf null fallback (Minor R9-4): persisted amountChf (Stufe 1) ?? mark × amount (Stufe 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 (nachgebaut, §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-watermark.helper.ts b/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts new file mode 100644 index 0000000000..435068ae13 --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts @@ -0,0 +1,43 @@ +import { SettingService } from 'src/shared/models/setting/setting.service'; + +// per-source checkpoint (§11.3): id-watermark + content-change scan watermark +export interface LedgerWatermark { + lastProcessedId: number; + lastReversalScan: Date; +} + +const WATERMARK_KEY_PREFIX = 'ledgerWatermark.'; + +/** + * 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 }>( + `${WATERMARK_KEY_PREFIX}${source}`, + ); + if (!raw) return undefined; + + return { lastProcessedId: raw.lastProcessedId, lastReversalScan: new Date(raw.lastReversalScan) }; +} + +/** + * Writes the per-source watermark (§11.3) — exclusively via `settingService.set` (never `setObj`/`settingRepo`; + * §4.10 R2-Ausnahme-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(), + }), + ); +} 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..5d14b8b578 --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/liquidity-mgmt.consumer.ts @@ -0,0 +1,194 @@ +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, setLedgerWatermark } from './ledger-watermark.helper'; + +const SOURCE_TYPE = 'liquidity_management_order'; +const BRIDGE_IN_COMMAND = 'bridge-in'; // dEURO bridge-in (§4.8 Zweig 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), + }; + + 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 times = batch.map((o) => o.updated.getTime()); + const marks = await this.markService.preload(new Date(Math.min(...times)), new Date(Math.max(...times))); + + 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 }); + } + } + + /** + * 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 Zweig-1 exchange skip: system=dEURO is otherwise an exchange (Zweig 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 Zweig 4 before Zweig 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 (rein lesend, kein 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 Zweig 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 Zweig 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..6be5908109 --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/liquidity-order-dex.consumer.ts @@ -0,0 +1,249 @@ +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 { getLedgerWatermark, setLedgerWatermark } from './ledger-watermark.helper'; + +const SOURCE_TYPE = 'liquidity_order'; +const CHF = 'CHF'; +const DEX = 'DfxDex'; + +// the LM consumer (§4.8 Zweig 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 (NEU, 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). Idempotency/uniqueness rest SOLELY on the ledger UNIQUE(sourceType,sourceId,seq) — the source + * @Index([context, correlationId]) is NOT unique (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), + }; + + // 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([LiquidityOrderType.PURCHASE, LiquidityOrderType.SELL]), + }, + order: { id: 'ASC' }, + take: Config.ledger.backfillBatchSize, + }); + if (!batch.length) return; + + const times = batch.map((o) => o.updated.getTime()); + const marks = await this.markService.preload(new Date(Math.min(...times)), new Date(Math.max(...times))); + + 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 }); + } + } + + /** + * §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); + + await this.bookingService.bookTx({ + sourceType: SOURCE_TYPE, + // sourceId = ':' (Minor R6-8: uniqueness rests on the ledger UNIQUE, not the + // non-unique source @Index([context, correlationId])) + 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-Strategie §5.1: no fee → no fee leg + + const mark = marks.getMarkAt(feeAsset.id, bookingDate); + const feeChf = mark != null ? Util.round(mark * feeAmount, 2) : undefined; + const feeNeedsMark = feeChf == 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). + // Skips the plug when any leg still needsMark (no silent plug without a mark, §5.1 Stufe 3 / §4.8a). + private async appendSpreadPlug(legs: LedgerLegInput[]): Promise { + if (legs.some((l) => l.needsMark)) 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}`; + } + + 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..284b536578 --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts @@ -0,0 +1,344 @@ +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 { LedgerAccountService } from '../ledger-account.service'; +import { LedgerBookingService, LedgerLegInput } from '../ledger-booking.service'; +import { LedgerMarkCache, LedgerMarkService } from '../ledger-mark.service'; +import { getLedgerWatermark, setLedgerWatermark } from './ledger-watermark.helper'; + +const SOURCE_TYPE = 'payout_order'; +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', +}; + +/** + * The EINZIGE 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 aus 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, + ) {} + + async process(): Promise { + const watermark = (await getLedgerWatermark(this.settingService, SOURCE_TYPE)) ?? { + lastProcessedId: 0, + lastReversalScan: new Date(0), + }; + + // 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 times = batch.map((o) => o.updated.getTime()); + const marks = await this.markService.preload(new Date(Math.min(...times)), new Date(Math.max(...times))); + + 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 }); + } + } + + 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), + }); + } + + /** + * 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 the owed + * COMPLETION value (amountInChf − totalFeeAmountChf of the linked product, the value §4.6/§4.7 seq1 credited to + * owed) so `owed` closes cent-exact to 0; the wallet Cr leg is the settlement mark × amount, and the + * completion↔settlement drift is taken by the fx-revaluation plug (Blocker R2-2). If the completion CHF cannot + * be resolved (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; + } + + const completionChf = await this.owedCompletionChf(order); // the persisted owed value (§4.5 "CHF aus Completion") + const mark = marks.getMarkAt(order.asset.id, bookingDate); + const settlementChf = mark != null ? Util.round(mark * order.amount, 2) : undefined; + + // 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; + const needsMark = settlementChf == 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 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-Strategie §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 + private async withFxPlug(legs: LedgerLegInput[]): Promise { + 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..fed4827841 --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/trading-order.consumer.ts @@ -0,0 +1,178 @@ +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 { getLedgerWatermark, 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 (NEU, 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), + }; + + // 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 times = batch.map((o) => o.updated.getTime()); + const marks = await this.markService.preload(new Date(Math.min(...times)), new Date(Math.max(...times))); + + 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 }); + } + } + + /** + * §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); + + 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. Skips the plug when any ASSET leg still needsMark (no silent plug without a mark, + * §5.1 Stufe 3 / §4.9) — mark-to-market revalues then. Sub-cent rest → ROUNDING (booking service). + */ + private async appendArbitragePlug(legs: LedgerLegInput[]): Promise { + if (legs.some((l) => l.needsMark)) 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..a8ac8ece26 --- /dev/null +++ b/src/subdomains/core/accounting/services/ledger-account.service.ts @@ -0,0 +1,40 @@ +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; re-run no-op on UNIQUE(name) + 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, + }); + + return this.ledgerAccountRepository.save(account); + } +} 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..34baf21b05 --- /dev/null +++ b/src/subdomains/core/accounting/services/ledger-booking-job.service.ts @@ -0,0 +1,108 @@ +import { Injectable } from '@nestjs/common'; +import { CronExpression } from '@nestjs/schedule'; +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 { 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 { + private readonly logger = new DfxLogger(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_LIQ_MGMT, 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..1aa0a12b87 --- /dev/null +++ b/src/subdomains/core/accounting/services/ledger-booking.service.ts @@ -0,0 +1,177 @@ +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 } 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'; + +@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 { + 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); + + return this.dataSource.transaction(async (manager) => { + 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 { + const nextSeq = await this.nextSeq(original.sourceType, original.sourceId); + + return this.bookTx({ + 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, + })), + }); + } + + // monotonic, collision-free seq allocation in the (sourceType, sourceId) namespace (§4.12) + async nextSeq(sourceType: string, sourceId: string): Promise { + const { max } = await this.dataSource + .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..981f69d3a5 --- /dev/null +++ b/src/subdomains/core/accounting/services/ledger-bootstrap.service.ts @@ -0,0 +1,147 @@ +import { Injectable } from '@nestjs/common'; +import { LiquidityManagementBalanceService } from 'src/subdomains/core/liquidity-management/services/liquidity-management-balance.service'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.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 (vorab-fixliste, 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..cfc3c09f71 --- /dev/null +++ b/src/subdomains/core/accounting/services/ledger-cutover.service.ts @@ -0,0 +1,460 @@ +import { Injectable } from '@nestjs/common'; +import { CronExpression } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +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 { BuyFiat } from 'src/subdomains/core/sell-crypto/process/buy-fiat.entity'; +import { ExchangeTx } from 'src/integration/exchange/entities/exchange-tx.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 { BankTx } from 'src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity'; +import { CryptoInput } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; +import { PayoutOrder } from 'src/subdomains/supporting/payout/entities/payout-order.entity'; +import { IsNull, LessThanOrEqual, Repository } from 'typeorm'; +import { AccountType, LedgerAccount } from '../entities/ledger-account.entity'; +import { LedgerBookingService, LedgerLegInput } from './ledger-booking.service'; +import { LedgerBootstrapService } from './ledger-bootstrap.service'; +import { LedgerAccountService } from './ledger-account.service'; +import { LedgerMarkCache, LedgerMarkService } from './ledger-mark.service'; + +const CUTOVER_LOG_ID_KEY = 'ledgerCutoverLogId'; +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) + +@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(CryptoInput) private readonly cryptoInputRepo: Repository, + @InjectRepository(ExchangeTx) private readonly exchangeTxRepo: Repository, + @InjectRepository(PayoutOrder) private readonly payoutOrderRepo: 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 + + try { + await this.cutover(); + } catch (e) { + // failure-isolation (§6.3): caught + logged, never hard-aborts; flag stays unset → consumers no-op, retry next run + this.logger.error('Ledger cutover failed', e); + } + } + + // gelockter 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 ≤ Stichtag (now) + const snapshot = await this.selectSnapshot(); + 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; + const marks = await this.markService.preload(Util.daysBefore(2, snapshotDate), snapshotDate); + const equity = await this.equityAccount(); + + // (3) 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); + + // (4) initialise consumer watermarks atomically — only rows settled at/before the snapshot (Blocker R3-1) + await this.initWatermarks(snapshotDate); + + // (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: newest valid=true FinancialDataLog ≤ Stichtag. 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 { + let seq = 0; + 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) { + seq++; + 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; + + await this.bookOpening( + snapshot, + seq++, + `${snapshot.id}`, + `Opening balance from FinancialDataLog #${snapshot.id}`, + snapshotDate, + { + account, + amount: native, + priceChf: priceChf ?? null, + amountChf, + needsMark: amountChf == null, // dauerhaft feedlos → nativ, mark-to-market bewertet nach (§5.1 Stufe 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); + + await this.openBuyFiatReceived(snapshot, snapshotDate, lookback, equity); + await this.openBuyFiatOwed(snapshot, snapshotDate, lookback, marks, equity); + await this.openBuyCryptoReceived(snapshot, snapshotDate, lookback, equity); + await this.openBuyCryptoOwed(snapshot, snapshotDate, lookback, marks, equity); + } + + // buyFiat-received: open rows with outputAmount NULL → CHF = amountInChf (Minor R3-6); per-row seq0-marker (R4-2) + private async openBuyFiatReceived(snapshot: Log, date: Date, lookback: Date, equity: LedgerAccount): Promise { + const rows = await this.buyFiatRepo.find({ + where: { isComplete: false, outputAmount: IsNull(), created: LessThanOrEqual(date) }, + }); + const liability = await this.liability('buyFiat-received'); + + for (const row of rows) { + if (row.created.getTime() < lookback.getTime() || row.amountInChf == null) continue; + await this.bookReceivedOwedOpening( + snapshot, + date, + `${snapshot.id}:buy_fiat:${row.id}`, + `Opening buyFiat-received from open buy_fiat #${row.id}`, + liability, + row.amountInChf, + equity, + ); + } + } + + // buyFiat-owed: open rows with outputAmount NOT NULL → CHF = outputAmount × mark(outputAsset-Fiat ≤ snapshot) (R6-1) + private async openBuyFiatOwed( + snapshot: Log, + date: Date, + lookback: Date, + marks: LedgerMarkCache, + equity: LedgerAccount, + ): Promise { + const rows = await this.buyFiatRepo.find({ + where: { isComplete: false, created: LessThanOrEqual(date) }, + relations: { outputAsset: true }, + }); + const liability = await this.liability('buyFiat-owed'); + + for (const row of rows) { + if (row.outputAmount == null || row.created.getTime() < lookback.getTime()) 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); + if (fiatMark == null) continue; // no mark → cannot value the CHF-denominated liability; leave to forward path + const amountChf = Util.round(row.outputAmount * fiatMark, 2); + + await this.bookReceivedOwedOpening( + snapshot, + date, + `${snapshot.id}:buy_fiat-owed:${row.id}`, + `Opening buyFiat-owed from open buy_fiat #${row.id}`, + liability, + amountChf, + equity, + ); + } + } + + // buyCrypto-received: open rows with outputAmount NULL → CHF = amountInChf (Minor R2-7); per-row seq0-marker (R4-2) + private async openBuyCryptoReceived(snapshot: Log, date: Date, lookback: Date, equity: LedgerAccount): Promise { + const rows = await this.buyCryptoRepo.find({ + where: { isComplete: false, outputAmount: IsNull(), created: LessThanOrEqual(date) }, + }); + const liability = await this.liability('buyCrypto-received'); + + for (const row of rows) { + if (row.created.getTime() < lookback.getTime() || row.amountInChf == null) continue; + await this.bookReceivedOwedOpening( + snapshot, + date, + `${snapshot.id}:buy_crypto:${row.id}`, + `Opening buyCrypto-received from open buy_crypto #${row.id}`, + liability, + row.amountInChf, + equity, + ); + } + } + + // 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: LessThanOrEqual(date) }, + relations: { outputAsset: true }, + }); + const liability = await this.liability('buyCrypto-owed'); + + for (const row of rows) { + if (row.outputAmount == null || row.created.getTime() < lookback.getTime()) 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; + + await this.bookReceivedOwedOpening( + snapshot, + date, + `${snapshot.id}:buy_crypto-owed:${row.id}`, + `Opening buyCrypto-owed from open buy_crypto #${row.id}`, + liability, + amountChf, + equity, + mark == null, // feedless outputAsset → needsMark, mark-to-market values it later (§5.1 Stufe 3) + ); + } + } + + // --- 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'); + let seq = 0; + for (const position of debts) { + if (!position?.value) { + seq++; + 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; + + await this.bookOpening( + snapshot, + seq++, + `${snapshot.id}:manual-debt:${position.assetId}`, + `Opening manual-debt for asset #${position.assetId} from FinancialDataLog #${snapshot.id}`, + snapshotDate, + { + account: manualDebt, + amount: -(amountChf ?? position.value), + priceChf: priceChf ?? null, + amountChf: amountChf != null ? -amountChf : undefined, + needsMark: amountChf == null, + }, + equity, + ); + } + } + + // --- WATERMARK INIT (§6.3 step 4, Blocker R3-1) --- // + + // sets each ledgerWatermark. to MAX(id) of pre-cutover settled rows + lastReversalScan = snapshotDate, + // so the forward consumers never re-book a row whose settlement the opening already covers (no double-count). + private async initWatermarks(snapshotDate: Date): Promise { + const sources: { source: string; maxId: () => Promise }[] = [ + { source: 'bank_tx', maxId: () => this.maxSettledId(this.bankTxRepo, 'bookingDate', snapshotDate) }, + { source: 'crypto_input', maxId: () => this.maxSettledId(this.cryptoInputRepo, 'updated', snapshotDate) }, + { source: 'payout_order', maxId: () => this.maxSettledId(this.payoutOrderRepo, 'updated', snapshotDate) }, + { source: 'exchange_tx', maxId: () => this.maxSettledId(this.exchangeTxRepo, 'created', snapshotDate) }, + { source: 'buy_crypto', maxId: () => this.maxSettledId(this.buyCryptoRepo, 'updated', snapshotDate) }, + { source: 'buy_fiat', maxId: () => this.maxSettledId(this.buyFiatRepo, 'updated', snapshotDate) }, + ]; + + for (const { source, maxId } of sources) { + await this.setWatermark(source, await maxId(), snapshotDate); + } + } + + // MAX(id) of rows whose settlement date ≤ snapshot (the consumer-specific settled filter, §4.x simplified to the + // settlement-date cutoff — the forward id-watermark only needs the highest pre-cutover-settled id, §6.3 Effekt) + private async maxSettledId(repo: Repository, dateColumn: string, snapshotDate: Date): Promise { + const { max } = (await repo + .createQueryBuilder('e') + .select('MAX(e.id)', 'max') + .where(`COALESCE(e.${dateColumn}, e.created) <= :date`, { date: snapshotDate }) + .getRawOne<{ max: number | null }>()) ?? { max: null }; + + return max ?? 0; + } + + 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( + snapshot: Log, + 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( + snapshot: Log, + bookingDate: Date, + sourceId: string, + description: string, + liability: LedgerAccount, + amountChf: number | undefined, + equity: LedgerAccount, + needsMark = false, + ): Promise { + await this.bookOpening( + snapshot, + 0, + sourceId, + description, + bookingDate, + { + account: liability, + amount: -(amountChf ?? 0), + priceChf: 1, + amountChf: amountChf != null ? -amountChf : undefined, + needsMark, + }, + 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..b8c242a735 --- /dev/null +++ b/src/subdomains/core/accounting/services/ledger-mark-to-market.service.ts @@ -0,0 +1,177 @@ +import { Injectable } from '@nestjs/common'; +import { CronExpression } from '@nestjs/schedule'; +import { Config } from 'src/config/config'; +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 { 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 { LedgerBookingJobService } from './ledger-booking-job.service'; +import { LedgerBookingService, LedgerLegInput } from './ledger-booking.service'; +import { LedgerAccountService } from './ledger-account.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 tagesaktuell + * revalued accounts (Minor R13-8). Batch-limited by Config.ledger.backfillBatchSize (no full-scan, §5.3 Minor R1-2). + */ +@Injectable() +export class LedgerMarkToMarketService { + private readonly logger = new DfxLogger(LedgerMarkToMarketService); + + constructor( + private readonly jobService: LedgerBookingJobService, + private readonly settingService: SettingService, + 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 + + try { + await this.markToMarket(); + } catch (e) { + this.logger.error('Ledger mark-to-market failed', e); + } + } + + private async markToMarket(): Promise { + const now = new Date(); + const accounts = await this.selectCandidates(); + if (!accounts.length) return; + + const marks = await this.markService.preload(Util.daysBefore(2, now), now); + const dayIndex = this.dayIndex(now); + const fx = await this.fxAccounts(); + + for (const account of 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) + } + } + } + + // §5.3 step 1: open ASSET/LIABILITY accounts (balance ≠ 0) PLUS accounts holding needsMark=true legs, batch-limited + private async selectCandidates(): Promise { + const openAccountIds = 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 can be marked (need an asset to look up) + .groupBy('leg.accountId') + .having('ABS(SUM(leg.amount)) > :tol OR BOOL_OR(leg.needsMark) = true', { tol: 1e-8 }) + .orderBy('leg.accountId', 'ASC') + .limit(Config.ledger.backfillBatchSize) + .getRawMany<{ accountId: number }>() + .then((rows) => rows.map((r) => r.accountId)); + + if (!openAccountIds.length) return []; + + return this.ledgerAccountRepository.findBy({ id: In(openAccountIds) }); + } + + // 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..1514517565 --- /dev/null +++ b/src/subdomains/core/accounting/services/ledger-mark.service.ts @@ -0,0 +1,124 @@ +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; +} + +/** + * 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) {} + + /** + * 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..8227b7b400 --- /dev/null +++ b/src/subdomains/core/accounting/services/ledger-query.service.ts @@ -0,0 +1,566 @@ +import { Injectable } 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, +} 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 { FeedStatus, LedgerReconciliationService } from './ledger-reconciliation.service'; + +const LEGS_PAGE_SIZE = 100; +const MARK_TO_MARKET_SOURCE = 'mark_to_market'; + +/** + * 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) 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 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); + + const accounts = await this.ledgerAccountRepository.find({ relations: { asset: { bank: true } } }); + const balances = await this.balancesByAccount(period.to); + + // ASSET-account recon snapshot for the list (against the persisted feed, §7) — feed read once for all accounts + const feedByAssetId = await this.feedByAssetId(); + + 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, 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) { + return { + accountId, + accountName: '', + currency: '', + period: LedgerDtoMapper.mapPeriod(period.from, period.to), + openingBalance: 0, + closingBalance: 0, + legs: [], + total: 0, + }; + } + + // opening balance = signed native Σ of all legs booked strictly before the period start + const openingBalance = await this.nativeBalanceBefore(accountId, period.from); + const periodNative = await 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(); + + const accounts = await this.ledgerAccountRepository.find({ + where: { type: AccountType.ASSET, active: true }, + relations: { asset: { bank: true } }, + }); + const feedByAssetId = await this.feedByAssetId(); + + const results: AccountReconResultDto[] = []; + for (const account of accounts) { + if (account.assetId == null) continue; + + const ledgerBalance = await this.journalNativeBalance(account.id); + const result = this.reconResult(account, ledgerBalance, feedByAssetId, 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 logs = await this.logService.getFinancialLogs(from, dailySample); + + const periods: EquityComparisonPeriodDto[] = []; + for (const log of logs) { + const finance = this.parseFinance(log.message); + const financialDataLogTotal = finance?.balancesTotal?.totalBalanceChf; + if (financialDataLogTotal == null) continue; + + const journalEquity = await this.journalEquityAt(log.created); + const difference = Util.round(journalEquity - financialDataLogTotal, 2); + const decomposition = await this.equityDecomposition(log.created, difference); + + 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); + } + + private async journalNativeBalance(accountId: number): Promise { + const raw = await this.ledgerLegRepository + .createQueryBuilder('leg') + .select('SUM(leg.amount)', 'native') + .where('leg.accountId = :accountId', { accountId }) + .getRawOne<{ native: string | null }>(); + + return Util.round(+(raw?.native ?? 0), 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, + now: Date, + ): AccountReconSnapshot | undefined { + if (account.type !== AccountType.ASSET || account.assetId == null) return undefined; + + const result = this.reconResult(account, ledgerBalance, feedByAssetId, now); + return { + reconStatus: result.status === 'suspense_alarm' ? 'diff' : result.status, + reconDiff: result.difference, + lastVerified: result.staleness === 'fresh' ? result.feedTimestamp : undefined, + }; + } + + private reconResult( + account: LedgerAccount, + ledgerBalance: number, + feedByAssetId: Map, + 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; + + const staleness = this.mapStaleness(classification.status); + const status = this.mapReconStatus(classification.status, difference); + + return { account, ledgerBalance, externalFeedBalance, difference, feedTimestamp, feedAge, staleness, status }; + } + + private mapStaleness(status: FeedStatus): LedgerFeedStaleness { + switch (status) { + case FeedStatus.FRESH: + return 'fresh'; + case FeedStatus.STALE: + return 'stale'; + case FeedStatus.PLACEHOLDER: + return 'placeholder'; + case FeedStatus.NO_FEED: + default: + return 'missing'; + } + } + + private mapReconStatus(status: FeedStatus, difference: number): LedgerReconResultStatus { + if (status === FeedStatus.STALE) return 'stale'; + if (status !== FeedStatus.FRESH) return 'unverified'; // placeholder / no-feed → unverified (§7.2) + + return Math.abs(difference) <= Config.ledger.reconciliationToleranceChf ? 'ok' : '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) --- // + + // signed Σ amountChf over the balance-account types up to `at` (Dr +, Cr − already in the leg sign convention, §2.3) + private async journalEquityAt(at: Date): Promise { + const raw = await this.ledgerLegRepository + .createQueryBuilder('leg') + .innerJoin('leg.tx', 'tx') + .innerJoin('leg.account', 'account') + .select('SUM(COALESCE(leg.amountChf, 0))', 'chf') + .where('tx.bookingDate <= :at', { at }) + .andWhere('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); + } + + // three buckets aggregated independently from ledger_* (Minor R13-5); `other` is the only residual (Class-5). + private async equityDecomposition(at: Date, difference: number): Promise { + const transitPhantom = await this.transitPhantom(at); + const staleFeed = await this.staleFeed(at); + const spreadFees = await this.spreadFees(at); + const other = Util.round(difference - (transitPhantom + staleFeed + spreadFees), 2); + + return { transitPhantom, staleFeed, spreadFees, other }; + } + + private async transitPhantom(at: Date): Promise { + const raw = await this.ledgerLegRepository + .createQueryBuilder('leg') + .innerJoin('leg.tx', 'tx') + .innerJoin('leg.account', 'account') + .select('SUM(COALESCE(leg.amountChf, 0))', 'chf') + .where('tx.bookingDate <= :at', { at }) + .andWhere('account.type = :type', { type: AccountType.TRANSIT }) + .getRawOne<{ chf: string | null }>(); + + return Util.round(+(raw?.chf ?? 0), 2); + } + + // Class-3: mark_to_market fx-revaluation legs on accounts that are currently unverified + private async staleFeed(at: Date): Promise { + const unverifiedAccountIds = await this.unverifiedAccountIds(); + if (!unverifiedAccountIds.length) return 0; + + const raw = await this.ledgerLegRepository + .createQueryBuilder('leg') + .innerJoin('leg.tx', 'tx') + .select('SUM(COALESCE(leg.amountChf, 0))', 'chf') + .where('tx.bookingDate <= :at', { at }) + .andWhere('tx.sourceType = :sourceType', { sourceType: MARK_TO_MARKET_SOURCE }) + .andWhere('leg.accountId IN (:...ids)', { ids: unverifiedAccountIds }) + .getRawOne<{ chf: string | null }>(); + + return Util.round(+(raw?.chf ?? 0), 2); + } + + // Class-6: Σ EXPENSE/spread-* + EXPENSE/network-fee (+ bank-fee, acquirer-fee) = executionCosts (type=EXPENSE) + private async spreadFees(at: Date): Promise { + const raw = await this.ledgerLegRepository + .createQueryBuilder('leg') + .innerJoin('leg.tx', 'tx') + .innerJoin('leg.account', 'account') + .select('SUM(COALESCE(leg.amountChf, 0))', 'chf') + .where('tx.bookingDate <= :at', { at }) + .andWhere('account.type = :type', { type: AccountType.EXPENSE }) + .andWhere('account.name NOT IN (:...excluded)', { + excluded: ['EXPENSE/refReward', 'EXPENSE/extraordinary', 'EXPENSE/fx-revaluation'], + }) + .getRawOne<{ chf: string | null }>(); + + return Util.round(+(raw?.chf ?? 0), 2); + } + + 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..7bb415c3ff --- /dev/null +++ b/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts @@ -0,0 +1,342 @@ +import { Injectable } from '@nestjs/common'; +import { CronExpression } from '@nestjs/schedule'; +import { Config } from 'src/config/config'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +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 { LogService } from 'src/subdomains/supporting/log/log.service'; +import { FinanceLog } from 'src/subdomains/supporting/log/dto/log.dto'; +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 { LedgerAccountRepository } from '../repositories/ledger-account.repository'; +import { LedgerLegRepository } from '../repositories/ledger-leg.repository'; +import { LedgerBookingJobService } from './ledger-booking-job.service'; + +const PLACEHOLDER_AMOUNT = 1.0; // Scrypt/EUR, Base/ZCHF placeholder feed → never reconcile (§7.1) + +export enum FeedStatus { + PLACEHOLDER = 'placeholder', + FRESH = 'fresh', + STALE = 'stale', + NO_FEED = 'no-feed', +} + +// §7.1 custody classification → staleness threshold (hours) +enum CustodyClass { + BANK_ACTIVE = 'bank-active', + BANK_DEAD = 'bank-dead', + ON_CHAIN_ACTIVE = 'on-chain-active', + ON_CHAIN_INACTIVE = 'on-chain-inactive', + EXCHANGE_ACTIVE = 'exchange-active', + EXCHANGE_ORDER_DRIVEN = 'exchange-order-driven', + EXCHANGE_FEEDLESS = 'exchange-feedless', +} + +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, + [CustodyClass.EXCHANGE_ORDER_DRIVEN]: 48, + [CustodyClass.EXCHANGE_FEEDLESS]: 0, // unverified from start +}; + +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 tagesaktuell + * 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, + ) {} + + @DfxCron(CronExpression.EVERY_DAY_AT_5AM, { process: Process.LEDGER_RECONCILIATION }) + async run(): Promise { + if (!(await this.jobService.isLedgerReady())) return; // cutover-gate (Blocker R1-6) + + try { + await this.reconcile(); + } catch (e) { + this.logger.error('Ledger reconciliation failed', e); + } + } + + 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) + const feed = await this.liquidityManagementBalanceService.getBalances(); + + await this.reconcileAssets(feed, now); + await this.checkTransitAge(now); + await this.checkSuspense(); + await this.checkEquityParity(); + } + + // --- ASSET RECONCILIATION (§7.1/§7.2/§7.3) --- // + + private async reconcileAssets(feed: LiquidityBalance[], now: Date): Promise { + const feedByAssetId = new Map(feed.filter((b) => b.asset?.id != null).map((b) => [b.asset.id, b])); + + const assetAccounts = await this.ledgerAccountRepository.find({ + where: { type: AccountType.ASSET, active: true }, + take: Config.ledger.backfillBatchSize, + }); + + const unverified: string[] = []; + 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) + } + + await this.reconcileFreshAsset(account, balance, now); + } + + // §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 }; + } + if (custodyClass === CustodyClass.EXCHANGE_FEEDLESS) { + return { status: FeedStatus.NO_FEED, custodyClass, thresholdHours }; // unverified from start (§7.1) + } + + const ageHours = Util.hoursDiff(balance.updated, now); + return { + status: ageHours > thresholdHours ? FeedStatus.STALE : FeedStatus.FRESH, + custodyClass, + thresholdHours, + }; + } + + // §7.1 custody-type → class. On-chain assets are blockchain-backed; bank/exchange assets are CUSTODY rows. + private classifyCustody(asset: Asset | undefined): CustodyClass { + if (!asset) return CustodyClass.ON_CHAIN_INACTIVE; + + // bank custody (asset linked to a Bank) → SEPA active threshold + if (asset.bank) return CustodyClass.BANK_ACTIVE; + + // exchange/feedless custody rows carry a non-blockchain custody marker + const blockchain = asset.blockchain; + const isOnChain = blockchain != null && blockchain !== Blockchain.KRAKEN && blockchain !== Blockchain.BINANCE; + + return isOnChain ? CustodyClass.ON_CHAIN_ACTIVE : CustodyClass.EXCHANGE_ACTIVE; + } + + // §7: compare journal balance vs feed within tolerance; on diff → log (the journal stays authoritative, observer) + private async reconcileFreshAsset(account: LedgerAccount, balance: LiquidityBalance, now: Date): Promise { + const journal = await this.journalNativeBalance(account.id); + const feedAmount = balance.amount ?? 0; + const diff = Util.round(journal - feedAmount, 8); + + if (Math.abs(diff) <= Config.ledger.reconciliationToleranceChf) return; // within tolerance → balanced + + await this.sendAlarm( + MailContext.LEDGER_RECONCILIATION, + 'Ledger reconciliation diff', + [`${account.name}: journal ${journal} vs feed ${feedAmount} (diff ${diff})`], + `ledger-recon-${account.id}-${this.dayKey(now)}`, + ); + } + + // --- TRANSIT-AGE (§7.4) --- // + + // transit account with balance ≠ 0 older than route threshold → alarm; age = MIN(bookingDate) of open legs + private async checkTransitAge(now: Date): Promise { + const overdue = await this.ledgerLegRepository + .createQueryBuilder('leg') + .innerJoin('leg.account', 'account') + .select('account.name', 'name') + .addSelect('SUM(leg.amount)', 'native') + .addSelect('MIN(leg.bookingDate)', 'oldest') + .where('account.type = :type', { type: AccountType.TRANSIT }) + .groupBy('account.id') + .addGroupBy('account.name') + .having('ABS(SUM(leg.amount)) > :tol', { tol: 1e-8 }) + .getRawMany<{ name: string; native: string; oldest: Date }>(); + + const thresholdDays = Config.ledger.transitAlarmThresholdDays; + const aged = overdue.filter((t) => t.oldest && Util.daysDiff(new Date(t.oldest), now) > thresholdDays); + if (!aged.length) return; + + await this.sendAlarm( + MailContext.LEDGER_TRANSIT_OVERDUE, + 'Ledger transit overdue', + aged.map((t) => `${t.name}: balance ${t.native} open since ${new Date(t.oldest).toISOString()}`), + `ledger-transit-${this.dayKey(now)}`, + ); + } + + // --- 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) --- // + + // journalEquity = signed Σ over all balance accounts (ASSET+/TRANSIT+/LIABILITY−/SUSPENSE/ROUNDING), no leading + // minus (Major R8-1) → positive, sign-consistent with totalBalanceChf. Compared against the FinancialDataLog total. + private async checkEquityParity(): Promise { + const journalEquity = await this.journalEquity(); + + const snapshot = await this.logService.getLatestFinancialLog(); + const finance = snapshot ? this.parseFinance(snapshot.message) : undefined; + if (!finance) return; + + const totalBalanceChf = finance.balancesTotal?.totalBalanceChf; + if (totalBalanceChf == null) return; + + const difference = Util.round(journalEquity - totalBalanceChf, 2); + this.logger.info( + `Ledger equity parity: journalEquity ${journalEquity} vs totalBalanceChf ${totalBalanceChf} (difference ${difference})`, + ); + } + + // 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 --- // + + private async journalNativeBalance(accountId: number): Promise { + const raw = await this.ledgerLegRepository + .createQueryBuilder('leg') + .select('SUM(leg.amount)', 'native') + .where('leg.accountId = :accountId', { accountId }) + .getRawOne<{ native: string | null }>(); + + return Util.round(+(raw?.native ?? 0), 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/supporting/notification/enums/index.ts b/src/subdomains/supporting/notification/enums/index.ts index e5facffbd5..0cbab85ea9 100644 --- a/src/subdomains/supporting/notification/enums/index.ts +++ b/src/subdomains/supporting/notification/enums/index.ts @@ -53,6 +53,9 @@ export enum MailContext { EMAIL_VERIFICATION = 'EmailVerification', RECOMMENDATION_MAIL = 'RecommendationMail', RECOMMENDATION_CONFIRMATION = 'RecommendationConfirmation', + LEDGER_RECONCILIATION = 'LedgerReconciliation', + LEDGER_SUSPENSE = 'LedgerSuspense', + LEDGER_TRANSIT_OVERDUE = 'LedgerTransitOverdue', } export enum MailContextType { @@ -110,4 +113,7 @@ 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, }; From 8719a8d198314b30624e604f1a7bec8e44b8149a Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 12 Jun 2026 13:15:09 +0200 Subject: [PATCH 02/25] fix(accounting): address review findings - bound cutover opening queries with lookback window and settled filters - correct check-constraint name to match TypeORM naming strategy - paginate mark-to-market candidate selection across all open accounts - align cutover watermark init with per-consumer settled semantics - harden isolation gates and fix vacuous setting stub in isolation test --- migration/1781235331368-AddLedgerTables.js | 2 +- scripts/ledger-isolation-gate.js | 23 +- scripts/ledger-isolation-gate.sh | 26 +- .../dto/__tests__/ledger-dto.mapper.spec.ts | 10 +- .../db-write-isolation.integration.spec.ts | 50 +++- .../evidence-week.integration.spec.ts | 238 ++++++++++++++++++ .../__tests__/integration/in-memory-ledger.ts | 16 +- .../integration/isolation-gate.spec.ts | 107 ++++++++ .../staleness-cutover.integration.spec.ts | 13 +- .../__tests__/ledger-booking.service.spec.ts | 114 +++++++++ .../__tests__/ledger-cutover.service.spec.ts | 81 +++++- .../ledger-mark-to-market.service.spec.ts | 21 ++ .../__tests__/ledger-query.service.spec.ts | 9 +- .../ledger-reconciliation.service.spec.ts | 37 +++ .../__tests__/bank-tx.consumer.spec.ts | 65 ++++- .../__tests__/buy-crypto.consumer.spec.ts | 16 +- .../__tests__/buy-fiat.consumer.spec.ts | 66 ++++- .../__tests__/crypto-input.consumer.spec.ts | 6 +- .../__tests__/payout-order.consumer.spec.ts | 45 ++++ .../services/consumers/bank-tx.consumer.ts | 115 +++++++-- .../services/consumers/buy-crypto.consumer.ts | 65 +++-- .../services/consumers/buy-fiat.consumer.ts | 116 +++++++-- .../consumers/crypto-input.consumer.ts | 52 ++-- .../consumers/ledger-watermark.helper.ts | 57 +++++ .../consumers/payout-order.consumer.ts | 57 ++++- .../services/ledger-booking.service.ts | 104 ++++++++ .../services/ledger-cutover.service.ts | 124 +++++++-- .../services/ledger-mark-to-market.service.ts | 66 +++-- .../services/ledger-reconciliation.service.ts | 55 ++-- 29 files changed, 1605 insertions(+), 151 deletions(-) diff --git a/migration/1781235331368-AddLedgerTables.js b/migration/1781235331368-AddLedgerTables.js index 7422e1e24d..da512e9b1d 100644 --- a/migration/1781235331368-AddLedgerTables.js +++ b/migration/1781235331368-AddLedgerTables.js @@ -26,7 +26,7 @@ module.exports = class AddLedgerTables1781235331368 { 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" integer NOT NULL DEFAULT 0, "reversalOfId" integer, CONSTRAINT "UQ_86a66bea626f9a32e1d26a7b136" UNIQUE ("sourceType", "sourceId", "seq"), CONSTRAINT "CHK_dcc2c4dd65621661cdd1f0b370" CHECK ("amountChfSum" = 0), CONSTRAINT "PK_2a5f197e0dbaa656731fee263d8" PRIMARY KEY ("id"))`, + `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" integer 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") `); diff --git a/scripts/ledger-isolation-gate.js b/scripts/ledger-isolation-gate.js index dff13b21c0..b184938f9e 100644 --- a/scripts/ledger-isolation-gate.js +++ b/scripts/ledger-isolation-gate.js @@ -15,7 +15,7 @@ const path = require('path'); const TARGET_DIR = process.argv[2] || 'src/subdomains/core/accounting'; -// the 4-block forbidden pattern (§4.10) — kept char-for-char equivalent to the shell PATTERN +// the 7-block forbidden pattern (§4.10) — kept char-for-char equivalent to the shell PATTERN const PATTERN = new RegExp( [ 'pricingService|PricingService|getPrice\\(|getPriceAt|priceProvider|CoinGecko|HttpService', @@ -23,7 +23,26 @@ const PATTERN = new RegExp( '\\blogService\\.(create|update)\\(|\\bsettingService\\.(setObj|updateProcess|addIpToBlacklist|deleteIpFromBlacklist)\\(', '\\.complete\\(|checkOrderCompletion|syncExchanges|doPayout|checkPayoutCompletionData|triggerWebhook|calculateSpreadFee', 'balanceRepo\\.(update|save|insert|delete|remove)\\(|\\b(?!ledger)\\w*Repo(sitory)?\\.(update|save|insert|delete|remove)\\(', - '\\bmanager\\.(save|insert|update|delete|remove|upsert|softDelete|softRemove|recover)\\(|\\bmanager\\.query\\(', + // 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|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)\\(|\\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('|'), ); diff --git a/scripts/ledger-isolation-gate.sh b/scripts/ledger-isolation-gate.sh index c8f59a3b9f..323bb73fbe 100755 --- a/scripts/ledger-isolation-gate.sh +++ b/scripts/ledger-isolation-gate.sh @@ -25,13 +25,35 @@ set -u TARGET_DIR="${1:-src/subdomains/core/accounting}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# the 4-block forbidden pattern (§4.10); \b word-boundary anchors keep the method tokens injection-name-independent +# the 7-block forbidden pattern (§4.10); \b word-boundary anchors keep the method tokens injection-name-independent PATTERN='pricingService|PricingService|getPrice\(|getPriceAt|priceProvider|CoinGecko|HttpService' PATTERN+='|\brefreshBalances\(|\brefreshBankBalance|\bhasPendingOrders|integration\.getBalances|integration\.hasPendingOrders|BankAdapter|balanceIntegrationFactory|LiquidityBalanceIntegrationFactory' PATTERN+='|\blogService\.(create|update)\(|\bsettingService\.(setObj|updateProcess|addIpToBlacklist|deleteIpFromBlacklist)\(' PATTERN+='|\.complete\(|checkOrderCompletion|syncExchanges|doPayout|checkPayoutCompletionData|triggerWebhook|calculateSpreadFee' PATTERN+='|balanceRepo\.(update|save|insert|delete|remove)\(|\b(?!ledger)\w*Repo(sitory)?\.(update|save|insert|delete|remove)\(' -PATTERN+='|\bmanager\.(save|insert|update|delete|remove|upsert|softDelete|softRemove|recover)\(|\bmanager\.query\(' +# 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. +PATTERN+='|\b\w*[Mm]anager\.(save|insert|update|delete|remove|upsert|softDelete|softRemove|recover|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). +PATTERN+='|getRepository\([^)]*\)\.(save|insert|update|delete|remove|upsert|softDelete|softRemove|recover)\(|\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. +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__') 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 index 61f6de5b13..f9ed97c69e 100644 --- a/src/subdomains/core/accounting/dto/__tests__/ledger-dto.mapper.spec.ts +++ b/src/subdomains/core/accounting/dto/__tests__/ledger-dto.mapper.spec.ts @@ -162,21 +162,23 @@ describe('LedgerDtoMapper', () => { 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-Raiffeisen-EUR', + name: 'SUSPENSE/untracked-bank-EUR', type: AccountType.SUSPENSE, currency: 'EUR', }); - const entry = leg({ id: 2, amount: 600000, amountChf: 580000, account }); + 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(600000); - expect(dto.amountChf).toBe(580000); + 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/services/__tests__/integration/db-write-isolation.integration.spec.ts b/src/subdomains/core/accounting/services/__tests__/integration/db-write-isolation.integration.spec.ts index 4735285323..980af0adf2 100644 --- 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 @@ -59,18 +59,46 @@ describe('Ledger DB-write isolation after a consumer/alarm run (§10.2)', () => jest.spyOn(markService, 'preload').mockResolvedValue(new LedgerMarkCache(markMap)); }); - // a source-table repository whose ALL write methods are spied on (must never be called by the consumer) - function readOnlyRepo(rows: T[]): { repo: Repository; writeSpies: jest.SpyInstance[] } { + // 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(rows as any); - const writeSpies = WRITE_METHODS.map((m) => jest.spyOn(repo as any, m)); - return { repo, writeSpies }; + 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(); @@ -109,6 +137,9 @@ describe('Ledger DB-write isolation after a consumer/alarm run (§10.2)', () => 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, @@ -128,12 +159,17 @@ describe('Ledger DB-write isolation after a consumer/alarm run (§10.2)', () => // the run actually booked something (otherwise the isolation assertion is vacuous) expect(ledger.txs.length).toBeGreaterThan(0); - // NO write method of the crypto_input / buy_fiat source repos was ever called (strict read-only observer) + // (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(); } - // the ONLY non-ledger_* writes are the two ledger watermark settings (sanctioned R2-Ausnahme-a) + // (3) the ONLY non-ledger_* writes are the two ledger watermark settings (sanctioned R2-Ausnahme-a) for (const key of [...ciSetting.setKeys, ...bfSetting.setKeys]) { expect(key).toMatch(/^ledgerWatermark\.|^ledgerCutoverLogId$/); } 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 index 14a85ed62e..f748a00f8d 100644 --- 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 @@ -73,6 +73,7 @@ describe('Ledger evidence-week integration (§10.2)', () => { 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; } @@ -102,6 +103,7 @@ describe('Ledger evidence-week integration (§10.2)', () => { 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', @@ -156,6 +158,7 @@ describe('Ledger evidence-week integration (§10.2)', () => { markService, bankTxRepo, bankRepo, + ledger.ledgerTxRepository(), ); } @@ -468,4 +471,239 @@ describe('Ledger evidence-week integration (§10.2)', () => { 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 + // eröffnet 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 + + return new BankTxConsumer( + s, + ledger.bookingService, + ledger.accountService, + markService, + bankTxRepo, + bankRepo, + ledger.ledgerTxRepository(), + ); + } + + it('keeps seq0, books a reversal (seq1, reversalOf=seq0, inverted) + a re-book (seq2, BUY_CRYPTO); 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 musterbeispiel 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); + expect(all.map((t) => t.seq)).toEqual([0, 1, 2]); // seq strictly monotonic over the cycle + + // (a) original seq0 stays untouched (append-only, §4.12 Z.802) + expect(all[0].seq).toBe(0); + expect(all[0].reversalOfId).toBeUndefined(); + + // (b) seq1 is the reversal of seq0 (reversalOfId points at the ORIGINAL, §4.12 Z.811) with inverted legs + expect(all[1].seq).toBe(1); + expect(all[1].reversalOfId).toBe(seq0.id); + + // (c) seq2 is the re-book (reversalOf NULL — a new valid booking), now BUY_CRYPTO → Cr buyCrypto-received + expect(all[2].seq).toBe(2); + 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 index b79520035b..57c4082ddd 100644 --- a/src/subdomains/core/accounting/services/__tests__/integration/in-memory-ledger.ts +++ b/src/subdomains/core/accounting/services/__tests__/integration/in-memory-ledger.ts @@ -167,8 +167,11 @@ export class InMemoryLedger { 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) : Object.assign(new LedgerLeg(), p); + 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) => { @@ -190,6 +193,15 @@ export class InMemoryLedger { 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) })), + ), } as any); return dataSource; 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 index 9d242815c4..619a7a3342 100644 --- a/src/subdomains/core/accounting/services/__tests__/integration/isolation-gate.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/integration/isolation-gate.spec.ts @@ -65,6 +65,33 @@ describe('Ledger isolation gate (§4.10 / §10.1 self-test)', () => { { 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();', + }, ]; it.each(violations)('flags a known violation: $name', ({ code }) => { @@ -99,6 +126,26 @@ describe('Ledger isolation gate (§4.10 / §10.1 self-test)', () => { 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 }) => { @@ -122,4 +169,64 @@ describe('Ledger isolation gate (§4.10 / §10.1 self-test)', () => { expect(result.output).toContain('manager.save(BankTx, e)'); expect(result.output).not.toContain('ledger-allowlist'); // the allowlisted line is filtered out of the matches }); + + // --- 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 index a3fd1ad283..532f1aad58 100644 --- 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 @@ -9,8 +9,11 @@ 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 { 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 { BankTx } from 'src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity'; import { Log } from 'src/subdomains/supporting/log/log.entity'; import { LogService } from 'src/subdomains/supporting/log/log.service'; @@ -246,7 +249,12 @@ describe('Ledger staleness + cutover integration (§10.2)', () => { const emptyRepo = () => { const repo = createMock>(); jest.spyOn(repo, 'find').mockResolvedValue([]); - const maxQb: any = { select: () => maxQb, where: () => maxQb, getRawOne: () => Promise.resolve({ max: 0 }) }; + const maxQb: any = { + select: () => maxQb, + where: () => maxQb, + andWhere: () => maxQb, + getRawOne: () => Promise.resolve({ max: 0 }), + }; jest.spyOn(repo, 'createQueryBuilder').mockReturnValue(maxQb); return repo; }; @@ -266,6 +274,9 @@ describe('Ledger staleness + cutover integration (§10.2)', () => { { 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(); 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 index 8b15eaac88..98fb8f3f14 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-booking.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-booking.service.spec.ts @@ -231,4 +231,118 @@ describe('LedgerBookingService', () => { 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; + }); + 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), + ), + 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); + expect(all.map((t) => t.seq)).toEqual([0, 1, 2]); // monotone over the cycle + expect(all[1].reversalOfId).toBe(all[0].id); // seq1 reverses the ORIGINAL seq0 + expect(all[2].reversalOfId).toBeUndefined(); // seq2 re-book is a new valid booking + }); + + 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]); + expect(all[1].reversalOfId).toBe(all[0].id); // flat reversal, no re-book + }); + }); }); 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 index 4c10ecb218..7b5461f596 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts @@ -7,6 +7,9 @@ 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'; @@ -65,6 +68,9 @@ describe('LedgerCutoverService', () => { let cryptoInputRepo: Repository; let exchangeTxRepo: Repository; let payoutOrderRepo: Repository; + let liquidityManagementOrderRepo: Repository; + let tradingOrderRepo: Repository; + let liquidityOrderRepo: Repository; let booked: LedgerTxInput[]; let nextSeqByKey: Map; @@ -96,6 +102,9 @@ describe('LedgerCutoverService', () => { cryptoInputRepo = createMock>(); exchangeTxRepo = createMock>(); payoutOrderRepo = createMock>(); + liquidityManagementOrderRepo = createMock>(); + tradingOrderRepo = createMock>(); + liquidityOrderRepo = createMock>(); jest.spyOn(bookingService, 'bookTx').mockImplementation((input: LedgerTxInput) => { booked.push(input); @@ -117,13 +126,24 @@ describe('LedgerCutoverService', () => { jest.spyOn(settingService, 'getObj').mockResolvedValue([] as any); jest.spyOn(markService, 'preload').mockResolvedValue(new LedgerMarkCache(new Map())); - // watermark MAX(id) query builder stub + // watermark MAX(id) query builder stub (chainable where/andWhere for the per-consumer settled filters) const maxQb: any = { select: () => maxQb, where: () => maxQb, + andWhere: () => maxQb, getRawOne: () => Promise.resolve({ max: 0 }), }; - for (const repo of [bankTxRepo, cryptoInputRepo, exchangeTxRepo, payoutOrderRepo, buyCryptoRepo, buyFiatRepo]) { + for (const repo of [ + bankTxRepo, + cryptoInputRepo, + exchangeTxRepo, + payoutOrderRepo, + buyCryptoRepo, + buyFiatRepo, + liquidityManagementOrderRepo, + tradingOrderRepo, + liquidityOrderRepo, + ]) { jest.spyOn(repo, 'createQueryBuilder').mockReturnValue(maxQb); } @@ -142,6 +162,9 @@ describe('LedgerCutoverService', () => { { 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(); @@ -330,6 +353,11 @@ describe('LedgerCutoverService', () => { 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'); @@ -346,5 +374,54 @@ describe('LedgerCutoverService', () => { 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 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 }); + }, + }; + 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 + }); }); }); 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 index 1e5e9d0f98..d654a8d2ee 100644 --- 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 @@ -202,4 +202,25 @@ describe('LedgerMarkToMarketService', () => { expect(markService.preload).not.toHaveBeenCalled(); expect(bookingService.bookTx).not.toHaveBeenCalled(); }); + + // §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 + }); }); 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 index 18434109a5..da29d695c0 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-query.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-query.service.spec.ts @@ -271,13 +271,16 @@ describe('LedgerQueryService', () => { 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-Raiffeisen-EUR', + name: 'SUSPENSE/untracked-bank-EUR', type: AccountType.SUSPENSE, currency: 'EUR', }); - const legA = makeLeg({ id: 1, amount: 600000, amountChf: 580000, account }, account, { + 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, { @@ -287,7 +290,7 @@ describe('LedgerQueryService', () => { const res = await service.getSuspense(); - expect(res.totalChf).toBe(580950); + expect(res.totalChf).toBe(5750); expect(res.legs).toHaveLength(2); expect(res.legs[0].currency).toBe('EUR'); expect(res.legs[0].age).toBeGreaterThan(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 index fe416bb87c..1d4a552523 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-reconciliation.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-reconciliation.service.spec.ts @@ -244,6 +244,43 @@ describe('LedgerReconciliationService', () => { 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 + }); + it('does NOT alarm on a placeholder feed (§7.1)', async () => { const now = new Date(); jest 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 index af69254802..96ea7cb404 100644 --- 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 @@ -6,6 +6,7 @@ import { TestUtil } from 'src/shared/utils/test.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 { 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'; @@ -19,6 +20,7 @@ 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', @@ -39,6 +41,7 @@ describe('BankTxConsumer', () => { let settingService: SettingService; let bankTxRepo: Repository; let bankRepo: Repository; + let ledgerTxRepo: Repository; let booked: LedgerTxInput[]; let createdAccounts: Map; @@ -56,6 +59,11 @@ describe('BankTxConsumer', () => { settingService = createMock(); bankTxRepo = createMock>(); bankRepo = createMock>(); + ledgerTxRepo = 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); jest.spyOn(bookingService, 'bookTx').mockImplementation((input: LedgerTxInput) => { booked.push(input); @@ -95,6 +103,7 @@ describe('BankTxConsumer', () => { { provide: SettingService, useValue: settingService }, { provide: getRepositoryToken(BankTx), useValue: bankTxRepo }, { provide: getRepositoryToken(Bank), useValue: bankRepo }, + { provide: getRepositoryToken(LedgerTx), useValue: ledgerTxRepo }, ], }).compile(); @@ -102,7 +111,11 @@ describe('BankTxConsumer', () => { }); function mockBatch(rows: BankTx[], bank?: Partial): void { - jest.spyOn(bankTxRepo, 'find').mockResolvedValue(rows); + // 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') @@ -187,6 +200,56 @@ describe('BankTxConsumer', () => { expect(cents(legs)).toBe(0); }); + 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({ 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 index 24ab09bada..c246f7b022 100644 --- 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 @@ -68,10 +68,17 @@ describe('BuyCryptoConsumer', () => { return Promise.resolve(acc); }); - // gate lookup: countBy returns 1 when the gate is open (seq0 crypto_input / cutover marker exists) + // gate lookup: countBy returns 1 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. jest.spyOn(ledgerTxRepo, 'countBy').mockImplementation(() => Promise.resolve(gateOpen ? 1 : 0)); 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({ @@ -90,7 +97,12 @@ describe('BuyCryptoConsumer', () => { }); const cents = (legs: LedgerLegInput[]) => legs.reduce((s, l) => s + Math.round((l.amountChf ?? 0) * 100), 0); - const mockBatch = (rows: BuyCrypto[]) => jest.spyOn(buyCryptoRepo, 'find').mockResolvedValue(rows); + // 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); 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 index c666b92af6..25184680ba 100644 --- 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 @@ -48,6 +48,8 @@ describe('BuyFiatConsumer', () => { let nextSeqValue: number; let gateCount: number; // countBy 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); @@ -59,6 +61,8 @@ describe('BuyFiatConsumer', () => { nextSeqValue = 0; gateCount = 1; seq0PaymentLinkChf = undefined; + cutoverOwedOpeningChf = undefined; + cutoverLogId = undefined; accounts = new Map([ ['Bank/CHF', chfBank], ['Bank/EUR', eurBank], @@ -91,7 +95,15 @@ describe('BuyFiatConsumer', () => { }); jest.spyOn(ledgerTxRepo, 'countBy').mockImplementation(() => Promise.resolve(gateCount)); - jest.spyOn(ledgerTxRepo, 'findOne').mockImplementation(() => { + 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 }); @@ -100,6 +112,7 @@ describe('BuyFiatConsumer', () => { jest.spyOn(markService, 'preload').mockResolvedValue(new LedgerMarkCache(markMap)); 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({ @@ -119,7 +132,12 @@ describe('BuyFiatConsumer', () => { }); const cents = (legs: LedgerLegInput[]) => legs.reduce((s, l) => s + Math.round((l.amountChf ?? 0) * 100), 0); - const mockBatch = (rows: BuyFiat[]) => jest.spyOn(buyFiatRepo, 'find').mockResolvedValue(rows); + // 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) => @@ -211,6 +229,50 @@ describe('BuyFiatConsumer', () => { 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) + gateCount = 0; // 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 () => { gateCount = 0; // neither G-a nor G-b 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 index 3d64df2fd0..ba91edf865 100644 --- 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 @@ -104,7 +104,11 @@ describe('CryptoInputConsumer', () => { }); const cents = (legs: LedgerLegInput[]) => legs.reduce((s, l) => s + Math.round((l.amountChf ?? 0) * 100), 0); - const mockBatch = (rows: CryptoInput[]) => jest.spyOn(cryptoInputRepo, 'find').mockResolvedValue(rows); + // 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(); 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 index 5e1186539b..fdceb52b39 100644 --- 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 @@ -12,6 +12,8 @@ import { 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'; @@ -49,6 +51,7 @@ describe('PayoutOrderConsumer', () => { let refRewardRepo: Repository; let buyCryptoRepo: Repository; let buyFiatRepo: Repository; + let ledgerTxRepo: Repository; let booked: LedgerTxInput[]; let accounts: Map; @@ -79,6 +82,11 @@ describe('PayoutOrderConsumer', () => { 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); @@ -116,6 +124,7 @@ describe('PayoutOrderConsumer', () => { { provide: getRepositoryToken(RefReward), useValue: refRewardRepo }, { provide: getRepositoryToken(BuyCrypto), useValue: buyCryptoRepo }, { provide: getRepositoryToken(BuyFiat), useValue: buyFiatRepo }, + { provide: getRepositoryToken(LedgerTx), useValue: ledgerTxRepo }, ], }).compile(); @@ -165,6 +174,42 @@ describe('PayoutOrderConsumer', () => { 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); diff --git a/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts b/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts index 126014cc9d..1860226356 100644 --- a/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts @@ -9,12 +9,17 @@ 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 { LessThan, 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 { LedgerBookingService, LedgerLegInput, LedgerTxInput } from '../ledger-booking.service'; import { LedgerMarkCache, LedgerMarkService } from '../ledger-mark.service'; -import { getLedgerWatermark, setLedgerWatermark } from './ledger-watermark.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'; // bank-side exchange route segment per type (§3.3 {ex}); SCB route is created lazily (§3.3 "neue Routen lazy") @@ -49,6 +54,7 @@ export class BankTxConsumer { private readonly markService: LedgerMarkService, @InjectRepository(BankTx) private readonly bankTxRepo: Repository, @InjectRepository(Bank) private readonly bankRepo: Repository, + @InjectRepository(LedgerTx) private readonly ledgerTxRepo: Repository, ) {} async process(): Promise { @@ -57,6 +63,30 @@ export class BankTxConsumer { lastReversalScan: new Date(0), }; + await this.processForward(watermark); + + // content-change scan (§4.12): re-classification of an already-booked bank_tx (the §4.12 musterbeispiel + // 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) => { + await this.reconcileBooking( + tx, + await this.markService.preload(tx.bookingDate ?? tx.created, tx.bookingDate ?? tx.created), + ); + }, + ); + } + + 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)) }, @@ -87,22 +117,37 @@ export class BankTxConsumer { } 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; // skipped type (BUY_FIAT / TEST_FIAT_FIAT) + if (!legs) return undefined; // skipped type (BUY_FIAT / TEST_FIAT_FIAT) - await this.bookingService.bookTx({ - sourceType: SOURCE_TYPE, - sourceId: `${tx.id}`, - seq: 0, - bookingDate, - valueDate, - legs, - }); + return { sourceType: SOURCE_TYPE, sourceId: `${tx.id}`, seq: 0, bookingDate, valueDate, legs }; } private async buildLegs( @@ -193,7 +238,11 @@ export class BankTxConsumer { return this.withFxPlug([bank, received]); } - // §4.2a — BUY_CRYPTO_RETURN DBIT: Dr LIABILITY/buyCrypto-owed / Cr ASSET/bank + fx-plug + // §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, @@ -201,12 +250,42 @@ export class BankTxConsumer { marks: LedgerMarkCache, ): Promise { const bank = this.bankAssetLeg(ctx, -tx.amount, bookingDate, marks, await this.bankAccount(ctx)); - // owed-Dr CHF = completion/opening value; the mark-consistent return value is the deterministic reference - const owed = this.namedLeg(await this.liability('buyCrypto-owed'), -(bank.amountChf ?? 0)); + 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]); } + // 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, @@ -284,7 +363,13 @@ export class BankTxConsumer { return [expense, bank]; } - // §4.2 BANK_TX_RETURN/BANK_TX_REPEAT CRDT: Dr ASSET/bank / Cr LIABILITY/{bucket} (both mark, 2-leg) + // §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, diff --git a/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts b/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts index 56d1270d0b..8373603f7d 100644 --- a/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts @@ -9,12 +9,13 @@ import { MoreThan, Repository } from 'typeorm'; import { AccountType, LedgerAccount } from '../../entities/ledger-account.entity'; import { LedgerTx } from '../../entities/ledger-tx.entity'; import { LedgerAccountService } from '../ledger-account.service'; -import { LedgerBookingService, LedgerLegInput } from '../ledger-booking.service'; -import { getLedgerWatermark, setLedgerWatermark } from './ledger-watermark.helper'; +import { LedgerBookingService, LedgerLegInput, LedgerTxInput } from '../ledger-booking.service'; +import { getLedgerWatermark, runContentChangeScan, setLedgerWatermark } from './ledger-watermark.helper'; const SOURCE_TYPE = 'buy_crypto'; const CRYPTO_INPUT_SOURCE = 'crypto_input'; const CUTOVER_SOURCE = 'cutover'; +const CUTOVER_LOG_ID_KEY = 'ledgerCutoverLogId'; const CHF = 'CHF'; /** @@ -43,6 +44,31 @@ export class BuyCryptoConsumer { 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 } }, + async (bc: BuyCrypto) => { + // §4.12: a Card-input amount/fee change (amountInChf) reverses + re-books the seq0 Card-input tx; then the + // idempotent forward book() appends any newly-settled seqs (seq1 completion). Non-Card inputs have no seq0 + // here (booked by the CryptoInput/BankTx single booker) → buildSeq0Input returns undefined → no-op reversal. + const seq0 = await this.buildCardInputSeq0(bc); + if (seq0) await this.bookingService.reverseAndRebookIfChanged(seq0); + await this.book(bc); + }, + ); + } + + 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 } }, @@ -86,21 +112,29 @@ export class BuyCryptoConsumer { // §4.6 seq0 — Card input only: Dr ASSET/Checkout{ccy} / Cr LIABILITY/buyCrypto-received (= amountInChf). // Bank input → BankTx consumer; crypto input → CryptoInput consumer (§4.1 single booker). private async bookCardInput(bc: BuyCrypto): Promise { - if (!bc.checkoutTx) return; // not a Card input → seq0 not this consumer's job - if (bc.amountInChf == null) return; 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; + const checkout = await this.checkoutAccount(bc.checkoutTx.currency); const received = await this.liability('buyCrypto-received'); - await this.bookingService.bookTx({ + return { sourceType: SOURCE_TYPE, sourceId: `${bc.id}`, seq: 0, bookingDate: bc.created, valueDate: bc.created, legs: [this.chfLeg(checkout, bc.amountInChf), this.chfLeg(received, -bc.amountInChf)], - }); + }; } /** @@ -154,17 +188,20 @@ export class BuyCryptoConsumer { if (ga > 0) return true; // G-a } - // G-b: cutover opening on buyCrypto-received for this buy_crypto.id (synthetic seq0 marker, §6.1) - const gb = await this.ledgerTxRepo.countBy({ - sourceType: CUTOVER_SOURCE, - sourceId: `${this.cutoverReceivedSourceId(bc.id)}`, - }); + // G-b: cutover opening on buyCrypto-received for this buy_crypto.id (synthetic seq0 marker, §6.1). The cutover + // writes `${snapshotLogId}:buy_crypto:${id}`, so the prefix must be resolved from ledgerCutoverLogId — an exact + // `:buy_crypto:${id}` match would NEVER hit (the snapshot logId prefix is missing → G-b dead, Blocker R4-2). + const cutoverSourceId = await this.cutoverReceivedSourceId(bc.id); + if (cutoverSourceId == null) return false; // cutover not run yet → no opening to match + const gb = await this.ledgerTxRepo.countBy({ sourceType: CUTOVER_SOURCE, sourceId: cutoverSourceId }); return gb > 0; } - // the cutover per-row received marker sourceId suffix (§6.1 / §4.7 G-b); the prefix is the snapshot logId - private cutoverReceivedSourceId(buyCryptoId: number): string { - return `:buy_crypto:${buyCryptoId}`; + // 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; } // --- HELPERS --- // diff --git a/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts b/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts index 726b84cac3..8242ee4104 100644 --- a/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts @@ -10,15 +10,17 @@ 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 { LedgerBookingService, LedgerLegInput, LedgerTxInput } from '../ledger-booking.service'; import { LedgerMarkCache, LedgerMarkService } from '../ledger-mark.service'; -import { getLedgerWatermark, setLedgerWatermark } from './ledger-watermark.helper'; +import { getLedgerWatermark, 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 CHF = 'CHF'; const PAYMENT_LINK = 'LIABILITY/paymentLink'; +const BUY_FIAT_OWED = 'LIABILITY/buyFiat-owed'; /** * The Class-1-Kern consumer (§4.7 + §4.7a + §4.7b, D04 §2 / D13 C). Pure observer: reads buy_fiat (+ ledger_tx @@ -49,6 +51,40 @@ export class BuyFiatConsumer { 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 } }, + async (bf: BuyFiat) => { + // §4.12: an amountInChf / totalFeeAmountChf change on a settled regular sell reverses + re-books the seq1 + // reclassification tx; then the idempotent forward book() appends any newly-settled seqs (transmit/booked). + // The paymentLink seq1 (venue-spread) and the later seqs are append-only and re-derived by the forward path. + // An owed-straddling row (§4.7a/§6.1) has its reclassification anchored in the cutover opening and skips seq1 + // → do NOT reverse/rebook a seq1 that was never booked by this consumer. + const owedOpeningChf = await this.cutoverOwedOpeningChf(bf.id); + if (owedOpeningChf == null) { + const seq1 = await this.buildReclassificationSeq1(bf); + if (seq1) await this.bookingService.reverseAndRebookIfChanged(seq1); + } + // honour the book() gate: a gate-blocked run (seq1 received/paymentLink not yet opened) returns false and must + // NOT advance the content-change watermark past this row, else the late-settling row is lost (Blocker R6-1) + if (!(await this.book(bf, await this.preloadMarks([bf])))) { + throw new Error(`buy_fiat ${bf.id} content-change scan gate-blocked — retry next run (§4.7 G-a)`); + } + }, + ); + } + + 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 } }, @@ -91,20 +127,27 @@ export class BuyFiatConsumer { // === (I) REGULAR SELL (§4.7 / §4.7a) === // private async bookRegular(bf: BuyFiat, marks: LedgerMarkCache): Promise { - // seq1 (fee + reclassification) — only once outputAmount is set AND received is opened (gate G-a/G-b) - if (bf.outputAmount != null && !(await this.alreadyBooked(bf.id, 1))) { + // §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); + 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); + await this.bookSettlement(bf, marks, owedOpeningChf); } return true; @@ -113,6 +156,13 @@ export class BuyFiatConsumer { // §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) @@ -122,7 +172,7 @@ export class BuyFiatConsumer { const owed = await this.liability('buyFiat-owed'); const feeIncome = await this.income('fee-buyFiat'); - await this.bookingService.bookTx({ + return { sourceType: SOURCE_TYPE, sourceId: `${bf.id}`, seq: 1, @@ -134,12 +184,14 @@ export class BuyFiatConsumer { 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) - private async bookTransmit(bf: BuyFiat): Promise { - const owedChf = this.owedChf(bf); + // §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 owedChf = this.owedChf(bf, owedOpeningChf); const owed = await this.liability('buyFiat-owed'); const transit = await this.transit(this.outputCurrency(bf)); @@ -155,9 +207,9 @@ export class BuyFiatConsumer { // §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): Promise { + private async bookSettlement(bf: BuyFiat, marks: LedgerMarkCache, owedOpeningChf?: number): Promise { const bookingDate = bf.fiatOutput.bankTx.bookingDate ?? bf.fiatOutput.bankTx.created; - const owedChf = this.owedChf(bf); + const owedChf = this.owedChf(bf, owedOpeningChf); const transit = await this.transit(this.outputCurrency(bf)); const bankLeg = await this.bankCrLeg(bf, bookingDate, marks); @@ -286,7 +338,11 @@ export class BuyFiatConsumer { // 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. - private owedChf(bf: BuyFiat): number { + // 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) return Util.round((bf.outputAmount ?? 0) * this.owedReferenceRate(bf), 2); return Util.round((bf.amountInChf ?? 0) - (bf.totalFeeAmountChf ?? 0), 2); } @@ -324,11 +380,39 @@ export class BuyFiatConsumer { if (ga > 0) return true; // G-a } - // G-b: cutover opening on buyFiat-received for this buy_fiat.id (synthetic seq0 marker, §6.1) - const gb = await this.ledgerTxRepo.countBy({ sourceType: CUTOVER_SOURCE, sourceId: `:buy_fiat:${bf.id}` }); + // 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 + const gb = await this.ledgerTxRepo.countBy({ sourceType: CUTOVER_SOURCE, sourceId: cutoverSourceId }); return gb > 0; } + // 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; + } + + // §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 seq0 paymentLink opening CHF (= −leg.amountChf) or undefined if not yet opened private async paymentLinkOpeningChf(bf: BuyFiat): Promise { const cryptoInputId = bf.cryptoInput?.id; diff --git a/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts b/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts index a730ae39bc..49529440cd 100644 --- a/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts @@ -8,9 +8,9 @@ import { CryptoInput, CryptoInputSettledStatus } from 'src/subdomains/supporting import { In, 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 { LedgerBookingService, LedgerLegInput, LedgerTxInput } from '../ledger-booking.service'; import { LedgerMarkCache, LedgerMarkService } from '../ledger-mark.service'; -import { getLedgerWatermark, setLedgerWatermark } from './ledger-watermark.helper'; +import { getLedgerWatermark, runContentChangeScan, setLedgerWatermark } from './ledger-watermark.helper'; const SOURCE_TYPE = 'crypto_input'; const CHF = 'CHF'; @@ -42,6 +42,28 @@ export class CryptoInputConsumer { 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) => { + const marks = await this.markService.preload(ci.updated, ci.updated); + 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) }, @@ -81,6 +103,16 @@ export class CryptoInputConsumer { 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; @@ -96,7 +128,7 @@ export class CryptoInputConsumer { if (ci.isPayment) { // paymentLink: 2-leg, mark-based (no per-input amountInChf anchor — @ManyToOne, Minor R10-4) const paymentLink = await this.liability('paymentLink'); - await this.bookingService.bookTx({ + return { sourceType: SOURCE_TYPE, sourceId: `${ci.id}`, seq: 0, @@ -112,15 +144,14 @@ export class CryptoInputConsumer { needsMark: assetChf == null, }, ], - }); - return; + }; } // 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; + return undefined; } const received = await this.liability(`${product.bucket}-received`); @@ -130,14 +161,7 @@ export class CryptoInputConsumer { ]; this.appendFxPlug(legs, await this.fxAccounts()); - await this.bookingService.bookTx({ - sourceType: SOURCE_TYPE, - sourceId: `${ci.id}`, - seq: 0, - bookingDate, - valueDate: bookingDate, - legs, - }); + 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}. diff --git a/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts b/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts index 435068ae13..bbb5b882f6 100644 --- a/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts +++ b/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts @@ -1,4 +1,7 @@ import { SettingService } from 'src/shared/models/setting/setting.service'; +import { Config } from 'src/config/config'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { MoreThan, Repository } from 'typeorm'; // per-source checkpoint (§11.3): id-watermark + content-change scan watermark export interface LedgerWatermark { @@ -41,3 +44,57 @@ export async function setLedgerWatermark( }), ); } + +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 by `updated > lastReversalScan` (independent of id), runs the SAME idempotent forward + * `book(row)` per row, and advances `lastReversalScan` ONLY after the whole scan committed without error (§4.12 Minor + * R12-2: a failed booking leaves the watermark unchanged → the 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. + */ +export async function runContentChangeScan( + settingService: SettingService, + source: string, + watermark: LedgerWatermark, + repo: Repository, + scanRelations: Record, + book: (row: T) => Promise, +): Promise { + const changed = await repo.find({ + where: { updated: MoreThan(watermark.lastReversalScan) } as any, + relations: scanRelations as any, + order: { updated: 'ASC', id: 'ASC' } as any, + take: Config.ledger.backfillBatchSize, + }); + if (!changed.length) return; + + let scannedThrough = watermark.lastReversalScan; + for (const row of changed) { + try { + await book(row); + scannedThrough = row.updated; // advance only past rows whose (idempotent) re-book committed + } catch (e) { + contentChangeLogger.error(`Content-change scan failed on ${source} ${row.id}`, e); + // a strict `MoreThan(updated)` advance must NOT skip the failed row when a committed earlier row shares its + // `updated` timestamp — cap the advance strictly BELOW the failed row's updated so it is re-selected next run + if (scannedThrough.getTime() >= row.updated.getTime()) { + scannedThrough = new Date(row.updated.getTime() - 1); + } + break; // leave the rest for the next run → self-healing retry (§4.12 Minor R12-2) + } + } + + if (scannedThrough.getTime() > watermark.lastReversalScan.getTime()) { + await setLedgerWatermark(settingService, source, { ...watermark, lastReversalScan: scannedThrough }); + } +} diff --git a/src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts b/src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts index 284b536578..5bc92e3346 100644 --- a/src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts @@ -15,12 +15,16 @@ import { } 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 { getLedgerWatermark, 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; @@ -32,6 +36,13 @@ const LIABILITY_BUCKET: Partial> = { [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 EINZIGE 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_*. @@ -59,6 +70,8 @@ export class PayoutOrderConsumer { // 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 { @@ -168,11 +181,14 @@ export class PayoutOrderConsumer { } /** - * §4.5 BuyCrypto/BuyCryptoReturn/BuyFiatReturn/Manual Dr leg: LIABILITY/{bucket}. The Dr CHF is the owed - * COMPLETION value (amountInChf − totalFeeAmountChf of the linked product, the value §4.6/§4.7 seq1 credited to - * owed) so `owed` closes cent-exact to 0; the wallet Cr leg is the settlement mark × amount, and the - * completion↔settlement drift is taken by the fx-revaluation plug (Blocker R2-2). If the completion CHF cannot - * be resolved (non-numeric correlationId / Manual / not found), fall back to the settlement mark (defensive). + * §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, @@ -185,7 +201,11 @@ export class PayoutOrderConsumer { return undefined; } - const completionChf = await this.owedCompletionChf(order); // the persisted owed value (§4.5 "CHF aus Completion") + // §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); const settlementChf = mark != null ? Util.round(mark * order.amount, 2) : undefined; @@ -227,6 +247,31 @@ export class PayoutOrderConsumer { 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 diff --git a/src/subdomains/core/accounting/services/ledger-booking.service.ts b/src/subdomains/core/accounting/services/ledger-booking.service.ts index 1aa0a12b87..b2f83e3aa4 100644 --- a/src/subdomains/core/accounting/services/ledger-booking.service.ts +++ b/src/subdomains/core/accounting/services/ledger-booking.service.ts @@ -98,6 +98,110 @@ export class LedgerBookingService { }); } + /** + * §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 + + // (1) reversal-tx (reversalOf = the active original, inverted legs) + await this.reverseTx(active); + + // (2) re-book-tx (reversalOf = NULL — a new valid booking) with the corrected legs, next free seq + const reSeq = await this.nextSeq(input.sourceType, input.sourceId); + await this.bookTx({ ...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; + } + + /** + * 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, reversalOf NULL). If it is reversed (some reversal's reversalOf + * points at it), its corrected re-book is the booking (reversalOf NULL) with the SMALLEST seq strictly above the + * reversal's seq (§4.12 Z.809 order: reversal at seq=N, re-book at seq=N+1); advance to it and repeat. When the + * current booking is NOT reversed it is the live correction → return it. A flat reversal (reversed, no re-book) + * returns undefined (= nothing booked now). + */ + 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.reversalOf == 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 = first real booking (reversalOf NULL) with seq strictly above the reversal's seq + const rebook = all + .filter((tx) => tx.reversalOf == null && tx.seq > reversal.seq) + .sort((a, b) => a.seq - b.seq)[0]; + if (!rebook) return undefined; // flat reversal (no 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) async nextSeq(sourceType: string, sourceId: string): Promise { const { max } = await this.dataSource diff --git a/src/subdomains/core/accounting/services/ledger-cutover.service.ts b/src/subdomains/core/accounting/services/ledger-cutover.service.ts index cfc3c09f71..369d993419 100644 --- a/src/subdomains/core/accounting/services/ledger-cutover.service.ts +++ b/src/subdomains/core/accounting/services/ledger-cutover.service.ts @@ -9,13 +9,22 @@ import { Util } from 'src/shared/utils/util'; 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 { ExchangeTx } from 'src/integration/exchange/entities/exchange-tx.entity'; +import { LiquidityManagementOrder } from 'src/subdomains/core/liquidity-management/entities/liquidity-management-order.entity'; +import { LiquidityManagementOrderStatus } from 'src/subdomains/core/liquidity-management/enums'; +import { TradingOrder } from 'src/subdomains/core/trading/entities/trading-order.entity'; +import { TradingOrderStatus } from 'src/subdomains/core/trading/enums'; +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 { BankTx } from 'src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity'; -import { CryptoInput } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; -import { PayoutOrder } from 'src/subdomains/supporting/payout/entities/payout-order.entity'; -import { IsNull, LessThanOrEqual, Repository } from 'typeorm'; +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, IsNull, Repository, SelectQueryBuilder } from 'typeorm'; import { AccountType, LedgerAccount } from '../entities/ledger-account.entity'; import { LedgerBookingService, LedgerLegInput } from './ledger-booking.service'; import { LedgerBootstrapService } from './ledger-bootstrap.service'; @@ -45,6 +54,10 @@ export class LedgerCutoverService { @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, ) {} /** @@ -191,12 +204,12 @@ export class LedgerCutoverService { // buyFiat-received: open rows with outputAmount NULL → CHF = amountInChf (Minor R3-6); per-row seq0-marker (R4-2) private async openBuyFiatReceived(snapshot: Log, date: Date, lookback: Date, equity: LedgerAccount): Promise { const rows = await this.buyFiatRepo.find({ - where: { isComplete: false, outputAmount: IsNull(), created: LessThanOrEqual(date) }, + where: { isComplete: false, outputAmount: IsNull(), created: Between(lookback, date) }, }); const liability = await this.liability('buyFiat-received'); for (const row of rows) { - if (row.created.getTime() < lookback.getTime() || row.amountInChf == null) continue; + if (row.amountInChf == null) continue; await this.bookReceivedOwedOpening( snapshot, date, @@ -218,13 +231,13 @@ export class LedgerCutoverService { equity: LedgerAccount, ): Promise { const rows = await this.buyFiatRepo.find({ - where: { isComplete: false, created: LessThanOrEqual(date) }, + where: { isComplete: false, created: Between(lookback, date) }, relations: { outputAsset: true }, }); const liability = await this.liability('buyFiat-owed'); for (const row of rows) { - if (row.outputAmount == null || row.created.getTime() < lookback.getTime()) continue; + if (row.outputAmount == null) 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); @@ -246,12 +259,12 @@ export class LedgerCutoverService { // buyCrypto-received: open rows with outputAmount NULL → CHF = amountInChf (Minor R2-7); per-row seq0-marker (R4-2) private async openBuyCryptoReceived(snapshot: Log, date: Date, lookback: Date, equity: LedgerAccount): Promise { const rows = await this.buyCryptoRepo.find({ - where: { isComplete: false, outputAmount: IsNull(), created: LessThanOrEqual(date) }, + where: { isComplete: false, outputAmount: IsNull(), created: Between(lookback, date) }, }); const liability = await this.liability('buyCrypto-received'); for (const row of rows) { - if (row.created.getTime() < lookback.getTime() || row.amountInChf == null) continue; + if (row.amountInChf == null) continue; await this.bookReceivedOwedOpening( snapshot, date, @@ -273,13 +286,13 @@ export class LedgerCutoverService { equity: LedgerAccount, ): Promise { const rows = await this.buyCryptoRepo.find({ - where: { isComplete: false, created: LessThanOrEqual(date) }, + where: { isComplete: false, created: Between(lookback, date) }, relations: { outputAsset: true }, }); const liability = await this.liability('buyCrypto-owed'); for (const row of rows) { - if (row.outputAmount == null || row.created.getTime() < lookback.getTime()) continue; + if (row.outputAmount == null) 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; @@ -344,14 +357,76 @@ export class LedgerCutoverService { // sets each ledgerWatermark. to MAX(id) of pre-cutover settled rows + lastReversalScan = snapshotDate, // 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 { const sources: { source: string; maxId: () => Promise }[] = [ { source: 'bank_tx', maxId: () => this.maxSettledId(this.bankTxRepo, 'bookingDate', snapshotDate) }, - { source: 'crypto_input', maxId: () => this.maxSettledId(this.cryptoInputRepo, 'updated', snapshotDate) }, - { source: 'payout_order', maxId: () => this.maxSettledId(this.payoutOrderRepo, 'updated', snapshotDate) }, - { source: 'exchange_tx', maxId: () => this.maxSettledId(this.exchangeTxRepo, 'created', snapshotDate) }, + // §4.4 — crypto_input: status ∈ CryptoInputSettledStatus + updated <= snapshot (§6.3 Z.917) + { + source: 'crypto_input', + maxId: () => + this.maxSettledId(this.cryptoInputRepo, 'updated', snapshotDate, (qb) => + qb.andWhere('e.status IN (:...ciStatus)', { ciStatus: CryptoInputSettledStatus }), + ), + }, + // §4.5 — payout_order: status='Complete' + updated <= snapshot (§6.3 Z.917) + { + source: 'payout_order', + maxId: () => + this.maxSettledId(this.payoutOrderRepo, 'updated', snapshotDate, (qb) => + qb.andWhere('e.status = :poStatus', { poStatus: PayoutOrderStatus.COMPLETE }), + ), + }, + // §4.3 — exchange_tx: status='ok' + (externalCreated ?? created) <= snapshot (§6.3 Z.917) + { + source: 'exchange_tx', + maxId: () => + this.maxSettledId(this.exchangeTxRepo, 'externalCreated', snapshotDate, (qb) => + qb.andWhere('e.status = :etStatus', { etStatus: 'ok' }), + ), + }, { 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, (qb) => + qb.andWhere('e.status = :lmStatus', { lmStatus: LiquidityManagementOrderStatus.COMPLETE }), + ), + }, + // §4.9 — trading_order: status='Complete' AND txId IS NOT NULL + updated <= snapshot + { + source: 'trading_order', + maxId: () => + this.maxSettledId(this.tradingOrderRepo, 'updated', snapshotDate, (qb) => + qb + .andWhere('e.status = :toStatus', { toStatus: TradingOrderStatus.COMPLETE }) + .andWhere('e.txId IS NOT NULL'), + ), + }, + // §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, (qb) => + 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], + }), + ), + }, ]; for (const { source, maxId } of sources) { @@ -359,14 +434,23 @@ export class LedgerCutoverService { } } - // MAX(id) of rows whose settlement date ≤ snapshot (the consumer-specific settled filter, §4.x simplified to the - // settlement-date cutoff — the forward id-watermark only needs the highest pre-cutover-settled id, §6.3 Effekt) - private async maxSettledId(repo: Repository, dateColumn: string, snapshotDate: Date): Promise { - const { max } = (await repo + // 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 }) - .getRawOne<{ max: number | null }>()) ?? { max: null }; + .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; } 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 index b8c242a735..83720c2513 100644 --- a/src/subdomains/core/accounting/services/ledger-mark-to-market.service.ts +++ b/src/subdomains/core/accounting/services/ledger-mark-to-market.service.ts @@ -30,7 +30,8 @@ interface AccountBalance { * 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 tagesaktuell - * revalued accounts (Minor R13-8). Batch-limited by Config.ledger.backfillBatchSize (no full-scan, §5.3 Minor R1-2). + * 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 { @@ -59,41 +60,74 @@ export class LedgerMarkToMarketService { private async markToMarket(): Promise { const now = new Date(); - const accounts = await this.selectCandidates(); - if (!accounts.length) return; + + // §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(); - for (const account of 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) + 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, batch-limited - private async selectCandidates(): Promise { - const openAccountIds = await this.ledgerLegRepository + /** + * §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 can be marked (need an asset to look up) + .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(Config.ledger.backfillBatchSize) + .limit(batchSize) .getRawMany<{ accountId: number }>() .then((rows) => rows.map((r) => r.accountId)); - if (!openAccountIds.length) return []; + 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 this.ledgerAccountRepository.findBy({ id: In(openAccountIds) }); + return { ids, maxId: Math.max(...ids), accounts }; } // one revaluation-tx per open account per day: ASSET/LIABILITY leg (amount=0, amountChf=diff) / fx-revaluation diff --git a/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts b/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts index 7bb415c3ff..a5943bdaaf 100644 --- a/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts +++ b/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts @@ -15,6 +15,7 @@ import { FinanceLog } from 'src/subdomains/supporting/log/dto/log.dto'; 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'; @@ -107,30 +108,44 @@ export class LedgerReconciliationService { private async reconcileAssets(feed: LiquidityBalance[], now: Date): Promise { const feedByAssetId = new Map(feed.filter((b) => b.asset?.id != null).map((b) => [b.asset.id, b])); - const assetAccounts = await this.ledgerAccountRepository.find({ - where: { type: AccountType.ASSET, active: true }, - take: Config.ledger.backfillBatchSize, - }); - const unverified: string[] = []; - 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.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) }, + 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) + } + + await this.reconcileFreshAsset(account, balance, now); } - await this.reconcileFreshAsset(account, balance, 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) From 3b716530a542fb54dbad1e9792e4670fd73336b4 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:57:39 +0200 Subject: [PATCH 03/25] fix(accounting): address second-wave review findings --- scripts/ledger-isolation-gate.js | 6 +- scripts/ledger-isolation-gate.sh | 6 +- .../core/accounting/accounting.module.ts | 4 + .../db-write-isolation.integration.spec.ts | 13 +- .../evidence-week.integration.spec.ts | 65 ++++-- .../integration/isolation-gate.spec.ts | 9 + .../staleness-cutover.integration.spec.ts | 64 +++++- .../ledger-booking-job.service.spec.ts | 32 ++- .../__tests__/ledger-booking.service.spec.ts | 12 +- .../__tests__/ledger-cutover.service.spec.ts | 97 ++++++++- .../__tests__/bank-tx.consumer.spec.ts | 190 ++++++++++++++++- .../__tests__/buy-crypto.consumer.spec.ts | 32 ++- .../__tests__/buy-fiat.consumer.spec.ts | 49 ++++- .../__tests__/crypto-input.consumer.spec.ts | 21 +- .../__tests__/exchange-tx.consumer.spec.ts | 72 +++++++ .../__tests__/ledger-watermark.helper.spec.ts | 121 +++++++++++ .../services/consumers/bank-tx.consumer.ts | 164 +++++++++++++-- .../services/consumers/buy-crypto.consumer.ts | 6 +- .../services/consumers/buy-fiat.consumer.ts | 13 +- .../consumers/crypto-input.consumer.ts | 13 +- .../consumers/exchange-tx.consumer.ts | 108 +++++++--- .../consumers/ledger-watermark.helper.ts | 81 +++++--- .../consumers/payout-order.consumer.ts | 7 +- .../services/ledger-booking.service.ts | 32 ++- .../services/ledger-cutover.service.ts | 193 +++++++++++++++++- src/subdomains/supporting/log/log.service.ts | 4 + 26 files changed, 1285 insertions(+), 129 deletions(-) create mode 100644 src/subdomains/core/accounting/services/consumers/__tests__/ledger-watermark.helper.spec.ts diff --git a/scripts/ledger-isolation-gate.js b/scripts/ledger-isolation-gate.js index b184938f9e..212c8e4618 100644 --- a/scripts/ledger-isolation-gate.js +++ b/scripts/ledger-isolation-gate.js @@ -22,7 +22,7 @@ const PATTERN = new RegExp( '\\brefreshBalances\\(|\\brefreshBankBalance|\\bhasPendingOrders|integration\\.getBalances|integration\\.hasPendingOrders|BankAdapter|balanceIntegrationFactory|LiquidityBalanceIntegrationFactory', '\\blogService\\.(create|update)\\(|\\bsettingService\\.(setObj|updateProcess|addIpToBlacklist|deleteIpFromBlacklist)\\(', '\\.complete\\(|checkOrderCompletion|syncExchanges|doPayout|checkPayoutCompletionData|triggerWebhook|calculateSpreadFee', - 'balanceRepo\\.(update|save|insert|delete|remove)\\(|\\b(?!ledger)\\w*Repo(sitory)?\\.(update|save|insert|delete|remove)\\(', + 'balanceRepo\\.(update|save|insert|delete|remove|increment|decrement)\\(|\\b(?!ledger)\\w*Repo(sitory)?\\.(update|save|insert|delete|remove|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 @@ -31,12 +31,12 @@ const PATTERN = new RegExp( // 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|query)\\(|\\bdataSource\\.query\\(|\\bqueryRunner\\.query\\(', + '\\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)\\(|\\b\\w+Service\\.(save|insert|update|delete|remove|upsert)\\w*\\(', + '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 diff --git a/scripts/ledger-isolation-gate.sh b/scripts/ledger-isolation-gate.sh index 323bb73fbe..d5a7a9370a 100755 --- a/scripts/ledger-isolation-gate.sh +++ b/scripts/ledger-isolation-gate.sh @@ -30,7 +30,7 @@ PATTERN='pricingService|PricingService|getPrice\(|getPriceAt|priceProvider|CoinG PATTERN+='|\brefreshBalances\(|\brefreshBankBalance|\bhasPendingOrders|integration\.getBalances|integration\.hasPendingOrders|BankAdapter|balanceIntegrationFactory|LiquidityBalanceIntegrationFactory' PATTERN+='|\blogService\.(create|update)\(|\bsettingService\.(setObj|updateProcess|addIpToBlacklist|deleteIpFromBlacklist)\(' PATTERN+='|\.complete\(|checkOrderCompletion|syncExchanges|doPayout|checkPayoutCompletionData|triggerWebhook|calculateSpreadFee' -PATTERN+='|balanceRepo\.(update|save|insert|delete|remove)\(|\b(?!ledger)\w*Repo(sitory)?\.(update|save|insert|delete|remove)\(' +PATTERN+='|balanceRepo\.(update|save|insert|delete|remove|increment|decrement)\(|\b(?!ledger)\w*Repo(sitory)?\.(update|save|insert|delete|remove|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 @@ -39,7 +39,7 @@ PATTERN+='|balanceRepo\.(update|save|insert|delete|remove)\(|\b(?!ledger)\w*Repo # `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. -PATTERN+='|\b\w*[Mm]anager\.(save|insert|update|delete|remove|upsert|softDelete|softRemove|recover|query)\(|\bdataSource\.query\(|\bqueryRunner\.query\(' +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 @@ -47,7 +47,7 @@ PATTERN+='|\b\w*[Mm]anager\.(save|insert|update|delete|remove|upsert|softDelete| # 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). -PATTERN+='|getRepository\([^)]*\)\.(save|insert|update|delete|remove|upsert|softDelete|softRemove|recover)\(|\b\w+Service\.(save|insert|update|delete|remove|upsert)\w*\(' +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 diff --git a/src/subdomains/core/accounting/accounting.module.ts b/src/subdomains/core/accounting/accounting.module.ts index 9d0bc70fb1..c936dd496b 100644 --- a/src/subdomains/core/accounting/accounting.module.ts +++ b/src/subdomains/core/accounting/accounting.module.ts @@ -11,6 +11,8 @@ 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 { LogModule } from 'src/subdomains/supporting/log/log.module'; import { NotificationModule } from 'src/subdomains/supporting/notification/notification.module'; @@ -60,6 +62,8 @@ import { LedgerReconciliationService } from './services/ledger-reconciliation.se 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) 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 index 980af0adf2..0d673807f1 100644 --- 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 @@ -23,7 +23,18 @@ 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'] as const; +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'); 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 index f748a00f8d..df8ec305d1 100644 --- 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 @@ -5,6 +5,8 @@ import { ExchangeTx, ExchangeTxType } from 'src/integration/exchange/entities/ex 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'; @@ -116,6 +118,7 @@ describe('Ledger evidence-week integration (§10.2)', () => { 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, @@ -144,13 +147,29 @@ describe('Ledger evidence-week integration (§10.2)', () => { } // 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>(); - jest - .spyOn(bankRepo, 'findOne') - .mockImplementation(({ where }: any) => Promise.resolve(banks.find((b) => b.iban === where.iban) ?? null)); + // 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, @@ -159,6 +178,8 @@ describe('Ledger evidence-week integration (§10.2)', () => { bankTxRepo, bankRepo, ledger.ledgerTxRepository(), + returnRepo, + repeatRepo, ); } @@ -167,6 +188,9 @@ describe('Ledger evidence-week integration (§10.2)', () => { 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)); }); @@ -337,11 +361,12 @@ describe('Ledger evidence-week integration (§10.2)', () => { // --- 5. CLASS-4 SWEEP → SUSPENSE (generic untracked-bank rule, no bank-name hardcode) --- // - it('Class-4: an untracked-bank credit lands in SUSPENSE, the exchange sweep pushes it back down', async () => { + 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). - // The credit's value lands in SUSPENSE as the native EUR custody amount (the SUSPENSE account has no asset row, - // so the consumer cannot mark-value it — the CHF side flows to fx-revaluation; the unambiguous Class-4 evidence - // is the NATIVE SUSPENSE balance + the fully-counted received liability). The exchange deposit then sweeps it. + // §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, @@ -350,14 +375,19 @@ describe('Ledger evidence-week integration (§10.2)', () => { amount: 1000, bankName: 'Raiffeisen', accountIban: 'SYNTH-UNTRACKED-IBAN', - buyCrypto: { amountInChf: 950 } as any, + 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('LIABILITY/buyCrypto-received')).toBe(-950); // Cr received fully counted (Class-4 fix) + 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({ @@ -639,6 +669,7 @@ describe('Ledger evidence-week integration (§10.2)', () => { }); const bankRepo = createMock>(); jest.spyOn(bankRepo, 'findOne').mockResolvedValue(null); // untracked → CHF SUSPENSE/unattributed path + const [returnRepo, repeatRepo] = chargebackRepos(); return new BankTxConsumer( s, @@ -648,10 +679,12 @@ describe('Ledger evidence-week integration (§10.2)', () => { bankTxRepo, bankRepo, ledger.ledgerTxRepository(), + returnRepo, + repeatRepo, ); } - it('keeps seq0, books a reversal (seq1, reversalOf=seq0, inverted) + a re-book (seq2, BUY_CRYPTO); seq strictly monotonic, no UNIQUE conflict', async () => { + 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, @@ -682,18 +715,20 @@ describe('Ledger evidence-week integration (§10.2)', () => { const all = ledger.txs .filter((t) => t.sourceType === 'bank_tx' && t.sourceId === '900') .sort((a, b) => a.seq - b.seq); - expect(all.map((t) => t.seq)).toEqual([0, 1, 2]); // seq strictly monotonic over the cycle + // §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) seq1 is the reversal of seq0 (reversalOfId points at the ORIGINAL, §4.12 Z.811) with inverted legs - expect(all[1].seq).toBe(1); + // (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) seq2 is the re-book (reversalOf NULL — a new valid booking), now BUY_CRYPTO → Cr buyCrypto-received - expect(all[2].seq).toBe(2); + // (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 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 index 619a7a3342..c635e855a6 100644 --- a/src/subdomains/core/accounting/services/__tests__/integration/isolation-gate.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/integration/isolation-gate.spec.ts @@ -92,6 +92,15 @@ describe('Ledger isolation gate (§4.10 / §10.1 self-test)', () => { 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);', + }, ]; it.each(violations)('flags a known violation: $name', ({ code }) => { 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 index 532f1aad58..8048a73de0 100644 --- 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 @@ -14,7 +14,10 @@ import { LiquidityManagementBalanceService } from 'src/subdomains/core/liquidity 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'; @@ -176,6 +179,7 @@ describe('Ledger staleness + cutover integration (§10.2)', () => { 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 }); @@ -197,6 +201,7 @@ describe('Ledger staleness + cutover integration (§10.2)', () => { beforeEach(async () => { booked = []; cutoverFlag = undefined; + snapshotPin = undefined; seqByKey.clear(); settingService = createMock(); @@ -206,19 +211,24 @@ describe('Ledger staleness + cutover integration (§10.2)', () => { accountService = createMock(); markService = createMock(); - // the Setting flag is the primary idempotency guard: set on success, read on the next run - jest - .spyOn(settingService, 'get') - .mockImplementation((key: string) => - Promise.resolve(key === 'ledgerCutoverLogId' ? (cutoverFlag as any) : '0'), - ); + // 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). + 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('0'); + }); 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) => { @@ -271,6 +281,9 @@ describe('Ledger staleness + cutover integration (§10.2)', () => { { 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() }, @@ -311,5 +324,44 @@ describe('Ledger staleness + cutover integration (§10.2)', () => { 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 (4) initWatermarks AFTER all openings committed but BEFORE the flag is set — the + // failure-isolated run() swallows it, the flag stays unset, the openings + the snapshot pin remain committed. + 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 (4) — crash there once, simulating the partial-cutover scenario + if (crashWatermarks && key.startsWith('ledgerWatermark.')) throw new Error('simulated crash in initWatermarks'); + return Promise.resolve(); + }); + + await service.run(); + expect(cutoverFlag).toBeUndefined(); // crash before step (5) → flag never set + expect(snapshotPin).toBe('1557344'); // pin survived (set in step (2) before any opening) + const firstRunBookings = booked.length; + expect(firstRunBookings).toBeGreaterThan(0); // at least the ASSET opening committed before the crash + + // 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).toHaveLength(firstRunBookings); // openings booked exactly once (no double-count via the drift) + // 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); + }); }); }); 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 index a8545182c5..1979d07171 100644 --- 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 @@ -148,13 +148,24 @@ describe('LedgerBookingJobService', () => { 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' } as any); + 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 () => { @@ -163,6 +174,19 @@ describe('LedgerBookingJobService', () => { }); it('writes a watermark exclusively via settingService.set (never setObj/settingRepo, §4.10 R2-Ausnahme-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, @@ -170,7 +194,7 @@ describe('LedgerBookingJobService', () => { }); expect(setSpy).toHaveBeenCalledWith( 'ledgerWatermark.crypto_input', - JSON.stringify({ lastProcessedId: 7, lastReversalScan: '2026-06-02T00:00:00.000Z' }), + 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 index 98fb8f3f14..8ae86c70ee 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-booking.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-booking.service.spec.ts @@ -226,7 +226,7 @@ describe('LedgerBookingService', () => { const reversal = await service.reverseTx(original); - expect(reversal.seq).toBe(2); // MAX(seq)=1 → next 2 (§4.12 monotone) + 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 @@ -308,9 +308,11 @@ describe('LedgerBookingService', () => { expect(changed).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, 2]); // monotone over the cycle - expect(all[1].reversalOfId).toBe(all[0].id); // seq1 reverses the ORIGINAL seq0 - expect(all[2].reversalOfId).toBeUndefined(); // seq2 re-book is a new valid booking + // §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 }); it('is a no-op when nothing changed (idempotent re-scan) and when a sub-tolerance mark drift occurs', async () => { @@ -341,7 +343,7 @@ describe('LedgerBookingService', () => { 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]); + 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 }); }); 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 index 7b5461f596..2ae08cc8f1 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts @@ -13,7 +13,10 @@ import { LiquidityOrder } from 'src/subdomains/supporting/dex/entities/liquidity 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 { BankTx } from 'src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity'; +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 } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; import { PayoutOrder } from 'src/subdomains/supporting/payout/entities/payout-order.entity'; import { Repository } from 'typeorm'; @@ -65,6 +68,9 @@ describe('LedgerCutoverService', () => { 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; @@ -99,6 +105,9 @@ describe('LedgerCutoverService', () => { buyFiatRepo = createMock>(); buyCryptoRepo = createMock>(); bankTxRepo = createMock>(); + bankRepo = createMock>(); + bankTxReturnRepo = createMock>(); + bankTxRepeatRepo = createMock>(); cryptoInputRepo = createMock>(); exchangeTxRepo = createMock>(); payoutOrderRepo = createMock>(); @@ -123,6 +132,10 @@ describe('LedgerCutoverService', () => { // 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([]); + jest.spyOn(bankRepo, 'findOne').mockResolvedValue(null); jest.spyOn(settingService, 'getObj').mockResolvedValue([] as any); jest.spyOn(markService, 'preload').mockResolvedValue(new LedgerMarkCache(new Map())); @@ -159,6 +172,9 @@ describe('LedgerCutoverService', () => { { 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 }, @@ -336,6 +352,85 @@ describe('LedgerCutoverService', () => { const liabilityLeg = owedTx.legs.find((l) => l.account.type === AccountType.LIABILITY); expect(liabilityLeg.needsMark).toBe(true); // no mark for asset 999 → mark-to-market values later }); + + // §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, 'findOne') + .mockResolvedValue(Object.assign(new Bank(), { 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, 'findOne') + .mockResolvedValue(Object.assign(new Bank(), { 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, 'findOne') + .mockResolvedValue(Object.assign(new Bank(), { name: 'Olkypay', currency: 'EUR', asset: { id: 269 } }) as any); + // first find() = typed credits (GSheet/Pending/Unknown), second find() = NULL-type credits + jest + .spyOn(bankTxRepo, 'find') + .mockResolvedValueOnce([ + Object.assign(new BankTx(), { id: 90, amount: 1000, accountIban: 'EUR-IBAN', currency: 'EUR' }), + ] as any) + .mockResolvedValueOnce([ + 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)', () => { 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 index 96ea7cb404..dc12b2860c 100644 --- 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 @@ -5,6 +5,8 @@ import { SettingService } from 'src/shared/models/setting/setting.service'; import { TestUtil } from 'src/shared/utils/test.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'; @@ -42,6 +44,8 @@ describe('BankTxConsumer', () => { let bankTxRepo: Repository; let bankRepo: Repository; let ledgerTxRepo: Repository; + let bankTxReturnRepo: Repository; + let bankTxRepeatRepo: Repository; let booked: LedgerTxInput[]; let createdAccounts: Map; @@ -60,10 +64,15 @@ describe('BankTxConsumer', () => { 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); @@ -104,6 +113,8 @@ describe('BankTxConsumer', () => { { 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(); @@ -130,6 +141,12 @@ describe('BankTxConsumer', () => { 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); }); } @@ -187,19 +204,53 @@ describe('BankTxConsumer', () => { expect(cents(legs)).toBe(0); }); - it('books BUY_CRYPTO on an untracked Raiffeisen bank against SUSPENSE/untracked-bank-Raiffeisen-EUR', async () => { + 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; - // untracked → no EUR mark via asset → SUSPENSE leg needsMark; the received anchor remains; plug absorbs - expect(legs[0].account.name).toBe('SUSPENSE/untracked-bank-Raiffeisen-EUR'); - expect(legs[0].needsMark).toBe(true); - expect(legs[1].account.name).toBe('LIABILITY/buyCrypto-received'); + 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); }); + it('books BUY_CRYPTO on an untracked bank with NO same-currency tracked mark as a needsMark leg, no silent plug', async () => { + // when no tracked bank of the currency exists, currencyMarkAssetId returns undefined → the SUSPENSE leg stays + // needsMark and withFxPlug books NO plug (§5.1 Stufe 3: no silent plug without a mark; mark-to-market revalues) + 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(); + + const legs = booked[0].legs; + const suspense = legs.find((l) => l.account.name === 'SUSPENSE/untracked-bank-Raiffeisen-EUR'); + expect(suspense.needsMark).toBe(true); // no mark resolvable + expect(legs.find((l) => l.account.name?.includes('fx-revaluation'))).toBeUndefined(); // NO silent plug + }); + 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 @@ -411,6 +462,135 @@ describe('BankTxConsumer', () => { 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 netto + EXPENSE/acquirer-fee / ASSET/Checkout brutto (CHF cross-asset)', async () => { createdAccounts.set('Checkout/EUR', account('Checkout/EUR', AccountType.ASSET, 'EUR', 270)); mockBatch([ 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 index c246f7b022..573299d976 100644 --- 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 @@ -37,11 +37,13 @@ describe('BuyCryptoConsumer', () => { 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')]]); @@ -53,9 +55,14 @@ describe('BuyCryptoConsumer', () => { 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 @@ -183,13 +190,34 @@ describe('BuyCryptoConsumer', () => { expect(booked).toHaveLength(0); }); - it('is idempotent: skips seq1 when already booked (re-run, nextSeq > 1)', async () => { - nextSeqValue = 2; + 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 })]); 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 index 25184680ba..a4dc7c2465 100644 --- 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 @@ -46,6 +46,7 @@ describe('BuyFiatConsumer', () => { let booked: LedgerTxInput[]; let accounts: Map; let nextSeqValue: number; + let activeKeys: Set; // `${sourceId}:${seq}` with an active booking — backs hasActiveTxAt (per-seq, R3) let gateCount: number; // countBy 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) @@ -59,6 +60,7 @@ describe('BuyFiatConsumer', () => { beforeEach(async () => { booked = []; nextSeqValue = 0; + activeKeys = new Set(); gateCount = 1; seq0PaymentLinkChf = undefined; cutoverOwedOpeningChf = undefined; @@ -77,9 +79,14 @@ describe('BuyFiatConsumer', () => { 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') @@ -384,8 +391,8 @@ describe('BuyFiatConsumer', () => { expect(sumOn('TRANSIT/payout/CHF')).toBe(0); }); - it('is idempotent: skips a fully booked row (re-run, nextSeq > 3)', async () => { - nextSeqValue = 4; + 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, @@ -405,6 +412,44 @@ describe('BuyFiatConsumer', () => { 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([ 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 index ba91edf865..3e1545aaca 100644 --- 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 @@ -41,6 +41,7 @@ describe('CryptoInputConsumer', () => { 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); @@ -54,6 +55,7 @@ describe('CryptoInputConsumer', () => { beforeEach(async () => { booked = []; nextSeqValue = 0; + activeKeys = new Set(); accounts = new Map([ ['Ethereum/ZCHF', zchfWallet], ['Bitcoin/BTC', btcWallet], @@ -67,9 +69,14 @@ describe('CryptoInputConsumer', () => { 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') @@ -196,8 +203,8 @@ describe('CryptoInputConsumer', () => { expect(cents(seq0.legs)).toBe(0); }); - // §10.2 fixture (B)(d) — no mark: ASSET leg needsMark, plug stays open, no silent priceChf=0 - it('flags the ASSET leg needsMark when no mark exists (no silent priceChf=0)', async () => { + // §10.2 fixture (B)(d) / §5.1 Stufe 3 — no mark: ASSET leg needsMark, NO silent fx-revaluation plug + it('flags the ASSET leg needsMark when no mark exists and books NO silent plug (§5.1 Stufe 3)', async () => { mockBatch([ cryptoInput({ id: 5, @@ -215,7 +222,11 @@ describe('CryptoInputConsumer', () => { const assetLeg = seq0.legs.find((l) => l.account.name === 'Unknown/XYZ'); expect(assetLeg.needsMark).toBe(true); expect(assetLeg.amountChf).toBeUndefined(); - expect(cents(seq0.legs)).toBe(0); // received −50000 + plug +50000 balances; mark-to-market revalues later + // NO fx-revaluation plug while the asset leg needsMark (the old bug plugged the FULL +50000 received value as a + // phantom fx-revaluation, vacuously balancing cents to 0). Per §5.1 Stufe 3 the tx stays unbalanced-by-mark and + // the mark-to-market job revalues the asset leg later — exactly the systemic needsMark-guard fix. + expect(seq0.legs.find((l) => l.account.name?.includes('fx-revaluation'))).toBeUndefined(); + expect(seq0.legs).toHaveLength(2); // asset (needsMark) + received anchor only, no plug leg }); it('books the forward fee (seq1) only when outTxId + forwardFeeAmountChf are set', async () => { @@ -256,8 +267,8 @@ describe('CryptoInputConsumer', () => { expect(booked.some((b) => b.seq === 1)).toBe(false); }); - it('is idempotent: skips seq0 when already booked (re-run, nextSeq > 0)', async () => { - nextSeqValue = 1; // seq0 already exists + 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, 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 index 567a6818ef..84f9b6c0a8 100644 --- 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 @@ -20,6 +20,7 @@ function exchangeTx(values: Partial): 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, @@ -112,12 +113,28 @@ describe('ExchangeTxConsumer', () => { 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 + // fill-index preload (status='ok', order in …) ranks among the still-ok trades + the merged batch rows + if (opts?.where?.order != null) + return Promise.resolve( + [...forward, ...changed].filter((r) => r.type === ExchangeTxType.TRADE && r.status === 'ok'), + ); + return Promise.resolve(forward.filter((r) => opts?.where?.status == null || r.status === opts.where.status)); + }); + } + it('is defined', () => { expect(consumer).toBeDefined(); }); @@ -337,4 +354,59 @@ describe('ExchangeTxConsumer', () => { 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=order, seq=fill-index (0 for the only fill of O-7) + expect(reverseSpy).toHaveBeenCalledWith('ExchangeTrade', 'O-7', 0); + }); + + 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(); + }); }); 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..67f55892e4 --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/__tests__/ledger-watermark.helper.spec.ts @@ -0,0 +1,121 @@ +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 { 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); + }); +}); diff --git a/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts b/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts index 1860226356..7992ebf132 100644 --- a/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts @@ -7,7 +7,9 @@ import { DfxLogger } from 'src/shared/services/dfx-logger'; 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 { LessThan, MoreThan, Repository } from 'typeorm'; +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 { 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'; @@ -21,6 +23,7 @@ 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 "neue Routen lazy") const EXCHANGE_ROUTE: Partial> = { @@ -34,6 +37,11 @@ interface BankContext { 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; } /** @@ -55,6 +63,9 @@ export class BankTxConsumer { @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 { @@ -383,7 +394,13 @@ export class BankTxConsumer { return [bank, liability]; } - // §4.2 BANK_TX_REPEAT_CHARGEBACK DBIT: Dr LIABILITY/{bucket} / Cr ASSET/bank + // §4.2 BANK_TX_REPEAT_CHARGEBACK DBIT: Dr LIABILITY/{bucket} (Eröffnungs-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, @@ -392,12 +409,20 @@ export class BankTxConsumer { 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)); + const liabilityChf = await this.chargebackOpeningChf(tx, bucket, [bank]); + const liability = this.namedLeg(await this.liability(bucket), liabilityChf); - return [liability, bank]; + // 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]); } - // §4.2 BANK_TX_RETURN_CHARGEBACK DBIT: Dr LIABILITY/{bucket} / Cr ASSET/bank (+ EXPENSE/bank-fee chargeAmountChf) + // §4.2 BANK_TX_RETURN_CHARGEBACK DBIT: Dr LIABILITY/{bucket} (Eröffnungs-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, @@ -406,18 +431,85 @@ export class BankTxConsumer { bucket: string, ): Promise { const bank = this.bankAssetLeg(ctx, -tx.amount, bookingDate, marks, await this.bankAccount(ctx)); - const legs: LedgerLegInput[] = [bank]; + const others: LedgerLegInput[] = [bank]; const feeChf = tx.chargeAmountChf; - if (feeChf != null && feeChf !== 0) legs.push(this.namedLeg(await this.expense('bank-fee'), feeChf)); // Pricing anchor + if (feeChf != null && feeChf !== 0) others.push(this.namedLeg(await this.expense('bank-fee'), feeChf)); // Pricing anchor - // LIABILITY-Dr = opening CHF; closes against bank + fee, residual via plug (§4.2-Note B-15) - const liabilityChf = -legs.reduce((s, l) => s + (l.amountChf ?? 0), 0); - legs.unshift(this.namedLeg(await this.liability(bucket), liabilityChf)); + const liabilityChf = await this.chargebackOpeningChf(tx, bucket, others); + const legs: LedgerLegInput[] = [this.namedLeg(await this.liability(bucket), liabilityChf), ...others]; return this.withFxPlug(legs); // ≤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 (netto) + Dr EXPENSE/acquirer-fee / Cr ASSET/Checkout (brutto), CHF-only private async checkoutLtdLegs( tx: BankTx, @@ -476,7 +568,10 @@ export class BankTxConsumer { // --- LEG/ACCOUNT HELPERS --- // - // the bank ASSET leg native+CHF (mark-consistent). `account` is pre-resolved (ASSET/bank or SUSPENSE/untracked) + // 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, @@ -484,7 +579,8 @@ export class BankTxConsumer { marks: LedgerMarkCache, account: LedgerAccount, ): LedgerLegInput { - const mark = ctx.currency === CHF ? 1 : ctx.asset ? marks.getMarkAt(ctx.asset.id, bookingDate) : undefined; + 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 }; @@ -496,8 +592,14 @@ export class BankTxConsumer { } // 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) + // the booking-service ROUNDING leg closes it (no plug created). + // NO silent plug while a leg still needsMark (§5.1 Stufe 3): an unmarked non-CHF leg carries amountChf=undefined + // (counted as 0 here), so plugging would book the full leg value as a phantom fx-revaluation; instead leave the tx + // unbalanced-by-mark and let the mark-to-market job revalue the leg later — consistent with exchange-tx.consumer.ts + // (the §4.2a fix resolves the EUR mark for untracked banks, so this guard only fires when the mark is truly absent). private async withFxPlug(legs: LedgerLegInput[]): Promise { + if (legs.some((l) => l.needsMark)) 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; @@ -548,10 +650,42 @@ export class BankTxConsumer { private async bankContext(tx: BankTx): Promise { if (tx.accountIban) { const bank = await this.bankRepo.findOne({ where: { iban: tx.accountIban }, relations: { asset: true } }); - if (bank) return { asset: bank.asset, currency: bank.currency, bankName: bank.name, tracked: bank.asset != null }; + 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 }; + 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 Stufe 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 index 8373603f7d..7fbd276411 100644 --- a/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts @@ -210,8 +210,12 @@ export class BuyCryptoConsumer { 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 (await this.bookingService.nextSeq(SOURCE_TYPE, `${id}`)) > seq; + return this.bookingService.hasActiveTxAt(SOURCE_TYPE, `${id}`, seq); } private async checkoutAccount(currency: string): Promise { diff --git a/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts b/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts index 8242ee4104..87b0b32703 100644 --- a/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts @@ -326,8 +326,13 @@ export class BuyFiatConsumer { }; } - // §4.7a — appends the FX-P&L leg = −(Σ CHF) for the EUR/output drift; CHF output → drift 0 → no leg + // §4.7a — appends the FX-P&L leg = −(Σ CHF) for the EUR/output drift; CHF output → drift 0 → no leg. + // No silent plug while a leg still needsMark (§5.1 Stufe 3): an unmarked leg carries amountChf=undefined (counted + // as 0), so plugging would book its full value as a phantom fx-revaluation — leave it for the mark-to-market job to + // revalue, consistent with exchange-tx.consumer.ts. private async appendFxResidual(legs: LedgerLegInput[]): Promise { + if (legs.some((l) => l.needsMark)) 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 @@ -434,8 +439,12 @@ export class BuyFiatConsumer { 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 (await this.bookingService.nextSeq(SOURCE_TYPE, `${id}`)) > seq; + return this.bookingService.hasActiveTxAt(SOURCE_TYPE, `${id}`, seq); } private async assetAccount(assetId: number): Promise { diff --git a/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts b/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts index 49529440cd..5d9618e181 100644 --- a/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts @@ -192,8 +192,13 @@ export class CryptoInputConsumer { // --- 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 + // sub-cent → the booking-service ROUNDING leg closes it. + // No silent plug while a leg still needsMark (§5.1 Stufe 3): an unmarked leg carries amountChf=undefined (counted + // as 0), so plugging would book its full value as a phantom fx-revaluation — leave it for the mark-to-market job to + // revalue, consistent with exchange-tx.consumer.ts. private appendFxPlug(legs: LedgerLegInput[], fx: { income: LedgerAccount; expense: LedgerAccount }): void { + if (legs.some((l) => l.needsMark)) 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; @@ -208,8 +213,12 @@ export class CryptoInputConsumer { 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 (await this.bookingService.nextSeq(SOURCE_TYPE, `${id}`)) > seq; + return this.bookingService.hasActiveTxAt(SOURCE_TYPE, `${id}`, seq); } private async walletAsset(ci: CryptoInput): Promise { diff --git a/src/subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts b/src/subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts index dd3688dc28..c888196b42 100644 --- a/src/subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts @@ -12,11 +12,13 @@ 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 } from '../ledger-booking.service'; +import { LedgerBookingService, LedgerLegInput, LedgerTxInput } from '../ledger-booking.service'; import { LedgerMarkCache, LedgerMarkService } from '../ledger-mark.service'; -import { getLedgerWatermark, setLedgerWatermark } from './ledger-watermark.helper'; +import { getLedgerWatermark, 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) @@ -42,12 +44,31 @@ export class ExchangeTxConsumer { ) {} async process(): Promise { - const source = 'exchange_tx'; - const watermark = (await getLedgerWatermark(this.settingService, source)) ?? { + 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' }, @@ -62,7 +83,8 @@ export class ExchangeTxConsumer { let lastProcessedId = watermark.lastProcessedId; for (const tx of batch) { try { - await this.book(tx, marks, fillIndexMap); + 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); @@ -71,27 +93,52 @@ export class ExchangeTxConsumer { } if (lastProcessedId > watermark.lastProcessedId) { - await setLedgerWatermark(this.settingService, source, { ...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 marks = await this.markService.preload(tx.externalCreated ?? tx.created, tx.externalCreated ?? tx.created); + 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; } + + await this.bookingService.reverseAndRebookIfChanged(spec); } - private async book(tx: ExchangeTx, marks: LedgerMarkCache, fillIndexMap: Map): Promise { + // 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.bookDeposit(tx, bookingDate, marks); + return this.depositSpec(tx, bookingDate, marks); case ExchangeTxType.WITHDRAWAL: - return this.bookWithdrawal(tx, bookingDate, marks); + return this.withdrawalSpec(tx, bookingDate, marks); case ExchangeTxType.TRADE: - return this.bookTrade(tx, bookingDate, marks, fillIndexMap); + 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 bookDeposit(tx: ExchangeTx, bookingDate: Date, marks: LedgerMarkCache): Promise { + 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); @@ -111,11 +158,11 @@ export class ExchangeTxConsumer { needsMark: chf.needsMark, }; - await this.bookSingle(tx, bookingDate, [assetLeg, counterLeg]); + return this.singleSpec(tx, bookingDate, [assetLeg, counterLeg]); } // §4.3/§4.3a — Withdrawal: Dr {routeCounterAccount} / Cr ASSET/{exchange}/{ccy} (mirror) - private async bookWithdrawal(tx: ExchangeTx, bookingDate: Date, marks: LedgerMarkCache): Promise { + 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); @@ -135,16 +182,16 @@ export class ExchangeTxConsumer { needsMark: chf.needsMark, }; - await this.bookSingle(tx, bookingDate, [counterLeg, assetLeg]); + return this.singleSpec(tx, bookingDate, [counterLeg, assetLeg]); } // §4.3 — Trade: Dr ASSET/{exchange}/{base} / Cr ASSET/{exchange}/{quote} + spread + (ccxt) fee leg - private async bookTrade( + private async tradeSpec( tx: ExchangeTx, bookingDate: Date, marks: LedgerMarkCache, fillIndexMap: Map, - ): Promise { + ): Promise { const parsed = this.parseSymbol(tx); if (!parsed) { // unattributable trade → SUSPENSE rest + alarm (§4.3, not silently dropped) @@ -155,11 +202,10 @@ export class ExchangeTxConsumer { ); this.logger.error(`exchange_tx ${tx.id} trade has no resolvable symbol/side → SUSPENSE`); const chf = tx.amountChf ?? 0; - await this.bookSingle(tx, bookingDate, [ + return this.singleSpec(tx, bookingDate, [ { account: suspense, amount: chf, priceChf: 1, amountChf: chf }, { account: suspense, amount: -chf, priceChf: 1, amountChf: -chf }, ]); - return; } const { base, quote, isBuy } = parsed; @@ -220,16 +266,16 @@ export class ExchangeTxConsumer { const seq = fillIndexMap.get(tx.id) ?? 0; const order = tx.order; const sourceId = order ? `${order}` : `${tx.id}`; - const sourceType = order ? 'ExchangeTrade' : 'exchange_tx'; + const sourceType = order ? TRADE_SOURCE_TYPE : SOURCE_TYPE; - await this.bookingService.bookTx({ + return { sourceType, sourceId, seq: order ? seq : 0, bookingDate, valueDate: bookingDate, legs, - }); + }; } // --- ROUTE DISAMBIGUATION (§4.3a/§4.3b) --- // @@ -326,8 +372,18 @@ export class ExchangeTxConsumer { select: { id: true, exchange: true, order: true }, }); + // merge the batch rows so a TRADE being reconciled keeps its booking-time rank even after it flipped away from + // 'ok' (the OK-filtered query no longer returns it). Ranking is by id and order-preserving, so reinserting the row + // at its id reproduces exactly the rank it had when it was booked → 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>( - existing, + [...merged.values()], (e) => `${e.exchange}|${e.order}`, ); for (const rows of byKey.values()) { @@ -340,15 +396,15 @@ export class ExchangeTxConsumer { // --- HELPERS --- // - private async bookSingle(tx: ExchangeTx, bookingDate: Date, legs: LedgerLegInput[]): Promise { - await this.bookingService.bookTx({ - sourceType: 'exchange_tx', + 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 (Stufe 1) ?? mark × amount (Stufe 2) ?? needsMark diff --git a/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts b/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts index bbb5b882f6..7b0f9d699d 100644 --- a/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts +++ b/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts @@ -1,12 +1,18 @@ import { SettingService } from 'src/shared/models/setting/setting.service'; import { Config } from 'src/config/config'; import { DfxLogger } from 'src/shared/services/dfx-logger'; -import { MoreThan, Repository } from 'typeorm'; +import { Raw, Repository } from 'typeorm'; -// per-source checkpoint (§11.3): id-watermark + content-change scan watermark +// 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.'; @@ -19,12 +25,18 @@ export async function getLedgerWatermark( settingService: SettingService, source: string, ): Promise { - const raw = await settingService.getObj<{ lastProcessedId: number; lastReversalScan: string }>( - `${WATERMARK_KEY_PREFIX}${source}`, - ); + 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) }; + return { + lastProcessedId: raw.lastProcessedId, + lastReversalScan: new Date(raw.lastReversalScan), + lastReversalScanId: raw.lastReversalScanId ?? 0, + }; } /** @@ -41,6 +53,7 @@ export async function setLedgerWatermark( JSON.stringify({ lastProcessedId: watermark.lastProcessedId, lastReversalScan: watermark.lastReversalScan.toISOString(), + lastReversalScanId: watermark.lastReversalScanId ?? 0, }), ); } @@ -56,11 +69,18 @@ const contentChangeLogger = new DfxLogger('LedgerContentChangeScan'); * - **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 by `updated > lastReversalScan` (independent of id), runs the SAME idempotent forward - * `book(row)` per row, and advances `lastReversalScan` ONLY after the whole scan committed without error (§4.12 Minor - * R12-2: a failed booking leaves the watermark unchanged → the 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. + * 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, @@ -70,31 +90,46 @@ export async function runContentChangeScan, 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: MoreThan(watermark.lastReversalScan) } as any, + 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 any, relations: scanRelations as any, order: { updated: 'ASC', id: 'ASC' } as any, - take: Config.ledger.backfillBatchSize, + take: batchSize, }); if (!changed.length) return; - let scannedThrough = watermark.lastReversalScan; + let cursor: { updated: Date; id: number } | undefined; for (const row of changed) { try { await book(row); - scannedThrough = row.updated; // advance only past rows whose (idempotent) re-book committed + 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); - // a strict `MoreThan(updated)` advance must NOT skip the failed row when a committed earlier row shares its - // `updated` timestamp — cap the advance strictly BELOW the failed row's updated so it is re-selected next run - if (scannedThrough.getTime() >= row.updated.getTime()) { - scannedThrough = new Date(row.updated.getTime() - 1); - } - break; // leave the rest for the next run → self-healing retry (§4.12 Minor R12-2) + break; // leave the failed row + the rest for the next run → self-healing retry (§4.12 Minor R12-2) } } - if (scannedThrough.getTime() > watermark.lastReversalScan.getTime()) { - await setLedgerWatermark(settingService, source, { ...watermark, lastReversalScan: scannedThrough }); + // 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/payout-order.consumer.ts b/src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts index 5bc92e3346..ddbc858006 100644 --- a/src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts @@ -341,8 +341,13 @@ export class PayoutOrderConsumer { // --- HELPERS --- // - // appends an EXPENSE/INCOME fx-revaluation plug for the CHF residual > tolerance (§4.5); sub-cent → ROUNDING + // appends an EXPENSE/INCOME fx-revaluation plug for the CHF residual > tolerance (§4.5); sub-cent → ROUNDING. + // No silent plug while a leg still needsMark (§5.1 Stufe 3): an unmarked leg carries amountChf=undefined (counted + // as 0), so plugging would book its full value as a phantom fx-revaluation — leave the tx for the mark-to-market job + // to revalue, consistent with exchange-tx.consumer.ts (mark-to-market then closes the residual). private async withFxPlug(legs: LedgerLegInput[]): Promise { + if (legs.some((l) => l.needsMark)) 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; diff --git a/src/subdomains/core/accounting/services/ledger-booking.service.ts b/src/subdomains/core/accounting/services/ledger-booking.service.ts index b2f83e3aa4..04a68c1b68 100644 --- a/src/subdomains/core/accounting/services/ledger-booking.service.ts +++ b/src/subdomains/core/accounting/services/ledger-booking.service.ts @@ -30,6 +30,14 @@ export interface LedgerTxInput { 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); @@ -78,7 +86,7 @@ export class LedgerBookingService { * seq in the (sourceType, sourceId) namespace; the original stays untouched. */ async reverseTx(original: LedgerTx): Promise { - const nextSeq = await this.nextSeq(original.sourceType, original.sourceId); + const nextSeq = await this.nextCorrectionSeq(original.sourceType, original.sourceId); return this.bookTx({ sourceType: original.sourceType, @@ -119,8 +127,9 @@ export class LedgerBookingService { // (1) reversal-tx (reversalOf = the active original, inverted legs) await this.reverseTx(active); - // (2) re-book-tx (reversalOf = NULL — a new valid booking) with the corrected legs, next free seq - const reSeq = await this.nextSeq(input.sourceType, input.sourceId); + // (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.nextCorrectionSeq(input.sourceType, input.sourceId); await this.bookTx({ ...input, seq: reSeq, reversalOf: undefined }); return true; @@ -139,6 +148,17 @@ export class LedgerBookingService { 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; + } + /** * 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 @@ -215,6 +235,12 @@ export class LedgerBookingService { return (max ?? -1) + 1; } + // 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); + } + 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; diff --git a/src/subdomains/core/accounting/services/ledger-cutover.service.ts b/src/subdomains/core/accounting/services/ledger-cutover.service.ts index 369d993419..0c22186542 100644 --- a/src/subdomains/core/accounting/services/ledger-cutover.service.ts +++ b/src/subdomains/core/accounting/services/ledger-cutover.service.ts @@ -21,10 +21,14 @@ import { 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 { BankTx } from 'src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +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, CryptoInputSettledStatus } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; import { PayoutOrder, PayoutOrderStatus } from 'src/subdomains/supporting/payout/entities/payout-order.entity'; -import { Between, IsNull, Repository, SelectQueryBuilder } from 'typeorm'; +import { Between, In, IsNull, Repository, SelectQueryBuilder } from 'typeorm'; import { AccountType, LedgerAccount } from '../entities/ledger-account.entity'; import { LedgerBookingService, LedgerLegInput } from './ledger-booking.service'; import { LedgerBootstrapService } from './ledger-bootstrap.service'; @@ -32,10 +36,17 @@ import { LedgerAccountService } from './ledger-account.service'; import { LedgerMarkCache, LedgerMarkService } from './ledger-mark.service'; 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'; 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 { @@ -51,6 +62,11 @@ export class LedgerCutoverService { @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, @@ -84,8 +100,9 @@ export class LedgerCutoverService { // (1) CoA bootstrap (idempotent, findOrCreate per account) await this.bootstrapService.bootstrap(); - // (2) snapshot logId = newest valid FinancialDataLog ≤ Stichtag (now) - const snapshot = await this.selectSnapshot(); + // (2) snapshot logId = newest valid FinancialDataLog ≤ Stichtag (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); @@ -110,6 +127,35 @@ export class LedgerCutoverService { // --- 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 ≤ Stichtag. Bounded read (last 2 days) then pick latest ≤ now. private async selectSnapshot(): Promise { const now = new Date(); @@ -199,6 +245,12 @@ export class LedgerCutoverService { await this.openBuyFiatOwed(snapshot, snapshotDate, lookback, marks, equity); await this.openBuyCryptoReceived(snapshot, snapshotDate, lookback, equity); await this.openBuyCryptoOwed(snapshot, snapshotDate, lookback, marks, equity); + // §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); + await this.openBankTxRepeat(snapshot, snapshotDate, lookback, marks, equity); + await this.openUnattributed(snapshot, snapshotDate, lookback, marks, equity); } // buyFiat-received: open rows with outputAmount NULL → CHF = amountInChf (Minor R3-6); per-row seq0-marker (R4-2) @@ -310,6 +362,139 @@ export class LedgerCutoverService { } } + // --- 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, + ): 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); + } + } + + // §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, + ): 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); + } + } + + // 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, + ): Promise { + if (bankTx?.amount == null) return; // no underlying bank_tx amount → nothing to anchor + + const { mark } = await this.bankMark(bankTx, date, marks); + const amountChf = mark != null ? Util.round(bankTx.amount * mark, 2) : undefined; + + await this.bookReceivedOwedOpening( + snapshot, + date, + `${snapshot.id}:${marker}:${bankTx.id}`, + `Opening ${marker} from open bank_tx #${bankTx.id}`, + liability, + amountChf, + equity, + mark == null, + ); + } + + // §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, + ): Promise { + // §6.1: type NULL/Pending/Unknown/GSheet credits → the unattributed bucket (two where-branches 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) } })), + ...(await this.bankTxRepo.find({ where: { ...credit, type: IsNull() } })), + ]; + + let amountChf = 0; + let needsMark = false; + for (const row of rows) { + if (row.amount == null) continue; + const { mark } = await this.bankMark(row, date, marks); + 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'); + await this.bookReceivedOwedOpening( + snapshot, + date, + `${snapshot.id}:unattributed`, + `Opening unattributed from open bank_tx credits as of FinancialDataLog #${snapshot.id}`, + liability, + Util.round(amountChf, 2), + equity, + needsMark, + ); + } + + // 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 async bankMark( + bankTx: BankTx, + date: Date, + marks: LedgerMarkCache, + ): Promise<{ asset?: Asset; mark: number | undefined }> { + const bank = bankTx.accountIban + ? await this.bankRepo.findOne({ where: { iban: bankTx.accountIban }, relations: { asset: true } }) + : null; + + 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. diff --git a/src/subdomains/supporting/log/log.service.ts b/src/subdomains/supporting/log/log.service.ts index 50593dd256..525ed4cb7d 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' } }); } From b603b29da9bd0a03e7184a75ba4b02eacbf2c1a9 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:21:27 +0200 Subject: [PATCH 04/25] test(accounting): polish review minors - drop unused imports in cutover spec, use synthetic source id in mapper spec - add multi-page pagination test for mark-to-market candidate selection - scope isolation-gate allowlist marker to write blocks only, with self-test --- scripts/ledger-isolation-gate.js | 29 +++++++- scripts/ledger-isolation-gate.sh | 49 +++++++++---- .../dto/__tests__/ledger-dto.mapper.spec.ts | 4 +- .../integration/isolation-gate.spec.ts | 37 ++++++++++ .../__tests__/ledger-cutover.service.spec.ts | 2 +- .../ledger-mark-to-market.service.spec.ts | 69 +++++++++++++++++-- 6 files changed, 167 insertions(+), 23 deletions(-) diff --git a/scripts/ledger-isolation-gate.js b/scripts/ledger-isolation-gate.js index 212c8e4618..c4c19cf551 100644 --- a/scripts/ledger-isolation-gate.js +++ b/scripts/ledger-isolation-gate.js @@ -15,13 +15,30 @@ const path = require('path'); const TARGET_DIR = process.argv[2] || 'src/subdomains/core/accounting'; -// the 7-block forbidden pattern (§4.10) — kept char-for-char equivalent to the shell PATTERN -const PATTERN = new RegExp( +// §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|increment|decrement)\\(|\\b(?!ledger)\\w*Repo(sitory)?\\.(update|save|insert|delete|remove|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`, @@ -89,7 +106,13 @@ if (stat?.isDirectory()) { for (const file of files) { const lines = fs.readFileSync(file, 'utf8').split('\n'); lines.forEach((line, i) => { - if (PATTERN.test(line) && !line.includes(ALLOWLIST_MARKER)) { + // 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()}`); } }); diff --git a/scripts/ledger-isolation-gate.sh b/scripts/ledger-isolation-gate.sh index d5a7a9370a..e8d72c196a 100755 --- a/scripts/ledger-isolation-gate.sh +++ b/scripts/ledger-isolation-gate.sh @@ -4,11 +4,18 @@ # 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 Block-4b pattern flags EVERY `manager`-write line +# 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 @@ -25,12 +32,19 @@ set -u TARGET_DIR="${1:-src/subdomains/core/accounting}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# the 7-block forbidden pattern (§4.10); \b word-boundary anchors keep the method tokens injection-name-independent -PATTERN='pricingService|PricingService|getPrice\(|getPriceAt|priceProvider|CoinGecko|HttpService' -PATTERN+='|\brefreshBalances\(|\brefreshBankBalance|\bhasPendingOrders|integration\.getBalances|integration\.hasPendingOrders|BankAdapter|balanceIntegrationFactory|LiquidityBalanceIntegrationFactory' -PATTERN+='|\blogService\.(create|update)\(|\bsettingService\.(setObj|updateProcess|addIpToBlacklist|deleteIpFromBlacklist)\(' -PATTERN+='|\.complete\(|checkOrderCompletion|syncExchanges|doPayout|checkPayoutCompletionData|triggerWebhook|calculateSpreadFee' -PATTERN+='|balanceRepo\.(update|save|insert|delete|remove|increment|decrement)\(|\b(?!ledger)\w*Repo(sitory)?\.(update|save|insert|delete|remove|increment|decrement)\(' +# 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|increment|decrement)\(|\b(?!ledger)\w*Repo(sitory)?\.(update|save|insert|delete|remove|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 @@ -39,7 +53,7 @@ PATTERN+='|balanceRepo\.(update|save|insert|delete|remove|increment|decrement)\( # `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. -PATTERN+='|\b\w*[Mm]anager\.(save|insert|update|delete|remove|upsert|softDelete|softRemove|recover|increment|decrement|query)\(|\bdataSource\.query\(|\bqueryRunner\.query\(' +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 @@ -47,19 +61,28 @@ PATTERN+='|\b\w*[Mm]anager\.(save|insert|update|delete|remove|upsert|softDelete| # 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). -PATTERN+='|getRepository\([^)]*\)\.(save|insert|update|delete|remove|upsert|softDelete|softRemove|recover|increment|decrement)\(|\b\w+Service\.(save|insert|update|delete|remove|upsert)\w*\(' +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. -PATTERN+='|\.createQueryBuilder\([^)]*\)\.(update|insert|delete|softDelete)\(' +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[@]}" "$PATTERN" "$TARGET_DIR" 2>/dev/null | grep -v 'ledger-allowlist' + "$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() { @@ -74,11 +97,11 @@ pcre2_grep() { MATCHES="" if command -v rg >/dev/null 2>&1 && rg --pcre2 --version >/dev/null 2>&1; then - MATCHES="$(rg --pcre2 -n -g '*.ts' -g '!*.spec.ts' -g '!__tests__' -g '!__mocks__' "$PATTERN" "$TARGET_DIR" 2>/dev/null | grep -v 'ledger-allowlist')" + 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 pattern + post-filter) + # 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 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 index f9ed97c69e..5edd07f79e 100644 --- a/src/subdomains/core/accounting/dto/__tests__/ledger-dto.mapper.spec.ts +++ b/src/subdomains/core/accounting/dto/__tests__/ledger-dto.mapper.spec.ts @@ -17,7 +17,7 @@ function tx(custom: Partial): LedgerTx { bookingDate: new Date('2026-06-07T00:00:00.000Z'), valueDate: new Date('2026-06-08T00:00:00.000Z'), sourceType: 'buy_fiat', - sourceId: '68310', + sourceId: '99001', seq: 1, ...custom, }); @@ -95,7 +95,7 @@ describe('LedgerDtoMapper', () => { 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('68310'); + expect(dto.sourceId).toBe('99001'); expect(dto.seq).toBe(1); expect(dto.counterAccountId).toBe(9); expect(dto.counterAccountName).toBe('LIABILITY/buyFiat-received'); 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 index c635e855a6..42efacafba 100644 --- a/src/subdomains/core/accounting/services/__tests__/integration/isolation-gate.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/integration/isolation-gate.spec.ts @@ -101,6 +101,25 @@ describe('Ledger isolation gate (§4.10 / §10.1 self-test)', () => { 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 }) => { @@ -179,6 +198,24 @@ describe('Ledger isolation gate (§4.10 / §10.1 self-test)', () => { 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 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 index 2ae08cc8f1..ea6e00f1d1 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts @@ -14,7 +14,7 @@ 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, BankTxIndicator, BankTxType } from 'src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.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 } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; 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 index d654a8d2ee..5ba699e87a 100644 --- 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 @@ -1,6 +1,7 @@ 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'; @@ -16,7 +17,8 @@ import { LedgerMarkCache, LedgerMarkService } from '../ledger-mark.service'; import { LedgerMarkToMarketService } from '../ledger-mark-to-market.service'; interface LegQueryStub { - candidateIds?: number[]; // selectCandidates getRawMany + 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 } @@ -47,20 +49,29 @@ describe('LedgerMarkToMarketService', () => { }); } - // a chainable query-builder stub that resolves its terminal method from legStub by query shape + // 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 = 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 = () => Promise.resolve((legStub.candidateIds ?? []).map((id) => ({ accountId: id }))); + 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; @@ -223,4 +234,54 @@ describe('LedgerMarkToMarketService', () => { 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 + }); }); From 6c17d9908df879d00c7e62f52ff4d8ef67b1060f Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 13 Jun 2026 17:08:32 +0200 Subject: [PATCH 05/25] test(accounting): harden branch coverage to >=90% across module - add controller, module-compile, DTO, repository and entity-relation tests (structural files to 100%) - cover consumer and service error/fallback/skip branches; module branch coverage 81.5% -> 97%, 619 tests - replace 4 weak tests with real behaviour assertions (fee amount/account, dex txId filter, bootstrap idempotency re-run, equity-parity arithmetic) - fix: openUnattributed passed -0 instead of undefined amountChf for needsMark opening legs (found via coverage) --- .../__tests__/accounting.module.spec.ts | 108 +++ .../__tests__/ledger.controller.spec.ts | 199 +++++ .../dto/__tests__/ledger-dto.shapes.spec.ts | 264 +++++++ .../__tests__/ledger-relations.spec.ts | 97 +++ .../ledger-account.repository.spec.ts | 27 + .../__tests__/ledger-leg.repository.spec.ts | 27 + .../__tests__/ledger-tx.repository.spec.ts | 27 + .../__tests__/ledger-account.service.spec.ts | 29 + .../__tests__/ledger-booking.service.spec.ts | 199 +++++ .../ledger-bootstrap.service.spec.ts | 32 +- .../__tests__/ledger-cutover.service.spec.ts | 543 ++++++++++++++ .../ledger-mark-to-market.service.spec.ts | 103 +++ .../__tests__/ledger-mark.service.spec.ts | 91 +++ .../__tests__/ledger-query.service.spec.ts | 328 +++++++++ .../ledger-reconciliation.service.spec.ts | 155 +++- .../__tests__/bank-tx.consumer.spec.ts | 623 ++++++++++++++++ .../__tests__/buy-crypto.consumer.spec.ts | 128 ++++ .../__tests__/buy-fiat.consumer.spec.ts | 697 +++++++++++++++++- .../__tests__/crypto-input.consumer.spec.ts | 213 ++++++ .../__tests__/exchange-tx.consumer.spec.ts | 567 ++++++++++++++ .../__tests__/liquidity-mgmt.consumer.spec.ts | 39 + .../liquidity-order-dex.consumer.spec.ts | 112 ++- .../__tests__/payout-order.consumer.spec.ts | 432 +++++++++++ .../__tests__/trading-order.consumer.spec.ts | 56 ++ .../services/ledger-cutover.service.ts | 4 +- 25 files changed, 5084 insertions(+), 16 deletions(-) create mode 100644 src/subdomains/core/accounting/__tests__/accounting.module.spec.ts create mode 100644 src/subdomains/core/accounting/controllers/__tests__/ledger.controller.spec.ts create mode 100644 src/subdomains/core/accounting/dto/__tests__/ledger-dto.shapes.spec.ts create mode 100644 src/subdomains/core/accounting/entities/__tests__/ledger-relations.spec.ts create mode 100644 src/subdomains/core/accounting/repositories/__tests__/ledger-account.repository.spec.ts create mode 100644 src/subdomains/core/accounting/repositories/__tests__/ledger-leg.repository.spec.ts create mode 100644 src/subdomains/core/accounting/repositories/__tests__/ledger-tx.repository.spec.ts 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/controllers/__tests__/ledger.controller.spec.ts b/src/subdomains/core/accounting/controllers/__tests__/ledger.controller.spec.ts new file mode 100644 index 0000000000..b0de5c83f8 --- /dev/null +++ b/src/subdomains/core/accounting/controllers/__tests__/ledger.controller.spec.ts @@ -0,0 +1,199 @@ +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 + const roleGuard = guards.find((g) => (g as { entryRole?: UserRole }).entryRole !== undefined) as { + entryRole: UserRole; + }; + expect(roleGuard).toBeDefined(); + expect(roleGuard.entryRole).toBe(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/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..2fe2d265ff --- /dev/null +++ b/src/subdomains/core/accounting/dto/__tests__/ledger-dto.shapes.spec.ts @@ -0,0 +1,264 @@ +import { plainToInstance } from 'class-transformer'; +import { validateSync } from 'class-validator'; +import { AccountType } from '../../entities/ledger-account.entity'; +import { + LedgerAccountBalanceDto, + LedgerAccountsResponseDto, + LedgerLegEntryDto, + LedgerLegsResponseDto, + LedgerPeriodDto, +} 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, + 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: '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: 'fresh', + status: '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('diff'); + expect(dto.accounts[0].staleness).toBe('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/entities/__tests__/ledger-relations.spec.ts b/src/subdomains/core/accounting/entities/__tests__/ledger-relations.spec.ts new file mode 100644 index 0000000000..c02bcbb7ca --- /dev/null +++ b/src/subdomains/core/accounting/entities/__tests__/ledger-relations.spec.ts @@ -0,0 +1,97 @@ +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 { 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: Function, 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: Function, 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); + }); + }); +}); 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/services/__tests__/ledger-account.service.spec.ts b/src/subdomains/core/accounting/services/__tests__/ledger-account.service.spec.ts index 19c4b7ac74..e20a03c5ba 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-account.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-account.service.spec.ts @@ -60,4 +60,33 @@ describe('LedgerAccountService', () => { 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.service.spec.ts b/src/subdomains/core/accounting/services/__tests__/ledger-booking.service.spec.ts index 8ae86c70ee..33cfb0252a 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-booking.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-booking.service.spec.ts @@ -85,6 +85,21 @@ describe('LedgerBookingService', () => { 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 }, @@ -346,5 +361,189 @@ describe('LedgerBookingService', () => { 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 (line 192): when MORE THAN ONE non-reversal booking sits above the reversal's + // seq, the walk must pick the one with the SMALLEST seq via `.sort((a,b) => a.seq - b.seq)[0]`. A single candidate + // never invokes the comparator; this seeds two candidates (1_000_001 and 1_000_002) above the reversal so the + // comparator actually runs and the EARLIEST corrected re-book (1_000_001) is chosen as the chain's next link. + it('picks the smallest-seq re-book when several non-reversal tx sit above the reversal (sort comparator)', async () => { + await service.bookTx(seq0Input(50000)); // forward seq0 (id 1) + const original = txStore.find((t) => t.seq === 0)!; + + // seed a reversal of seq0 plus TWO independent non-reversal re-books above it (out of seq order on purpose) + 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, // larger seq, inserted first → comparator must still pick the smaller one + 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, // the SMALLEST seq above the reversal → 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 earliest re-book (50000) → the walk resolves the active tx to the + // smallest-seq re-book (1_000_001), finds it unchanged → no-op (false). A broken comparator would resolve to + // 1_000_002 (60000), 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 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); + }); + }); + + 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 index 6754f638b0..b036818432 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-bootstrap.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-bootstrap.service.spec.ts @@ -175,15 +175,37 @@ describe('LedgerBootstrapService', () => { expect(created.find((c) => c.name === 'TRANSIT/wallet↔Binance/USDT')?.currency).toBe('USDT'); }); - it('is idempotent — findOrCreate per account (re-run no-op via UNIQUE(name))', async () => { + 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 firstRunCount = (ledgerAccountService.findOrCreate as jest.Mock).mock.calls.length; + const secondRunCalls = (ledgerAccountService.findOrCreate as jest.Mock).mock.calls.length - firstRunCalls; - // findOrCreate guarantees no duplicate names; a re-run resolves existing accounts - expect(firstRunCount).toBeGreaterThan(0); - expect(new Set(created.map((c) => c.name)).size).toBe(created.length); // no duplicate names + 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 index ea6e00f1d1..bec0c80cef 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts @@ -518,5 +518,548 @@ describe('LedgerCutoverService', () => { 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 + }); + }); + + // --- SNAPSHOT SELECTION + PINNING (§6.3 / Major design-accounting R3-1) --- // + + describe('snapshot selection + pinning (R3-1)', () => { + it('throws (caught by run) when there is no valid FinancialDataLog snapshot ≤ Stichtag', 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 → run() catches it → 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()).resolves.toBeUndefined(); + + 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 (caught) when the pinned snapshot log no longer exists', 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()).resolves.toBeUndefined(); // run swallows the throw + + 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 (caught) when the snapshot message is not parseable JSON', 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()).resolves.toBeUndefined(); + + 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(); + }); + + it('skips a buyFiat-received row whose amountInChf is null (no CHF anchor → no leg)', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + 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); + }); + + it('opens buyCrypto-received per row CHF = amountInChf and skips a row with null amountInChf', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest.spyOn(buyCryptoRepo, 'find').mockImplementation(({ where }: any) => { + // received query: outputAmount IS NULL → one bookable row (id 60) + one null-amountInChf row (id 61, skipped) + if (where?.outputAmount) { + return Promise.resolve([ + buyCrypto({ id: 60, amountInChf: 15000, outputAmount: null }), + buyCrypto({ id: 61, amountInChf: null, outputAmount: null }), + ]); + } + 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); // null amountInChf → skipped + }); + + 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); + }); + + it('opens an aggregated unattributed with needsMark when a credit cannot be valued (no mark)', 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 → needsMark + jest + .spyOn(bankRepo, 'findOne') + .mockResolvedValue(Object.assign(new Bank(), { 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 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.needsMark).toBe(true); // a feedless credit → needsMark, valued later by mark-to-market + expect(liabilityLeg.amountChf).toBeUndefined(); + }); + + 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, 'findOne').mockResolvedValue(null); // 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); + }); + + // §6.1 openBuyFiatOwed: a foreign-currency (EUR) output whose fiat-mark is missing is skipped — line 296 + // (cannot value the CHF-denominated owed liability now; the forward path values it later). + it('skips a foreign-currency buyFiat-owed row when its fiat-mark is missing (no value)', 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 service.run(); + + expect(booked.some((b) => b.sourceId === '1557344:buy_fiat-owed:51')).toBe(false); // no fiat-mark → skipped + }); + + // fiatMark (line 719 `assetId != null` false side): a foreign-currency buyFiat-owed whose outputAsset has NO id → + // fiatMark(undefined, …) returns undefined → the row is skipped (cannot value the owed liability). + it('skips a foreign-currency buyFiat-owed row 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 → skip + return Promise.resolve([buyFiat({ id: 53, outputAmount: 1000, outputAsset: { name: 'EUR' } as any })]); + } + return Promise.resolve([]); + }); + + await service.run(); + + 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 → needsMark opening (valued later by mark-to-market). Distinct from the id-present-no-mark case. + it('opens a buyCrypto-owed with needsMark when the 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 + } + return Promise.resolve([]); + }); + + await service.run(); + + const owedTx = booked.find((b) => b.sourceId === '1557344:buy_crypto-owed:55'); + expect(owedTx).toBeDefined(); + const liabilityLeg = owedTx.legs.find((l) => l.account.type === AccountType.LIABILITY); + expect(liabilityLeg.needsMark).toBe(true); // no outputAsset.id → no mark → needsMark + expect(liabilityLeg.amountChf).toBeUndefined(); + }); + + // §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 → needsMark, amountChf + // undefined (line 423 undefined side). EUR bank, empty mark cache → mark undefined → the liability leg is feedless. + it('opens a bankTx-return with needsMark when the bank mark is missing (line 423 undefined side)', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest.spyOn(markService, 'preload').mockResolvedValue(new LedgerMarkCache(new Map())); // no EUR mark + jest + .spyOn(bankRepo, 'findOne') + .mockResolvedValue(Object.assign(new Bank(), { 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 service.run(); + + const returnTx = booked.find((b) => b.sourceId === '1557344:bank_tx-return:95'); + expect(returnTx).toBeDefined(); + const liabilityLeg = returnTx.legs.find((l) => l.account.type === AccountType.LIABILITY); + expect(liabilityLeg.needsMark).toBe(true); // EUR + no mark → feedless, valued later + expect(liabilityLeg.amountChf).toBeUndefined(); + }); + + // §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 → the unattributed leg is needsMark. + it('values an unattributed credit needsMark when the bank_tx has no accountIban and no 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 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.needsMark).toBe(true); // no accountIban, EUR, no mark → needsMark + }); + + // §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 → needsMark. + it('values an unattributed credit needsMark when the matched bank has no asset', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest.spyOn(markService, 'preload').mockResolvedValue(new LedgerMarkCache(new Map())); + jest + .spyOn(bankRepo, 'findOne') + .mockResolvedValue(Object.assign(new Bank(), { 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 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.needsMark).toBe(true); // bank has no asset → no mark → needsMark + }); + }); + + // --- 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(); + 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) + 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 (seq advances, no leg)', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({ '100': { priceChf: 2 } })]); + jest.spyOn(settingService, 'getObj').mockResolvedValue([ + { assetId: 100, value: 0 }, // no value → skipped + { assetId: 100, value: 500 }, // bookable → seq must be 1 (the skipped position advanced seq) + ] as any); + + await service.run(); + + expect(booked.some((b) => b.sourceId === '1557344:manual-debt:100' && b.seq === 0)).toBe(false); // skipped at seq0 + const booked1 = booked.find((b) => b.sourceId === '1557344:manual-debt:100' && b.seq === 1); + expect(booked1).toBeDefined(); + expect(booked1.legs.find((l) => l.account.type === AccountType.LIABILITY).amountChf).toBe(-1000); + }); + + it('opens a manual-debt with needsMark + native fallback when the asset priceChf is non-finite', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({ '100': { priceChf: NaN } })]); + jest.spyOn(settingService, 'getObj').mockResolvedValue([{ assetId: 100, value: 1000 }] as any); + + await service.run(); + + const manualTx = booked.find((b) => b.sourceId === '1557344:manual-debt:100'); + expect(manualTx).toBeDefined(); + const liabilityLeg = manualTx.legs.find((l) => l.account.type === AccountType.LIABILITY); + expect(liabilityLeg.needsMark).toBe(true); + expect(liabilityLeg.amount).toBe(-1000); // native fallback −value (no CHF basis yet) + expect(liabilityLeg.amountChf).toBeUndefined(); + }); + + 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 seq 0 → nextSeq already returns 1 → alreadyBooked(seq 0) true → no re-book + nextSeqByKey.set('cutover:1557344', 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 index 5ba699e87a..2ef263e828 100644 --- 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 @@ -181,6 +181,65 @@ describe('LedgerMarkToMarketService', () => { 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)]); @@ -214,6 +273,50 @@ describe('LedgerMarkToMarketService', () => { expect(bookingService.bookTx).not.toHaveBeenCalled(); }); + // run() failure-isolation (line 57): markToMarket throwing (e.g. the candidate query fails) is caught and logged, + // run() resolves without rethrowing — the cron must never crash the scheduler. + it('catches a markToMarket error in run() and never rethrows (failure-isolation)', 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 + }); + const errSpy = jest.spyOn(service['logger'], 'error'); + + await expect(service.run()).resolves.toBeUndefined(); + + expect(errSpy).toHaveBeenCalledWith('Ledger mark-to-market failed', expect.any(Error)); + 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. 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 index 78031c69a5..8f5b83d72f 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-mark.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-mark.service.spec.ts @@ -107,4 +107,95 @@ describe('LedgerMarkService', () => { 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 index da29d695c0..d60f69892f 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-query.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-query.service.spec.ts @@ -217,6 +217,26 @@ describe('LedgerQueryService', () => { 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('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', () => { @@ -267,6 +287,25 @@ describe('LedgerQueryService', () => { 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, 'journalNativeBalance').mockResolvedValue(0); + + const res = await service.getReconStatus(); + + const acc = res.accounts.find((a) => a.accountId === 4); + expect(acc).toBeDefined(); + expect(acc.staleness).toBe('placeholder'); // line 370 + expect(acc.status).toBe('unverified'); // placeholder → not fresh, not stale → unverified + }); }); describe('getSuspense', () => { @@ -352,6 +391,37 @@ describe('LedgerQueryService', () => { 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); + }); + + // 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', () => { @@ -444,5 +514,263 @@ describe('LedgerQueryService', () => { 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, journalNativeBalance, 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 journalNativeBalance (null getRawOne → 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'))]); + // journalNativeBalance getRawOne → null → 0; feed amount 0 → diff 0 → ok (fresh by default mock) + jest.spyOn(ledgerLegRepository, 'createQueryBuilder').mockImplementation(() => richLegQb(() => null)); + + const res = await service.getReconStatus(); + + expect(res.accounts[0].ledgerBalance).toBe(0); // SUM(amount) null → 0 + expect(res.accounts[0].status).toBe('ok'); // diff 0 ≤ tolerance + }); + + it('getEquityComparison runs the real journalEquityAt + decomposition query-builders (residual = other)', async () => { + const log = Object.assign(new Log(), { + id: 1, + created: new Date('2026-06-10T00:00:00.000Z'), + message: JSON.stringify({ + assets: {}, + tradings: {}, + balancesByFinancialType: {}, + balancesTotal: { totalBalanceChf: 20000 }, + }), + }); + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([log]); + // an unverified ASSET account drives unverifiedAccountIds → the staleFeed query actually runs (not skipped) + 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').mockImplementation(() => + richLegQb((_selects, wheres) => { + // staleFeed: mark_to_market sourceType + accountId IN (unverified) → −200 (check FIRST, no account.type) + if (wheres.includes('tx.sourceType = :sourceType')) return { chf: '-200' }; + // spreadFees: EXPENSE type + account.name NOT IN (...) → −100 (has account.type = :type too → check before it) + if (wheres.includes('account.name NOT IN')) return { chf: '-100' }; + // journalEquityAt: balance-account types IN (...) → 19000 + if (wheres.includes('account.type IN')) return { chf: '19000' }; + // transitPhantom: account.type = :type (TRANSIT) → −500 + if (wheres.includes('account.type = :type')) return { chf: '-500' }; + return { chf: '0' }; + }), + ); + + const res = await service.getEquityComparison(undefined, true); + + const period = res.periods[0]; + expect(period.journalEquity).toBe(19000); + expect(period.difference).toBe(-1000); // 19000 − 20000 + expect(period.decomposition.transitPhantom).toBe(-500); + expect(period.decomposition.staleFeed).toBe(-200); + expect(period.decomposition.spreadFees).toBe(-100); + expect(period.decomposition.other).toBe(-200); // −1000 − (−800) + }); + + // null-COALESCE fallbacks across the equity decomposition: journalEquityAt (L470), transitPhantom (L493), + // staleFeed (L510) and spreadFees (L527) each do `+(raw?.chf ?? 0)` — drive EVERY getRawOne to null so all four + // `?? 0` null sides fire (each helper → 0). Additionally unverifiedAccountIds (L540) sees an ASSET account whose + // assetId is null → `if (account.assetId == null) return false` (the false guard) plus a genuinely-unverified + // (NO_FEED) account, so the list is non-empty and the staleFeed SUM query actually executes (not short-circuited). + it('null getRawOne drives every equity-decomposition ?? 0 to 0 and skips a null-assetId account in the unverified scan (L470/L493/L510/L527/L540)', async () => { + const log = Object.assign(new Log(), { + id: 1, + created: new Date('2026-06-10T00:00:00.000Z'), + message: JSON.stringify({ balancesTotal: { totalBalanceChf: 20000 } }), + }); + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([log]); + + const nullAssetIdAccount = createCustomLedgerAccount({ + id: 7, + name: 'ASSET/no-asset-link', + type: AccountType.ASSET, + assetId: undefined, // → L540 false guard, skipped before classifyFeed + }); + const unverified = assetAccount(2, 12, 'Olkypay/EUR'); // NO_FEED → non-fresh → keeps the list 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); + + let staleQueryRan = false; + jest.spyOn(ledgerLegRepository, 'createQueryBuilder').mockImplementation(() => + richLegQb((_selects, wheres) => { + if (wheres.includes('tx.sourceType = :sourceType')) staleQueryRan = true; // staleFeed SUM query executed + return null; // every getRawOne → null → +(raw?.chf ?? 0) → 0 for all four helpers + }), + ); + + const res = await service.getEquityComparison(undefined, true); + + const period = res.periods[0]; + expect(staleQueryRan).toBe(true); // unverified list non-empty (null-assetId account skipped, NO_FEED kept) → SUM ran + expect(period.journalEquity).toBe(0); // journalEquityAt null chf → 0 (L470) + expect(period.difference).toBe(-20000); // 0 − 20000 + expect(period.decomposition.transitPhantom).toBe(0); // L493 + expect(period.decomposition.staleFeed).toBe(0); // L510 + expect(period.decomposition.spreadFees).toBe(0); // L527 + expect(period.decomposition.other).toBe(-20000); // −20000 − (0+0+0) residual + }); + + it('staleFeed short-circuits to 0 when there are no unverified accounts', async () => { + const log = Object.assign(new Log(), { + id: 1, + created: new Date('2026-06-10T00:00:00.000Z'), + message: JSON.stringify({ balancesTotal: { totalBalanceChf: 100 } }), + }); + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([log]); + // a FRESH account → unverifiedAccountIds is empty → staleFeed returns 0 WITHOUT running the SUM query + 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); + + let staleQueryRan = false; + jest.spyOn(ledgerLegRepository, 'createQueryBuilder').mockImplementation(() => + richLegQb((_selects, wheres) => { + if (wheres.includes('tx.sourceType = :sourceType')) staleQueryRan = true; // staleFeed SUM query + if (wheres.includes('account.type IN')) return { chf: '100' }; + return { chf: '0' }; + }), + ); + + const res = await service.getEquityComparison(undefined, true); + + expect(staleQueryRan).toBe(false); // no unverified accounts → the staleFeed SUM query is never executed + expect(res.periods[0].decomposition.staleFeed).toBe(0); + }); + }); + + // --- 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 index 1d4a552523..8e09d7283f 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-reconciliation.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-reconciliation.service.spec.ts @@ -281,6 +281,51 @@ describe('LedgerReconciliationService', () => { 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 @@ -312,21 +357,121 @@ describe('LedgerReconciliationService', () => { 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 and logs the difference (no leading minus, R8-1)', async () => { + 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) + }); + + it('logs the EXACT computed difference = journalEquity − totalBalanceChf (not a vacuous substring)', async () => { jest.spyOn(logService, 'getLatestFinancialLog').mockResolvedValue(financeLog(16000)); - legStub.equityChf = '16050'; // journalEquity query disambiguated by its 'account.type IN' where clause + legStub.equityChf = '16050'; const logSpy = jest.spyOn(service['logger'], 'info'); await service.run(); const parityLog = logSpy.mock.calls.find((c) => c[0].includes('equity parity')); expect(parityLog).toBeDefined(); - // journalEquity positive (16050), difference = 16050 − 16000 = 50, sign-consistent with totalBalanceChf - expect(parityLog[0]).toContain('journalEquity 16050'); - expect(parityLog[0]).toContain('difference 50'); + + // parse the logged numbers and assert the difference is ARITHMETICALLY journalEquity − totalBalanceChf — this + // catches a swapped subtraction (16000 − 16050 = −50) or a wrong operand, which a substring check would miss. + const msg: string = parityLog[0]; + const journalEquity = Number(/journalEquity (-?\d+(?:\.\d+)?)/.exec(msg)![1]); + const totalBalanceChf = Number(/totalBalanceChf (-?\d+(?:\.\d+)?)/.exec(msg)![1]); + const difference = Number(/difference (-?\d+(?:\.\d+)?)/.exec(msg)![1]); + expect(journalEquity).toBe(16050); + expect(totalBalanceChf).toBe(16000); + expect(difference).toBe(Util.round(journalEquity - totalBalanceChf, 2)); // = +50, NOT −50 (subtraction order) + expect(difference).toBe(50); + }); + + it('skips the parity log when there is no FinancialDataLog snapshot', async () => { + jest.spyOn(logService, 'getLatestFinancialLog').mockResolvedValue(null); + const logSpy = jest.spyOn(service['logger'], 'info'); + + await service.run(); + + expect(logSpy.mock.calls.find((c) => c[0].includes('equity parity'))).toBeUndefined(); + }); + + it('skips the parity log when the snapshot carries no totalBalanceChf', async () => { + // a snapshot whose balancesTotal lacks totalBalanceChf → checkEquityParity returns before the log (line 287) + const noTotal = Object.assign(new Log(), { + id: 2, + created: new Date('2026-06-11T00:00:00Z'), + message: JSON.stringify({ assets: {}, tradings: {}, balancesByFinancialType: {}, balancesTotal: {} }), + }); + jest.spyOn(logService, 'getLatestFinancialLog').mockResolvedValue(noTotal); + const logSpy = jest.spyOn(service['logger'], 'info'); + + await service.run(); + + expect(logSpy.mock.calls.find((c) => c[0].includes('equity parity'))).toBeUndefined(); + }); + }); + + describe('classification + run error branches', () => { + const now = new Date('2026-06-11T12:00:00Z'); + + // §7.1: an EXCHANGE_FEEDLESS custody (Kraken/Binance blockchain) is unverified from start → NO_FEED even with a + // present, fresh balance (the source line 173 branch). classifyCustody maps KRAKEN/BINANCE → EXCHANGE_ACTIVE, so to + // reach EXCHANGE_FEEDLESS we drive classifyFeed against an account whose custodyClass is EXCHANGE_FEEDLESS via a + // 0-threshold; simplest deterministic trigger: a Kraken-blockchain asset has EXCHANGE_ACTIVE (4h) — instead assert + // the documented feedless path by a balance whose age exceeds the threshold is already covered; here we cover the + // EXCHANGE custody branch (non-on-chain) explicitly. + 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.thresholdHours).toBe(4); // EXCHANGE_ACTIVE + expect(result.status).toBe(FeedStatus.FRESH); + }); + + 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) + }); + + it('catches a reconcile error in run() and never throws (failure-isolation)', async () => { + jest.spyOn(liquidityManagementBalanceService, 'getBalances').mockRejectedValue(new Error('feed down')); + const errSpy = jest.spyOn(service['logger'], 'error'); + + await expect(service.run()).resolves.toBeUndefined(); // swallowed, not rethrown + + expect(errSpy).toHaveBeenCalled(); }); }); }); 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 index dc12b2860c..37e1b3ed5a 100644 --- 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 @@ -698,4 +698,627 @@ describe('BankTxConsumer', () => { 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, brutto == netto + 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, brutto = netto + }), + ]); + 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.amountChf).toBe(-100); // brutto == netto (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 at tx.created (the null-side of the ?? in the scan callback, source line 94). + it('content-change scan uses tx.created for the mark window when bookingDate is null (line 94 null side)', 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) was invoked with tx.created on both bounds (bookingDate null) + const lastPreload = preloadSpy.mock.calls[preloadSpy.mock.calls.length - 1]; + expect(lastPreload[0]).toBe(created); + expect(lastPreload[1]).toBe(created); + }); + + // §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 index 573299d976..e735398f2f 100644 --- 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 @@ -149,6 +149,23 @@ describe('BuyCryptoConsumer', () => { 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([ @@ -231,4 +248,115 @@ describe('BuyCryptoConsumer', () => { 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 () => { + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + accounts.delete('Checkout/EUR'); // findByName('Checkout/EUR') → undefined → throw in checkoutAccount + mockBatch([buyCrypto({ id: 20, amountInChf: 1000, 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 (countBy>0) 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 (countBy>0) + mockBatch([ + buyCrypto({ + id: 21, + amountInChf: 1000, + totalFeeAmountChf: 10, + isComplete: true, + cryptoInput: { id: 555 } as any, // G-a: countBy(crypto_input, 555, seq0) > 0 (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; // countBy → 0 AND ledgerCutoverLogId → undefined + mockBatch([ + buyCrypto({ + id: 22, + amountInChf: 1000, + totalFeeAmountChf: 10, + isComplete: true, + cryptoInput: { id: 556 } as any, // G-a countBy → 0; 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 --- // + + // the content-change scan reverses+rebooks a Card-input seq0 and then books any newly-settled completion + it('runs the content-change scan: reverses+rebooks the Card seq0 then books the completion', async () => { + const rebookSpy = jest.spyOn(bookingService, 'reverseAndRebookIfChanged').mockResolvedValue(true); + const changed = buyCrypto({ + id: 30, + amountInChf: 1000, + totalFeeAmountChf: 10, + isComplete: true, + checkoutTx: { currency: 'EUR' } as any, // Card input → buildCardInputSeq0 resolves → reverseAndRebookIfChanged + }); + // 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(rebookSpy).toHaveBeenCalledTimes(1); // the Card seq0 reverse-and-rebook ran + expect(rebookSpy.mock.calls[0][0].sourceId).toBe('30'); + // 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 seq0 here → buildCardInputSeq0 undefined → no reversal, + // but the idempotent book() still appends the completion + it('content-change scan: a non-Card row triggers no seq0 reversal but still books its completion', async () => { + const rebookSpy = jest.spyOn(bookingService, 'reverseAndRebookIfChanged').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(rebookSpy).not.toHaveBeenCalled(); // non-Card → no seq0 reverse + expect(booked.some((b) => b.seq === 1 && b.sourceId === '31')).toBe(true); // completion still booked + }); }); 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 index a4dc7c2465..bdebd59af5 100644 --- 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 @@ -325,11 +325,22 @@ describe('BuyFiatConsumer', () => { 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 + // 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(); - expect(feeIncome.amountChf).toBeLessThan(0); // negative = INCOME credit + // 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); @@ -465,4 +476,686 @@ describe('BuyFiatConsumer', () => { 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(); + gateCount = 1; // 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. + it('appends NO fx residual when the bank leg still needsMark (unmarked EUR output)', async () => { + 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 → needsMark + bankTx: { bookingDate: SUN }, + } as any, + }), + ]); + await consumer.process(); + + const s3 = seq(3); + expect(s3.legs).toHaveLength(2); // TRANSIT + bank only, NO fx-revaluation plug while unmarked + expect(leg(s3, 'TRANSIT/payout/EUR').amountChf).toBe(9000); // owed_chf = 9000 − 0 + const bankLeg = s3.legs.find((l) => l.account.name === 'Bank/CHF'); // asset 999 → findByAssetId fallback + expect(bankLeg.amount).toBe(-10000); // native −outputAmount + expect(bankLeg.needsMark).toBe(true); // unmarked → carried for the mark-to-market job + expect(bankLeg.amountChf).toBeUndefined(); + expect(s3.legs.some((l) => /fx-revaluation/.test(l.account.name))).toBe(false); + }); + + // 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. + it('marks the bank leg as needsMark when the bank.asset has no id (outputMark null-id arm)', async () => { + 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(); + + const s3 = seq(3); + const bankLeg = s3.legs.find((l) => l.needsMark); + expect(bankLeg).toBeDefined(); + expect(bankLeg.amount).toBe(-8400); + expect(bankLeg.amountChf).toBeUndefined(); + expect(s3.legs.some((l) => /fx-revaluation/.test(l.account.name))).toBe(false); + }); + + // 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); + }); + + // owedReferenceRate ref==null + non-CHF output (line 359 → returns 0): a paymentLink EUR row with + // outputReferenceAmount null → owed_chf = outputAmount × 0 = 0. Asserted on seq2 transmit (both legs 0). + it('uses owedReferenceRate 0 for a non-CHF paymentLink row with a null reference', async () => { + seq0PaymentLinkChf = -100; // opening present (gate passes); spread plugs to fx, not asserted here + mockBatch([ + buyFiat({ + id: 19, + amountInChf: 100, + totalFeeAmountChf: 0, + outputReferenceAmount: null, // ref null → EUR → rate 0 + 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(); + + const s2 = seq(2); + expect(leg(s2, 'LIABILITY/paymentLink').amountChf).toBe(0); // outputAmount × 0 + expect(leg(s2, 'TRANSIT/payout/EUR').amountChf).toBe(-0); + expect(cents(s2.legs)).toBe(0); + }); + + // 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. countBy returns 0 for the crypto_input G-a query and >0 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 countBy runs + // distinguish the two gate queries by sourceType: G-a (crypto_input) closed, G-b (cutover) open + jest + .spyOn(ledgerTxRepo, 'countBy') + .mockImplementation(({ sourceType }: any) => Promise.resolve(sourceType === 'cutover' ? 1 : 0)); + 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 content-change scan (process lines 60-84): a settled regular row surfaced ONLY by the content-change scan + // (where.updated) → buildReclassificationSeq1 is reverse-and-rebooked, then the idempotent book() appends seq2/seq3. + it('reverse-and-rebooks the seq1 on a content-change scan and appends the later seqs', async () => { + const reverseSpy = jest.spyOn(bookingService, 'reverseAndRebookIfChanged').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(reverseSpy).toHaveBeenCalledTimes(1); + const reInput = reverseSpy.mock.calls[0][0]; + expect(reInput.seq).toBe(1); // the rebuilt seq1 reclassification + expect(leg(reInput, '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 + gateCount = 0; // 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 () => { + gateCount = 0; // 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 + }); + + // 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). + it('defaults null outputAmount/amountInChf/totalFeeAmountChf to 0 in seq2/seq3 (L316, L352)', async () => { + mockBatch([ + buyFiat({ + id: 29, + amountInChf: null, // → owedChf amountInChf ?? 0 (L352) + totalFeeAmountChf: null, // → owedChf totalFeeAmountChf ?? 0 (L352) + outputAmount: null, // → seq1 skipped + bankCrLeg outputAmount ?? 0 (L316) + 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); // (0) − (0) + expect(leg(s2, 'TRANSIT/payout/CHF').amountChf).toBe(-0); + const s3 = seq(3); + expect(leg(s3, 'TRANSIT/payout/CHF').amountChf).toBe(0); + const bankLeg = leg(s3, 'Bank/CHF'); + expect(bankLeg.amount).toBe(-0); // −(outputAmount ?? 0) = −0 + expect(bankLeg.amountChf).toBe(-0); // −(mark 1 × 0) = −0 + expect(bankLeg.needsMark).toBe(false); // CHF mark present → marked + }); + + // 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. + it('defaults a null outputAmount to 0 in the paymentLink owedChf (L351)', async () => { + seq0PaymentLinkChf = -100; // opening present (irrelevant: seq1 skipped by the outputAmount gate) + mockBatch([ + buyFiat({ + id: 30, + amountInChf: 100, + totalFeeAmountChf: 0, + outputReferenceAmount: 100, + outputAmount: null, // → owedChf (outputAmount ?? 0) × rate = 0 + 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 + const s2 = seq(2); + expect(leg(s2, 'LIABILITY/paymentLink').amountChf).toBe(0); // (outputAmount ?? 0) × rate = 0 + expect(leg(s2, 'TRANSIT/payout/CHF').amountChf).toBe(-0); + expect(cents(s2.legs)).toBe(0); + }); + + // 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 + }); }); 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 index 3e1545aaca..bb32174d83 100644 --- 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 @@ -301,4 +301,217 @@ describe('CryptoInputConsumer', () => { 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 + }); + + // 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 + }); + + // --- 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. With a buyFiat anchor the needsMark asset leg short-circuits appendFxPlug → NO fx plug leg. + // (Distinct from the id-5 test, which resolves a wallet WITH assetId 999 → takes the getMarkAt side.) + it('takes the undefined-mark branch when the wallet account has a null assetId (no fx plug)', async () => { + 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(); + + const seq0 = booked.find((b) => b.seq === 0); + const assetLeg = seq0.legs.find((l) => l.account.name === 'Bitcoin/BTC'); + expect(assetLeg.needsMark).toBe(true); + expect(assetLeg.amountChf).toBeUndefined(); + expect(assetLeg.priceChf).toBeNull(); // mark ?? null + // a needsMark leg short-circuits appendFxPlug (§5.1 Stufe 3) → received anchor only, NO plug leg + expect(seq0.legs.find((l) => l.account.name?.includes('fx-revaluation'))).toBeUndefined(); + expect(seq0.legs).toHaveLength(2); + }); + + // lines 141-144 (isPayment, assetChf == null): a PAYMENT crypto_input whose asset has NO mark → mark undefined → + // assetChf undefined → BOTH legs needsMark; the paymentLink leg amountChf undefined (the `: undefined` side). + it('books a 2-leg isPayment tx with both legs needsMark when the asset has no mark', async () => { + mockBatch([ + cryptoInput({ + id: 41, + amount: 1, + txType: PayInType.PAYMENT, + asset: { id: 999, uniqueName: 'Unknown/XYZ' }, // no mark in markMap + }), + ]); + // wallet carries assetId 999 → line-117 takes getMarkAt(999) which is undefined (not in markMap) + 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.needsMark).toBe(true); + expect(assetLeg.amountChf).toBeUndefined(); + expect(paymentLink.needsMark).toBe(true); + expect(paymentLink.amountChf).toBeUndefined(); // assetChf == null → `: undefined` + expect(paymentLink.amount).toBe(-0); // -(assetChf ?? 0) → -0 (the negation of the 0 fallback) + }); + + // lines 176-187 forward-fee: forwardFeeAmount (feeNative) null while forwardFeeAmountChf set → mark falls to null + // (the `feeChf != null && feeNative` guard is false) and the wallet native amount uses the `-(feeNative ?? feeChf)` + // fallback = −feeChf. + it('books the forward fee with a null priceChf and the feeChf native fallback when forwardFeeAmount is null', 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 → mark null, native fallback to feeChf + 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(-5); // -(feeNative ?? feeChf) = -5 (the ?? fallback) + expect(wallet.amountChf).toBe(-5); + expect(wallet.priceChf).toBeNull(); // feeNative falsy → mark null + expect(cents(seq1.legs)).toBe(0); + }); + + // 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 index 84f9b6c0a8..15bea6035c 100644 --- 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 @@ -409,4 +409,571 @@ describe('ExchangeTxConsumer', () => { 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(); + }); + + // --- 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 + it('flags the ccxt quote leg needsMark and skips the quote-spread plug when the quote mark is missing', async () => { + 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(); + + const quote = booked[0].legs.find((l) => l.account.name === 'Binance/XYZ'); + expect(quote.needsMark).toBe(true); // no mark for the quote asset + expect(quote.amountChf).toBeUndefined(); + // no mark-based quote-spread plug appended while a leg needsMark (only base + quote legs, no spread plug) + expect(booked[0].legs.filter((l) => l.account.name?.includes('spread-Binance'))).toHaveLength(0); + }); + + // §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); + }); + + // 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` (L103, both operands): a content-change ok row with + // externalCreated NULL → the reconcile mark-preload AND the recomputed spec use `created` as the booking date. + 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 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(); + }); + + // 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 `?? `). + it('treats a null cost as 0 on a Scrypt Trade (quote amount −0, priceChf null, amountChf = −base)', async () => { + mockBatch([ + exchangeTx({ + id: 36, + exchange: ExchangeName.SCRYPT, + type: ExchangeTxType.TRADE, + symbol: 'USDT/CHF', + side: 'buy', + order: 'O-36', + amount: 1000, + amountChf: 900, + cost: null, // tx.cost ?? 0 → 0 + feeAmountChf: 0, + }), + ]); + await consumer.process(); + + const quote = booked[0].legs.find((l) => l.account.name === 'Scrypt/CHF'); + expect(quote.amount).toBe(-0); // isBuy ? -(cost ?? 0) = -0 + expect(quote.priceChf).toBeNull(); // quoteAmount 0 → no price + expect(quote.amountChf).toBe(-900); // plug = −base + expect(cents(booked[0].legs)).toBe(0); + }); + + // 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__/liquidity-mgmt.consumer.spec.ts b/src/subdomains/core/accounting/services/consumers/__tests__/liquidity-mgmt.consumer.spec.ts index 7ae2d4969b..ab42208197 100644 --- 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 @@ -161,6 +161,45 @@ describe('LiquidityMgmtConsumer', () => { 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 Zweig 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([ 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 index 88a6c9f800..f9a70b88b7 100644 --- 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 @@ -8,7 +8,7 @@ import { LiquidityOrderContext, LiquidityOrderType, } from 'src/subdomains/supporting/dex/entities/liquidity-order.entity'; -import { In, Not, Repository } from 'typeorm'; +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'; @@ -231,8 +231,10 @@ describe('LiquidityOrderDexConsumer', () => { expect(findSpy).toHaveBeenCalledTimes(1); const where = findSpy.mock.calls[0][0].where as Record; - // txId IS NOT NULL (Not(IsNull())) — excludes Reservation rows without an on-chain settlement - expect(where.txId).toEqual(Not(expect.anything())); + // 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) @@ -254,4 +256,108 @@ describe('LiquidityOrderDexConsumer', () => { 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. + it('flags the gas-fee legs needsMark when the fee asset has no mark (lines 155/176-177/217)', 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) deliberately absent → no fee mark + ]), + ), + ); + mockBatch([ + liquidityOrder({ id: 21, feeAsset: { id: ETH_ASSET_ID, uniqueName: 'Ethereum/ETH' }, feeAmount: 0.01 }), + ]); + await consumer.process(); + + const tx = booked[0]; + const networkFee = leg(tx, 'EXPENSE/network-fee'); + const ethLeg = leg(tx, 'Ethereum/ETH'); + expect(networkFee.needsMark).toBe(true); // feeChf undefined → needsMark + expect(networkFee.amountChf).toBeUndefined(); + expect(networkFee.amount).toBe(0); // networkFeeLeg `feeChf ?? 0` → 0 + expect(ethLeg.needsMark).toBe(true); + expect(ethLeg.amountChf).toBeUndefined(); + expect(leg(tx, 'EXPENSE/spread-DfxDex')).toBeUndefined(); // a needsMark leg → no spread plug + expect(leg(tx, 'INCOME/spread-DfxDex')).toBeUndefined(); + }); + + // §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. + it('folds a no-mark swap-asset fee and marks the combined leg needsMark (lines 163/223)', async () => { + 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(); + + const tx = booked[0]; + const swapLegs = tx.legs.filter((l) => l.account.name === 'Ethereum/USDC'); + expect(swapLegs).toHaveLength(1); + expect(swapLegs[0].amount).toBe(-1055); // −1050 − 5 folded native + expect(swapLegs[0].needsMark).toBe(true); // no swap mark → combined leg needsMark + expect(leg(tx, 'EXPENSE/network-fee').needsMark).toBe(true); + }); + + // §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(); + }); }); 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 index fdceb52b39..301f8f6a05 100644 --- 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 @@ -359,6 +359,35 @@ describe('PayoutOrderConsumer', () => { expect(wallet.amountChf).toBeUndefined(); }); + // §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); @@ -390,4 +419,407 @@ describe('PayoutOrderConsumer', () => { await consumer.process(); expect(booked).toHaveLength(0); }); + + // --- 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 Stufe 3, no silent plug). + it('books NO fx-revaluation plug while the wallet leg needsMark (no silent plug, §5.1 Stufe 3)', async () => { + 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(); + + // owed-Dr 50000 (completion) + wallet needsMark (amountChf undefined) → cents don't net, but NO plug is appended + expect(leg(booked[0], 'INCOME/fx-revaluation')).toBeUndefined(); + expect(leg(booked[0], 'EXPENSE/fx-revaluation')).toBeUndefined(); + expect(leg(booked[0], 'Unknown/XYZ').needsMark).toBe(true); + }); + + // §4.5 liabilityCounter (lines 220/223): a liability row where BOTH the completion CHF (non-integer correlationId → + // no product lookup) AND the settlement CHF (no mark for the payout asset) are undefined → liabilityChf undefined → + // the owed leg amount falls to 0, amountChf undefined, needsMark true. mainChf undefined → wallet also needsMark → + // withFxPlug short-circuits, no plug. + it('books an owed leg with amount 0 / needsMark when both completion and settlement CHF are undefined', async () => { + const findSpy = jest.spyOn(buyCryptoRepo, 'findOneBy'); + jest + .spyOn(accountService, 'findByAssetId') + .mockResolvedValue(account('Unknown/XYZ', AccountType.ASSET, 'XYZ', 999)); // no mark → settlementChf undefined + 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' }, // no mark + 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.amount).toBe(0); // liabilityChf undefined → amount falls to 0 (line 220) + expect(owed.amountChf).toBeUndefined(); // line 222: amountChf = liabilityChf (undefined) + expect(owed.needsMark).toBe(true); // line 223: needsMark = liabilityChf == null + const wallet = leg(booked[0], 'Unknown/XYZ'); + expect(wallet.needsMark).toBe(true); // mainChf undefined → wallet leg needsMark too + expect(wallet.amountChf).toBeUndefined(); + expect(leg(booked[0], 'INCOME/fx-revaluation')).toBeUndefined(); // a leg needsMark → no silent plug + expect(leg(booked[0], 'EXPENSE/fx-revaluation')).toBeUndefined(); + }); + + // §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. + it('flags a distinct fee-asset leg needsMark when that fee asset has no mark (no plug booked)', async () => { + 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(); + + const tx = booked[0]; + const feeLeg = leg(tx, 'NoMark/NOM'); + expect(feeLeg.amount).toBe(-0.003); // native fee against the fee asset + expect(feeLeg.amountChf).toBeUndefined(); // no mark → chf undefined (line 304) + expect(feeLeg.needsMark).toBe(true); // line 305: needsMark = chf == null + expect(feeLeg.priceChf).toBeNull(); // line 303: priceChf = mark ?? null + expect(leg(tx, 'EXPENSE/network-fee').amountChf).toBe(5); // CHF fee leg still booked + expect(leg(tx, 'INCOME/fx-revaluation')).toBeUndefined(); // a leg needsMark → no plug + expect(leg(tx, 'EXPENSE/fx-revaluation')).toBeUndefined(); + }); + + // §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. + it('takes chf=0 for a folded payout-asset fee when the payout asset has no mark (needsMark)', async () => { + 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(); + + const tx = booked[0]; + const walletLegs = tx.legs.filter((l) => l.account.name === 'Unknown/XYZ'); + expect(walletLegs).toHaveLength(1); // fee folded into the single wallet leg + expect(walletLegs[0].amount).toBe(-1.0002); // amount + folded fee native + expect(walletLegs[0].amountChf).toBeUndefined(); // no mark → mainChf undefined → wallet chf undefined + expect(walletLegs[0].needsMark).toBe(true); // fee.needsMark (no mark) → wallet needsMark + expect(leg(tx, 'INCOME/fx-revaluation')).toBeUndefined(); // a leg needsMark → no plug + expect(leg(tx, 'EXPENSE/fx-revaluation')).toBeUndefined(); + }); + + // §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 index 31ed7928b5..a44fa393b2 100644 --- 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 @@ -195,6 +195,30 @@ describe('TradingOrderConsumer', () => { 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) it('flags the ASSET leg needsMark and skips the plug when a mark is missing', async () => { jest.spyOn(markService, 'preload').mockResolvedValue( @@ -209,6 +233,19 @@ describe('TradingOrderConsumer', () => { expect(leg(tx, 'INCOME/spread-arbitrage')).toBeUndefined(); }); + // §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 })]); @@ -238,4 +275,23 @@ describe('TradingOrderConsumer', () => { 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 + }); }); diff --git a/src/subdomains/core/accounting/services/ledger-cutover.service.ts b/src/subdomains/core/accounting/services/ledger-cutover.service.ts index 0c22186542..b5d5c3420a 100644 --- a/src/subdomains/core/accounting/services/ledger-cutover.service.ts +++ b/src/subdomains/core/accounting/services/ledger-cutover.service.ts @@ -473,7 +473,9 @@ export class LedgerCutoverService { `${snapshot.id}:unattributed`, `Opening unattributed from open bank_tx credits as of FinancialDataLog #${snapshot.id}`, liability, - Util.round(amountChf, 2), + // a feedless/unmatched credit leaves the aggregate unvaluable → carry amountChf=undefined (needsMark) so the + // mark-to-market job values the whole bucket later (§5.1 Stufe 3); never a partial/-0 phantom CHF on the leg. + needsMark ? undefined : Util.round(amountChf, 2), equity, needsMark, ); From d03d69132ea4fc1a50790e1a1ab682c40b661669 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 13 Jun 2026 17:15:51 +0200 Subject: [PATCH 06/25] test(accounting): use Type instead of Function in relations spec Satisfies @typescript-eslint no-unsafe-function-type (CI lint). --- .../accounting/entities/__tests__/ledger-relations.spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/subdomains/core/accounting/entities/__tests__/ledger-relations.spec.ts b/src/subdomains/core/accounting/entities/__tests__/ledger-relations.spec.ts index c02bcbb7ca..a39972dcc0 100644 --- a/src/subdomains/core/accounting/entities/__tests__/ledger-relations.spec.ts +++ b/src/subdomains/core/accounting/entities/__tests__/ledger-relations.spec.ts @@ -1,3 +1,4 @@ +import { Type } from '@nestjs/common'; import { getMetadataArgsStorage } from 'typeorm'; import { RelationMetadataArgs } from 'typeorm/metadata-args/RelationMetadataArgs'; import { RelationIdMetadataArgs } from 'typeorm/metadata-args/RelationIdMetadataArgs'; @@ -14,13 +15,13 @@ import { LedgerTx } from '../ledger-tx.entity'; // pointed at the wrong relation would make these red. const storage = getMetadataArgsStorage(); -function relation(target: Function, propertyName: string): RelationMetadataArgs { +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: Function, propertyName: string): RelationIdMetadataArgs { +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; From e43c5c674c01f75e244d7b06416d801849ac34ba Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:10:52 +0200 Subject: [PATCH 07/25] fix(accounting): widen cent columns to bigint and make lazy account creation race-safe - ledger_leg.amountChfCents / ledger_tx.amountChfSum become bigint: the int4 ceiling (~21.4m CHF per leg) would permanently stall a source chain on the first oversized transfer. A fail-loud transformer converts the PG string back to number and throws beyond Number.MAX_SAFE_INTEGER instead of rounding. - findOrCreate now treats a concurrent UNIQUE(name) violation (23505) as the documented no-op by re-fetching the winning row; any other error propagates. --- migration/1781235331368-AddLedgerTables.js | 9 ++-- .../__tests__/ledger-relations.spec.ts | 45 ++++++++++++++++++- .../accounting/entities/ledger-leg.entity.ts | 25 +++++++++-- .../accounting/entities/ledger-tx.entity.ts | 33 ++++++++++++-- .../evidence-week.integration.spec.ts | 2 +- .../__tests__/ledger-account.service.spec.ts | 45 +++++++++++++++++++ .../__tests__/ledger-booking.service.spec.ts | 2 +- .../services/ledger-account.service.ts | 20 ++++++++- 8 files changed, 167 insertions(+), 14 deletions(-) diff --git a/migration/1781235331368-AddLedgerTables.js b/migration/1781235331368-AddLedgerTables.js index da512e9b1d..0f4bb13ef0 100644 --- a/migration/1781235331368-AddLedgerTables.js +++ b/migration/1781235331368-AddLedgerTables.js @@ -6,7 +6,10 @@ /** * 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 `integer` (never bigint → JS string), the single-row balance + * 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 @@ -26,13 +29,13 @@ module.exports = class AddLedgerTables1781235331368 { 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" integer 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"))`, + `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" integer NOT NULL DEFAULT 0, "needsMark" boolean NOT NULL DEFAULT false, "txId" integer NOT NULL, "accountId" integer NOT NULL, CONSTRAINT "PK_6566e1943c692f0caad604015d0" PRIMARY KEY ("id"))`, + `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") `); diff --git a/src/subdomains/core/accounting/entities/__tests__/ledger-relations.spec.ts b/src/subdomains/core/accounting/entities/__tests__/ledger-relations.spec.ts index a39972dcc0..388c8d58d0 100644 --- a/src/subdomains/core/accounting/entities/__tests__/ledger-relations.spec.ts +++ b/src/subdomains/core/accounting/entities/__tests__/ledger-relations.spec.ts @@ -6,7 +6,7 @@ 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 { LedgerAccount } from '../ledger-account.entity'; -import { LedgerLeg } from '../ledger-leg.entity'; +import { chfCentsTransformer, 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 @@ -96,3 +96,46 @@ describe('ledger entity relations', () => { }); }); }); + +// 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-leg.entity.ts b/src/subdomains/core/accounting/entities/ledger-leg.entity.ts index d0c7571cb3..0f9bb485b7 100644 --- a/src/subdomains/core/accounting/entities/ledger-leg.entity.ts +++ b/src/subdomains/core/accounting/entities/ledger-leg.entity.ts @@ -1,8 +1,27 @@ import { IEntity } from 'src/shared/models/entity'; -import { Column, Entity, Index, JoinColumn, ManyToOne, RelationId } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, ManyToOne, RelationId, ValueTransformer } from 'typeorm'; import { LedgerAccount } from './ledger-account.entity'; import { LedgerTx } from './ledger-tx.entity'; +// 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. Colocated per entity (not a shared import) on purpose: +// ledger-leg and ledger-tx import each other, so a cross-file const would risk an undefined transformer at +// decorator-eval time depending on module load order (matches the repo's inline-transformer precedent). +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; + }, +}; + @Entity() export class LedgerLeg extends IEntity { @Index() @@ -31,8 +50,8 @@ export class LedgerLeg extends IEntity { @Column({ type: 'float', nullable: true }) amountChf?: number; // Util.round(amount × priceChf, 2) (null if no mark) - // integer cents for checksum (PostgreSQL integer, never bigint → JS string, see §2-header) - @Column({ type: 'int', default: 0 }) + // 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() diff --git a/src/subdomains/core/accounting/entities/ledger-tx.entity.ts b/src/subdomains/core/accounting/entities/ledger-tx.entity.ts index f556b389fc..79b89a4881 100644 --- a/src/subdomains/core/accounting/entities/ledger-tx.entity.ts +++ b/src/subdomains/core/accounting/entities/ledger-tx.entity.ts @@ -1,7 +1,34 @@ import { IEntity } from 'src/shared/models/entity'; -import { Check, Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, RelationId, Unique } from 'typeorm'; +import { + Check, + Column, + Entity, + Index, + JoinColumn, + ManyToOne, + OneToMany, + RelationId, + Unique, + ValueTransformer, +} from 'typeorm'; import { LedgerLeg } from './ledger-leg.entity'; +// PostgreSQL bigint → JS number on read, fail LOUD outside the safe-integer range instead of silently rounding (see +// the identical, deliberately colocated transformer in ledger-leg.entity.ts for the full rationale — the two entities +// import each other, so a shared cross-file const would risk an undefined transformer at decorator-eval time). +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 column → 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; + }, +}; + // 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) @@ -35,8 +62,8 @@ export class LedgerTx extends IEntity { @RelationId((tx: LedgerTx) => tx.reversalOf) reversalOfId?: number; - // integer cents (PostgreSQL integer, never bigint → JS string, see §2-header); always 0 per tx - @Column({ type: 'int', default: 0 }) + // 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) 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 index df8ec305d1..b521a25fc8 100644 --- 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 @@ -493,7 +493,7 @@ describe('Ledger evidence-week integration (§10.2)', () => { expect(ledger.txs.length).toBeGreaterThan(0); expect(ledger.everyTxBalances()).toBe(true); for (const tx of ledger.txs) { - expect(typeof tx.amountChfSum).toBe('number'); // integer type, never a bigint string (Blocker R1-4) + expect(typeof tx.amountChfSum).toBe('number'); // JS number, never a raw bigint string (bigint column → chfCentsTransformer, Blocker R1-4) expect(tx.amountChfSum).toBe(0); } 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 index e20a03c5ba..2ba15c4a2c 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-account.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-account.service.spec.ts @@ -42,6 +42,51 @@ describe('LedgerAccountService', () => { 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); 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 index 33cfb0252a..33de45dce7 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-booking.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-booking.service.spec.ts @@ -115,7 +115,7 @@ describe('LedgerBookingService', () => { }); expect(tx.amountChfSum).toBe(0); - expect(typeof tx.amountChfSum).toBe('number'); // integer type guarantee (Blocker R1-4) + 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]); diff --git a/src/subdomains/core/accounting/services/ledger-account.service.ts b/src/subdomains/core/accounting/services/ledger-account.service.ts index a8ac8ece26..ea17eb6c57 100644 --- a/src/subdomains/core/accounting/services/ledger-account.service.ts +++ b/src/subdomains/core/accounting/services/ledger-account.service.ts @@ -16,7 +16,10 @@ export class LedgerAccountService { return (await this.ledgerAccountRepository.findOneBy({ asset: { id: assetId } })) ?? undefined; } - // bootstrap core mechanism (§3): lookup-by-name, create-if-missing; re-run no-op on UNIQUE(name) + // 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, @@ -35,6 +38,19 @@ export class LedgerAccountService { asset: assetId != null ? ({ id: assetId } as Asset) : undefined, }); - return this.ledgerAccountRepository.save(account); + 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'; } } From e400b73777a22689392cbefcbd32de7fdfe0728d Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:10:52 +0200 Subject: [PATCH 08/25] fix(accounting): stop double-booking card inputs across the cutover boundary Card buy-crypto rows are excluded from the per-row cutover opening (their seq0 is owned by the forward consumer), and the consumer card gate now blocks seq0/seq1 for rows whose immutable outputDate shows they had settled at the cutover snapshot - their value is already inside the aggregate opening. The cutover snapshot date is pinned as a setting so the gate has a stable anchor. --- .../buy-crypto-cutover.integration.spec.ts | 124 ++++++++++++++++++ .../__tests__/ledger-cutover.service.spec.ts | 42 ++++++ .../__tests__/buy-crypto.consumer.spec.ts | 61 +++++++++ .../services/consumers/buy-crypto.consumer.ts | 30 ++++- .../services/ledger-cutover.service.ts | 19 ++- 5 files changed, 273 insertions(+), 3 deletions(-) create mode 100644 src/subdomains/core/accounting/services/__tests__/integration/buy-crypto-cutover.integration.spec.ts 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..497cc9355b --- /dev/null +++ b/src/subdomains/core/accounting/services/__tests__/integration/buy-crypto-cutover.integration.spec.ts @@ -0,0 +1,124 @@ +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: the per-row cutover opening is EXCLUDED for Card, so the forward consumer's seq0 IS + * the card opening — after completion seq0 + seq1 close LIABILITY/buyCrypto-received to 0 (pre-fix: −amountInChf); + * (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). + * 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; + + 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 + ...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] : [])); + return new BuyCryptoConsumer( + cutoverSettingService(lastProcessedId), + ledger.bookingService, + ledger.accountService, + repo, + ledger.ledgerTxRepository(), + ); + } + + it('Scenario A: an open Card row completing after the cutover books seq0+seq1 → received closes to 0', async () => { + // the cutover did NOT open a per-row received for this Card row (Card excluded); it completes post-cutover, so the + // content-change scan books the fresh seq0 (the card opening) + seq1 (fee/reclass) → received nets to 0. + const row = buyCrypto({ + id: 70, + amountInChf: 1000, + totalFeeAmountChf: 10, + isComplete: true, + outputDate: POST_CUTOVER, // completed AFTER the snapshot → NOT covered by the aggregate opening + updated: POST_CUTOVER, + }); + + await consumer(row, 100).process(); + + // seq0 (card opening) + seq1 (completion) both booked + expect(ledger.txs.some((t) => t.sourceType === 'buy_crypto' && t.sourceId === '70' && t.seq === 0)).toBe(true); + expect(ledger.txs.some((t) => t.sourceType === 'buy_crypto' && t.sourceId === '70' && t.seq === 1)).toBe(true); + // received: opened −1000 (seq0), debited +10 fee + +990 reclass (seq1) → closes to 0 (NOT −1000, the pre-fix bug) + expect(ledger.chfBalance('LIABILITY/buyCrypto-received')).toBe(0); + // owed = −(amountInChf − fee); the Card custody is debited exactly once by amountInChf + expect(ledger.chfBalance('LIABILITY/buyCrypto-owed')).toBe(-990); + expect(ledger.chfBalance('Checkout/EUR')).toBe(1000); + 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); + }); +}); 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 index bec0c80cef..6e445cd304 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts @@ -470,6 +470,22 @@ describe('LedgerCutoverService', () => { 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 @@ -741,6 +757,32 @@ describe('LedgerCutoverService', () => { expect(booked.some((b) => b.sourceId === '1557344:buy_crypto:61')).toBe(false); // null amountInChf → skipped }); + // MAJOR (double-book guard): Card inputs (checkoutTx != null) MUST be excluded from the per-row buyCrypto-received + // opening — card received is booked by the forward consumer's seq0 (buy-crypto.consumer bookCardInput). A per-row + // opening here would double-book received across the cutover boundary (the content-change scan re-books seq0 after + // completion → received ends at −amountInChf + a phantom EQUITY opening). Asserts the query passes checkoutTx IS + // NULL (else the mock returns the Card row) AND that the Card row gets no opening. + it('excludes Card inputs (checkoutTx) from the per-row buyCrypto-received opening', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest.spyOn(buyCryptoRepo, 'find').mockImplementation(({ where }: any) => { + // received query: outputAmount IS NULL. The service now also passes checkoutTx IS NULL → simulate the DB filter: + // when the where carries checkoutTx, the Card row (checkoutTx set) is filtered out, only the non-Card remains. + if (where?.outputAmount) { + const rows = [ + 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(where.checkoutTx ? rows.filter((r) => r.checkoutTx == null) : rows); + } + return Promise.resolve([]); + }); + + await service.run(); + + 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(false); // Card excluded (forward seq0 books it) + }); + 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([ 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 index e735398f2f..7d573a2380 100644 --- 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 @@ -359,4 +359,65 @@ describe('BuyCryptoConsumer', () => { expect(rebookSpy).not.toHaveBeenCalled(); // non-Card → no seq0 reverse expect(booked.some((b) => b.seq === 1 && b.sourceId === '31')).toBe(true); // completion still booked }); + + // --- §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 + }); + + // a Card row OPEN at the cutover that completes AFTER it (outputDate > snapshot) IS booked (seq0 + seq1) — the + // forward seq0 is its opening (the per-row cutover opening is excluded for Card). received closes to 0. (Scenario A) + it('books seq0 + seq1 for a Card row completing after the cutover (Scenario A, not settled-before-cutover)', 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, + totalFeeAmountChf: 10, + isComplete: true, + outputDate: new Date('2026-06-20T00:00:00Z'), // completed AFTER the snapshot → NOT covered by the opening + 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).toBeDefined(); // forward seq0 = the card opening + expect(s1).toBeDefined(); // completion books (outputDate > snapshot → not settled-before-cutover → gate open) + 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 + }); }); diff --git a/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts b/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts index 7fbd276411..2a7310e00a 100644 --- a/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts @@ -16,6 +16,7 @@ const SOURCE_TYPE = 'buy_crypto'; 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'; /** @@ -123,6 +124,10 @@ export class BuyCryptoConsumer { 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; + // §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 OPEN-at-cutover rows (whose per-row + // cutover opening is excluded, so the forward seq0 IS their opening) and post-cutover Card rows get a forward seq0. + if (await this.settledBeforeCutover(bc)) return undefined; const checkout = await this.checkoutAccount(bc.checkoutTx.currency); const received = await this.liability('buyCrypto-received'); @@ -176,7 +181,11 @@ export class BuyCryptoConsumer { // 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 { - if (bc.checkoutTx) return true; // Card input opened received via this consumer's own seq0 + // Card input: received was opened by this consumer's own seq0 — EXCEPT a row already settled at the cutover, whose + // seq0 is (correctly) skipped as covered by the aggregate opening → its completion must stay blocked too. This + // mirrors the non-Card G-a/G-b path, which finds neither a crypto_input seq0 (G-a) nor a per-row cutover opening + // (G-b) for a pre-cutover-settled row → gate closed. Without it, seq1 would Dr received with no −opening → phantom. + if (bc.checkoutTx) return !(await this.settledBeforeCutover(bc)); const cryptoInputId = bc.cryptoInput?.id; if (cryptoInputId != null) { @@ -204,6 +213,25 @@ export class BuyCryptoConsumer { return cutoverLogId != null ? `${cutoverLogId}:buy_crypto:${buyCryptoId}` : undefined; } + // §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 { + if (!bc.checkoutTx || !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 { diff --git a/src/subdomains/core/accounting/services/ledger-cutover.service.ts b/src/subdomains/core/accounting/services/ledger-cutover.service.ts index b5d5c3420a..143be52a0a 100644 --- a/src/subdomains/core/accounting/services/ledger-cutover.service.ts +++ b/src/subdomains/core/accounting/services/ledger-cutover.service.ts @@ -40,6 +40,11 @@ const CUTOVER_LOG_ID_KEY = 'ledgerCutoverLogId'; // 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'; @@ -120,6 +125,11 @@ export class LedgerCutoverService { // (4) initialise consumer watermarks atomically — only rows settled at/before the snapshot (Blocker R3-1) await this.initWatermarks(snapshotDate); + // 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}`); @@ -308,10 +318,15 @@ export class LedgerCutoverService { } } - // buyCrypto-received: open rows with outputAmount NULL → CHF = amountInChf (Minor R2-7); per-row seq0-marker (R4-2) + // buyCrypto-received: open rows with outputAmount NULL → CHF = amountInChf (Minor R2-7); per-row seq0-marker (R4-2). + // Card inputs (checkoutTx != null) are EXCLUDED: card received is booked by the forward consumer's seq0 + // (buy-crypto.consumer bookCardInput), so a per-row opening would double-book received across the cutover boundary. + // (The open Card row sits below the id-watermark → the forward id-scan skips it, but after completion the + // content-change scan re-books its seq0 −amountInChf on top of this opening → received would end at −amountInChf + // instead of 0, plus a phantom EQUITY opening. The forward consumer books the card seq0 fresh once it completes.) 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) }, + where: { isComplete: false, outputAmount: IsNull(), checkoutTx: IsNull(), created: Between(lookback, date) }, }); const liability = await this.liability('buyCrypto-received'); From b899605c4e49722f139db86cd0f4857012e11cb6 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:10:52 +0200 Subject: [PATCH 09/25] fix(accounting): repair transit-age query, arm equity-parity alarm, fix reconciliation units - checkTransitAge joined only leg.account but selected MIN(leg.bookingDate), a column that lives on ledger_tx - every reconciliation run would crash on real Postgres. Now joins leg.tx and selects MIN(tx.bookingDate); the spec asserts the exact join/select so a regression fails. - checkEquityParity was log-only. It now alarms (new LEDGER_EQUITY_PARITY mail context) above a runtime threshold, compares against the median of the last 5 valid FinancialDataLog snapshots (immune to transient snapshot skew) and folds the open referral-credit liability in explicitly, logging every component so the difference stays attributable. - Per-asset reconciliation compared a NATIVE quantity diff against a CHF-denominated tolerance; the diff is now marked to CHF first, and assets without a mark are reported as unverified instead of silently passing. - getEquityComparison required no lower bound and re-read feeds per period; from is now mandatory (92-day cap) and the per-day aggregates collapse into one grouped query. --- .../core/accounting/accounting.module.ts | 2 + .../db-write-isolation.integration.spec.ts | 11 +- .../staleness-cutover.integration.spec.ts | 11 +- .../__tests__/ledger-query.service.spec.ts | 280 ++++++++++-------- .../ledger-reconciliation.service.spec.ts | 214 +++++++++++-- .../services/ledger-query.service.ts | 198 ++++++++----- .../services/ledger-reconciliation.service.ts | 112 +++++-- src/subdomains/supporting/log/log.service.ts | 11 + .../supporting/notification/enums/index.ts | 2 + 9 files changed, 592 insertions(+), 249 deletions(-) diff --git a/src/subdomains/core/accounting/accounting.module.ts b/src/subdomains/core/accounting/accounting.module.ts index c936dd496b..02292520b3 100644 --- a/src/subdomains/core/accounting/accounting.module.ts +++ b/src/subdomains/core/accounting/accounting.module.ts @@ -6,6 +6,7 @@ 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'; @@ -69,6 +70,7 @@ import { LedgerReconciliationService } from './services/ledger-reconciliation.se 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: [ 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 index 0d673807f1..2357555f27 100644 --- 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 @@ -6,6 +6,7 @@ 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'; @@ -54,6 +55,7 @@ const SUN = new Date('2026-06-07T00:00:00Z'); 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 }]], @@ -68,6 +70,8 @@ describe('Ledger DB-write isolation after a consumer/alarm run (§10.2)', () => 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 @@ -239,6 +243,7 @@ describe('Ledger DB-write isolation after a consumer/alarm run (§10.2)', () => 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(); @@ -273,6 +278,8 @@ describe('Ledger DB-write isolation after a consumer/alarm run (§10.2)', () => liqBalance, accountRepo, legRepo, + markService, + refRewardService, ); const before = notification.length; @@ -303,7 +310,7 @@ describe('Ledger DB-write isolation after a consumer/alarm run (§10.2)', () => const liqBalance = createMock(); jest.spyOn(liqBalance, 'getBalances').mockResolvedValue([]); // no accounts → no diff, no unverified → no alarm const logService = createMock(); - jest.spyOn(logService, 'getLatestFinancialLog').mockResolvedValue(undefined); + jest.spyOn(logService, 'getLatestValidFinancialLogs').mockResolvedValue([]); // no snapshot → equity parity skipped const accountRepo = createMock(); jest.spyOn(accountRepo, 'find').mockResolvedValue([]); const legRepo = createMock(); @@ -325,6 +332,8 @@ describe('Ledger DB-write isolation after a consumer/alarm run (§10.2)', () => liqBalance, accountRepo, legRepo, + markService, + refRewardService, ); await service.run(); 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 index 8048a73de0..8bd384e3a7 100644 --- 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 @@ -11,6 +11,7 @@ import { BuyCrypto } from 'src/subdomains/core/buy-crypto/process/entities/buy-c 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'; @@ -103,7 +104,13 @@ describe('Ledger staleness + cutover integration (§10.2)', () => { }); jest.spyOn(ledgerLegRepository, 'createQueryBuilder').mockImplementation(() => legQb()); jest.spyOn(settingService, 'get').mockResolvedValue('0'); - jest.spyOn(logService, 'getLatestFinancialLog').mockResolvedValue(undefined); + 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: [ @@ -116,6 +123,8 @@ describe('Ledger staleness + cutover integration (§10.2)', () => { { provide: LiquidityManagementBalanceService, useValue: liquidityManagementBalanceService }, { provide: LedgerAccountRepository, useValue: ledgerAccountRepository }, { provide: LedgerLegRepository, useValue: ledgerLegRepository }, + { provide: LedgerMarkService, useValue: markService }, + { provide: RefRewardService, useValue: refRewardService }, ], }).compile(); 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 index d60f69892f..5bdd327fb2 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-query.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-query.service.spec.ts @@ -1,3 +1,4 @@ +import { BadRequestException } from '@nestjs/common'; import { createMock } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { Asset } from 'src/shared/models/asset/asset.entity'; @@ -54,6 +55,19 @@ describe('LedgerQueryService', () => { 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, @@ -122,6 +136,33 @@ describe('LedgerQueryService', () => { 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 = {}; @@ -425,27 +466,20 @@ describe('LedgerQueryService', () => { }); describe('getEquityComparison', () => { - 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 }, - }), - }); - } - - it('computes journalEquity, difference and the four-bucket decomposition (other = residual)', async () => { + 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)]); - jest.spyOn(service as any, 'journalEquityAt').mockResolvedValue(19000); - jest.spyOn(service as any, 'transitPhantom').mockResolvedValue(-500); - jest.spyOn(service as any, 'staleFeed').mockResolvedValue(-200); - jest.spyOn(service as any, 'spreadFees').mockResolvedValue(-100); + // 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(undefined, true); + const res = await service.getEquityComparison(new Date('2026-06-01'), true); const period = res.periods[0]; expect(period.journalEquity).toBe(19000); @@ -461,12 +495,98 @@ describe('LedgerQueryService', () => { }); it('skips logs without a totalBalanceChf', async () => { - const broken = Object.assign(new Log(), { id: 2, created: new Date(), message: '{ not json' }); + 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); + }); - const res = await service.getEquityComparison(); + // 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 }); }); @@ -620,121 +740,35 @@ describe('LedgerQueryService', () => { expect(res.accounts[0].status).toBe('ok'); // diff 0 ≤ tolerance }); - it('getEquityComparison runs the real journalEquityAt + decomposition query-builders (residual = other)', async () => { - const log = Object.assign(new Log(), { - id: 1, - created: new Date('2026-06-10T00:00:00.000Z'), - message: JSON.stringify({ - assets: {}, - tradings: {}, - balancesByFinancialType: {}, - balancesTotal: { totalBalanceChf: 20000 }, - }), - }); - jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([log]); - // an unverified ASSET account drives unverifiedAccountIds → the staleFeed query actually runs (not skipped) - 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').mockImplementation(() => - richLegQb((_selects, wheres) => { - // staleFeed: mark_to_market sourceType + accountId IN (unverified) → −200 (check FIRST, no account.type) - if (wheres.includes('tx.sourceType = :sourceType')) return { chf: '-200' }; - // spreadFees: EXPENSE type + account.name NOT IN (...) → −100 (has account.type = :type too → check before it) - if (wheres.includes('account.name NOT IN')) return { chf: '-100' }; - // journalEquityAt: balance-account types IN (...) → 19000 - if (wheres.includes('account.type IN')) return { chf: '19000' }; - // transitPhantom: account.type = :type (TRANSIT) → −500 - if (wheres.includes('account.type = :type')) return { chf: '-500' }; - return { chf: '0' }; - }), - ); - - const res = await service.getEquityComparison(undefined, true); - - const period = res.periods[0]; - expect(period.journalEquity).toBe(19000); - expect(period.difference).toBe(-1000); // 19000 − 20000 - expect(period.decomposition.transitPhantom).toBe(-500); - expect(period.decomposition.staleFeed).toBe(-200); - expect(period.decomposition.spreadFees).toBe(-100); - expect(period.decomposition.other).toBe(-200); // −1000 − (−800) - }); - - // null-COALESCE fallbacks across the equity decomposition: journalEquityAt (L470), transitPhantom (L493), - // staleFeed (L510) and spreadFees (L527) each do `+(raw?.chf ?? 0)` — drive EVERY getRawOne to null so all four - // `?? 0` null sides fire (each helper → 0). Additionally unverifiedAccountIds (L540) sees an ASSET account whose - // assetId is null → `if (account.assetId == null) return false` (the false guard) plus a genuinely-unverified - // (NO_FEED) account, so the list is non-empty and the staleFeed SUM query actually executes (not short-circuited). - it('null getRawOne drives every equity-decomposition ?? 0 to 0 and skips a null-assetId account in the unverified scan (L470/L493/L510/L527/L540)', async () => { - const log = Object.assign(new Log(), { - id: 1, - created: new Date('2026-06-10T00:00:00.000Z'), - message: JSON.stringify({ balancesTotal: { totalBalanceChf: 20000 } }), - }); - jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([log]); + // 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, // → L540 false guard, skipped before classifyFeed + assetId: undefined, // → the `assetId == null` guard, skipped before classifyFeed }); - const unverified = assetAccount(2, 12, 'Olkypay/EUR'); // NO_FEED → non-fresh → keeps the list non-empty + 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); - let staleQueryRan = false; - jest.spyOn(ledgerLegRepository, 'createQueryBuilder').mockImplementation(() => - richLegQb((_selects, wheres) => { - if (wheres.includes('tx.sourceType = :sourceType')) staleQueryRan = true; // staleFeed SUM query executed - return null; // every getRawOne → null → +(raw?.chf ?? 0) → 0 for all four helpers - }), - ); - - const res = await service.getEquityComparison(undefined, true); - - const period = res.periods[0]; - expect(staleQueryRan).toBe(true); // unverified list non-empty (null-assetId account skipped, NO_FEED kept) → SUM ran - expect(period.journalEquity).toBe(0); // journalEquityAt null chf → 0 (L470) - expect(period.difference).toBe(-20000); // 0 − 20000 - expect(period.decomposition.transitPhantom).toBe(0); // L493 - expect(period.decomposition.staleFeed).toBe(0); // L510 - expect(period.decomposition.spreadFees).toBe(0); // L527 - expect(period.decomposition.other).toBe(-20000); // −20000 − (0+0+0) residual - }); - - it('staleFeed short-circuits to 0 when there are no unverified accounts', async () => { - const log = Object.assign(new Log(), { - id: 1, - created: new Date('2026-06-10T00:00:00.000Z'), - message: JSON.stringify({ balancesTotal: { totalBalanceChf: 100 } }), - }); - jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([log]); - // a FRESH account → unverifiedAccountIds is empty → staleFeed returns 0 WITHOUT running the SUM query - const fresh = assetAccount(1, 11, 'Binance/EUR'); - jest.spyOn(ledgerAccountRepository, 'find').mockResolvedValue([fresh]); + const captured = { selects: [] as string[], params: {} as Record }; 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); + .spyOn(ledgerLegRepository, 'createQueryBuilder') + .mockReturnValue( + equityGroupedQb([{ day: '2026-06-10', equity: '19000', transit: '0', stale: '-200', spread: '0' }], captured), + ); - let staleQueryRan = false; - jest.spyOn(ledgerLegRepository, 'createQueryBuilder').mockImplementation(() => - richLegQb((_selects, wheres) => { - if (wheres.includes('tx.sourceType = :sourceType')) staleQueryRan = true; // staleFeed SUM query - if (wheres.includes('account.type IN')) return { chf: '100' }; - return { chf: '0' }; - }), - ); - - const res = await service.getEquityComparison(undefined, true); + const res = await service.getEquityComparison(new Date('2026-06-01'), true); - expect(staleQueryRan).toBe(false); // no unverified accounts → the staleFeed SUM query is never executed - expect(res.periods[0].decomposition.staleFeed).toBe(0); + 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 }); }); 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 index 8e09d7283f..141d8eb4cc 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-reconciliation.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-reconciliation.service.spec.ts @@ -10,6 +10,7 @@ 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'; @@ -20,6 +21,7 @@ import { createCustomLedgerAccount } from '../../entities/__mocks__/ledger-accou 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 { FeedStatus, LedgerReconciliationService } from '../ledger-reconciliation.service'; interface LegQueryStub { @@ -39,6 +41,8 @@ describe('LedgerReconciliationService', () => { let liquidityManagementBalanceService: LiquidityManagementBalanceService; let ledgerAccountRepository: LedgerAccountRepository; let ledgerLegRepository: LedgerLegRepository; + let markService: LedgerMarkService; + let refRewardService: RefRewardService; let mails: MailRequest[]; let legStub: LegQueryStub; @@ -57,17 +61,21 @@ describe('LedgerReconciliationService', () => { return Object.assign(new LiquidityBalance(), { asset: { id: assetId } as Asset, amount, updated }); } - function financeLog(totalBalanceChf: number): Log { - return Object.assign(new Log(), { - id: 1, - created: new Date('2026-06-11T00:00:00Z'), - message: JSON.stringify({ - assets: {}, - tradings: {}, - balancesByFinancialType: {}, - balancesTotal: { totalBalanceChf }, + // 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 @@ -115,6 +123,8 @@ describe('LedgerReconciliationService', () => { liquidityManagementBalanceService = createMock(); ledgerAccountRepository = createMock(); ledgerLegRepository = createMock(); + markService = createMock(); + refRewardService = createMock(); jest.spyOn(jobService, 'isLedgerReady').mockResolvedValue(true); jest.spyOn(notificationService, 'sendMail').mockImplementation((request: MailRequest) => { @@ -125,6 +135,12 @@ describe('LedgerReconciliationService', () => { 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: [ @@ -137,6 +153,8 @@ describe('LedgerReconciliationService', () => { { provide: LiquidityManagementBalanceService, useValue: liquidityManagementBalanceService }, { provide: LedgerAccountRepository, useValue: ledgerAccountRepository }, { provide: LedgerLegRepository, useValue: ledgerLegRepository }, + { provide: LedgerMarkService, useValue: markService }, + { provide: RefRewardService, useValue: refRewardService }, ], }).compile(); @@ -162,7 +180,7 @@ describe('LedgerReconciliationService', () => { }); it('reads the feed exactly once per run (§7.0 Minor R13-2)', async () => { - jest.spyOn(logService, 'getLatestFinancialLog').mockResolvedValue(financeLog(1000)); + jest.spyOn(logService, 'getLatestValidFinancialLogs').mockResolvedValue(validLogs([1000])); await service.run(); @@ -349,6 +367,34 @@ describe('LedgerReconciliationService', () => { expect(mails.some((m) => m.context === MailContext.LEDGER_TRANSIT_OVERDUE)).toBe(true); }); + // Finding 1 (regression): the transit-age aggregate reads MIN(bookingDate), but bookingDate lives on ledger_tx, + // NOT ledger_leg. The query MUST join leg.tx and select MIN(tx.bookingDate); a MIN(leg.bookingDate) references a + // non-existent column and crashes EVERY reconciliation run on real PG. Assert the exact join args + select + // expression so a regression back to leg.bookingDate (or a dropped leg.tx join) turns this test red. + it('checkTransitAge joins leg.tx and aggregates MIN(tx.bookingDate), never leg.bookingDate (Finding 1)', async () => { + const calls = { innerJoins: [] as [string, string][], selects: [] as string[] }; + const qb: any = {}; + qb.innerJoin = (a: string, b: string) => { + calls.innerJoins.push([a, b]); + return qb; + }; + qb.select = (e: string) => (calls.selects.push(e), qb); + qb.addSelect = (e: string) => (calls.selects.push(e), qb); + qb.where = () => qb; + qb.groupBy = () => qb; + qb.addGroupBy = () => qb; + qb.having = () => qb; + qb.getRawMany = () => Promise.resolve([]); + 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('MIN(tx.bookingDate)'); // age = oldest booking date from ledger_tx + expect(calls.selects).not.toContain('MIN(leg.bookingDate)'); // the crashing regression (no such column) + }); + 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 @@ -396,50 +442,156 @@ describe('LedgerReconciliationService', () => { expect(journalEquity).toBe(16050.01); // round(16050.005, 2), positive (sign-consistent with totalBalanceChf) }); - it('logs the EXACT computed difference = journalEquity − totalBalanceChf (not a vacuous substring)', async () => { - jest.spyOn(logService, 'getLatestFinancialLog').mockResolvedValue(financeLog(16000)); + // 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 parityLog = logSpy.mock.calls.find((c) => c[0].includes('equity parity')); - expect(parityLog).toBeDefined(); - - // parse the logged numbers and assert the difference is ARITHMETICALLY journalEquity − totalBalanceChf — this - // catches a swapped subtraction (16000 − 16050 = −50) or a wrong operand, which a substring check would miss. - const msg: string = parityLog[0]; - const journalEquity = Number(/journalEquity (-?\d+(?:\.\d+)?)/.exec(msg)![1]); - const totalBalanceChf = Number(/totalBalanceChf (-?\d+(?:\.\d+)?)/.exec(msg)![1]); - const difference = Number(/difference (-?\d+(?:\.\d+)?)/.exec(msg)![1]); - expect(journalEquity).toBe(16050); - expect(totalBalanceChf).toBe(16000); - expect(difference).toBe(Util.round(journalEquity - totalBalanceChf, 2)); // = +50, NOT −50 (subtraction order) - expect(difference).toBe(50); + 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); }); - it('skips the parity log when there is no FinancialDataLog snapshot', async () => { - jest.spyOn(logService, 'getLatestFinancialLog').mockResolvedValue(null); + // 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 log when the snapshot carries no totalBalanceChf', async () => { - // a snapshot whose balancesTotal lacks totalBalanceChf → checkEquityParity returns before the log (line 287) + 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, 'getLatestFinancialLog').mockResolvedValue(noTotal); + 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 }); }); diff --git a/src/subdomains/core/accounting/services/ledger-query.service.ts b/src/subdomains/core/accounting/services/ledger-query.service.ts index 8227b7b400..27a29e4053 100644 --- a/src/subdomains/core/accounting/services/ledger-query.service.ts +++ b/src/subdomains/core/accounting/services/ledger-query.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } 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'; @@ -42,6 +42,19 @@ import { FeedStatus, LedgerReconciliationService } from './ledger-reconciliation 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) and the read-only LogService @@ -211,7 +224,25 @@ export class LedgerQueryService { // --- 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) { @@ -219,9 +250,15 @@ export class LedgerQueryService { const financialDataLogTotal = finance?.balancesTotal?.totalBalanceChf; if (financialDataLogTotal == null) continue; - const journalEquity = await this.journalEquityAt(log.created); + const at = this.cumulativeAt(cumulative, log.created); + const journalEquity = Util.round(at.equity, 2); const difference = Util.round(journalEquity - financialDataLogTotal, 2); - const decomposition = await this.equityDecomposition(log.created, difference); + 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(), @@ -448,83 +485,98 @@ export class LedgerQueryService { // --- EQUITY-COMPARISON HELPERS (§7.6) --- // - // signed Σ amountChf over the balance-account types up to `at` (Dr +, Cr − already in the leg sign convention, §2.3) - private async journalEquityAt(at: Date): Promise { - const raw = await this.ledgerLegRepository - .createQueryBuilder('leg') - .innerJoin('leg.tx', 'tx') - .innerJoin('leg.account', 'account') - .select('SUM(COALESCE(leg.amountChf, 0))', 'chf') - .where('tx.bookingDate <= :at', { at }) - .andWhere('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); - } - - // three buckets aggregated independently from ledger_* (Minor R13-5); `other` is the only residual (Class-5). - private async equityDecomposition(at: Date, difference: number): Promise { - const transitPhantom = await this.transitPhantom(at); - const staleFeed = await this.staleFeed(at); - const spreadFees = await this.spreadFees(at); - const other = Util.round(difference - (transitPhantom + staleFeed + spreadFees), 2); - - return { transitPhantom, staleFeed, spreadFees, other }; - } - - private async transitPhantom(at: Date): Promise { - const raw = await this.ledgerLegRepository + // §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('SUM(COALESCE(leg.amountChf, 0))', 'chf') - .where('tx.bookingDate <= :at', { at }) - .andWhere('account.type = :type', { type: AccountType.TRANSIT }) - .getRawOne<{ chf: string | null }>(); - - return Util.round(+(raw?.chf ?? 0), 2); - } - - // Class-3: mark_to_market fx-revaluation legs on accounts that are currently unverified - private async staleFeed(at: Date): Promise { - const unverifiedAccountIds = await this.unverifiedAccountIds(); - if (!unverifiedAccountIds.length) return 0; + .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 + }; - const raw = await this.ledgerLegRepository - .createQueryBuilder('leg') - .innerJoin('leg.tx', 'tx') - .select('SUM(COALESCE(leg.amountChf, 0))', 'chf') - .where('tx.bookingDate <= :at', { at }) - .andWhere('tx.sourceType = :sourceType', { sourceType: MARK_TO_MARKET_SOURCE }) - .andWhere('leg.accountId IN (:...ids)', { ids: unverifiedAccountIds }) - .getRawOne<{ chf: string | null }>(); + // 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'); + } - return Util.round(+(raw?.chf ?? 0), 2); + 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 }; + }); } - // Class-6: Σ EXPENSE/spread-* + EXPENSE/network-fee (+ bank-fee, acquirer-fee) = executionCosts (type=EXPENSE) - private async spreadFees(at: Date): Promise { - const raw = await this.ledgerLegRepository - .createQueryBuilder('leg') - .innerJoin('leg.tx', 'tx') - .innerJoin('leg.account', 'account') - .select('SUM(COALESCE(leg.amountChf, 0))', 'chf') - .where('tx.bookingDate <= :at', { at }) - .andWhere('account.type = :type', { type: AccountType.EXPENSE }) - .andWhere('account.name NOT IN (:...excluded)', { - excluded: ['EXPENSE/refReward', 'EXPENSE/extraordinary', 'EXPENSE/fx-revaluation'], - }) - .getRawOne<{ chf: string | null }>(); - - return Util.round(+(raw?.chf ?? 0), 2); + // 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 { diff --git a/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts b/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts index a5943bdaaf..9762373b97 100644 --- a/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts +++ b/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts @@ -10,6 +10,7 @@ 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 { LogService } from 'src/subdomains/supporting/log/log.service'; import { FinanceLog } from 'src/subdomains/supporting/log/dto/log.dto'; import { MailContext, MailType } from 'src/subdomains/supporting/notification/enums'; @@ -20,9 +21,14 @@ 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', @@ -78,6 +84,8 @@ export class LedgerReconciliationService { 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 }) @@ -97,15 +105,19 @@ export class LedgerReconciliationService { // §7.0: feed read ONCE per run, held in-memory for all batches (never per-batch, Minor R13-2) const feed = await this.liquidityManagementBalanceService.getBalances(); - await this.reconcileAssets(feed, now); + // §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). + const marks = await this.markService.preload(Util.daysBefore(2, now), now); + + await this.reconcileAssets(feed, marks, now); await this.checkTransitAge(now); await this.checkSuspense(); - await this.checkEquityParity(); + await this.checkEquityParity(now); } // --- ASSET RECONCILIATION (§7.1/§7.2/§7.3) --- // - private async reconcileAssets(feed: LiquidityBalance[], now: Date): Promise { + private async reconcileAssets(feed: LiquidityBalance[], marks: LedgerMarkCache, now: Date): Promise { const feedByAssetId = new Map(feed.filter((b) => b.asset?.id != null).map((b) => [b.asset.id, b])); const unverified: string[] = []; @@ -141,7 +153,17 @@ export class LedgerReconciliationService { continue; // unverified → no per-asset diff alarm, aggregated below (§7.2/§7.3) } - await this.reconcileFreshAsset(account, balance, now); + // §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, now); } lastId = assetAccounts[assetAccounts.length - 1].id; @@ -196,32 +218,45 @@ export class LedgerReconciliationService { return isOnChain ? CustodyClass.ON_CHAIN_ACTIVE : CustodyClass.EXCHANGE_ACTIVE; } - // §7: compare journal balance vs feed within tolerance; on diff → log (the journal stays authoritative, observer) - private async reconcileFreshAsset(account: LedgerAccount, balance: LiquidityBalance, now: Date): Promise { + // §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, + now: Date, + ): Promise { const journal = await this.journalNativeBalance(account.id); const feedAmount = balance.amount ?? 0; const diff = Util.round(journal - feedAmount, 8); + const diffChf = Util.round(diff * mark, 2); - if (Math.abs(diff) <= Config.ledger.reconciliationToleranceChf) return; // within tolerance → balanced + 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})`], + [ + `${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; age = MIN(bookingDate) of open legs + // transit account with balance ≠ 0 older than route threshold → alarm; age = MIN(bookingDate) of open legs. + // bookingDate lives on ledger_tx, NOT ledger_leg — the aggregate MUST join leg.tx and read MIN(tx.bookingDate) + // (a MIN(leg.bookingDate) references a non-existent column and crashes the whole reconciliation run on real PG). private async checkTransitAge(now: Date): Promise { const overdue = await this.ledgerLegRepository .createQueryBuilder('leg') .innerJoin('leg.account', 'account') + .innerJoin('leg.tx', 'tx') .select('account.name', 'name') .addSelect('SUM(leg.amount)', 'native') - .addSelect('MIN(leg.bookingDate)', 'oldest') + .addSelect('MIN(tx.bookingDate)', 'oldest') .where('account.type = :type', { type: AccountType.TRANSIT }) .groupBy('account.id') .addGroupBy('account.name') @@ -274,22 +309,59 @@ export class LedgerReconciliationService { // --- EQUITY PARITY (§7.6) --- // - // journalEquity = signed Σ over all balance accounts (ASSET+/TRANSIT+/LIABILITY−/SUSPENSE/ROUNDING), no leading - // minus (Major R8-1) → positive, sign-consistent with totalBalanceChf. Compared against the FinancialDataLog total. - private async checkEquityParity(): Promise { + // §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 snapshot = await this.logService.getLatestFinancialLog(); - const finance = snapshot ? this.parseFinance(snapshot.message) : undefined; - if (!finance) return; + 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); - const totalBalanceChf = finance.balancesTotal?.totalBalanceChf; - if (totalBalanceChf == null) return; + // 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); - const difference = Util.round(journalEquity - totalBalanceChf, 2); this.logger.info( - `Ledger equity parity: journalEquity ${journalEquity} vs totalBalanceChf ${totalBalanceChf} (difference ${difference})`, + `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) diff --git a/src/subdomains/supporting/log/log.service.ts b/src/subdomains/supporting/log/log.service.ts index 525ed4cb7d..299ad0c577 100644 --- a/src/subdomains/supporting/log/log.service.ts +++ b/src/subdomains/supporting/log/log.service.ts @@ -67,6 +67,17 @@ export class LogService { return this.logRepo.getLatestFinancialLog(); } + // 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.logRepo.find({ + where: { system: 'LogService', subsystem: 'FinancialDataLog', severity: LogSeverity.INFO, valid: true }, + order: { id: 'DESC' }, + take: 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 0cbab85ea9..acdeea9179 100644 --- a/src/subdomains/supporting/notification/enums/index.ts +++ b/src/subdomains/supporting/notification/enums/index.ts @@ -56,6 +56,7 @@ export enum MailContext { LEDGER_RECONCILIATION = 'LedgerReconciliation', LEDGER_SUSPENSE = 'LedgerSuspense', LEDGER_TRANSIT_OVERDUE = 'LedgerTransitOverdue', + LEDGER_EQUITY_PARITY = 'LedgerEquityParity', } export enum MailContextType { @@ -116,4 +117,5 @@ export const MailContextTypeMapper: { [MailContext.LEDGER_RECONCILIATION]: null, [MailContext.LEDGER_SUSPENSE]: null, [MailContext.LEDGER_TRANSIT_OVERDUE]: null, + [MailContext.LEDGER_EQUITY_PARITY]: null, }; From b673ec28ea9fcc702564dc8f94f505d3703f39b3 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:10:52 +0200 Subject: [PATCH 10/25] test(accounting): align controller guard assertions with variadic RoleGuard develop changed RoleGuardClass to hold an entryRoles array (multi-role OR support); the spec still probed the removed entryRole field. --- .../controllers/__tests__/ledger.controller.spec.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/subdomains/core/accounting/controllers/__tests__/ledger.controller.spec.ts b/src/subdomains/core/accounting/controllers/__tests__/ledger.controller.spec.ts index b0de5c83f8..6e22c4911e 100644 --- a/src/subdomains/core/accounting/controllers/__tests__/ledger.controller.spec.ts +++ b/src/subdomains/core/accounting/controllers/__tests__/ledger.controller.spec.ts @@ -184,11 +184,12 @@ describe('LedgerController', () => { expect(guards).toHaveLength(3); // RoleGuard is the only instance-based guard in the chain; it must carry the ADMIN entry role - const roleGuard = guards.find((g) => (g as { entryRole?: UserRole }).entryRole !== undefined) as { - entryRole: UserRole; + // (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.entryRole).toBe(UserRole.ADMIN); + 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); From 17b5c262d28f016e74cdf124944111de6535d0e4 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:22:19 +0200 Subject: [PATCH 11/25] fix(accounting): address review findings (consumer wedges, dex idempotency, conventions) - buy-fiat scans now load fiatOutput.bank.asset - the settlement leg read it unloaded and threw on the first tracked-bank row, wedging the consumer - content-change scans preload marks with a 2-day lookback; the zero-width window left every non-CHF row unmarkable and stalled the cursor - liquidity-order-dex sourceId includes the row id: (context, correlationId) is not unique, so a second settled swap was silently dropped - conventions: English-only comments, PascalCase enum values, logger colon format, shared cent transformer, no redundant try/catch in cron runners --- .../__tests__/ledger-relations.spec.ts | 3 +- .../entities/ledger-cents.transformer.ts | 19 ++++++++++ .../accounting/entities/ledger-leg.entity.ts | 22 +---------- .../accounting/entities/ledger-tx.entity.ts | 30 +-------------- .../db-write-isolation.integration.spec.ts | 4 +- .../evidence-week.integration.spec.ts | 12 +++--- .../staleness-cutover.integration.spec.ts | 7 ++-- .../ledger-booking-job.service.spec.ts | 2 +- .../__tests__/ledger-cutover.service.spec.ts | 21 ++++++----- .../ledger-mark-to-market.service.spec.ts | 12 +++--- .../__tests__/ledger-mark.service.spec.ts | 4 +- .../ledger-reconciliation.service.spec.ts | 9 ++--- .../__tests__/bank-tx.consumer.spec.ts | 22 ++++++----- .../__tests__/buy-fiat.consumer.spec.ts | 17 ++++++++- .../__tests__/crypto-input.consumer.spec.ts | 37 ++++++++++++++++--- .../__tests__/exchange-tx.consumer.spec.ts | 11 +++++- .../__tests__/liquidity-mgmt.consumer.spec.ts | 20 +++++----- .../liquidity-order-dex.consumer.spec.ts | 27 ++++++++++++-- .../__tests__/payout-order.consumer.spec.ts | 6 +-- .../__tests__/trading-order.consumer.spec.ts | 2 +- .../services/consumers/bank-tx.consumer.ts | 24 ++++++------ .../services/consumers/buy-crypto.consumer.ts | 2 +- .../services/consumers/buy-fiat.consumer.ts | 14 +++---- .../consumers/crypto-input.consumer.ts | 11 +++--- .../consumers/exchange-tx.consumer.ts | 16 ++++---- .../consumers/ledger-watermark.helper.ts | 4 +- .../consumers/liquidity-mgmt.consumer.ts | 14 +++---- .../consumers/liquidity-order-dex.consumer.ts | 19 +++++----- .../consumers/payout-order.consumer.ts | 10 ++--- .../consumers/trading-order.consumer.ts | 4 +- .../services/ledger-bootstrap.service.ts | 2 +- .../services/ledger-cutover.service.ts | 19 ++++------ .../services/ledger-mark-to-market.service.ts | 10 ++--- .../services/ledger-reconciliation.service.ts | 30 +++++++-------- 34 files changed, 253 insertions(+), 213 deletions(-) create mode 100644 src/subdomains/core/accounting/entities/ledger-cents.transformer.ts diff --git a/src/subdomains/core/accounting/entities/__tests__/ledger-relations.spec.ts b/src/subdomains/core/accounting/entities/__tests__/ledger-relations.spec.ts index 388c8d58d0..7c2fc214f0 100644 --- a/src/subdomains/core/accounting/entities/__tests__/ledger-relations.spec.ts +++ b/src/subdomains/core/accounting/entities/__tests__/ledger-relations.spec.ts @@ -5,8 +5,9 @@ import { RelationIdMetadataArgs } from 'typeorm/metadata-args/RelationIdMetadata 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 { chfCentsTransformer, LedgerLeg } from '../ledger-leg.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 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 index 0f9bb485b7..dadddd61b2 100644 --- a/src/subdomains/core/accounting/entities/ledger-leg.entity.ts +++ b/src/subdomains/core/accounting/entities/ledger-leg.entity.ts @@ -1,27 +1,9 @@ import { IEntity } from 'src/shared/models/entity'; -import { Column, Entity, Index, JoinColumn, ManyToOne, RelationId, ValueTransformer } from 'typeorm'; +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'; -// 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. Colocated per entity (not a shared import) on purpose: -// ledger-leg and ledger-tx import each other, so a cross-file const would risk an undefined transformer at -// decorator-eval time depending on module load order (matches the repo's inline-transformer precedent). -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; - }, -}; - @Entity() export class LedgerLeg extends IEntity { @Index() diff --git a/src/subdomains/core/accounting/entities/ledger-tx.entity.ts b/src/subdomains/core/accounting/entities/ledger-tx.entity.ts index 79b89a4881..53288ba38b 100644 --- a/src/subdomains/core/accounting/entities/ledger-tx.entity.ts +++ b/src/subdomains/core/accounting/entities/ledger-tx.entity.ts @@ -1,34 +1,8 @@ import { IEntity } from 'src/shared/models/entity'; -import { - Check, - Column, - Entity, - Index, - JoinColumn, - ManyToOne, - OneToMany, - RelationId, - Unique, - ValueTransformer, -} from 'typeorm'; +import { Check, Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, RelationId, Unique } from 'typeorm'; +import { chfCentsTransformer } from './ledger-cents.transformer'; import { LedgerLeg } from './ledger-leg.entity'; -// PostgreSQL bigint → JS number on read, fail LOUD outside the safe-integer range instead of silently rounding (see -// the identical, deliberately colocated transformer in ledger-leg.entity.ts for the full rationale — the two entities -// import each other, so a shared cross-file const would risk an undefined transformer at decorator-eval time). -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 column → 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; - }, -}; - // 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) 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 index 2357555f27..ee55b4686e 100644 --- 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 @@ -46,7 +46,7 @@ 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-Ausnahme-b). Every business table (bank_tx, + * 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 @@ -184,7 +184,7 @@ describe('Ledger DB-write isolation after a consumer/alarm run (§10.2)', () => expect(spy).not.toHaveBeenCalled(); } - // (3) the ONLY non-ledger_* writes are the two ledger watermark settings (sanctioned R2-Ausnahme-a) + // (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$/); } 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 index b521a25fc8..ac0c494605 100644 --- 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 @@ -32,11 +32,11 @@ 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 Integrationstests = the synthetic evidence-week, run against the REAL booking+account services over a + * §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 tripel, no real IBAN. + * 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; @@ -256,7 +256,7 @@ describe('Ledger evidence-week integration (§10.2)', () => { 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 GENAU once by exactly the output amount + // 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); @@ -352,7 +352,7 @@ describe('Ledger evidence-week integration (§10.2)', () => { }); await exchangeTxConsumer([settled, walletWithdrawal]).process(); - // GENAU one booking of the deposit (no double count from the pending phase) + // 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); @@ -550,7 +550,7 @@ describe('Ledger evidence-week integration (§10.2)', () => { 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 - // eröffnet buyFiat-received per-row with the synthetic marker; the buy_fiat consumer must resolve G-b from + // 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 @@ -705,7 +705,7 @@ describe('Ledger evidence-week integration (§10.2)', () => { 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 musterbeispiel trigger) + // 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 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 index 8bd384e3a7..999d32a77d 100644 --- 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 @@ -335,8 +335,9 @@ describe('Ledger staleness + cutover integration (§10.2)', () => { }); 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 (4) initWatermarks AFTER all openings committed but BEFORE the flag is set — the - // failure-isolated run() swallows it, the flag stays unset, the openings + the snapshot pin remain committed. + // Run A crashes in step (4) initWatermarks AFTER all openings committed but BEFORE the flag is set — run() now + // propagates the error to @DfxCron's lock layer (no redundant in-method catch); the flag stays unset, the + // openings + the snapshot pin remain committed. let crashWatermarks = true; jest.spyOn(settingService, 'set').mockImplementation((key: string, value: string) => { if (key === 'ledgerCutoverLogId') cutoverFlag = value; @@ -346,7 +347,7 @@ describe('Ledger staleness + cutover integration (§10.2)', () => { return Promise.resolve(); }); - await service.run(); + 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) before any opening) const firstRunBookings = booked.length; 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 index 1979d07171..f8c54230f3 100644 --- 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 @@ -173,7 +173,7 @@ describe('LedgerBookingJobService', () => { expect(await getLedgerWatermark(settingService, 'bank_tx')).toBeUndefined(); }); - it('writes a watermark exclusively via settingService.set (never setObj/settingRepo, §4.10 R2-Ausnahme-a)', async () => { + 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, 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 index 6e445cd304..2f20f8ab7c 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts @@ -219,11 +219,14 @@ describe('LedgerCutoverService', () => { }); describe('failure-isolation (§6.3)', () => { - it('catches a cutover error and never sets the flag (consumers stay no-op)', async () => { + // 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()).resolves.toBeUndefined(); + await expect(service.run()).rejects.toThrow('boom'); const flagSet = (settingService.set as jest.Mock).mock.calls.find((c) => c[0] === 'ledgerCutoverLogId'); expect(flagSet).toBeUndefined(); @@ -567,15 +570,15 @@ describe('LedgerCutoverService', () => { // --- SNAPSHOT SELECTION + PINNING (§6.3 / Major design-accounting R3-1) --- // describe('snapshot selection + pinning (R3-1)', () => { - it('throws (caught by run) when there is no valid FinancialDataLog snapshot ≤ Stichtag', async () => { + 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 → run() catches it → flag stays unset. + // 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()).resolves.toBeUndefined(); + 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(); @@ -604,7 +607,7 @@ describe('LedgerCutoverService', () => { expect(setSpy).toHaveBeenLastCalledWith('ledgerCutoverLogId', '999'); }); - it('throws (caught) when the pinned snapshot log no longer exists', async () => { + 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); @@ -612,7 +615,7 @@ describe('LedgerCutoverService', () => { jest.spyOn(logService, 'getLog').mockResolvedValue(null); // pinned log was deleted const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); - await expect(service.run()).resolves.toBeUndefined(); // run swallows the throw + 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 }); @@ -660,7 +663,7 @@ describe('LedgerCutoverService', () => { expect(setSpy).toHaveBeenLastCalledWith('ledgerCutoverLogId', '888'); // flag set to the concurrent winner }); - it('throws (caught) when the snapshot message is not parseable JSON', async () => { + 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, @@ -671,7 +674,7 @@ describe('LedgerCutoverService', () => { jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([broken]); const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); - await expect(service.run()).resolves.toBeUndefined(); + 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(); 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 index 2ef263e828..a84887a9f6 100644 --- 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 @@ -273,18 +273,16 @@ describe('LedgerMarkToMarketService', () => { expect(bookingService.bookTx).not.toHaveBeenCalled(); }); - // run() failure-isolation (line 57): markToMarket throwing (e.g. the candidate query fails) is caught and logged, - // run() resolves without rethrowing — the cron must never crash the scheduler. - it('catches a markToMarket error in run() and never rethrows (failure-isolation)', async () => { + // 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 }); - const errSpy = jest.spyOn(service['logger'], 'error'); - await expect(service.run()).resolves.toBeUndefined(); + await expect(service.run()).rejects.toThrow('db down'); - expect(errSpy).toHaveBeenCalledWith('Ledger mark-to-market failed', expect.any(Error)); expect(bookingService.bookTx).not.toHaveBeenCalled(); }); @@ -313,7 +311,7 @@ describe('LedgerMarkToMarketService', () => { await service.run(); - expect(errSpy).toHaveBeenCalledWith('Failed to mark-to-market ledger account 9000', expect.any(Error)); + 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 }); 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 index 8f5b83d72f..aeb993f699 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-mark.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-mark.service.spec.ts @@ -33,7 +33,7 @@ describe('LedgerMarkService', () => { expect(service).toBeDefined(); }); - it('returns the priceChf of the latest mark ≤ bookingDate (Stufe 2)', async () => { + it('returns the priceChf of the latest mark ≤ bookingDate (stage 2)', async () => { jest .spyOn(logService, 'getFinancialLogs') .mockResolvedValue([ @@ -48,7 +48,7 @@ describe('LedgerMarkService', () => { expect(cache.getMarkAt(5, new Date('2026-06-03'))).toBe(52000); }); - it('returns undefined when no log row ≤ bookingDate exists (Stufe 3 → needsMark)', async () => { + 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 } })]); 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 index 141d8eb4cc..9ac9844875 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-reconciliation.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-reconciliation.service.spec.ts @@ -617,13 +617,12 @@ describe('LedgerReconciliationService', () => { expect(result.thresholdHours).toBe(24); // ON_CHAIN_INACTIVE (asset undefined) }); - it('catches a reconcile error in run() and never throws (failure-isolation)', async () => { + // 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')); - const errSpy = jest.spyOn(service['logger'], 'error'); - await expect(service.run()).resolves.toBeUndefined(); // swallowed, not rethrown - - expect(errSpy).toHaveBeenCalled(); + 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 index 37e1b3ed5a..8735c32804 100644 --- 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 @@ -3,6 +3,7 @@ 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'; @@ -230,7 +231,7 @@ describe('BankTxConsumer', () => { it('books BUY_CRYPTO on an untracked bank with NO same-currency tracked mark as a needsMark leg, no silent plug', async () => { // when no tracked bank of the currency exists, currencyMarkAssetId returns undefined → the SUSPENSE leg stays - // needsMark and withFxPlug books NO plug (§5.1 Stufe 3: no silent plug without a mark; mark-to-market revalues) + // needsMark and withFxPlug books NO plug (§5.1 stage 3: no silent plug without a mark; mark-to-market revalues) const buyCrypto = { amountInChf: 9480 } as any; mockBatch( [bankTx({ type: BankTxType.BUY_CRYPTO, accountIban: 'UNTRACKED-IBAN', amount: 10000, buyCrypto })], @@ -591,7 +592,7 @@ describe('BankTxConsumer', () => { expect(cents(legs)).toBe(0); }); - it('books CHECKOUT_LTD CRDT: ASSET/bank netto + EXPENSE/acquirer-fee / ASSET/Checkout brutto (CHF cross-asset)', async () => { + it('books CHECKOUT_LTD CRDT: ASSET/bank net + EXPENSE/acquirer-fee / ASSET/Checkout gross (CHF cross-asset)', async () => { createdAccounts.set('Checkout/EUR', account('Checkout/EUR', AccountType.ASSET, 'EUR', 270)); mockBatch([ bankTx({ @@ -608,7 +609,7 @@ describe('BankTxConsumer', () => { 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)); // brutto = netto + fee (Minor R3-5) + expect(checkout.amountChf).toBe(-(95 + 3)); // gross = net + fee (Minor R3-5) expect(cents(legs)).toBe(0); }); @@ -800,7 +801,7 @@ describe('BankTxConsumer', () => { 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, brutto == netto + // §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([ @@ -809,14 +810,14 @@ describe('BankTxConsumer', () => { creditDebitIndicator: BankTxIndicator.CREDIT, accountIban: 'CHF-IBAN', amount: 100, - chargeAmountChf: null, // no fee → fee leg skipped, brutto = netto + 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.amountChf).toBe(-100); // brutto == netto (no fee) + expect(checkout.amountChf).toBe(-100); // gross == net (no fee) expect(cents(legs)).toBe(0); }); @@ -868,8 +869,9 @@ describe('BankTxConsumer', () => { // --- 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 at tx.created (the null-side of the ?? in the scan callback, source line 94). - it('content-change scan uses tx.created for the mark window when bookingDate is null (line 94 null side)', async () => { + // → 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'); @@ -890,9 +892,9 @@ describe('BankTxConsumer', () => { expect(rebookSpy).toHaveBeenCalledTimes(1); expect(rebookSpy.mock.calls[0][0].sourceId).toBe('40'); - // the scan callback preload (the LAST preload call) was invoked with tx.created on both bounds (bookingDate null) + // 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]).toBe(created); + expect(lastPreload[0]).toEqual(Util.daysBefore(2, created)); expect(lastPreload[1]).toBe(created); }); 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 index bdebd59af5..a8f33039db 100644 --- 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 @@ -157,6 +157,21 @@ describe('BuyFiatConsumer', () => { 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([ @@ -391,7 +406,7 @@ describe('BuyFiatConsumer', () => { mockBatch([make(101, 1000), make(102, 500), make(103, 300)]); await consumer.process(); - // three seq3 legs, each its own outputAmount; ASSET/bank debited GENAU um fiat_output.amount = 1800 total + // 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)); 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 index bb32174d83..b57b7a4c55 100644 --- 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 @@ -3,6 +3,7 @@ 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'; @@ -203,8 +204,8 @@ describe('CryptoInputConsumer', () => { expect(cents(seq0.legs)).toBe(0); }); - // §10.2 fixture (B)(d) / §5.1 Stufe 3 — no mark: ASSET leg needsMark, NO silent fx-revaluation plug - it('flags the ASSET leg needsMark when no mark exists and books NO silent plug (§5.1 Stufe 3)', async () => { + // §10.2 fixture (B)(d) / §5.1 stage 3 — no mark: ASSET leg needsMark, NO silent fx-revaluation plug + it('flags the ASSET leg needsMark when no mark exists and books NO silent plug (§5.1 stage 3)', async () => { mockBatch([ cryptoInput({ id: 5, @@ -223,7 +224,7 @@ describe('CryptoInputConsumer', () => { expect(assetLeg.needsMark).toBe(true); expect(assetLeg.amountChf).toBeUndefined(); // NO fx-revaluation plug while the asset leg needsMark (the old bug plugged the FULL +50000 received value as a - // phantom fx-revaluation, vacuously balancing cents to 0). Per §5.1 Stufe 3 the tx stays unbalanced-by-mark and + // phantom fx-revaluation, vacuously balancing cents to 0). Per §5.1 stage 3 the tx stays unbalanced-by-mark and // the mark-to-market job revalues the asset leg later — exactly the systemic needsMark-guard fix. expect(seq0.legs.find((l) => l.account.name?.includes('fx-revaluation'))).toBeUndefined(); expect(seq0.legs).toHaveLength(2); // asset (needsMark) + received anchor only, no plug leg @@ -252,7 +253,7 @@ describe('CryptoInputConsumer', () => { expect(cents(seq1.legs)).toBe(0); }); - it('does NOT book a forward fee leg when forwardFeeAmountChf is null (Null-Strategie)', async () => { + it('does NOT book a forward fee leg when forwardFeeAmountChf is null (null strategy)', async () => { mockBatch([ cryptoInput({ id: 7, @@ -369,6 +370,32 @@ describe('CryptoInputConsumer', () => { 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); @@ -407,7 +434,7 @@ describe('CryptoInputConsumer', () => { expect(assetLeg.needsMark).toBe(true); expect(assetLeg.amountChf).toBeUndefined(); expect(assetLeg.priceChf).toBeNull(); // mark ?? null - // a needsMark leg short-circuits appendFxPlug (§5.1 Stufe 3) → received anchor only, NO plug leg + // a needsMark leg short-circuits appendFxPlug (§5.1 stage 3) → received anchor only, NO plug leg expect(seq0.legs.find((l) => l.account.name?.includes('fx-revaluation'))).toBeUndefined(); expect(seq0.legs).toHaveLength(2); }); 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 index 15bea6035c..86c8b86cf7 100644 --- 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 @@ -5,6 +5,7 @@ import { ExchangeTx, ExchangeTxType } from 'src/integration/exchange/entities/ex 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'; @@ -763,10 +764,12 @@ describe('ExchangeTxConsumer', () => { expect(asset.amountChf).toBe(95); }); - // reconcileBooking `tx.externalCreated ?? tx.created` (L103, both operands): a content-change ok row with - // externalCreated NULL → the reconcile mark-preload AND the recomputed spec use `created` as the booking date. + // 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({ @@ -787,6 +790,10 @@ describe('ExchangeTxConsumer', () => { 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 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 index ab42208197..ec34cf76e0 100644 --- 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 @@ -119,8 +119,8 @@ describe('LiquidityMgmtConsumer', () => { expect(consumer).toBeDefined(); }); - // §4.8 Zweig 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 (Zweig 4)', async () => { + // §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, @@ -145,8 +145,8 @@ describe('LiquidityMgmtConsumer', () => { expect(cents(tx.legs)).toBe(0); // single currency, same mark → no spread plug needed }); - // §4.8 Zweig 4 — dEURO bridge-in: system=dEURO (otherwise exchange) + command='bridge-in' → BOOK (not skip) - it('books a dEURO bridge-in order (Zweig 4, NOT skipped as exchange)', async () => { + // §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, @@ -200,7 +200,7 @@ describe('LiquidityMgmtConsumer', () => { expect(setSpy).not.toHaveBeenCalled(); }); - // §4.8 Zweig 1 DEDUP: exchange-routed → SKIP (exchange_tx authoritative). The same transfer must NOT be booked + // §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, @@ -211,15 +211,15 @@ describe('LiquidityMgmtConsumer', () => { LiquidityManagementSystem.FRANKENCOIN, LiquidityManagementSystem.DEURO, LiquidityManagementSystem.JUICE, - ])('skips %s exchange-routed orders (Zweig 1, exchange_tx authoritative)', async (system) => { + ])('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 Zweig 2 DEDUP: DfxDex purchase/sell → SKIP (liquidity_order dex authoritative, §4.8a). The same on-chain + // §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 (Zweig 2, liquidity_order dex authoritative)', async (cmd) => { + it.each(['purchase', 'sell'])('skips DfxDex %s orders (branch 2, liquidity_order dex authoritative)', async (cmd) => { mockBatch([ lmOrder({ id: 21, @@ -233,8 +233,8 @@ describe('LiquidityMgmtConsumer', () => { expect(booked).toHaveLength(0); // dedup: §4.8a books this swap, not the LM consumer }); - // §4.8 Zweig 3 DEDUP: DfxDex withdraw → SKIP (target deposit exchange_tx authoritative) - it('skips DfxDex withdraw orders (Zweig 3, target deposit exchange_tx authoritative)', async () => { + // §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, 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 index f9a70b88b7..d49d72d1cd 100644 --- 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 @@ -129,7 +129,7 @@ describe('LiquidityOrderDexConsumer', () => { expect(booked).toHaveLength(1); const tx = booked[0]; expect(tx.sourceType).toBe('liquidity_order'); - expect(tx.sourceId).toBe('LiquidityManagement:123177'); // ':' (Minor R6-8) + expect(tx.sourceId).toBe('LiquidityManagement:123177:10'); // '::' (Minor R6-8) const target = leg(tx, 'Ethereum/EURC'); const swap = leg(tx, 'Ethereum/USDC'); @@ -187,7 +187,7 @@ describe('LiquidityOrderDexConsumer', () => { }); // §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-Strategie)', async () => { + 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(); @@ -214,15 +214,34 @@ describe('LiquidityOrderDexConsumer', () => { expect(booked).toHaveLength(0); }); - // §4.8a idempotency / adversarial dedup: re-running over the same ':' key must NOT + // §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 () => { + 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 () => { 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 index 301f8f6a05..34b8d8f058 100644 --- 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 @@ -304,7 +304,7 @@ describe('PayoutOrderConsumer', () => { await consumer.process(); const tx = booked[0]; - expect(leg(tx, 'Ethereum/ETH').amount).toBe(-0.002); // native gegen ETH + 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); @@ -533,8 +533,8 @@ describe('PayoutOrderConsumer', () => { }); // §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 Stufe 3, no silent plug). - it('books NO fx-revaluation plug while the wallet leg needsMark (no silent plug, §5.1 Stufe 3)', async () => { + // though the CHF cents don't balance — the mark-to-market job revalues it later (§5.1 stage 3, no silent plug). + it('books NO fx-revaluation plug while the wallet leg needsMark (no silent plug, §5.1 stage 3)', async () => { jest.spyOn(buyCryptoRepo, 'findOneBy').mockResolvedValue({ amountInChf: 50000, totalFeeAmountChf: 0 } as any); jest .spyOn(accountService, 'findByAssetId') 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 index a44fa393b2..4831e84d20 100644 --- 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 @@ -291,7 +291,7 @@ describe('TradingOrderConsumer', () => { 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(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 }); }); diff --git a/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts b/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts index 7992ebf132..24d5a47357 100644 --- a/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts @@ -25,7 +25,7 @@ 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 "neue Routen lazy") +// 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', @@ -76,7 +76,7 @@ export class BankTxConsumer { await this.processForward(watermark); - // content-change scan (§4.12): re-classification of an already-booked bank_tx (the §4.12 musterbeispiel + // 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 @@ -89,9 +89,11 @@ export class BankTxConsumer { this.bankTxRepo, { buyCrypto: true }, async (tx: BankTx) => { + const t = tx.bookingDate ?? tx.created; await this.reconcileBooking( tx, - await this.markService.preload(tx.bookingDate ?? tx.created, tx.bookingDate ?? tx.created), + // lookback so getMarkAt finds the latest mark at-or-before the row timestamp + await this.markService.preload(Util.daysBefore(2, t), t), ); }, ); @@ -117,7 +119,7 @@ export class BankTxConsumer { await this.book(tx, marks); lastProcessedId = tx.id; } catch (e) { - this.logger.error(`Failed to book bank_tx ${tx.id}`, e); + this.logger.error(`Failed to book bank_tx ${tx.id}:`, e); break; // failure-isolation: leave watermark unchanged, retry next run (§4-header) } } @@ -394,7 +396,7 @@ export class BankTxConsumer { return [bank, liability]; } - // §4.2 BANK_TX_REPEAT_CHARGEBACK DBIT: Dr LIABILITY/{bucket} (Eröffnungs-CHF) / Cr ASSET/bank (EUR-mark) + fx-plug. + // §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 @@ -417,7 +419,7 @@ export class BankTxConsumer { return this.withFxPlug([liability, bank]); } - // §4.2 BANK_TX_RETURN_CHARGEBACK DBIT: Dr LIABILITY/{bucket} (Eröffnungs-CHF) / Cr ASSET/bank (EUR-mark) + // §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 @@ -510,21 +512,21 @@ export class BankTxConsumer { return Util.round(-leg.amountChf, 2); // cutover Cr leg is −openingChf → chargeback Dr debits +openingChf } - // §4.2 CHECKOUT_LTD CRDT: Dr ASSET/bank (netto) + Dr EXPENSE/acquirer-fee / Cr ASSET/Checkout (brutto), CHF-only + // §4.2 CHECKOUT_LTD CRDT: Dr ASSET/bank (net) + Dr EXPENSE/acquirer-fee / Cr ASSET/Checkout (gross), CHF-only 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)); // netto + 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)); - // brutto Checkout custody Cr-leg: no own *Chf → CHF = netto + fee (both CHF-known), else needsMark (Minor R3-5) + // gross Checkout custody Cr-leg: 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), @@ -593,7 +595,7 @@ export class BankTxConsumer { // 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). - // NO silent plug while a leg still needsMark (§5.1 Stufe 3): an unmarked non-CHF leg carries amountChf=undefined + // NO silent plug while a leg still needsMark (§5.1 stage 3): an unmarked non-CHF leg carries amountChf=undefined // (counted as 0 here), so plugging would book the full leg value as a phantom fx-revaluation; instead leave the tx // unbalanced-by-mark and let the mark-to-market job revalue the leg later — consistent with exchange-tx.consumer.ts // (the §4.2a fix resolves the EUR mark for untracked banks, so this guard only fires when the mark is truly absent). @@ -676,7 +678,7 @@ export class BankTxConsumer { // 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 Stufe 3). + // 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 diff --git a/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts b/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts index 2a7310e00a..141ad6b058 100644 --- a/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts @@ -86,7 +86,7 @@ export class BuyCryptoConsumer { if (!advance) break; lastProcessedId = bc.id; } catch (e) { - this.logger.error(`Failed to book buy_crypto ${bc.id}`, e); + this.logger.error(`Failed to book buy_crypto ${bc.id}:`, e); break; // failure-isolation: leave watermark unchanged, retry next run (§4-header) } } diff --git a/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts b/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts index 87b0b32703..c40aeb110e 100644 --- a/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts @@ -23,7 +23,7 @@ const PAYMENT_LINK = 'LIABILITY/paymentLink'; const BUY_FIAT_OWED = 'LIABILITY/buyFiat-owed'; /** - * The Class-1-Kern consumer (§4.7 + §4.7a + §4.7b, D04 §2 / D13 C). Pure observer: reads buy_fiat (+ ledger_tx + * 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, @@ -63,7 +63,7 @@ export class BuyFiatConsumer { SOURCE_TYPE, afterForward, this.buyFiatRepo, - { cryptoInput: { paymentLinkPayment: true }, fiatOutput: { bankTx: true } }, + { cryptoInput: { paymentLinkPayment: true }, fiatOutput: { bankTx: true, bank: { asset: true } } }, async (bf: BuyFiat) => { // §4.12: an amountInChf / totalFeeAmountChf change on a settled regular sell reverses + re-books the seq1 // reclassification tx; then the idempotent forward book() appends any newly-settled seqs (transmit/booked). @@ -87,7 +87,7 @@ export class BuyFiatConsumer { 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 } }, + relations: { cryptoInput: { paymentLinkPayment: true }, fiatOutput: { bankTx: true, bank: { asset: true } } }, order: { id: 'ASC' }, take: Config.ledger.backfillBatchSize, }); @@ -103,7 +103,7 @@ export class BuyFiatConsumer { if (!advance) break; lastProcessedId = bf.id; } catch (e) { - this.logger.error(`Failed to book buy_fiat ${bf.id}`, e); + this.logger.error(`Failed to book buy_fiat ${bf.id}:`, e); break; // failure-isolation: leave watermark unchanged, retry next run (§4-header) } } @@ -258,13 +258,13 @@ export class BuyFiatConsumer { 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 schluss-consistent value + // 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 Krypto↔Fiat sell-spread + 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'); @@ -327,7 +327,7 @@ export class BuyFiatConsumer { } // §4.7a — appends the FX-P&L leg = −(Σ CHF) for the EUR/output drift; CHF output → drift 0 → no leg. - // No silent plug while a leg still needsMark (§5.1 Stufe 3): an unmarked leg carries amountChf=undefined (counted + // No silent plug while a leg still needsMark (§5.1 stage 3): an unmarked leg carries amountChf=undefined (counted // as 0), so plugging would book its full value as a phantom fx-revaluation — leave it for the mark-to-market job to // revalue, consistent with exchange-tx.consumer.ts. private async appendFxResidual(legs: LedgerLegInput[]): Promise { diff --git a/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts b/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts index 5d9618e181..8d11ce13d7 100644 --- a/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts @@ -16,7 +16,7 @@ const SOURCE_TYPE = 'crypto_input'; const CHF = 'CHF'; /** - * The EINZIGE booker of the crypto-input leg (Single-Booker §4.1 Blocker R1-1) + the standalone forward-fee + * 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_*. * @@ -56,7 +56,8 @@ export class CryptoInputConsumer { this.cryptoInputRepo, { buyFiat: true, buyCrypto: true }, async (ci: CryptoInput) => { - const marks = await this.markService.preload(ci.updated, ci.updated); + // 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); const input = await this.buildSeq0Input(ci, ci.updated, marks); if (input) await this.bookingService.reverseAndRebookIfChanged(input); }, @@ -82,7 +83,7 @@ export class CryptoInputConsumer { await this.book(ci, marks); lastProcessedId = ci.id; } catch (e) { - this.logger.error(`Failed to book crypto_input ${ci.id}`, e); + this.logger.error(`Failed to book crypto_input ${ci.id}:`, e); break; // failure-isolation: leave watermark unchanged, retry next run (§4-header) } } @@ -167,7 +168,7 @@ export class CryptoInputConsumer { // 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): Promise { - if (!ci.outTxId || ci.forwardFeeAmountChf == null) return; // null fee → no leg (Null-Strategie §5.1) + 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); @@ -193,7 +194,7 @@ export class CryptoInputConsumer { // 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. - // No silent plug while a leg still needsMark (§5.1 Stufe 3): an unmarked leg carries amountChf=undefined (counted + // No silent plug while a leg still needsMark (§5.1 stage 3): an unmarked leg carries amountChf=undefined (counted // as 0), so plugging would book its full value as a phantom fx-revaluation — leave it for the mark-to-market job to // revalue, consistent with exchange-tx.consumer.ts. private appendFxPlug(legs: LedgerLegInput[], fx: { income: LedgerAccount; expense: LedgerAccount }): void { diff --git a/src/subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts b/src/subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts index c888196b42..054b5f2452 100644 --- a/src/subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts @@ -87,7 +87,7 @@ export class ExchangeTxConsumer { if (spec) await this.bookingService.bookTx(spec); lastProcessedId = tx.id; } catch (e) { - this.logger.error(`Failed to book exchange_tx ${tx.id}`, e); + this.logger.error(`Failed to book exchange_tx ${tx.id}:`, e); break; // failure-isolation: leave watermark unchanged, retry next run (§4-header) } } @@ -100,7 +100,9 @@ export class ExchangeTxConsumer { // §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 marks = await this.markService.preload(tx.externalCreated ?? tx.created, tx.externalCreated ?? tx.created); + 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 @@ -212,7 +214,7 @@ export class ExchangeTxConsumer { 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 (Stufe 1) ?? mark + // 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 = { @@ -280,7 +282,7 @@ export class ExchangeTxConsumer { // --- ROUTE DISAMBIGUATION (§4.3a/§4.3b) --- // - // determines the route-passing counter account of a deposit/withdrawal (rein lesend, §4.3a) + // 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; @@ -333,7 +335,7 @@ export class ExchangeTxConsumer { return 'none'; } - // rein lesend: a bank_tx KRAKEN/SCRYPT/SCB on the same currency within a date window (§4.3a-R2 nachgebaut) + // 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; @@ -407,7 +409,7 @@ export class ExchangeTxConsumer { }; } - // §4.3 amountChf null fallback (Minor R9-4): persisted amountChf (Stufe 1) ?? mark × amount (Stufe 2) ?? needsMark + // §4.3 amountChf null fallback (Minor R9-4): persisted amountChf (stage 1) ?? mark × amount (stage 2) ?? needsMark private depositChf( tx: ExchangeTx, asset: LedgerAccount, @@ -454,7 +456,7 @@ export class ExchangeTxConsumer { return account; } - // trade base/quote via symbol+side (not null-pair); reuse dashboard-reconciliation parse logic (nachgebaut, §4.3) + // 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('/'); diff --git a/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts b/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts index 7b0f9d699d..507ec25866 100644 --- a/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts +++ b/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts @@ -41,7 +41,7 @@ export async function getLedgerWatermark( /** * Writes the per-source watermark (§11.3) — exclusively via `settingService.set` (never `setObj`/`settingRepo`; - * §4.10 R2-Ausnahme-a). The watermark is only advanced after a successful batch (§4-header failure-isolation). + * §4.10 R2-exception-a). The watermark is only advanced after a successful batch (§4-header failure-isolation). */ export async function setLedgerWatermark( settingService: SettingService, @@ -119,7 +119,7 @@ export async function runContentChangeScan::`) to stay row-unique; the + * ledger UNIQUE(sourceType,sourceId,seq) then backstops idempotency (Minor R6-8). */ @Injectable() export class LiquidityOrderDexConsumer { @@ -84,7 +85,7 @@ export class LiquidityOrderDexConsumer { await this.book(order, marks); lastProcessedId = order.id; } catch (e) { - this.logger.error(`Failed to book liquidity_order ${order.id}`, e); + this.logger.error(`Failed to book liquidity_order ${order.id}:`, e); break; // failure-isolation: leave watermark unchanged, retry next run (§4-header) } } @@ -125,8 +126,8 @@ export class LiquidityOrderDexConsumer { await this.bookingService.bookTx({ sourceType: SOURCE_TYPE, - // sourceId = ':' (Minor R6-8: uniqueness rests on the ledger UNIQUE, not the - // non-unique source @Index([context, correlationId])) + // 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, @@ -149,7 +150,7 @@ export class LiquidityOrderDexConsumer { legs: LedgerLegInput[], ): Promise { const { feeAsset, feeAmount, targetAsset, swapAsset } = order; - if (!feeAsset || feeAmount == null || feeAmount === 0) return; // Null-Strategie §5.1: no fee → no fee leg + if (!feeAsset || feeAmount == null || feeAmount === 0) return; // null strategy §5.1: no fee → no fee leg const mark = marks.getMarkAt(feeAsset.id, bookingDate); const feeChf = mark != null ? Util.round(mark * feeAmount, 2) : undefined; @@ -180,7 +181,7 @@ export class LiquidityOrderDexConsumer { } // appends an EXPENSE/INCOME spread-DfxDex plug for the CHF residual; sub-cent → ROUNDING (booking service). - // Skips the plug when any leg still needsMark (no silent plug without a mark, §5.1 Stufe 3 / §4.8a). + // Skips the plug when any leg still needsMark (no silent plug without a mark, §5.1 stage 3 / §4.8a). private async appendSpreadPlug(legs: LedgerLegInput[]): Promise { if (legs.some((l) => l.needsMark)) return; @@ -226,7 +227,7 @@ export class LiquidityOrderDexConsumer { // --- HELPERS --- // private sourceId(order: LiquidityOrder): string { - return `${order.context}:${order.correlationId}`; + return `${order.context}:${order.correlationId}:${order.id}`; } private async assetAccount(asset: Asset): Promise { diff --git a/src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts b/src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts index ddbc858006..d573276ebd 100644 --- a/src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts @@ -44,7 +44,7 @@ const CUTOVER_OWED_MARKER: Partial> = { }; /** - * The EINZIGE booker of all payout network fees (§4.5/§1.7, all contexts). Pure observer: reads payout_order + * 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 @@ -65,7 +65,7 @@ export class PayoutOrderConsumer { @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 aus Completion"); the WP1 repo summary lists only PayoutOrder/RefReward, but the §4.5 body requires + // "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, @@ -97,7 +97,7 @@ export class PayoutOrderConsumer { await this.book(order, marks); lastProcessedId = order.id; } catch (e) { - this.logger.error(`Failed to book payout_order ${order.id}`, e); + this.logger.error(`Failed to book payout_order ${order.id}:`, e); break; // failure-isolation: leave watermark unchanged, retry next run (§4-header) } } @@ -285,7 +285,7 @@ export class PayoutOrderConsumer { legs: LedgerLegInput[], ): Promise { const feeChf = this.networkFeeChf(order); - if (feeChf === 0) return; // LN (Fee=0) or both null → no fee leg (Null-Strategie §5.1) + 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)); @@ -342,7 +342,7 @@ export class PayoutOrderConsumer { // --- HELPERS --- // // appends an EXPENSE/INCOME fx-revaluation plug for the CHF residual > tolerance (§4.5); sub-cent → ROUNDING. - // No silent plug while a leg still needsMark (§5.1 Stufe 3): an unmarked leg carries amountChf=undefined (counted + // No silent plug while a leg still needsMark (§5.1 stage 3): an unmarked leg carries amountChf=undefined (counted // as 0), so plugging would book its full value as a phantom fx-revaluation — leave the tx for the mark-to-market job // to revalue, consistent with exchange-tx.consumer.ts (mark-to-market then closes the residual). private async withFxPlug(legs: LedgerLegInput[]): Promise { diff --git a/src/subdomains/core/accounting/services/consumers/trading-order.consumer.ts b/src/subdomains/core/accounting/services/consumers/trading-order.consumer.ts index fed4827841..80f0afae02 100644 --- a/src/subdomains/core/accounting/services/consumers/trading-order.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/trading-order.consumer.ts @@ -63,7 +63,7 @@ export class TradingOrderConsumer { await this.book(order, marks); lastProcessedId = order.id; } catch (e) { - this.logger.error(`Failed to book trading_order ${order.id}`, e); + this.logger.error(`Failed to book trading_order ${order.id}:`, e); break; // failure-isolation: leave watermark unchanged, retry next run (§4-header) } } @@ -118,7 +118,7 @@ export class TradingOrderConsumer { * 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. Skips the plug when any ASSET leg still needsMark (no silent plug without a mark, - * §5.1 Stufe 3 / §4.9) — mark-to-market revalues then. Sub-cent rest → ROUNDING (booking service). + * §5.1 stage 3 / §4.9) — mark-to-market revalues then. Sub-cent rest → ROUNDING (booking service). */ private async appendArbitragePlug(legs: LedgerLegInput[]): Promise { if (legs.some((l) => l.needsMark)) return; diff --git a/src/subdomains/core/accounting/services/ledger-bootstrap.service.ts b/src/subdomains/core/accounting/services/ledger-bootstrap.service.ts index 981f69d3a5..08b9419534 100644 --- a/src/subdomains/core/accounting/services/ledger-bootstrap.service.ts +++ b/src/subdomains/core/accounting/services/ledger-bootstrap.service.ts @@ -55,7 +55,7 @@ export class LedgerBootstrapService { private static readonly EQUITY_ACCOUNTS = ['opening-balance', 'retained-earnings']; - // §3.3 — canonical direction-neutral TRANSIT routes (vorab-fixliste, line 266) + // §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' }, diff --git a/src/subdomains/core/accounting/services/ledger-cutover.service.ts b/src/subdomains/core/accounting/services/ledger-cutover.service.ts index 143be52a0a..30af4ac190 100644 --- a/src/subdomains/core/accounting/services/ledger-cutover.service.ts +++ b/src/subdomains/core/accounting/services/ledger-cutover.service.ts @@ -92,20 +92,15 @@ export class LedgerCutoverService { async run(): Promise { if ((await this.settingService.get(CUTOVER_LOG_ID_KEY)) != null) return; // primary guard: already cut over → no-op - try { - await this.cutover(); - } catch (e) { - // failure-isolation (§6.3): caught + logged, never hard-aborts; flag stays unset → consumers no-op, retry next run - this.logger.error('Ledger cutover failed', e); - } + await this.cutover(); } - // gelockter Cutover-Run, fixed order (§6.3 Blocker R1-6/R3-1) + // 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 ≤ Stichtag (now), PINNED on first run so a crash-then-retry + // (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'); @@ -166,7 +161,7 @@ export class LedgerCutoverService { return repinned != null && +repinned !== snapshot.id ? this.logService.getLog(+repinned) : snapshot; } - // §6.3: newest valid=true FinancialDataLog ≤ Stichtag. Bounded read (last 2 days) then pick latest ≤ now. + // §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)); @@ -218,7 +213,7 @@ export class LedgerCutoverService { amount: native, priceChf: priceChf ?? null, amountChf, - needsMark: amountChf == null, // dauerhaft feedlos → nativ, mark-to-market bewertet nach (§5.1 Stufe 3) + needsMark: amountChf == null, // permanently feedless → native, mark-to-market revalues later (§5.1 stage 3) }, equity, ); @@ -372,7 +367,7 @@ export class LedgerCutoverService { liability, amountChf, equity, - mark == null, // feedless outputAsset → needsMark, mark-to-market values it later (§5.1 Stufe 3) + mark == null, // feedless outputAsset → needsMark, mark-to-market values it later (§5.1 stage 3) ); } } @@ -489,7 +484,7 @@ export class LedgerCutoverService { `Opening unattributed from open bank_tx credits as of FinancialDataLog #${snapshot.id}`, liability, // a feedless/unmatched credit leaves the aggregate unvaluable → carry amountChf=undefined (needsMark) so the - // mark-to-market job values the whole bucket later (§5.1 Stufe 3); never a partial/-0 phantom CHF on the leg. + // mark-to-market job values the whole bucket later (§5.1 stage 3); never a partial/-0 phantom CHF on the leg. needsMark ? undefined : Util.round(amountChf, 2), equity, needsMark, 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 index 83720c2513..01493a76b5 100644 --- a/src/subdomains/core/accounting/services/ledger-mark-to-market.service.ts +++ b/src/subdomains/core/accounting/services/ledger-mark-to-market.service.ts @@ -29,7 +29,7 @@ interface AccountBalance { * 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 tagesaktuell + * 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). */ @@ -51,11 +51,7 @@ export class LedgerMarkToMarketService { async run(): Promise { if (!(await this.jobService.isLedgerReady())) return; // cutover-gate (Blocker R1-6) applies here too - try { - await this.markToMarket(); - } catch (e) { - this.logger.error('Ledger mark-to-market failed', e); - } + await this.markToMarket(); } private async markToMarket(): Promise { @@ -78,7 +74,7 @@ export class LedgerMarkToMarketService { try { await this.revalue(account, marks, now, dayIndex, fx); } catch (e) { - this.logger.error(`Failed to mark-to-market ledger account ${account.id}`, 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) } } diff --git a/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts b/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts index 9762373b97..a043cd1669 100644 --- a/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts +++ b/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts @@ -30,21 +30,21 @@ const PLACEHOLDER_AMOUNT = 1.0; // Scrypt/EUR, Base/ZCHF placeholder feed → ne const EQUITY_PARITY_MEDIAN_SAMPLE = 5; export enum FeedStatus { - PLACEHOLDER = 'placeholder', - FRESH = 'fresh', - STALE = 'stale', - NO_FEED = 'no-feed', + PLACEHOLDER = 'Placeholder', + FRESH = 'Fresh', + STALE = 'Stale', + NO_FEED = 'NoFeed', } // §7.1 custody classification → staleness threshold (hours) enum CustodyClass { - BANK_ACTIVE = 'bank-active', - BANK_DEAD = 'bank-dead', - ON_CHAIN_ACTIVE = 'on-chain-active', - ON_CHAIN_INACTIVE = 'on-chain-inactive', - EXCHANGE_ACTIVE = 'exchange-active', - EXCHANGE_ORDER_DRIVEN = 'exchange-order-driven', - EXCHANGE_FEEDLESS = 'exchange-feedless', + BANK_ACTIVE = 'BankActive', + BANK_DEAD = 'BankDead', + ON_CHAIN_ACTIVE = 'OnChainActive', + ON_CHAIN_INACTIVE = 'OnChainInactive', + EXCHANGE_ACTIVE = 'ExchangeActive', + EXCHANGE_ORDER_DRIVEN = 'ExchangeOrderDriven', + EXCHANGE_FEEDLESS = 'ExchangeFeedless', } const STALENESS_THRESHOLD_HOURS: Record = { @@ -68,7 +68,7 @@ export interface FeedClassification { * 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 tagesaktuell + * 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. */ @@ -92,11 +92,7 @@ export class LedgerReconciliationService { async run(): Promise { if (!(await this.jobService.isLedgerReady())) return; // cutover-gate (Blocker R1-6) - try { - await this.reconcile(); - } catch (e) { - this.logger.error('Ledger reconciliation failed', e); - } + await this.reconcile(); } private async reconcile(): Promise { From 2fed1fa9b1ed375e672a7559ca62c3161569c71a Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:49:44 +0200 Subject: [PATCH 12/25] fix(accounting): second review pass (forward-scan lookback, reversal walk, recon relations) - forward scans preload marks with the same 2-day lookback as the content-change path; the earliest batch row previously fell exactly on the window start and could never be marked, wedging the consumer - activeTx walks the reversal chain via the loaded reversalOfId scalar and requires the re-book at reversal.seq + 1; the unloaded relation made the null-check vacuous and a seq gap could pick a foreign tx as active - reconcileAssets loads asset.bank so custody classification stops falling back to the on-chain default for every account (bank accounts were marked stale over weekends and their diff reconciliation silently skipped) - conventions: import order, section comment style, dead snapshot parameter, bank lookup and native-balance aggregation without N+1 --- .../core/accounting/accounting.module.ts | 2 +- .../staleness-cutover.integration.spec.ts | 8 +- .../__tests__/ledger-booking.service.spec.ts | 55 +++++++++++--- .../__tests__/ledger-cutover.service.spec.ts | 42 +++++++---- .../__tests__/ledger-query.service.spec.ts | 47 ++++++++++-- .../ledger-reconciliation.service.spec.ts | 61 +++++++++++++++- .../__tests__/bank-tx.consumer.spec.ts | 35 +++++++++ .../services/consumers/bank-tx.consumer.ts | 6 +- .../services/consumers/buy-fiat.consumer.ts | 11 +-- .../consumers/crypto-input.consumer.ts | 6 +- .../consumers/exchange-tx.consumer.ts | 10 ++- .../consumers/ledger-watermark.helper.ts | 2 +- .../consumers/liquidity-mgmt.consumer.ts | 6 +- .../consumers/liquidity-order-dex.consumer.ts | 6 +- .../consumers/payout-order.consumer.ts | 6 +- .../consumers/trading-order.consumer.ts | 6 +- .../services/ledger-booking.service.ts | 23 +++--- .../services/ledger-bootstrap.service.ts | 2 +- .../services/ledger-cutover.service.ts | 73 ++++++++++++------- .../services/ledger-query.service.ts | 17 +++-- .../services/ledger-reconciliation.service.ts | 37 +++++++--- 21 files changed, 353 insertions(+), 108 deletions(-) diff --git a/src/subdomains/core/accounting/accounting.module.ts b/src/subdomains/core/accounting/accounting.module.ts index 02292520b3..df6f6ab4e8 100644 --- a/src/subdomains/core/accounting/accounting.module.ts +++ b/src/subdomains/core/accounting/accounting.module.ts @@ -10,10 +10,10 @@ 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 { 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 { 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'; 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 index 999d32a77d..41047aa1b2 100644 --- 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 @@ -58,7 +58,7 @@ describe('Ledger staleness + cutover integration (§10.2)', () => { let ledgerLegRepository: LedgerLegRepository; let mails: MailRequest[]; - let journalNative: string; // the stubbed journal native balance (journalNativeBalance getRawOne) + let journalNative: string; // the stubbed journal native balance (fed through the nativeBalanceByAccount map, m8) function assetAccount(assetId: number, asset: Partial): LedgerAccount { return createCustomLedgerAccount({ @@ -129,6 +129,12 @@ describe('Ledger staleness + cutover integration (§10.2)', () => { }).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 () => { 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 index 33de45dce7..07f5c7d676 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-booking.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-booking.service.spec.ts @@ -413,15 +413,15 @@ describe('LedgerBookingService', () => { expect(await service.hasActiveTxAt('bank_tx', '900', 5)).toBe(false); }); - // §4.12 activeTx re-book selection (line 192): when MORE THAN ONE non-reversal booking sits above the reversal's - // seq, the walk must pick the one with the SMALLEST seq via `.sort((a,b) => a.seq - b.seq)[0]`. A single candidate - // never invokes the comparator; this seeds two candidates (1_000_001 and 1_000_002) above the reversal so the - // comparator actually runs and the EARLIEST corrected re-book (1_000_001) is chosen as the chain's next link. - it('picks the smallest-seq re-book when several non-reversal tx sit above the reversal (sort comparator)', async () => { + // §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 TWO independent non-reversal re-books above it (out of seq order on purpose) + // 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', @@ -434,7 +434,7 @@ describe('LedgerBookingService', () => { id: nextId++, sourceType: 'bank_tx', sourceId: '900', - seq: 1_000_002, // larger seq, inserted first → comparator must still pick the smaller one + 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 }, @@ -444,7 +444,7 @@ describe('LedgerBookingService', () => { id: nextId++, sourceType: 'bank_tx', sourceId: '900', - seq: 1_000_001, // the SMALLEST seq above the reversal → the chain's next link + 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 }, @@ -452,14 +452,47 @@ describe('LedgerBookingService', () => { }); txStore.push(reversal, rebookLater, rebookEarliest); - // a re-scan with the SAME value as the earliest re-book (50000) → the walk resolves the active tx to the - // smallest-seq re-book (1_000_001), finds it unchanged → no-op (false). A broken comparator would resolve to - // 1_000_002 (60000), see a diff, and wrongly book another reversal. + // 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` 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 index 2f20f8ab7c..7cfaadb34f 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts @@ -135,7 +135,9 @@ describe('LedgerCutoverService', () => { jest.spyOn(bankTxRepo, 'find').mockResolvedValue([]); jest.spyOn(bankTxReturnRepo, 'find').mockResolvedValue([]); jest.spyOn(bankTxRepeatRepo, 'find').mockResolvedValue([]); - jest.spyOn(bankRepo, 'findOne').mockResolvedValue(null); + // 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())); @@ -366,8 +368,10 @@ describe('LedgerCutoverService', () => { new LedgerMarkCache(new Map([[269, [{ created: new Date('2026-06-01'), priceChf: 0.95 }]]])), ); jest - .spyOn(bankRepo, 'findOne') - .mockResolvedValue(Object.assign(new Bank(), { name: 'Olkypay', currency: 'EUR', asset: { id: 269 } }) as any); + .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' }), @@ -388,8 +392,10 @@ describe('LedgerCutoverService', () => { it('opens bankTx-repeat per row at mark 1 for a CHF bank', async () => { jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); jest - .spyOn(bankRepo, 'findOne') - .mockResolvedValue(Object.assign(new Bank(), { name: 'Yapeal', currency: 'CHF', asset: { id: 100 } }) as any); + .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' }), @@ -414,8 +420,10 @@ describe('LedgerCutoverService', () => { new LedgerMarkCache(new Map([[269, [{ created: new Date('2026-06-01'), priceChf: 0.95 }]]])), ); jest - .spyOn(bankRepo, 'findOne') - .mockResolvedValue(Object.assign(new Bank(), { name: 'Olkypay', currency: 'EUR', asset: { id: 269 } }) as any); + .spyOn(bankRepo, 'find') + .mockResolvedValue([ + Object.assign(new Bank(), { iban: 'EUR-IBAN', name: 'Olkypay', currency: 'EUR', asset: { id: 269 } }), + ] as any); // first find() = typed credits (GSheet/Pending/Unknown), second find() = NULL-type credits jest .spyOn(bankTxRepo, 'find') @@ -803,8 +811,10 @@ describe('LedgerCutoverService', () => { 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 → needsMark jest - .spyOn(bankRepo, 'findOne') - .mockResolvedValue(Object.assign(new Bank(), { name: 'Olkypay', currency: 'EUR', asset: { id: 269 } }) as any); + .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([ @@ -837,7 +847,7 @@ describe('LedgerCutoverService', () => { 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, 'findOne').mockResolvedValue(null); // no Bank row, but the tx itself is CHF → mark 1 + jest.spyOn(bankRepo, 'find').mockResolvedValue([]); // no Bank row, but the tx itself is CHF → mark 1 jest .spyOn(bankTxRepo, 'find') .mockResolvedValueOnce([ @@ -964,8 +974,10 @@ describe('LedgerCutoverService', () => { jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); jest.spyOn(markService, 'preload').mockResolvedValue(new LedgerMarkCache(new Map())); // no EUR mark jest - .spyOn(bankRepo, 'findOne') - .mockResolvedValue(Object.assign(new Bank(), { name: 'Olkypay', currency: 'EUR', asset: { id: 269 } }) as any); + .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' }), @@ -1007,8 +1019,10 @@ describe('LedgerCutoverService', () => { jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); jest.spyOn(markService, 'preload').mockResolvedValue(new LedgerMarkCache(new Map())); jest - .spyOn(bankRepo, 'findOne') - .mockResolvedValue(Object.assign(new Bank(), { name: 'Olkypay', currency: 'EUR', asset: undefined }) as any); + .spyOn(bankRepo, 'find') + .mockResolvedValue([ + Object.assign(new Bank(), { iban: 'EUR-IBAN', name: 'Olkypay', currency: 'EUR', asset: undefined }), + ] as any); jest .spyOn(bankTxRepo, 'find') .mockResolvedValueOnce([ 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 index 5bdd327fb2..600761c0c6 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-query.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-query.service.spec.ts @@ -298,9 +298,14 @@ describe('LedgerQueryService', () => { status: balance.asset.id === 12 ? FeedStatus.STALE : FeedStatus.FRESH, } as any; }); - // journalNativeBalance getRawOne → 100 for all (fresh: diff 0 → ok) - qbStub.rawOne = {}; - jest.spyOn(service as any, 'journalNativeBalance').mockResolvedValue(100); + // 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(); @@ -338,7 +343,7 @@ describe('LedgerQueryService', () => { .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, 'journalNativeBalance').mockResolvedValue(0); + jest.spyOn(service as any, 'nativeBalanceByAccount').mockResolvedValue(new Map([[4, 0]])); const res = await service.getReconStatus(); @@ -662,7 +667,7 @@ describe('LedgerQueryService', () => { // --- 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, journalNativeBalance, journalEquityAt, transitPhantom, staleFeed, + // 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 @@ -725,21 +730,47 @@ describe('LedgerQueryService', () => { expect(res.closingBalance).toBe(100); }); - it('getReconStatus uses the real journalNativeBalance (null getRawOne → ledger balance 0)', async () => { + 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'))]); - // journalNativeBalance getRawOne → null → 0; feed amount 0 → diff 0 → ok (fresh by default mock) + // 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); // SUM(amount) null → 0 + expect(res.accounts[0].ledgerBalance).toBe(0); // account absent from the GROUP-BY map → 0 expect(res.accounts[0].status).toBe('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. 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 index 9ac9844875..a8617e10b9 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-reconciliation.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-reconciliation.service.spec.ts @@ -25,7 +25,7 @@ import { LedgerMarkService } from '../ledger-mark.service'; import { FeedStatus, LedgerReconciliationService } from '../ledger-reconciliation.service'; interface LegQueryStub { - native?: string; // journalNativeBalance getRawOne + native?: string; // journal native balance per account (fed through the nativeBalanceByAccount map, §7.0 m8) equityChf?: string; // journalEquity getRawOne transit?: { name: string; native: string; oldest: Date }[]; suspense?: { name: string; chf: string }[]; @@ -46,6 +46,7 @@ describe('LedgerReconciliationService', () => { let mails: MailRequest[]; let legStub: LegQueryStub; + let nativeBalanceSpy: jest.SpyInstance; function assetAccount(assetId: number, asset?: Partial): LedgerAccount { return createCustomLedgerAccount({ @@ -159,6 +160,13 @@ describe('LedgerReconciliationService', () => { }).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', () => { @@ -355,6 +363,57 @@ describe('LedgerReconciliationService', () => { 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)', () => { 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 index 8735c32804..abd22fd88d 100644 --- 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 @@ -898,6 +898,41 @@ describe('BankTxConsumer', () => { 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 () => { diff --git a/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts b/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts index 24d5a47357..f346642fce 100644 --- a/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts @@ -5,10 +5,10 @@ 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 { 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 { 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'; @@ -109,7 +109,9 @@ export class BankTxConsumer { }); if (!batch.length) return; - const from = Util.minObj(batch, 'created').created; + // 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); diff --git a/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts b/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts index c40aeb110e..962d77081d 100644 --- a/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts @@ -116,7 +116,8 @@ export class BuyFiatConsumer { private async preloadMarks(batch: BuyFiat[]): Promise { const dates = batch.flatMap((bf) => [bf.updated, bf.fiatOutput?.bankTx?.bookingDate].filter((d): d is Date => !!d)); const times = dates.map((d) => d.getTime()); - return this.markService.preload(new Date(Math.min(...times)), new Date(Math.max(...times))); + // 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 @@ -124,7 +125,7 @@ export class BuyFiatConsumer { return bf.cryptoInput?.paymentLinkPayment ? this.bookPaymentLink(bf, marks) : this.bookRegular(bf, marks); } - // === (I) REGULAR SELL (§4.7 / §4.7a) === // + // --- (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 @@ -228,7 +229,7 @@ export class BuyFiatConsumer { }); } - // === (II) PAYMENTLINK MERCHANT PAYOUT (§4.7b) === // + // --- (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 @@ -305,7 +306,7 @@ export class BuyFiatConsumer { }); } - // === SHARED HELPERS === // + // --- 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 { @@ -371,7 +372,7 @@ export class BuyFiatConsumer { return bf.outputAsset?.name ?? bf.fiatOutput?.currency ?? CHF; } - // === GATE (§4.7 G-a/G-b) === // + // --- 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 { diff --git a/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts b/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts index 8d11ce13d7..6965811025 100644 --- a/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts @@ -75,7 +75,11 @@ export class CryptoInputConsumer { if (!batch.length) return; const times = batch.map((ci) => ci.updated.getTime()); - const marks = await this.markService.preload(new Date(Math.min(...times)), new Date(Math.max(...times))); + 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) { diff --git a/src/subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts b/src/subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts index 054b5f2452..c6cbc7e6c4 100644 --- a/src/subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts @@ -1,11 +1,11 @@ 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 { ExchangeName } from 'src/integration/exchange/enums/exchange.enum'; -import { ExchangeTx, ExchangeTxType } from 'src/integration/exchange/entities/exchange-tx.entity'; 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'; @@ -77,7 +77,11 @@ export class ExchangeTxConsumer { if (!batch.length) return; const times = batch.map((tx) => (tx.externalCreated ?? tx.created).getTime()); - const marks = await this.markService.preload(new Date(Math.min(...times)), new Date(Math.max(...times))); + 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; diff --git a/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts b/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts index 507ec25866..84b151fa18 100644 --- a/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts +++ b/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts @@ -1,5 +1,5 @@ -import { SettingService } from 'src/shared/models/setting/setting.service'; import { Config } from 'src/config/config'; +import { SettingService } from 'src/shared/models/setting/setting.service'; import { DfxLogger } from 'src/shared/services/dfx-logger'; import { Raw, Repository } from 'typeorm'; diff --git a/src/subdomains/core/accounting/services/consumers/liquidity-mgmt.consumer.ts b/src/subdomains/core/accounting/services/consumers/liquidity-mgmt.consumer.ts index 32d1333130..0d3e774818 100644 --- a/src/subdomains/core/accounting/services/consumers/liquidity-mgmt.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/liquidity-mgmt.consumer.ts @@ -61,7 +61,11 @@ export class LiquidityMgmtConsumer { if (!batch.length) return; const times = batch.map((o) => o.updated.getTime()); - const marks = await this.markService.preload(new Date(Math.min(...times)), new Date(Math.max(...times))); + 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 order of batch) { 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 index 583c6367ea..6a51483ca4 100644 --- a/src/subdomains/core/accounting/services/consumers/liquidity-order-dex.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/liquidity-order-dex.consumer.ts @@ -77,7 +77,11 @@ export class LiquidityOrderDexConsumer { if (!batch.length) return; const times = batch.map((o) => o.updated.getTime()); - const marks = await this.markService.preload(new Date(Math.min(...times)), new Date(Math.max(...times))); + 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 order of batch) { diff --git a/src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts b/src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts index d573276ebd..b591f0be8b 100644 --- a/src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts @@ -89,7 +89,11 @@ export class PayoutOrderConsumer { if (!batch.length) return; const times = batch.map((o) => o.updated.getTime()); - const marks = await this.markService.preload(new Date(Math.min(...times)), new Date(Math.max(...times))); + 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 order of batch) { diff --git a/src/subdomains/core/accounting/services/consumers/trading-order.consumer.ts b/src/subdomains/core/accounting/services/consumers/trading-order.consumer.ts index 80f0afae02..25eece596b 100644 --- a/src/subdomains/core/accounting/services/consumers/trading-order.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/trading-order.consumer.ts @@ -55,7 +55,11 @@ export class TradingOrderConsumer { if (!batch.length) return; const times = batch.map((o) => o.updated.getTime()); - const marks = await this.markService.preload(new Date(Math.min(...times)), new Date(Math.max(...times))); + 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 order of batch) { diff --git a/src/subdomains/core/accounting/services/ledger-booking.service.ts b/src/subdomains/core/accounting/services/ledger-booking.service.ts index 04a68c1b68..99046d1fe5 100644 --- a/src/subdomains/core/accounting/services/ledger-booking.service.ts +++ b/src/subdomains/core/accounting/services/ledger-booking.service.ts @@ -165,11 +165,12 @@ export class LedgerBookingService { * 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, reversalOf NULL). If it is reversed (some reversal's reversalOf - * points at it), its corrected re-book is the booking (reversalOf NULL) with the SMALLEST seq strictly above the - * reversal's seq (§4.12 Z.809 order: reversal at seq=N, re-book at seq=N+1); advance to it and repeat. When the - * current booking is NOT reversed it is the live correction → return it. A flat reversal (reversed, no re-book) - * returns undefined (= nothing booked now). + * 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({ @@ -179,18 +180,18 @@ export class LedgerBookingService { }); if (!all.length) return undefined; - let current = all.find((tx) => tx.seq === originalSeq && tx.reversalOf == null); + 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 = first real booking (reversalOf NULL) with seq strictly above the reversal's seq - const rebook = all - .filter((tx) => tx.reversalOf == null && tx.seq > reversal.seq) - .sort((a, b) => a.seq - b.seq)[0]; - if (!rebook) return undefined; // flat reversal (no re-book) → nothing booked now + // 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; } } diff --git a/src/subdomains/core/accounting/services/ledger-bootstrap.service.ts b/src/subdomains/core/accounting/services/ledger-bootstrap.service.ts index 08b9419534..ffbcba07ef 100644 --- a/src/subdomains/core/accounting/services/ledger-bootstrap.service.ts +++ b/src/subdomains/core/accounting/services/ledger-bootstrap.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; -import { LiquidityManagementBalanceService } from 'src/subdomains/core/liquidity-management/services/liquidity-management-balance.service'; 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'; diff --git a/src/subdomains/core/accounting/services/ledger-cutover.service.ts b/src/subdomains/core/accounting/services/ledger-cutover.service.ts index 30af4ac190..e132532ab6 100644 --- a/src/subdomains/core/accounting/services/ledger-cutover.service.ts +++ b/src/subdomains/core/accounting/services/ledger-cutover.service.ts @@ -1,6 +1,8 @@ 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'; @@ -8,7 +10,6 @@ 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 { BuyFiat } from 'src/subdomains/core/sell-crypto/process/buy-fiat.entity'; -import { ExchangeTx } from 'src/integration/exchange/entities/exchange-tx.entity'; import { LiquidityManagementOrder } from 'src/subdomains/core/liquidity-management/entities/liquidity-management-order.entity'; import { LiquidityManagementOrderStatus } from 'src/subdomains/core/liquidity-management/enums'; import { TradingOrder } from 'src/subdomains/core/trading/entities/trading-order.entity'; @@ -21,11 +22,10 @@ import { 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 { Asset } from 'src/shared/models/asset/asset.entity'; -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 { Bank } from 'src/subdomains/supporting/bank/bank/bank.entity'; 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'; @@ -203,7 +203,6 @@ export class LedgerCutoverService { const amountChf = priceChf != null ? Util.round(priceChf * native, 2) : undefined; await this.bookOpening( - snapshot, seq++, `${snapshot.id}`, `Opening balance from FinancialDataLog #${snapshot.id}`, @@ -245,6 +244,9 @@ export class LedgerCutoverService { 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(); await this.openBuyFiatReceived(snapshot, snapshotDate, lookback, equity); await this.openBuyFiatOwed(snapshot, snapshotDate, lookback, marks, equity); @@ -253,9 +255,9 @@ export class LedgerCutoverService { // §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); - await this.openBankTxRepeat(snapshot, snapshotDate, lookback, marks, equity); - await this.openUnattributed(snapshot, snapshotDate, lookback, marks, equity); + 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) @@ -268,7 +270,6 @@ export class LedgerCutoverService { for (const row of rows) { if (row.amountInChf == null) continue; await this.bookReceivedOwedOpening( - snapshot, date, `${snapshot.id}:buy_fiat:${row.id}`, `Opening buyFiat-received from open buy_fiat #${row.id}`, @@ -302,7 +303,6 @@ export class LedgerCutoverService { const amountChf = Util.round(row.outputAmount * fiatMark, 2); await this.bookReceivedOwedOpening( - snapshot, date, `${snapshot.id}:buy_fiat-owed:${row.id}`, `Opening buyFiat-owed from open buy_fiat #${row.id}`, @@ -328,7 +328,6 @@ export class LedgerCutoverService { for (const row of rows) { if (row.amountInChf == null) continue; await this.bookReceivedOwedOpening( - snapshot, date, `${snapshot.id}:buy_crypto:${row.id}`, `Opening buyCrypto-received from open buy_crypto #${row.id}`, @@ -360,7 +359,6 @@ export class LedgerCutoverService { const amountChf = mark != null ? Util.round(row.outputAmount * mark, 2) : undefined; await this.bookReceivedOwedOpening( - snapshot, date, `${snapshot.id}:buy_crypto-owed:${row.id}`, `Opening buyCrypto-owed from open buy_crypto #${row.id}`, @@ -385,6 +383,7 @@ export class LedgerCutoverService { lookback: Date, marks: LedgerMarkCache, equity: LedgerAccount, + bankByIban: Map, ): Promise { const rows = await this.bankTxReturnRepo.find({ where: { chargebackBankTx: IsNull(), created: Between(lookback, date) }, @@ -393,7 +392,16 @@ export class LedgerCutoverService { 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); + await this.openOpenLiabilityRow( + snapshot, + date, + marks, + equity, + liability, + 'bank_tx-return', + row.bankTx, + bankByIban, + ); } } @@ -404,6 +412,7 @@ export class LedgerCutoverService { lookback: Date, marks: LedgerMarkCache, equity: LedgerAccount, + bankByIban: Map, ): Promise { const rows = await this.bankTxRepeatRepo.find({ where: { chargebackBankTx: IsNull(), created: Between(lookback, date) }, @@ -412,7 +421,16 @@ export class LedgerCutoverService { 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); + await this.openOpenLiabilityRow( + snapshot, + date, + marks, + equity, + liability, + 'bank_tx-repeat', + row.bankTx, + bankByIban, + ); } } @@ -426,14 +444,14 @@ export class LedgerCutoverService { 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 } = await this.bankMark(bankTx, date, marks); + const { mark } = this.bankMark(bankTx, date, marks, bankByIban); const amountChf = mark != null ? Util.round(bankTx.amount * mark, 2) : undefined; await this.bookReceivedOwedOpening( - snapshot, date, `${snapshot.id}:${marker}:${bankTx.id}`, `Opening ${marker} from open bank_tx #${bankTx.id}`, @@ -454,6 +472,7 @@ export class LedgerCutoverService { lookback: Date, marks: LedgerMarkCache, equity: LedgerAccount, + bankByIban: Map, ): Promise { // §6.1: type NULL/Pending/Unknown/GSheet credits → the unattributed bucket (two where-branches for the NULL type) const credit = { creditDebitIndicator: BankTxIndicator.CREDIT, created: Between(lookback, date) }; @@ -466,7 +485,7 @@ export class LedgerCutoverService { let needsMark = false; for (const row of rows) { if (row.amount == null) continue; - const { mark } = await this.bankMark(row, date, marks); + 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; @@ -478,7 +497,6 @@ export class LedgerCutoverService { const liability = await this.liability('unattributed'); await this.bookReceivedOwedOpening( - snapshot, date, `${snapshot.id}:unattributed`, `Opening unattributed from open bank_tx credits as of FinancialDataLog #${snapshot.id}`, @@ -491,17 +509,22 @@ export class LedgerCutoverService { ); } + // 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 async bankMark( + private bankMark( bankTx: BankTx, date: Date, marks: LedgerMarkCache, - ): Promise<{ asset?: Asset; mark: number | undefined }> { - const bank = bankTx.accountIban - ? await this.bankRepo.findOne({ where: { iban: bankTx.accountIban }, relations: { asset: true } }) - : null; - + 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 }; @@ -533,7 +556,6 @@ export class LedgerCutoverService { const amountChf = priceChf != null ? Util.round(priceChf * position.value, 2) : undefined; await this.bookOpening( - snapshot, seq++, `${snapshot.id}:manual-debt:${position.assetId}`, `Opening manual-debt for asset #${position.assetId} from FinancialDataLog #${snapshot.id}`, @@ -663,7 +685,6 @@ export class LedgerCutoverService { // a single 2-leg opening tx (account leg + EQUITY counter-leg) → balances by construction in CHF (§6.2) private async bookOpening( - snapshot: Log, seq: number, sourceId: string, description: string, @@ -696,7 +717,6 @@ export class LedgerCutoverService { // per-row received/owed opening (seq=0): Cr LIABILITY/{…} / Dr EQUITY/opening-balance, CHF-valued (§6.3 R4-2/R6-1) private async bookReceivedOwedOpening( - snapshot: Log, bookingDate: Date, sourceId: string, description: string, @@ -706,7 +726,6 @@ export class LedgerCutoverService { needsMark = false, ): Promise { await this.bookOpening( - snapshot, 0, sourceId, description, diff --git a/src/subdomains/core/accounting/services/ledger-query.service.ts b/src/subdomains/core/accounting/services/ledger-query.service.ts index 27a29e4053..28f5d01461 100644 --- a/src/subdomains/core/accounting/services/ledger-query.service.ts +++ b/src/subdomains/core/accounting/services/ledger-query.service.ts @@ -158,12 +158,13 @@ export class LedgerQueryService { relations: { asset: { bank: true } }, }); const feedByAssetId = await this.feedByAssetId(); + const balances = await this.nativeBalanceByAccount(); const results: AccountReconResultDto[] = []; for (const account of accounts) { if (account.assetId == null) continue; - const ledgerBalance = await this.journalNativeBalance(account.id); + const ledgerBalance = balances.get(account.id) ?? 0; const result = this.reconResult(account, ledgerBalance, feedByAssetId, now); results.push(LedgerDtoMapper.mapReconResult(result)); } @@ -314,14 +315,18 @@ export class LedgerQueryService { return Util.round(+(raw?.native ?? 0), 8); } - private async journalNativeBalance(accountId: number): Promise { + // 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('SUM(leg.amount)', 'native') - .where('leg.accountId = :accountId', { accountId }) - .getRawOne<{ native: string | null }>(); + .select('leg.accountId', 'accountId') + .addSelect('SUM(leg.amount)', 'native') + .groupBy('leg.accountId') + .getRawMany<{ accountId: number; native: string }>(); - return Util.round(+(raw?.native ?? 0), 8); + 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 diff --git a/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts b/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts index a043cd1669..9a0f0e4def 100644 --- a/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts +++ b/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts @@ -1,8 +1,8 @@ import { Injectable } from '@nestjs/common'; import { CronExpression } from '@nestjs/schedule'; import { Config } from 'src/config/config'; -import { Asset } from 'src/shared/models/asset/asset.entity'; 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'; @@ -11,8 +11,8 @@ 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 { LogService } from 'src/subdomains/supporting/log/log.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'; @@ -105,7 +105,11 @@ export class LedgerReconciliationService { // native journal↔feed diff in CHF before comparing against the CHF tolerance (see reconcileFreshAsset). const marks = await this.markService.preload(Util.daysBefore(2, now), now); - await this.reconcileAssets(feed, marks, now); + // §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 balances = await this.nativeBalanceByAccount(); + + await this.reconcileAssets(feed, marks, balances, now); await this.checkTransitAge(now); await this.checkSuspense(); await this.checkEquityParity(now); @@ -113,7 +117,12 @@ export class LedgerReconciliationService { // --- ASSET RECONCILIATION (§7.1/§7.2/§7.3) --- // - private async reconcileAssets(feed: LiquidityBalance[], marks: LedgerMarkCache, now: Date): Promise { + 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[] = []; @@ -127,6 +136,7 @@ export class LedgerReconciliationService { 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, }); @@ -159,7 +169,7 @@ export class LedgerReconciliationService { continue; } - await this.reconcileFreshAsset(account, balance, mark, now); + await this.reconcileFreshAsset(account, balance, mark, balances, now); } lastId = assetAccounts[assetAccounts.length - 1].id; @@ -221,9 +231,10 @@ export class LedgerReconciliationService { account: LedgerAccount, balance: LiquidityBalance, mark: number, + balances: Map, now: Date, ): Promise { - const journal = await this.journalNativeBalance(account.id); + 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); @@ -382,14 +393,18 @@ export class LedgerReconciliationService { // --- HELPERS --- // - private async journalNativeBalance(accountId: number): Promise { + // 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('SUM(leg.amount)', 'native') - .where('leg.accountId = :accountId', { accountId }) - .getRawOne<{ native: string | null }>(); + .select('leg.accountId', 'accountId') + .addSelect('SUM(leg.amount)', 'native') + .groupBy('leg.accountId') + .getRawMany<{ accountId: number; native: string }>(); - return Util.round(+(raw?.native ?? 0), 8); + 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). From 764a2deb7ace4a72dc726db8198fab4eb59f9338 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:07:46 +0200 Subject: [PATCH 13/25] fix(accounting): third review pass (bank-funded completion gate, atomic rebook, dto enums) - receivedOpened now recognizes bank-funded buy-cryptos (gates on the bank_tx opening booking); a post-cutover bank-funded completion was silently skipped in the content-change scan and wedged the forward scan - the content-change callback no longer ignores a gate-blocked completion - it throws like buy-fiat so the cursor retries instead of skipping the row - reverseAndRebookIfChanged wraps reversal and re-book in one transaction; a crash between the two left a flat-reversal state that unique-collided on every retry - admin recon status marks the native diff to CHF before the tolerance check, matching the reconciliation job; feedless accounts report as unverified - conventions: DTO status unions become PascalCase enums (endpoints ship with this PR, no consumer exists), cutover import order fixed - test substance: exact spread-leg amounts, real re-run idempotency case, discriminating ambiguous-sweep assertions --- .../dto/__tests__/ledger-dto.mapper.spec.ts | 20 ++-- .../dto/__tests__/ledger-dto.shapes.spec.ts | 13 ++- .../core/accounting/dto/ledger-account.dto.ts | 8 +- .../dto/ledger-reconciliation.dto.ts | 15 ++- .../__tests__/integration/in-memory-ledger.ts | 4 + .../__tests__/ledger-booking.service.spec.ts | 68 ++++++++++++ .../__tests__/ledger-query.service.spec.ts | 62 ++++++++--- .../__tests__/buy-crypto.consumer.spec.ts | 66 +++++++++++ .../__tests__/exchange-tx.consumer.spec.ts | 61 ++++++++++- .../services/consumers/buy-crypto.consumer.ts | 18 ++- .../services/ledger-booking.service.ts | 103 ++++++++++++------ .../services/ledger-cutover.service.ts | 12 +- .../services/ledger-query.service.ts | 69 +++++++++--- 13 files changed, 427 insertions(+), 92 deletions(-) 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 index 5edd07f79e..506430c499 100644 --- a/src/subdomains/core/accounting/dto/__tests__/ledger-dto.mapper.spec.ts +++ b/src/subdomains/core/accounting/dto/__tests__/ledger-dto.mapper.spec.ts @@ -3,6 +3,7 @@ import { createCustomLedgerLeg } from '../../entities/__mocks__/ledger-leg.entit 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, @@ -10,6 +11,7 @@ import { LedgerDtoMapper, SuspenseLegRow, } from '../ledger-dto.mapper'; +import { LedgerFeedStaleness, LedgerReconResultStatus } from '../ledger-reconciliation.dto'; function tx(custom: Partial): LedgerTx { return Object.assign(new LedgerTx(), { @@ -67,14 +69,14 @@ describe('LedgerDtoMapper', () => { it('attaches the recon snapshot for an ASSET account', () => { const balance: AccountBalance = { account, balanceNative: 1000, balanceChf: 950 }; const recon: AccountReconSnapshot = { - reconStatus: 'ok', + 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('ok'); + expect(dto.reconStatus).toBe(LedgerReconStatus.OK); expect(dto.reconDiff).toBe(0.4); expect(dto.lastVerified).toBe('2026-06-10T05:00:00.000Z'); }); @@ -122,8 +124,8 @@ describe('LedgerDtoMapper', () => { difference: 0.5, feedTimestamp: new Date('2026-06-10T04:00:00.000Z'), feedAge: 2, - staleness: 'fresh', - status: 'diff', + staleness: LedgerFeedStaleness.FRESH, + status: LedgerReconResultStatus.DIFF, }; const dto = LedgerDtoMapper.mapReconResult(result); @@ -136,8 +138,8 @@ describe('LedgerDtoMapper', () => { difference: 0.5, feedTimestamp: '2026-06-10T04:00:00.000Z', feedAge: 2, - staleness: 'fresh', - status: 'diff', + staleness: LedgerFeedStaleness.FRESH, + status: LedgerReconResultStatus.DIFF, }); }); @@ -150,13 +152,13 @@ describe('LedgerDtoMapper', () => { difference: 1, feedTimestamp: undefined, feedAge: undefined, - staleness: 'missing', - status: 'unverified', + staleness: LedgerFeedStaleness.MISSING, + status: LedgerReconResultStatus.UNVERIFIED, }); expect(dto.feedTimestamp).toBeUndefined(); expect(dto.feedAge).toBeUndefined(); - expect(dto.staleness).toBe('missing'); + expect(dto.staleness).toBe(LedgerFeedStaleness.MISSING); }); }); 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 index 2fe2d265ff..87c2b9b4f8 100644 --- a/src/subdomains/core/accounting/dto/__tests__/ledger-dto.shapes.spec.ts +++ b/src/subdomains/core/accounting/dto/__tests__/ledger-dto.shapes.spec.ts @@ -7,6 +7,7 @@ import { LedgerLegEntryDto, LedgerLegsResponseDto, LedgerPeriodDto, + LedgerReconStatus, } from '../ledger-account.dto'; import { EquityComparisonDto, @@ -23,6 +24,8 @@ import { } from '../ledger-query.dto'; import { AccountReconResultDto, + LedgerFeedStaleness, + LedgerReconResultStatus, ReconStatusResponseDto, SuspenseLegDto, SuspenseResponseDto, @@ -42,7 +45,7 @@ describe('ledger account/margin/reconciliation response DTOs', () => { currency: 'EUR', balanceNative: 1234.5, balanceChf: 1180.25, - reconStatus: 'ok', + reconStatus: LedgerReconStatus.OK, reconDiff: 0, lastVerified: '2026-03-31T05:00:00.000Z', }); @@ -142,8 +145,8 @@ describe('ledger account/margin/reconciliation response DTOs', () => { difference: 0.5, feedTimestamp: '2026-06-10T04:00:00.000Z', feedAge: 2, - staleness: 'fresh', - status: 'diff', + staleness: LedgerFeedStaleness.FRESH, + status: LedgerReconResultStatus.DIFF, }); const dto = Object.assign(new ReconStatusResponseDto(), { runAt: '2026-06-10T05:00:00.000Z', @@ -152,8 +155,8 @@ describe('ledger account/margin/reconciliation response DTOs', () => { expect(dto).toBeInstanceOf(ReconStatusResponseDto); expect(dto.accounts[0]).toBeInstanceOf(AccountReconResultDto); - expect(dto.accounts[0].status).toBe('diff'); - expect(dto.accounts[0].staleness).toBe('fresh'); + expect(dto.accounts[0].status).toBe(LedgerReconResultStatus.DIFF); + expect(dto.accounts[0].staleness).toBe(LedgerFeedStaleness.FRESH); }); it('builds a SuspenseResponseDto with a suspense leg', () => { diff --git a/src/subdomains/core/accounting/dto/ledger-account.dto.ts b/src/subdomains/core/accounting/dto/ledger-account.dto.ts index 4465398a1f..048f644dbd 100644 --- a/src/subdomains/core/accounting/dto/ledger-account.dto.ts +++ b/src/subdomains/core/accounting/dto/ledger-account.dto.ts @@ -1,6 +1,12 @@ import { AccountType } from '../entities/ledger-account.entity'; -export type LedgerReconStatus = 'ok' | 'diff' | 'stale' | 'unverified' | 'placeholder'; +export enum LedgerReconStatus { + OK = 'Ok', + DIFF = 'Diff', + STALE = 'Stale', + UNVERIFIED = 'Unverified', + PLACEHOLDER = 'Placeholder', +} export class LedgerAccountBalanceDto { accountId: number; diff --git a/src/subdomains/core/accounting/dto/ledger-reconciliation.dto.ts b/src/subdomains/core/accounting/dto/ledger-reconciliation.dto.ts index 031199731a..d51ccc1886 100644 --- a/src/subdomains/core/accounting/dto/ledger-reconciliation.dto.ts +++ b/src/subdomains/core/accounting/dto/ledger-reconciliation.dto.ts @@ -1,5 +1,16 @@ -export type LedgerFeedStaleness = 'fresh' | 'stale' | 'missing' | 'placeholder'; -export type LedgerReconResultStatus = 'ok' | 'diff' | 'stale' | 'unverified' | 'suspense_alarm'; +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; 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 index 57c4082ddd..2191743dca 100644 --- a/src/subdomains/core/accounting/services/__tests__/integration/in-memory-ledger.ts +++ b/src/subdomains/core/accounting/services/__tests__/integration/in-memory-ledger.ts @@ -188,6 +188,10 @@ export class InMemoryLedger { } 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; }); 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 index 07f5c7d676..65387368b1 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-booking.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-booking.service.spec.ts @@ -64,6 +64,9 @@ describe('LedgerBookingService', () => { 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; }); @@ -280,6 +283,9 @@ describe('LedgerBookingService', () => { 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; }); @@ -330,6 +336,68 @@ describe('LedgerBookingService', () => { 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 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 index 600761c0c6..c28a144327 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-query.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-query.service.spec.ts @@ -8,6 +8,8 @@ import { LiquidityBalance } from 'src/subdomains/core/liquidity-management/entit 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'; @@ -15,6 +17,7 @@ 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'; @@ -36,10 +39,19 @@ describe('LedgerQueryService', () => { 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, @@ -170,6 +182,7 @@ describe('LedgerQueryService', () => { ledgerLegRepository = createMock(); reconciliationService = createMock(); liquidityManagementBalanceService = createMock(); + markService = createMock(); logService = createMock(); jest.spyOn(ledgerLegRepository, 'createQueryBuilder').mockImplementation(() => legQb()); @@ -177,6 +190,9 @@ describe('LedgerQueryService', () => { 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 @@ -191,6 +207,7 @@ describe('LedgerQueryService', () => { { provide: LedgerLegRepository, useValue: ledgerLegRepository }, { provide: LedgerReconciliationService, useValue: reconciliationService }, { provide: LiquidityManagementBalanceService, useValue: liquidityManagementBalanceService }, + { provide: LedgerMarkService, useValue: markService }, { provide: LogService, useValue: logService }, ], }).compile(); @@ -226,7 +243,7 @@ describe('LedgerQueryService', () => { 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('ok'); + expect(assetDto.reconStatus).toBe(LedgerReconStatus.OK); const liabilityDto = res.accounts.find((a) => a.accountId === 6); expect(liabilityDto.balanceChf).toBe(-500); @@ -244,10 +261,29 @@ describe('LedgerQueryService', () => { const res = await service.getAccounts(); - expect(res.accounts[0].reconStatus).toBe('diff'); // diff 50 > tolerance 1 + 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]); @@ -274,9 +310,9 @@ describe('LedgerQueryService', () => { const res = await service.getAccounts(); const dto = res.accounts.find((a) => a.accountId === 5); - expect(dto.reconStatus).toBe('stale'); // mapReconStatus(STALE, …) → 'stale' + 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 + expect(dto.lastVerified).toBeUndefined(); // staleness stale → non-fresh side of L339 }); }); @@ -311,12 +347,12 @@ describe('LedgerQueryService', () => { expect(res.runAt).toBeDefined(); const byId = new Map(res.accounts.map((a) => [a.accountId, a])); - expect(byId.get(1).staleness).toBe('fresh'); - expect(byId.get(1).status).toBe('ok'); - expect(byId.get(2).staleness).toBe('stale'); - expect(byId.get(2).status).toBe('stale'); - expect(byId.get(3).staleness).toBe('missing'); - expect(byId.get(3).status).toBe('unverified'); + 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); }); @@ -349,8 +385,8 @@ describe('LedgerQueryService', () => { const acc = res.accounts.find((a) => a.accountId === 4); expect(acc).toBeDefined(); - expect(acc.staleness).toBe('placeholder'); // line 370 - expect(acc.status).toBe('unverified'); // placeholder → not fresh, not stale → unverified + expect(acc.staleness).toBe(LedgerFeedStaleness.PLACEHOLDER); // line 370 + expect(acc.status).toBe(LedgerReconResultStatus.UNVERIFIED); // placeholder → not fresh, not stale → unverified }); }); @@ -742,7 +778,7 @@ describe('LedgerQueryService', () => { 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('ok'); // diff 0 ≤ tolerance + 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 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 index 7d573a2380..24e8b28106 100644 --- 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 @@ -420,4 +420,70 @@ describe('BuyCryptoConsumer', () => { .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 (countBy) 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 + }); }); 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 index 86c8b86cf7..f2efdca9bf 100644 --- 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 @@ -198,7 +198,17 @@ describe('ExchangeTxConsumer', () => { 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(); - expect(booked[0].legs.some((l) => l.account.name === 'SUSPENSE/untracked-bank-Raiffeisen-EUR')).toBe(true); + + 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 () => { @@ -253,6 +263,7 @@ describe('ExchangeTxConsumer', () => { 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(); @@ -277,7 +288,7 @@ describe('ExchangeTxConsumer', () => { }), ]); await consumer.process(); - expect(booked[0].legs.some((l) => l.account.name === 'EXPENSE/spread-Scrypt')).toBe(true); + expect(booked[0].legs.some((l) => l.account.name === 'EXPENSE/spread-Scrypt' && l.amountChf === 5)).toBe(true); expect(cents(booked[0].legs)).toBe(0); }); @@ -346,6 +357,52 @@ describe('ExchangeTxConsumer', () => { expect(booked.every((b) => b.sourceId === 'O-9')).toBe(true); }); + // 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 === 'O-9')).toBe(true); + }); + it('advances the watermark after a successful batch', async () => { const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); mockBatch([ diff --git a/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts b/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts index 141ad6b058..fa9b42dad1 100644 --- a/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts @@ -14,6 +14,7 @@ import { getLedgerWatermark, runContentChangeScan, setLedgerWatermark } from './ 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'; @@ -57,14 +58,18 @@ export class BuyCryptoConsumer { SOURCE_TYPE, afterForward, this.buyCryptoRepo, - { checkoutTx: true, cryptoInput: { paymentLinkPayment: true } }, + { checkoutTx: true, cryptoInput: { paymentLinkPayment: true }, bankTx: true }, async (bc: BuyCrypto) => { // §4.12: a Card-input amount/fee change (amountInChf) reverses + re-books the seq0 Card-input tx; then the // idempotent forward book() appends any newly-settled seqs (seq1 completion). Non-Card inputs have no seq0 // here (booked by the CryptoInput/BankTx single booker) → buildSeq0Input returns undefined → no-op reversal. const seq0 = await this.buildCardInputSeq0(bc); if (seq0) await this.bookingService.reverseAndRebookIfChanged(seq0); - await this.book(bc); + // honour the book() gate: a gate-blocked run (seq1 received not yet opened) returns false and must NOT + // advance the content-change cursor past this row, else the late-settling row is lost (§4.7 G-a) + if (!(await this.book(bc))) { + throw new Error(`buy_crypto ${bc.id} content-change scan gate-blocked — retry next run (§4.7 G-a)`); + } }, ); } @@ -72,7 +77,7 @@ export class BuyCryptoConsumer { 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 } }, + relations: { checkoutTx: true, cryptoInput: { paymentLinkPayment: true }, bankTx: true }, order: { id: 'ASC' }, take: Config.ledger.backfillBatchSize, }); @@ -187,6 +192,13 @@ export class BuyCryptoConsumer { // (G-b) for a pre-cutover-settled row → gate closed. Without it, seq1 would Dr received with no −opening → phantom. if (bc.checkoutTx) return !(await this.settledBeforeCutover(bc)); + // 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) { const ga = await this.ledgerTxRepo.countBy({ diff --git a/src/subdomains/core/accounting/services/ledger-booking.service.ts b/src/subdomains/core/accounting/services/ledger-booking.service.ts index 99046d1fe5..813e8f9af5 100644 --- a/src/subdomains/core/accounting/services/ledger-booking.service.ts +++ b/src/subdomains/core/accounting/services/ledger-booking.service.ts @@ -2,7 +2,7 @@ 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 } from 'typeorm'; +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'; @@ -54,6 +54,12 @@ export class LedgerBookingService { * 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); @@ -61,24 +67,22 @@ export class LedgerBookingService { const amountChfSum = legs.reduce((sum, leg) => sum + leg.amountChfCents, 0); - return this.dataSource.transaction(async (manager) => { - 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; + 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; } /** @@ -86,9 +90,15 @@ export class LedgerBookingService { * seq in the (sourceType, sourceId) namespace; the original stays untouched. */ async reverseTx(original: LedgerTx): Promise { - const nextSeq = await this.nextCorrectionSeq(original.sourceType, original.sourceId); + 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.bookTx({ + return this.bookTxWithManager(manager, { sourceType: original.sourceType, sourceId: original.sourceId, seq: nextSeq, @@ -124,13 +134,18 @@ export class LedgerBookingService { await this.appendRoundingLeg(fresh); if (!this.legsDiffer(active.legs, fresh)) return false; // unchanged within §4.12 tolerances → no-op - // (1) reversal-tx (reversalOf = the active original, inverted legs) - await this.reverseTx(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.nextCorrectionSeq(input.sourceType, input.sourceId); - await this.bookTx({ ...input, seq: reSeq, reversalOf: undefined }); + // §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; } @@ -223,9 +238,33 @@ export class LedgerBookingService { return false; } - // monotonic, collision-free seq allocation in the (sourceType, sourceId) namespace (§4.12) + // 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 { - const { max } = await this.dataSource + 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') @@ -236,12 +275,6 @@ export class LedgerBookingService { return (max ?? -1) + 1; } - // 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); - } - 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; diff --git a/src/subdomains/core/accounting/services/ledger-cutover.service.ts b/src/subdomains/core/accounting/services/ledger-cutover.service.ts index e132532ab6..c9939b2813 100644 --- a/src/subdomains/core/accounting/services/ledger-cutover.service.ts +++ b/src/subdomains/core/accounting/services/ledger-cutover.service.ts @@ -9,11 +9,15 @@ 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 { BuyFiat } from 'src/subdomains/core/sell-crypto/process/buy-fiat.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, @@ -22,17 +26,13 @@ import { 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 { 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 { 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 { LedgerAccountService } from './ledger-account.service'; import { LedgerMarkCache, LedgerMarkService } from './ledger-mark.service'; const CUTOVER_LOG_ID_KEY = 'ledgerCutoverLogId'; diff --git a/src/subdomains/core/accounting/services/ledger-query.service.ts b/src/subdomains/core/accounting/services/ledger-query.service.ts index 28f5d01461..f681415d8b 100644 --- a/src/subdomains/core/accounting/services/ledger-query.service.ts +++ b/src/subdomains/core/accounting/services/ledger-query.service.ts @@ -18,6 +18,7 @@ import { LedgerAccountsResponseDto, LedgerLegEntryDto, LedgerLegsResponseDto, + LedgerReconStatus, } from '../dto/ledger-account.dto'; import { AccountBalance, @@ -37,6 +38,7 @@ 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; @@ -57,7 +59,8 @@ interface EquityDayAgg { /** * 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) and the read-only LogService + * 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. @@ -69,6 +72,7 @@ export class LedgerQueryService { private readonly ledgerLegRepository: LedgerLegRepository, private readonly reconciliationService: LedgerReconciliationService, private readonly liquidityManagementBalanceService: LiquidityManagementBalanceService, + private readonly markService: LedgerMarkService, private readonly logService: LogService, ) {} @@ -83,6 +87,9 @@ export class LedgerQueryService { // ASSET-account recon snapshot for the list (against the persisted feed, §7) — feed read once for all accounts const feedByAssetId = await this.feedByAssetId(); + // §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 marks = await this.markService.preload(Util.daysBefore(2, now), now); const accountDtos: LedgerAccountBalanceDto[] = accounts.map((account) => { const balance: AccountBalance = { @@ -90,7 +97,7 @@ export class LedgerQueryService { balanceNative: balances.get(account.id)?.native ?? 0, balanceChf: balances.get(account.id)?.chf ?? 0, }; - const recon = this.reconSnapshot(account, balance.balanceNative, feedByAssetId, now); + const recon = this.reconSnapshot(account, balance.balanceNative, feedByAssetId, marks, now); return LedgerDtoMapper.mapAccountBalance(balance, recon); }); @@ -159,13 +166,15 @@ export class LedgerQueryService { }); const feedByAssetId = await this.feedByAssetId(); const balances = await this.nativeBalanceByAccount(); + // §7 (unit fix): marks read ONCE per request (same 2-day window as the reconciliation job) — see mapReconStatus + const marks = await 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, now); + const result = this.reconResult(account, ledgerBalance, feedByAssetId, marks, now); results.push(LedgerDtoMapper.mapReconResult(result)); } @@ -370,22 +379,41 @@ export class LedgerQueryService { 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, now); + const result = this.reconResult(account, ledgerBalance, feedByAssetId, marks, now); return { - reconStatus: result.status === 'suspense_alarm' ? 'diff' : result.status, + reconStatus: this.toReconStatus(result.status), reconDiff: result.difference, - lastVerified: result.staleness === 'fresh' ? result.feedTimestamp : undefined, + 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; @@ -396,8 +424,11 @@ export class LedgerQueryService { 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); + const status = this.mapReconStatus(classification.status, difference, mark); return { account, ledgerBalance, externalFeedBalance, difference, feedTimestamp, feedAge, staleness, status }; } @@ -405,22 +436,28 @@ export class LedgerQueryService { private mapStaleness(status: FeedStatus): LedgerFeedStaleness { switch (status) { case FeedStatus.FRESH: - return 'fresh'; + return LedgerFeedStaleness.FRESH; case FeedStatus.STALE: - return 'stale'; + return LedgerFeedStaleness.STALE; case FeedStatus.PLACEHOLDER: - return 'placeholder'; + return LedgerFeedStaleness.PLACEHOLDER; case FeedStatus.NO_FEED: default: - return 'missing'; + return LedgerFeedStaleness.MISSING; } } - private mapReconStatus(status: FeedStatus, difference: number): LedgerReconResultStatus { - if (status === FeedStatus.STALE) return 'stale'; - if (status !== FeedStatus.FRESH) return 'unverified'; // placeholder / no-feed → unverified (§7.2) - - return Math.abs(difference) <= Config.ledger.reconciliationToleranceChf ? 'ok' : 'diff'; + 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) --- // From dce62674cab1eedfd70248648cc06838e2dbf1e5 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 15 Jul 2026 02:43:12 +0200 Subject: [PATCH 14/25] fix(accounting): fourth review pass (late-settling coverage, chain rebooks, cutover fail-loud) - every status-filtered source now has content-change coverage: a row the forward watermark skipped while unsettled is forward-booked idempotently once it settles (payout, trading, liq-mgmt, dex; crypto-input and exchange-tx callbacks now forward-book instead of only reversing) - gate-blocked rows distinguish never-bookable (settled before the cutover snapshot - value lives in the aggregate opening - skip and advance) from not-yet-bookable (throw and retry); a flag bump on an old row no longer wedges the scan - value-coupled seq chains reverse and re-book as one interleaved transaction, so a late amount change cannot leave the liability open - cutover openings and owedReferenceRate fail loud on missing marks instead of booking silent zeros; dead injection and any-casts removed --- .../__tests__/ledger-booking.service.spec.ts | 94 ++++++++++++++++ .../__tests__/ledger-cutover.service.spec.ts | 93 ++++++++-------- .../__tests__/buy-crypto.consumer.spec.ts | 56 ++++++++-- .../__tests__/buy-fiat.consumer.spec.ts | 105 +++++++++++++++--- .../__tests__/crypto-input.consumer.spec.ts | 39 +++++++ .../__tests__/exchange-tx.consumer.spec.ts | 101 +++++++++++++++++ .../__tests__/liquidity-mgmt.consumer.spec.ts | 7 +- .../liquidity-order-dex.consumer.spec.ts | 12 +- .../__tests__/payout-order.consumer.spec.ts | 66 ++++++++++- .../__tests__/trading-order.consumer.spec.ts | 7 +- .../services/consumers/buy-crypto.consumer.ts | 45 ++++++-- .../services/consumers/buy-fiat.consumer.ts | 96 +++++++++++++--- .../consumers/crypto-input.consumer.ts | 6 + .../consumers/exchange-tx.consumer.ts | 11 +- .../consumers/ledger-watermark.helper.ts | 10 +- .../consumers/liquidity-mgmt.consumer.ts | 39 +++++-- .../consumers/liquidity-order-dex.consumer.ts | 44 ++++++-- .../consumers/payout-order.consumer.ts | 39 +++++-- .../consumers/trading-order.consumer.ts | 39 +++++-- .../services/ledger-booking.service.ts | 54 +++++++++ .../services/ledger-cutover.service.ts | 33 +++--- .../services/ledger-mark-to-market.service.ts | 2 - 22 files changed, 838 insertions(+), 160 deletions(-) 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 index 65387368b1..d16de3d350 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-booking.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-booking.service.spec.ts @@ -296,6 +296,13 @@ describe('LedgerBookingService', () => { .filter((tx) => tx.sourceType === where.sourceType && tx.sourceId === where.sourceId) .sort((a, b) => a.seq - b.seq), ), + // hasAnyTxAt: count of any tx at the exact (sourceType, sourceId, seq) + countBy: (where: any) => + Promise.resolve( + txStore.filter( + (tx) => tx.sourceType === where.sourceType && tx.sourceId === where.sourceId && tx.seq === where.seq, + ).length, + ), createQueryBuilder: () => { let st: string, sid: string; const qb: any = {}; @@ -626,6 +633,93 @@ describe('LedgerBookingService', () => { }; 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', () => { 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 index 7cfaadb34f..e50c9f3f66 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts @@ -341,21 +341,23 @@ describe('LedgerCutoverService', () => { expect(liabilityLeg.amountChf).toBe(-14851.5); // CHF output → mark 1 }); - it('opens buyCrypto-owed CHF = outputAmount × getMarkAt(outputAsset), needsMark when feedless (R6-1)', async () => { + // 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 })]); + return Promise.resolve([buyCrypto({ id: 50, outputAmount: 2, outputAsset: { id: 999 } as any })]); // no mark } return Promise.resolve([]); }); - await service.run(); + await expect(service.run()).rejects.toThrow(/without a mark/i); - const owedTx = booked.find((b) => b.sourceId === '1557344:buy_crypto-owed:50'); - expect(owedTx).toBeDefined(); - const liabilityLeg = owedTx.legs.find((l) => l.account.type === AccountType.LIABILITY); - expect(liabilityLeg.needsMark).toBe(true); // no mark for asset 999 → mark-to-market values later + 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 @@ -807,9 +809,11 @@ describe('LedgerCutoverService', () => { expect(booked.some((b) => b.sourceId === '1557344:bank_tx-return:70')).toBe(false); }); - it('opens an aggregated unattributed with needsMark when a credit cannot be valued (no mark)', async () => { + // 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 → needsMark + // 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([ @@ -822,13 +826,11 @@ describe('LedgerCutoverService', () => { ] as any) .mockResolvedValueOnce([]); - await service.run(); + await expect(service.run()).rejects.toThrow(/without a mark/i); - 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.needsMark).toBe(true); // a feedless credit → needsMark, valued later by mark-to-market - expect(liabilityLeg.amountChf).toBeUndefined(); + 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 () => { @@ -935,24 +937,23 @@ describe('LedgerCutoverService', () => { 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 → needsMark opening (valued later by mark-to-market). Distinct from the id-present-no-mark case. - it('opens a buyCrypto-owed with needsMark when the outputAsset has no id (line 349 null-id side)', async () => { + // §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 + return Promise.resolve([buyCrypto({ id: 55, outputAmount: 2, outputAsset: {} as any })]); // no id → no mark } return Promise.resolve([]); }); - await service.run(); + await expect(service.run()).rejects.toThrow(/without a mark/i); - const owedTx = booked.find((b) => b.sourceId === '1557344:buy_crypto-owed:55'); - expect(owedTx).toBeDefined(); - const liabilityLeg = owedTx.legs.find((l) => l.account.type === AccountType.LIABILITY); - expect(liabilityLeg.needsMark).toBe(true); // no outputAsset.id → no mark → needsMark - expect(liabilityLeg.amountChf).toBeUndefined(); + 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 @@ -968,9 +969,9 @@ describe('LedgerCutoverService', () => { 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 → needsMark, amountChf - // undefined (line 423 undefined side). EUR bank, empty mark cache → mark undefined → the liability leg is feedless. - it('opens a bankTx-return with needsMark when the bank mark is missing (line 423 undefined side)', async () => { + // §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 @@ -984,18 +985,16 @@ describe('LedgerCutoverService', () => { }), ] as any); - await service.run(); + await expect(service.run()).rejects.toThrow(/without a mark/i); - const returnTx = booked.find((b) => b.sourceId === '1557344:bank_tx-return:95'); - expect(returnTx).toBeDefined(); - const liabilityLeg = returnTx.legs.find((l) => l.account.type === AccountType.LIABILITY); - expect(liabilityLeg.needsMark).toBe(true); // EUR + no mark → feedless, valued later - expect(liabilityLeg.amountChf).toBeUndefined(); + 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 → the unattributed leg is needsMark. - it('values an unattributed credit needsMark when the bank_tx has no accountIban and no CHF currency', async () => { + // 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 @@ -1005,17 +1004,16 @@ describe('LedgerCutoverService', () => { ] as any) .mockResolvedValueOnce([]); - await service.run(); + await expect(service.run()).rejects.toThrow(/without a mark/i); - 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.needsMark).toBe(true); // no accountIban, EUR, no mark → needsMark + 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 → needsMark. - it('values an unattributed credit needsMark when the matched bank has no asset', async () => { + // 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 @@ -1030,12 +1028,11 @@ describe('LedgerCutoverService', () => { ] as any) .mockResolvedValueOnce([]); - await service.run(); + await expect(service.run()).rejects.toThrow(/without a mark/i); - 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.needsMark).toBe(true); // bank has no asset → no mark → needsMark + 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); }); }); 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 index 24e8b28106..2e0827250a 100644 --- 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 @@ -316,15 +316,17 @@ describe('BuyCryptoConsumer', () => { // --- §4.12 / §6.3 CONTENT-CHANGE SCAN --- // - // the content-change scan reverses+rebooks a Card-input seq0 and then books any newly-settled completion - it('runs the content-change scan: reverses+rebooks the Card seq0 then books the completion', async () => { - const rebookSpy = jest.spyOn(bookingService, 'reverseAndRebookIfChanged').mockResolvedValue(true); + // 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- + // Schliessung bricht) → 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 () => { + const chainSpy = jest.spyOn(bookingService, 'reverseAndRebookChainIfChanged').mockResolvedValue(true); const changed = buyCrypto({ id: 30, amountInChf: 1000, totalFeeAmountChf: 10, isComplete: true, - checkoutTx: { currency: 'EUR' } as any, // Card input → buildCardInputSeq0 resolves → reverseAndRebookIfChanged + 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 @@ -333,16 +335,18 @@ describe('BuyCryptoConsumer', () => { await consumer.process(); - expect(rebookSpy).toHaveBeenCalledTimes(1); // the Card seq0 reverse-and-rebook ran - expect(rebookSpy.mock.calls[0][0].sourceId).toBe('30'); + 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 seq0 here → buildCardInputSeq0 undefined → no reversal, - // but the idempotent book() still appends the completion - it('content-change scan: a non-Card row triggers no seq0 reversal but still books its completion', async () => { - const rebookSpy = jest.spyOn(bookingService, 'reverseAndRebookIfChanged').mockResolvedValue(false); + // 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, @@ -356,10 +360,40 @@ describe('BuyCryptoConsumer', () => { await consumer.process(); - expect(rebookSpy).not.toHaveBeenCalled(); // non-Card → no seq0 reverse + 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, 'countBy').mockResolvedValue(0); // 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/ 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 index a8f33039db..3fb4bd2771 100644 --- 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 @@ -713,16 +713,18 @@ describe('BuyFiatConsumer', () => { expect(cents(s2.legs)).toBe(0); }); - // owedReferenceRate ref==null + non-CHF output (line 359 → returns 0): a paymentLink EUR row with - // outputReferenceAmount null → owed_chf = outputAmount × 0 = 0. Asserted on seq2 transmit (both legs 0). - it('uses owedReferenceRate 0 for a non-CHF paymentLink row with a null reference', async () => { - seq0PaymentLinkChf = -100; // opening present (gate passes); spread plugs to fx, not asserted here + // 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 → EUR → rate 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 } }, @@ -731,10 +733,8 @@ describe('BuyFiatConsumer', () => { ]); await consumer.process(); - const s2 = seq(2); - expect(leg(s2, 'LIABILITY/paymentLink').amountChf).toBe(0); // outputAmount × 0 - expect(leg(s2, 'TRANSIT/payout/EUR').amountChf).toBe(-0); - expect(cents(s2.legs)).toBe(0); + 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 @@ -798,10 +798,11 @@ describe('BuyFiatConsumer', () => { expect(sumOn('LIABILITY/buyFiat-received')).toBe(1000); }); - // §4.12 content-change scan (process lines 60-84): a settled regular row surfaced ONLY by the content-change scan - // (where.updated) → buildReclassificationSeq1 is reverse-and-rebooked, then the idempotent book() appends seq2/seq3. - it('reverse-and-rebooks the seq1 on a content-change scan and appends the later seqs', async () => { - const reverseSpy = jest.spyOn(bookingService, 'reverseAndRebookIfChanged').mockResolvedValue(true); + // §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( @@ -827,10 +828,16 @@ describe('BuyFiatConsumer', () => { ); await consumer.process(); - expect(reverseSpy).toHaveBeenCalledTimes(1); - const reInput = reverseSpy.mock.calls[0][0]; - expect(reInput.seq).toBe(1); // the rebuilt seq1 reclassification - expect(leg(reInput, 'LIABILITY/buyFiat-owed').amountChf).toBe(-990); + 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(); @@ -906,6 +913,70 @@ describe('BuyFiatConsumer', () => { 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(); + gateCount = 0; // 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. 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 index b57b7a4c55..9f2af90111 100644 --- 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 @@ -409,6 +409,45 @@ describe('CryptoInputConsumer', () => { 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 + }); + // --- ADDITIONAL BRANCH COVERAGE --- // // line 117 UNDEFINED side: the resolved wallet LedgerAccount has assetId == null → the `wallet.assetId != null` 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 index f2efdca9bf..e8e20c1449 100644 --- 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 @@ -480,6 +480,107 @@ describe('ExchangeTxConsumer', () => { 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 + }); + // --- FORWARD ERROR / UNHANDLED-TYPE BRANCHES --- // it('skips an unhandled exchange_tx type in the forward scan (buildSpec undefined → no booking)', async () => { 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 index ec34cf76e0..f086bb011e 100644 --- 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 @@ -112,7 +112,12 @@ describe('LiquidityMgmtConsumer', () => { }); const cents = (legs: LedgerLegInput[]) => legs.reduce((s, l) => s + Math.round((l.amountChf ?? 0) * 100), 0); - const mockBatch = (rows: LiquidityManagementOrder[]) => jest.spyOn(orderRepo, 'find').mockResolvedValue(rows); + // 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', () => { 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 index d49d72d1cd..366d46f563 100644 --- 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 @@ -114,7 +114,12 @@ describe('LiquidityOrderDexConsumer', () => { }); const cents = (legs: LedgerLegInput[]) => legs.reduce((s, l) => s + Math.round((l.amountChf ?? 0) * 100), 0); - const mockBatch = (rows: LiquidityOrder[]) => jest.spyOn(liquidityOrderRepo, 'find').mockResolvedValue(rows); + // 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', () => { @@ -248,8 +253,9 @@ describe('LiquidityOrderDexConsumer', () => { const findSpy = mockBatch([]); await consumer.process(); - expect(findSpy).toHaveBeenCalledTimes(1); - const where = findSpy.mock.calls[0][0].where as Record; + // 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. 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 index 34b8d8f058..33e68790d9 100644 --- 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 @@ -132,7 +132,12 @@ describe('PayoutOrderConsumer', () => { }); const cents = (legs: LedgerLegInput[]) => legs.reduce((s, l) => s + Math.round((l.amountChf ?? 0) * 100), 0); - const mockBatch = (rows: PayoutOrder[]) => jest.spyOn(payoutOrderRepo, 'find').mockResolvedValue(rows); + // 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', () => { @@ -420,6 +425,65 @@ describe('PayoutOrderConsumer', () => { 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) 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 index 4831e84d20..4f54b9bb33 100644 --- 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 @@ -109,7 +109,12 @@ describe('TradingOrderConsumer', () => { }); const cents = (legs: LedgerLegInput[]) => legs.reduce((s, l) => s + Math.round((l.amountChf ?? 0) * 100), 0); - const mockBatch = (rows: TradingOrder[]) => jest.spyOn(tradingOrderRepo, 'find').mockResolvedValue(rows); + // 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', () => { diff --git a/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts b/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts index fa9b42dad1..87eb2fd28b 100644 --- a/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts @@ -60,16 +60,25 @@ export class BuyCryptoConsumer { this.buyCryptoRepo, { checkoutTx: true, cryptoInput: { paymentLinkPayment: true }, bankTx: true }, async (bc: BuyCrypto) => { - // §4.12: a Card-input amount/fee change (amountInChf) reverses + re-books the seq0 Card-input tx; then the - // idempotent forward book() appends any newly-settled seqs (seq1 completion). Non-Card inputs have no seq0 - // here (booked by the CryptoInput/BankTx single booker) → buildSeq0Input returns undefined → no-op reversal. - const seq0 = await this.buildCardInputSeq0(bc); - if (seq0) await this.bookingService.reverseAndRebookIfChanged(seq0); - // honour the book() gate: a gate-blocked run (seq1 received not yet opened) returns false and must NOT - // advance the content-change cursor past this row, else the late-settling row is lost (§4.7 G-a) + // 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; throw new Error(`buy_crypto ${bc.id} content-change scan gate-blocked — retry next run (§4.7 G-a)`); } + + // 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); }, ); } @@ -154,6 +163,13 @@ export class BuyCryptoConsumer { * −(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 @@ -171,14 +187,14 @@ export class BuyCryptoConsumer { this.chfLeg(owed, -reclassChf), // Cr owed −(amountInChf−fee) ]; - await this.bookingService.bookTx({ + return { sourceType: SOURCE_TYPE, sourceId: `${bc.id}`, seq: 1, bookingDate: bc.outputDate ?? bc.updated, valueDate: bc.outputDate ?? bc.updated, legs, - }); + }; } // --- GATE (§4.6/§4.7 G-a/G-b) --- // @@ -231,7 +247,16 @@ export class BuyCryptoConsumer { // 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 { - if (!bc.checkoutTx || !bc.isComplete || bc.outputDate == null) return false; + 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; diff --git a/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts b/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts index 962d77081d..c161694703 100644 --- a/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts @@ -18,6 +18,7 @@ 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'; @@ -65,21 +66,35 @@ export class BuyFiatConsumer { this.buyFiatRepo, { cryptoInput: { paymentLinkPayment: true }, fiatOutput: { bankTx: true, bank: { asset: true } } }, async (bf: BuyFiat) => { - // §4.12: an amountInChf / totalFeeAmountChf change on a settled regular sell reverses + re-books the seq1 - // reclassification tx; then the idempotent forward book() appends any newly-settled seqs (transmit/booked). - // The paymentLink seq1 (venue-spread) and the later seqs are append-only and re-derived by the forward path. - // An owed-straddling row (§4.7a/§6.1) has its reclassification anchored in the cutover opening and skips seq1 - // → do NOT reverse/rebook a seq1 that was never booked by this consumer. - const owedOpeningChf = await this.cutoverOwedOpeningChf(bf.id); - if (owedOpeningChf == null) { - const seq1 = await this.buildReclassificationSeq1(bf); - if (seq1) await this.bookingService.reverseAndRebookIfChanged(seq1); - } - // honour the book() gate: a gate-blocked run (seq1 received/paymentLink not yet opened) returns false and must - // NOT advance the content-change watermark past this row, else the late-settling row is lost (Blocker R6-1) - if (!(await this.book(bf, await this.preloadMarks([bf])))) { + 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; 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-Schliessung bricht) — 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); + } }, ); } @@ -192,23 +207,45 @@ export class BuyFiatConsumer { // 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)); - await this.bookingService.bookTx({ + 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); @@ -219,14 +256,14 @@ export class BuyFiatConsumer { const legs: LedgerLegInput[] = [this.chfLeg(transit, owedChf), bankLeg]; await this.appendFxResidual(legs); - await this.bookingService.bookTx({ + return { sourceType: SOURCE_TYPE, sourceId: `${bf.id}`, seq: 3, bookingDate, valueDate: bf.fiatOutput.bankTx.valueDate ?? bookingDate, legs, - }); + }; } // --- (II) PAYMENTLINK MERCHANT PAYOUT (§4.7b) --- // @@ -357,7 +394,17 @@ export class BuyFiatConsumer { // 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) return this.outputCurrency(bf) === CHF ? 1 : 0; + 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); } @@ -402,6 +449,19 @@ export class BuyFiatConsumer { 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). diff --git a/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts b/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts index 6965811025..77e6c9fb8c 100644 --- a/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts @@ -58,6 +58,12 @@ export class CryptoInputConsumer { 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). + if (CryptoInputSettledStatus.includes(ci.status)) 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); }, diff --git a/src/subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts b/src/subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts index c6cbc7e6c4..b6fcc6c21e 100644 --- a/src/subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts @@ -118,7 +118,16 @@ export class ExchangeTxConsumer { return; } - await this.bookingService.reverseAndRebookIfChanged(spec); + // 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). + if (!(await this.bookingService.hasAnyTxAt(spec.sourceType, spec.sourceId, spec.seq))) { + await this.bookingService.bookTx(spec); + } } // builds the LedgerTxInput spec for an exchange_tx (or undefined for an unhandled type) — shared by the forward diff --git a/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts b/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts index 84b151fa18..54347ebd04 100644 --- a/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts +++ b/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts @@ -1,7 +1,7 @@ import { Config } from 'src/config/config'; import { SettingService } from 'src/shared/models/setting/setting.service'; import { DfxLogger } from 'src/shared/services/dfx-logger'; -import { Raw, Repository } from 'typeorm'; +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 @@ -87,7 +87,7 @@ export async function runContentChangeScan, - scanRelations: Record, + scanRelations: FindOptionsRelations, book: (row: T) => Promise, ): Promise { const batchSize = Config.ledger.backfillBatchSize; @@ -106,9 +106,9 @@ export async function runContentChangeScan, + relations: scanRelations, + order: { updated: 'ASC', id: 'ASC' } as FindOptionsOrder, take: batchSize, }); if (!changed.length) return; diff --git a/src/subdomains/core/accounting/services/consumers/liquidity-mgmt.consumer.ts b/src/subdomains/core/accounting/services/consumers/liquidity-mgmt.consumer.ts index 0d3e774818..155d02ebea 100644 --- a/src/subdomains/core/accounting/services/consumers/liquidity-mgmt.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/liquidity-mgmt.consumer.ts @@ -17,7 +17,7 @@ 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, setLedgerWatermark } from './ledger-watermark.helper'; +import { getLedgerWatermark, 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) @@ -53,6 +53,30 @@ export class LiquidityMgmtConsumer { 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; + 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' }, @@ -60,12 +84,7 @@ export class LiquidityMgmtConsumer { }); if (!batch.length) return; - const times = batch.map((o) => o.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)), - ); + const marks = await this.preloadMarks(batch); let lastProcessedId = watermark.lastProcessedId; for (const order of batch) { @@ -83,6 +102,12 @@ export class LiquidityMgmtConsumer { } } + // 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 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 index 6a51483ca4..24126fd5c3 100644 --- a/src/subdomains/core/accounting/services/consumers/liquidity-order-dex.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/liquidity-order-dex.consumer.ts @@ -15,9 +15,10 @@ 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, setLedgerWatermark } from './ledger-watermark.helper'; +import { getLedgerWatermark, runContentChangeScan, setLedgerWatermark } from './ledger-watermark.helper'; const SOURCE_TYPE = 'liquidity_order'; +const BOOKED_TYPES = [LiquidityOrderType.PURCHASE, LiquidityOrderType.SELL]; const CHF = 'CHF'; const DEX = 'DfxDex'; @@ -60,6 +61,32 @@ export class LiquidityOrderDexConsumer { 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; + 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 @@ -69,19 +96,14 @@ export class LiquidityOrderDexConsumer { id: MoreThan(watermark.lastProcessedId), txId: Not(IsNull()), context: In(BOOKED_CONTEXTS), - type: In([LiquidityOrderType.PURCHASE, LiquidityOrderType.SELL]), + type: In(BOOKED_TYPES), }, order: { id: 'ASC' }, take: Config.ledger.backfillBatchSize, }); if (!batch.length) return; - const times = batch.map((o) => o.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)), - ); + const marks = await this.preloadMarks(batch); let lastProcessedId = watermark.lastProcessedId; for (const order of batch) { @@ -99,6 +121,12 @@ export class LiquidityOrderDexConsumer { } } + // 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). diff --git a/src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts b/src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts index b591f0be8b..782b6eea5a 100644 --- a/src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts @@ -20,7 +20,7 @@ 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 { getLedgerWatermark, setLedgerWatermark } from './ledger-watermark.helper'; +import { getLedgerWatermark, runContentChangeScan, setLedgerWatermark } from './ledger-watermark.helper'; const SOURCE_TYPE = 'payout_order'; const CUTOVER_SOURCE = 'cutover'; @@ -80,6 +80,30 @@ export class PayoutOrderConsumer { 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 }, @@ -88,12 +112,7 @@ export class PayoutOrderConsumer { }); if (!batch.length) return; - const times = batch.map((o) => o.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)), - ); + const marks = await this.preloadMarks(batch); let lastProcessedId = watermark.lastProcessedId; for (const order of batch) { @@ -111,6 +130,12 @@ export class PayoutOrderConsumer { } } + // 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`); diff --git a/src/subdomains/core/accounting/services/consumers/trading-order.consumer.ts b/src/subdomains/core/accounting/services/consumers/trading-order.consumer.ts index 25eece596b..394caffaca 100644 --- a/src/subdomains/core/accounting/services/consumers/trading-order.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/trading-order.consumer.ts @@ -12,7 +12,7 @@ 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, setLedgerWatermark } from './ledger-watermark.helper'; +import { getLedgerWatermark, runContentChangeScan, setLedgerWatermark } from './ledger-watermark.helper'; const SOURCE_TYPE = 'trading_order'; const CHF = 'CHF'; @@ -46,6 +46,30 @@ export class TradingOrderConsumer { 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; + 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()) }, @@ -54,12 +78,7 @@ export class TradingOrderConsumer { }); if (!batch.length) return; - const times = batch.map((o) => o.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)), - ); + const marks = await this.preloadMarks(batch); let lastProcessedId = watermark.lastProcessedId; for (const order of batch) { @@ -77,6 +96,12 @@ export class TradingOrderConsumer { } } + // 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 = diff --git a/src/subdomains/core/accounting/services/ledger-booking.service.ts b/src/subdomains/core/accounting/services/ledger-booking.service.ts index 813e8f9af5..59636a8739 100644 --- a/src/subdomains/core/accounting/services/ledger-booking.service.ts +++ b/src/subdomains/core/accounting/services/ledger-booking.service.ts @@ -150,6 +150,48 @@ export class LedgerBookingService { 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-Schliessung + * bricht). 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 @@ -174,6 +216,18 @@ export class LedgerBookingService { 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 (await this.dataSource.getRepository(LedgerTx).countBy({ sourceType, sourceId, seq })) > 0; + } + /** * 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 diff --git a/src/subdomains/core/accounting/services/ledger-cutover.service.ts b/src/subdomains/core/accounting/services/ledger-cutover.service.ts index c9939b2813..155729b874 100644 --- a/src/subdomains/core/accounting/services/ledger-cutover.service.ts +++ b/src/subdomains/core/accounting/services/ledger-cutover.service.ts @@ -358,6 +358,9 @@ export class LedgerCutoverService { 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}`, @@ -365,7 +368,6 @@ export class LedgerCutoverService { liability, amountChf, equity, - mark == null, // feedless outputAsset → needsMark, mark-to-market values it later (§5.1 stage 3) ); } } @@ -451,6 +453,8 @@ export class LedgerCutoverService { 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}`, @@ -458,7 +462,6 @@ export class LedgerCutoverService { liability, amountChf, equity, - mark == null, ); } @@ -496,16 +499,16 @@ export class LedgerCutoverService { 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, - // a feedless/unmatched credit leaves the aggregate unvaluable → carry amountChf=undefined (needsMark) so the - // mark-to-market job values the whole bucket later (§5.1 stage 3); never a partial/-0 phantom CHF on the leg. needsMark ? undefined : Util.round(amountChf, 2), equity, - needsMark, ); } @@ -723,20 +726,24 @@ export class LedgerCutoverService { liability: LedgerAccount, amountChf: number | undefined, equity: LedgerAccount, - needsMark = false, ): Promise { + // m6 fail-loud: a received/owed/unattributed 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 ?? 0), - priceChf: 1, - amountChf: amountChf != null ? -amountChf : undefined, - needsMark, - }, + { account: liability, amount: -amountChf, priceChf: 1, amountChf: -amountChf, needsMark: false }, equity, ); } 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 index 01493a76b5..4878302e75 100644 --- a/src/subdomains/core/accounting/services/ledger-mark-to-market.service.ts +++ b/src/subdomains/core/accounting/services/ledger-mark-to-market.service.ts @@ -1,7 +1,6 @@ import { Injectable } from '@nestjs/common'; import { CronExpression } from '@nestjs/schedule'; import { Config } from 'src/config/config'; -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'; @@ -39,7 +38,6 @@ export class LedgerMarkToMarketService { constructor( private readonly jobService: LedgerBookingJobService, - private readonly settingService: SettingService, private readonly bookingService: LedgerBookingService, private readonly accountService: LedgerAccountService, private readonly markService: LedgerMarkService, From 93f917402a4f88a41a922d749f381607db5b0856 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 15 Jul 2026 03:49:32 +0200 Subject: [PATCH 15/25] fix(accounting): fifth review pass (owed-straddling cutover boundary, conventions) - openManualDebt and openBuyFiatOwed abort the cutover run loudly on a missing mark instead of silently dropping the CHF value or skipping the opening; already-booked openings are skipped idempotently on the retry - buy-crypto recognizes the cutover owed-opening marker: a row whose owed was opened at the cutover skips seq1 (and the card seq0) instead of wedging on the received gate or double-counting the card inflow - conventions: existsBy for existence checks, drop redundant eager:false, spell out LedgerBookingLiquidityManagement, move the valid-financial-log query into the repository, capitalize client-facing error messages --- src/shared/services/process.service.ts | 2 +- .../entities/ledger-account.entity.ts | 4 +- .../accounting/entities/ledger-leg.entity.ts | 4 +- .../accounting/entities/ledger-tx.entity.ts | 2 +- .../buy-crypto-cutover.integration.spec.ts | 82 ++++++++++++++++++- .../__tests__/integration/in-memory-ledger.ts | 26 +++--- .../ledger-booking-job.service.spec.ts | 2 +- .../__tests__/ledger-booking.service.spec.ts | 8 +- .../__tests__/ledger-cutover.service.spec.ts | 63 ++++++++------ .../__tests__/buy-crypto.consumer.spec.ts | 24 ++++-- .../__tests__/buy-fiat.consumer.spec.ts | 26 +++--- .../services/consumers/buy-crypto.consumer.ts | 40 +++++++-- .../services/consumers/buy-fiat.consumer.ts | 15 ++-- .../services/ledger-booking-job.service.ts | 2 +- .../services/ledger-booking.service.ts | 2 +- .../services/ledger-cutover.service.ts | 45 +++++----- .../services/ledger-query.service.ts | 2 +- .../supporting/log/log.repository.ts | 11 +++ src/subdomains/supporting/log/log.service.ts | 9 +- 19 files changed, 246 insertions(+), 123 deletions(-) diff --git a/src/shared/services/process.service.ts b/src/shared/services/process.service.ts index 85d9d663ca..e7bd0256b8 100644 --- a/src/shared/services/process.service.ts +++ b/src/shared/services/process.service.ts @@ -103,7 +103,7 @@ export enum Process { LEDGER_BOOKING_PAYOUT = 'LedgerBookingPayout', LEDGER_BOOKING_BUY_CRYPTO = 'LedgerBookingBuyCrypto', LEDGER_BOOKING_BUY_FIAT = 'LedgerBookingBuyFiat', - LEDGER_BOOKING_LIQ_MGMT = 'LedgerBookingLiqMgmt', + LEDGER_BOOKING_LIQUIDITY_MANAGEMENT = 'LedgerBookingLiquidityManagement', LEDGER_BOOKING_TRADING_ORDER = 'LedgerBookingTradingOrder', LEDGER_BOOKING_LIQUIDITY_ORDER = 'LedgerBookingLiquidityOrder', LEDGER_RECONCILIATION = 'LedgerReconciliation', diff --git a/src/subdomains/core/accounting/entities/ledger-account.entity.ts b/src/subdomains/core/accounting/entities/ledger-account.entity.ts index 84fd858a2f..8e1224d19d 100644 --- a/src/subdomains/core/accounting/entities/ledger-account.entity.ts +++ b/src/subdomains/core/accounting/entities/ledger-account.entity.ts @@ -22,9 +22,9 @@ export class LedgerAccount extends IEntity { @Column({ length: 32 }) type: AccountType; - // only ASSET accounts backed by an asset row; nullable read-only join (no cascade, eager:false) + // only ASSET accounts backed by an asset row; nullable read-only join (no cascade, not eager) @Index() - @ManyToOne(() => Asset, { nullable: true, eager: false }) + @ManyToOne(() => Asset, { nullable: true }) @JoinColumn() asset?: Asset; diff --git a/src/subdomains/core/accounting/entities/ledger-leg.entity.ts b/src/subdomains/core/accounting/entities/ledger-leg.entity.ts index dadddd61b2..305907e72c 100644 --- a/src/subdomains/core/accounting/entities/ledger-leg.entity.ts +++ b/src/subdomains/core/accounting/entities/ledger-leg.entity.ts @@ -7,7 +7,7 @@ import { LedgerTx } from './ledger-tx.entity'; @Entity() export class LedgerLeg extends IEntity { @Index() - @ManyToOne(() => LedgerTx, (tx) => tx.legs, { nullable: false, eager: false }) + @ManyToOne(() => LedgerTx, (tx) => tx.legs, { nullable: false }) @JoinColumn() tx: LedgerTx; @@ -15,7 +15,7 @@ export class LedgerLeg extends IEntity { txId: number; @Index() - @ManyToOne(() => LedgerAccount, { nullable: false, eager: false }) + @ManyToOne(() => LedgerAccount, { nullable: false }) @JoinColumn() account: LedgerAccount; diff --git a/src/subdomains/core/accounting/entities/ledger-tx.entity.ts b/src/subdomains/core/accounting/entities/ledger-tx.entity.ts index 53288ba38b..c3b2b73de2 100644 --- a/src/subdomains/core/accounting/entities/ledger-tx.entity.ts +++ b/src/subdomains/core/accounting/entities/ledger-tx.entity.ts @@ -29,7 +29,7 @@ export class LedgerTx extends IEntity { // self-FK for corrections (§4.12); references the original tx @Index() - @ManyToOne(() => LedgerTx, { nullable: true, eager: false }) + @ManyToOne(() => LedgerTx, { nullable: true }) @JoinColumn() reversalOf?: LedgerTx; 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 index 497cc9355b..955b5d6639 100644 --- 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 @@ -15,6 +15,11 @@ import { InMemoryLedger } from './in-memory-ledger'; * (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)', () => { @@ -23,6 +28,7 @@ describe('Ledger buy_crypto cutover Card double-book (§10.2, MAJOR)', () => { 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) @@ -66,8 +72,9 @@ describe('Ledger buy_crypto cutover Card double-book (§10.2, MAJOR)', () => { jest .spyOn(repo, 'find') .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [row] : [])); + settingService = cutoverSettingService(lastProcessedId); return new BuyCryptoConsumer( - cutoverSettingService(lastProcessedId), + settingService, ledger.bookingService, ledger.accountService, repo, @@ -75,6 +82,25 @@ describe('Ledger buy_crypto cutover Card double-book (§10.2, MAJOR)', () => { ); } + // 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 }, + ], + }); + } + it('Scenario A: an open Card row completing after the cutover books seq0+seq1 → received closes to 0', async () => { // the cutover did NOT open a per-row received for this Card row (Card excluded); it completes post-cutover, so the // content-change scan books the fresh seq0 (the card opening) + seq1 (fee/reclass) → received nets to 0. @@ -121,4 +147,58 @@ describe('Ledger buy_crypto cutover Card double-book (§10.2, MAJOR)', () => { 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/in-memory-ledger.ts b/src/subdomains/core/accounting/services/__tests__/integration/in-memory-ledger.ts index 2191743dca..4a3bc1e355 100644 --- a/src/subdomains/core/accounting/services/__tests__/integration/in-memory-ledger.ts +++ b/src/subdomains/core/accounting/services/__tests__/integration/in-memory-ledger.ts @@ -82,21 +82,15 @@ export class InMemoryLedger { } /** - * In-memory LedgerTx repository for the cross-consumer gate reads (BuyFiat consumer countBy/findOne, §4.7 + * 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>(); - jest.spyOn(repo, 'countBy').mockImplementation((where: any) => { - return Promise.resolve( - 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), - ).length, - ); + // 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) => { @@ -111,6 +105,16 @@ export class InMemoryLedger { 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); } @@ -206,6 +210,8 @@ export class InMemoryLedger { .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; 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 index f8c54230f3..e794cc5a93 100644 --- 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 @@ -129,7 +129,7 @@ describe('LedgerBookingJobService', () => { runPayoutOrder: Process.LEDGER_BOOKING_PAYOUT, runBuyCrypto: Process.LEDGER_BOOKING_BUY_CRYPTO, runBuyFiat: Process.LEDGER_BOOKING_BUY_FIAT, - runLiquidityMgmt: Process.LEDGER_BOOKING_LIQ_MGMT, + runLiquidityMgmt: Process.LEDGER_BOOKING_LIQUIDITY_MANAGEMENT, runLiquidityOrderDex: Process.LEDGER_BOOKING_LIQUIDITY_ORDER, runTradingOrder: Process.LEDGER_BOOKING_TRADING_ORDER, }; 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 index d16de3d350..788e100812 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-booking.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-booking.service.spec.ts @@ -296,12 +296,12 @@ describe('LedgerBookingService', () => { .filter((tx) => tx.sourceType === where.sourceType && tx.sourceId === where.sourceId) .sort((a, b) => a.seq - b.seq), ), - // hasAnyTxAt: count of any tx at the exact (sourceType, sourceId, seq) - countBy: (where: any) => + // hasAnyTxAt: any tx exists at the exact (sourceType, sourceId, seq) + existsBy: (where: any) => Promise.resolve( - txStore.filter( + txStore.some( (tx) => tx.sourceType === where.sourceType && tx.sourceId === where.sourceId && tx.seq === where.seq, - ).length, + ), ), createQueryBuilder: () => { let st: string, sid: string; 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 index e50c9f3f66..7df7fc3611 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts @@ -625,7 +625,7 @@ describe('LedgerCutoverService', () => { 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'); + 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 }); @@ -878,9 +878,11 @@ describe('LedgerCutoverService', () => { expect(booked.some((b) => b.sourceId === '1557344:buy_fiat-owed:50')).toBe(false); }); - // §6.1 openBuyFiatOwed: a foreign-currency (EUR) output whose fiat-mark is missing is skipped — line 296 - // (cannot value the CHF-denominated owed liability now; the forward path values it later). - it('skips a foreign-currency buyFiat-owed row when its fiat-mark is missing (no value)', async () => { + // 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) => { @@ -890,25 +892,29 @@ describe('LedgerCutoverService', () => { return Promise.resolve([]); }); - await service.run(); + await expect(service.run()).rejects.toThrow(/without a mark/i); - expect(booked.some((b) => b.sourceId === '1557344:buy_fiat-owed:51')).toBe(false); // no fiat-mark → skipped + 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 (line 719 `assetId != null` false side): a foreign-currency buyFiat-owed whose outputAsset has NO id → - // fiatMark(undefined, …) returns undefined → the row is skipped (cannot value the owed liability). - it('skips a foreign-currency buyFiat-owed row whose outputAsset has no id (fiatMark undefined)', async () => { + // 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 → skip + // 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 service.run(); + 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); }); @@ -1052,40 +1058,43 @@ describe('LedgerCutoverService', () => { 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 (seq advances, no leg)', async () => { + 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 - { assetId: 100, value: 500 }, // bookable → seq must be 1 (the skipped position advanced seq) + { assetId: 100, value: 0 }, // no value → skipped, books nothing + { assetId: 100, value: 500 }, // bookable ] as any); await service.run(); - expect(booked.some((b) => b.sourceId === '1557344:manual-debt:100' && b.seq === 0)).toBe(false); // skipped at seq0 - const booked1 = booked.find((b) => b.sourceId === '1557344:manual-debt:100' && b.seq === 1); - expect(booked1).toBeDefined(); - expect(booked1.legs.find((l) => l.account.type === AccountType.LIABILITY).amountChf).toBe(-1000); + 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); }); - it('opens a manual-debt with needsMark + native fallback when the asset priceChf is non-finite', async () => { - jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({ '100': { priceChf: NaN } })]); + // 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 service.run(); + await expect(service.run()).rejects.toThrow(/without a mark/i); - const manualTx = booked.find((b) => b.sourceId === '1557344:manual-debt:100'); - expect(manualTx).toBeDefined(); - const liabilityLeg = manualTx.legs.find((l) => l.account.type === AccountType.LIABILITY); - expect(liabilityLeg.needsMark).toBe(true); - expect(liabilityLeg.amount).toBe(-1000); // native fallback −value (no CHF basis yet) - expect(liabilityLeg.amountChf).toBeUndefined(); + 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 () => { 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 index 2e0827250a..9be440a790 100644 --- 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 @@ -75,10 +75,16 @@ describe('BuyCryptoConsumer', () => { return Promise.resolve(acc); }); - // gate lookup: countBy returns 1 when the gate is open (seq0 crypto_input / cutover marker exists). For the + // 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. - jest.spyOn(ledgerTxRepo, 'countBy').mockImplementation(() => Promise.resolve(gateOpen ? 1 : 0)); + // 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 @@ -263,16 +269,16 @@ describe('BuyCryptoConsumer', () => { 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 (countBy>0) opens the completion + // §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 (countBy>0) + // 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: countBy(crypto_input, 555, seq0) > 0 (gateOpen=true default) + cryptoInput: { id: 555 } as any, // G-a: existsBy(crypto_input, 555, seq0) true (gateOpen=true default) }), ]); await consumer.process(); @@ -287,14 +293,14 @@ describe('BuyCryptoConsumer', () => { // §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; // countBy → 0 AND ledgerCutoverLogId → undefined + gateOpen = false; // existsBy → false AND ledgerCutoverLogId → undefined mockBatch([ buyCrypto({ id: 22, amountInChf: 1000, totalFeeAmountChf: 10, isComplete: true, - cryptoInput: { id: 556 } as any, // G-a countBy → 0; cutoverReceivedSourceId → undefined (no logId) + cryptoInput: { id: 556 } as any, // G-a existsBy → false; cutoverReceivedSourceId → undefined (no logId) }), ]); await consumer.process(); @@ -375,7 +381,7 @@ describe('BuyCryptoConsumer', () => { if (key === 'ledgerCutoverSnapshotDate') return Promise.resolve('2026-06-07T22:00:00.000Z'); return Promise.resolve(undefined); }); - jest.spyOn(ledgerTxRepo, 'countBy').mockResolvedValue(0); // gate closed: neither G-a nor G-b opening + jest.spyOn(ledgerTxRepo, 'existsBy').mockResolvedValue(false); // gate closed: neither G-a nor G-b opening const settled = buyCrypto({ id: 45, amountInChf: 1000, @@ -461,7 +467,7 @@ describe('BuyCryptoConsumer', () => { // 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 (countBy) and G-b (cutover logId) both closed + 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({ 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 index 3fb4bd2771..442a8ad1b3 100644 --- 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 @@ -47,7 +47,7 @@ describe('BuyFiatConsumer', () => { let accounts: Map; let nextSeqValue: number; let activeKeys: Set; // `${sourceId}:${seq}` with an active booking — backs hasActiveTxAt (per-seq, R3) - let gateCount: number; // countBy result (received/cutover gate) + 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) @@ -61,7 +61,7 @@ describe('BuyFiatConsumer', () => { booked = []; nextSeqValue = 0; activeKeys = new Set(); - gateCount = 1; + gateOpen = true; seq0PaymentLinkChf = undefined; cutoverOwedOpeningChf = undefined; cutoverLogId = undefined; @@ -101,7 +101,7 @@ describe('BuyFiatConsumer', () => { return Promise.resolve(acc); }); - jest.spyOn(ledgerTxRepo, 'countBy').mockImplementation(() => Promise.resolve(gateCount)); + 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') { @@ -257,7 +257,7 @@ describe('BuyFiatConsumer', () => { 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) - gateCount = 0; // the received seq0 gate would NEVER open (the financing crypto_input settled pre-cutover) + gateOpen = false; // the received seq0 gate would NEVER open (the financing crypto_input settled pre-cutover) mockBatch([ buyFiat({ id: 6, @@ -297,7 +297,7 @@ describe('BuyFiatConsumer', () => { // §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 () => { - gateCount = 0; // neither G-a nor G-b + gateOpen = false; // neither G-a nor G-b const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); mockBatch([ buyFiat({ @@ -522,7 +522,7 @@ describe('BuyFiatConsumer', () => { // 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(); - gateCount = 1; // received gate OPEN → we reach buildReclassificationSeq1, not the gate skip + gateOpen = true; // received gate OPEN → we reach buildReclassificationSeq1, not the gate skip mockBatch([ buyFiat({ id: 12, @@ -767,13 +767,13 @@ describe('BuyFiatConsumer', () => { }); // 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. countBy returns 0 for the crypto_input G-a query and >0 for the cutover G-b query. + // 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 countBy runs + 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, 'countBy') - .mockImplementation(({ sourceType }: any) => Promise.resolve(sourceType === 'cutover' ? 1 : 0)); + .spyOn(ledgerTxRepo, 'existsBy') + .mockImplementation(({ sourceType }: any) => Promise.resolve(sourceType === 'cutover')); mockBatch([ buyFiat({ id: 21, @@ -850,7 +850,7 @@ describe('BuyFiatConsumer', () => { 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 - gateCount = 0; // received gate stays closed for this row (would never open via G-a) + 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( @@ -887,7 +887,7 @@ describe('BuyFiatConsumer', () => { // §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 () => { - gateCount = 0; // received gate closed → bookRegular returns false (book gate-blocked) + 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); @@ -947,7 +947,7 @@ describe('BuyFiatConsumer', () => { // 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(); - gateCount = 0; // received gate closed (no G-a/G-b) + gateOpen = false; // received gate closed (no G-a/G-b) jest .spyOn(settingService, 'get') .mockImplementation((key: string) => diff --git a/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts b/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts index 87eb2fd28b..149d32b6a7 100644 --- a/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts @@ -27,6 +27,8 @@ const CHF = 'CHF'; * 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 { @@ -70,6 +72,11 @@ export class BuyCryptoConsumer { 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 @@ -112,6 +119,12 @@ export class BuyCryptoConsumer { // 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 @@ -216,13 +229,11 @@ export class BuyCryptoConsumer { if (bc.bankTx && (await this.bookingService.hasActiveTxAt(BANK_TX_SOURCE, `${bc.bankTx.id}`, 0))) return true; const cryptoInputId = bc.cryptoInput?.id; - if (cryptoInputId != null) { - const ga = await this.ledgerTxRepo.countBy({ - sourceType: CRYPTO_INPUT_SOURCE, - sourceId: `${cryptoInputId}`, - seq: 0, - }); - if (ga > 0) return true; // G-a + 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). The cutover @@ -230,8 +241,7 @@ export class BuyCryptoConsumer { // `:buy_crypto:${id}` match would NEVER hit (the snapshot logId prefix is missing → G-b dead, Blocker R4-2). const cutoverSourceId = await this.cutoverReceivedSourceId(bc.id); if (cutoverSourceId == null) return false; // cutover not run yet → no opening to match - const gb = await this.ledgerTxRepo.countBy({ sourceType: CUTOVER_SOURCE, sourceId: cutoverSourceId }); - return gb > 0; + 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_crypto:${id}`. @@ -241,6 +251,18 @@ export class BuyCryptoConsumer { return cutoverLogId != null ? `${cutoverLogId}:buy_crypto:${buyCryptoId}` : undefined; } + // §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 — diff --git a/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts b/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts index c161694703..90987699bf 100644 --- a/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts @@ -424,13 +424,11 @@ export class BuyFiatConsumer { // 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) { - const ga = await this.ledgerTxRepo.countBy({ - sourceType: CRYPTO_INPUT_SOURCE, - sourceId: `${cryptoInputId}`, - seq: 0, - }); - if (ga > 0) return true; // G-a + 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 @@ -438,8 +436,7 @@ export class BuyFiatConsumer { // `: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 - const gb = await this.ledgerTxRepo.countBy({ sourceType: CUTOVER_SOURCE, sourceId: cutoverSourceId }); - return gb > 0; + 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}`. diff --git a/src/subdomains/core/accounting/services/ledger-booking-job.service.ts b/src/subdomains/core/accounting/services/ledger-booking-job.service.ts index 34baf21b05..7cdb37b54e 100644 --- a/src/subdomains/core/accounting/services/ledger-booking-job.service.ts +++ b/src/subdomains/core/accounting/services/ledger-booking-job.service.ts @@ -86,7 +86,7 @@ export class LedgerBookingJobService { } // §4.8 — bridge-only (skips exchange/DfxDex movements booked by their authoritative consumers) - @DfxCron(CronExpression.EVERY_MINUTE, { process: Process.LEDGER_BOOKING_LIQ_MGMT, timeout: 1800 }) + @DfxCron(CronExpression.EVERY_MINUTE, { process: Process.LEDGER_BOOKING_LIQUIDITY_MANAGEMENT, timeout: 1800 }) async runLiquidityMgmt(): Promise { if (!(await this.isLedgerReady())) return; await this.liquidityMgmtConsumer.process(); diff --git a/src/subdomains/core/accounting/services/ledger-booking.service.ts b/src/subdomains/core/accounting/services/ledger-booking.service.ts index 59636a8739..73f940c41a 100644 --- a/src/subdomains/core/accounting/services/ledger-booking.service.ts +++ b/src/subdomains/core/accounting/services/ledger-booking.service.ts @@ -225,7 +225,7 @@ export class LedgerBookingService { * walk the reversal chain — mere existence at the seq is the signal. */ async hasAnyTxAt(sourceType: string, sourceId: string, seq: number): Promise { - return (await this.dataSource.getRepository(LedgerTx).countBy({ sourceType, sourceId, seq })) > 0; + return this.dataSource.getRepository(LedgerTx).existsBy({ sourceType, sourceId, seq }); } /** diff --git a/src/subdomains/core/accounting/services/ledger-cutover.service.ts b/src/subdomains/core/accounting/services/ledger-cutover.service.ts index 155729b874..21ceca3cc9 100644 --- a/src/subdomains/core/accounting/services/ledger-cutover.service.ts +++ b/src/subdomains/core/accounting/services/ledger-cutover.service.ts @@ -145,7 +145,7 @@ export class LedgerCutoverService { 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`); + if (!log) throw new Error(`Pinned cutover snapshot FinancialDataLog #${pinned} no longer exists`); return log; } @@ -299,9 +299,13 @@ export class LedgerCutoverService { // 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); - if (fiatMark == null) continue; // no mark → cannot value the CHF-denominated liability; leave to forward path - const amountChf = Util.round(row.outputAmount * fiatMark, 2); + 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}`, @@ -547,29 +551,24 @@ export class LedgerCutoverService { if (!debts?.length) return; const manualDebt = await this.liability('manual-debt'); - let seq = 0; for (const position of debts) { - if (!position?.value) { - seq++; - continue; - } + 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; - await this.bookOpening( - seq++, + // 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}`, - snapshotDate, - { - account: manualDebt, - amount: -(amountChf ?? position.value), - priceChf: priceChf ?? null, - amountChf: amountChf != null ? -amountChf : undefined, - needsMark: amountChf == null, - }, + manualDebt, + amountChf, equity, ); } @@ -727,11 +726,11 @@ export class LedgerCutoverService { amountChf: number | undefined, equity: LedgerAccount, ): Promise { - // m6 fail-loud: a received/owed/unattributed 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. + // 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`, diff --git a/src/subdomains/core/accounting/services/ledger-query.service.ts b/src/subdomains/core/accounting/services/ledger-query.service.ts index f681415d8b..90a891c9f5 100644 --- a/src/subdomains/core/accounting/services/ledger-query.service.ts +++ b/src/subdomains/core/accounting/services/ledger-query.service.ts @@ -240,7 +240,7 @@ export class LedgerQueryService { // 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`); + 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: [] }; 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 299ad0c577..897fcb1bab 100644 --- a/src/subdomains/supporting/log/log.service.ts +++ b/src/subdomains/supporting/log/log.service.ts @@ -67,15 +67,8 @@ export class LogService { return this.logRepo.getLatestFinancialLog(); } - // 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.logRepo.find({ - where: { system: 'LogService', subsystem: 'FinancialDataLog', severity: LogSeverity.INFO, valid: true }, - order: { id: 'DESC' }, - take: count, - }); + return this.logRepo.getLatestValidFinancialLogs(count); } async getLatestFinancialChangesLog(): Promise { From e0a036702df121b127037df8c0e1e63ece560cce Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 15 Jul 2026 05:24:52 +0200 Subject: [PATCH 16/25] fix(accounting): sixth review pass (uniform settled-before-cutover guard, conventions) - every asset consumer now carries the settled-before-cutover guard, not just buy-crypto/buy-fiat: a pre-cutover-settled row re-selected by an updated bump can no longer re-book its seq0 on top of the aggregate opening (wallet double-count + phantom liability); the cutover persists a per-source boundary id as the durable signal - conventions: English-only comment leftovers, unused logger, capitalized plain-prose error messages, NotFoundException for unknown accounts, import order, Promise.all for independent reads, single ORed query for the unattributed opening --- .../__tests__/ledger-cutover.service.spec.ts | 48 +++++- .../__tests__/ledger-query.service.spec.ts | 10 +- .../__tests__/buy-crypto.consumer.spec.ts | 4 +- .../__tests__/crypto-input.consumer.spec.ts | 57 +++++++ .../__tests__/exchange-tx.consumer.spec.ts | 27 ++++ .../__tests__/liquidity-mgmt.consumer.spec.ts | 27 ++++ .../liquidity-order-dex.consumer.spec.ts | 19 +++ .../__tests__/trading-order.consumer.spec.ts | 19 +++ .../services/consumers/bank-tx.consumer.ts | 4 +- .../services/consumers/buy-crypto.consumer.ts | 2 +- .../services/consumers/buy-fiat.consumer.ts | 4 +- .../consumers/crypto-input.consumer.ts | 19 ++- .../consumers/exchange-tx.consumer.ts | 17 +- .../consumers/ledger-watermark.helper.ts | 61 +++++++ .../consumers/liquidity-mgmt.consumer.ts | 13 +- .../consumers/liquidity-order-dex.consumer.ts | 13 +- .../consumers/payout-order.consumer.ts | 2 +- .../consumers/trading-order.consumer.ts | 13 +- .../services/ledger-booking-job.service.ts | 3 - .../services/ledger-booking.service.ts | 4 +- .../services/ledger-cutover.service.ts | 149 ++++++++++++------ .../services/ledger-mark-to-market.service.ts | 2 +- .../services/ledger-query.service.ts | 50 +++--- .../services/ledger-reconciliation.service.ts | 10 +- 24 files changed, 460 insertions(+), 117 deletions(-) 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 index 7df7fc3611..d5747c7553 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts @@ -141,12 +141,14 @@ describe('LedgerCutoverService', () => { 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) + // 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, @@ -426,13 +428,11 @@ describe('LedgerCutoverService', () => { .mockResolvedValue([ Object.assign(new Bank(), { iban: 'EUR-IBAN', name: 'Olkypay', currency: 'EUR', asset: { id: 269 } }), ] as any); - // first find() = typed credits (GSheet/Pending/Unknown), second find() = NULL-type credits + // ONE find() serves both ORed where-branches (typed GSheet/Pending/Unknown credits + NULL-type credits) jest .spyOn(bankTxRepo, 'find') - .mockResolvedValueOnce([ + .mockResolvedValue([ Object.assign(new BankTx(), { id: 90, amount: 1000, accountIban: 'EUR-IBAN', currency: 'EUR' }), - ] as any) - .mockResolvedValueOnce([ Object.assign(new BankTx(), { id: 91, amount: 2000, accountIban: 'EUR-IBAN', currency: 'EUR' }), ] as any); @@ -533,6 +533,8 @@ describe('LedgerCutoverService', () => { 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; }; @@ -575,6 +577,42 @@ describe('LedgerCutoverService', () => { 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); + }); }); // --- SNAPSHOT SELECTION + PINNING (§6.3 / Major design-accounting R3-1) --- // 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 index c28a144327..3ac77c7744 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-query.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-query.service.spec.ts @@ -1,4 +1,4 @@ -import { BadRequestException } from '@nestjs/common'; +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'; @@ -632,14 +632,10 @@ describe('LedgerQueryService', () => { }); describe('getAccountDetail', () => { - it('returns an empty shell for an unknown account', async () => { + it('throws NotFoundException for an unknown account', async () => { jest.spyOn(ledgerAccountRepository, 'findOneBy').mockResolvedValue(null); - const res = await service.getAccountDetail(999); - - expect(res.legs).toHaveLength(0); - expect(res.total).toBe(0); - expect(res.openingBalance).toBe(0); + await expect(service.getAccountDetail(999)).rejects.toThrow(NotFoundException); }); it('maps legs with opening/closing balance and a 2-leg counter account', async () => { 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 index 9be440a790..3bc24fd4c8 100644 --- 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 @@ -323,8 +323,8 @@ describe('BuyCryptoConsumer', () => { // --- §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- - // Schliessung bricht) → the chain method reverses the whole active chain atomically. + // 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 () => { const chainSpy = jest.spyOn(bookingService, 'reverseAndRebookChainIfChanged').mockResolvedValue(true); const changed = buyCrypto({ 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 index 9f2af90111..abe5dd6a86 100644 --- 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 @@ -448,6 +448,63 @@ describe('CryptoInputConsumer', () => { 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` 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 index e8e20c1449..e2e3f8c495 100644 --- 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 @@ -581,6 +581,33 @@ describe('ExchangeTxConsumer', () => { 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 () => { 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 index f086bb011e..887187cd72 100644 --- 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 @@ -327,4 +327,31 @@ describe('LiquidityMgmtConsumer', () => { 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 index 366d46f563..325320c742 100644 --- 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 @@ -385,4 +385,23 @@ describe('LiquidityOrderDexConsumer', () => { 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__/trading-order.consumer.spec.ts b/src/subdomains/core/accounting/services/consumers/__tests__/trading-order.consumer.spec.ts index 4f54b9bb33..f9f1002098 100644 --- 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 @@ -299,4 +299,23 @@ describe('TradingOrderConsumer', () => { 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 index f346642fce..d3ebbb3b75 100644 --- a/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts @@ -619,7 +619,7 @@ export class BankTxConsumer { 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)`); + if (!account) throw new Error(`Ledger account for asset ${ctx.asset.id} not found (CoA bootstrap missing)`); return account; } @@ -634,7 +634,7 @@ export class BankTxConsumer { 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)`); + if (!account) throw new Error(`Ledger account Checkout/${currency} not found (CoA bootstrap missing)`); return account; } diff --git a/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts b/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts index 149d32b6a7..dbd12f1a14 100644 --- a/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts @@ -307,7 +307,7 @@ export class BuyCryptoConsumer { 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)`); + if (!account) throw new Error(`Ledger account Checkout/${currency} not found (CoA bootstrap missing)`); return account; } diff --git a/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts b/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts index 90987699bf..c98c6d849a 100644 --- a/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts @@ -80,7 +80,7 @@ export class BuyFiatConsumer { // 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-Schliessung bricht) — the chain method reverses the + // 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). @@ -507,7 +507,7 @@ export class BuyFiatConsumer { 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)`); + if (!account) throw new Error(`Ledger account for asset ${assetId} not found (CoA bootstrap missing)`); return account; } diff --git a/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts b/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts index 77e6c9fb8c..63141f9b95 100644 --- a/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts @@ -10,7 +10,12 @@ 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 { getLedgerWatermark, runContentChangeScan, setLedgerWatermark } from './ledger-watermark.helper'; +import { + getLedgerWatermark, + isCoveredByCutoverOpening, + runContentChangeScan, + setLedgerWatermark, +} from './ledger-watermark.helper'; const SOURCE_TYPE = 'crypto_input'; const CHF = 'CHF'; @@ -61,7 +66,15 @@ export class CryptoInputConsumer { // 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). - if (CryptoInputSettledStatus.includes(ci.status)) await this.book(ci, marks); + // §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); @@ -235,7 +248,7 @@ export class CryptoInputConsumer { 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)`); + if (!account) throw new Error(`Ledger account for asset ${ci.asset.id} not found (CoA bootstrap missing)`); return account; } diff --git a/src/subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts b/src/subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts index b6fcc6c21e..6692f98ba0 100644 --- a/src/subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts @@ -14,7 +14,12 @@ 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 { getLedgerWatermark, runContentChangeScan, setLedgerWatermark } from './ledger-watermark.helper'; +import { + getLedgerWatermark, + isCoveredByCutoverOpening, + runContentChangeScan, + setLedgerWatermark, +} from './ledger-watermark.helper'; const OK_STATUS = 'ok'; const SOURCE_TYPE = 'exchange_tx'; @@ -125,7 +130,13 @@ export class ExchangeTxConsumer { // 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). - if (!(await this.bookingService.hasAnyTxAt(spec.sourceType, spec.sourceId, spec.seq))) { + // §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); } } @@ -465,7 +476,7 @@ export class ExchangeTxConsumer { 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)`); + if (!account) throw new Error(`Ledger account ${exchange}/${ccy} not found (CoA bootstrap missing)`); return account; } diff --git a/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts b/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts index 54347ebd04..d73a013bb7 100644 --- a/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts +++ b/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts @@ -17,6 +17,13 @@ export interface LedgerWatermark { const WATERMARK_KEY_PREFIX = 'ledgerWatermark.'; +const CUTOVER_BOUNDARY_KEY_PREFIX = 'ledgerCutoverBoundary.'; + +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). @@ -58,6 +65,60 @@ export async function setLedgerWatermark( ); } +/** + * Writes the per-source cutover boundary (§6.3) — exclusively via `settingService.set` (never `setObj`/`settingRepo`; + * §4.10 R2-exception-a), materialised ONCE at cutover time. `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 }), + ); +} + +/** + * 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 materialises it per source as an + * immutable boundary (`setCutoverBoundary`) 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 +} + const contentChangeLogger = new DfxLogger('LedgerContentChangeScan'); /** diff --git a/src/subdomains/core/accounting/services/consumers/liquidity-mgmt.consumer.ts b/src/subdomains/core/accounting/services/consumers/liquidity-mgmt.consumer.ts index 155d02ebea..7cfc0db6cd 100644 --- a/src/subdomains/core/accounting/services/consumers/liquidity-mgmt.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/liquidity-mgmt.consumer.ts @@ -17,7 +17,12 @@ 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, runContentChangeScan, setLedgerWatermark } from './ledger-watermark.helper'; +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) @@ -71,6 +76,10 @@ export class LiquidityMgmtConsumer { // 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])); }, ); @@ -213,7 +222,7 @@ export class LiquidityMgmtConsumer { 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)`); + if (!account) throw new Error(`Ledger account for asset ${asset.id} not found (CoA bootstrap missing)`); return account; } 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 index 24126fd5c3..8235d6cff1 100644 --- a/src/subdomains/core/accounting/services/consumers/liquidity-order-dex.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/liquidity-order-dex.consumer.ts @@ -15,7 +15,12 @@ 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, runContentChangeScan, setLedgerWatermark } from './ledger-watermark.helper'; +import { + getLedgerWatermark, + isCoveredByCutoverOpening, + runContentChangeScan, + setLedgerWatermark, +} from './ledger-watermark.helper'; const SOURCE_TYPE = 'liquidity_order'; const BOOKED_TYPES = [LiquidityOrderType.PURCHASE, LiquidityOrderType.SELL]; @@ -81,6 +86,10 @@ export class LiquidityOrderDexConsumer { // 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])); }, ); @@ -264,7 +273,7 @@ export class LiquidityOrderDexConsumer { 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)`); + if (!account) throw new Error(`Ledger account for asset ${asset.id} not found (CoA bootstrap missing)`); return account; } diff --git a/src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts b/src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts index 782b6eea5a..3071b3216b 100644 --- a/src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts @@ -398,7 +398,7 @@ export class PayoutOrderConsumer { 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)`); + if (!account) throw new Error(`Ledger account for asset ${asset.id} not found (CoA bootstrap missing)`); return account; } diff --git a/src/subdomains/core/accounting/services/consumers/trading-order.consumer.ts b/src/subdomains/core/accounting/services/consumers/trading-order.consumer.ts index 394caffaca..2544db2594 100644 --- a/src/subdomains/core/accounting/services/consumers/trading-order.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/trading-order.consumer.ts @@ -12,7 +12,12 @@ 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, runContentChangeScan, setLedgerWatermark } from './ledger-watermark.helper'; +import { + getLedgerWatermark, + isCoveredByCutoverOpening, + runContentChangeScan, + setLedgerWatermark, +} from './ledger-watermark.helper'; const SOURCE_TYPE = 'trading_order'; const CHF = 'CHF'; @@ -64,6 +69,10 @@ export class TradingOrderConsumer { // 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])); }, ); @@ -189,7 +198,7 @@ export class TradingOrderConsumer { 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)`); + if (!account) throw new Error(`Ledger account for asset ${asset.id} not found (CoA bootstrap missing)`); return account; } diff --git a/src/subdomains/core/accounting/services/ledger-booking-job.service.ts b/src/subdomains/core/accounting/services/ledger-booking-job.service.ts index 7cdb37b54e..95a439ca0e 100644 --- a/src/subdomains/core/accounting/services/ledger-booking-job.service.ts +++ b/src/subdomains/core/accounting/services/ledger-booking-job.service.ts @@ -1,7 +1,6 @@ import { Injectable } from '@nestjs/common'; import { CronExpression } from '@nestjs/schedule'; 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 { BankTxConsumer } from './consumers/bank-tx.consumer'; @@ -28,8 +27,6 @@ const CUTOVER_LOG_ID_KEY = 'ledgerCutoverLogId'; */ @Injectable() export class LedgerBookingJobService { - private readonly logger = new DfxLogger(LedgerBookingJobService); - constructor( private readonly settingService: SettingService, private readonly bankTxConsumer: BankTxConsumer, diff --git a/src/subdomains/core/accounting/services/ledger-booking.service.ts b/src/subdomains/core/accounting/services/ledger-booking.service.ts index 73f940c41a..5307533266 100644 --- a/src/subdomains/core/accounting/services/ledger-booking.service.ts +++ b/src/subdomains/core/accounting/services/ledger-booking.service.ts @@ -154,8 +154,8 @@ export class LedgerBookingService { * §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-Schliessung - * bricht). This reverses the WHOLE currently-active chain and re-books every input atomically, but ONLY when at least + * 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 diff --git a/src/subdomains/core/accounting/services/ledger-cutover.service.ts b/src/subdomains/core/accounting/services/ledger-cutover.service.ts index 21ceca3cc9..4b2cc0ef81 100644 --- a/src/subdomains/core/accounting/services/ledger-cutover.service.ts +++ b/src/subdomains/core/accounting/services/ledger-cutover.service.ts @@ -34,6 +34,7 @@ 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 { setCutoverBoundary } 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 @@ -481,12 +482,15 @@ export class LedgerCutoverService { equity: LedgerAccount, bankByIban: Map, ): Promise { - // §6.1: type NULL/Pending/Unknown/GSheet credits → the unattributed bucket (two where-branches for the NULL type) + // §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) } })), - ...(await this.bankTxRepo.find({ where: { ...credit, type: IsNull() } })), - ]; + const rows = await this.bankTxRepo.find({ + where: [ + { ...credit, type: In(UNATTRIBUTED_TYPES) }, + { ...credit, type: IsNull() }, + ], + }); let amountChf = 0; let needsMark = false; @@ -583,75 +587,86 @@ export class LedgerCutoverService { // 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 { - const sources: { source: string; maxId: () => 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. Reusing one filter guarantees a + // settled-at-snapshot row is classified identically on both sides → it can never be misread as an open hole + // (which would re-book its seq0 post-cutover → double-count the ASSET + phantom liability, §6.3 the bug fixed here). + 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, (qb) => - qb.andWhere('e.status IN (:...ciStatus)', { ciStatus: CryptoInputSettledStatus }), - ), + 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, (qb) => - qb.andWhere('e.status = :poStatus', { poStatus: PayoutOrderStatus.COMPLETE }), - ), + 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, (qb) => - qb.andWhere('e.status = :etStatus', { etStatus: 'ok' }), - ), + 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, (qb) => - qb.andWhere('e.status = :lmStatus', { lmStatus: LiquidityManagementOrderStatus.COMPLETE }), - ), + 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, (qb) => - qb - .andWhere('e.status = :toStatus', { toStatus: TradingOrderStatus.COMPLETE }) - .andWhere('e.txId IS NOT NULL'), - ), + 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, (qb) => - 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], - }), - ), + maxId: () => this.maxSettledId(this.liquidityOrderRepo, 'updated', snapshotDate, loFilter), + holeIds: (b) => this.openHoleIds(this.liquidityOrderRepo, b, snapshotDate, 'updated', loFilter), }, ]; - for (const { source, maxId } of sources) { - await this.setWatermark(source, await maxId(), snapshotDate); + for (const { source, maxId, holeIds } of sources) { + const boundaryId = await maxId(); + await this.setWatermark(source, boundaryId, snapshotDate); + // persist the immutable cutover boundary for the guard sources — the watermark value above is untouched + if (holeIds) + await setCutoverBoundary(this.settingService, source, { boundaryId, holeIds: await holeIds(boundaryId) }); } } @@ -676,6 +691,48 @@ export class LedgerCutoverService { 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 so a + // settled-at-snapshot row can never be misclassified as a hole (which would re-book 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}`, @@ -733,7 +790,7 @@ export class LedgerCutoverService { // 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`, + `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`, ); } 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 index 4878302e75..473b5febdc 100644 --- a/src/subdomains/core/accounting/services/ledger-mark-to-market.service.ts +++ b/src/subdomains/core/accounting/services/ledger-mark-to-market.service.ts @@ -9,9 +9,9 @@ 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 { LedgerAccountService } from './ledger-account.service'; import { LedgerMarkCache, LedgerMarkService } from './ledger-mark.service'; const SOURCE_TYPE = 'mark_to_market'; diff --git a/src/subdomains/core/accounting/services/ledger-query.service.ts b/src/subdomains/core/accounting/services/ledger-query.service.ts index 90a891c9f5..4180c3adbe 100644 --- a/src/subdomains/core/accounting/services/ledger-query.service.ts +++ b/src/subdomains/core/accounting/services/ledger-query.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +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'; @@ -82,14 +82,15 @@ export class LedgerQueryService { const now = new Date(); const period = this.resolvePeriod(from, to, now); - const accounts = await this.ledgerAccountRepository.find({ relations: { asset: { bank: true } } }); - const balances = await this.balancesByAccount(period.to); - - // ASSET-account recon snapshot for the list (against the persisted feed, §7) — feed read once for all accounts - const feedByAssetId = await this.feedByAssetId(); + // 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 marks = await this.markService.preload(Util.daysBefore(2, now), now); + 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 = { @@ -111,22 +112,13 @@ export class LedgerQueryService { const period = this.resolvePeriod(from, to, now); const account = await this.ledgerAccountRepository.findOneBy({ id: accountId }); - if (!account) { - return { - accountId, - accountName: '', - currency: '', - period: LedgerDtoMapper.mapPeriod(period.from, period.to), - openingBalance: 0, - closingBalance: 0, - legs: [], - total: 0, - }; - } + if (!account) throw new NotFoundException('Ledger account not found'); // opening balance = signed native Σ of all legs booked strictly before the period start - const openingBalance = await this.nativeBalanceBefore(accountId, period.from); - const periodNative = await this.nativeBalanceInPeriod(accountId, period.from, period.to); + 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 @@ -160,14 +152,16 @@ export class LedgerQueryService { async getReconStatus(): 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(); - const balances = await this.nativeBalanceByAccount(); // §7 (unit fix): marks read ONCE per request (same 2-day window as the reconciliation job) — see mapReconStatus - const marks = await this.markService.preload(Util.daysBefore(2, now), now); + 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) { diff --git a/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts b/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts index 9a0f0e4def..3aaebb40a0 100644 --- a/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts +++ b/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts @@ -99,15 +99,15 @@ export class LedgerReconciliationService { const now = new Date(); // §7.0: feed read ONCE per run, held in-memory for all batches (never per-batch, Minor R13-2) - const feed = await this.liquidityManagementBalanceService.getBalances(); - // §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). - const marks = await this.markService.preload(Util.daysBefore(2, now), now); - // §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 balances = await this.nativeBalanceByAccount(); + 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); From fc3b922662b92ee3b9a679f465413c6d763c5715 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 15 Jul 2026 06:45:28 +0200 Subject: [PATCH 17/25] fix(accounting): seventh review pass (pinned cutover boundary, checkout native units, custody classes) - the cutover pins boundary ids and hole ids set-once right after pinning the snapshot, before any opening: a fail-loud retry hours later can no longer widen the settled-classification window and double-book rows whose updated was bumped in between; the remaining seconds-wide residual is documented instead of claimed impossible - Checkout custody accounts carry one native convention (gross in card currency, CHF mark-consistent): the card seq0 books inputReferenceAmount and the CheckoutLtd settlement books gross native, so mark-to-market no longer flushes the acquirer fee as phantom fx pnl double-counted in the margin report - custody classification uses the objective blockchain lists (XT/MEXC were falling back to on-chain thresholds, frozen bank assets to 24h): BankDead maps to OlkyFrozen; the unreachable order-driven/feedless exchange classes are removed with their thresholds --- .../buy-crypto-cutover.integration.spec.ts | 1 + .../checkout-margin.integration.spec.ts | 159 ++++++++++++++++++ .../staleness-cutover.integration.spec.ts | 25 +-- .../__tests__/ledger-cutover.service.spec.ts | 66 ++++++++ .../__tests__/ledger-query.service.spec.ts | 17 ++ .../ledger-reconciliation.service.spec.ts | 57 ++++++- .../__tests__/bank-tx.consumer.spec.ts | 70 +++++++- .../__tests__/buy-crypto.consumer.spec.ts | 35 +++- .../__tests__/ledger-watermark.helper.spec.ts | 31 +++- .../services/consumers/bank-tx.consumer.ts | 39 ++++- .../services/consumers/buy-crypto.consumer.ts | 26 ++- .../consumers/ledger-watermark.helper.ts | 37 +++- .../consumers/liquidity-order-dex.consumer.ts | 2 +- .../consumers/trading-order.consumer.ts | 2 +- .../services/ledger-cutover.service.ts | 69 +++++--- .../services/ledger-reconciliation.service.ts | 42 +++-- 16 files changed, 596 insertions(+), 82 deletions(-) create mode 100644 src/subdomains/core/accounting/services/__tests__/integration/checkout-margin.integration.spec.ts 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 index 955b5d6639..e5f71531db 100644 --- 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 @@ -46,6 +46,7 @@ describe('Ledger buy_crypto cutover Card double-book (§10.2, MAJOR)', () => { 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, }); } 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/staleness-cutover.integration.spec.ts b/src/subdomains/core/accounting/services/__tests__/integration/staleness-cutover.integration.spec.ts index 41047aa1b2..4c63d7e31e 100644 --- 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 @@ -227,11 +227,13 @@ describe('Ledger staleness + cutover integration (§10.2)', () => { 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). + // (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('0'); + return Promise.resolve(undefined as any); }); jest.spyOn(settingService, 'set').mockImplementation((key: string, value: string) => { if (key === 'ledgerCutoverLogId') cutoverFlag = value; @@ -341,23 +343,23 @@ describe('Ledger staleness + cutover integration (§10.2)', () => { }); 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 (4) initWatermarks AFTER all openings committed but BEFORE the flag is set — run() now - // propagates the error to @DfxCron's lock layer (no redundant in-method catch); the flag stays unset, the - // openings + the snapshot pin remain committed. + // 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 (4) — crash there once, simulating the partial-cutover scenario + // 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) before any opening) - const firstRunBookings = booked.length; - expect(firstRunBookings).toBeGreaterThan(0); // at least the ASSET opening committed before the crash + 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 → @@ -375,9 +377,12 @@ describe('Ledger staleness + cutover integration (§10.2)', () => { 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).toHaveLength(firstRunBookings); // openings booked exactly once (no double-count via the drift) + 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-cutover.service.spec.ts b/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts index d5747c7553..87a99d168b 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts @@ -613,6 +613,72 @@ describe('LedgerCutoverService', () => { // 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 + }); }); // --- SNAPSHOT SELECTION + PINNING (§6.3 / Major design-accounting R3-1) --- // 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 index 3ac77c7744..7a14d76e27 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-query.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-query.service.spec.ts @@ -488,6 +488,23 @@ describe('LedgerQueryService', () => { 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. 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 index a8617e10b9..891b10a352 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-reconciliation.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-reconciliation.service.spec.ts @@ -22,7 +22,7 @@ import { LedgerAccountRepository } from '../../repositories/ledger-account.repos import { LedgerLegRepository } from '../../repositories/ledger-leg.repository'; import { LedgerBookingJobService } from '../ledger-booking-job.service'; import { LedgerMarkService } from '../ledger-mark.service'; -import { FeedStatus, LedgerReconciliationService } from '../ledger-reconciliation.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) @@ -657,19 +657,60 @@ describe('LedgerReconciliationService', () => { describe('classification + run error branches', () => { const now = new Date('2026-06-11T12:00:00Z'); - // §7.1: an EXCHANGE_FEEDLESS custody (Kraken/Binance blockchain) is unverified from start → NO_FEED even with a - // present, fresh balance (the source line 173 branch). classifyCustody maps KRAKEN/BINANCE → EXCHANGE_ACTIVE, so to - // reach EXCHANGE_FEEDLESS we drive classifyFeed against an account whose custodyClass is EXCHANGE_FEEDLESS via a - // 0-threshold; simplest deterministic trigger: a Kraken-blockchain asset has EXCHANGE_ACTIVE (4h) — instead assert - // the documented feedless path by a balance whose age exceeds the threshold is already covered; here we cover the - // EXCHANGE custody branch (non-on-chain) explicitly. + // §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.thresholdHours).toBe(4); // EXCHANGE_ACTIVE + 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); 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 index abd22fd88d..9e4cff4ab5 100644 --- 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 @@ -592,7 +592,7 @@ describe('BankTxConsumer', () => { expect(cents(legs)).toBe(0); }); - it('books CHECKOUT_LTD CRDT: ASSET/bank net + EXPENSE/acquirer-fee / ASSET/Checkout gross (CHF cross-asset)', async () => { + 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({ @@ -609,10 +609,75 @@ describe('BankTxConsumer', () => { 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 = net + fee (Minor R3-5) + 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) + }); + it('books GSHEET CRDT to LIABILITY/unattributed (EUR-mark in both legs)', async () => { mockBatch([ bankTx({ @@ -817,6 +882,7 @@ describe('BankTxConsumer', () => { 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); }); 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 index 3bc24fd4c8..260b2bc4dc 100644 --- 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 @@ -3,6 +3,7 @@ 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'; @@ -123,17 +124,37 @@ describe('BuyCryptoConsumer', () => { expect(consumer).toBeDefined(); }); - // §4.6 seq0 — Card input only: Dr ASSET/Checkout / Cr LIABILITY/buyCrypto-received (= amountInChf) + // §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 () => { - mockBatch([buyCrypto({ id: 1, amountInChf: 1000, checkoutTx: { currency: 'EUR' } as any })]); + mockBatch([ + buyCrypto({ id: 1, amountInChf: 1000, inputReferenceAmount: 1050, checkoutTx: { currency: 'EUR' } as any }), + ]); await consumer.process(); const tx = seq(0); - expect(leg(tx, 'Checkout/EUR').amountChf).toBe(1000); - expect(leg(tx, 'LIABILITY/buyCrypto-received').amountChf).toBe(-1000); + 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 () => { + 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(); @@ -262,7 +283,9 @@ describe('BuyCryptoConsumer', () => { it('stops the batch and leaves the watermark when the Checkout account is missing (failure-isolation)', async () => { const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); accounts.delete('Checkout/EUR'); // findByName('Checkout/EUR') → undefined → throw in checkoutAccount - mockBatch([buyCrypto({ id: 20, amountInChf: 1000, checkoutTx: { currency: 'EUR' } as any })]); + mockBatch([ + buyCrypto({ id: 20, amountInChf: 1000, inputReferenceAmount: 1050, checkoutTx: { currency: 'EUR' } as any }), + ]); await consumer.process(); expect(booked).toHaveLength(0); @@ -330,6 +353,7 @@ describe('BuyCryptoConsumer', () => { 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 @@ -439,6 +463,7 @@ describe('BuyCryptoConsumer', () => { 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 → NOT covered by the opening 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 index 67f55892e4..a61a086b8b 100644 --- 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 @@ -2,7 +2,7 @@ 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 { LedgerWatermark, runContentChangeScan } from '../ledger-watermark.helper'; +import { getCutoverBoundary, LedgerWatermark, runContentChangeScan } from '../ledger-watermark.helper'; interface Row { id: number; @@ -119,3 +119,32 @@ describe('runContentChangeScan combined (updated, id) cursor', () => { 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/bank-tx.consumer.ts b/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts index d3ebbb3b75..bfc6071e8e 100644 --- a/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts @@ -514,7 +514,22 @@ export class BankTxConsumer { 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 / Cr ASSET/Checkout (gross), CHF-only + // §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; + // 2. chargeAmount persisted in the settlement currency (chargeCurrency === tx.currency) → 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 custody leg books the CORRECT + // gross native + needsMark → the mark-to-market job revalues it later against the correct gross. private async checkoutLtdLegs( tx: BankTx, ctx: BankContext, @@ -528,12 +543,28 @@ export class BankTxConsumer { if (feeChf !== 0) legs.push(this.namedLeg(await this.expense('acquirer-fee'), feeChf)); - // gross Checkout custody Cr-leg: no own *Chf → CHF = net + fee (both CHF-known), else needsMark (Minor R3-5) + // 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 === tx.currency) { + feeNative = tx.chargeAmount; // ladder 2: fee persisted in the settlement currency — use it directly + } 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: -tx.amount, - priceChf: null, + amount: -Util.round(tx.amount + feeNative, 8), + priceChf: bank.priceChf, amountChf: grossChf != null ? -grossChf : undefined, needsMark: grossChf == null, }); diff --git a/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts b/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts index dbd12f1a14..c87136d7dd 100644 --- a/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts @@ -137,7 +137,10 @@ export class BuyCryptoConsumer { return true; } - // §4.6 seq0 — Card input only: Dr ASSET/Checkout{ccy} / Cr LIABILITY/buyCrypto-received (= amountInChf). + // §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; @@ -156,6 +159,14 @@ export class BuyCryptoConsumer { // cutover opening is excluded, so the forward seq0 IS their opening) and post-cutover Card rows get a forward seq0. if (await this.settledBeforeCutover(bc)) 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'); @@ -165,7 +176,18 @@ export class BuyCryptoConsumer { seq: 0, bookingDate: bc.created, valueDate: bc.created, - legs: [this.chfLeg(checkout, bc.amountInChf), this.chfLeg(received, -bc.amountInChf)], + 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) + ], }; } diff --git a/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts b/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts index d73a013bb7..f8698c97e7 100644 --- a/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts +++ b/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts @@ -67,11 +67,13 @@ export async function setLedgerWatermark( /** * Writes the per-source cutover boundary (§6.3) — exclusively via `settingService.set` (never `setObj`/`settingRepo`; - * §4.10 R2-exception-a), materialised ONCE at cutover time. `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. + * §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, @@ -84,12 +86,29 @@ export async function setCutoverBoundary( ); } +/** + * 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 materialises it per source as an - * immutable boundary (`setCutoverBoundary`) 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): + * 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 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 index 8235d6cff1..bb3fa36e6d 100644 --- a/src/subdomains/core/accounting/services/consumers/liquidity-order-dex.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/liquidity-order-dex.consumer.ts @@ -37,7 +37,7 @@ const BOOKED_CONTEXTS = [ ]; /** - * §4.8a LiquidityOrderDex consumer (NEU, D14 §B.3/§B.6, D04 §7.2/§7.3; Blocker R5-1). Authoritative for DfxDex + * §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_*. * diff --git a/src/subdomains/core/accounting/services/consumers/trading-order.consumer.ts b/src/subdomains/core/accounting/services/consumers/trading-order.consumer.ts index 2544db2594..8f07c0ee22 100644 --- a/src/subdomains/core/accounting/services/consumers/trading-order.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/trading-order.consumer.ts @@ -25,7 +25,7 @@ const CHF = 'CHF'; const SWAP_VENUE = 'DfxDex'; /** - * §4.9 TradingOrder consumer (NEU, D10 D, D14 B.4; Blocker R1-3). Books DFX arbitrage swaps. Pure observer: reads + * §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). * diff --git a/src/subdomains/core/accounting/services/ledger-cutover.service.ts b/src/subdomains/core/accounting/services/ledger-cutover.service.ts index 4b2cc0ef81..4ddef82543 100644 --- a/src/subdomains/core/accounting/services/ledger-cutover.service.ts +++ b/src/subdomains/core/accounting/services/ledger-cutover.service.ts @@ -34,7 +34,7 @@ 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 { setCutoverBoundary } from './consumers/ledger-watermark.helper'; +import { getCutoverBoundary, setCutoverBoundary } 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 @@ -110,17 +110,21 @@ export class LedgerCutoverService { 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(); - // (3) ASSET openings → LIABILITY openings → Manual openings (TRANSIT stays 0) + // (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); - // (4) initialise consumer watermarks atomically — only rows settled at/before the snapshot (Blocker R3-1) - await this.initWatermarks(snapshotDate); - // 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. @@ -578,19 +582,23 @@ export class LedgerCutoverService { } } - // --- WATERMARK INIT (§6.3 step 4, Blocker R3-1) --- // + // --- WATERMARK INIT (§6.3 step 3, Blocker R3-1) --- // - // sets each ledgerWatermark. to MAX(id) of pre-cutover settled rows + lastReversalScan = snapshotDate, - // 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). + // 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. Reusing one filter guarantees a - // settled-at-snapshot row is classified identically on both sides → it can never be misread as an open hole - // (which would re-book its seq0 post-cutover → double-count the ASSET + phantom liability, §6.3 the bug fixed here). + // 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) => @@ -661,12 +669,29 @@ export class LedgerCutoverService { }, ]; + // 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: within the FIRST run a tiny window remains between the snapshot's `created` timestamp and this pin — + // a guard-source row settled at-or-before the snapshot whose `updated` is bumped inside that window is still + // misclassified. It is bounded to first-run execution latency (seconds) and, unlike the pre-fix retry window + // (potentially hours), CANNOT grow across retries → deliberately not closed with a heavier snapshot/lock mechanism. for (const { source, maxId, holeIds } of sources) { - const boundaryId = await maxId(); - await this.setWatermark(source, boundaryId, snapshotDate); - // persist the immutable cutover boundary for the guard sources — the watermark value above is untouched - if (holeIds) + 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 + } } } @@ -692,8 +717,10 @@ export class LedgerCutoverService { } // §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 so a - // settled-at-snapshot row can never be misclassified as a hole (which would re-book it post-cutover → double-count). + // = (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, diff --git a/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts b/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts index 3aaebb40a0..7af2b9372b 100644 --- a/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts +++ b/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts @@ -36,15 +36,25 @@ export enum FeedStatus { 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) -enum CustodyClass { +export enum CustodyClass { BANK_ACTIVE = 'BankActive', BANK_DEAD = 'BankDead', ON_CHAIN_ACTIVE = 'OnChainActive', ON_CHAIN_INACTIVE = 'OnChainInactive', EXCHANGE_ACTIVE = 'ExchangeActive', - EXCHANGE_ORDER_DRIVEN = 'ExchangeOrderDriven', - EXCHANGE_FEEDLESS = 'ExchangeFeedless', } const STALENESS_THRESHOLD_HOURS: Record = { @@ -53,8 +63,6 @@ const STALENESS_THRESHOLD_HOURS: Record = { [CustodyClass.ON_CHAIN_ACTIVE]: 4, [CustodyClass.ON_CHAIN_INACTIVE]: 24, [CustodyClass.EXCHANGE_ACTIVE]: 4, - [CustodyClass.EXCHANGE_ORDER_DRIVEN]: 48, - [CustodyClass.EXCHANGE_FEEDLESS]: 0, // unverified from start }; export interface FeedClassification { @@ -198,9 +206,6 @@ export class LedgerReconciliationService { if (balance.amount === PLACEHOLDER_AMOUNT) { return { status: FeedStatus.PLACEHOLDER, custodyClass, thresholdHours }; } - if (custodyClass === CustodyClass.EXCHANGE_FEEDLESS) { - return { status: FeedStatus.NO_FEED, custodyClass, thresholdHours }; // unverified from start (§7.1) - } const ageHours = Util.hoursDiff(balance.updated, now); return { @@ -210,18 +215,19 @@ export class LedgerReconciliationService { }; } - // §7.1 custody-type → class. On-chain assets are blockchain-backed; bank/exchange assets are CUSTODY rows. + // §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; - - // bank custody (asset linked to a Bank) → SEPA active threshold - if (asset.bank) return CustodyClass.BANK_ACTIVE; - - // exchange/feedless custody rows carry a non-blockchain custody marker - const blockchain = asset.blockchain; - const isOnChain = blockchain != null && blockchain !== Blockchain.KRAKEN && blockchain !== Blockchain.BINANCE; - - return isOnChain ? CustodyClass.ON_CHAIN_ACTIVE : CustodyClass.EXCHANGE_ACTIVE; + 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). From 5435ebd833f84526353ca15b0f339e4e6b928cbd Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 15 Jul 2026 07:04:21 +0200 Subject: [PATCH 18/25] fix(accounting): harden checkout fee ladder and pin-resume semantics - ladder 2 requires an explicit non-null charge currency matching the custody account unit (a null==null match could smuggle an unverified unit in); mismatches fall through to the mark bridge - document that a fee not yet CHF-priced books net-as-gross temporarily and self-heals via the content-change rebook once priced - mid-loop resume test: a crash between boundary write and watermark write re-derives the watermark from the pinned boundary, never a fresh MAX(id) --- .../__tests__/ledger-cutover.service.spec.ts | 41 +++++++++++++++++++ .../services/consumers/bank-tx.consumer.ts | 14 +++++-- .../services/ledger-cutover.service.ts | 10 +++-- 3 files changed, 58 insertions(+), 7 deletions(-) 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 index 87a99d168b..4037748292 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts @@ -679,6 +679,47 @@ describe('LedgerCutoverService', () => { 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) --- // diff --git a/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts b/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts index bfc6071e8e..535c491042 100644 --- a/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts @@ -521,8 +521,11 @@ export class BankTxConsumer { // 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; - // 2. chargeAmount persisted in the settlement currency (chargeCurrency === tx.currency) → use it directly; + // 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; @@ -547,8 +550,11 @@ export class BankTxConsumer { let feeNative: number; if (feeChf === 0) { feeNative = 0; - } else if (tx.chargeAmount != null && tx.chargeCurrency === tx.currency) { - feeNative = tx.chargeAmount; // ladder 2: fee persisted in the settlement currency — use it directly + } 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 { diff --git a/src/subdomains/core/accounting/services/ledger-cutover.service.ts b/src/subdomains/core/accounting/services/ledger-cutover.service.ts index 4ddef82543..c739ac52cc 100644 --- a/src/subdomains/core/accounting/services/ledger-cutover.service.ts +++ b/src/subdomains/core/accounting/services/ledger-cutover.service.ts @@ -672,10 +672,14 @@ export class LedgerCutoverService { // 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: within the FIRST run a tiny window remains between the snapshot's `created` timestamp and this pin — + // 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. It is bounded to first-run execution latency (seconds) and, unlike the pre-fix retry window - // (potentially hours), CANNOT grow across retries → deliberately not closed with a heavier snapshot/lock mechanism. + // 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; From bf54b9927c9f709c8752841ba8a5bf68a712e3f1 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:34:59 +0200 Subject: [PATCH 19/25] fix(accounting): eighth review pass (card collateral cutover, mark bridge, stable fill ranking) - open-at-cutover card rows get a per-row received opening: the Checkout collateral feed already carries their gross, so the forward seq0 gates on the cutover marker instead of double-booking onto the aggregate opening - asset openings book under per-asset source ids; a retry after a partial crash can no longer shift sequence slots when the chart of accounts grew in between - exchange trade source ids carry the venue prefix and the fill ranking is status-agnostic, so an ok-to-failed flip neither collides nor reshuffles surviving fills - mixed transactions with a missing historical mark value the open leg at the latest available mark (warn-logged, needsMark kept) or defer the row loudly - bookTx never receives an unbalanceable leg set and the head of the line can no longer wedge - fail-loud guards for null trade cost, null output amounts and CHF-derived forward fees; isolation gate covers upsert/soft deletes and fails closed on an empty scan --- scripts/ledger-isolation-gate.js | 12 +- scripts/ledger-isolation-gate.sh | 12 +- .../buy-crypto-cutover.integration.spec.ts | 56 ++++++++-- .../__tests__/ledger-cutover.service.spec.ts | 69 +++++++++--- .../__tests__/ledger-mark.service.spec.ts | 34 ++++++ .../__tests__/bank-tx.consumer.spec.ts | 15 +-- .../__tests__/buy-crypto.consumer.spec.ts | 52 ++++++++- .../__tests__/buy-fiat.consumer.spec.ts | 95 ++++++++++------ .../__tests__/crypto-input.consumer.spec.ts | 104 +++++++++++++----- .../__tests__/exchange-tx.consumer.spec.ts | 88 +++++++++++---- .../__tests__/liquidity-mgmt.consumer.spec.ts | 1 + .../liquidity-order-dex.consumer.spec.ts | 45 ++++---- .../__tests__/payout-order.consumer.spec.ts | 80 +++++++++----- .../__tests__/trading-order.consumer.spec.ts | 34 +++++- .../services/consumers/bank-tx.consumer.ts | 24 ++-- .../services/consumers/buy-crypto.consumer.ts | 45 +++++--- .../services/consumers/buy-fiat.consumer.ts | 39 +++++-- .../consumers/crypto-input.consumer.ts | 47 ++++++-- .../consumers/exchange-tx.consumer.ts | 38 +++++-- .../consumers/ledger-mark-bridge.helper.ts | 59 ++++++++++ .../consumers/liquidity-order-dex.consumer.ts | 11 +- .../consumers/payout-order.consumer.ts | 13 ++- .../consumers/trading-order.consumer.ts | 13 ++- .../services/ledger-cutover.service.ts | 34 +++--- .../services/ledger-mark.service.ts | 43 ++++++++ .../services/ledger-query.service.ts | 1 - 26 files changed, 804 insertions(+), 260 deletions(-) create mode 100644 src/subdomains/core/accounting/services/consumers/ledger-mark-bridge.helper.ts diff --git a/scripts/ledger-isolation-gate.js b/scripts/ledger-isolation-gate.js index c4c19cf551..4922155c84 100644 --- a/scripts/ledger-isolation-gate.js +++ b/scripts/ledger-isolation-gate.js @@ -39,7 +39,7 @@ const NON_ALLOWLISTABLE_PATTERN = new RegExp( const ALLOWLISTABLE_WRITE_PATTERN = new RegExp( [ // Block 4a/4b (non-ledger repository write) - 'balanceRepo\\.(update|save|insert|delete|remove|increment|decrement)\\(|\\b(?!ledger)\\w*Repo(sitory)?\\.(update|save|insert|delete|remove|increment|decrement)\\(', + '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 @@ -103,6 +103,16 @@ if (stat?.isDirectory()) { 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) => { diff --git a/scripts/ledger-isolation-gate.sh b/scripts/ledger-isolation-gate.sh index e8d72c196a..f143a953b8 100755 --- a/scripts/ledger-isolation-gate.sh +++ b/scripts/ledger-isolation-gate.sh @@ -32,6 +32,16 @@ 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 @@ -44,7 +54,7 @@ NON_ALLOWLISTABLE_PATTERN+='|\.complete\(|checkOrderCompletion|syncExchanges|doP # 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|increment|decrement)\(|\b(?!ledger)\w*Repo(sitory)?\.(update|save|insert|delete|remove|increment|decrement)\(' +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 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 index e5f71531db..9716df21bd 100644 --- 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 @@ -10,8 +10,11 @@ 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: the per-row cutover opening is EXCLUDED for Card, so the forward consumer's seq0 IS - * the card opening — after completion seq0 + seq1 close LIABILITY/buyCrypto-received to 0 (pre-fix: −amountInChf); + * (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). @@ -102,28 +105,59 @@ describe('Ledger buy_crypto cutover Card double-book (§10.2, MAJOR)', () => { }); } - it('Scenario A: an open Card row completing after the cutover books seq0+seq1 → received closes to 0', async () => { - // the cutover did NOT open a per-row received for this Card row (Card excluded); it completes post-cutover, so the - // content-change scan books the fresh seq0 (the card opening) + seq1 (fee/reclass) → received nets to 0. + // 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 → NOT covered by the aggregate opening + outputDate: POST_CUTOVER, // completed AFTER the snapshot → completion booked post-cutover updated: POST_CUTOVER, }); await consumer(row, 100).process(); - // seq0 (card opening) + seq1 (completion) both booked - expect(ledger.txs.some((t) => t.sourceType === 'buy_crypto' && t.sourceId === '70' && t.seq === 0)).toBe(true); + // 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 (seq0), debited +10 fee + +990 reclass (seq1) → closes to 0 (NOT −1000, the pre-fix bug) + // 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); the Card custody is debited exactly once by amountInChf + // owed = −(amountInChf − fee) expect(ledger.chfBalance('LIABILITY/buyCrypto-owed')).toBe(-990); - expect(ledger.chfBalance('Checkout/EUR')).toBe(1000); + // 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); }); 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 index 4037748292..0c6935130c 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts @@ -220,6 +220,44 @@ describe('LedgerCutoverService', () => { 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)', () => { @@ -915,30 +953,32 @@ describe('LedgerCutoverService', () => { expect(booked.some((b) => b.sourceId === '1557344:buy_crypto:61')).toBe(false); // null amountInChf → skipped }); - // MAJOR (double-book guard): Card inputs (checkoutTx != null) MUST be excluded from the per-row buyCrypto-received - // opening — card received is booked by the forward consumer's seq0 (buy-crypto.consumer bookCardInput). A per-row - // opening here would double-book received across the cutover boundary (the content-change scan re-books seq0 after - // completion → received ends at −amountInChf + a phantom EQUITY opening). Asserts the query passes checkoutTx IS - // NULL (else the mock returns the Card row) AND that the Card row gets no opening. - it('excludes Card inputs (checkoutTx) from the per-row buyCrypto-received opening', async () => { + // 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 now also passes checkoutTx IS NULL → simulate the DB filter: - // when the where carries checkoutTx, the Card row (checkoutTx set) is filtered out, only the non-Card remains. + // 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) { - const rows = [ + 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(where.checkoutTx ? rows.filter((r) => r.checkoutTx == null) : rows); + ]); } 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(false); // Card excluded (forward seq0 books it) + 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 () => { @@ -1265,8 +1305,9 @@ describe('LedgerCutoverService', () => { }); jest.spyOn(settingService, 'get').mockResolvedValue(undefined); jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshot]); - // the ASSET opening sits at seq 0 → nextSeq already returns 1 → alreadyBooked(seq 0) true → no re-book - nextSeqByKey.set('cutover:1557344', 1); + // 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(); 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 index aeb993f699..f3c963ac9d 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-mark.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-mark.service.spec.ts @@ -33,6 +33,40 @@ describe('LedgerMarkService', () => { 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') 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 index 9e4cff4ab5..b2c7a438ba 100644 --- 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 @@ -100,6 +100,7 @@ describe('BankTxConsumer', () => { 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); @@ -229,9 +230,11 @@ describe('BankTxConsumer', () => { expect(cents(legs)).toBe(0); }); - it('books BUY_CRYPTO on an untracked bank with NO same-currency tracked mark as a needsMark leg, no silent plug', async () => { - // when no tracked bank of the currency exists, currencyMarkAssetId returns undefined → the SUSPENSE leg stays - // needsMark and withFxPlug books NO plug (§5.1 stage 3: no silent plug without a mark; mark-to-market revalues) + // 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 })], @@ -246,10 +249,8 @@ describe('BankTxConsumer', () => { }); await consumer.process(); - const legs = booked[0].legs; - const suspense = legs.find((l) => l.account.name === 'SUSPENSE/untracked-bank-Raiffeisen-EUR'); - expect(suspense.needsMark).toBe(true); // no mark resolvable - expect(legs.find((l) => l.account.name?.includes('fx-revaluation'))).toBeUndefined(); // NO silent plug + 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 () => { 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 index 260b2bc4dc..e51f7805cc 100644 --- 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 @@ -127,6 +127,7 @@ describe('BuyCryptoConsumer', () => { // §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 }), ]); @@ -145,6 +146,7 @@ describe('BuyCryptoConsumer', () => { // 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 }), @@ -281,6 +283,7 @@ describe('BuyCryptoConsumer', () => { // §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([ @@ -349,6 +352,7 @@ describe('BuyCryptoConsumer', () => { // 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, @@ -452,9 +456,11 @@ describe('BuyCryptoConsumer', () => { expect(booked.some((b) => b.sourceId === '40')).toBe(false); // neither seq0 nor seq1 booked }); - // a Card row OPEN at the cutover that completes AFTER it (outputDate > snapshot) IS booked (seq0 + seq1) — the - // forward seq0 is its opening (the per-row cutover opening is excluded for Card). received closes to 0. (Scenario A) - it('books seq0 + seq1 for a Card row completing after the cutover (Scenario A, not settled-before-cutover)', async () => { + // 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'); @@ -466,7 +472,7 @@ describe('BuyCryptoConsumer', () => { inputReferenceAmount: 1050, // card-currency gross (F2 custody native) totalFeeAmountChf: 10, isComplete: true, - outputDate: new Date('2026-06-20T00:00:00Z'), // completed AFTER the snapshot → NOT covered by the opening + 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, }); @@ -478,8 +484,42 @@ describe('BuyCryptoConsumer', () => { const s0 = booked.find((b) => b.sourceId === '41' && b.seq === 0); const s1 = booked.find((b) => b.sourceId === '41' && b.seq === 1); - expect(s0).toBeDefined(); // forward seq0 = the card opening - expect(s1).toBeDefined(); // completion books (outputDate > snapshot → not settled-before-cutover → gate open) + 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); 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 index 442a8ad1b3..114f9f4b5a 100644 --- 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 @@ -118,6 +118,7 @@ describe('BuyFiatConsumer', () => { }); 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(); @@ -542,7 +543,37 @@ describe('BuyFiatConsumer', () => { // 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. - it('appends NO fx residual when the bank leg still needsMark (unmarked EUR output)', async () => { + // 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, @@ -554,7 +585,7 @@ describe('BuyFiatConsumer', () => { fiatOutput: { isTransmittedDate: FRI, currency: 'EUR', - bank: { asset: { id: 999 } }, // 999 is NOT in markMap → getMarkAt → undefined → needsMark + bank: { asset: { id: 999 } }, bankTx: { bookingDate: SUN }, } as any, }), @@ -562,18 +593,22 @@ describe('BuyFiatConsumer', () => { await consumer.process(); const s3 = seq(3); - expect(s3.legs).toHaveLength(2); // TRANSIT + bank only, NO fx-revaluation plug while unmarked - expect(leg(s3, 'TRANSIT/payout/EUR').amountChf).toBe(9000); // owed_chf = 9000 − 0 - const bankLeg = s3.legs.find((l) => l.account.name === 'Bank/CHF'); // asset 999 → findByAssetId fallback + const bankLeg = s3.legs.find((l) => l.needsMark); expect(bankLeg.amount).toBe(-10000); // native −outputAmount - expect(bankLeg.needsMark).toBe(true); // unmarked → carried for the mark-to-market job - expect(bankLeg.amountChf).toBeUndefined(); - expect(s3.legs.some((l) => /fx-revaluation/.test(l.account.name))).toBe(false); + 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. - it('marks the bank leg as needsMark when the bank.asset has no id (outputMark null-id arm)', async () => { + // 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, @@ -592,12 +627,8 @@ describe('BuyFiatConsumer', () => { ]); await consumer.process(); - const s3 = seq(3); - const bankLeg = s3.legs.find((l) => l.needsMark); - expect(bankLeg).toBeDefined(); - expect(bankLeg.amount).toBe(-8400); - expect(bankLeg.amountChf).toBeUndefined(); - expect(s3.legs.some((l) => /fx-revaluation/.test(l.account.name))).toBe(false); + 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 → @@ -1091,13 +1122,16 @@ describe('BuyFiatConsumer', () => { // 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). - it('defaults null outputAmount/amountInChf/totalFeeAmountChf to 0 in seq2/seq3 (L316, L352)', async () => { + // 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, // → owedChf amountInChf ?? 0 (L352) - totalFeeAmountChf: null, // → owedChf totalFeeAmountChf ?? 0 (L352) - outputAmount: null, // → seq1 skipped + bankCrLeg outputAmount ?? 0 (L316) + 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: { @@ -1112,19 +1146,18 @@ describe('BuyFiatConsumer', () => { expect(seq(1)).toBeUndefined(); // outputAmount null → seq1 gate skips it const s2 = seq(2); - expect(leg(s2, 'LIABILITY/buyFiat-owed').amountChf).toBe(0); // (0) − (0) + expect(leg(s2, 'LIABILITY/buyFiat-owed').amountChf).toBe(0); // regular owedChf: (0) − (0) expect(leg(s2, 'TRANSIT/payout/CHF').amountChf).toBe(-0); - const s3 = seq(3); - expect(leg(s3, 'TRANSIT/payout/CHF').amountChf).toBe(0); - const bankLeg = leg(s3, 'Bank/CHF'); - expect(bankLeg.amount).toBe(-0); // −(outputAmount ?? 0) = −0 - expect(bankLeg.amountChf).toBe(-0); // −(mark 1 × 0) = −0 - expect(bankLeg.needsMark).toBe(false); // CHF mark present → marked + 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. - it('defaults a null outputAmount to 0 in the paymentLink owedChf (L351)', async () => { + // 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({ @@ -1132,7 +1165,7 @@ describe('BuyFiatConsumer', () => { amountInChf: 100, totalFeeAmountChf: 0, outputReferenceAmount: 100, - outputAmount: null, // → owedChf (outputAmount ?? 0) × rate = 0 + 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, @@ -1141,10 +1174,8 @@ describe('BuyFiatConsumer', () => { await consumer.process(); expect(seq(1)).toBeUndefined(); // outputAmount null → §4.7b seq1 gate skips it - const s2 = seq(2); - expect(leg(s2, 'LIABILITY/paymentLink').amountChf).toBe(0); // (outputAmount ?? 0) × rate = 0 - expect(leg(s2, 'TRANSIT/payout/CHF').amountChf).toBe(-0); - expect(cents(s2.legs)).toBe(0); + 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 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 index abe5dd6a86..a9392c0740 100644 --- 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 @@ -93,6 +93,7 @@ describe('CryptoInputConsumer', () => { }); 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(); @@ -204,8 +205,11 @@ describe('CryptoInputConsumer', () => { expect(cents(seq0.legs)).toBe(0); }); - // §10.2 fixture (B)(d) / §5.1 stage 3 — no mark: ASSET leg needsMark, NO silent fx-revaluation plug - it('flags the ASSET leg needsMark when no mark exists and books NO silent plug (§5.1 stage 3)', async () => { + // §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, @@ -217,17 +221,41 @@ describe('CryptoInputConsumer', () => { 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.needsMark).toBe(true); - expect(assetLeg.amountChf).toBeUndefined(); - // NO fx-revaluation plug while the asset leg needsMark (the old bug plugged the FULL +50000 received value as a - // phantom fx-revaluation, vacuously balancing cents to 0). Per §5.1 stage 3 the tx stays unbalanced-by-mark and - // the mark-to-market job revalues the asset leg later — exactly the systemic needsMark-guard fix. - expect(seq0.legs.find((l) => l.account.name?.includes('fx-revaluation'))).toBeUndefined(); - expect(seq0.legs).toHaveLength(2); // asset (needsMark) + received anchor only, no plug leg + 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 () => { @@ -508,10 +536,11 @@ describe('CryptoInputConsumer', () => { // --- 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. With a buyFiat anchor the needsMark asset leg short-circuits appendFxPlug → NO fx plug leg. - // (Distinct from the id-5 test, which resolves a wallet WITH assetId 999 → takes the getMarkAt side.) - it('takes the undefined-mark branch when the wallet account has a null assetId (no fx plug)', async () => { + // 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, @@ -525,14 +554,8 @@ describe('CryptoInputConsumer', () => { await consumer.process(); - const seq0 = booked.find((b) => b.seq === 0); - const assetLeg = seq0.legs.find((l) => l.account.name === 'Bitcoin/BTC'); - expect(assetLeg.needsMark).toBe(true); - expect(assetLeg.amountChf).toBeUndefined(); - expect(assetLeg.priceChf).toBeNull(); // mark ?? null - // a needsMark leg short-circuits appendFxPlug (§5.1 stage 3) → received anchor only, NO plug leg - expect(seq0.legs.find((l) => l.account.name?.includes('fx-revaluation'))).toBeUndefined(); - expect(seq0.legs).toHaveLength(2); + expect(booked.find((b) => b.seq === 0)).toBeUndefined(); // nothing booked (deferred — no assetId to bridge) + expect(setSpy).not.toHaveBeenCalled(); // watermark NOT advanced }); // lines 141-144 (isPayment, assetChf == null): a PAYMENT crypto_input whose asset has NO mark → mark undefined → @@ -564,10 +587,10 @@ describe('CryptoInputConsumer', () => { expect(paymentLink.amount).toBe(-0); // -(assetChf ?? 0) → -0 (the negation of the 0 fallback) }); - // lines 176-187 forward-fee: forwardFeeAmount (feeNative) null while forwardFeeAmountChf set → mark falls to null - // (the `feeChf != null && feeNative` guard is false) and the wallet native amount uses the `-(feeNative ?? feeChf)` - // fallback = −feeChf. - it('books the forward fee with a null priceChf and the feeChf native fallback when forwardFeeAmount is null', async () => { + // 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, @@ -575,7 +598,7 @@ describe('CryptoInputConsumer', () => { asset: { id: BTC_ASSET_ID, uniqueName: 'Bitcoin/BTC' }, buyFiat: { amountInChf: 50000 } as any, outTxId: '0xforward', - forwardFeeAmount: null, // feeNative null → mark null, native fallback to feeChf + forwardFeeAmount: null, // feeNative null → derive native from feeChf via the BTC mark (never CHF as native) forwardFeeAmountChf: 5, }), ]); @@ -586,12 +609,37 @@ describe('CryptoInputConsumer', () => { 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(-5); // -(feeNative ?? feeChf) = -5 (the ?? fallback) + 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).toBeNull(); // feeNative falsy → mark null + 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. Uses an isPayment row so seq0 (2-leg, both needsMark → balances) + // books and the forward-fee seq1 is the only thing that throws — isolating the 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(); + mockBatch([ + cryptoInput({ + id: 43, + amount: 1, + txType: PayInType.PAYMENT, // isPayment → seq0 is a balanced 2-leg (no appendFxPlug), so seq0 books + 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 () => { 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 index e2e3f8c495..22e4fd9eda 100644 --- 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 @@ -88,6 +88,7 @@ describe('ExchangeTxConsumer', () => { }); 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([]); @@ -127,11 +128,16 @@ describe('ExchangeTxConsumer', () => { 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 - // fill-index preload (status='ok', order in …) ranks among the still-ok trades + the merged batch rows - if (opts?.where?.order != null) + // 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( - [...forward, ...changed].filter((r) => r.type === ExchangeTxType.TRADE && r.status === 'ok'), + 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)); }); } @@ -269,7 +275,7 @@ describe('ExchangeTxConsumer', () => { expect(quote).toBeDefined(); expect(cents(legs)).toBe(0); // closes without ROUNDING throw expect(booked[0].sourceType).toBe('ExchangeTrade'); - expect(booked[0].sourceId).toBe('O-1'); + expect(booked[0].sourceId).toBe('Scrypt:O-1'); // B3: exchange-prefixed composite sourceId }); it('books a positive Scrypt spread to EXPENSE/spread-Scrypt', async () => { @@ -354,7 +360,7 @@ describe('ExchangeTxConsumer', () => { 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 === 'O-9')).toBe(true); + 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() @@ -400,7 +406,7 @@ describe('ExchangeTxConsumer', () => { 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 === 'O-9')).toBe(true); + expect(booked.every((b) => b.sourceType === 'ExchangeTrade' && b.sourceId === 'Scrypt:O-9')).toBe(true); // B3 }); it('advances the watermark after a successful batch', async () => { @@ -444,8 +450,48 @@ describe('ExchangeTxConsumer', () => { mockContentChange([], [canceled]); await consumer.process(); - // trade reversal targets sourceType=ExchangeTrade, sourceId=order, seq=fill-index (0 for the only fill of O-7) - expect(reverseSpy).toHaveBeenCalledWith('ExchangeTrade', 'O-7', 0); + // 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 () => { @@ -634,7 +680,10 @@ describe('ExchangeTxConsumer', () => { // §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 - it('flags the ccxt quote leg needsMark and skips the quote-spread plug when the quote mark is missing', async () => { + // 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({ @@ -652,11 +701,8 @@ describe('ExchangeTxConsumer', () => { ]); await consumer.process(); - const quote = booked[0].legs.find((l) => l.account.name === 'Binance/XYZ'); - expect(quote.needsMark).toBe(true); // no mark for the quote asset - expect(quote.amountChf).toBeUndefined(); - // no mark-based quote-spread plug appended while a leg needsMark (only base + quote legs, no spread plug) - expect(booked[0].legs.filter((l) => l.account.name?.includes('spread-Binance'))).toHaveLength(0); + 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 @@ -1073,7 +1119,10 @@ describe('ExchangeTxConsumer', () => { // 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 `?? `). - it('treats a null cost as 0 on a Scrypt Trade (quote amount −0, priceChf null, amountChf = −base)', async () => { + // 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, @@ -1084,17 +1133,14 @@ describe('ExchangeTxConsumer', () => { order: 'O-36', amount: 1000, amountChf: 900, - cost: null, // tx.cost ?? 0 → 0 + cost: null, // 'ok' trade with no cost → A4 throws (never a silent 0-quote) feeAmountChf: 0, }), ]); await consumer.process(); - const quote = booked[0].legs.find((l) => l.account.name === 'Scrypt/CHF'); - expect(quote.amount).toBe(-0); // isBuy ? -(cost ?? 0) = -0 - expect(quote.priceChf).toBeNull(); // quoteAmount 0 → no price - expect(quote.amountChf).toBe(-900); // plug = −base - expect(cents(booked[0].legs)).toBe(0); + 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 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 index 887187cd72..d720df45c6 100644 --- 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 @@ -93,6 +93,7 @@ describe('LiquidityMgmtConsumer', () => { }); 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(); 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 index 325320c742..44a546feb3 100644 --- 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 @@ -95,6 +95,7 @@ describe('LiquidityOrderDexConsumer', () => { }); 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(); @@ -199,17 +200,18 @@ describe('LiquidityOrderDexConsumer', () => { }); // §4.8a missing mark → ASSET leg needsMark, plug skipped (no silent plug without a mark) - it('flags the ASSET leg needsMark and skips the spread plug when a mark is missing', async () => { + // 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(); - const tx = booked[0]; - expect(leg(tx, 'Ethereum/EURC').needsMark).toBe(true); - expect(leg(tx, 'Ethereum/EURC').amountChf).toBeUndefined(); - expect(leg(tx, 'EXPENSE/spread-DfxDex')).toBeUndefined(); - expect(leg(tx, 'INCOME/spread-DfxDex')).toBeUndefined(); + + 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 @@ -314,7 +316,11 @@ describe('LiquidityOrderDexConsumer', () => { // §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. - it('flags the gas-fee legs needsMark when the fee asset has no mark (lines 155/176-177/217)', async () => { + // 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([ @@ -329,33 +335,24 @@ describe('LiquidityOrderDexConsumer', () => { ]); await consumer.process(); - const tx = booked[0]; - const networkFee = leg(tx, 'EXPENSE/network-fee'); - const ethLeg = leg(tx, 'Ethereum/ETH'); - expect(networkFee.needsMark).toBe(true); // feeChf undefined → needsMark - expect(networkFee.amountChf).toBeUndefined(); - expect(networkFee.amount).toBe(0); // networkFeeLeg `feeChf ?? 0` → 0 - expect(ethLeg.needsMark).toBe(true); - expect(ethLeg.amountChf).toBeUndefined(); - expect(leg(tx, 'EXPENSE/spread-DfxDex')).toBeUndefined(); // a needsMark leg → no spread plug - expect(leg(tx, 'INCOME/spread-DfxDex')).toBeUndefined(); + 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. - it('folds a no-mark swap-asset fee and marks the combined leg needsMark (lines 163/223)', async () => { + // 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(); - const tx = booked[0]; - const swapLegs = tx.legs.filter((l) => l.account.name === 'Ethereum/USDC'); - expect(swapLegs).toHaveLength(1); - expect(swapLegs[0].amount).toBe(-1055); // −1050 − 5 folded native - expect(swapLegs[0].needsMark).toBe(true); // no swap mark → combined leg needsMark - expect(leg(tx, 'EXPENSE/network-fee').needsMark).toBe(true); + expect(booked[0]).toBeUndefined(); // deferred: unvalued combined swap leg on a mixed tx + expect(setSpy).not.toHaveBeenCalled(); // watermark NOT advanced }); // §4.8a appendSpreadPlug sub-cent branch (lines 187-188): a swap whose two mark legs net to 0 (within tolerance) → 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 index 33e68790d9..097ec27be1 100644 --- 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 @@ -109,6 +109,7 @@ describe('PayoutOrderConsumer', () => { }); 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(); @@ -341,7 +342,10 @@ describe('PayoutOrderConsumer', () => { }); // §4.5 missing wallet mark → needsMark, plug stays open, no silent priceChf=0 - it('flags the wallet leg needsMark when the payout-asset mark is missing', async () => { + // 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') @@ -359,9 +363,37 @@ describe('PayoutOrderConsumer', () => { ]); 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.needsMark).toBe(true); - expect(wallet.amountChf).toBeUndefined(); + 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 @@ -598,7 +630,10 @@ describe('PayoutOrderConsumer', () => { // §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). - it('books NO fx-revaluation plug while the wallet leg needsMark (no silent plug, §5.1 stage 3)', async () => { + // 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') @@ -616,10 +651,8 @@ describe('PayoutOrderConsumer', () => { ]); await consumer.process(); - // owed-Dr 50000 (completion) + wallet needsMark (amountChf undefined) → cents don't net, but NO plug is appended - expect(leg(booked[0], 'INCOME/fx-revaluation')).toBeUndefined(); - expect(leg(booked[0], 'EXPENSE/fx-revaluation')).toBeUndefined(); - expect(leg(booked[0], 'Unknown/XYZ').needsMark).toBe(true); + expect(booked[0]).toBeUndefined(); // deferred: no booking, no silent phantom plug + expect(setSpy).not.toHaveBeenCalled(); // watermark NOT advanced }); // §4.5 liabilityCounter (lines 220/223): a liability row where BOTH the completion CHF (non-integer correlationId → @@ -768,7 +801,10 @@ describe('PayoutOrderConsumer', () => { // §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. - it('flags a distinct fee-asset leg needsMark when that fee asset has no mark (no plug booked)', async () => { + // 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)); @@ -789,15 +825,8 @@ describe('PayoutOrderConsumer', () => { ]); await consumer.process(); - const tx = booked[0]; - const feeLeg = leg(tx, 'NoMark/NOM'); - expect(feeLeg.amount).toBe(-0.003); // native fee against the fee asset - expect(feeLeg.amountChf).toBeUndefined(); // no mark → chf undefined (line 304) - expect(feeLeg.needsMark).toBe(true); // line 305: needsMark = chf == null - expect(feeLeg.priceChf).toBeNull(); // line 303: priceChf = mark ?? null - expect(leg(tx, 'EXPENSE/network-fee').amountChf).toBe(5); // CHF fee leg still booked - expect(leg(tx, 'INCOME/fx-revaluation')).toBeUndefined(); // a leg needsMark → no plug - expect(leg(tx, 'EXPENSE/fx-revaluation')).toBeUndefined(); + 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 @@ -831,7 +860,10 @@ describe('PayoutOrderConsumer', () => { // §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. - it('takes chf=0 for a folded payout-asset fee when the payout asset has no mark (needsMark)', async () => { + // 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') @@ -851,14 +883,8 @@ describe('PayoutOrderConsumer', () => { ]); await consumer.process(); - const tx = booked[0]; - const walletLegs = tx.legs.filter((l) => l.account.name === 'Unknown/XYZ'); - expect(walletLegs).toHaveLength(1); // fee folded into the single wallet leg - expect(walletLegs[0].amount).toBe(-1.0002); // amount + folded fee native - expect(walletLegs[0].amountChf).toBeUndefined(); // no mark → mainChf undefined → wallet chf undefined - expect(walletLegs[0].needsMark).toBe(true); // fee.needsMark (no mark) → wallet needsMark - expect(leg(tx, 'INCOME/fx-revaluation')).toBeUndefined(); // a leg needsMark → no plug - expect(leg(tx, 'EXPENSE/fx-revaluation')).toBeUndefined(); + 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 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 index f9f1002098..a38036b8b4 100644 --- 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 @@ -90,6 +90,7 @@ describe('TradingOrderConsumer', () => { }); 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(); @@ -225,17 +226,40 @@ describe('TradingOrderConsumer', () => { }); // §4.9 missing mark → ASSET leg needsMark, plug stays open (no silent plug without a mark) - it('flags the ASSET leg needsMark and skips the plug when a mark is missing', async () => { + // 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').needsMark).toBe(true); - expect(leg(tx, 'Ethereum/TOKEN').amountChf).toBeUndefined(); - expect(leg(tx, 'EXPENSE/spread-arbitrage')).toBeUndefined(); // no silent plug without a mark - expect(leg(tx, 'INCOME/spread-arbitrage')).toBeUndefined(); + 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 diff --git a/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts b/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts index 535c491042..ca6cc7f828 100644 --- a/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts @@ -16,6 +16,7 @@ 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'; @@ -250,7 +251,7 @@ export class BankTxConsumer { 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]); + 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 × @@ -270,7 +271,7 @@ export class BankTxConsumer { // 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]); + 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 @@ -361,7 +362,7 @@ export class BankTxConsumer { const expenseChf = tx.chargeAmountChf ?? -(bank.amountChf ?? 0); // Pricing anchor const expense = this.namedLeg(await this.expense('bank-fee'), expenseChf); - return this.withFxPlug([expense, bank]); // ≤2c → ROUNDING, >2c → fx-revaluation (§4.2-Note B-10) + 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 @@ -418,7 +419,7 @@ export class BankTxConsumer { // 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]); + 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) @@ -443,7 +444,7 @@ export class BankTxConsumer { const liabilityChf = await this.chargebackOpeningChf(tx, bucket, others); const legs: LedgerLegInput[] = [this.namedLeg(await this.liability(bucket), liabilityChf), ...others]; - return this.withFxPlug(legs); // ≤2c → ROUNDING, >2c → fx-revaluation + 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 / @@ -634,12 +635,13 @@ export class BankTxConsumer { // 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). - // NO silent plug while a leg still needsMark (§5.1 stage 3): an unmarked non-CHF leg carries amountChf=undefined - // (counted as 0 here), so plugging would book the full leg value as a phantom fx-revaluation; instead leave the tx - // unbalanced-by-mark and let the mark-to-market job revalue the leg later — consistent with exchange-tx.consumer.ts - // (the §4.2a fix resolves the EUR mark for untracked banks, so this guard only fires when the mark is truly absent). - private async withFxPlug(legs: LedgerLegInput[]): Promise { - if (legs.some((l) => l.needsMark)) return legs; + // 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; diff --git a/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts b/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts index c87136d7dd..2cb75461c9 100644 --- a/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts @@ -155,9 +155,16 @@ export class BuyCryptoConsumer { if (!bc.checkoutTx) return undefined; // not a Card input → seq0 not this consumer's job if (bc.amountInChf == null) 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 OPEN-at-cutover rows (whose per-row - // cutover opening is excluded, so the forward seq0 IS their opening) and post-cutover Card rows get a forward seq0. + // (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 @@ -237,11 +244,16 @@ export class BuyCryptoConsumer { // 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: received was opened by this consumer's own seq0 — EXCEPT a row already settled at the cutover, whose - // seq0 is (correctly) skipped as covered by the aggregate opening → its completion must stay blocked too. This - // mirrors the non-Card G-a/G-b path, which finds neither a crypto_input seq0 (G-a) nor a per-row cutover opening - // (G-b) for a pre-cutover-settled row → gate closed. Without it, seq1 would Dr received with no −opening → phantom. - if (bc.checkoutTx) return !(await this.settledBeforeCutover(bc)); + // 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 @@ -258,12 +270,8 @@ export class BuyCryptoConsumer { return true; // G-a } - // G-b: cutover opening on buyCrypto-received for this buy_crypto.id (synthetic seq0 marker, §6.1). The cutover - // writes `${snapshotLogId}:buy_crypto:${id}`, so the prefix must be resolved from ledgerCutoverLogId — an exact - // `:buy_crypto:${id}` match would NEVER hit (the snapshot logId prefix is missing → G-b dead, Blocker R4-2). - const cutoverSourceId = await this.cutoverReceivedSourceId(bc.id); - if (cutoverSourceId == null) return false; // cutover not run yet → no opening to match - return this.ledgerTxRepo.existsBy({ sourceType: CUTOVER_SOURCE, sourceId: cutoverSourceId }); + // 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}`. @@ -273,6 +281,17 @@ export class BuyCryptoConsumer { 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. diff --git a/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts b/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts index c98c6d849a..ef5e94106d 100644 --- a/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts @@ -12,6 +12,7 @@ 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 = 'buy_fiat'; @@ -129,7 +130,12 @@ export class BuyFiatConsumer { } private async preloadMarks(batch: BuyFiat[]): Promise { - const dates = batch.flatMap((bf) => [bf.updated, bf.fiatOutput?.bankTx?.bookingDate].filter((d): d is Date => !!d)); + // 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))); @@ -254,7 +260,7 @@ export class BuyFiatConsumer { // §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); + await this.appendFxResidual(legs, `buy_fiat ${bf.id} seq3`); return { sourceType: SOURCE_TYPE, @@ -351,7 +357,12 @@ export class BuyFiatConsumer { if (!bankAsset) throw new Error(`buy_fiat ${bf.id} fiatOutput has no bank.asset (untracked output bank)`); const account = await this.assetAccount(bankAsset.id); - const outputAmount = bf.outputAmount ?? 0; + // 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; @@ -365,11 +376,12 @@ export class BuyFiatConsumer { } // §4.7a — appends the FX-P&L leg = −(Σ CHF) for the EUR/output drift; CHF output → drift 0 → no leg. - // No silent plug while a leg still needsMark (§5.1 stage 3): an unmarked leg carries amountChf=undefined (counted - // as 0), so plugging would book its full value as a phantom fx-revaluation — leave it for the mark-to-market job to - // revalue, consistent with exchange-tx.consumer.ts. - private async appendFxResidual(legs: LedgerLegInput[]): Promise { - if (legs.some((l) => l.needsMark)) return; + // 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 @@ -386,7 +398,16 @@ export class BuyFiatConsumer { // 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) return Util.round((bf.outputAmount ?? 0) * this.owedReferenceRate(bf), 2); + 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); } diff --git a/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts b/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts index 63141f9b95..6a27a58da1 100644 --- a/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts @@ -10,6 +10,7 @@ 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, @@ -120,7 +121,7 @@ export class CryptoInputConsumer { const bookingDate = ci.updated; await this.bookInput(ci, bookingDate, marks); // seq0 - await this.bookForwardFee(ci, bookingDate); // seq1 (only if outTxId + forwardFeeAmountChf) + await this.bookForwardFee(ci, bookingDate, marks); // seq1 (only if outTxId + forwardFeeAmountChf) } // seq0 — the crypto-input leg (§4.4/§4.4a) @@ -183,23 +184,41 @@ export class CryptoInputConsumer { assetLeg, { account: received, amount: -product.amountInChf, priceChf: 1, amountChf: -product.amountInChf }, ]; - this.appendFxPlug(legs, await this.fxAccounts()); + 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): Promise { + 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; - const feeNative = ci.forwardFeeAmount; - const mark = feeChf != null && feeNative ? Util.round(feeChf / feeNative, 8) : null; - const networkFee = await this.expense('network-fee'); + 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}`, @@ -208,7 +227,7 @@ export class CryptoInputConsumer { valueDate: bookingDate, legs: [ { account: networkFee, amount: feeChf, priceChf: 1, amountChf: feeChf }, - { account: wallet, amount: -(feeNative ?? feeChf), priceChf: mark, amountChf: -feeChf }, + { account: wallet, amount: -feeNative, priceChf: mark, amountChf: -feeChf }, ], }); } @@ -217,11 +236,15 @@ export class CryptoInputConsumer { // 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. - // No silent plug while a leg still needsMark (§5.1 stage 3): an unmarked leg carries amountChf=undefined (counted - // as 0), so plugging would book its full value as a phantom fx-revaluation — leave it for the mark-to-market job to - // revalue, consistent with exchange-tx.consumer.ts. - private appendFxPlug(legs: LedgerLegInput[], fx: { income: LedgerAccount; expense: LedgerAccount }): void { - if (legs.some((l) => l.needsMark)) return; + // 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; diff --git a/src/subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts b/src/subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts index 6692f98ba0..be9f194099 100644 --- a/src/subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts @@ -14,6 +14,7 @@ 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, @@ -249,6 +250,14 @@ export class ExchangeTxConsumer { 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; @@ -278,9 +287,11 @@ export class ExchangeTxConsumer { 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). Skip when a leg - // still needsMark (no silent plug, §4.3) — mark-to-market revalues then. - if (!legs.some((l) => l.needsMark)) { + // 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) { @@ -291,7 +302,11 @@ export class ExchangeTxConsumer { const seq = fillIndexMap.get(tx.id) ?? 0; const order = tx.order; - const sourceId = order ? `${order}` : `${tx.id}`; + // 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 { @@ -393,14 +408,21 @@ export class ExchangeTxConsumer { 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, status: OK_STATUS, order: In(orders) }, + where: { type: ExchangeTxType.TRADE, order: In(orders) }, select: { id: true, exchange: true, order: true }, }); - // merge the batch rows so a TRADE being reconciled keeps its booking-time rank even after it flipped away from - // 'ok' (the OK-filtered query no longer returns it). Ranking is by id and order-preserving, so reinserting the row - // at its id reproduces exactly the rank it had when it was booked → the reversal targets the right seq (§4.3). + // 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) { 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/liquidity-order-dex.consumer.ts b/src/subdomains/core/accounting/services/consumers/liquidity-order-dex.consumer.ts index bb3fa36e6d..d0f83d33e2 100644 --- a/src/subdomains/core/accounting/services/consumers/liquidity-order-dex.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/liquidity-order-dex.consumer.ts @@ -15,6 +15,7 @@ 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, @@ -163,7 +164,7 @@ export class LiquidityOrderDexConsumer { const legs: LedgerLegInput[] = [targetLeg, swapLeg]; await this.appendFeeLegs(order, bookingDate, marks, targetLeg, swapLeg, legs); - await this.appendSpreadPlug(legs); + await this.appendSpreadPlug(legs, `liquidity_order ${order.id}`); await this.bookingService.bookTx({ sourceType: SOURCE_TYPE, @@ -222,9 +223,11 @@ export class LiquidityOrderDexConsumer { } // appends an EXPENSE/INCOME spread-DfxDex plug for the CHF residual; sub-cent → ROUNDING (booking service). - // Skips the plug when any leg still needsMark (no silent plug without a mark, §5.1 stage 3 / §4.8a). - private async appendSpreadPlug(legs: LedgerLegInput[]): Promise { - if (legs.some((l) => l.needsMark)) return; + // 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; diff --git a/src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts b/src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts index 3071b3216b..812c7c0ba5 100644 --- a/src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts @@ -20,6 +20,7 @@ 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'; @@ -164,7 +165,7 @@ export class PayoutOrderConsumer { seq: 0, bookingDate, valueDate: bookingDate, - legs: await this.withFxPlug(legs), + legs: await this.withFxPlug(legs, `payout_order ${order.id}`), }); } @@ -371,11 +372,11 @@ export class PayoutOrderConsumer { // --- HELPERS --- // // appends an EXPENSE/INCOME fx-revaluation plug for the CHF residual > tolerance (§4.5); sub-cent → ROUNDING. - // No silent plug while a leg still needsMark (§5.1 stage 3): an unmarked leg carries amountChf=undefined (counted - // as 0), so plugging would book its full value as a phantom fx-revaluation — leave the tx for the mark-to-market job - // to revalue, consistent with exchange-tx.consumer.ts (mark-to-market then closes the residual). - private async withFxPlug(legs: LedgerLegInput[]): Promise { - if (legs.some((l) => l.needsMark)) return legs; + // 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; diff --git a/src/subdomains/core/accounting/services/consumers/trading-order.consumer.ts b/src/subdomains/core/accounting/services/consumers/trading-order.consumer.ts index 8f07c0ee22..07333ee256 100644 --- a/src/subdomains/core/accounting/services/consumers/trading-order.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/trading-order.consumer.ts @@ -12,6 +12,7 @@ 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, @@ -140,7 +141,7 @@ export class TradingOrderConsumer { 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); + await this.appendArbitragePlug(legs, `trading_order ${order.id}`); await this.bookingService.bookTx({ sourceType: SOURCE_TYPE, @@ -155,11 +156,13 @@ export class TradingOrderConsumer { /** * 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. Skips the plug when any ASSET leg still needsMark (no silent plug without a mark, - * §5.1 stage 3 / §4.9) — mark-to-market revalues then. Sub-cent rest → ROUNDING (booking service). + * 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[]): Promise { - if (legs.some((l) => l.needsMark)) return; + 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; diff --git a/src/subdomains/core/accounting/services/ledger-cutover.service.ts b/src/subdomains/core/accounting/services/ledger-cutover.service.ts index c739ac52cc..eb975abda0 100644 --- a/src/subdomains/core/accounting/services/ledger-cutover.service.ts +++ b/src/subdomains/core/accounting/services/ledger-cutover.service.ts @@ -192,25 +192,27 @@ export class LedgerCutoverService { snapshotDate: Date, equity: LedgerAccount, ): Promise { - let seq = 0; 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) { - seq++; - continue; // feedless/placeholder/zero → opening 0, no leg - } + 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( - seq++, - `${snapshot.id}`, - `Opening balance from FinancialDataLog #${snapshot.id}`, + 0, + `${snapshot.id}:asset:${assetId}`, + `Opening balance for asset #${assetId} from FinancialDataLog #${snapshot.id}`, snapshotDate, { account, @@ -323,14 +325,18 @@ export class LedgerCutoverService { } // buyCrypto-received: open rows with outputAmount NULL → CHF = amountInChf (Minor R2-7); per-row seq0-marker (R4-2). - // Card inputs (checkoutTx != null) are EXCLUDED: card received is booked by the forward consumer's seq0 - // (buy-crypto.consumer bookCardInput), so a per-row opening would double-book received across the cutover boundary. - // (The open Card row sits below the id-watermark → the forward id-scan skips it, but after completion the - // content-change scan re-books its seq0 −amountInChf on top of this opening → received would end at −amountInChf - // instead of 0, plus a phantom EQUITY opening. The forward consumer books the card seq0 fresh once it completes.) + // §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). private async openBuyCryptoReceived(snapshot: Log, date: Date, lookback: Date, equity: LedgerAccount): Promise { const rows = await this.buyCryptoRepo.find({ - where: { isComplete: false, outputAmount: IsNull(), checkoutTx: IsNull(), created: Between(lookback, date) }, + where: { isComplete: false, outputAmount: IsNull(), created: Between(lookback, date) }, }); const liability = await this.liability('buyCrypto-received'); diff --git a/src/subdomains/core/accounting/services/ledger-mark.service.ts b/src/subdomains/core/accounting/services/ledger-mark.service.ts index 1514517565..f891a0f286 100644 --- a/src/subdomains/core/accounting/services/ledger-mark.service.ts +++ b/src/subdomains/core/accounting/services/ledger-mark.service.ts @@ -10,6 +10,11 @@ interface MarkPoint { 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. @@ -47,6 +52,44 @@ export class LedgerMarkCache { 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 diff --git a/src/subdomains/core/accounting/services/ledger-query.service.ts b/src/subdomains/core/accounting/services/ledger-query.service.ts index 4180c3adbe..422381698f 100644 --- a/src/subdomains/core/accounting/services/ledger-query.service.ts +++ b/src/subdomains/core/accounting/services/ledger-query.service.ts @@ -436,7 +436,6 @@ export class LedgerQueryService { case FeedStatus.PLACEHOLDER: return LedgerFeedStaleness.PLACEHOLDER; case FeedStatus.NO_FEED: - default: return LedgerFeedStaleness.MISSING; } } From ca4c457558a1a73ab2f7388bc8936e6563cf1d90 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 15 Jul 2026 09:14:11 +0200 Subject: [PATCH 20/25] fix(accounting): ninth review pass (route checkout settlement through the plug, transit age) - checkoutLtdLegs returns through withFxPlug so a missing historical mark bridges the two asset legs instead of throwing a CHF imbalance that wedged the whole bank_tx forward scan - the dex network-fee expense leg derives its CHF from the same bridged mark as its asset counter-leg, keeping mixed swaps balanced - transit-age alarms measure since the balance last left zero instead of the first booking ever, so churning routes stop reporting fresh residuals as ancient --- .../ledger-reconciliation.service.spec.ts | 60 ++++++++++++----- .../__tests__/bank-tx.consumer.spec.ts | 62 ++++++++++++++++++ .../liquidity-order-dex.consumer.spec.ts | 34 ++++++++++ .../services/consumers/bank-tx.consumer.ts | 14 +++- .../consumers/liquidity-order-dex.consumer.ts | 12 +++- .../services/ledger-reconciliation.service.ts | 65 ++++++++++++++++--- 6 files changed, 217 insertions(+), 30 deletions(-) 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 index 891b10a352..1d2664bdea 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-reconciliation.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-reconciliation.service.spec.ts @@ -27,7 +27,8 @@ import { CustodyClass, FeedStatus, LedgerReconciliationService } from '../ledger interface LegQueryStub { native?: string; // journal native balance per account (fed through the nativeBalanceByAccount map, §7.0 m8) equityChf?: string; // journalEquity getRawOne - transit?: { name: string; native: string; oldest: Date }[]; + 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 }[]; } @@ -97,13 +98,17 @@ describe('LedgerReconciliationService', () => { 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.transit ?? []); // checkTransitAge (MIN bookingDate) - return Promise.resolve(legStub.suspense ?? []); // checkSuspense + 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(' '); @@ -419,39 +424,64 @@ describe('LedgerReconciliationService', () => { 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 = [{ name: 'TRANSIT/payout/CHF', native: '14851.5', oldest: oldDate }]; + 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); }); - // Finding 1 (regression): the transit-age aggregate reads MIN(bookingDate), but bookingDate lives on ledger_tx, - // NOT ledger_leg. The query MUST join leg.tx and select MIN(tx.bookingDate); a MIN(leg.bookingDate) references a - // non-existent column and crashes EVERY reconciliation run on real PG. Assert the exact join args + select - // expression so a regression back to leg.bookingDate (or a dropped leg.tx join) turns this test red. - it('checkTransitAge joins leg.tx and aggregates MIN(tx.bookingDate), never leg.bookingDate (Finding 1)', async () => { + // 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 = {}; + 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); - qb.addSelect = (e: string) => (calls.selects.push(e), 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; - qb.getRawMany = () => Promise.resolve([]); + // 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('MIN(tx.bookingDate)'); // age = oldest booking date from ledger_tx - expect(calls.selects).not.toContain('MIN(leg.bookingDate)'); // the crashing regression (no such column) + 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 () => { 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 index b2c7a438ba..160128ea95 100644 --- 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 @@ -679,6 +679,68 @@ describe('BankTxConsumer', () => { 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({ 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 index 44a546feb3..1b10616ce0 100644 --- 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 @@ -355,6 +355,40 @@ describe('LiquidityOrderDexConsumer', () => { 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. diff --git a/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts b/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts index ca6cc7f828..71e8c47d3d 100644 --- a/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts @@ -532,8 +532,12 @@ export class BankTxConsumer { // 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 custody leg books the CORRECT - // gross native + needsMark → the mark-to-market job revalues it later against the correct gross. + // 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, @@ -576,7 +580,11 @@ export class BankTxConsumer { needsMark: grossChf == null, }); - return legs; + // 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) 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 index d0f83d33e2..9486f66584 100644 --- a/src/subdomains/core/accounting/services/consumers/liquidity-order-dex.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/liquidity-order-dex.consumer.ts @@ -194,9 +194,17 @@ export class LiquidityOrderDexConsumer { const { feeAsset, feeAmount, targetAsset, swapAsset } = order; if (!feeAsset || feeAmount == null || feeAmount === 0) return; // null strategy §5.1: no fee → no fee leg - const mark = marks.getMarkAt(feeAsset.id, bookingDate); + // 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 = feeChf == null; + 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)); diff --git a/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts b/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts index 7af2b9372b..2fff5327bf 100644 --- a/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts +++ b/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts @@ -259,35 +259,80 @@ export class LedgerReconciliationService { // --- TRANSIT-AGE (§7.4) --- // - // transit account with balance ≠ 0 older than route threshold → alarm; age = MIN(bookingDate) of open legs. - // bookingDate lives on ledger_tx, NOT ledger_leg — the aggregate MUST join leg.tx and read MIN(tx.bookingDate) - // (a MIN(leg.bookingDate) references a non-existent column and crashes the whole reconciliation run on real PG). + // 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 { - const overdue = await this.ledgerLegRepository + // 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') - .innerJoin('leg.tx', 'tx') - .select('account.name', 'name') + .select('account.id', 'id') + .addSelect('account.name', 'name') .addSelect('SUM(leg.amount)', 'native') - .addSelect('MIN(tx.bookingDate)', 'oldest') .where('account.type = :type', { type: AccountType.TRANSIT }) .groupBy('account.id') .addGroupBy('account.name') .having('ABS(SUM(leg.amount)) > :tol', { tol: 1e-8 }) - .getRawMany<{ name: string; native: string; oldest: Date }>(); + .getRawMany<{ id: number; name: string; native: string }>(); + if (!open.length) return; const thresholdDays = Config.ledger.transitAlarmThresholdDays; - const aged = overdue.filter((t) => t.oldest && Util.daysDiff(new Date(t.oldest), now) > thresholdDays); + 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 ${new Date(t.oldest).toISOString()}`), + 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 From 525abae72e545ae77b96ec50b8f3caada607ac05 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:35:35 +0200 Subject: [PATCH 21/25] fix(accounting): tenth review pass (paymentLink cutover, unpriced-row pinning, bridge routing) - straddling paymentLink rows open per-row on LIABILITY/paymentLink and the completion gates accept that marker; they no longer wedge the scan or land in the wrong bucket - rows open and unpriced at the cutover are pinned: the scans skip them with an alarm instead of throwing forever, and the card seq0 cannot re-book gross that the collateral feed already carried into the aggregate opening - paymentLink-funded buy-cryptos clear LIABILITY/paymentLink instead of letting both liabilities drift equity-neutrally - the Scrypt trade branch, payment seq0 and payout liability counter route through the mark bridge: no leg set reaches bookTx unbalanceable and no unvalued leg lands on a CHF-denominated liability --- .../__tests__/ledger-cutover.service.spec.ts | 85 ++++++++++- .../__tests__/buy-crypto.consumer.spec.ts | 115 ++++++++++++++ .../__tests__/buy-fiat.consumer.spec.ts | 88 +++++++++++ .../__tests__/crypto-input.consumer.spec.ts | 54 +++++-- .../__tests__/exchange-tx.consumer.spec.ts | 59 ++++++++ .../__tests__/payout-order.consumer.spec.ts | 58 +++++-- .../services/consumers/buy-crypto.consumer.ts | 100 ++++++++++++- .../services/consumers/buy-fiat.consumer.ts | 50 ++++++- .../consumers/crypto-input.consumer.ts | 28 ++-- .../consumers/exchange-tx.consumer.ts | 18 ++- .../consumers/ledger-watermark.helper.ts | 44 ++++++ .../consumers/payout-order.consumer.ts | 25 +++- .../services/ledger-cutover.service.ts | 141 +++++++++++++++--- 13 files changed, 785 insertions(+), 80 deletions(-) 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 index 0c6935130c..5c2702014b 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts @@ -381,6 +381,66 @@ describe('LedgerCutoverService', () => { expect(liabilityLeg.amountChf).toBe(-14851.5); // CHF output → mark 1 }); + // §4.7b/§6.1 (F1): a paymentLink-funded open buy_fiat (outputAmount NULL) 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). + it('opens a paymentLink-funded buyFiat-received row on LIABILITY/paymentLink, not buyFiat-received (F1)', async () => { + 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: { paymentLinkPayment: { id: 1 } } as any, + }), + ]); + } + 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 () => { + 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: { 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 + }); + // 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. @@ -918,8 +978,11 @@ describe('LedgerCutoverService', () => { expect(assetLeg.amountChf).toBeUndefined(); }); - it('skips a buyFiat-received row whose amountInChf is null (no CHF anchor → no leg)', async () => { + // 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([]); @@ -927,17 +990,25 @@ describe('LedgerCutoverService', () => { await service.run(); - expect(booked.some((b) => b.sourceId === '1557344:buy_fiat:42')).toBe(false); + 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 }); - it('opens buyCrypto-received per row CHF = amountInChf and skips a row with null amountInChf', async () => { + // 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 bookable row (id 60) + one null-amountInChf row (id 61, skipped) + // 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([]); @@ -950,7 +1021,11 @@ describe('LedgerCutoverService', () => { 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); // null amountInChf → skipped + 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 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 index e51f7805cc..a359b1b40d 100644 --- 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 @@ -7,6 +7,7 @@ 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'; @@ -591,4 +592,118 @@ describe('BuyCryptoConsumer', () => { 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 index 114f9f4b5a..3372494919 100644 --- 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 @@ -1275,4 +1275,92 @@ describe('BuyFiatConsumer', () => { 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 index a9392c0740..1ed1b08dc1 100644 --- 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 @@ -558,18 +558,44 @@ describe('CryptoInputConsumer', () => { expect(setSpy).not.toHaveBeenCalled(); // watermark NOT advanced }); - // lines 141-144 (isPayment, assetChf == null): a PAYMENT crypto_input whose asset has NO mark → mark undefined → - // assetChf undefined → BOTH legs needsMark; the paymentLink leg amountChf undefined (the `: undefined` side). - it('books a 2-leg isPayment tx with both legs needsMark when the asset has no mark', async () => { + // 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 + 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' }, }), ]); - // wallet carries assetId 999 → line-117 takes getMarkAt(999) which is undefined (not in markMap) jest .spyOn(accountService, 'findByAssetId') .mockResolvedValue(account('Unknown/XYZ', AccountType.ASSET, 'XYZ', 999)); @@ -580,11 +606,12 @@ describe('CryptoInputConsumer', () => { 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.needsMark).toBe(true); - expect(assetLeg.amountChf).toBeUndefined(); - expect(paymentLink.needsMark).toBe(true); - expect(paymentLink.amountChf).toBeUndefined(); // assetChf == null → `: undefined` - expect(paymentLink.amount).toBe(-0); // -(assetChf ?? 0) → -0 (the negation of the 0 fallback) + 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 @@ -616,15 +643,16 @@ describe('CryptoInputConsumer', () => { }); // 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. Uses an isPayment row so seq0 (2-leg, both needsMark → balances) - // books and the forward-fee seq1 is the only thing that throws — isolating the B7 guard. + // 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, // isPayment → seq0 is a balanced 2-leg (no appendFxPlug), so seq0 books + 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 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 index 22e4fd9eda..2c354dad1d 100644 --- 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 @@ -946,6 +946,65 @@ describe('ExchangeTxConsumer', () => { 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([ 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 index 097ec27be1..91c1233582 100644 --- 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 @@ -655,38 +655,66 @@ describe('PayoutOrderConsumer', () => { expect(setSpy).not.toHaveBeenCalled(); // watermark NOT advanced }); - // §4.5 liabilityCounter (lines 220/223): a liability row where BOTH the completion CHF (non-integer correlationId → - // no product lookup) AND the settlement CHF (no mark for the payout asset) are undefined → liabilityChf undefined → - // the owed leg amount falls to 0, amountChf undefined, needsMark true. mainChf undefined → wallet also needsMark → - // withFxPlug short-circuits, no plug. - it('books an owed leg with amount 0 / needsMark when both completion and settlement CHF are undefined', async () => { + // §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)); // no mark → settlementChf undefined + .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' }, // no mark + asset: { id: 999, uniqueName: 'Unknown/XYZ' }, // feedless 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.amount).toBe(0); // liabilityChf undefined → amount falls to 0 (line 220) - expect(owed.amountChf).toBeUndefined(); // line 222: amountChf = liabilityChf (undefined) - expect(owed.needsMark).toBe(true); // line 223: needsMark = liabilityChf == null + 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(wallet.needsMark).toBe(true); // mainChf undefined → wallet leg needsMark too - expect(wallet.amountChf).toBeUndefined(); - expect(leg(booked[0], 'INCOME/fx-revaluation')).toBeUndefined(); // a leg needsMark → no silent plug - expect(leg(booked[0], 'EXPENSE/fx-revaluation')).toBeUndefined(); + 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. diff --git a/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts b/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts index 2cb75461c9..63fa35f04f 100644 --- a/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts @@ -7,10 +7,16 @@ 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, runContentChangeScan, setLedgerWatermark } from './ledger-watermark.helper'; +import { + getLedgerWatermark, + isUnpricedAtCutover, + runContentChangeScan, + setLedgerWatermark, +} from './ledger-watermark.helper'; const SOURCE_TYPE = 'buy_crypto'; const CRYPTO_INPUT_SOURCE = 'crypto_input'; @@ -19,6 +25,7 @@ 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 @@ -69,6 +76,16 @@ export class BuyCryptoConsumer { // 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)`); } @@ -154,6 +171,10 @@ export class BuyCryptoConsumer { 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. @@ -216,9 +237,19 @@ export class BuyCryptoConsumer { 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'); - const owed = await this.liability('buyCrypto-owed'); // 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'); @@ -239,6 +270,63 @@ export class BuyCryptoConsumer { }; } + /** + * §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 @@ -356,7 +444,15 @@ export class BuyCryptoConsumer { 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 index ef5e94106d..d9b1106d25 100644 --- a/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts @@ -13,7 +13,12 @@ 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'; +import { + getLedgerWatermark, + isUnpricedAtCutover, + runContentChangeScan, + setLedgerWatermark, +} from './ledger-watermark.helper'; const SOURCE_TYPE = 'buy_fiat'; const CRYPTO_INPUT_SOURCE = 'crypto_input'; @@ -76,6 +81,16 @@ export class BuyFiatConsumer { // 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)`); } @@ -497,19 +512,40 @@ export class BuyFiatConsumer { return Util.round(-leg.amountChf, 2); // the opening Cr leg is −openingChf → owed-Dr debits +openingChf } - // §4.7b gate — returns the seq0 paymentLink opening CHF (= −leg.amountChf) or undefined if not yet opened + // §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) return undefined; + 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 + } - const seq0 = await this.ledgerTxRepo.findOne({ - where: { sourceType: CRYPTO_INPUT_SOURCE, sourceId: `${cryptoInputId}`, seq: 0 }, + // §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 = seq0?.legs?.find((l: LedgerLeg) => l.account?.name === PAYMENT_LINK); + const leg = opening?.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 + return Util.round(-leg.amountChf, 2); } // --- ACCOUNT/LEG HELPERS --- // diff --git a/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts b/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts index 6a27a58da1..ebbec01668 100644 --- a/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts @@ -151,24 +151,30 @@ export class CryptoInputConsumer { }; if (ci.isPayment) { - // paymentLink: 2-leg, mark-based (no per-input amountInChf anchor — @ManyToOne, Minor R10-4) + // §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: -(assetChf ?? 0), - priceChf: 1, - amountChf: assetChf != null ? -assetChf : undefined, - needsMark: assetChf == null, - }, - ], + legs: [assetLeg, { account: paymentLink, amount: -chf, priceChf: 1, amountChf: -chf, needsMark: false }], }; } diff --git a/src/subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts b/src/subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts index be9f194099..368431246e 100644 --- a/src/subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts @@ -269,9 +269,23 @@ export class ExchangeTxConsumer { const spreadChf = tx.feeAmountChf ?? 0; if (spreadChf !== 0) legs.push(await this.spreadLeg(tx.exchange, spreadChf)); - const plugChf = -legs.reduce((s, l) => s + (l.amountChf ?? 0), 0); // quote leg closes the tx + // 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 }); + 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) diff --git a/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts b/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts index f8698c97e7..31534a5cbf 100644 --- a/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts +++ b/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts @@ -19,6 +19,8 @@ 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 @@ -138,6 +140,48 @@ export async function isCoveredByCutoverOpening( 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'); /** diff --git a/src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts b/src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts index 812c7c0ba5..65f5f3026c 100644 --- a/src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/payout-order.consumer.ts @@ -237,12 +237,33 @@ export class PayoutOrderConsumer { 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); - const settlementChf = mark != null ? Util.round(mark * order.amount, 2) : undefined; + 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; - const needsMark = settlementChf == null; + // 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: { diff --git a/src/subdomains/core/accounting/services/ledger-cutover.service.ts b/src/subdomains/core/accounting/services/ledger-cutover.service.ts index eb975abda0..902591a7a0 100644 --- a/src/subdomains/core/accounting/services/ledger-cutover.service.ts +++ b/src/subdomains/core/accounting/services/ledger-cutover.service.ts @@ -34,7 +34,12 @@ 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, setCutoverBoundary } from './consumers/ledger-watermark.helper'; +import { + getCutoverBoundary, + getCutoverUnpricedIdsRaw, + 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 @@ -255,10 +260,18 @@ export class LedgerCutoverService { // against this map instead of a per-row bankRepo.findOne (N+1) const bankByIban = await this.bankByIban(); - await this.openBuyFiatReceived(snapshot, snapshotDate, lookback, equity); - await this.openBuyFiatOwed(snapshot, snapshotDate, lookback, marks, equity); - await this.openBuyCryptoReceived(snapshot, snapshotDate, lookback, equity); + // 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). @@ -267,43 +280,75 @@ export class LedgerCutoverService { 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) - private async openBuyFiatReceived(snapshot: Log, date: Date, lookback: Date, equity: LedgerAccount): Promise { + // 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). + 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. + relations: { cryptoInput: { paymentLinkPayment: true } }, }); - const liability = await this.liability('buyFiat-received'); + 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) continue; - await this.bookReceivedOwedOpening( - date, - `${snapshot.id}:buy_fiat:${row.id}`, - `Opening buyFiat-received from open buy_fiat #${row.id}`, - liability, - row.amountInChf, - equity, - ); + if (row.amountInChf == null) { + unpriced.push(row.id); // F2: no CHF anchor → pin; forward SKIPs+advances (alarm), value stays in the aggregate + 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) + // 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 { + ): Promise { const rows = await this.buyFiatRepo.find({ where: { isComplete: false, created: Between(lookback, date) }, - relations: { outputAsset: true }, + // F1: load paymentLinkPayment to detect a paymentLink row (its opening goes to LIABILITY/paymentLink, not -owed) + relations: { outputAsset: true, cryptoInput: { paymentLinkPayment: true } }, }); - const liability = await this.liability('buyFiat-owed'); + 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; + // 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; @@ -317,11 +362,38 @@ export class LedgerCutoverService { date, `${snapshot.id}:buy_fiat-owed:${row.id}`, `Opening buyFiat-owed from open buy_fiat #${row.id}`, - liability, + 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). @@ -334,14 +406,25 @@ export class LedgerCutoverService { // (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). - private async openBuyCryptoReceived(snapshot: Log, date: Date, lookback: Date, equity: LedgerAccount): Promise { + 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) }, }); const liability = await this.liability('buyCrypto-received'); + const unpriced: number[] = []; for (const row of rows) { - if (row.amountInChf == null) continue; + 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; + } await this.bookReceivedOwedOpening( date, `${snapshot.id}:buy_crypto:${row.id}`, @@ -351,6 +434,18 @@ export class LedgerCutoverService { 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) From 26ccab8dd7f803e8ce6bbf033e8b29333284d44f Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:16:42 +0200 Subject: [PATCH 22/25] fix(accounting): eleventh review pass (received opening mutually exclusive with input seq0) An open buy row whose funding crypto input had not settled at the snapshot no longer gets a per-row received/paymentLink opening: the input's value is not in the aggregate asset opening yet, so the forward crypto_input seq0 is the single opener and the pair would have double-credited the liability. Settled inputs, card and bank funded rows open per-row as before; a counter-check confirms the new tests fail without the guard. --- .../crypto-input-cutover.integration.spec.ts | 375 ++++++++++++++++++ .../__tests__/ledger-cutover.service.spec.ts | 167 +++++++- .../services/ledger-cutover.service.ts | 27 ++ 3 files changed, 564 insertions(+), 5 deletions(-) create mode 100644 src/subdomains/core/accounting/services/__tests__/integration/crypto-input-cutover.integration.spec.ts 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__/ledger-cutover.service.spec.ts b/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts index 5c2702014b..8afb1e5fa7 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts @@ -17,7 +17,7 @@ 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 } from 'src/subdomains/supporting/payin/entities/crypto-input.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'; @@ -341,6 +341,160 @@ describe('LedgerCutoverService', () => { 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 () => { + 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 () => { + 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. + 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: { 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 () => { + 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 () => { + 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('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 @@ -381,9 +535,12 @@ describe('LedgerCutoverService', () => { expect(liabilityLeg.amountChf).toBe(-14851.5); // CHF output → mark 1 }); - // §4.7b/§6.1 (F1): a paymentLink-funded open buy_fiat (outputAmount NULL) 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). + // §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 () => { jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); jest.spyOn(buyFiatRepo, 'find').mockImplementation(({ where }: any) => { @@ -393,7 +550,7 @@ describe('LedgerCutoverService', () => { id: 45, amountInChf: 1000, outputAmount: null, - cryptoInput: { paymentLinkPayment: { id: 1 } } as any, + cryptoInput: { paymentLinkPayment: { id: 1 }, status: PayInStatus.FORWARD_CONFIRMED } as any, // settled pre-cutover }), ]); } diff --git a/src/subdomains/core/accounting/services/ledger-cutover.service.ts b/src/subdomains/core/accounting/services/ledger-cutover.service.ts index 902591a7a0..2720815971 100644 --- a/src/subdomains/core/accounting/services/ledger-cutover.service.ts +++ b/src/subdomains/core/accounting/services/ledger-cutover.service.ts @@ -282,6 +282,12 @@ export class LedgerCutoverService { // 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 was NOT settled at the snapshot (status ∉ CryptoInputSettledStatus) + // 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 settled 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, @@ -293,6 +299,8 @@ export class LedgerCutoverService { // 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.status is ALSO read below (G-a): a funding input NOT settled at the snapshot has its forward seq0 as + // the sole received/paymentLink opener, so the per-row opening is skipped to avoid a double-credit. relations: { cryptoInput: { paymentLinkPayment: true } }, }); const received = await this.liability('buyFiat-received'); @@ -304,6 +312,10 @@ export class LedgerCutoverService { unpriced.push(row.id); // F2: no CHF anchor → pin; forward SKIPs+advances (alarm), value stays in the aggregate continue; } + // G-a: received opening is mutually exclusive with the forward crypto_input seq0 — the cutover only opens received + // when the input's value already sits in the aggregate ASSET opening (settled ≤ snapshot → its seq0 is suppressed + // by isCoveredByCutoverOpening); otherwise the forward seq0 is the single opener. + if (row.cryptoInput && !CryptoInputSettledStatus.includes(row.cryptoInput.status)) continue; await this.openBuyFiatRow(snapshot, date, row, received, paymentLink, equity); } @@ -406,6 +418,14 @@ export class LedgerCutoverService { // (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 was NOT settled at the + // snapshot (status ∉ CryptoInputSettledStatus) 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 settled 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, @@ -414,6 +434,9 @@ export class LedgerCutoverService { ): Promise { const rows = await this.buyCryptoRepo.find({ where: { isComplete: false, outputAmount: IsNull(), created: Between(lookback, date) }, + // G-a: load cryptoInput (+ status) to decide the received opener — a crypto-funded row whose input was not settled + // at the snapshot 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[] = []; @@ -425,6 +448,10 @@ export class LedgerCutoverService { unpriced.push(row.id); continue; } + // G-a: received opening is mutually exclusive with the forward crypto_input seq0 — the cutover only opens received + // when the input's value already sits in the aggregate ASSET opening (settled ≤ snapshot → its seq0 is suppressed + // by isCoveredByCutoverOpening); otherwise the forward seq0 is the single opener. + if (row.cryptoInput && !CryptoInputSettledStatus.includes(row.cryptoInput.status)) continue; await this.bookReceivedOwedOpening( date, `${snapshot.id}:buy_crypto:${row.id}`, From 1b35934ce8af281f0a41fc61e5d905d5b9f5a5ff Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:43:22 +0200 Subject: [PATCH 23/25] fix(accounting): twelfth review pass (per-row opening keys on the pinned boundary) The received/paymentLink per-row cutover opening now keys on the same immutable at-snapshot boundary as the forward seq0 suppression instead of the mutable live status: an input settling inside the retry window can no longer be opened per-row AND booked by its forward seq0. The opening is the exact complement of the suppression by construction. --- .../__tests__/ledger-cutover.service.spec.ts | 70 ++++++++++++++++++- .../services/ledger-cutover.service.ts | 43 ++++++++---- 2 files changed, 97 insertions(+), 16 deletions(-) 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 index 8afb1e5fa7..63da218dab 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts @@ -92,6 +92,17 @@ describe('LedgerCutoverService', () => { }); } + // 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(); @@ -350,6 +361,7 @@ describe('LedgerCutoverService', () => { // 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) @@ -371,6 +383,7 @@ describe('LedgerCutoverService', () => { }); 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) @@ -399,6 +412,7 @@ describe('LedgerCutoverService', () => { // 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) @@ -407,7 +421,7 @@ describe('LedgerCutoverService', () => { id: 82, amountInChf: 1000, outputAmount: null, - cryptoInput: { paymentLinkPayment: { id: 1 }, status: PayInStatus.ACKNOWLEDGED } as any, // unsettled + cryptoInput: { id: 820, paymentLinkPayment: { id: 1 }, status: PayInStatus.ACKNOWLEDGED } as any, // unsettled }), ]); return Promise.resolve([]); @@ -420,6 +434,7 @@ describe('LedgerCutoverService', () => { }); 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) @@ -440,6 +455,7 @@ describe('LedgerCutoverService', () => { }); 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) @@ -493,6 +509,55 @@ describe('LedgerCutoverService', () => { 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 () => { @@ -542,6 +607,7 @@ describe('LedgerCutoverService', () => { // 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) { @@ -550,7 +616,7 @@ describe('LedgerCutoverService', () => { id: 45, amountInChf: 1000, outputAmount: null, - cryptoInput: { paymentLinkPayment: { id: 1 }, status: PayInStatus.FORWARD_CONFIRMED } as any, // settled pre-cutover + cryptoInput: { id: 815, paymentLinkPayment: { id: 1 }, status: PayInStatus.FORWARD_CONFIRMED } as any, // settled pre-cutover }), ]); } diff --git a/src/subdomains/core/accounting/services/ledger-cutover.service.ts b/src/subdomains/core/accounting/services/ledger-cutover.service.ts index 2720815971..2372ccd4f9 100644 --- a/src/subdomains/core/accounting/services/ledger-cutover.service.ts +++ b/src/subdomains/core/accounting/services/ledger-cutover.service.ts @@ -37,6 +37,7 @@ import { LedgerMarkCache, LedgerMarkService } from './ledger-mark.service'; import { getCutoverBoundary, getCutoverUnpricedIdsRaw, + isCoveredByCutoverOpening, setCutoverBoundary, setCutoverUnpricedIds, } from './consumers/ledger-watermark.helper'; @@ -283,10 +284,11 @@ export class LedgerCutoverService { // 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 was NOT settled at the snapshot (status ∉ CryptoInputSettledStatus) - // is skipped here — its forward seq0 is the SINGLE received/paymentLink opener; opening it per-row too would + // 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 settled input keeps the per-row opening (its seq0 is suppressed as + // 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, @@ -312,10 +314,16 @@ export class LedgerCutoverService { unpriced.push(row.id); // F2: no CHF anchor → pin; forward SKIPs+advances (alarm), value stays in the aggregate continue; } - // G-a: received opening is mutually exclusive with the forward crypto_input seq0 — the cutover only opens received - // when the input's value already sits in the aggregate ASSET opening (settled ≤ snapshot → its seq0 is suppressed - // by isCoveredByCutoverOpening); otherwise the forward seq0 is the single opener. - if (row.cryptoInput && !CryptoInputSettledStatus.includes(row.cryptoInput.status)) 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); } @@ -418,11 +426,12 @@ export class LedgerCutoverService { // (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 was NOT settled at the - // snapshot (status ∉ CryptoInputSettledStatus) is EXCLUDED here — its forward crypto_input seq0 is the SOLE + // 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 settled input keeps the per-row opening (its + // 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. @@ -448,10 +457,16 @@ export class LedgerCutoverService { unpriced.push(row.id); continue; } - // G-a: received opening is mutually exclusive with the forward crypto_input seq0 — the cutover only opens received - // when the input's value already sits in the aggregate ASSET opening (settled ≤ snapshot → its seq0 is suppressed - // by isCoveredByCutoverOpening); otherwise the forward seq0 is the single opener. - if (row.cryptoInput && !CryptoInputSettledStatus.includes(row.cryptoInput.status)) 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}`, From 5e2f57840075eb1d511a1d6eead51e9c08f4ba8a Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:07:49 +0200 Subject: [PATCH 24/25] fix(accounting): thirteenth review pass (owed opening joins the boundary complement) openBuyFiatOwed now carries the same pinned-boundary coverage guard as the received openings, so its paymentLink and owed per-row openings can no longer pair with a forward seq0 of an input that settled inside the retry window; stale comments describing the old live-status logic rewritten. --- .../__tests__/ledger-cutover.service.spec.ts | 70 ++++++++++++++++++- .../services/ledger-cutover.service.ts | 22 ++++-- 2 files changed, 87 insertions(+), 5 deletions(-) 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 index 63da218dab..d3162519ae 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts @@ -637,6 +637,7 @@ describe('LedgerCutoverService', () => { // §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 @@ -647,7 +648,7 @@ describe('LedgerCutoverService', () => { amountInChf: 1000, outputAmount: 940, outputAsset: { id: 1, name: 'CHF' } as any, - cryptoInput: { paymentLinkPayment: { id: 1 } } as any, + cryptoInput: { id: 860, paymentLinkPayment: { id: 1 } } as any, }), ]); } @@ -664,6 +665,73 @@ describe('LedgerCutoverService', () => { 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 + }); + // 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. diff --git a/src/subdomains/core/accounting/services/ledger-cutover.service.ts b/src/subdomains/core/accounting/services/ledger-cutover.service.ts index 2372ccd4f9..40bdd9706b 100644 --- a/src/subdomains/core/accounting/services/ledger-cutover.service.ts +++ b/src/subdomains/core/accounting/services/ledger-cutover.service.ts @@ -301,8 +301,9 @@ export class LedgerCutoverService { // 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.status is ALSO read below (G-a): a funding input NOT settled at the snapshot has its forward seq0 as - // the sole received/paymentLink opener, so the per-row opening is skipped to avoid a double-credit. + // 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'); @@ -351,6 +352,18 @@ export class LedgerCutoverService { 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) { @@ -443,8 +456,9 @@ export class LedgerCutoverService { ): Promise { const rows = await this.buyCryptoRepo.find({ where: { isComplete: false, outputAmount: IsNull(), created: Between(lookback, date) }, - // G-a: load cryptoInput (+ status) to decide the received opener — a crypto-funded row whose input was not settled - // at the snapshot is skipped (its forward seq0 opens received); a Card/bank-funded row has cryptoInput=null. + // 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'); From 6e5a56ced23d8323396d8a2e1768acb9ec56b645 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:46:37 +0200 Subject: [PATCH 25/25] fix(accounting): fourteenth review pass (fourth opening joins the boundary complement) openBuyCryptoOwed carries the same pinned-boundary coverage guard as its three siblings, closing the last opener/suppression divergence cell in the snapshot-to-pin residual window; the buy-fiat owed relations comment now documents the coverage read. --- .../__tests__/ledger-cutover.service.spec.ts | 61 +++++++++++++++++++ .../services/ledger-cutover.service.ts | 23 ++++++- 2 files changed, 82 insertions(+), 2 deletions(-) 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 index d3162519ae..a0c8ac5d70 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts @@ -732,6 +732,67 @@ describe('LedgerCutoverService', () => { 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. diff --git a/src/subdomains/core/accounting/services/ledger-cutover.service.ts b/src/subdomains/core/accounting/services/ledger-cutover.service.ts index 40bdd9706b..c5e18dabe0 100644 --- a/src/subdomains/core/accounting/services/ledger-cutover.service.ts +++ b/src/subdomains/core/accounting/services/ledger-cutover.service.ts @@ -342,7 +342,8 @@ export class LedgerCutoverService { ): 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) + // 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'); @@ -514,13 +515,31 @@ export class LedgerCutoverService { ): Promise { const rows = await this.buyCryptoRepo.find({ where: { isComplete: false, created: Between(lookback, date) }, - relations: { outputAsset: true }, + // 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;