1use crate::{
6 categorize::categorize as categorize_core, dedupe::decide_capture as decide_capture_core,
7 engine, filter::TransactionFilter, money::parse_amount_paise,
8 non_transaction::is_non_transaction, categorize::normalize_merchant,
9 parser::encode_inbox_line as encode_inbox_line_core, split::split_bill_paisa,
10 CaptureDecision, ExistingRow, ParsedTransaction, Rule, TxRow,
11};
12
13#[derive(Debug, Clone, uniffi::Record)]
15pub struct CaptureInput {
16 pub body: String,
17 pub sender: String,
18 pub timestamp_ms: i64,
19}
20
21#[uniffi::export]
22pub fn parse_capture(sms_body: String, sender: String, timestamp_ms: i64) -> Option<ParsedTransaction> {
23 engine::parse(&sms_body, &sender, timestamp_ms)
24}
25
26#[uniffi::export]
27pub fn parse_captures(items: Vec<CaptureInput>) -> Vec<Option<ParsedTransaction>> {
28 items.iter().map(|i| engine::parse(&i.body, &i.sender, i.timestamp_ms)).collect()
29}
30
31#[uniffi::export]
32pub fn check_capture(
33 candidate_ref: Option<String>,
34 amount_paise: i64,
35 is_income: bool,
36 txn_ms: i64,
37 candidate_hash: Option<u64>,
38 existing: Vec<ExistingRow>,
39) -> CaptureDecision {
40 decide_capture_core(
41 candidate_ref.as_deref(),
42 amount_paise,
43 is_income,
44 txn_ms,
45 candidate_hash,
46 &existing,
47 )
48}
49
50#[uniffi::export]
51pub fn categorize_merchant(merchant: String, rules: Vec<Rule>) -> Option<Rule> {
52 categorize_core(&merchant, &rules).cloned()
53}
54
55#[uniffi::export]
56pub fn normalize_merchant_text(raw: String) -> String {
57 normalize_merchant(&raw)
58}
59
60#[uniffi::export]
61pub fn split_bill(total_paise: i64, count: u64) -> Vec<i64> {
62 split_bill_paisa(total_paise, usize::try_from(count).unwrap_or(usize::MAX))
65}
66
67#[uniffi::export]
68pub fn max_body_bytes() -> u64 {
69 engine::MAX_BODY_BYTES as u64
70}
71
72#[uniffi::export]
73pub fn max_batch_items() -> u64 {
74 engine::MAX_BATCH_ITEMS as u64
75}
76
77#[uniffi::export]
78pub fn parse_amount(text: Option<String>) -> Option<i64> {
79 parse_amount_paise(text.as_deref())
80}
81
82#[uniffi::export]
83pub fn is_spam(text: String) -> bool {
84 is_non_transaction(&text)
85}
86
87#[uniffi::export]
88pub fn apply_filter(f: TransactionFilter, rows: Vec<TxRow>) -> Vec<TxRow> {
89 f.apply(&rows).into_iter().cloned().collect()
90}
91
92#[uniffi::export]
93pub fn encode_inbox_line(package: String, text: String, seen_at: String) -> String {
94 encode_inbox_line_core(&package, &text, &seen_at)
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100
101 #[test]
102 fn ffi_round_trips() {
103 let t = parse_capture("₹450 paid to Swiggy using UPI UPI Ref 123456789012".into(), "hdfcbk".into(), 1).unwrap();
104 assert_eq!(t.payment.merchant, "Swiggy");
105 assert_eq!(t.sender, "HDFCBK");
106 assert_eq!(parse_captures(vec![]).len(), 0);
107 let mixed = parse_captures(vec![
109 CaptureInput { body: "₹450 paid to Swiggy".into(), sender: "gpay".into(), timestamp_ms: 1 },
110 CaptureInput { body: "just saying hi".into(), sender: "friend".into(), timestamp_ms: 2 },
111 ]);
112 assert_eq!(mixed.len(), 2);
113 assert!(mixed[0].is_some() && mixed[1].is_none()); assert_eq!(split_bill(100, 3), vec![34, 33, 33]);
115 assert_eq!(max_body_bytes(), 16384);
116 assert_eq!(max_batch_items(), 10000);
117 assert_eq!(parse_amount(Some("2.345".into())), Some(235));
118 assert!(is_spam("OTP is 123456. Do not share with anyone.".into()));
119 assert!(!is_spam("₹450 paid to Swiggy".into()));
120 let rules = vec![Rule::new("swiggy", "builtin", 1)];
121 assert_eq!(categorize_merchant("Swiggy dinner".into(), rules.clone()).unwrap().category_id, Some(1));
122 assert!(categorize_merchant("zzz".into(), rules).is_none()); assert_eq!(normalize_merchant_text("ZOMATO-UB".into()), "zomato ub");
124 assert!(encode_inbox_line("p".into(), "t".into(), "s".into()).contains("\"package\":\"p\""));
125 let rows = vec![TxRow {
126 id: 1, amount_paise: 10000, merchant: "Zomato".into(), category_id: Some(1),
127 payment_method: "upi".into(), is_income: false, txn_ms: 5, note: None, upi_ref: None,
128 }];
129 let f = TransactionFilter { query: "zom".into(), ..Default::default() };
130 assert_eq!(apply_filter(f, rows).len(), 1);
131 assert_eq!(
132 check_capture(Some("ABC12345".into()), 45000, false, 2000, Some(7), vec![]),
133 CaptureDecision::Insert
134 );
135 let live = vec![ExistingRow {
137 upi_ref: Some("ABC12345".into()), amount_paise: 45000, is_income: false,
138 txn_ms: 1000, is_deleted: false, content_hash: None,
139 }];
140 assert_eq!(
141 check_capture(Some("ABC12345".into()), 45000, false, 2000, Some(7), live),
142 CaptureDecision::Skip { backfill_ref: false }
143 );
144 }
145}
146