Files
accounted/supabase/migrations/20260808090100_transactions_transaction_method_backfill.sql
T
Jakob Wennberg 70845edf69 feat(transactions): structured transaction_method instead of channel-in-the-name (#1459)
* feat(transactions): structured transaction_method instead of channel-in-the-name

Swedish bank feeds embed the payment channel in the description string
("Vercel Jul Överföring via internet", "ANTHROPIC* ... Kortköp/uttag"):
the PSD2 remittance array is joined into one string and the ISO 20022
type codes were dropped at insert. This promotes the channel to data:

- transactions.transaction_method (text + CHECK closed vocabulary: card,
  transfer, bankgiro, plusgiro, swish, autogiro, e_invoice, international,
  deposit, withdrawal, salary, fee, interest, adjustment) plus verbatim
  bank_transaction_code / proprietary_bank_transaction_code evidence
  columns (data_quality_master Appendix B "Layer-A capture").
- classifyTransactionMethod() in lib/transactions/transaction-method.ts:
  explicit source method (Stripe txn.type) > trailing Swedish channel
  phrase > ISO 20022 family/subfamily > proprietary-code keywords > MCC.
  It also splits the clean display title off the description.
- Ingest stores the clean title as description and the full bank string
  as original_description; dedup is untouched (external_id is date+öre,
  the content bridge reads original_description and is prefix-based, and
  a trailing strip leaves a prefix). Enable Banking passes the codes
  through; the Stripe feed sets methods from its balance-txn types.
- Backfill migration classifies existing rows from the description text
  (+ MCC and Stripe prefixes) and strips unedited titles; user-edited
  titles are never rewritten.
- mapping-engine also matches original_description so user rules written
  against the full bank text keep firing.
- UI: the inbox row shows the clean name; clicking it now folds out
  "Betalsätt: Kortköp" etc. (sv/en), making every classified row
  expandable.

A card purchase implies a physical receipt, a Bankgiro/e-invoice payment
implies a supplier invoice: downstream automations can now branch on the
rail instead of regexing display strings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(transactions): anchor counterparty-template identity on original_description

Audit follow-up to the phrase-strip change: counterparty template lookup
AND learning derived their key from merchant_name || description. With
the working title now stripped ("SPOTIFY AB Kortköp" -> "SPOTIFY AB"),
templates learned from the full bank string would only re-match via the
occurrence-gated single-token tier, and single-token counterparties with
fewer than 3 bookings would silently stop matching.

Both sides now read merchant_name || original_description || description:
the immutable bank original is identical across eras (and across user
renames), so every stored key and alias keeps matching exactly. Same
anchoring rationale as buildMerchantHistory in category-suggestions.

Existing tests that relied on the fixture's default original_description
now state it explicitly; two new regression tests pin the era stability
(lookup via alias on the full string, learning key derivation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(transactions): review follow-ups on method classification

- methodFromCodes: two-pass subfamily-then-family scan so a SALA/XBCT
  refinement on the proprietary code beats a bare family match on the
  ISO code, matching the documented precedence; pinned by a test.
- mapping-engine: regression tests for merchant/description patterns
  that only match original_description, including the invalid-regex
  substring fallback and the no-match default.
- Stripe: regression test for the SDK-unmodeled 'tax' balance-txn type
  mapping to 'fee'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(transactions): scope method classification to feed rows + adjective guard

Multi-bank risk hardening before the backfill ships:

- Feed-row scope: classification and title stripping now require a real
  import feed (import_source present, not manual/mcp), both at the
  ingest boundary (USER_CREATED_IMPORT_SOURCES, now exported) and in
  every backfill statement. User-authored titles like "Egen insättning"
  on manual/MCP rows are never classified and never rewritten.
- Adjective guard (TS + SQL): a strip that would leave the title ending
  in a possessive/scope adjective (egen/eget/privat/intern/extern ...)
  is skipped, so "Egen insättning" stays whole even on bank-feed rows;
  the method column still classifies (deposit).
- Unknown bank phrasings remain untouched by construction: an unmatched
  phrase means no method and no rewrite, so the worst case for any bank
  whose vocabulary we have not seen is the status quo.

Pinned by new unit + pg-real cases (user-created exclusion for
NULL/manual/mcp, adjective guard, feed defaults in the pg fixture).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(migrations): re-timestamp transaction_method migrations after rebase

Main gained migrations dated 20260729-20260730 (already applied to prod)
while this branch carried 20260728 versions, which would have applied
out-of-order on merge. The files have never reached prod, so renaming to
current timestamps is safe and removes any dependence on the integration's
out-of-order handling. All code/doc references updated; the pg test reads
the backfill by its new filename.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(migrations): bump transaction_method versions past prod's max

Main's newest applied migration is 20260730090000 (future-leaning
timestamp), so the previous 202607300731xx rename still sorted before
prod's tail and risked a silent skip on merge-time apply. Versions are
now 20260730100000/20260730100100, strictly after everything applied to
prod. References updated; full migration stream replays clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(transactions): final review round: keyboard guard + bank_connection_id feed marker

- TransactionInboxCard: row-level Enter/Space handling now ignores events
  bubbling from nested controls, so keyboard activation of Bokför / the
  overflow menu is no longer cancelled by the (now much more common)
  expandable row.
- Feed predicate parity with isImportedTransaction(): a live
  bank_connection_id marks a feed row even when import_source is unset
  (the oldest PSD2 rows predate that column), in both the ingest
  classifier and every backfill statement: those legacy rows now get
  classified instead of being skipped as user-created.
- pg fixture typing uses the TransactionMethod union.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(migrations): re-timestamp transaction_method migrations past prod's 20260807 tail

Prod max applied is 20260807170000 (verified by name via list_migrations);
the 20260730-stamped pair would sort before it. References in code,
tests, and DECISIONS.md updated to the new versions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(migrations): enforce, not assume, original_description preservation in the title strip

The strip UPDATE now fills a NULL original_description from the
pre-strip description in the same statement. Prod has zero such rows
(0/25,566 feed-scope rows, verified read-only), and 20260605120000's
backfill plus ingest make the NULL case unreachable on any DB that
replayed history, but the migration should not depend on that history
to avoid losing the only copy of a bank string.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: record the compliance-review triage of the backfill's booked-row title strip

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
2026-08-08 11:58:51 +02:00

204 lines
11 KiB
SQL

-- Backfill transaction_method for existing FEED rows and strip the trailing
-- channel phrase from unedited feed titles.
--
-- One-shot, point-in-time backfill: it deliberately duplicates the trailing-
-- phrase vocabulary of classifyTransactionMethod() (lib/transactions/
-- transaction-method.ts) in SQL. This is NOT a live mirror that must stay in
-- sync (contrast normalize_counterparty_key): rows ingested after this
-- migration are classified in TS at the ingest boundary.
--
-- SCOPE: bank/feed rows only: a live bank_connection_id, OR an
-- import_source that is present and not manual/mcp (the isImportedTransaction
-- predicate from lib/transactions/origin.ts; bank_connection_id covers the
-- oldest PSD2 rows that predate the import_source column). User-created rows
-- (manual UI adds have import_source NULL or 'manual', MCP/agent rows 'mcp')
-- carry user-authored titles like "Egen insättning": classifying or rewriting
-- those from a channel vocabulary would corrupt meaning, so they are excluded
-- from every statement below. The same invariant holds at ingest.
--
-- Classification reads coalesce(original_description, description): the
-- immutable bank original when present (title edits never touch it), the
-- working title for legacy rows predating the column. Historical rows carry
-- no ISO codes (they were dropped at insert before this feature), so text is
-- the only available signal; MCC and the Stripe feed's own description
-- prefixes fill the gaps.
--
-- Title stripping only touches rows the user has NOT renamed
-- (title_edited_at IS NULL), never empties a title (a description that IS
-- just the phrase, e.g. a bare "Insättning", is kept), and never strips when
-- the remaining title would end in a possessive/scope adjective (the
-- adjective guard: "Egen insättning" must not become "Egen"; the method
-- classification still applies). Stripping a TRAILING phrase leaves a prefix
-- of the original string, so the content-dedup bridge (descriptionsBridge:
-- symmetric prefix containment against original_description ?? description)
-- still bridges re-imports. Booked rows are included: transactions.description
-- is staging/display data, not räkenskapsinformation (see 20260605120000);
-- the verifikat text lives on the journal entry.
-- ===== 1. Classify from the trailing channel phrase (feed rows only) =====
-- Most-specific vocabularies first; the generic bare "överföring" runs last.
-- Postgres POSIX regexes prefer the longest alternation match, so
-- "överföring via internet" always beats "överföring" within one pattern.
UPDATE public.transactions
SET transaction_method = 'card'
WHERE transaction_method IS NULL
AND (bank_connection_id IS NOT NULL OR (import_source IS NOT NULL AND import_source NOT IN ('manual', 'mcp')))
AND coalesce(original_description, description) ~* '(^|[[:space:]])(kortköp/uttag|kortköp|kortbetalning|webbköp)[[:space:]]*$';
UPDATE public.transactions
SET transaction_method = 'bankgiro'
WHERE transaction_method IS NULL
AND (bank_connection_id IS NOT NULL OR (import_source IS NOT NULL AND import_source NOT IN ('manual', 'mcp')))
AND coalesce(original_description, description) ~* '(^|[[:space:]])(bg-bet\. via internet|bg-bet via internet|bg-betalning|bg betalning|bgmax|bg-inb|bankgiro|bg-bet\.?)[[:space:]]*$';
UPDATE public.transactions
SET transaction_method = 'plusgiro'
WHERE transaction_method IS NULL
AND (bank_connection_id IS NOT NULL OR (import_source IS NOT NULL AND import_source NOT IN ('manual', 'mcp')))
AND coalesce(original_description, description) ~* '(^|[[:space:]])(pg-betalning|pg betalning|plusgiro)[[:space:]]*$';
UPDATE public.transactions
SET transaction_method = 'international'
WHERE transaction_method IS NULL
AND (bank_connection_id IS NOT NULL OR (import_source IS NOT NULL AND import_source NOT IN ('manual', 'mcp')))
AND coalesce(original_description, description) ~* '(^|[[:space:]])(europabetalning|utlandsbetalning)[[:space:]]*$';
UPDATE public.transactions
SET transaction_method = 'salary'
WHERE transaction_method IS NULL
AND (bank_connection_id IS NOT NULL OR (import_source IS NOT NULL AND import_source NOT IN ('manual', 'mcp')))
AND coalesce(original_description, description) ~* '(^|[[:space:]])(löneinsättning|lönebetalning|löneutbetalning|lön)[[:space:]]*$';
UPDATE public.transactions
SET transaction_method = 'e_invoice'
WHERE transaction_method IS NULL
AND (bank_connection_id IS NOT NULL OR (import_source IS NOT NULL AND import_source NOT IN ('manual', 'mcp')))
AND coalesce(original_description, description) ~* '(^|[[:space:]])(e-faktura|efaktura)[[:space:]]*$';
UPDATE public.transactions
SET transaction_method = 'swish'
WHERE transaction_method IS NULL
AND (bank_connection_id IS NOT NULL OR (import_source IS NOT NULL AND import_source NOT IN ('manual', 'mcp')))
AND (
coalesce(original_description, description) ~* '(^|[[:space:]])(swish-betalning|swish betalning|swish)[[:space:]]*$'
OR coalesce(original_description, description) ~* '^swish (till|från)([[:space:]]|$)'
);
UPDATE public.transactions
SET transaction_method = 'autogiro'
WHERE transaction_method IS NULL
AND (bank_connection_id IS NOT NULL OR (import_source IS NOT NULL AND import_source NOT IN ('manual', 'mcp')))
AND coalesce(original_description, description) ~* '(^|[[:space:]])(autogirobetalning|autogiro)[[:space:]]*$';
UPDATE public.transactions
SET transaction_method = 'fee'
WHERE transaction_method IS NULL
AND (bank_connection_id IS NOT NULL OR (import_source IS NOT NULL AND import_source NOT IN ('manual', 'mcp')))
AND coalesce(original_description, description) ~* '(^|[[:space:]])(pris betalning|prisbetalning|avgift)[[:space:]]*$';
UPDATE public.transactions
SET transaction_method = 'interest'
WHERE transaction_method IS NULL
AND (bank_connection_id IS NOT NULL OR (import_source IS NOT NULL AND import_source NOT IN ('manual', 'mcp')))
AND coalesce(original_description, description) ~* '(^|[[:space:]])(insättningsränta|ränta)[[:space:]]*$';
UPDATE public.transactions
SET transaction_method = 'deposit'
WHERE transaction_method IS NULL
AND (bank_connection_id IS NOT NULL OR (import_source IS NOT NULL AND import_source NOT IN ('manual', 'mcp')))
AND coalesce(original_description, description) ~* '(^|[[:space:]])(kontantinsättning|insättning)[[:space:]]*$';
UPDATE public.transactions
SET transaction_method = 'withdrawal'
WHERE transaction_method IS NULL
AND (bank_connection_id IS NOT NULL OR (import_source IS NOT NULL AND import_source NOT IN ('manual', 'mcp')))
AND coalesce(original_description, description) ~* '(^|[[:space:]])(bankomatuttag|kontantuttag|uttag)[[:space:]]*$';
UPDATE public.transactions
SET transaction_method = 'transfer'
WHERE transaction_method IS NULL
AND (bank_connection_id IS NOT NULL OR (import_source IS NOT NULL AND import_source NOT IN ('manual', 'mcp')))
AND coalesce(original_description, description) ~* '(^|[[:space:]])(överföring via internet|överföring via mobil|överföring via app|överföring inom banken|överföring inom bank|överföring mellan konton|direktöverföring|direktbetalning|internetbetalning|mobilbetalning|överföring)[[:space:]]*$';
-- ===== 2. MCC fallback (card rail; 6011 = ATM cash disbursement) =====
UPDATE public.transactions
SET transaction_method = CASE WHEN mcc_code = 6011 THEN 'withdrawal' ELSE 'card' END
WHERE transaction_method IS NULL
AND (bank_connection_id IS NOT NULL OR (import_source IS NOT NULL AND import_source NOT IN ('manual', 'mcp')))
AND mcc_code IS NOT NULL;
-- ===== 3. Stripe feed rows: the sync's own description prefixes are a =====
-- ===== reliable type discriminator (describeBalanceTxn is deterministic) ====
UPDATE public.transactions
SET transaction_method = 'fee'
WHERE transaction_method IS NULL
AND import_source = 'stripe'
AND (
coalesce(original_description, description) LIKE 'Stripe-avgift%'
OR coalesce(original_description, description) LIKE 'Stripe: Billing%'
OR coalesce(original_description, description) LIKE 'Stripe: Automatic Taxes%'
);
UPDATE public.transactions
SET transaction_method = 'card'
WHERE transaction_method IS NULL
AND import_source = 'stripe'
AND (
coalesce(original_description, description) LIKE 'Stripe-betalning%'
OR coalesce(original_description, description) LIKE 'Stripe-återbetalning%'
);
UPDATE public.transactions
SET transaction_method = 'transfer'
WHERE transaction_method IS NULL
AND import_source = 'stripe'
AND coalesce(original_description, description) LIKE 'Stripe-utbetalning%';
UPDATE public.transactions
SET transaction_method = 'adjustment'
WHERE transaction_method IS NULL
AND import_source = 'stripe'
AND (
coalesce(original_description, description) LIKE 'Stripe-justering%'
OR coalesce(original_description, description) LIKE 'Stripe-tvist%'
);
-- ===== 4. Strip the trailing channel phrase from unedited FEED titles =====
-- The union of every trailing vocabulary above. Guarded so a title is never
-- emptied, never rewritten to itself, never rewritten on a user-created row,
-- and never left ending in a possessive/scope adjective ("Egen insättning"
-- keeps its full title; the method column still says deposit).
-- original_description keeps the full bank string, so the rewrite is exactly
-- reversible and "restore original" still works. That invariant already holds
-- on any DB that replayed 20260605120000 (its backfill filled every NULL, and
-- ingest writes the column on every insert since), but the strip below
-- ENFORCES it rather than assuming it: a row that somehow reached this point
-- with original_description NULL gets its pre-strip description preserved in
-- the same statement, so the full bank string can never be lost.
WITH pat AS (
SELECT '(^|[[:space:]])(kortköp/uttag|kortköp|kortbetalning|webbköp|bg-bet\. via internet|bg-bet via internet|bg-betalning|bg betalning|bgmax|bg-inb|bankgiro|bg-bet\.?|pg-betalning|pg betalning|plusgiro|europabetalning|utlandsbetalning|löneinsättning|lönebetalning|löneutbetalning|lön|e-faktura|efaktura|swish-betalning|swish betalning|swish|autogirobetalning|autogiro|pris betalning|prisbetalning|avgift|insättningsränta|ränta|kontantinsättning|insättning|bankomatuttag|kontantuttag|uttag|överföring via internet|överföring via mobil|överföring via app|överföring inom banken|överföring inom bank|överföring mellan konton|direktöverföring|direktbetalning|internetbetalning|mobilbetalning|överföring)[[:space:]]*$'::text AS p
),
stripped AS (
SELECT t.id,
btrim(regexp_replace(t.description, pat.p, '', 'i')) AS new_desc
FROM public.transactions t, pat
WHERE t.title_edited_at IS NULL
AND (t.bank_connection_id IS NOT NULL OR (t.import_source IS NOT NULL AND t.import_source NOT IN ('manual', 'mcp')))
AND t.description ~* pat.p
)
UPDATE public.transactions t
SET description = s.new_desc,
original_description = coalesce(t.original_description, t.description)
FROM stripped s
WHERE t.id = s.id
AND s.new_desc <> ''
AND s.new_desc <> t.description
-- Adjective guard: the last remaining word must not be a possessive/scope
-- adjective whose meaning depended on the stripped noun.
AND lower(regexp_replace(s.new_desc, '^.*[[:space:]]', '')) NOT IN
('egen', 'eget', 'egna', 'privat', 'privata', 'intern', 'interna', 'extern', 'externa');