From a3d92ea3185f18d9ac9cd7e5efaf06d3ac8b93b1 Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:35:05 -0300 Subject: [PATCH] fix(accounting): recurring post-cutover CoA bootstrap (Frick bank_tx booking wedge) (#4309) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(accounting): re-run the idempotent CoA bootstrap after cutover The Chart-of-Accounts bootstrap only ran inside the one-time ledger cutover. Custody assets created after the cutover (e.g. the Bank Frick assets from #4252, prod-live three days after the cutover completed) never get an ASSET ledger account, so the first bank_tx referencing them wedges the BankTxConsumer fail-loud ('CoA bootstrap missing'), stalls the booking watermark and floods the log (~120 errors/h since 2026-07-22 12:16 UTC on bank_tx 207473, Frick/EUR). Register a recurring 5-minute @DfxCron wrapper (own kill-switch Process.LEDGER_COA_BOOTSTRAP, gated on isLedgerReady) that re-runs the idempotent bootstrap, and add a rename-guard in bootstrapAssetAccounts: UNIQUE is on name only, so after a uniqueName change findOrCreate(name) would create a second account for the same asset and make findByAssetId ambiguous — the existing account wins. * fix(accounting): fail loud on CoA account name collision After a uniqueName rename, the old name can be reused by a new asset; findOrCreate then name-hits the foreign account and returns it without creating anything — the recurring bootstrap would silently no-op on the exact case it exists to heal. Log an error instead (re-fires every run, persistent signal). Also cover the guard-skip loop continuation with a mixed two-asset spec. * test(accounting): restore the DfxLogger prototype spy after the collision spec The prototype-level mock outlives beforeEach's createMock rebuilds and would leak call history into later tests (repo convention restores it, cf. crypto-input.consumer.spec.ts). * fix(accounting): create CoA accounts active and isolate per-asset bootstrap failures Asset.isActive measures user tradability (all custody/bank assets are false), not account liveness - deriving ledger_account.active from it made every bootstrap-created ASSET account invisible to the reconciliation safety net while still being booked. findOrCreate now always creates live accounts; active=false stays a manual marker. A failing asset no longer aborts the whole recurring run: log and continue, so later assets and the transit/named sections still get their accounts. The collision check now also catches name-hits on NULL-assetId rows, and a real creation logs an info line as the post-deploy signal that the recurring bootstrap did its work. * test(accounting): assert cron schedule and lock timeout of the ledger job wrappers * fix(accounting): activate the bootstrap-created ASSET ledger accounts The bootstrap derived ledger_account.active from Asset.isActive, which is false for every custody/bank asset - so ALL asset-backed accounts created at cutover are invisible to the daily reconciliation while still being booked. The code fix stops the derivation for new accounts; this migration repairs the existing rows so the reconciliation safety net actually covers them. --- ...84600000011-ActivateAssetLedgerAccounts.js | 45 +++++ src/shared/services/process.service.ts | 1 + .../ledger-booking-job.service.spec.ts | 49 +++-- .../ledger-bootstrap.service.spec.ts | 185 +++++++++++++++++- .../services/ledger-account.service.ts | 12 +- .../services/ledger-booking-job.service.ts | 23 ++- .../services/ledger-bootstrap.service.ts | 44 ++++- 7 files changed, 314 insertions(+), 45 deletions(-) create mode 100644 migration/1784600000011-ActivateAssetLedgerAccounts.js diff --git a/migration/1784600000011-ActivateAssetLedgerAccounts.js b/migration/1784600000011-ActivateAssetLedgerAccounts.js new file mode 100644 index 0000000000..7cff68e79d --- /dev/null +++ b/migration/1784600000011-ActivateAssetLedgerAccounts.js @@ -0,0 +1,45 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * Activates the ASSET ledger accounts the CoA bootstrap created inactive. The bootstrap derived + * ledger_account.active from Asset.isActive — the OR of the user-tradability flags, which is false for every + * custody/bank asset — so all custody/bank ASSET accounts were created with active=false. Bookings still went + * through (account resolution does not filter on active), but the daily reconciliation, the recon status and the + * unverified-account query all select { type: 'Asset', active: true } and therefore never covered them: drift on + * exactly these accounts could not alarm. The code fix in this PR stops deriving active on creation; this + * migration repairs the rows that already exist. + * + * Scope: every ASSET account backed by an asset row (assetId IS NOT NULL). All of them were created by the + * bootstrap from the CoA universe (custody assets + on-chain assets with a liquidity balance), i.e. they are + * live-booked accounts; none was deactivated deliberately (the feature predates any manual curation). Feedless or + * dead-bank assets are safe to activate: the reconciliation classifies them (NO_FEED / BANK_DEAD / STALE) instead + * of diff-alarming. Rows with a NULL assetId (manual intervention only) are deliberately left untouched. + * + * Idempotent: WHERE "active" = false, a re-run touches nothing. + * + * Verified on a throwaway Postgres 16: flips exactly the inactive ASSET rows with an assetId, leaves active ASSET, + * non-ASSET and NULL-assetId rows untouched, and is a no-op (UPDATE 0) on re-run. + * + * @class + * @implements {MigrationInterface} + */ +module.exports = class ActivateAssetLedgerAccounts1784600000011 { + name = 'ActivateAssetLedgerAccounts1784600000011'; + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + await queryRunner.query( + `UPDATE "ledger_account" SET "active" = true WHERE "type" = 'Asset' AND "assetId" IS NOT NULL AND "active" = false`, + ); + } + + async down() { + // no-op: the pre-migration active=false state WAS the defect, and accounts created active=true by the fixed + // bootstrap are indistinguishable from the repaired rows — flipping them back would re-blind reconciliation. + } +}; diff --git a/src/shared/services/process.service.ts b/src/shared/services/process.service.ts index c41778f409..1999527ea0 100644 --- a/src/shared/services/process.service.ts +++ b/src/shared/services/process.service.ts @@ -112,6 +112,7 @@ export enum Process { LEDGER_RECONCILIATION = 'LedgerReconciliation', LEDGER_MARK_TO_MARKET = 'LedgerMarkToMarket', LEDGER_CUTOVER = 'LedgerCutover', + LEDGER_COA_BOOTSTRAP = 'LedgerCoaBootstrap', } const safetyProcesses: Process[] = [ 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 e794cc5a93..ed0304f0ce 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 @@ -1,4 +1,5 @@ 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'; @@ -13,6 +14,7 @@ import { LiquidityOrderDexConsumer } from '../consumers/liquidity-order-dex.cons import { PayoutOrderConsumer } from '../consumers/payout-order.consumer'; import { TradingOrderConsumer } from '../consumers/trading-order.consumer'; import { getLedgerWatermark, LedgerBookingJobService, setLedgerWatermark } from '../ledger-booking-job.service'; +import { LedgerBootstrapService } from '../ledger-bootstrap.service'; describe('LedgerBookingJobService', () => { let service: LedgerBookingJobService; @@ -26,6 +28,7 @@ describe('LedgerBookingJobService', () => { let liquidityMgmtConsumer: LiquidityMgmtConsumer; let liquidityOrderDexConsumer: LiquidityOrderDexConsumer; let tradingOrderConsumer: TradingOrderConsumer; + let bootstrapService: LedgerBootstrapService; beforeEach(async () => { settingService = createMock(); @@ -38,6 +41,7 @@ describe('LedgerBookingJobService', () => { liquidityMgmtConsumer = createMock(); liquidityOrderDexConsumer = createMock(); tradingOrderConsumer = createMock(); + bootstrapService = createMock(); const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -52,6 +56,7 @@ describe('LedgerBookingJobService', () => { { provide: LiquidityMgmtConsumer, useValue: liquidityMgmtConsumer }, { provide: LiquidityOrderDexConsumer, useValue: liquidityOrderDexConsumer }, { provide: TradingOrderConsumer, useValue: tradingOrderConsumer }, + { provide: LedgerBootstrapService, useValue: bootstrapService }, ], }).compile(); @@ -86,6 +91,7 @@ describe('LedgerBookingJobService', () => { await service.runLiquidityMgmt(); await service.runLiquidityOrderDex(); await service.runTradingOrder(); + await service.runCoaBootstrap(); expect(bankTxConsumer.process).not.toHaveBeenCalled(); expect(exchangeTxConsumer.process).not.toHaveBeenCalled(); expect(cryptoInputConsumer.process).not.toHaveBeenCalled(); @@ -95,6 +101,7 @@ describe('LedgerBookingJobService', () => { expect(liquidityMgmtConsumer.process).not.toHaveBeenCalled(); expect(liquidityOrderDexConsumer.process).not.toHaveBeenCalled(); expect(tradingOrderConsumer.process).not.toHaveBeenCalled(); + expect(bootstrapService.bootstrap).not.toHaveBeenCalled(); }); it('runs the consumers once the ledger is ready', async () => { @@ -108,6 +115,7 @@ describe('LedgerBookingJobService', () => { await service.runLiquidityMgmt(); await service.runLiquidityOrderDex(); await service.runTradingOrder(); + await service.runCoaBootstrap(); expect(bankTxConsumer.process).toHaveBeenCalledTimes(1); expect(exchangeTxConsumer.process).toHaveBeenCalledTimes(1); expect(cryptoInputConsumer.process).toHaveBeenCalledTimes(1); @@ -117,31 +125,42 @@ describe('LedgerBookingJobService', () => { expect(liquidityMgmtConsumer.process).toHaveBeenCalledTimes(1); expect(liquidityOrderDexConsumer.process).toHaveBeenCalledTimes(1); expect(tradingOrderConsumer.process).toHaveBeenCalledTimes(1); + expect(bootstrapService.bootstrap).toHaveBeenCalledTimes(1); }); }); - describe('@DfxCron kill-switch (Hard Constraint #5, Minor R9-2)', () => { - // every registered cron method must carry a Process.LEDGER_BOOKING_* flag (no silent no-guard cron) - const expectedFlags: Record = { - runBankTx: Process.LEDGER_BOOKING_BANK_TX, - runExchangeTx: Process.LEDGER_BOOKING_EXCHANGE_TX, - runCryptoInput: Process.LEDGER_BOOKING_CRYPTO_INPUT, - runPayoutOrder: Process.LEDGER_BOOKING_PAYOUT, - runBuyCrypto: Process.LEDGER_BOOKING_BUY_CRYPTO, - runBuyFiat: Process.LEDGER_BOOKING_BUY_FIAT, - runLiquidityMgmt: Process.LEDGER_BOOKING_LIQUIDITY_MANAGEMENT, - runLiquidityOrderDex: Process.LEDGER_BOOKING_LIQUIDITY_ORDER, - runTradingOrder: Process.LEDGER_BOOKING_TRADING_ORDER, + describe('@DfxCron kill-switch + schedule (Hard Constraint #5, Minor R9-2)', () => { + // every registered cron method must carry a Process flag (no silent no-guard cron) AND its intended schedule: + // the nine booking wrappers run EVERY_MINUTE, only the CoA bootstrap runs EVERY_5_MINUTES + const expectedCrons: Record = { + runBankTx: { process: Process.LEDGER_BOOKING_BANK_TX, expression: CronExpression.EVERY_MINUTE }, + runExchangeTx: { process: Process.LEDGER_BOOKING_EXCHANGE_TX, expression: CronExpression.EVERY_MINUTE }, + runCryptoInput: { process: Process.LEDGER_BOOKING_CRYPTO_INPUT, expression: CronExpression.EVERY_MINUTE }, + runPayoutOrder: { process: Process.LEDGER_BOOKING_PAYOUT, expression: CronExpression.EVERY_MINUTE }, + runBuyCrypto: { process: Process.LEDGER_BOOKING_BUY_CRYPTO, expression: CronExpression.EVERY_MINUTE }, + runBuyFiat: { process: Process.LEDGER_BOOKING_BUY_FIAT, expression: CronExpression.EVERY_MINUTE }, + runLiquidityMgmt: { + process: Process.LEDGER_BOOKING_LIQUIDITY_MANAGEMENT, + expression: CronExpression.EVERY_MINUTE, + }, + runLiquidityOrderDex: { + process: Process.LEDGER_BOOKING_LIQUIDITY_ORDER, + expression: CronExpression.EVERY_MINUTE, + }, + runTradingOrder: { process: Process.LEDGER_BOOKING_TRADING_ORDER, expression: CronExpression.EVERY_MINUTE }, + runCoaBootstrap: { process: Process.LEDGER_COA_BOOTSTRAP, expression: CronExpression.EVERY_5_MINUTES }, }; - for (const [method, flag] of Object.entries(expectedFlags)) { - it(`${method} carries its own ${flag} process flag`, () => { + for (const [method, expected] of Object.entries(expectedCrons)) { + it(`${method} carries its own ${expected.process} process flag, its schedule and the lock timeout`, () => { const params: DfxCronParams = Reflect.getMetadata( DFX_CRONJOB_PARAMS, LedgerBookingJobService.prototype[method as keyof LedgerBookingJobService], ); expect(params).toBeDefined(); - expect(params.process).toBe(flag); + expect(params.process).toBe(expected.process); + expect(params.expression).toBe(expected.expression); + expect(params.timeout).toBe(1800); }); } }); diff --git a/src/subdomains/core/accounting/services/__tests__/ledger-bootstrap.service.spec.ts b/src/subdomains/core/accounting/services/__tests__/ledger-bootstrap.service.spec.ts index b036818432..c3377b0015 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-bootstrap.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-bootstrap.service.spec.ts @@ -1,8 +1,9 @@ import { createMock } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; -import { AssetType } from 'src/shared/models/asset/asset.entity'; +import { Asset, 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 { DfxLogger } from 'src/shared/services/dfx-logger'; 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'; @@ -17,7 +18,7 @@ describe('LedgerBootstrapService', () => { let assetService: AssetService; let liquidityManagementBalanceService: LiquidityManagementBalanceService; - let created: { name: string; type: AccountType; currency: string; assetId?: number; active?: boolean }[]; + let created: { name: string; type: AccountType; currency: string; assetId?: number }[]; beforeEach(async () => { created = []; @@ -26,12 +27,19 @@ describe('LedgerBootstrapService', () => { 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 }); + jest.spyOn(ledgerAccountService, 'findOrCreate').mockImplementation(async (name, type, currency, assetId) => { + created.push({ name, type, currency, assetId }); + // mirror the real create path: the asset relation stub is set, @RelationId (assetId) stays unset + return createCustomLedgerAccount({ + name, + type, + currency, + asset: assetId != null ? ({ id: assetId } as Asset) : undefined, }); + }); + jest.spyOn(ledgerAccountService, 'findByAssetId').mockResolvedValue(undefined); + jest.spyOn(DfxLogger.prototype, 'info').mockImplementation(); + jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(); const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -45,10 +53,169 @@ describe('LedgerBootstrapService', () => { service = module.get(LedgerBootstrapService); }); + // the DfxLogger.prototype spies must not leak call history into other spec files, even when an assert throws + afterEach(() => jest.restoreAllMocks()); + it('should be defined', () => { expect(service).toBeDefined(); }); + it('skips an asset that already has an account under another name (rename-guard, UNIQUE is on name only)', async () => { + const renamed = createCustomAsset({ + id: 435, + uniqueName: 'FrickLI/EUR', + name: 'EUR', + dexName: 'EUR', + type: AssetType.CUSTODY, + }); + jest.spyOn(assetService, 'getAssetsWith').mockResolvedValue([renamed]); + jest.spyOn(liquidityManagementBalanceService, 'getBalances').mockResolvedValue([]); + jest + .spyOn(ledgerAccountService, 'findByAssetId') + .mockResolvedValue(createCustomLedgerAccount({ name: 'Frick/EUR', type: AccountType.ASSET, currency: 'EUR' })); + + await service.bootstrap(); + + expect(created.find((c) => c.name === 'FrickLI/EUR')).toBeUndefined(); + }); + + it('still creates accounts for later assets in the same run when an earlier one is guard-skipped', async () => { + const covered = createCustomAsset({ + id: 269, + uniqueName: 'Olkypay/EUR', + name: 'EUR', + dexName: 'EUR', + type: AssetType.CUSTODY, + }); + const uncovered = createCustomAsset({ + id: 435, + uniqueName: 'Frick/EUR', + name: 'EUR', + dexName: 'EUR', + type: AssetType.CUSTODY, + }); + jest.spyOn(assetService, 'getAssetsWith').mockResolvedValue([covered, uncovered]); + jest.spyOn(liquidityManagementBalanceService, 'getBalances').mockResolvedValue([]); + jest + .spyOn(ledgerAccountService, 'findByAssetId') + .mockImplementation(async (assetId) => + assetId === 269 + ? createCustomLedgerAccount({ name: 'Olkypay/EUR', type: AccountType.ASSET, currency: 'EUR' }) + : undefined, + ); + + await service.bootstrap(); + + expect(created.find((c) => c.name === 'Olkypay/EUR')).toBeUndefined(); + expect(created.find((c) => c.name === 'Frick/EUR')).toMatchObject({ type: AccountType.ASSET, assetId: 435 }); + }); + + it('logs an error when findOrCreate resolves to a foreign account (name collision, no silent no-op)', async () => { + const collided = createCustomAsset({ + id: 500, + uniqueName: 'Frick/EUR', + name: 'EUR', + dexName: 'EUR', + type: AssetType.CUSTODY, + }); + jest.spyOn(assetService, 'getAssetsWith').mockResolvedValue([collided]); + jest.spyOn(liquidityManagementBalanceService, 'getBalances').mockResolvedValue([]); + // the name is taken by asset 435's pre-rename account → findOrCreate name-hits the foreign account + jest.spyOn(ledgerAccountService, 'findOrCreate').mockResolvedValue( + Object.assign(createCustomLedgerAccount({ name: 'Frick/EUR', type: AccountType.ASSET, currency: 'EUR' }), { + assetId: 435, + }), + ); + const errorSpy = jest.spyOn(DfxLogger.prototype, 'error'); + + await service.bootstrap(); + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('name collision')); + }); + + it('logs a name collision (not a success) when findOrCreate name-hits a row with a NULL assetId', async () => { + const orphaned = createCustomAsset({ + id: 500, + uniqueName: 'Frick/EUR', + name: 'EUR', + dexName: 'EUR', + type: AssetType.CUSTODY, + }); + jest.spyOn(assetService, 'getAssetsWith').mockResolvedValue([orphaned]); + jest.spyOn(liquidityManagementBalanceService, 'getBalances').mockResolvedValue([]); + // manual-DB shape: the name is taken by a row without an asset id → neither assetId nor asset is populated; + // the asset still has no account, so this must stay loud, not count as a successful creation + jest + .spyOn(ledgerAccountService, 'findOrCreate') + .mockResolvedValue(createCustomLedgerAccount({ name: 'Frick/EUR', type: AccountType.ASSET, currency: 'EUR' })); + const errorSpy = jest.spyOn(DfxLogger.prototype, 'error'); + + await service.bootstrap(); + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('name collision')); + }); + + it('logs creation success and NO false collision error on a legitimate first creation', async () => { + // pins the `assetId ?? asset.id` coalesce: the create path returns an entity without @RelationId, so a + // plain assetId comparison would log a false collision ERROR for every real creation — the suite would stay + // green while prod floods the fail-loud channel + const custody = createCustomAsset({ + id: 435, + uniqueName: 'Frick/EUR', + name: 'EUR', + dexName: 'EUR', + type: AssetType.CUSTODY, + }); + jest.spyOn(assetService, 'getAssetsWith').mockResolvedValue([custody]); + jest.spyOn(liquidityManagementBalanceService, 'getBalances').mockResolvedValue([]); + const infoSpy = jest.spyOn(DfxLogger.prototype, 'info'); + const errorSpy = jest.spyOn(DfxLogger.prototype, 'error'); + + await service.bootstrap(); + + expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining("created ASSET account 'Frick/EUR'")); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it('continues with later assets and the transit/named sections when one asset fails (per-asset isolation)', async () => { + const failing = createCustomAsset({ + id: 1, + uniqueName: 'Bad/X', + name: 'X', + dexName: 'X', + type: AssetType.CUSTODY, + }); + const healthy = createCustomAsset({ + id: 2, + uniqueName: 'Good/Y', + name: 'Y', + dexName: 'Y', + type: AssetType.CUSTODY, + }); + jest.spyOn(assetService, 'getAssetsWith').mockResolvedValue([failing, healthy]); + jest.spyOn(liquidityManagementBalanceService, 'getBalances').mockResolvedValue([]); + jest.spyOn(ledgerAccountService, 'findOrCreate').mockImplementation(async (name, type, currency, assetId) => { + if (name === 'Bad/X') throw new Error('value too long for type character varying(16)'); + created.push({ name, type, currency, assetId }); + return createCustomLedgerAccount({ + name, + type, + currency, + asset: assetId != null ? ({ id: assetId } as Asset) : undefined, + }); + }); + const errorSpy = jest.spyOn(DfxLogger.prototype, 'error'); + + await service.bootstrap(); + + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('CoA bootstrap failed for asset 1'), + expect.any(Error), + ); + expect(created.find((c) => c.name === 'Good/Y')).toMatchObject({ type: AccountType.ASSET, assetId: 2 }); + expect(created.map((c) => c.name)).toContain('ROUNDING'); // named section still runs after the failure + }); + it('creates ASSET accounts from custody asset rows with name=uniqueName, currency=dexName, assetId set', async () => { const custody = createCustomAsset({ id: 100, @@ -183,10 +350,10 @@ describe('LedgerBootstrapService', () => { // already in the store returns the EXISTING account and is NOT pushed again → `created` only grows on first sight. const store = new Map>(); (ledgerAccountService.findOrCreate as jest.Mock).mockImplementation( - async (name: string, type: AccountType, currency: string, assetId?: number, active?: boolean) => { + async (name: string, type: AccountType, currency: string, assetId?: number) => { const existing = store.get(name); if (existing) return existing; // re-run no-op (the second bootstrap must hit only this branch) - created.push({ name, type, currency, assetId, active }); + created.push({ name, type, currency, assetId }); const acc = createCustomLedgerAccount({ name, type, currency }); store.set(name, acc); return acc; diff --git a/src/subdomains/core/accounting/services/ledger-account.service.ts b/src/subdomains/core/accounting/services/ledger-account.service.ts index ea17eb6c57..0a42db2f30 100644 --- a/src/subdomains/core/accounting/services/ledger-account.service.ts +++ b/src/subdomains/core/accounting/services/ledger-account.service.ts @@ -20,13 +20,7 @@ export class LedgerAccountService { // concurrent first-time create of the same lazily-created account (two consumer crons hitting the same TRANSIT // route) makes the loser's save hit the UNIQUE(name) constraint. Catch exactly that (SQLSTATE 23505), reload the // row the winner committed and return it → a true idempotent no-op. Any other error propagates unchanged. - async findOrCreate( - name: string, - type: AccountType, - currency: string, - assetId?: number, - active = true, - ): Promise { + async findOrCreate(name: string, type: AccountType, currency: string, assetId?: number): Promise { const existing = await this.findByName(name); if (existing) return existing; @@ -34,7 +28,9 @@ export class LedgerAccountService { name, type, currency, - active, + // always live: active=false is a manual/historical marker, never derived from asset tradability — an + // inactive ASSET account would still be booked but be invisible to reconciliation (§7) + active: true, asset: assetId != null ? ({ id: assetId } as Asset) : undefined, }); diff --git a/src/subdomains/core/accounting/services/ledger-booking-job.service.ts b/src/subdomains/core/accounting/services/ledger-booking-job.service.ts index 95a439ca0e..39ea8b7f0b 100644 --- a/src/subdomains/core/accounting/services/ledger-booking-job.service.ts +++ b/src/subdomains/core/accounting/services/ledger-booking-job.service.ts @@ -12,6 +12,7 @@ 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 { LedgerBootstrapService } from './ledger-bootstrap.service'; // 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'; @@ -19,11 +20,12 @@ export { getLedgerWatermark, LedgerWatermark, setLedgerWatermark } from './consu 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. + * Holds the shared cutover-gate (§4-header Blocker R1-6) and registers the @DfxCron wrappers for the consumers, + * plus the recurring post-cutover CoA bootstrap. 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 { @@ -38,6 +40,7 @@ export class LedgerBookingJobService { private readonly liquidityMgmtConsumer: LiquidityMgmtConsumer, private readonly liquidityOrderDexConsumer: LiquidityOrderDexConsumer, private readonly tradingOrderConsumer: TradingOrderConsumer, + private readonly bootstrapService: LedgerBootstrapService, ) {} // cutover-gate (Blocker R1-6): no consumer books before bootstrap+opening set the ready marker @@ -102,4 +105,14 @@ export class LedgerBookingJobService { if (!(await this.isLedgerReady())) return; await this.tradingOrderConsumer.process(); } + + // post-cutover CoA maintenance: assets created AFTER the one-time cutover (new bank/custody assets, e.g. Bank + // Frick #4252) never get an ASSET account from the cutover-only bootstrap and wedge their consumer fail-loud + // ("CoA bootstrap missing"). bootstrap() is idempotent (findOrCreate, §3), so a recurring re-run is a no-op + // once complete. Pre-cutover the cutover run owns the bootstrap → gate on isLedgerReady. + @DfxCron(CronExpression.EVERY_5_MINUTES, { process: Process.LEDGER_COA_BOOTSTRAP, timeout: 1800 }) + async runCoaBootstrap(): Promise { + if (!(await this.isLedgerReady())) return; + await this.bootstrapService.bootstrap(); + } } diff --git a/src/subdomains/core/accounting/services/ledger-bootstrap.service.ts b/src/subdomains/core/accounting/services/ledger-bootstrap.service.ts index ffbcba07ef..5702a18dbf 100644 --- a/src/subdomains/core/accounting/services/ledger-bootstrap.service.ts +++ b/src/subdomains/core/accounting/services/ledger-bootstrap.service.ts @@ -1,12 +1,15 @@ import { Injectable } from '@nestjs/common'; import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; import { AssetService } from 'src/shared/models/asset/asset.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; import { LiquidityManagementBalanceService } from 'src/subdomains/core/liquidity-management/services/liquidity-management-balance.service'; import { AccountType } from '../entities/ledger-account.entity'; import { LedgerAccountService } from './ledger-account.service'; @Injectable() export class LedgerBootstrapService { + private readonly logger = new DfxLogger(LedgerBootstrapService); + // §3.4 — single authoritative bootstrap name lists (character-exact) private static readonly LIABILITY_ACCOUNTS = [ 'buyFiat-owed', @@ -100,14 +103,39 @@ export class LedgerBootstrapService { 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, - ); + // per-asset failure isolation: on the recurring run a deterministically failing asset must not starve the + // assets after it (and the transit/named sections) every cycle — log and continue instead of aborting + try { + // rename-guard: UNIQUE is on name only, so after a uniqueName change findOrCreate(name) would create a + // second account for the same asset and make findByAssetId ambiguous — the existing account wins + if (await this.ledgerAccountService.findByAssetId(asset.id)) continue; + + // non-null fallback for currency (currency is NOT NULL, dexName is nullable) — §3.2 Minor R7-8 + const account = await this.ledgerAccountService.findOrCreate( + asset.uniqueName, + AccountType.ASSET, + asset.dexName ?? asset.name, + asset.id, + ); + + // name collision: findOrCreate hit an account NOT belonging to this asset (another asset's pre-rename name + // or a NULL-assetId row) → this asset still has no account and its consumers stay wedged; keep the + // recurring re-run loud instead of no-oping. The coalesce is load-bearing: @RelationId stays unset on the + // entity returned by the create path (only a name-hit load populates it), the create path carries the + // asset relation stub instead. + const ownerId = account.assetId ?? account.asset?.id; + if (ownerId !== asset.id) { + this.logger.error( + `CoA account name collision: '${asset.uniqueName}' belongs to asset ${ownerId ?? 'NULL'}, no account created for asset ${asset.id}`, + ); + } else { + // once per new asset (the rename-guard keeps the steady state silent) — the post-deploy signal that the + // recurring bootstrap actually created something, distinguishable from the cron not running at all + this.logger.info(`CoA bootstrap created ASSET account '${asset.uniqueName}' for asset ${asset.id}`); + } + } catch (e) { + this.logger.error(`CoA bootstrap failed for asset ${asset.id} ('${asset.uniqueName}'):`, e); + } } }