From 88977fa1f3146b5c5f7278085ec143da5c1782b8 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 1/4] 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 0c75396d2b..052a9daf66 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -113,6 +113,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 f16da5d8c0..8f0263e8d8 100644 --- a/src/shared/services/process.service.ts +++ b/src/shared/services/process.service.ts @@ -91,6 +91,19 @@ export enum Process { TRADE_APPROVAL_DATE = 'TradeApprovalDate', SUPPORT_BOT = 'SupportBot', GUARANTEED_PRICE = 'GuaranteedPrice', + // 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 ba1055112defb88b298e2caf393f88010ad37366 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 2/4] 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 d5a41168ecd9a32e3417fa3d274108e996571bf0 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 3/4] 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 d63cefcd9a..2fa00baf66 100644 --- a/src/subdomains/supporting/log/log.service.ts +++ b/src/subdomains/supporting/log/log.service.ts @@ -39,6 +39,10 @@ export class LogService { return this.logRepo.save({ ...log, ...dto }); } + 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 9b07c3fd0af4433dba16b0ecc93c9365e8a3b83c 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 4/4] 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 + }); });