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¶
- 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. - Store the bank's per-tx running balance on import. OCBC already exposes it
(
velocity-client.transformTransactionmapsrunningBalance: raw.balanceAmount) but the sync drops it before write (sync-accounting.ts/convertToAccountingTransaction). Airwallex exposesrunning_balance. Persist it on the tx (e.g.transaction.<provider>.runningBalance). - 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).
- 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.
- 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.)
- 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, nobankAccountIdβ 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_importsourced, 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
runningBalancetoOCBCTransactionFields+AirwallexTransactionFields(types.ts); OCBCconvertToAccountingTransactionnow persistsocbc.runningBalance = tx.runningBalance(it was computed byvelocity-clientthen dropped before write). Airwallex deferred: the live sync uses thefinancial_transactionsAPI, whose row type (AirwallexFinancialTransaction) carries no per-tx running balance β only theAirwallexTransaction(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 freezeassertPeriodOpenenforces β 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 --noEmitclean; full suite 537/537 (incl. the newimportPeriodFilter.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 RAWVelocityTransactionRawrows fromgetTransactionsand writes them totebs-erl_debug/velocityRawSampleon 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 touchedmain. (I can read the doc back via theaote-pmsservice-account once the owner runs a sync.) sortByis silently dropped invelocity-client.tstransformTransaction(it's onVelocityTransactionRawbut not carried through). It's the bank's own per-row sort key β if the sample shows it's stable across syncs (unlikeseqNo), it's a candidate primary dedup key, not just an ordering one.- Layered fingerprint > single key: prefer the durable printed references
(
bankReference/tag86Info/trxnParticularDescβ theFPSβ¦/HKITβ¦/ERL-WOPCβ¦strings the paper statement prints) first, thendate + amount + isDebit + runningBalanceas the disambiguator. NeverseqNo(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.datewith no tiebreak, so same-day rows scramble. The running-balance chain (each balance = prior Β± signed amount) reconstructs the exact same-day order deterministically β andsortBycorroborates. 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 confirmpostDateprecision +sortBy. - Not mine on
main: I put nothing for T-145 onmain. 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 writestebs-erl_debug/velocityRawSample.getTransactionsreturns the first 8 raw rows; the OCBC sync persists them best-effort. Owner will connect Velocity + sync; I read the doc back via theaote-pmsservice account, confirmsortBystability +postDateprecision + 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 theaote-pmsSA. - Source: Accounting (Infrastructure) Β· https://claude.ai/code/session_015P6KzVYsQCLgEmUjR9bMwM
- Sample findings (from the RAW
response.data.transactionList, pre-transform): postDate/valueDate/sortByare all day-granular (T00:00:00+08:00) β no intraday time, andsortByis just the date restated (NOT a fine sequence key). So neither a timestamp norsortBycan order same-day rows.bankReference/tag86Info/trxnParticularDescare empty; the descriptive text is inclientReference(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.13before89.20, but89.20 β 0.07 = 89.13β true order89.20β89.13; 2026-04-09283.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) βmatchesBankTxin the sync + fill-on-match backfill; order the register's same-day rows viaorderByBalanceChain; 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 livevelocity.ocbc.comsession (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-historycalls (30 tx rows total). Scanned every row:postDate/valueDateare00:00:00,sortByis00: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.tscheckOCBCDuplicates(seqNo) βreconcileOCBCDuplicates, built on the new purereconcileBankTxImport(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/velocityRawSamplewrite, and deleted the_debug/velocityRawSampledoc fromtebs-erlvia the SA. - Tests:
bankTxFingerprint.test.tsnow 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 bytransaction.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, soorderByBalanceChainreconstruction 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):
runningBalanceContinuityexists + 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/seqNoonto matched legacy rows (additive, closed-period-safe). No change to Airwallex/DSB paths. Not deployed β merge tomainis manual (Vercel); this is on aclaude/**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,+1sper 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) thedatefield already carries synthetic times by convention (manual txs are stored at noon viatoCalendarNoonDate); (c) it's a uniform, provider-agnostic key (no bank-specific column) and needs zero read-side change β the register's existingdate-sort + the running-balance accumulation just work. Rejected the separatepostSeqfield 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.). Wroteocbc.runningBalanceon 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 viaassertPeriodOpen; 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). Uniformdatefield; no schema/index change; no read-side change.tscclean; 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). Flippingstatus: doing β doneper 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)
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.