diff --git a/.forgejo/workflows/email.yml b/.forgejo/workflows/email.yml index c05e458..8559fec 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:: finance_report::" + run: "cargo test --locked -p nigig-core --lib -- email_ secret:: mail_backend:: mail_proxy:: imap_client:: credential_store::" # 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; 234 after the trip-receipt extraction and finance report. + # tests. - name: The email domain test suite must not shrink run: | set -euo pipefail - FLOOR=225 - out="$(cargo test --locked -p nigig-core --lib -- email_ secret:: mail_backend:: mail_proxy:: imap_client:: credential_store:: finance_report:: 2>&1)" + FLOOR=210 + out="$(cargo test --locked -p nigig-core --lib -- email_ secret:: mail_backend:: mail_proxy:: imap_client:: credential_store:: 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 26edcc5..1ab0d82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3038,9 +3038,6 @@ 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 4272450..ac748e4 100644 --- a/REVIEWS/NIGIG_EMAIL_ASSESSMENT_AND_PLAN.md +++ b/REVIEWS/NIGIG_EMAIL_ASSESSMENT_AND_PLAN.md @@ -549,7 +549,6 @@ 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 fc8cefe..b1b3031 100644 --- a/crates/apps/nigig-email/src/email_frame/pages/more.rs +++ b/crates/apps/nigig-email/src/email_frame/pages/more.rs @@ -77,39 +77,6 @@ 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 } } - } - } } } } @@ -143,36 +110,6 @@ 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 eb6dac7..c78eaa8 100644 --- a/crates/nigig-core/Cargo.toml +++ b/crates/nigig-core/Cargo.toml @@ -58,11 +58,6 @@ 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 @@ -80,9 +75,6 @@ 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 deleted file mode 100644 index 65ef38f..0000000 --- a/crates/nigig-core/examples/trip_report_sample.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! 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 deleted file mode 100644 index 798398d..0000000 --- a/crates/nigig-core/src/email_receipts.rs +++ /dev/null @@ -1,490 +0,0 @@ -//! 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 4f90c79..2693089 100644 --- a/crates/nigig-core/src/email_worker.rs +++ b/crates/nigig-core/src/email_worker.rs @@ -135,121 +135,43 @@ 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 = fetch_inbox_messages(settings, secret).await; + 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(), + ) + } + } + }; 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 @@ -989,10 +911,6 @@ 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)] @@ -1660,7 +1578,7 @@ mod tests { fn a_self_signed_certificate_is_rejected() { use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; - use tokio::io::AsyncWriteExt; + use tokio::io::{AsyncReadExt, 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 deleted file mode 100644 index 8df7d5f..0000000 --- a/crates/nigig-core/src/finance_report.rs +++ /dev/null @@ -1,478 +0,0 @@ -//! 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 aa61973..bc3225d 100644 --- a/crates/nigig-core/src/lib.rs +++ b/crates/nigig-core/src/lib.rs @@ -31,9 +31,6 @@ 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 08df53c..5f59cbb 100755 --- a/tools/test-email-coverage.sh +++ b/tools/test-email-coverage.sh @@ -57,9 +57,7 @@ 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/email_receipts.rs:90 -nigig-core/src/finance_report.rs:80}" +nigig-core/src/imap_client.rs:85}" WORK="$(mktemp -d "${TMPDIR:-/tmp}/email-coverage.XXXXXXXX")" cleanup() { @@ -100,7 +98,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:: finance_report:: \ + email_ secret:: mail_backend:: mail_proxy:: imap_client:: credential_store:: \ >"$WORK/test.log" 2>&1 || { cat "$WORK/test.log"; exit 1; } grep -E 'test result' "$WORK/test.log" | tail -5 @@ -138,8 +136,6 @@ 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' @@ -170,8 +166,7 @@ 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_receipts.rs", "finance_report.rs") + "email_cache.rs", "email_session.rs", "imap_client.rs") files = [f for f in data["data"][0]["files"] if f["filename"].endswith(keep)]