#[derive(Debug, Clone, uniffi::Record)]
pub struct ExistingRow {
pub upi_ref: Option<String>,
pub amount_paise: i64,
pub is_income: bool,
pub txn_ms: i64,
pub is_deleted: bool,
pub content_hash: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, uniffi::Enum)]
pub enum CaptureDecision {
Insert,
Skip {
backfill_ref: bool,
},
}
pub const WINDOW_MS: i64 = 5 * 60 * 1000;
pub const DRIFT_SECS: i64 = 300;
fn valid_ref(r: Option<&str>) -> Option<&str> {
r.map(str::trim).filter(|s| !s.is_empty())
}
pub fn decide_capture(
candidate_ref: Option<&str>,
amount_paise: i64,
is_income: bool,
txn_ms: i64,
candidate_hash: Option<u64>,
existing: &[ExistingRow],
) -> CaptureDecision {
let cand = valid_ref(candidate_ref);
let live = |r: &ExistingRow| !r.is_deleted;
if let Some(c) = cand {
if existing.iter().any(|r| live(r) && valid_ref(r.upi_ref.as_deref()) == Some(c)) {
return CaptureDecision::Skip { backfill_ref: false };
}
}
if let Some(h) = candidate_hash {
if existing.iter().any(|r| live(r) && r.content_hash == Some(h)) {
return CaptureDecision::Skip { backfill_ref: false };
}
}
if let Some(dup) = existing.iter().filter(|r| live(r)).find(|r| {
r.amount_paise == amount_paise
&& r.is_income == is_income
&& r.txn_ms.abs_diff(txn_ms) <= WINDOW_MS as u64
}) {
let distinct_refs = match (cand, valid_ref(dup.upi_ref.as_deref())) {
(Some(a), Some(b)) => a != b,
_ => false,
};
if !distinct_refs && dup.txn_ms.abs_diff(txn_ms) / 1000 <= DRIFT_SECS as u64 {
let backfill_ref = cand.is_some() && valid_ref(dup.upi_ref.as_deref()).is_none();
return CaptureDecision::Skip { backfill_ref };
}
}
CaptureDecision::Insert
}
#[cfg(test)]
mod tests {
use super::*;
fn row(upi_ref: Option<&str>, amount_paise: i64, is_income: bool, txn_ms: i64) -> ExistingRow {
ExistingRow { upi_ref: upi_ref.map(str::to_string), amount_paise, is_income, txn_ms, is_deleted: false, content_hash: None }
}
#[test]
fn same_ref_skips() {
let ex = vec![row(Some("ABC12345"), 45000, false, 1000)];
assert_eq!(decide_capture(Some("ABC12345"), 45000, false, 2000, Some(7), &ex), CaptureDecision::Skip { backfill_ref: false });
let mut del = ex.clone();
del[0].is_deleted = true;
assert_eq!(decide_capture(Some("ABC12345"), 45000, false, 2000, Some(7), &del), CaptureDecision::Insert);
}
#[test]
fn window_duplicate_skips_and_backfills() {
let ex = vec![row(None, 45000, false, 100_000)];
assert_eq!(
decide_capture(Some("NEWREF12"), 45000, false, 160_000, None, &ex),
CaptureDecision::Skip { backfill_ref: true }
);
assert_eq!(
decide_capture(None, 45000, false, 160_000, None, &ex),
CaptureDecision::Skip { backfill_ref: false }
);
}
#[test]
fn distinct_refs_are_back_to_back_inserts() {
let ex = vec![row(Some("AAAA1111"), 45000, false, 100_000)];
assert_eq!(decide_capture(Some("BBBB2222"), 45000, false, 160_000, None, &ex), CaptureDecision::Insert);
}
#[test]
fn outside_window_or_different_side_inserts() {
let ex = vec![row(None, 45000, false, 100_000)];
assert_eq!(decide_capture(None, 45000, false, 100_000 + WINDOW_MS + 1, None, &ex), CaptureDecision::Insert);
assert_eq!(decide_capture(None, 46000, false, 160_000, None, &ex), CaptureDecision::Insert);
assert_eq!(decide_capture(None, 45000, true, 160_000, None, &ex), CaptureDecision::Insert);
}
#[test]
fn hostile_timestamps_cant_panic() {
let ex = vec![row(None, 45000, false, 0)];
assert_eq!(
decide_capture(None, 45000, false, i64::MIN, None, &ex),
CaptureDecision::Insert
);
assert_eq!(
decide_capture(None, 45000, false, i64::MAX, None, &ex),
CaptureDecision::Insert
);
}
#[test]
fn hash_catches_redelivery_outside_window() {
let mut redelivered = row(None, 45000, false, 100_000);
redelivered.content_hash = Some(99);
let ex = vec![redelivered];
assert_eq!(
decide_capture(None, 45000, false, 100_000 + 3_600_000, Some(99), &ex),
CaptureDecision::Skip { backfill_ref: false }
);
let ex_legacy = vec![row(None, 45000, false, 100_000)];
assert_eq!(
decide_capture(None, 45000, false, 100_000 + 3_600_000, Some(99), &ex_legacy),
CaptureDecision::Insert
);
let mut del = ex.clone();
del[0].is_deleted = true;
assert_eq!(
decide_capture(None, 45000, false, 100_000 + 3_600_000, Some(99), &del),
CaptureDecision::Insert
);
}
}