Files
accounted/lib/transactions/external-id.ts
T
Jakob Wennberg 506d030bb1 fix(reconciliation): exclude ignored transactions from the bank total and bridge whitespace-drifted duplicate descriptions (#1705)
Bank reconciliation counted ignored transactions in bank_transaction_total
while excluding them from the unmatched count, so after the sanctioned
duplicate cleanup (ignore one twin) the differens showed the ignored sum
forever and is_reconciled was unreachable: observed live as a permanent
116 367 kr differens on a fully booked enskild firma (78 867 kr ignored
reconnect duplicates + 37 500 kr genuinely unbooked). The ignore toast
already promised 'försvinner från avstämningen'; now the engine keeps
that promise. Ignored rows are surfaced separately (count + sum) in the
status object, the UI card, and the v1 API, mirroring the IB pattern.

The duplicates themselves came from a PSD2 reconnect: the new connection
re-rendered identical transactions with drifted whitespace (CRLF vs
space, and a DROPPED space), so the prefix-containment content bridge
missed every twin. descriptionsBridge now strips all whitespace before
comparing: char-filtering preserves existing prefix relations, and the
compare stays confined to a (date, öre) bucket.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 20:19:26 +02:00

199 lines
10 KiB
TypeScript

/**
* Shared helpers for deriving stable bank-transaction `external_id`s and for
* normalizing monetary amounts used in dedup keys.
*
* Why this exists
* ---------------
* The transactions table is deduplicated on `(company_id, external_id)` (a
* partial unique index, see migration 20260330130000). The dedup is therefore
* only as good as the stability of `external_id` across re-syncs.
*
* For Enable Banking (PSD2 / Berlin Group) the previous scheme keyed
* `external_id` off the bank's `entry_reference` / `transaction_id`
* (`eb_{account}_{tx.id}`). Many Swedish ASPSPs do NOT return those fields
* stably across requests: a later "synka nu" can return the same underlying
* transaction with a different id, which produced a *new* `external_id` and
* therefore a duplicate row (including for transactions the user had already
* booked). See `buildStableExternalIds` for the content-derived replacement.
*/
/**
* Normalize a monetary amount to integer öre (hundredths) for stable,
* representation-agnostic comparison.
*
* PostgREST may return a `numeric` column as a JS number OR as a string
* (preserving precision), so `1234.5` and the string `"1234.50"` can describe
* the same amount. Interpolating either directly into a dedup key yields
* different strings (`"1234.5"` vs `"1234.50"`), silently breaking content
* dedup. Rounding to integer öre collapses both to `123450`.
*
* Uses the project-standard `Math.round(x * 100)` (never `toFixed`).
*/
export function amountToOre(amount: number | string): number {
return Math.round(Number(amount) * 100)
}
/**
* Swedish-first placeholder for transactions a bank/import source gives no
* usable title. Centralized so every import path and the tests agree.
*/
export const FALLBACK_DESCRIPTION = 'Okänd transaktion'
/**
* Normalize an imported transaction title for storage and display.
*
* Maps both an empty/whitespace title AND the legacy English 'Unknown'
* sentinel (still emitted by the bank-file format parsers and as the Enable
* Banking converter's last resort) to a Swedish-first neutral. Applied once at
* the ingest boundary so every source (PSD2 sync + CSV/CAMT import) inherits
* it; the bank's verbatim text is preserved separately in
* `transactions.original_description`. Match on the exact 'unknown' sentinel
* (case-insensitive) so a real description that merely contains the word is
* never clobbered.
*/
export function normalizeImportedDescription(raw: string | null | undefined): string {
const trimmed = (raw ?? '').trim()
if (!trimmed || trimmed.toLowerCase() === 'unknown') return FALLBACK_DESCRIPTION
return trimmed
}
/**
* Build stable, collision-safe `external_id`s for a batch of bank transactions
* whose provider does not supply a reliable stable id (e.g. Enable Banking).
*
* The id is derived from content: `{prefix}_{accountScope}_{date}_{öre}_{n}`,
* where `n` is an occurrence index that disambiguates genuinely identical
* transactions (same account, date and amount) within the batch.
*
* ⚠️ THE FORMAT STRING IS A STORED KEY. It is persisted to
* `transactions.external_id` and dedup compares incoming ids against the stored
* ones byte-for-byte. Changing this template silently orphans every prior row
* (its stored id no longer matches the new scheme) and re-imports them all on
* the next sync: this is exactly what happened in the June 2026 fleet-wide
* incident. Any format change MUST ship a coordinated backfill of existing rows
* and is locked by a frozen-format test (see `external-id.test.ts`).
*
* Properties this guarantees:
* - **Re-sync dedupe**: the same set of transactions produces the same *set*
* of ids regardless of the order the ASPSP returns them in, so a repeat sync
* collides with the existing rows on `(company_id, external_id)` and is
* skipped, even after the user has booked them. (The id *set* is what the
* unique index enforces; which physical row maps to `..._0` vs `..._1` need
* not be stable, only the set.)
* - **No false dedupe**: two legitimately distinct transactions that share a
* date and amount get different ids (`..._0`, `..._1`) and are both kept.
* This is the safeguard the bank-file importer already relies on via its
* `rowIndex` component (see `lib/import/bank-file/parser.ts`).
*
* Why description is NOT an input here (but IS in the content bridge): the
* `external_id` must be a *stable unique key*, so it cannot depend on a field
* that drifts: PSD2 enriches/reorders descriptions between a transaction's
* pending and booked states. The occurrence index gives uniqueness without
* that fragility. The content bridge (`contentBucketKey` + `descriptionsBridge`)
* has the opposite job: it is a best-effort *bridge* that must avoid dropping
* real transactions, so it keeps the description (see those for the trade-off).
*
* @param prefix Source tag, e.g. `'eb'` for Enable Banking.
* @param accountScope Stable per-account scope (prefer IBAN, fall back to the
* provider account uid). Keeps ids unique across accounts.
* Callers should pass a whitespace/case-normalized IBAN so
* formatting variants ("SE45 5000…" vs "SE455000…") don't
* produce different ids for the same account.
* @param txns Batch in provider order; each needs `date` + `amount`.
*/
export function buildStableExternalIds(
prefix: string,
accountScope: string,
txns: Array<{ date: string; amount: number | string }>
): string[] {
const occurrences = new Map<string, number>()
return txns.map((tx) => {
const fingerprint = `${tx.date}_${amountToOre(tx.amount)}`
const n = occurrences.get(fingerprint) ?? 0
occurrences.set(fingerprint, n + 1)
return `${prefix}_${accountScope}_${fingerprint}_${n}`
})
}
/**
* Bucket key for the content-dedup bridge: `{date}|{öre}`, deliberately NO
* description. Transactions that share a date and amount fall into the same
* bucket; `descriptionsBridge` then decides, per pair, whether two rows in that
* bucket are the same transaction. Splitting bucketing (date+öre) from matching
* (description) is what lets the bridge survive description drift while still
* keeping genuinely-distinct same-(date,amount) transactions apart.
*
* öre via `amountToOre` so a JS number (`-250`) and a PostgREST numeric string
* (`"-250.00"`) collapse to the same bucket, otherwise dedup silently misses.
*/
export function contentBucketKey(date: string, amount: number | string): string {
return `${date}|${amountToOre(amount)}`
}
/**
* Decide whether two normalized descriptions (same date+öre bucket) describe the
* same underlying transaction, the matching half of the content-dedup bridge.
*
* Returns true when either description is a prefix of the other. PSD2 enrichment
* is **prefix-preserving**: the same transaction's title grows between syncs
* ("TIC" → "TIC BG 0000005786439 Bg-bet. via internet", "UTBETALNING" →
* "UTBETALNING Insättning"), so prefix-containment bridges the two where a
* fixed-length prefix *equality* check (the pre-June-2026 scheme) missed and
* re-imported. Whitespace is not part of the signal: both sides are compared
* with ALL whitespace stripped, because banks reformat spacing between API
* sessions (see the inline comment). A blank description carries no signal, so it never bridges a
* *described* row: otherwise an empty title would wildcard-match any
* same-(date,öre) transaction and could silently consume a real one; only two
* blanks bridge each other (date+öre identity). In practice every caller
* normalizes blanks to FALLBACK_DESCRIPTION upstream (see
* normalizeImportedDescription), so the blank path is defense-in-depth.
* Genuinely distinct descriptions ("Coffee" vs "Lunch", or two different
* reference codes that share a date and amount) are NOT prefixes of one another
* and never bridge, so two real same-(date,amount) transactions are kept apart.
*
* This is a *best-effort* signal, consumed with COUNTING semantics in the ingest
* pipeline (N existing matches consume N incoming): its job is to skip re-imports
* WITHOUT ever dropping a real transaction. The asymmetry favours keeping a
* visible, user-deletable duplicate over silently losing a row.
*/
export function descriptionsBridge(
a: string | null | undefined,
b: string | null | undefined
): boolean {
// Whitespace is stripped ENTIRELY (not collapsed) before comparing: the same
// bank renders the same transaction with drifting whitespace between API
// sessions ("BaBylissURSPRUNGLIGT" vs "BaByliss URSPRUNGLIGT", CRLF vs
// space), and a collapse-only normalization still misses the dropped-space
// form. Observed in the 2026-08-18 reconnect incident: five historical rows
// re-imported as twins whose descriptions differed only in whitespace, and
// every dedup layer missed them. Stripping is safe for the title-prefix
// bridge too: char-filtering is concatenation-homomorphic, so every existing
// prefix relation is preserved (it can only ADD bridges, never remove one).
// The compare stays confined to a (date, öre) bucket, so the widened match
// can only collapse same-day same-amount rows.
const x = (a ?? '').toLowerCase().replace(/\s+/g, '')
const y = (b ?? '').toLowerCase().replace(/\s+/g, '')
// A blank never wildcards a described row; only two blanks bridge each other.
if (x === '' || y === '') return x === y
return x.startsWith(y) || y.startsWith(x)
}
/**
* Shift an ISO `YYYY-MM-DD` date by a whole number of days, returning a new
* `YYYY-MM-DD` string. Deterministic and INPUT-ONLY: it does the arithmetic
* with `Date.UTC` on the parsed components and `new Date(ms)`, never the wall
* clock (`Date.now()` / argless `new Date()`), so it is safe in dedup code that
* must not depend on the current time. Correctly crosses month, year and
* leap-day boundaries via UTC epoch math.
*
* Used to enumerate the adjacent date buckets the date-drift dedup shadow
* inspects: a booking date that drifts a day between syncs lands its twin in
* `contentBucketKey(shiftIsoDate(date, ±1), amount)`, which the exact-date
* content bridge cannot see.
*/
export function shiftIsoDate(date: string, deltaDays: number): string {
const [y, m, d] = date.split('-').map(Number)
const ms = Date.UTC(y, m - 1, d) + deltaDays * 86_400_000
return new Date(ms).toISOString().slice(0, 10)
}