Skip to main content

kharcha_core/
dedupe.rs

1//! Capture dedupe rules. Port of the `insertCaptured` dedupe in
2//! `lib/data/transaction_repository.dart` (DB access stripped out — this file
3//! is the pure decision; the caller owns storage).
4//!
5//! Rules, in order:
6//! 1. Same non-empty `upi_ref` on a live row → duplicate (soft-deleted rows
7//!    are excluded, so a re-sent notification after deletion re-captures).
8//! 2. Same `content_hash` on a live row → exact redelivery (other channel,
9//!    other clock, no ref) → duplicate. Beats pennywise's md5(body): the hash
10//!    covers amount|direction|merchant|ref (sender excluded so SMS-vs-push
11//!    match), so carrier-added footers ("Bal: ...") don't break it the way
12//!    raw-body hashing does.
13//! 3. Cross-channel window: same amount + direction within ±5 min on a live
14//!    row. Two rows with valid but DISTINCT refs are genuine back-to-back
15//!    payments → insert. Otherwise, if the clocks drift ≤ 300 s → skip; if we
16//!    carry a ref the stored row lacks, backfill it onto that row.
17//!    (Window is 5 min, not 2, because SMS catch-up uses the carrier clock
18//!    while notifications use device time.)
19
20/// A stored transaction row, as far as dedupe cares.
21#[derive(Debug, Clone, uniffi::Record)]
22pub struct ExistingRow {
23    pub upi_ref: Option<String>,
24    pub amount_paise: i64,
25    pub is_income: bool,
26    /// Epoch millis.
27    pub txn_ms: i64,
28    pub is_deleted: bool,
29    /// `engine::content_hash` at insert time. Legacy rows carry None —
30    /// the hash signal skips them (ref + window still apply).
31    pub content_hash: Option<u64>,
32}
33
34/// Caller applies the storage side-effect (insert row / write ref onto row id).
35#[derive(Debug, Clone, PartialEq, uniffi::Enum)]
36pub enum CaptureDecision {
37    Insert,
38    Skip {
39        /// Write the candidate ref onto the matched row (it had none).
40        backfill_ref: bool,
41    },
42}
43
44/// ±5 min cross-channel window, millis. Matches the Kotlin background window.
45pub const WINDOW_MS: i64 = 5 * 60 * 1000;
46/// Max clock drift inside the window to still count as duplicate, seconds.
47/// Compared in TRUNCATED seconds to mirror Dart `.inSeconds.abs() <= 300`
48/// (audit: millis `<= 300_000` diverges on the 300.001–300.999 s sliver).
49pub const DRIFT_SECS: i64 = 300;
50
51fn valid_ref(r: Option<&str>) -> Option<&str> {
52    r.map(str::trim).filter(|s| !s.is_empty())
53}
54
55/// Decide a capture against live rows. `existing` should be recency-ordered;
56/// the first window match decides (mirrors Dart's `limit(1)`, which itself
57/// has no ORDER BY — recency order is a caller convention, not a guarantee).
58///
59/// Audit note — NOT ported (caller owns post-decision enrichment, same as the
60/// Dart side owns it post-`insertCaptured`): income→"Other income" category
61/// assignment and wallet match/auto-create. This file answers only
62/// insert-or-skip (+ref backfill).
63pub fn decide_capture(
64    candidate_ref: Option<&str>,
65    amount_paise: i64,
66    is_income: bool,
67    txn_ms: i64,
68    candidate_hash: Option<u64>,
69    existing: &[ExistingRow],
70) -> CaptureDecision {
71    let cand = valid_ref(candidate_ref);
72    let live = |r: &ExistingRow| !r.is_deleted;
73
74    // 1. Exact-ref gate.
75    if let Some(c) = cand {
76        if existing.iter().any(|r| live(r) && valid_ref(r.upi_ref.as_deref()) == Some(c)) {
77            return CaptureDecision::Skip { backfill_ref: false };
78        }
79    }
80
81    // 2. Content-hash gate: exact redelivery, any clock, no ref needed.
82    if let Some(h) = candidate_hash {
83        if existing.iter().any(|r| live(r) && r.content_hash == Some(h)) {
84            return CaptureDecision::Skip { backfill_ref: false };
85        }
86    }
87
88    // 3. Cross-channel window: same amount + direction, ±5 min.
89    // Audit: `abs_diff` — plain `-`/`.abs()` panics on i64::MIN from FFI.
90    if let Some(dup) = existing.iter().filter(|r| live(r)).find(|r| {
91        r.amount_paise == amount_paise
92            && r.is_income == is_income
93            && r.txn_ms.abs_diff(txn_ms) <= WINDOW_MS as u64
94    }) {
95        let distinct_refs = match (cand, valid_ref(dup.upi_ref.as_deref())) {
96            (Some(a), Some(b)) => a != b,
97            _ => false,
98        };
99        if !distinct_refs && dup.txn_ms.abs_diff(txn_ms) / 1000 <= DRIFT_SECS as u64 {
100            let backfill_ref = cand.is_some() && valid_ref(dup.upi_ref.as_deref()).is_none();
101            return CaptureDecision::Skip { backfill_ref };
102        }
103    }
104
105    CaptureDecision::Insert
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    fn row(upi_ref: Option<&str>, amount_paise: i64, is_income: bool, txn_ms: i64) -> ExistingRow {
113        ExistingRow { upi_ref: upi_ref.map(str::to_string), amount_paise, is_income, txn_ms, is_deleted: false, content_hash: None }
114    }
115
116    #[test]
117    fn same_ref_skips() {
118        let ex = vec![row(Some("ABC12345"), 45000, false, 1000)];
119        assert_eq!(decide_capture(Some("ABC12345"), 45000, false, 2000, Some(7), &ex), CaptureDecision::Skip { backfill_ref: false });
120        // Deleted rows don't block: re-capture after deletion inserts.
121        let mut del = ex.clone();
122        del[0].is_deleted = true;
123        assert_eq!(decide_capture(Some("ABC12345"), 45000, false, 2000, Some(7), &del), CaptureDecision::Insert);
124    }
125
126    #[test]
127    fn window_duplicate_skips_and_backfills() {
128        let ex = vec![row(None, 45000, false, 100_000)];
129        // Same amount, 60 s apart, row lacks ref → skip + backfill.
130        assert_eq!(
131            decide_capture(Some("NEWREF12"), 45000, false, 160_000, None, &ex),
132            CaptureDecision::Skip { backfill_ref: true }
133        );
134        // Neither side has a ref → plain skip.
135        assert_eq!(
136            decide_capture(None, 45000, false, 160_000, None, &ex),
137            CaptureDecision::Skip { backfill_ref: false }
138        );
139    }
140
141    #[test]
142    fn distinct_refs_are_back_to_back_inserts() {
143        let ex = vec![row(Some("AAAA1111"), 45000, false, 100_000)];
144        assert_eq!(decide_capture(Some("BBBB2222"), 45000, false, 160_000, None, &ex), CaptureDecision::Insert);
145    }
146
147    #[test]
148    fn outside_window_or_different_side_inserts() {
149        let ex = vec![row(None, 45000, false, 100_000)];
150        assert_eq!(decide_capture(None, 45000, false, 100_000 + WINDOW_MS + 1, None, &ex), CaptureDecision::Insert);
151        assert_eq!(decide_capture(None, 46000, false, 160_000, None, &ex), CaptureDecision::Insert);
152        assert_eq!(decide_capture(None, 45000, true, 160_000, None, &ex), CaptureDecision::Insert);
153    }
154
155    #[test]
156    fn hostile_timestamps_cant_panic() {
157        // Audit: (txn_ms - i64::MIN).abs() panics. abs_diff never does.
158        let ex = vec![row(None, 45000, false, 0)];
159        assert_eq!(
160            decide_capture(None, 45000, false, i64::MIN, None, &ex),
161            CaptureDecision::Insert
162        );
163        assert_eq!(
164            decide_capture(None, 45000, false, i64::MAX, None, &ex),
165            CaptureDecision::Insert
166        );
167    }
168
169    #[test]
170    fn hash_catches_redelivery_outside_window() {
171        // Same payment re-delivered an hour later, no ref anywhere.
172        let mut redelivered = row(None, 45000, false, 100_000);
173        redelivered.content_hash = Some(99);
174        let ex = vec![redelivered];
175        assert_eq!(
176            decide_capture(None, 45000, false, 100_000 + 3_600_000, Some(99), &ex),
177            CaptureDecision::Skip { backfill_ref: false }
178        );
179        // Legacy row without a hash → hash signal skips it, window expired → insert.
180        let ex_legacy = vec![row(None, 45000, false, 100_000)];
181        assert_eq!(
182            decide_capture(None, 45000, false, 100_000 + 3_600_000, Some(99), &ex_legacy),
183            CaptureDecision::Insert
184        );
185        // Same hash on a soft-deleted row → re-captures.
186        let mut del = ex.clone();
187        del[0].is_deleted = true;
188        assert_eq!(
189            decide_capture(None, 45000, false, 100_000 + 3_600_000, Some(99), &del),
190            CaptureDecision::Insert
191        );
192    }
193}