Compare commits
4 commits
e0d86274d8
...
6bf138d027
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bf138d027 | |||
| 074066a382 | |||
| ee61546c2c | |||
| a935133bb4 |
11 changed files with 1224 additions and 40 deletions
|
|
@ -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
3
Cargo.lock
generated
|
|
@ -3038,6 +3038,9 @@ dependencies = [
|
|||
"lettre",
|
||||
"makepad-widgets",
|
||||
"matrix_client",
|
||||
"nigig-pdf-cos",
|
||||
"nigig-pdf-document",
|
||||
"nigig-pdf-graphics",
|
||||
"nigig-system-prefs",
|
||||
"postcard",
|
||||
"proptest",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
51
crates/nigig-core/examples/trip_report_sample.rs
Normal file
51
crates/nigig-core/examples/trip_report_sample.rs
Normal 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),
|
||||
);
|
||||
}
|
||||
490
crates/nigig-core/src/email_receipts.rs
Normal file
490
crates/nigig-core/src/email_receipts.rs
Normal 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 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<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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
478
crates/nigig-core/src/finance_report.rs
Normal file
478
crates/nigig-core/src/finance_report.rs
Normal 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, ¤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<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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue