1#[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 pub txn_ms: i64,
28 pub is_deleted: bool,
29 pub content_hash: Option<u64>,
32}
33
34#[derive(Debug, Clone, PartialEq, uniffi::Enum)]
36pub enum CaptureDecision {
37 Insert,
38 Skip {
39 backfill_ref: bool,
41 },
42}
43
44pub const WINDOW_MS: i64 = 5 * 60 * 1000;
46pub 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
55pub 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 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 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 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 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 assert_eq!(
131 decide_capture(Some("NEWREF12"), 45000, false, 160_000, None, &ex),
132 CaptureDecision::Skip { backfill_ref: true }
133 );
134 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 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 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 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 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}