Skip to main content

kharcha_core/
ffi.rs

1//! FFI surface (uniffi → Kotlin/JVM first, Swift/Python later).
2//! Core modules stay FFI-agnostic (lifetimes, slices, &str); every exported
3//! fn takes/returns owned types only. New API? Wrap it here, never in core.
4
5use 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/// One batch-drain item (uniffi has no tuples).
14#[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    // Audit: try_from can't truncate — absurd counts funnel into the 10k
63    // clamp in split_bill_paisa and come back empty (see INTEGRATION.md).
64    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        // Audit: prove uniffi nesting support, not just delegation.
108        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()); // Vec<Option<Record>>
114        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()); // Option<Record> None-case
123        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        // Enum-with-fields variant across the boundary.
136        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