diff --git a/.forgejo/workflows/email.yml b/.forgejo/workflows/email.yml index 8559fec..c05e458 100644 --- a/.forgejo/workflows/email.yml +++ b/.forgejo/workflows/email.yml @@ -405,18 +405,18 @@ jobs: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - name: Email domain tests - run: "cargo test --locked -p nigig-core --lib -- email_ secret:: mail_backend:: mail_proxy:: imap_client:: credential_store::" + run: "cargo test --locked -p nigig-core --lib -- email_ secret:: mail_backend:: mail_proxy:: imap_client:: credential_store:: finance_report::" # A floor, not a ratchet: these tests are cheap, pure, and the # number should only go up. 38 at Phase 0; 154 after C1c/C1d; # 195 after the Phase C/D completion; 214 after Phase E (proptest + # the SMTP sink integration tests); 216 after the §8 TLS handshake - # tests. + # tests; 234 after the trip-receipt extraction and finance report. - name: The email domain test suite must not shrink run: | set -euo pipefail - FLOOR=210 - out="$(cargo test --locked -p nigig-core --lib -- email_ secret:: mail_backend:: mail_proxy:: imap_client:: credential_store:: 2>&1)" + FLOOR=225 + out="$(cargo test --locked -p nigig-core --lib -- email_ secret:: mail_backend:: mail_proxy:: imap_client:: credential_store:: finance_report:: 2>&1)" # C1e/C1f: the IMAP transport and the platform keystore are both # feature-gated (native only). They must still COMPILE when the diff --git a/Cargo.lock b/Cargo.lock index 1ab0d82..26edcc5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3038,6 +3038,9 @@ dependencies = [ "lettre", "makepad-widgets", "matrix_client", + "nigig-pdf-cos", + "nigig-pdf-document", + "nigig-pdf-graphics", "nigig-system-prefs", "postcard", "proptest", diff --git a/REVIEWS/NIGIG_EMAIL_ASSESSMENT_AND_PLAN.md b/REVIEWS/NIGIG_EMAIL_ASSESSMENT_AND_PLAN.md index ac748e4..4272450 100644 --- a/REVIEWS/NIGIG_EMAIL_ASSESSMENT_AND_PLAN.md +++ b/REVIEWS/NIGIG_EMAIL_ASSESSMENT_AND_PLAN.md @@ -549,6 +549,7 @@ snapshot. Commits are on `main`. | *(this turn)* | **C6/C7/C1f gaps closed** — C6: `email_bulk.rs` actually *uses* the pacing — `bulk_send_plan` batches a list over the 100-recipient cap and `run_bulk_send` sends the batches paced (gap + rate-limiter + abandon), wired into the Bulk page. C7: pull-to-refresh on the inbox (the SMS/M-Pesa `scrolled`+`scroll_position` pattern) in addition to the button. C1f: a real `KeyringCredentialStore` (OS Secret Service / Credential Manager / Keychain via `keyring`) behind the `keystore` feature; the fail-closed default remains when the feature is off. Domain tests **195 → 206**. | | *(this turn)* | **Phase E COMPLETE** — E1 (pure-logic units, already >40); E2 (`proptest` + `email_properties.rs`: never-panic + structural invariants across the address/hostname/recipient/date parsers and `preview_line`); E3 (`FLOOR=205`); E4 (clippy ratchet at 0); E5 (a real SMTP conversation against a local sink, plus a silent-server timeout test — which **caught a real defect**: lettre's `.timeout()` only bounds the TCP connect, not the greeting/command reads, so the send path now wraps the whole operation in `platform::timeout`); E6 (the shared conversation kit is now unit-tested: `should_emit_clicked` + payload). Domain tests **206 → 214**; `nigig-uikit` gains its first 5 tests. | | *(this turn)* | **§8 follow-up: close the "not verified" list.** The TLS handshake is now EXECUTED against the production `build_transport` path — a STARTTLS-downgrade test (no `AUTH`/`MAIL`/`RCPT`/`DATA` over a cleartext link) and a self-signed-cert rejection test (`rcgen` + `tokio-rustls` server, real handshake, `accept_invalid_certs: false` observed). The wasm path is now BUILT (`cargo check --target wasm32-unknown-unknown`, gated in CI). B1 and the test/clippy baselines are now runs/measurements, not reads. Domain tests **214 → 216**. | +| *(this turn)* | **New feature: trip-expense report.** `email_receipts.rs` extracts `TripReceipt` (date, amount, currency, route, receipt id) from Bolt/ride-hailing receipt emails, host-tested against realistic fixtures. `finance_report.rs` renders those into a finance-department PDF — a summary table of dates and amounts with a total, then one receipt block per trip — using the nigig PDF stack (`nigig-pdf-graphics`), native-only and parse-back tested. `spawn_export_trip_report` fetches the inbox, extracts, builds, and writes `trip-expense-report.pdf`; the More page has an "Export trip report (PDF)" button. Domain tests **216 → 234**; coverage **90.6%** over 15 files. | **Phase 0 is complete.** All seven items done; 0.7 was fixed upstream. diff --git a/crates/apps/nigig-email/src/email_frame/pages/more.rs b/crates/apps/nigig-email/src/email_frame/pages/more.rs index b1b3031..fc8cefe 100644 --- a/crates/apps/nigig-email/src/email_frame/pages/more.rs +++ b/crates/apps/nigig-email/src/email_frame/pages/more.rs @@ -77,6 +77,39 @@ script_mod! { draw_text +: { color: #x94A3B8, text_style: theme.font_regular { font_size: 10.0 } } } } + + finance_card := RoundedView { + width: Fill, height: Fit + flow: Down + spacing: 8 + padding: 18 + show_bg: true + draw_bg +: { color: #xF8FAFC, border_radius: 22.0, border_size: 1.0, border_color: #xE2E8F0 } + + Label { + text: "Finance" + draw_text +: { color: #x1C274C, text_style: theme.font_bold { font_size: 22.0 } } + } + + Label { + width: Fill, height: Fit + text: "Compile trip receipts (e.g. Bolt rides) from your inbox into a PDF for the finance department, with a summary of dates and amounts." + draw_text +: { color: #x64748B, text_style: theme.font_regular { font_size: 11.0 } } + } + + export_btn := Button { + width: Fill, height: 46 + text: "Export trip report (PDF)" + draw_bg +: { color: #x1C274C, color_hover: #x2A3F6E, border_radius: 14.0 } + draw_text +: { color: #xFFFFFF, text_style: theme.font_bold { font_size: 13.0 } } + } + + report_status := Label { + width: Fill, height: Fit + text: "" + draw_text +: { color: #x64748B, text_style: theme.font_regular { font_size: 10.5 } } + } + } } } } @@ -110,6 +143,36 @@ impl Widget for EmailMorePage { nigig_core::email_session::clear_session(); Cx::post_action(EmailSessionAction::SignedOut); } + + if self.button(cx, ids!(export_btn)).clicked(actions) { + self.view + .label(cx, ids!(report_status)) + .set_text(cx, "Reading your inbox for trip receipts…"); + self.view.redraw(cx); + #[cfg(not(target_arch = "wasm32"))] + nigig_core::email_worker::spawn_export_trip_report(); + #[cfg(target_arch = "wasm32")] + self.view + .label(cx, ids!(report_status)) + .set_text(cx, "Trip reports are not available in a web browser."); + } + + #[cfg(not(target_arch = "wasm32"))] + if let Some(nigig_core::email_worker::EmailWorkerAction::TripReportExported(result)) = + actions.iter().find_map(|a| a.downcast_ref()) + { + let msg = match result { + Ok(r) => format!( + "Wrote {} trip(s), total {}, to {}", + r.trip_count, + nigig_core::finance_report::format_money(r.total, &r.currency), + r.path + ), + Err(e) => format!("Export failed: {e}"), + }; + self.view.label(cx, ids!(report_status)).set_text(cx, &msg); + self.view.redraw(cx); + } } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { diff --git a/crates/nigig-core/Cargo.toml b/crates/nigig-core/Cargo.toml index c78eaa8..eb6dac7 100644 --- a/crates/nigig-core/Cargo.toml +++ b/crates/nigig-core/Cargo.toml @@ -58,6 +58,11 @@ async-native-tls = { version = "0.5", optional = true } async-net = { version = "2", optional = true } # C1f: a real platform keystore (feature `keystore`), native only. keyring = { version = "4", optional = true } +# Trip-expense reports (finance_report) render receipts to PDF with the +# nigig PDF stack. Native only: a browser cannot meaningfully write the +# file the finance department consumes. +nigig-pdf-graphics = { path = "../apps/pdf/pdf-graphics" } +nigig-pdf-cos = { path = "../apps/pdf/pdf-cos" } [dev-dependencies] # E2: property-test the address/date parsers — never panic on arbitrary @@ -75,6 +80,9 @@ tokio-rustls = "0.26" # provider lettre selects, so `with_single_cert` can sign an ECDSA key. rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } tokio = { version = "1", features = ["rt", "macros", "net", "time", "io-util", "sync"] } +# Parse generated trip reports back in tests (finance_report tests assert +# page counts and content, not bytes). +nigig-pdf-document = { path = "../apps/pdf/pdf-document" } [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen = "0.2" diff --git a/crates/nigig-core/examples/trip_report_sample.rs b/crates/nigig-core/examples/trip_report_sample.rs new file mode 100644 index 0000000..65ef38f --- /dev/null +++ b/crates/nigig-core/examples/trip_report_sample.rs @@ -0,0 +1,51 @@ +//! Generate a sample trip-expense report from a few hardcoded receipts. +//! +//! ```text +//! cargo run -p nigig-core --example trip_report_sample -- sample.pdf +//! ``` +//! +//! The output is the same PDF the finance-department export produces: a +//! summary table of dates and amounts, then one receipt block per trip. + +use nigig_core::email_receipts::TripReceipt; +use nigig_core::finance_report::{build_trip_report, format_money}; + +fn receipt(id: &str, date: &str, amount: f64, pickup: &str, dropoff: &str) -> TripReceipt { + let naive = chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(); + TripReceipt { + receipt_id: id.to_string(), + source_email_id: format!("email-{id}"), + date_ms: naive.and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis(), + date_label: date.to_string(), + amount, + currency: "KES".to_string(), + pickup: pickup.to_string(), + dropoff: dropoff.to_string(), + subject: "Your trip receipt".to_string(), + } +} + +fn main() { + let out = std::env::args() + .nth(1) + .unwrap_or_else(|| "trip-expense-report-sample.pdf".to_string()); + + let trips = vec![ + receipt("BLT-8F3A-1001", "2026-08-10", 1250.00, "Westlands, Nairobi", "JKIA Terminal 1A"), + receipt("BLT-8F3A-1002", "2026-08-14", 340.50, "Kilimani, Nairobi", "CBD, Nairobi"), + receipt("BLT-8F3A-1003", "2026-08-17", 980.00, "CBD, Nairobi", "Karen, Nairobi"), + receipt("BLT-8F3A-1004", "2026-08-20", 145.75, "Kasarani, Nairobi", "Roysambu, Nairobi"), + ]; + + let report = build_trip_report(&trips, "Trip Expense Report"); + std::fs::write(&out, &report.bytes).expect("write"); + + println!( + "wrote {} ({} bytes, {} pages): {} trip(s), total {}", + out, + report.bytes.len(), + report.page_count, + report.trip_count, + format_money(report.total, &report.currency), + ); +} diff --git a/crates/nigig-core/src/email_receipts.rs b/crates/nigig-core/src/email_receipts.rs new file mode 100644 index 0000000..798398d --- /dev/null +++ b/crates/nigig-core/src/email_receipts.rs @@ -0,0 +1,490 @@ +//! Trip-receipt extraction from email (Bolt ride receipts, and the same +//! shape from any ride-hailing provider). +//! +//! The finance department wants one thing out of the inbox: a per-trip +//! summary of dates and amounts, so expenses can be reconciled. This module +//! is the pure, host-testable seam that turns `EmailMessage`s into a +//! `TripReceipt` — no network, no display, no PDF. The PDF lives in +//! `finance_report`; the orchestration in `email_worker`. +//! +//! ## Honesty about the parser +//! +//! Receipt bodies are not a stable format: providers reword them, localise +//! currencies, and reflow whitespace. This is a *tolerant* parser, not a +//! grammar. It detects the sender, finds a currency+amount, and finds a +//! date, falling back to the email's own timestamp when the body has no +//! usable date. Every rule is exercised by the tests below against +//! realistic fixtures, so the extraction is pinned where it matters (the +//! amount, the currency, and the date) rather than inferred. +//! +//! A message that cannot yield an amount is deliberately NOT a receipt: +//! a finance row without an amount is worse than no row. + +use crate::email_store::EmailMessage; + +/// One trip, as the finance report needs it. +#[derive(Clone, Debug, PartialEq)] +pub struct TripReceipt { + /// Provider receipt/reference number, or the email id when none. + pub receipt_id: String, + /// The email this was extracted from, for traceability. + pub source_email_id: String, + /// Trip date as epoch millis (fallback: the email's timestamp). + pub date_ms: i64, + /// Trip date, `YYYY-MM-DD`, for the summary table. + pub date_label: String, + pub amount: f64, + /// Normalised 3-letter code, e.g. `KES`. + pub currency: String, + pub pickup: String, + pub dropoff: String, + pub subject: String, +} + +/// Is this message a trip receipt from a ride-hailing provider? +/// +/// The sender address or display name names the provider. Case-insensitive, +/// trimmed, so `Receipts@Bolt.eu`, `noreply@bolt.eu` and a display name of +/// "Bolt" all match. +pub fn is_trip_receipt(msg: &EmailMessage) -> bool { + let sender = format!("{} {}", msg.from_address, msg.from_name).to_lowercase(); + sender.contains("bolt") || sender.contains("uber") || sender.contains("taxify") +} + +/// Extract every receipt from a list of messages, in input order. +/// +/// Non-receipt messages are dropped; receipts whose amount cannot be +/// parsed are dropped too (a finance row without an amount is noise). +pub fn extract_receipts(messages: &[EmailMessage]) -> Vec { + messages.iter().filter_map(extract_receipt).collect() +} + +/// Extract one receipt, or `None` when the message is not a receipt or its +/// amount cannot be read. +pub fn extract_receipt(msg: &EmailMessage) -> Option { + if !is_trip_receipt(msg) { + return None; + } + let (amount, currency) = extract_amount(&msg.body)?; + let (date_ms, date_label) = extract_date(&msg.body).unwrap_or_else(|| { + // The email's own timestamp is a reasonable fallback: a ride is + // billed on the day it is emailed. + let ms = msg.date_ms; + let label = epoch_to_label(ms); + (ms, label) + }); + let pickup = value_after(&msg.body, &["pickup", "pick up", "origin"]).unwrap_or_default(); + let dropoff = value_after( + &msg.body, + &["dropoff", "drop off", "drop-off", "destination"], + ) + .unwrap_or_default(); + let receipt_id = value_after( + &msg.body, + &[ + "receipt no", + "receipt number", + "receipt #", + "receipt id", + "order id", + "trip id", + "reference", + "receipt", + ], + ) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| msg.id.clone()); + + Some(TripReceipt { + receipt_id, + source_email_id: msg.id.clone(), + date_ms, + date_label, + amount, + currency, + pickup, + dropoff, + subject: msg.subject.clone(), + }) +} + +/// Sum of all receipt amounts, for the report's total line. +pub fn total_amount(receipts: &[TripReceipt]) -> f64 { + receipts.iter().map(|r| r.amount).sum() +} + +// ---------------------------------------------------------------- currency + +/// Currency tokens we recognise, normalised to a 3-letter code. `KSh` / +/// `Ksh` / `KES` all mean Kenyan shillings; the other common East/Central +/// African and international codes are listed explicitly so a stray +/// three-letter word is never mistaken for money. +fn normalise_currency(token: &str) -> Option { + let t = token.to_uppercase(); + Some( + match t.as_str() { + "KSH" | "KSHS" | "KES" => "KES", + "USHS" | "UGX" => "UGX", + "TSHS" | "TZS" => "TZS", + "RWF" => "RWF", + "BIF" => "BIF", + "ETB" => "ETB", + "ZAR" => "ZAR", + "NGN" => "NGN", + "GHS" => "GHS", + "USD" | "US$" => "USD", + "EUR" | "EURO" => "EUR", + "GBP" => "GBP", + _ => return None, + } + .to_string(), + ) +} + +/// Find a currency token in a line, if any. +fn currency_in(line: &str) -> Option { + line.split(|c: char| !c.is_ascii_alphanumeric() && c != '$') + .filter_map(|tok| normalise_currency(tok.trim())) + .next() +} + +// ------------------------------------------------------------------ amount + +/// Find the first decimal number in a line, tolerating `,` thousands +/// separators and ` ` / non-breaking-space digit grouping. +fn number_in(line: &str) -> Option { + let bytes = line.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i].is_ascii_digit() { + let mut j = i; + let mut raw = String::new(); + while j < bytes.len() { + let c = bytes[j] as char; + if c.is_ascii_digit() || c == ',' || c == '.' || c == ' ' || c == '\u{00A0}' { + raw.push(c); + j += 1; + } else { + break; + } + } + let cleaned: String = raw + .chars() + .filter(|c| c.is_ascii_digit() || *c == '.') + .collect(); + if let Ok(v) = cleaned.parse::() { + return Some(v); + } + i = j; + } else { + i += 1; + } + } + None +} + +/// Extract `(amount, currency)` from a body, preferring the line that +/// names the total. +fn extract_amount(body: &str) -> Option<(f64, String)> { + let mut best: Option<(f64, String, i32)> = None; + for line in body.lines() { + let lower = line.to_lowercase(); + if lower.contains("subtotal") || lower.contains("discount") || lower.contains("tip") { + continue; + } + let priority = if lower.contains("total") { + 3 + } else if lower.contains("fare") { + 2 + } else if lower.contains("amount") || lower.contains("price") || lower.contains("cost") { + 1 + } else { + 0 + }; + if let Some(currency) = currency_in(line) { + if let Some(amount) = number_in(line) { + if best.as_ref().map_or(true, |(_, _, p)| priority > *p) { + best = Some((amount, currency, priority)); + } + } + } + } + best.map(|(a, c, _)| (a, c)) +} + +// -------------------------------------------------------------------- date + +const DATE_FORMATS: &[&str] = &[ + "%Y-%m-%d", + "%Y/%m/%d", + "%d/%m/%Y", + "%d-%m-%Y", + "%d.%m.%Y", + "%m/%d/%Y", + "%d %b %Y", + "%d %B %Y", + "%b %d %Y", + "%B %d %Y", + "%b %d, %Y", + "%B %d, %Y", + "%d %b, %Y", +]; + +fn ts(naive: chrono::NaiveDate) -> i64 { + naive + .and_hms_opt(0, 0, 0) + .expect("midnight is valid") + .and_utc() + .timestamp_millis() +} + +fn epoch_to_label(ms: i64) -> String { + chrono::DateTime::from_timestamp_millis(ms) + .map(|dt| dt.format("%Y-%m-%d").to_string()) + .unwrap_or_default() +} + +/// Extract `(epoch_ms, "YYYY-MM-DD")` from a body, trying each line whole +/// and then sliding windows of 2–3 tokens within a line. Returns `None` +/// when no date is recognisable. +fn extract_date(body: &str) -> Option<(i64, String)> { + for line in body.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + // Whole line first (ISO dates, or a line that is just a date). + for fmt in DATE_FORMATS { + if let Ok(d) = chrono::NaiveDate::parse_from_str(line, fmt) { + return Some((ts(d), d.format("%Y-%m-%d").to_string())); + } + } + // Sliding windows of tokens, punctuation-stripped, widest first. + // Width 3 covers "17 Aug 2026" / "Aug 17, 2026"; width 1 covers the + // ISO "2026-08-17" and "17/08/2026". Width 2 is deliberately + // omitted: no real date spans exactly two whitespace tokens with a + // year, and chrono's flexible `%d`/`%Y` would otherwise split + // "2026" in "Aug 2026" into day 20 + year 26. + let tokens: Vec = line + .split_whitespace() + .map(|t| { + t.trim_matches(|c: char| !c.is_ascii_alphanumeric()) + .to_string() + }) + .filter(|t| !t.is_empty()) + .collect(); + for width in [3usize, 1usize] { + if tokens.len() < width { + continue; + } + for start in 0..=tokens.len() - width { + let joined = tokens[start..start + width].join(" "); + for fmt in DATE_FORMATS { + if let Ok(d) = chrono::NaiveDate::parse_from_str(&joined, fmt) { + return Some((ts(d), d.format("%Y-%m-%d").to_string())); + } + } + } + } + } + None +} + +// ------------------------------------------------------------- line value + +/// The value after the first label that matches one of `labels` at the +/// start of a line, case-insensitively. Handles `Label:` and +/// `Label location:` shapes and preserves the original casing. +fn value_after(body: &str, labels: &[&str]) -> Option { + for line in body.lines() { + let trimmed = line.trim(); + for label in labels { + let Some(head) = trimmed.get(..label.len()) else { + continue; + }; + if !head.eq_ignore_ascii_case(label) { + continue; + } + let mut rest = trimmed[label.len()..].trim_start(); + // "Pickup location: …" and "Pickup: …" both mean pickup. + if rest.len() >= 8 && rest[..8].eq_ignore_ascii_case("location") { + rest = rest[8..].trim_start(); + } + let value = rest.trim_start_matches(':').trim(); + if !value.is_empty() { + return Some(value.to_string()); + } + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn msg(from: &str, name: &str, subject: &str, body: &str) -> EmailMessage { + EmailMessage { + id: "m1".into(), + from_address: from.into(), + from_name: name.into(), + subject: subject.into(), + body: body.into(), + date_ms: 1_753_132_800_000, // 2025-07-22 + is_read: false, + is_outgoing: false, + } + } + + const BOLT_BODY: &str = "\ + Thanks for riding with Bolt.\n\ + Receipt\n\ + Receipt No: BLT-8F3A-2026\n\ + Pickup: Westlands, Nairobi\n\ + Dropoff: JKIA Terminal 1A\n\ + Date: 17 Aug 2026\n\ + Total: KES 1,250.00\n\ + Payment method: Cash\n"; + + #[test] + fn bolt_senders_are_detected() { + for (addr, name) in [ + ("receipts@bolt.eu", ""), + ("noreply@bolt.eu", "Bolt"), + ("no-reply@taxify.eu", "Taxify"), + ("receipts@uber.com", "Uber"), + ] { + assert!( + is_trip_receipt(&msg(addr, name, "Your trip receipt", "")), + "{addr} {name}" + ); + } + assert!(!is_trip_receipt(&msg( + "alerts@bank.co.ke", + "Equity", + "", + "" + ))); + assert!(!is_trip_receipt(&msg("jane@example.com", "Jane", "", ""))); + } + + #[test] + fn a_bolt_receipt_extracts_amount_currency_date_and_route() { + let r = extract_receipt(&msg( + "receipts@bolt.eu", + "Bolt", + "Your trip receipt", + BOLT_BODY, + )) + .expect("should extract"); + assert_eq!(r.amount, 1250.0); + assert_eq!(r.currency, "KES"); + assert_eq!(r.date_label, "2026-08-17"); + assert_eq!(r.receipt_id, "BLT-8F3A-2026"); + assert_eq!(r.pickup, "Westlands, Nairobi"); + assert_eq!(r.dropoff, "JKIA Terminal 1A"); + } + + #[test] + fn amounts_tolerate_comma_grouping_and_currency_first() { + let body = "Ride summary\nFare KES 450\nTotal: 1,250.00 KES\n"; + let r = extract_receipt(&msg("noreply@bolt.eu", "Bolt", "Trip", body)).unwrap(); + assert_eq!(r.amount, 1250.0); + assert_eq!(r.currency, "KES"); + } + + #[test] + fn ksh_spelling_is_normalised_to_kes() { + let body = "Total: KSh 980\n"; + let r = extract_receipt(&msg("receipts@bolt.eu", "Bolt", "Trip", body)).unwrap(); + assert_eq!(r.amount, 980.0); + assert_eq!(r.currency, "KES"); + } + + #[test] + fn a_total_line_is_preferred_over_a_fare_line() { + let body = "Fare: KES 400\nTotal: KES 650\n"; + let r = extract_receipt(&msg("noreply@bolt.eu", "Bolt", "Trip", body)).unwrap(); + assert_eq!(r.amount, 650.0); + } + + #[test] + fn a_non_receipt_email_yields_nothing() { + assert!(extract_receipt(&msg( + "alerts@bank.co.ke", + "Equity", + "Statement", + "Balance: KES 5,000" + )) + .is_none()); + } + + #[test] + fn a_receipt_without_an_amount_is_not_a_receipt() { + // Sender is Bolt but no amount anywhere: cannot build a finance row. + assert!(extract_receipt(&msg( + "receipts@bolt.eu", + "Bolt", + "Trip", + "Thanks for riding!" + )) + .is_none()); + } + + #[test] + fn a_missing_date_falls_back_to_the_email_timestamp() { + let m = msg("receipts@bolt.eu", "Bolt", "Trip", "Total: KES 300"); + let r = extract_receipt(&m).unwrap(); + assert_eq!(r.date_ms, m.date_ms); + assert_eq!(r.date_label, epoch_to_label(m.date_ms)); + assert!(!r.date_label.is_empty()); + } + + #[test] + fn iso_dates_are_read_directly() { + let body = "Date: 2026-08-17\nTotal: KES 200"; + let r = extract_receipt(&msg("noreply@bolt.eu", "Bolt", "Trip", body)).unwrap(); + assert_eq!(r.date_label, "2026-08-17"); + } + + #[test] + fn month_day_comma_year_is_read() { + let body = "Trip on Aug 17, 2026 at 10:45\nTotal: KES 500"; + let r = extract_receipt(&msg("receipts@bolt.eu", "Bolt", "Trip", body)).unwrap(); + assert_eq!(r.date_label, "2026-08-17"); + } + + #[test] + fn totals_sum_every_receipt() { + let a = TripReceipt { + receipt_id: "1".into(), + source_email_id: "e1".into(), + date_ms: 0, + date_label: "2026-08-17".into(), + amount: 100.0, + currency: "KES".into(), + pickup: String::new(), + dropoff: String::new(), + subject: String::new(), + }; + let b = TripReceipt { + amount: 250.5, + ..a.clone() + }; + assert!((total_amount(&[a, b]) - 350.5).abs() < 1e-9); + } + + #[test] + fn the_trip_date_is_never_the_fallback_when_present() { + // A body date that differs from the email timestamp must win. + let m = msg( + "receipts@bolt.eu", + "Bolt", + "Trip", + "Date: 17 Aug 2026\nTotal: KES 100", + ); + let r = extract_receipt(&m).unwrap(); + assert_ne!(r.date_ms, m.date_ms); + assert_eq!(r.date_label, "2026-08-17"); + } +} diff --git a/crates/nigig-core/src/email_worker.rs b/crates/nigig-core/src/email_worker.rs index 2693089..4f90c79 100644 --- a/crates/nigig-core/src/email_worker.rs +++ b/crates/nigig-core/src/email_worker.rs @@ -135,43 +135,121 @@ pub fn spawn_proxy_verify(settings: crate::mail_backend::ProxySettings, token: S /// empty inbox). Posts `EmailWorkerAction::InboxFetched` either way. pub fn spawn_fetch_inbox(settings: crate::mail_backend::BackendSettings, secret: Secret) { crate::platform::spawn(async move { - let result: Result, String> = match settings { - crate::mail_backend::BackendSettings::ProxyApi(s) => { - let client = crate::mail_proxy::ProxyApiClient::new(s, secret); - #[cfg(not(target_arch = "wasm32"))] - let r = client - .list_inbox(&crate::mail_proxy::ReqwestTransport::default()) - .await; - #[cfg(target_arch = "wasm32")] - let r = client - .list_inbox(&crate::mail_proxy::WasmFetchTransport) - .await; - r.map_err(|e| e.message()) - } - crate::mail_backend::BackendSettings::ImapSmtp(s) => { - #[cfg(feature = "imap")] - { - let client = crate::imap_client::ImapClient::new(s, secret); - client - .list_inbox(&crate::imap_client::AsyncImapTransport) - .await - .map_err(|e| e.message()) - } - #[cfg(not(feature = "imap"))] - { - let _ = (s, secret); - Err( - "Reading mail directly needs the IMAP feature, which is not \ - built into this build." - .to_string(), - ) - } - } - }; + let result = fetch_inbox_messages(settings, secret).await; Cx::post_action(EmailWorkerAction::InboxFetched(result)); }); } +/// Fetch the inbox for a backend, independent of any UI. Shared by the +/// inbox fetch and the trip-report export (which needs the messages to +/// extract receipts from). +async fn fetch_inbox_messages( + settings: crate::mail_backend::BackendSettings, + secret: Secret, +) -> Result, String> { + match settings { + crate::mail_backend::BackendSettings::ProxyApi(s) => { + let client = crate::mail_proxy::ProxyApiClient::new(s, secret); + #[cfg(not(target_arch = "wasm32"))] + let r = client + .list_inbox(&crate::mail_proxy::ReqwestTransport::default()) + .await; + #[cfg(target_arch = "wasm32")] + let r = client + .list_inbox(&crate::mail_proxy::WasmFetchTransport) + .await; + r.map_err(|e| e.message()) + } + crate::mail_backend::BackendSettings::ImapSmtp(s) => { + #[cfg(feature = "imap")] + { + let client = crate::imap_client::ImapClient::new(s, secret); + client + .list_inbox(&crate::imap_client::AsyncImapTransport) + .await + .map_err(|e| e.message()) + } + #[cfg(not(feature = "imap"))] + { + let _ = (s, secret); + Err( + "Reading mail directly needs the IMAP feature, which is not \ + built into this build." + .to_string(), + ) + } + } + } +} + +/// Where a finished trip report is written. +#[cfg(not(target_arch = "wasm32"))] +fn trip_report_path() -> std::path::PathBuf { + crate::dir::app_data_dir().join("trip-expense-report.pdf") +} + +/// The result of exporting a trip report, posted to the UI. +#[derive(Clone, Debug)] +pub struct TripReportExport { + pub path: String, + pub trip_count: usize, + pub total: f64, + pub currency: String, +} + +/// Fetch the inbox, extract trip receipts, and write a finance report PDF. +/// +/// The "check the Bolt trip receipts and compile them into a PDF for the +/// finance department" feature. Uses the signed-in backend, extracts every +/// trip receipt, and writes `trip-expense-report.pdf` into the app-data +/// directory. Posts `EmailWorkerAction::TripReportExported` with the path +/// and a summary (or the reason it failed). +#[cfg(not(target_arch = "wasm32"))] +pub fn spawn_export_trip_report() { + let Some(session) = crate::email_session::current_session() else { + Cx::post_action(EmailWorkerAction::TripReportExported(Err( + "Connect an account first — there is no inbox to read.".to_string(), + ))); + return; + }; + let settings = session.account.backend.clone(); + let secret = session.secret; + + crate::platform::spawn(async move { + let result = export_trip_report(settings, secret).await; + Cx::post_action(EmailWorkerAction::TripReportExported(result)); + }); +} + +/// The async body of the export, split out so it is testable without a Cx. +#[cfg(not(target_arch = "wasm32"))] +async fn export_trip_report( + settings: crate::mail_backend::BackendSettings, + secret: Secret, +) -> Result { + let messages = fetch_inbox_messages(settings, secret).await?; + let receipts = crate::email_receipts::extract_receipts(&messages); + if receipts.is_empty() { + return Err("No trip receipts found in the inbox.".to_string()); + } + let total = crate::email_receipts::total_amount(&receipts); + let currency = receipts + .first() + .map(|r| r.currency.clone()) + .unwrap_or_default(); + let report = crate::finance_report::build_trip_report(&receipts, "Trip Expense Report"); + let path = trip_report_path(); + crate::platform::fs_write(&path, &report.bytes) + .await + .map_err(|e| format!("Could not write the report: {e}"))?; + Ok(TripReportExport { + path: path.display().to_string(), + trip_count: report.trip_count, + total, + currency, + }) +} + /// Send a message through the signed-in backend (C5). /// /// The compose page (and any future caller) does not know whether the @@ -911,6 +989,10 @@ pub enum EmailWorkerAction { /// Progress of a paced bulk send (C6), posted after each batch and once /// at the end (`done`). BulkSendProgress(crate::email_bulk::BulkProgress), + /// Result of exporting the trip-expense report (path + summary, or the + /// reason it failed). + #[cfg(not(target_arch = "wasm32"))] + TripReportExported(Result), } #[cfg(test)] @@ -1578,7 +1660,7 @@ mod tests { fn a_self_signed_certificate_is_rejected() { use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; - use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::io::AsyncWriteExt; let rt = tokio::runtime::Builder::new_current_thread() .enable_all() diff --git a/crates/nigig-core/src/finance_report.rs b/crates/nigig-core/src/finance_report.rs new file mode 100644 index 0000000..8df7d5f --- /dev/null +++ b/crates/nigig-core/src/finance_report.rs @@ -0,0 +1,478 @@ +//! Trip-expense report: a PDF for the finance department. +//! +//! Turns `TripReceipt`s (from `email_receipts`) into a single PDF: a +//! summary table of dates and amounts, then one receipt block per trip. +//! Built with the nigig PDF stack (`nigig-pdf-graphics` / +//! `nigig-pdf-cos`), base-14 Helvetica so the output is self-contained and +//! opens in any viewer with no embedded font. +//! +//! Pure and host-testable: `build_trip_report` takes receipts and returns +//! bytes, and the tests read those bytes back through `PdfDocument`, +//! asserting page counts and that the dates, amounts and total landed in +//! the content streams. Only the final write-to-disk is I/O (and lives in +//! `email_worker`). + +use nigig_pdf_cos::writer::{DocumentMetadata, OutlineItem}; +use nigig_pdf_graphics::content_writer::ContentWriter; +use nigig_pdf_graphics::create::DocumentCreator; + +use crate::email_receipts::TripReceipt; + +/// A4 in points. +const PAGE_W: f64 = 595.0; +const PAGE_H: f64 = 842.0; +const MARGIN_L: f64 = 40.0; +const MARGIN_R: f64 = 40.0; +const MARGIN_T: f64 = 60.0; +const MARGIN_B: f64 = 50.0; + +/// Summary-table column geometry (x, width). +const COL_DATE: (f64, f64) = (40.0, 90.0); +const COL_TRIP: (f64, f64) = (130.0, 300.0); +const COL_AMOUNT: (f64, f64) = (430.0, 125.0); + +/// The finished report, as handed to the caller after writing. +#[derive(Clone, Debug, PartialEq)] +pub struct TripReport { + pub bytes: Vec, + pub trip_count: usize, + pub total: f64, + pub currency: String, + pub page_count: usize, +} + +/// `amount` with thousands grouping and two decimals, then the currency +/// code: `1,250.00 KES`. +pub fn format_money(amount: f64, currency: &str) -> String { + let whole = amount.abs().trunc() as i64; + let frac = ((amount.abs() - whole as f64) * 100.0).round() as i64; + let digits = whole.to_string(); + let mut grouped = String::new(); + for (i, c) in digits.chars().rev().enumerate() { + if i > 0 && i % 3 == 0 { + grouped.push(','); + } + grouped.push(c); + } + let grouped: String = grouped.chars().rev().collect(); + let sign = if amount < 0.0 { "-" } else { "" }; + format!("{sign}{grouped}.{frac:02} {currency}") +} + +/// Build the finance-department report for `receipts`. +/// +/// Receipts are sorted by date before rendering, so the table reads in +/// chronological order whatever order the inbox returned them in. +pub fn build_trip_report(receipts: &[TripReceipt], title: &str) -> TripReport { + let mut sorted: Vec<&TripReceipt> = receipts.iter().collect(); + sorted.sort_by_key(|r| r.date_ms); + + let currency = sorted + .first() + .map(|r| r.currency.clone()) + .unwrap_or_else(|| "KES".to_string()); + let total = crate::email_receipts::total_amount(receipts); + + let mut rep = Report::new(title, ¤cy, total, &sorted); + rep.draw_summary_table(); + rep.draw_receipts(); + let page_count = rep.page_index + 1; + let bytes = rep.finish(); + + TripReport { + bytes, + trip_count: receipts.len(), + total, + currency, + page_count, + } +} + +/// The page-cursor + creator state shared by every draw step. +struct Report<'a> { + creator: DocumentCreator, + cw: ContentWriter, + y: f64, + page_index: usize, + receipts: &'a [&'a TripReceipt], + currency: String, + total: f64, +} + +impl<'a> Report<'a> { + fn new(title: &str, currency: &str, total: f64, receipts: &'a [&'a TripReceipt]) -> Self { + let mut creator = DocumentCreator::new(); + creator.add_standard_font("Helv", "Helvetica"); + creator.add_standard_font("Helv-Bold", "Helvetica-Bold"); + + creator.builder().set_metadata(DocumentMetadata { + title: Some(title.to_string()), + author: Some("nigig-email".to_string()), + subject: Some("Trip expense summary".to_string()), + keywords: Some("expense receipts trips finance".to_string()), + creator: Some("nigig-pdf".to_string()), + producer: Some("nigig-pdf".to_string()), + creation_date: Some(now_pdf_date()), + mod_date: None, + }); + + let mut report = Report { + creator, + cw: ContentWriter::new(), + y: PAGE_H - MARGIN_T, + page_index: 0, + receipts, + currency: currency.to_string(), + total, + }; + report.draw_heading(title); + report + } + + fn top(&self) -> f64 { + PAGE_H - MARGIN_T + } + + /// Reserve `needed` points of vertical space, starting a new page when + /// the current one is full. + fn ensure(&mut self, needed: f64) { + if self.y - needed < MARGIN_B { + self.new_page(); + } + } + + fn new_page(&mut self) { + let content = std::mem::replace(&mut self.cw, ContentWriter::new()).build(); + self.creator.builder().add_page(PAGE_W, PAGE_H, &content); + self.page_index += 1; + self.y = self.top(); + } + + fn text(&mut self, font: &str, size: f64, x: f64, y: f64, s: &str) { + self.cw.begin_text(); + self.cw.set_font(font, size); + self.cw.text_at(x, y, s); + self.cw.end_text(); + } + + fn right_text(&mut self, font: &str, size: f64, x: f64, width: f64, y: f64, s: &str) { + // Base-14 Helvetica averages ~0.5 * size per glyph; good enough for + // right-aligning the amount column without a metrics table. + let approx = s.len() as f64 * size * 0.5; + let left = x + width - approx; + self.text(font, size, left.max(MARGIN_L), y, s); + } + + fn hline(&mut self, y: f64) { + self.cw.rgb_stroke(0.75, 0.75, 0.78); + self.cw.set_stroke_width(0.6); + self.cw.move_to(MARGIN_L, y); + self.cw.line_to(PAGE_W - MARGIN_R, y); + self.cw.stroke(); + } + + fn draw_heading(&mut self, title: &str) { + self.text("Helv-Bold", 18.0, MARGIN_L, self.y, title); + self.y -= 24.0; + self.text( + "Helv", + 10.0, + MARGIN_L, + self.y, + "Prepared for the Finance Department", + ); + self.y -= 14.0; + self.text( + "Helv", + 9.0, + MARGIN_L, + self.y, + &format!("Trip expense summary — {} trip(s)", self.receipts.len()), + ); + self.y -= 20.0; + } + + fn draw_summary_table(&mut self) { + // Column header row. + self.text("Helv-Bold", 10.0, COL_DATE.0, self.y, "Date"); + self.text("Helv-Bold", 10.0, COL_TRIP.0, self.y, "Trip"); + self.right_text( + "Helv-Bold", + 10.0, + COL_AMOUNT.0, + COL_AMOUNT.1, + self.y, + "Amount", + ); + self.y -= 6.0; + self.hline(self.y); + self.y -= 12.0; + + for r in self.receipts { + self.ensure(18.0); + let trip = format!("{} → {}", r.pickup.trim(), r.dropoff.trim()); + let amount = format_money(r.amount, &r.currency); + self.text("Helv", 9.0, COL_DATE.0, self.y, &r.date_label); + self.text("Helv", 9.0, COL_TRIP.0, self.y, &truncate(&trip, 60)); + self.right_text("Helv", 9.0, COL_AMOUNT.0, COL_AMOUNT.1, self.y, &amount); + self.y -= 15.0; + } + + // Total row. + self.ensure(20.0); + self.hline(self.y + 4.0); + self.y -= 2.0; + let total = format_money(self.total, &self.currency); + self.text("Helv-Bold", 10.0, COL_TRIP.0, self.y, "Total"); + self.right_text( + "Helv-Bold", + 10.0, + COL_AMOUNT.0, + COL_AMOUNT.1, + self.y, + &total, + ); + self.y -= 28.0; + } + + fn draw_receipts(&mut self) { + for r in self.receipts { + // Reserve space for a whole block, or break to a new page. + self.ensure(72.0); + self.text( + "Helv-Bold", + 11.0, + MARGIN_L, + self.y, + &format!("Receipt {}", r.receipt_id), + ); + self.y -= 15.0; + self.text( + "Helv", + 9.0, + MARGIN_L, + self.y, + &format!("Date: {}", r.date_label), + ); + self.y -= 13.0; + self.text( + "Helv", + 9.0, + MARGIN_L, + self.y, + &format!("Route: {} → {}", r.pickup.trim(), r.dropoff.trim()), + ); + self.y -= 13.0; + self.text( + "Helv", + 9.0, + MARGIN_L, + self.y, + &format!("Amount: {}", format_money(r.amount, &r.currency)), + ); + self.y -= 24.0; + } + } + + fn finish(mut self) -> Vec { + // Flush the final page. + let content = std::mem::take(&mut self.cw).build(); + self.creator.builder().add_page(PAGE_W, PAGE_H, &content); + + // Outline: one entry per page (the summary page plus each receipt + // page), named so the finance team can jump straight to a trip. + let mut outline = vec![OutlineItem::new("Summary", 0)]; + for (i, r) in self.receipts.iter().enumerate() { + let label = format!("{} — {}", r.date_label, format_money(r.amount, &r.currency)); + outline.push(OutlineItem::new(label, i + 1)); + } + self.creator.builder().set_outline(outline); + + self.creator.finish() + } +} + +/// Truncate to `max` chars (char-safe) with an ellipsis. +fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + return s.to_string(); + } + let mut out: String = s.chars().take(max.saturating_sub(1)).collect(); + out.push('…'); + out +} + +/// `D:YYYYMMDDHHMMSSZ` for the metadata, in UTC. +fn now_pdf_date() -> String { + chrono::Utc::now().format("D:%Y%m%d%H%M%SZ").to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::email_receipts::TripReceipt; + use nigig_pdf_document::PdfDocument; + + fn receipt(date: &str, amount: f64, pickup: &str, dropoff: &str) -> TripReceipt { + let naive = chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(); + let ms = naive + .and_hms_opt(0, 0, 0) + .unwrap() + .and_utc() + .timestamp_millis(); + TripReceipt { + receipt_id: format!("BLT-{}", date), + source_email_id: format!("email-{}", date), + date_ms: ms, + date_label: date.to_string(), + amount, + currency: "KES".to_string(), + pickup: pickup.to_string(), + dropoff: dropoff.to_string(), + subject: "Trip receipt".to_string(), + } + } + + fn sample() -> Vec { + vec![ + receipt( + "2026-08-10", + 1250.0, + "Westlands, Nairobi", + "JKIA Terminal 1A", + ), + receipt("2026-08-14", 340.5, "Kilimani, Nairobi", "CBD, Nairobi"), + receipt("2026-08-17", 980.0, "CBD, Nairobi", "Karen, Nairobi"), + ] + } + + #[test] + fn money_is_grouped_and_signed() { + assert_eq!(format_money(1250.0, "KES"), "1,250.00 KES"); + assert_eq!(format_money(340.5, "KES"), "340.50 KES"); + assert_eq!(format_money(1234567.89, "KES"), "1,234,567.89 KES"); + assert_eq!(format_money(-45.0, "KES"), "-45.00 KES"); + assert_eq!(format_money(0.0, "UGX"), "0.00 UGX"); + } + + #[test] + fn a_report_parses_back_with_pages_and_the_right_totals() { + let trips = sample(); + let report = build_trip_report(&trips, "Trip Expense Report"); + + assert_eq!(report.trip_count, 3); + assert!((report.total - 2570.5).abs() < 1e-9); + assert_eq!(report.currency, "KES"); + + let mut doc = PdfDocument::parse(&report.bytes).expect("report must parse"); + // 1 summary page + 3 receipt blocks all fit on one page → but the + // summary table and receipts flow; assert at least 1 page and that + // every page is A4. + assert!(doc.page_count() >= 1, "page count {}", doc.page_count()); + for i in 0..doc.page_count() { + let page = doc.page(i).unwrap(); + assert_eq!(page.media_box, [0.0, 0.0, PAGE_W, PAGE_H]); + } + + // The summary table text is present on page 1: every date and the + // total line. + let page0 = String::from_utf8_lossy(&doc.page(0).unwrap().content_data).to_string(); + for d in ["2026-08-10", "2026-08-14", "2026-08-17"] { + assert!(page0.contains(d), "missing date {d} in summary"); + } + assert!(page0.contains("2,570.50 KES"), "missing total in summary"); + } + + #[test] + fn receipts_appear_somewhere_in_the_document() { + let trips = sample(); + let report = build_trip_report(&trips, "Trip Expense Report"); + let mut doc = PdfDocument::parse(&report.bytes).unwrap(); + let mut all = String::new(); + for i in 0..doc.page_count() { + all.push_str(&String::from_utf8_lossy(&doc.page(i).unwrap().content_data)); + } + // Each receipt id and amount appears in the receipt sections. + for id in ["BLT-2026-08-10", "BLT-2026-08-14", "BLT-2026-08-17"] { + assert!(all.contains(id), "missing receipt {id}"); + } + assert!(all.contains("1,250.00 KES")); + assert!(all.contains("340.50 KES")); + assert!(all.contains("980.00 KES")); + } + + #[test] + fn an_empty_report_is_still_a_valid_single_page_pdf() { + let report = build_trip_report(&[], "Trip Expense Report"); + assert_eq!(report.trip_count, 0); + assert!((report.total - 0.0).abs() < 1e-9); + let doc = PdfDocument::parse(&report.bytes).expect("parses"); + assert_eq!(doc.page_count(), 1); + } + + #[test] + fn a_large_report_paginates() { + // 40 receipts: the summary table + receipt blocks must span more + // than one page. + let trips: Vec = (0..40) + .map(|i| { + receipt( + &format!("2026-08-{:02}", (i % 28) + 1), + 100.0 + i as f64, + "A", + "B", + ) + }) + .collect(); + let report = build_trip_report(&trips, "Trip Expense Report"); + let doc = PdfDocument::parse(&report.bytes).unwrap(); + assert!(doc.page_count() > 1, "40 receipts should paginate"); + assert_eq!(report.page_count, doc.page_count()); + } + + /// The whole post-fetch pipeline: a mixed inbox (Bolt receipts and + /// unrelated mail) becomes a report whose trip count and total match + /// only the receipts. + #[test] + fn a_mixed_inbox_yields_a_report_of_only_the_receipts() { + use crate::email_receipts::extract_receipts; + use crate::email_store::EmailMessage; + + let bolt = |id: &str, date: &str, amount: f64| EmailMessage { + id: id.to_string(), + from_address: "receipts@bolt.eu".to_string(), + from_name: "Bolt".to_string(), + subject: "Your trip receipt".to_string(), + body: format!("Receipt No: {id}\nDate: {date}\nTotal: KES {amount:.2}\n"), + date_ms: 0, + is_read: false, + is_outgoing: false, + }; + let inbox = vec![ + bolt("BLT-1", "2026-08-10", 1250.0), + EmailMessage { + id: "bank".into(), + from_address: "alerts@bank.co.ke".into(), + from_name: "Equity".into(), + subject: "Statement ready".into(), + body: "Balance: KES 5,000".into(), + date_ms: 0, + is_read: false, + is_outgoing: false, + }, + bolt("BLT-2", "2026-08-14", 340.5), + bolt("BLT-3", "2026-08-17", 980.0), + ]; + + let receipts = extract_receipts(&inbox); + assert_eq!(receipts.len(), 3, "only Bolt receipts are extracted"); + + let report = build_trip_report(&receipts, "Trip Expense Report"); + assert_eq!(report.trip_count, 3); + assert!((report.total - 2570.5).abs() < 1e-9); + + // And it parses back as a real document. + let doc = PdfDocument::parse(&report.bytes).unwrap(); + assert!(doc.page_count() >= 1); + } +} diff --git a/crates/nigig-core/src/lib.rs b/crates/nigig-core/src/lib.rs index bc3225d..aa61973 100644 --- a/crates/nigig-core/src/lib.rs +++ b/crates/nigig-core/src/lib.rs @@ -31,6 +31,9 @@ pub mod credential_store; pub mod email_cache; pub mod imap_client; pub mod email_session; +pub mod email_receipts; +#[cfg(not(target_arch = "wasm32"))] +pub mod finance_report; #[cfg(test)] mod email_properties; diff --git a/tools/test-email-coverage.sh b/tools/test-email-coverage.sh index 5f59cbb..08df53c 100755 --- a/tools/test-email-coverage.sh +++ b/tools/test-email-coverage.sh @@ -57,7 +57,9 @@ nigig-core/src/email_bulk.rs:85 nigig-core/src/credential_store.rs:90 nigig-core/src/email_cache.rs:80 nigig-core/src/email_session.rs:90 -nigig-core/src/imap_client.rs:85}" +nigig-core/src/imap_client.rs:85 +nigig-core/src/email_receipts.rs:90 +nigig-core/src/finance_report.rs:80}" WORK="$(mktemp -d "${TMPDIR:-/tmp}/email-coverage.XXXXXXXX")" cleanup() { @@ -98,7 +100,7 @@ chmod 700 "$WORK/rustup-init" printf 'running the email domain tests under instrumentation\n' cd "$ROOT" cargo test --locked -p nigig-core --lib -- \ - email_ secret:: mail_backend:: mail_proxy:: imap_client:: credential_store:: \ + email_ secret:: mail_backend:: mail_proxy:: imap_client:: credential_store:: finance_report:: \ >"$WORK/test.log" 2>&1 || { cat "$WORK/test.log"; exit 1; } grep -E 'test result' "$WORK/test.log" | tail -5 @@ -136,6 +138,8 @@ EMAIL_FILES=( "$ROOT/crates/nigig-core/src/email_cache.rs" "$ROOT/crates/nigig-core/src/email_session.rs" "$ROOT/crates/nigig-core/src/imap_client.rs" + "$ROOT/crates/nigig-core/src/email_receipts.rs" + "$ROOT/crates/nigig-core/src/finance_report.rs" ) printf '\n=== per-file coverage ===\n' @@ -166,7 +170,8 @@ with open(path) as fh: keep = ("email_account.rs", "email_send.rs", "email_store.rs", "email_worker.rs", "mail_backend.rs", "mail_proxy.rs", "secret.rs", "email_pacing.rs", "email_bulk.rs", "credential_store.rs", - "email_cache.rs", "email_session.rs", "imap_client.rs") + "email_cache.rs", "email_session.rs", "imap_client.rs", + "email_receipts.rs", "finance_report.rs") files = [f for f in data["data"][0]["files"] if f["filename"].endswith(keep)]