Skip to content

ADR-0020: Cross-source duplicate suppression (bank preferred over Google Wallet)

  • Status: Accepted
  • Date: 2026-08-14
  • Deciders: maintainers
  • Related: ADR-0005, ADR-0019

Context

A Google Wallet card charge produces two notifications: one from Google Wallet (com.google.android.apps.walletnfcrel, merchant title + amount + card last-4) and one from the issuing bank app (e.g. Maybank2u). Both are tracked packages, so both reach CapturePipeline and both parse into a transaction.

The existing dedupe is per-notification: NotificationKey =sourcePackage:occurredAt:sha256(normalized). The two alerts differ in sourcePackage, rawText, and slightly in occurredAt (post time), so they produce different keys and the same card charge is recorded twice.

Decision

Google Wallet is treated as a mirror of the issuing bank. The banking app is the authoritative record; a Wallet alert is suppressed whenever the same charge is already (or later) recorded by a bank.

TransactionRepository.resolveCrossSourceDuplicate(...) runs on every completed candidate before insert:

  • Incoming = Wallet: if a completed transaction from any other package matches amountMillis + currency + direction within a ±90 s window of the alert time, the Wallet alert is suppressed (no row inserted).
  • Incoming = bank: if an existing completed Wallet-only row matches, that row is deleted and the bank row is inserted in its place — so the bank record wins regardless of arrival order.

The window (±90 s) reflects that both apps react to the same card swipe within seconds. Matching is deliberately on amount + currency + direction + time only: merchant wording differs across apps and the card last-4 is not yet persisted. Only COMPLETED rows participate; pending/failed alerts never suppress a real charge.

Consequences

  • The same card charge is recorded once, with the bank app as source.
  • A legitimate same-amount purchase from two different banks within 90 s of each other could be merged (rare; bounded by the tight window).
  • The dedupe is scoped to Wallet overlaps only — bank-vs-bank notifications are untouched and keep the existing per-notification NotificationKey behaviour.
  • Wallet rules can ship without double-counting for users who track both the Wallet app and their bank app.

Alternatives considered

  • Rely on NotificationKey by widening the key to amount + time bucket — rejected: changes dedupe for every package and cannot prefer one source.
  • Store both rows and let the user merge in a review list — rejected for now: adds schema + UI work; the amount/time heuristic is strong enough for v1.
  • Scoped suppression only on the Wallet side (skip Wallet, never touch bank rows) — refined: that still leaves the bank-after-Wallet ordering wrong, so the Wallet row is deleted instead when the bank alert arrives later.