kharcha-core 0.1.0

Deterministic core of the Kharcha UPI expense tracker: SMS parsing, spam rejection, categorization, money math, dedupe. Rule-based, no AI.
Documentation
//! Spam rejection. Port of `_nonTransactionRe` + `isNonTransaction` in
//! `lib/core/upi_parser.dart`: recharge/promo/OTP/request/failure texts
//! return None before parsing.
//!
//! Pattern notes (parity with Dart `RegExp`, `caseSensitive: false`, non-unicode):
//! - `fancy-regex 0.14` rejects `(?-u)` (`NonUnicodeUnsupported`), so ASCII
//!   parity is mechanical: every `\d`→`[0-9]`, every `\s`→`[ \t\n\x0B\f\r]`
//!   (Dart's ASCII whitespace set). `\b` stays Unicode-aware — the only
//!   divergence, and only when non-ASCII word chars touch ASCII keywords;
//!   the parity corpus is real-world SMS (ASCII) and proves the behavior.
//! - Otherwise patterns are verbatim from the Dart source so the two files
//!   stay diffable.

use std::sync::OnceLock;

use fancy_regex::Regex;

/// Case-insensitive prefix; single place to change.
pub(crate) const ASCII_CI: &str = "(?i:";

fn compiled() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| {
        Regex::new(concat!(
            "(?i:",
            r#"\b(?:"#,
            // 1. Telecom Recharge: confirmations, promos, plans, validity, data packs
            r#"recharge (?:of|for|plan|pack|offer|done|successful|processed|is|credited|with|now|soon|your|immediately)|"#,
            r#"recharge.*(?:successful|done|processed|validity|number|mobile|prepaid)|"#,
            r#"successful recharge|recharge successful|recharge done|"#,
            r#"recharge ending|recharge will end|recharge expires|recharge expired|plan expires|plan expiring|"#,
            r#"validity expires|validity expiring|validity ending|pack expires|pack expiring|pack will expire|"#,
            r#"please recharge|plz recharge|to continue services|to enjoy unlimited|plan has expired|"#,
            r#"data pack|daily data|talktime|unlimited 5g|prepaid account|"#,
            r#"for your (?:jio|airtel|vi|vodafone|idea|bsnl) (?:number|mobile)|"#,
            r#"on (?:your )?(?:jio|airtel|vi|vodafone|idea|bsnl) (?:number|mobile)|"#,
            r#"(?:jio|airtel|vi|bsnl) prepaid|"#,
            r#"benefits:[ \t\n\x0B\f\r]*[0-9]|"#,
            // 2. Bill due / Payment due reminders / Statements
            r#"is due|due date|due on|bill generated|bill due|overdue|payment reminder|reminder:|"#,
            r#"bill of (?:rs|inr|₹)|bill amount of|pay before|pay your bill|outstanding bill|"#,
            r#"outstanding amount|payable amount|amount payable|minimum amount due|total amount due|"#,
            r#"statement for|statement generated|e-statement|"#,
            // 3. OTP & Security verification codes
            r#"otp\b|one time password|verification code|security code|secret code|do not share|"#,
            r#"is your code|auth code|use code [0-9]|pin for txn|"#,
            // 4. Marketing promos, Loan offers, Lottery, Referral & Investment spam
            r#"pre-approved|pre approved|loan offer|apply for loan|instant loan|personal loan of|personal loan|"#,
            r#"credit limit of|approved loan|get a loan|quick cash|instant cash|"#,
            r#"win up to|stand a chance to win|congratulations you won|congratulations!|congratulations\b|claim your reward|"#,
            r#"flat off|supercoins|free delivery|shop for|save extra|enjoy flat|use code|"#,
            r#"scratch card|refer and earn|invite and earn|voucher of (?:rs|inr|₹)|"#,
            // Premortem: "received Rs 100 cashback voucher" promos carry receive
            // verbs but move no money. Any voucher mention voids the message.
            r#"voucher|"#,
            r#"invest in|invest rs|start investing|trade now|"#,
            // 5. Payment requests & Collect requests & Pending/Initiated (not completed payments)
            r#"requesting payment|requested payment|payment request|has requested|collect request|"#,
            r#"approve request|autopay request|mandate request|request to pay|request of (?:rs|inr|₹)|"#,
            r#"requested\b|standing instruction|mandate created|autopay scheduled|"#,
            r#"\bpending\b|\binitiated\b|in progress|processing payment|\bprocessing\b|scheduled for|"#,
            r#"will be debited|will be credited|"#,
            // 6. Failed & Declined transactions
            r#"failed|declined|unsuccessful|cancelled|canceled|could not be processed|timed out|aborted|rejected"#,
            r#")\b)"#,
        ))
        .expect("static non-transaction pattern")
    })
}

/// True if `text` is a non-transaction message. Engine errors fail open
/// (false = keep parsing); static patterns are covered by tests below.
pub fn is_non_transaction(text: &str) -> bool {
    compiled().is_match(text.trim()).unwrap_or(false)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn rejects_recharge_prompts() {
        for s in [
            "Ur recharge is ending or will end plz recharge with 196rs",
            "Dear Customer, your Jio pack of Rs 239 will expire on 25-Aug. Recharge now with Rs 239 to continue services.",
            "Your Airtel plan expires tomorrow. Please recharge with Rs 199 to enjoy unlimited calls.",
            "Your Vi pack validity ending today. Plz recharge with 299 immediately.",
            "Recharge of Rs 299 is successful for your Jio number 9876543210. Transaction ID: 123456789. Your plan validity is 28 days.",
            "Recharge of Rs. 199 is successful for mobile 9876543210. Benefits: 1.5GB/day.",
            "Recharge Successful! Rs 299 credited to your Jio prepaid account. Validity: 28 days.",
            "Payment received of Rs 299 for recharge of Airtel mobile 9876543210.",
            "Recharge done for Rs 479 on Vi mobile 9876543210.",
            "Your recharge of Rs 666 for Jio number 9876543210 is processed.",
        ] {
            assert!(is_non_transaction(s), "{s}");
        }
    }

    #[test]
    fn rejects_bills_otps_loans_requests_failures() {
        for s in [
            "Reminder: Electricity bill of Rs 1,450 is due on 30-Aug. Pay now to avoid disconnection.",
            "Your Credit Card bill of INR 12,500.00 is generated. Due date: 15-SEP-26.",
            "Payment reminder: Rs 599 is due for your broadband account.",
            "OTP for transaction of INR 450.00 at Zomato is 928301. Do not share OTP with anyone.",
            "Your verification code for payment of Rs 1,000 is 445566.",
            "Congratulations! You are eligible for pre-approved loan of Rs 5,00,000. Apply now.",
            "Avail instant personal loan of Rs. 1,00,000 in 2 minutes.",
            "Swiggy is requesting payment of Rs 350 via UPI. Approve in GPay.",
            "Collect request of Rs 500 received from rahul@upi.",
            "Payment of Rs 500 to Uber failed due to bank server issue.",
            "Transaction of Rs 1,200 at Swiggy was declined due to insufficient funds.",
            "Akash has requested Rs 500 from you on PhonePe. Click here to approve.",
            "Payment request of Rs 1,200 received from Ramesh on Google Pay.",
            "Collect request of Rs 350 initiated by Zomato. Authorize in your UPI app.",
            "Request to pay INR 450 from merchant ABC. Approve to pay.",
            "Mandate created for Rs 199/month for Netflix.",
            "Autopay scheduled for Rs 499 on 15th.",
            "Payment of Rs 1,000 is pending.",
            "Transaction of Rs 500 initiated.",
            "Payment of Rs 800 in progress.",
            "Pre-approved personal loan of ₹5,00,000 at 10.5% interest. Apply now.",
            "Congratulations! You have won Rs 500 cashback voucher on PhonePe. Claim now.",
            "Win up to Rs 10,000 on Cred. Spin now.",
            "Your credit limit of Rs 75,000 is approved. Click to activate.",
            // "Earn Rs 500 by referring..." carries no spam keyword — Dart nulls
            // it at the payment-verb gate, not here. Covered in tests/parity.rs.
            "Invest Rs 500 in top mutual funds today.",
            "Your ICICI Credit Card statement for Dec has been generated. Total amount due: Rs 4,500.",
        ] {
            assert!(is_non_transaction(s), "{s}");
        }
    }

    #[test]
    fn keeps_real_payments() {
        for s in [
            "₹450 paid to Swiggy using UPI UPI Ref 123456789012",
            "₹5000 received from Akash. UPI Ref 123456789012",
            "Paid Rs 150 on Swiggy. Earn up to Rs 20 cashback on next order.",
            "INR 899.00 refunded to your A/c XX1234 from Amazon. UPI Ref: 102938475610",
        ] {
            assert!(!is_non_transaction(s), "{s}");
        }
    }
}