Skip to content

uid: T-145 title: Stable bank-tx dedup + store per-tx running balance + period-lock-aware import (OCBC seqNo is unstable) status: done area: accounting created: 2026-06-30 updated: 2026-07-02 related: I-023


T-145 β€” Durable bank-tx dedup (don't trust the seqNo)

Why (proven in I-023, 2026-06-30)

The OCBC-S reconciliation (ticket I-023) found 6 duplicate transactions and, crucially, the root cause: the OCBC Velocity API's per-tx seqNo is not stable over time. The updateLog provenance proved the duplicates were created at sync time (OCBC Velocity sync, run by the owner through the web app), and the same real transaction recorded a different seqNo on different syncs:

tx first sync seqNo then re-sync seqNo then
βˆ’3000 2026-02-01 2026013104156577 2026-04-07 75205
βˆ’2000 2026-05-05 2026050545719968 2026-06-30 31937

lib/ocbc/velocity-client.ts has always stored seqNo: raw.seqNo verbatim (verified across all git history β€” it never fabricates), so the differing values came from the bank API itself: OCBC returns a temporary 16-digit date-stamped reference for recently-posted transactions that later settles to a short permanent seqNo. Because checkOCBCDuplicates dedups on exactly that volatile seqNo, a re-import of the same tx (now carrying the settled seqNo) is seen as new β†’ duplicate. The owner witnessed this live on 2026-06-30 (RMG7).

What to build

  1. Dedup on a STABLE fingerprint, not the seqNo. Match on date + amount + isDebit + running balance (the running balance is unique per account-position and chains, so it disambiguates same-day same-amount txs). Keep the seqNo as metadata only. Consider storing all seqNos ever seen for a tx so a settled-seqNo re-import maps back to the original row.
  2. Store the bank's per-tx running balance on import. OCBC already exposes it (velocity-client.transformTransaction maps runningBalance: raw.balanceAmount) but the sync drops it before write (sync-accounting.ts / convertToAccountingTransaction). Airwallex exposes running_balance. Persist it on the tx (e.g. transaction.<provider>.runningBalance).
  3. Running-balance continuity cross-check. On import (and as a reconciler), assert each tx's stored balance == prior balance Β± signed amount; surface any break (that's how a missing/duplicate/mis-ordered row is caught β€” exactly the check that validated I-023 against the statements).
  4. Period-lock-aware import. Today's sync wrote an unmatched row into an already-closed period (RMG7 into closed May). Don't silently import into a closed period β€” flag/queue for review instead.
  5. Backfill existing txs with missing bank-feed fields (running balance, particulars, reference, remarks) β€” non-clobber: fill empty feed fields only, never touch user categorisation/status/memo. (Folds in the owner's earlier "scan existing txs, avoid duplications, backfill" ask.)
  6. One-time cross-account fluke audit. OCBC-S is done + statement-verified (I-023). Run the same statement reconciliation across ERL-DSB-S (Dah Sing) and ERL-AWX-HKD (Airwallex) and any other accounts; clean any remaining unstable-seqNo duplicates the same way.

Notes / open items surfaced 2026-06-30

  • ERL account map: ERL-OCBC-S (99, +283.30 βœ“ verified), ERL-AWX-HKD (Airwallex, 40, +129.72 β€” renders as "DBS" in the register Bank column), ERL-DSB-S (Dah Sing, 36, nets 0.00), plus one untagged $35,000 CR (70SxfD9Z2vEtGrr8auD2, 2024-12-31, matched invoice ERL-2024-016-1025, no bankAccountId β†’ blank Bank column). Resolve: mis-tagged Dah Sing deposit vs duplicate of a DSB row.
  • The OCBC statement (savings) prints no per-tx seqNo; the durable printed reference is the HKIT…/ERLWOPC…/FPS… string. Any fingerprint scheme should prefer those + running balance over the API seqNo.

Cross-account audit result (2026-06-30, item 6 done)

Ran the I-023 statement reconciliation across all ERL bank accounts (statements pulled via the service account):

  • ERL-OCBC-S β€” cleaned + statement-verified (I-023). Had the only flukes (6, from the unstable OCBC seqNo). Now 99 txs, +283.30, ties at every monthly cutoff.
  • ERL-AWX-HKD (Airwallex; renders as "DBS" β€” the Global Account is provided by DBS Bank HK) β€” all 7 statements tie to the cent (2025-02 β†’ 2026-05, each month's start = prior end). 40 txs, no flukes, no dups; Airwallex dedups on a stable UUID (transaction.airwallex.id), so the seqNo problem can't occur. Current balance 129.72 = post-statement June activity.
  • ERL-DSB-S (Dah Sing) β€” 7/8 statements tie to the cent (2024-10 β†’ 2025-04); the 2025-05 PDF wouldn't download (proxy drop) but the account nets to 0.00 after its final cash withdrawal and is dormant. 36 txs, mt940_import sourced, no flukes; the only same-day/same-amount pair (2025-04-26 1000Γ—2) is two distinct coaching invoices (MT-010, MT-011), not a duplicate.
  • $35,000 manual cash receipt (70SxfD9Z…, no bankAccountId) β€” owner-confirmed correct: a client's cash payment never deposited to a bank, entered manually, matched to ERL-2024-016-1025; appears in no bank statement. Not a fluke.

Conclusion: the unstable-seqNo duplication was OCBC-only. The code work below (stable-fingerprint dedup, store running balance, continuity check, period-lock import, backfill) remains as preventive hardening so OCBC can't re-duplicate.

Progress β€” 2026-07-01: Phase A + C shipped (Phase B pending)

  • βœ… Attestation (Accounting (Diagnostics)): read AGENTS.md; board checked by scope. Owner approved: "Proceed with Phase A+C" (2026-07-01), holding Phase B (the dedup swap) for a design review.
  • Source: Accounting (Diagnostics) Β· https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea
  • Phase A β€” store the per-tx running balance (item 2): added runningBalance to OCBCTransactionFields + AirwallexTransactionFields (types.ts); OCBC convertToAccountingTransaction now persists ocbc.runningBalance = tx.runningBalance (it was computed by velocity-client then dropped before write). Airwallex deferred: the live sync uses the financial_transactions API, whose row type (AirwallexFinancialTransaction) carries no per-tx running balance β€” only the AirwallexTransaction (getTransactions) shape does; the field is reserved on the type for when that source is wired in.
  • Phase C β€” period-lock-aware import (item 4): new pure helper lib/accounting/importPeriodFilter.ts β†’ partitionByClosedPeriod() (unit-tested, 6 cases incl. the HK-tz month boundary). Both sync paths (ocbc/velocity/sync-accounting.ts, airwallex/sync.ts) now load the subsidiary's closed months (listClosedPeriodsServer) and skip txs dated in a closed period β€” respecting the same freeze assertPeriodOpen enforces β€” instead of importing silently. The skipped count is returned by the sync API and surfaced in the transaction-sync notification ("N fell in a closed period β€” reopen to import"). Non-destructive: the bank stays source-of-truth, so reopening the period
  • re-syncing imports them. (This is precisely how the RMG7 βˆ’2,000 slipped into closed May, I-023.)
  • Verification: tsc --noEmit clean; full suite 537/537 (incl. the new importPeriodFilter.test.ts).
  • Still open β€” Phase B (dedup swap, items 1 & 3): replace the volatile-seqNo dedup with a stable fingerprint (date+amount+direction+running balance) + a running-balance continuity check. Held for a design + test write-up before changing import dedup behavior (owner's call). Item 5 (backfill existing txs' running balance) rides with Phase B; item 6 (cross-account audit) is done above.
  • Blast radius (accounting import): OCBC + Airwallex sync API routes + a new pure helper + the tx-sync popup counts. Write-side is additive (new ocbc.runningBalance); the one behavior change is Phase C skipping closed-period txs (previously imported silently). The dedup key is unchanged (that's Phase B).

Verification target

After T-145, a full re-sync (even a wide-range one) of an already-imported account produces zero new rows, and the running-balance chain stays continuous.

Source

Accounting (Diagnostics) Β· proposed + owner-approved 2026-06-30 Β· https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea

2026-07-01 β€” Phase B input handed off; Accounting (Infrastructure) stands down

  • βœ… Attestation (Accounting (Infrastructure)): read AGENTS.md. The owner briefly routed this task to me (via EOP Local Assistance), but you (Diagnostics) originated it and were already ahead β€” Phase A + C are merged. To avoid duplicating your work, I'm standing down; T-145 stays yours. This is a handoff of Phase-B-relevant material, not a claim.
  • Source: Accounting (Infrastructure) Β· https://claude.ai/code/session_015P6KzVYsQCLgEmUjR9bMwM
  • For your held Phase B (dedup swap) β€” a raw-sample diagnostic + analysis:
  • Branch claude/t145-raw-sample-diag (pushed): a TEMP diagnostic that surfaces the first 8 RAW VelocityTransactionRaw rows from getTransactions and writes them to tebs-erl _debug/velocityRawSample on sync β€” so the actual values of the fields we discard can be inspected (this is the "design write-up" input). Cherry-pick or re-derive; it never touched main. (I can read the doc back via the aote-pms service-account once the owner runs a sync.)
  • sortBy is silently dropped in velocity-client.ts transformTransaction (it's on VelocityTransactionRaw but not carried through). It's the bank's own per-row sort key β€” if the sample shows it's stable across syncs (unlike seqNo), it's a candidate primary dedup key, not just an ordering one.
  • Layered fingerprint > single key: prefer the durable printed references (bankReference / tag86Info / trxnParticularDesc β€” the FPS…/HKIT…/ERL-WOPC… strings the paper statement prints) first, then date + amount + isDebit + runningBalance as the disambiguator. Never seqNo (proven unstable, I-023).
  • Same-day ordering (owner-raised 2026-07-01) is the same data as Phase B: the register orders purely by transaction.transactional.date with no tiebreak, so same-day rows scramble. The running-balance chain (each balance = prior Β± signed amount) reconstructs the exact same-day order deterministically β€” and sortBy corroborates. So Phase B's continuity check doubles as the ordering fix; no puppeteer/HTML scraping needed (the API already sends both keys β€” we just discard them). The owner agreed to capture a raw sample to confirm postDate precision + sortBy.
  • Not mine on main: I put nothing for T-145 on main. Left entirely to you.

2026-07-01 (update) β€” owner REVERSED: Phase B to be done here (Accounting (Infrastructure))

  • βœ… Attestation (Accounting (Infrastructure)): read AGENTS.md. Right after the stand-down above, the owner decided to proceed with T-145 here instead (verbatim: "read the recent commits regarding T-145 … and we'll proceed with T-145 here instead"). So Phase B (items 1, 3, 5) + same-day ordering is now mine; Diagnostics keeps full credit for the merged Phase A + C, which I'm building on top of (not over). Coordination: Diagnostics should NOT also do Phase B now β€” owner reassigned it to avoid the reverse overlap.
  • Source: Accounting (Infrastructure) Β· https://claude.ai/code/session_015P6KzVYsQCLgEmUjR9bMwM
  • Step 1 (this commit): the raw-sample diagnostic is now landed on main (not just the branch) so a normal prod sync writes tebs-erl _debug/velocityRawSample. getTransactions returns the first 8 raw rows; the OCBC sync persists them best-effort. Owner will connect Velocity + sync; I read the doc back via the aote-pms service account, confirm sortBy stability + postDate precision + which durable refs are present, then design + implement the fingerprint + ordering + continuity check. The capture is TEMPORARY β€” removed once the sample is in hand.

2026-07-02 β€” raw sample analysed; Phase B core built + unit-tested

  • βœ… Attestation (Accounting (Infrastructure)): read AGENTS.md. Read the captured sample (_debug/velocityRawSample, 99-tx range) via the aote-pms SA.
  • Source: Accounting (Infrastructure) Β· https://claude.ai/code/session_015P6KzVYsQCLgEmUjR9bMwM
  • Sample findings (from the RAW response.data.transactionList, pre-transform):
  • postDate / valueDate / sortBy are all day-granular (T00:00:00+08:00) β€” no intraday time, and sortBy is just the date restated (NOT a fine sequence key). So neither a timestamp nor sortBy can order same-day rows.
  • bankReference / tag86Info / trxnParticularDesc are empty; the descriptive text is in clientReference (e.g. ERLWOPC/2026-002, Google CLOUD Fw3PV), not always unique.
  • The running balance (balanceAmount) is present + chains, and it's the ONLY reliable same-day disambiguator/order key. Verified against the real feed: the API returned same-day rows OUT of order (2026-03-04: 89.13 before 89.20, but 89.20 βˆ’ 0.07 = 89.13 β‡’ true order 89.20β†’89.13; 2026-04-09 283.36β†’283.30).
  • Built (this commit): lib/accounting/bankTxFingerprint.ts β€” pure, Firestore-free (mirrors the Phase C helper): bankTxFingerprint (stable key, never seqNo), matchesBankTx (tiered exact/likely/ none β€” the seqNo-free dedup, disambiguated by running balance), runningBalanceContinuity (chain gap check), orderByBalanceChain (same-day order reconstruction). 13 unit tests pass, incl. the I-023 seqNo case, the Dah Sing same-day/same-amount distinct case, and the real same-day reorders.
  • Still to wire (Phase B2/B3): swap checkOCBCDuplicates (seqNo) β†’ matchesBankTx in the sync + fill-on-match backfill; order the register's same-day rows via orderByBalanceChain; then remove the temporary capture.
  • HELD pending owner verification: the owner wants to see the real OCBC API response live (concern that our capture/type might drop fields) before I finalise the fingerprint. Not wiring the dedup swap until that's confirmed β€” see the chat thread.

2026-07-02 β€” HAR verified the API; Phase B dedup swap + backfill WIRED (on branch, PR open)

  • βœ… Attestation (Accounting (Infrastructure)): read AGENTS.md; board checked by scope (no dup β€” this is T-145's held Phase B). Owner cleared the hold by supplying a full DevTools HAR of a live velocity.ocbc.com session (their requested independent verification) + said "Proceed".
  • Source: Accounting (Infrastructure) Β· https://claude.ai/code/session_015P6KzVYsQCLgEmUjR9bMwM
  • HAR verification (the owner's concern that our capture dropped fields β€” resolved): parsed the 6 live casa/transaction-history calls (30 tx rows total). Scanned every row: postDate/valueDate are 00:00:00, sortBy is 00:00 β€” zero intraday time anywhere, confirming the feed is 100% day-granular and our type drops nothing that would order same-day rows. Confirmed the running-balance chain reconstructs true same-day order on real 4-row groups (2025-07-24, 2025-09-01, 2026-03-04), including a real two-identical-βˆ’6000-debits day distinguished only by balance (12105.88 vs 6105.88).
  • Landed (this branch / PR):
  • Dedup swap (item 1). sync-accounting.ts checkOCBCDuplicates (seqNo) β†’ reconcileOCBCDuplicates, built on the new pure reconcileBankTxImport(inputs, existing). It loads the account's stored rows over the import window and matches 1:1 within each (day+amount+direction) cohort, preferring an equal running balance, falling back to a count-preserving pairing for legacy rows. This fixes I-023 (a settled-seqNo re-sync now matches by balance) and can neither over- nor under-dedup.
  • Fill-on-match backfill (item 5). A matched legacy row missing a running balance is backfilled from the input (best-effort, skips closed-period rows) so the next sync matches it exact.
  • Diagnostic removed. Dropped getTransactions().rawSample + the _debug/velocityRawSample write, and deleted the _debug/velocityRawSample doc from tebs-erl via the SA.
  • Tests: bankTxFingerprint.test.ts now 20/20, incl. reconcile cases and the owner's balance-oscillation scenario ($20β†’$0β†’$20 with identical Β±$20 rows) β€” proving dedup stays count-correct even when (day,amount,direction,balance) tuples repeat.
  • DEFERRED β€” same-day ordering DISPLAY β€” SUPERSEDED by the 2026-07-02 (evening) entry below: shipped via fake-time-in-date + a one-time MT940 backfill; no separate field/index in the end. (Original note kept for the reasoning trail:) the register still sorts only by transaction.transactional.date, so same-day rows can display out of order. Why deferred + why the approach changed (owner-raised 2026-07-02): the owner pointed out an oscillating balance ($20β†’$0β†’$20…) makes same-amount rows share a balance, so orderByBalanceChain reconstruction is ambiguous in that degenerate case. The durable fix is therefore to persist the bank's own feed order (the feed returns each day newest-first β€” a real sequence) as an intra-day sort key at import, and sort the register by (day, that key); the balance chain stays only for legacy rows where feed order wasn't captured + for continuity. That's a separable change (persisted field + register query/index + backfill) held out of this dedup-correctness PR to keep the financial-import change focused and reviewable.
  • Continuity check (item 3): runningBalanceContinuity exists + is tested; wiring it as an active import-time assertion/reconciler rides with the deferred ordering follow-up.
  • Blast radius (accounting import): OCBC sync dedup path only. Dedup is now balance-aware (behavior change vs seqNo); backfill writes transaction.ocbc.runningBalance/seqNo onto matched legacy rows (additive, closed-period-safe). No change to Airwallex/DSB paths. Not deployed β€” merge to main is manual (Vercel); this is on a claude/** branch with a draft PR for owner review before it touches live imports.

2026-07-02 (evening) β€” same-day ordering SHIPPED (fake-time) + one-time MT940 backfill (live data op)

  • βœ… Attestation (Accounting (Infrastructure)): read AGENTS.md. Owner-driven design + explicit authorization for the live backfill ("Let's backfill without deploy" β†’ "Proceed with Documentation, rework now and finalize #845 then ship it").
  • Source: Accounting (Infrastructure) Β· https://claude.ai/code/session_015P6KzVYsQCLgEmUjR9bMwM
  • Design decision β€” encode same-day order as a synthetic sub-day TIME on transaction.transactional.date, NOT a new field (owner's call). Oldest row of a day = 00:00:00, +1s per feed-order rank. Rationale: (a) the register renders date-only (formatDate β†’ DD MMM YYYY), so the synthetic time is never displayed β€” it only drives sort; (b) the date field already carries synthetic times by convention (manual txs are stored at noon via toCalendarNoonDate); (c) it's a uniform, provider-agnostic key (no bank-specific column) and needs zero read-side change β€” the register's existing date-sort + the running-balance accumulation just work. Rejected the separate postSeq field for those reasons. Feed order is authoritative + oscillation-proof (unlike the balance chain); assignFeedOrder() computes the rank (0 = oldest), anchored at the oldest so new same-day arrivals don't disturb existing ranks.
  • Triangulated that no finer data exists: compared the API (VelocityTransactionRaw), MT940, and MT942 β€” all are day-granular (no :13D:/intraday time; MT942 export is empty). MT940 adds only a SWIFT type code + per-day available balance (both marginal; not ingested). So statement/feed order is the definitive ceiling for same-day sequencing.
  • One-time historical backfill (ERL-OCBC-S, 99 rows) β€” done via service account, no deploy. Source = the two exported MT940 statements (authoritative; 57/57 daily balance chains validate; net 283.30 = the account's real balance, matching I-023), cross-checked against the HAR order on overlapping days. Matched all 99 stored rows to MT940 lines (91 same-day; 8 value-dateβ‰ booking-date month-end items paired across Β±1 day β€” same txs, refs HKIT260131… etc.). Wrote ocbc.runningBalance on all 99 (none had it β€” Phase A never backfilled these pre-existing rows) + a sub-day order time on the 42 multi-tx-day rows (25 days). Post-write verify: 99/99 have balance, net 283.30, 0 running-balance-chain breaks, 25/25 multi-tx days ordered. Went through the period-close freeze (all 99 are in closed months): the freeze guards user financial edits via assertPeriodOpen; this is a system write that changes only the sub-day time + adds a balance annotation β€” no financial substance (day/amount/direction/GL/links) changed, so nothing to re-seal. Reversible: a per-row snapshot of prior (date-ms, had-balance) was saved before writing.
  • Going-forward code (this PR, #845): assignFeedOrder + the OCBC sync sets the fake-time on new imports (dateSettled = postDate + rankΓ—1s); the reconciler now also backfills the order time (dateMs) onto matched existing rows whose sub-day time differs (open periods; closed stay frozen β€” already remediated by the one-time backfill above). Uniform date field; no schema/index change; no read-side change. tsc clean; fingerprint tests 25/25; full suite green.
  • Register impact (no deploy needed for existing data): the ledger already sorts by date, so the 99 backfilled rows now display in true same-day order and the running-balance column computes to the cent. The PR only affects future syncs.

2026-07-02 (evening) β€” CLOSED (status β†’ done): merged, deployed, PROD-VERIFIED

  • βœ… Attestation (Accounting (Infrastructure)): read AGENTS.md; board checked by scope (no dup β€” own close-out). Flipping status: doing β†’ done per the "status flips with the ship" rule (this should have ridden the #845 merge; catching it now).
  • Source: Accounting (Infrastructure) Β· https://claude.ai/code/session_015P6KzVYsQCLgEmUjR9bMwM

Verdict β€” all items delivered and verified LIVE IN PRODUCTION (owner re-sync, 2026-07-02, via the deployed code):

HTTP 200 Β· created: 0 Β· duplicatesSkipped: 4 Β· deferredClosedPeriod: 95   (4+95 = 99, ZERO duplicates)
- Dedup (items 1, 3): balance-aware reconcileBankTxImport replaced seqNo dedup. The 4 open-period rows matched by running balance (not the unstable seqNo) β†’ 0 created. The I-023 duplication mode can no longer recur β€” proven on live prod data, not just unit tests. - Running balance (item 2) + backfill (item 5): persisted on import (Phase A); all 99 existing ERL-OCBC-S rows backfilled out-of-band from the authoritative MT940 statements (net 283.30, 0 balance-chain breaks). On-sync fill-on-match also backfills matched rows going forward. - Period-lock import (item 4): Phase C deferred the 95 closed-period rows β€” visible above. - Same-day ordering: shipped as a synthetic sub-day time on transaction.transactional.date (feed-order rank via assignFeedOrder), not a new field β€” uniform across banks, invisible (register renders date-only), zero read-side change. 25/25 multi-tx days ordered; reversible snapshot saved before the write. - Cross-account audit (item 6): done earlier (OCBC-only flukes; DSB/Airwallex clean).

Post-merge prod bug + fix (#847): the first prod re-sync 500'd β€” my dedup query implicitly needed a (bankAccountId, date ASC) composite index that wasn't declared (I wrongly assumed it existed). Fixed by adding .orderBy('transaction.transactional.date','desc') so it reuses the existing (bankAccountId, date DESC) index (firestore.indexes.json). No data impact β€” the 500 threw at the read, before any write (99 rows verified intact). Re-sync succeeded after.

Every related SHA (append-only, newest last): b4cff8fb Β· 557e2506 (I-023 tickets) Β· 8b3f0c88 (open) Β· 83599e1d (cross-account audit) Β· 567f93f2 (Phase A+C β€” Accounting (Diagnostics)) Β· eb9729b1 (Phase-B handoff) Β· 7ae59744 (raw-sample capture) Β· 7cad16d4 (Phase B core: fingerprint + tests) Β· 5df2445b (#845 β€” Phase B dedup wiring + feed-order fake-time ordering) Β· 09d98e18 (#847 β€” reconcile index fix). Plus the one-time MT940 data backfill (service-account script, out-of-band, no code SHA).

Deploy: MERGED and DEPLOYED to Vercel β€” both #845 and #847 built to production READY (2026-07-02). (done = merged per policy; this one is also live + prod-verified.)

Blast radius (for nearby agents): the OCBC→accounting import path (pages/api/ocbc/velocity/sync-accounting.ts, lib/accounting/bankTxFingerprint.ts, lib/ocbc/velocity-client.ts) + the bank register's same-day ordering (BankTransactionsTab — no code change; it reads the fake-time on transaction.transactional.date). Data written: ERL-OCBC-S rows in accounting/transactions/entries gained ocbc.runningBalance + a sub-day order time (calendar day, amount, direction, GL/links all unchanged). Reuses the existing (bankAccountId, date DESC) index — no new index. Airwallex/DSB paths untouched. Anyone editing the OCBC sync, the register's date ordering, or the entries date field should note the synthetic sub-day time now carries same-day order.