Compare commits

...

4 commits

Author SHA1 Message Date
6bf138d027 ci(email): cover the trip-report modules
Some checks failed
email.yml / ci(email): cover the trip-report modules (push) Failing after 0s
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
doc-engine / engine (push) Successful in 18s
doc-engine / coverage (push) Successful in 30s
doc-engine / consumer (push) Successful in 4m58s
nigig-map / test (push) Failing after 2m18s
sms / gates (push) Successful in 3s
sms / robius-sms (push) Failing after 11m46s
sms / android (push) Successful in 1m48s
sms / nigig-sms (push) Successful in 5m42s
sms / supply-chain (push) Successful in 7s
The domain test filter and floor (225) now include finance_report and
email_receipts, and test-email-coverage.sh instruments both new files.
Domain tests 216 -> 234; coverage 90.6% over 15 files. The review doc
records the new feature.
2026-08-17 12:08:19 +00:00
074066a382 feat(email): export the trip report from the inbox
spawn_export_trip_report fetches the inbox (shared fetch_inbox_messages
helper), extracts trip receipts, builds the report, and writes
trip-expense-report.pdf into app-data; it posts TripReportExported with the
path and a summary. The More page gains a Finance card with an
'Export trip report (PDF)' button and a status line.
2026-08-17 12:08:19 +00:00
ee61546c2c feat(email): trip-expense report PDF for the finance department
finance_report.rs renders TripReceipts into a self-contained PDF with the
nigig PDF stack (nigig-pdf-graphics, base-14 Helvetica): a summary table of
dates and amounts with a total row, then one receipt block per trip, A4
with pagination and a bookmark outline. build_trip_report is pure and its
tests read the output back through PdfDocument, asserting page sizes,
dates, amounts and the total landed. Native-only (gated behind
not(wasm32)), so the wasm build stays free of the PDF dependency. 6 tests,
plus a runnable example.
2026-08-17 12:08:19 +00:00
a935133bb4 feat(email): trip-receipt extraction (Bolt ride receipts)
email_receipts.rs turns inbox messages into a TripReceipt — date, amount,
currency, route, receipt id — for the finance department's expense report.
Sender detection (bolt/uber/taxify), a tolerant currency+amount finder
(total > fare > amount priority, comma/space grouping, KSh→KES), a date
extractor (ISO, d/m/y, '17 Aug 2026', 'Aug 17, 2026', with the email
timestamp as fallback), and label-prefix value extraction for the route and
receipt id. A message without an amount is not a receipt. 12 host tests over
realistic fixtures.
2026-08-17 12:08:19 +00:00
11 changed files with 1224 additions and 40 deletions

View file

@ -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

3
Cargo.lock generated
View file

@ -3038,6 +3038,9 @@ dependencies = [
"lettre",
"makepad-widgets",
"matrix_client",
"nigig-pdf-cos",
"nigig-pdf-document",
"nigig-pdf-graphics",
"nigig-system-prefs",
"postcard",
"proptest",

View file

@ -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.

View file

@ -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 {

View file

@ -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"

View file

@ -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),
);
}

View file

@ -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<TripReceipt> {
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<TripReceipt> {
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<String> {
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<String> {
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<f64> {
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::<f64>() {
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 23 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<String> = 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<String> {
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");
}
}

View file

@ -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<Vec<crate::email_store::EmailMessage>, 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<Vec<crate::email_store::EmailMessage>, 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<TripReportExport, String> {
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<TripReportExport, String>),
}
#[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()

View file

@ -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<u8>,
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, &currency, 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<u8> {
// 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<TripReceipt> {
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<TripReceipt> = (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);
}
}

View file

@ -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;

View file

@ -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)]