Skip to content

Release: develop -> main - #4479

Merged
TaprootFreak merged 3 commits into
mainfrom
develop
Jul 30, 2026
Merged

Release: develop -> main#4479
TaprootFreak merged 3 commits into
mainfrom
develop

Conversation

@github-actions

Copy link
Copy Markdown

Automatic Release PR

This PR was automatically created after changes were pushed to develop.

Commits: 1 new commit(s)

Checklist

  • Review all changes
  • Verify CI passes
  • Approve and merge when ready for production

* feat(bank): make the deposit-target bank an operational input

The customer-facing deposit selector filtered Bank Frick out by name, so
switching a currency to a different receiving bank required a deploy.

Replace that hardcoded exclusion with a Bank.receivePriority column,
mirroring the existing Bank.sendPriority tie-breaker: lower value wins,
ties broken by ascending id. Deploying this changes nothing on its own -
every row is backfilled to the neutral default, so the incumbent banks
keep their currencies until the priority is deliberately lowered.

The migration is schema-only and never touches bank rows, per the
established convention for that table.

* test(bank): build the selector fixtures as real Bank instances

A plain spread of the shared mocks produces an object literal without the
entity methods, so it does not satisfy the repository's Bank[] contract
and fails the type check. Route the id-carrying copies through
createCustomBank, which assigns onto a real Bank.

* fix(bank): make deposit-target eligibility explicit instead of implied by rank

Review found that ranking alone does not preserve today's behaviour. The
removed filter excluded a bank categorically; a low rank only deprioritises
it. Whenever the incumbent for a currency is missing or receive=false, or
the sctInst lookup falls through to the general fallback, the previously
excluded bank would be shown to a customer without anyone switching it on.

Make receivePriority nullable with NULL meaning 'never offered as a deposit
target', and filter on it before ranking. Eligibility is deliberately not
derived from receive: a bank can need to accept and reconcile incoming money
without being advertised. The migration now backfills exactly the banks the
old name filter already allowed, so the deploy freezes today's routing
instead of merely making a change unlikely.

* refactor(bank): declare the selector's undefined result and correct a stale comment

getBank and getMatchingBank returned Bank while both find() calls can
yield undefined. That was already true, but explicit eligibility makes it
a documented outcome: a bank left at NULL priority is skipped even as the
last candidate, so no match is a state the callers must handle. Both of
them already did.

Also correct the payout comment that claimed to mirror a bank-name
exclusion in the deposit selector - that exclusion no longer exists there.

* fix(bank): give the new migration a fresh identity and pin sctInst eligibility

TypeORM identifies a migration by its class name, not its file contents.
This migration was edited in place after its first version already existed
on the branch, so any database that had run the old NOT NULL DEFAULT 1000
version would never run the corrected one and would keep every bank
eligible. Production has not run it (no such row in migrations), but a
local database might have, so the migration gets a new timestamp and class
name. If the old version did run somewhere, ADD COLUMN now fails loudly
instead of leaving a silently wrong state.

Also add the missing INSTANT case: a NULL-priority bank carrying sctInst
must not win the instant branch, which pins that eligibility is filtered
before the sctInst lookup rather than after it.

* refactor(buy): state the non-null precondition of the bank response builder

Widening getBank to Bank | undefined widened this helper's parameter with
it, while the body dereferences the bank unconditionally. The caller
already rejects the undefined case, so the signature should carry that
precondition rather than claim to accept a value it cannot handle.

* fix(seed): seed receivePriority so a fresh database has eligible deposit banks

The selector skips any bank whose receivePriority is NULL. The migration
backfills rows that already exist, but a fresh database is migrated while
the bank table is still empty, so the backfill touches nothing and the
seed then inserts every bank without the column - leaving local, CI and
onboarding environments with no eligible deposit target at all.

Seed the column explicitly: 1000 for the banks the selector already
offered, empty (NULL) for the dormant Bank Frick rows, mirroring the split
the migration applies to an existing database.

* docs(bank): scope the receivePriority guarantee to the generic selector

The column comment claimed a bank at NULL is never offered to a customer
as a deposit target. That overstates it: the explicit personal-IBAN path
resolves its own bank and is not gated by this column - it never was, and
the bank-name filter this replaced did not cover it either. A comment that
promises a guarantee the code does not hold is worse than none, so state
the scope and note the cache delay that applies to receive just the same.

* docs(bank): scope the remaining eligibility statements to the generic selector

The entity comment was corrected, but the migration docstring and the seed
comment still claimed a NULL leaves no eligible deposit target at all.
Explicit personal-IBAN paths are not gated by this column, so both now name
the selector they actually describe.

* docs(bank): spell out that receive and receivePriority are ANDed

The comment said a number makes a bank eligible, next to a line saying
eligibility is not derived from receive. Read together that suggests the
priority alone decides. It does not: the selector starts from receive=true
rows, so a receive=false bank stays excluded whatever its priority.

* refactor(bank): route EUR deposits to Bank Frick in code

Replaces the data-driven receivePriority approach with the hardcoded rule
the product decision calls for: an EUR bank transfer is routed to Bank
Frick. The column, its migration, the seed entries and the debug allowlist
entry are removed again.

Other receiving banks stay a fallback: if the Frick EUR row is not
receiving, EUR deposits keep working through the incumbents rather than
failing. SEPA Instant is exempt because Bank Frick does not offer it, so an
instant request still reaches a bank that can execute it.

Switching this back now requires a release rather than a data change.

* test(bank): drop the import left unused by the removed priority cases

* test(bank): pin the currency check in the Frick EUR rule

Removing `bank.currency === 'EUR'` from the rule left the whole suite green,
so nothing guarded it. The rule matches on name and currency and find()
takes the first hit, so without that check an EUR deposit could be handed
the franc account's IBAN. The CHF row is listed first to make the case bite.

* fix(bank): keep Bank Frick out of every path the EUR rule does not cover

The removed bank-name filter excluded Bank Frick from the whole selector.
Replacing it with an EUR-only rule dropped that exclusion everywhere else,
so Frick became a candidate again in paths the rule never claimed:

- a CHF request could return the Frick franc row, which is receive=true in
  production, depending on a database order that is not guaranteed
- an EUR instant request with no sctInst bank available fell through to the
  generic fallback and could return Frick there - the exact case the instant
  exemption exists to prevent
- the condition tested paymentMethod !== INSTANT, which is also true for CARD

Scope the rule positively to BANK, and restore the categorical exclusion for
everything after it. Resolve several qualifying Frick rows by highest id, the
way getBankInternal already resolves an ambiguous (name, currency) pair.

One test asserted the fallback behaviour that was the defect; it now asserts
the incumbent wins, in both input orders.

* fix(bank): resolve the Frick EUR row through the attribution mechanism

The tie-breaker sorted by highest id while claiming to mirror
getBankInternal. It did not: selectAttributionBank prefers the asset-linked
row, because that binding is what isBankMatching and the booked bank_tx
history are keyed on. With two active Frick EUR rows the customer would have
been shown the newer row's IBAN while attribution stayed on the older one,
so incoming payments would not match and book with pendingInputAmount 0 -
the netting skew the code warns about at that very function.

getReceiveBanks does not load the asset relation either, so the preference
could never have applied there. Resolve through getBankInternal instead,
which is cached and does load it, and keep the receive check since it does
not filter on that.

* test(bank): pin that no other Frick row substitutes the attributed one

When the asset-linked Frick EUR row is not receiving, a second unbound Frick
row must not stand in for it: attribution stays on the disabled row, so a
payment into the unbound IBAN would book against a row nothing is keyed on.

Also scope the comment above the rule. It aligns the selection rule with
attribution, not the caches - ibanCache is loaded once at module init while
this read goes through the repository cache, so a row edited at runtime can
still be seen differently by the two until restart. That gap predates this
rule and applies to every bank, and the comment should not read as a promise
that they can never disagree.

* test(bank): reject array filters in the findCached mock instead of casting

The helper cast the where clause to the object variant, discarding the array
form the signature allows. A future array filter would then have matched
nothing and silently returned every bank instead of failing. Its sibling
three lines up already rejects that case explicitly; do the same here.
…-log fields in SQL (#4480)

* perf(ledger): index the content-change scan and project financial-log fields in SQL

Two independent sources of database load on dfxprd, both measured in production.

1. The nine ledger consumers each run a (updated, id) keyset content-change scan
   every minute, but none of their source tables had an index on `updated`.
   EXPLAIN (ANALYZE, BUFFERS) for trading_order showed a Parallel Seq Scan reading
   ~494 MB from disk and filtering 5,418,069 rows to return 4, at 151.765 ms per
   call. Add composite (updated, id) indexes on all nine source tables.

2. GET /v1/dashboard/financial/log selected every matching row including the
   ~42 KB `message` JSON: 1,316 MB of message payload across the 31,925 matching
   rows, requested twice per minute, with a 6.6 s median and 29.8 s maximum query
   time. The endpoint needs only four `balancesTotal` scalars, one BTC price and
   the `balancesByFinancialType` sub-tree — `assets` (1,346 MB) and `tradings`
   (43 MB) were never read. Project just those sub-trees in SQL. The response is
   unchanged for the same underlying data.

Malformed `message` JSON now aborts the query instead of silently dropping the
row: for a financial dashboard a visible failure is preferable to an unnoticed
gap in the series. Verified that all 31,925 matching rows currently hold valid
JSON.

* style: apply prettier formatting to log repository spec

* fix: use deterministic TypeORM index names in ledger scan migration

Replace the nine self-invented index names in
AddLedgerContentChangeScanIndexes1785460000000 with the deterministic
names TypeORM's DefaultNamingStrategy would generate itself, per the
"No custom naming for TypeORM indexes" rule. Also documents the
derivation in the migration's docstring so the names can be recomputed.

* fix(log): address financial-log projection review findings

- Restore the falsy btcAssetId check (0/undefined/null all take the
  no-BTC-asset path) that extractBtcPrice used to have, instead of
  only excluding undefined.
- Guard all five projected numbers (totalBalanceChf, plusBalanceChf,
  minusBalanceChf, btcPriceChf, fxPnlChf) with a jsonb_typeof check so
  one bad value nulls only that field instead of aborting the whole
  query, and align the fxPnlChf guard with the same pattern used by
  the neighboring priceChf projection.
- Remove the now-unused extractBtcPrice helper.
- Add coverage for the ORDER BY direction, the exact $N parameter
  position per SQL condition, and the projection's edge cases
  (btcAssetId 0/undefined, missing/null balancesTotal fields), and
  drop assertions that only read back their own test fixture.

* docs(migration): correct the lock-behaviour analysis for the index migration

The previous note claimed a CREATE INDEX SHARE lock is held only for the
duration of that one index build, and justified the write-blocking risk for
trading_order on that basis. That was wrong.

All nine CREATE INDEX statements run inside a single transaction: TypeORM's
migrationsTransactionMode defaults to "all" (DataSource.js:263-265) and
config.ts never overrides it, and MigrationExecutor.js:206 opens one
transaction for all pending migrations. PostgreSQL releases locks at COMMIT,
not per statement. The write-blocking window for trading_order is therefore
the sum of all nine builds, and once the transaction commits all nine tables
are simultaneously write-blocked -- including bank_tx, buy_crypto,
crypto_input and payout_order. Splitting this across several migration files
would not change that.

Also drop the redundant SET LOCAL lock_timeout calls. SET LOCAL is scoped to
the transaction, so 18 of the 19 had no effect and implied a per-statement
guard that does not exist; one call in up() and one in down() remain.

* fix(log): stop defaulting SQL NULL to 0 in the financial-log projection

totalBalanceChf/plusBalanceChf/minusBalanceChf now stay null when the SQL
projection nulls them, matching the fxPnlChf handling already in place; the
`?? 0` default remains solely at the mapSummaryToEntry call site. Also drop
the Number() coercion in the balancesByType loop so a genuinely missing key
passes through unconverted instead of becoming NaN/null, and fail loud with
the log id and type key when a balancesByFinancialType entry is not an
object instead of throwing an unhandled TypeError. Narrows the "byte-for-byte
identical" docstring claim to its actual scope and documents down()'s
ACCESS EXCLUSIVE lock behaviour and the per-statement scope of lock_timeout
in the index migration.

* fix(log): make balancesByType optional and revert the F11 throw

FinancialLogSummary.balancesByType and FinancialLogEntryDto.balancesByType
promised plain `number` fields, but a real production row has an entry
missing one of the two keys, which the repository already passes through
as `undefined` (proven by an existing test). Mark both fields optional and
narrow the `as` cast accordingly instead of forcing a promise the code
doesn't keep.

Also drop the now-unreachable JSON-string branch when parsing
balancesByFinancialType: pg-types always returns jsonb columns already
parsed (OID 3802), so the string path had zero coverage and no path to it.

Most importantly, revert the typeof/null throw added for a prior finding:
it was too broad. Before that guard, a primitive balancesByFinancialType
entry (number/string/boolean) never threw at all - JS auto-boxes primitives
for property access, so e.g. `(1).plusBalanceChf` is simply `undefined`.
Only `null` used to throw, and only inside the old mapper's per-row
try/catch, silently dropping just that one row. The throw turned three
previously-harmless cases into a 500 for the whole request, and widened
null's blast radius from "one row" to "everything". Optional chaining
covers all of it in one line: primitives resolve to undefined exactly as
before, and null now keeps the row (with an empty balancesByType entry)
instead of either silently vanishing or failing the whole request.

* docs(log): describe what the financial-log projection actually does

Two comments claimed more than the code delivers.

mapSummaryToEntry still promised a byte-identical response. That stopped being
true on purpose: a `balancesByFinancialType` entry holding `null` used to make
the previous mapLogToEntry drop the entire log line via its per-row try/catch,
whereas the row is now kept with an empty entry. A silent gap in a financial
curve is worse than an empty partial entry. The same comment also claimed SQL
"fails loud" without qualification, while individual scalar values are in fact
tolerated by the jsonb_typeof guards.

The balancesByType loop now records why it deliberately has no per-property
typeof guard: the case is unproven in production (287,989 entries with both
values numeric, one with plusBalanceChf missing, none with a string, boolean or
null value), a guard would break response equivalence with the old mapLogToEntry
which passed such values through unchanged, and unlike the five scalar fields
there is no ::float8 cast here that could abort the query.

* fix(log): keep only real numbers in the balancesByType projection

The signature promises `plusBalanceChf?: number`, but a balancesByFinancialType
entry whose property itself holds a string, boolean or null was passed through
unchanged, so the endpoint could return values the DTO does not allow. The
previous mapLogToEntry had the same hole; this closes it instead of carrying it
forward.

Only real numbers are kept now; every other value becomes undefined. On the
current production data this changes nothing (287,989 entries with both values
numeric, one with plusBalanceChf missing, none holding a string, boolean or
null), so it exists to protect the contract for future data. A missing key still
yields undefined, and a non-object entry still does not throw.

This applies the same hardening the five scalar fields already get via
jsonb_typeof in SQL, in TypeScript because balancesByFinancialType is passed
through as a raw JSON object.
@TaprootFreak
TaprootFreak merged commit 15d781d into main Jul 30, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant