Expensa · Test run · run 2026-11-07
Expensa Test run, as shipped in the download
577 checks passed, 0 failed, when the suite that ships in the zip was run on 2026-11-07 with php tests/run.php. Run it yourself after you download.
== 1. moneyToCents: US, EU, symbols, negatives, raw numbers ==
PASS US "$1,234.56" (got 123456, want 123456)
PASS EU "1.234,56 €" (got 123456, want 123456)
PASS plain "1234.5" (got 123450, want 123450)
PASS thousands "1,234" (got 123400, want 123400)
PASS EU decimal "12,34" (got 1234, want 1234)
PASS "0.99" (got 99, want 99)
PASS "$0" (got 0, want 0)
PASS parens negative "(45.00)" (got -4500, want -4500)
PASS minus negative "-45.00" (got -4500, want -4500)
PASS float 1234.56 (got 123456, want 123456)
PASS int 1234 (major units) (got 123400, want 123400)
PASS "USD 89.90" (got 8990, want 8990)
PASS space thousands "1 234,56" (got 123456, want 123456)
PASS empty -> null (got NULL, want NULL)
PASS "N/A" -> null (got NULL, want NULL)
PASS null -> null (got NULL, want NULL)
== 2. centsToDecimal / money formatting ==
PASS 123456 -> "1234.56" (got '1234.56', want '1234.56')
PASS 5 -> "0.05" (got '0.05', want '0.05')
PASS -4500 -> "-45.00" (got '-45.00', want '-45.00')
PASS null -> "" (got '', want '')
PASS money 123456 grouped (got '1,234.56', want '1,234.56')
PASS money null -> dash (got '—', want '—')
== 3. normalizeDate: ISO, textual, numeric, ambiguity, invalid ==
PASS ISO passthrough (got '2026-04-03', want '2026-04-03')
PASS US m/d/Y (default) (got '2026-04-03', want '2026-04-03')
PASS d/m/Y with dayFirst (got '2026-03-04', want '2026-03-04')
PASS day>12 disambiguates (got '2026-04-13', want '2026-04-13')
PASS EU dotted 03.04.2026 (got '2026-03-04', want '2026-03-04')
PASS textual "Apr 3, 2026" (got '2026-04-03', want '2026-04-03')
PASS 2-digit year 4/3/26 (got '2026-04-03', want '2026-04-03')
PASS impossible -> null (got NULL, want NULL)
PASS garbage -> null (got NULL, want NULL)
PASS empty -> null (got NULL, want NULL)
PASS 3.3.0: date with its time (got array (
0 => '2026-03-04',
1 => '2026-03-04',
2 => '2026-03-04',
3 => '2026-03-04',
4 => '2026-03-04',
), want array (
0 => '2026-03-04',
1 => '2026-03-04',
2 => '2026-03-04',
3 => '2026-03-04',
4 => '2026-03-04',
))
PASS 3.3.0: an impossible time or a bare time is still not a date (got array (
0 => NULL,
1 => NULL,
2 => NULL,
), want array (
0 => NULL,
1 => NULL,
2 => NULL,
))
== 4. currencyCode from code or symbol ==
PASS code "usd" (got 'USD', want 'USD')
PASS symbol "€" (got 'EUR', want 'EUR')
PASS symbol "£" (got 'GBP', want 'GBP')
PASS empty -> "" (got '', want '')
== 5. normalizeCategory: free-text -> configured list, unknowns -> Other ==
PASS exact "Meals" (got 'Meals', want 'Meals')
PASS case-insensitive "meals" (got 'Meals', want 'Meals')
PASS synonym "Restaurant" (got 'Meals', want 'Meals')
PASS synonym "coffee" (got 'Meals', want 'Meals')
PASS synonym "hotel" (got 'Lodging', want 'Lodging')
PASS synonym "gas" (got 'Fuel', want 'Fuel')
PASS synonym "Uber ride" (got 'Travel', want 'Travel')
PASS synonym "SaaS subscription" (got 'Software', want 'Software')
PASS synonym "stationery" (got 'Office Supplies', want 'Office Supplies')
PASS two-word "office supplies" (got 'Office Supplies', want 'Office Supplies')
PASS unknown -> Other (got 'Other', want 'Other')
PASS empty -> Other (got 'Other', want 'Other')
PASS null -> Other (got 'Other', want 'Other')
PASS custom exact "Transport" (got 'Transport', want 'Transport')
PASS custom unknown -> last (got 'Misc', want 'Misc')
== 6. normalizePaymentMethod ==
PASS "VISA ****4242" -> Credit Card (got 'Credit Card', want 'Credit Card')
PASS "paid in cash" -> Cash (got 'Cash', want 'Cash')
PASS "Debit" -> Debit Card (got 'Debit Card', want 'Debit Card')
PASS "PayPal" -> PayPal (got 'PayPal', want 'PayPal')
PASS "ACH transfer" -> Bank Transfer (got 'Bank Transfer', want 'Bank Transfer')
PASS empty -> "" (got '', want '')
== 7. normalizeReceipt: raw LLM JSON -> canonical receipt ==
PASS vendor via "merchant" (got 'Blue Bottle Coffee', want 'Blue Bottle Coffee')
PASS txn_date normalized (got '2026-03-04', want '2026-03-04')
PASS currency from "$" (got 'USD', want 'USD')
PASS amount cents (got 1850, want 1850)
PASS tax cents (got 150, want 150)
PASS category snapped -> Meals (got 'Meals', want 'Meals')
PASS payment normalized (got 'Credit Card', want 'Credit Card')
PASS empty raw -> blank vendor (got '', want '')
PASS empty raw -> null date (got NULL, want NULL)
PASS empty raw -> null amount (got NULL, want NULL)
PASS empty raw -> Other category (got 'Other', want 'Other')
PASS empty raw -> blank payment (got '', want '')
PASS receipt day-first date (got '2026-03-04', want '2026-03-04')
== 8. aggregate: per-currency + per-category, currencies NEVER summed together ==
PASS currencies sorted (got array (
0 => 'EUR',
1 => 'USD',
), want array (
0 => 'EUR',
1 => 'USD',
))
PASS multi-currency flag set
PASS USD total = 3500 only (got 3500, want 3500)
PASS USD tax total = 350 (got 350, want 350)
PASS USD priced count = 3 (got 3, want 3)
PASS EUR total = 3000 (got 3000, want 3000)
PASS USD Meals subtotal 1500 (got 1500, want 1500)
PASS USD Meals count 2 (got 2, want 2)
PASS USD Travel subtotal 2000 (got 2000, want 2000)
PASS EUR Lodging subtotal 3000 (got 3000, want 3000)
PASS priced receipts = 4 (got 4, want 4)
PASS unpriced (pending) = 1 (got 1, want 1)
PASS no combined grand_total key
PASS USD total is not USD+EUR
PASS EUR total is not USD+EUR
== 9. aggregate: single currency, case-normalised, fallback ==
PASS single currency list (got array (
0 => 'USD',
), want array (
0 => 'USD',
))
PASS single currency flag false
PASS case-folded USD total (got 3500, want 3500)
PASS case-folded USD count (got 2, want 2)
PASS fallback currency used (got array (
0 => 'GBP',
), want array (
0 => 'GBP',
))
PASS empty report -> no currencies (got array (
), want array (
))
PASS empty report not multi-currency
== 10. toCsv: RFC-4180 escaping + decimals ==
PASS header row (got 'Date,Vendor,Category,Payment Method,Currency,Amount,Tax', want 'Date,Vendor,Category,Payment Method,Currency,Amount,Tax')
PASS vendor with comma+quote round-trips
PASS amount rendered as decimal (got '18.50', want '18.50')
PASS CRLF line endings
PASS row count (header + 2) (got 3, want 3)
PASS missing tax -> empty cell (got '', want '')
== 10b. toCsv neutralizes spreadsheet formula injection (CSV injection / DDE) ==
PASS vendor '=…' neutralized (leading ')
PASS category '@…' neutralized
PASS payment '+…' neutralized
PASS currency '-cmd' neutralized
PASS negative amount stays numeric (not quoted) (got '-45.00', want '-45.00')
PASS positive tax unchanged (got '10.00', want '10.00')
PASS ordinary vendor untouched (got 'Blue Bottle', want 'Blue Bottle')
PASS ordinary amount untouched (got '18.50', want '18.50')
== 11. status transitions: pending -> extracted -> reviewed ==
PASS rank pending (got 0, want 0)
PASS rank extracted (got 1, want 1)
PASS rank reviewed (got 2, want 2)
PASS rank unknown (got -1, want -1)
PASS extract pending -> extracted (got 'extracted', want 'extracted')
PASS review extracted -> reviewed (got 'reviewed', want 'reviewed')
PASS cannot review a pending receipt (got 'pending', want 'pending')
PASS review reviewed stays reviewed (got 'reviewed', want 'reviewed')
== 12. clip(): clamp user input to column width (MySQL STRICT-mode 500 guard) ==
PASS clips over-width to max (got 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', want 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')
PASS short string untouched (got 'Acme Co', want 'Acme Co')
PASS trims surrounding space (got 'hi', want 'hi')
PASS null -> empty (got '', want '')
PASS exact width kept (got 'USD', want 'USD')
PASS over-width currency clamped (got 'USD', want 'USD')
PASS multibyte-safe (no split char)
== LLM truncation guard ==
PASS truncated (openai/ollama length) -> clear error
PASS truncated (anthropic max_tokens) -> same error
PASS error names the 4096-token ceiling
PASS error tells the user what to DO (split the receipt)
PASS normal stop -> no error
PASS end_turn -> no error
PASS missing reason -> no error
PASS all three providers carry the raised cap (no stray literal)
PASS every provider path guards truncation before parsing
== AI model: current default, retired ids replaced, a refused model named ==
PASS the Anthropic default is the id Anthropic names as the replacement (read 2026-09-24) (got 'claude-haiku-4-5-20251001', want 'claude-haiku-4-5-20251001')
PASS the OpenAI and Ollama defaults are unchanged (got array (
0 => 'gpt-4o-mini',
1 => 'llama3.2-vision',
), want array (
0 => 'gpt-4o-mini',
1 => 'llama3.2-vision',
))
PASS a saved claude-3-haiku-20240307 (the old default) is replaced (got 'claude-haiku-4-5-20251001', want 'claude-haiku-4-5-20251001')
PASS a saved claude-3-haiku-20240307 becomes claude-haiku-4-5-20251001 (got 'claude-haiku-4-5-20251001', want 'claude-haiku-4-5-20251001')
PASS a saved claude-3-5-sonnet becomes claude-sonnet-4-6 (got 'claude-sonnet-4-6', want 'claude-sonnet-4-6')
PASS a current id is left as the owner typed it (got 'claude-sonnet-5', want 'claude-sonnet-5')
PASS claude-opus-4-5 is current and not caught by the retired opus-4-0/4-1 entries (got 'claude-opus-4-5-20251101', want 'claude-opus-4-5-20251101')
PASS an empty model falls back to the provider default (got 'claude-haiku-4-5-20251001', want 'claude-haiku-4-5-20251001')
PASS OpenAI ids are never rewritten (got 'gpt-4.1-mini', want 'gpt-4.1-mini')
PASS Anthropic refusing a model: the message names it, Settings and the current default
PASS OpenAI model_not_found is named the same way
PASS Ollama without the model: the message says to pull it
PASS a refused key says what to do, and keeps the provider text (3.2.0)
PASS out of credit, rate-limited, too large and a provider fault each get their own plain reason (3.2.0)
PASS anything else keeps the provider text as before (got 'LLM API error: bad param', want 'LLM API error: bad param')
PASS a reply that opens with a thinking block still yields its text (got '{"a":1}', want '{"a":1}')
PASS a refusal gets a plain message, not a JSON parse error
PASS every provider call resolves its model through resolveModel (no inline default left)
PASS provider errors pass through apiErrorMessage with the provider and model
PASS the Anthropic reply is read through anthropicText, not content[0]
PASS no retired Claude 3 id is offered by the installer, Settings or the settings save
PASS installer and settings save both resolve the model through Llm (retired ids stored as the replacement)
PASS Settings reads the one default list and says when a saved model was retired
PASS the upload page says, on the public demo only, that extraction there is a canned sample
== Load demo data keeps the owner's settings ==
PASS the seed check ran cleanly (no .demo-mode marker in the tree)
PASS loading demo data on a real install leaves the owner's company name, base currency and timezone as they were
PASS ...and still loads the demo records
== Invitations by email through your own SMTP ==
PASS the invite check ran cleanly
PASS the email names the organization, the app, the role, the link and the 7-day expiry
PASS a line break in the organization name cannot add a mail header
PASS with no SMTP server set, the box is not offered
PASS ...and a send is refused with a reason, not attempted
PASS with SMTP set, the box is offered
PASS an unreachable mail server comes back as a reported failure, never an exception
PASS a real send goes out through the owner's SMTP server
PASS the invitee's message carries their address, the link and the expiry
PASS the invite handler emails only when "Email the link" is ticked, and still shows the link once
PASS the public demo never sends an invitation
PASS the invite form offers "Email the link", ticked only when SMTP is set
== Backups and restore are for an administrator only ==
PASS the backup guard check ran cleanly
PASS a signed-in viewer is refused backup_json (403)
PASS a signed-in member is refused backup_json (403)
PASS a signed-in viewer is refused backup_sqlite (403)
PASS a signed-in member is refused backup_sqlite (403)
PASS a signed-in viewer is refused restore_page (403)
PASS a signed-in member is refused restore_page (403)
PASS a signed-in viewer is refused restore_upload (403)
PASS a signed-in member is refused restore_upload (403)
PASS a signed-in viewer is refused restore_commit (403)
PASS a signed-in member is refused restore_commit (403)
PASS a signed-in viewer is refused backup_token_mint (403)
PASS a signed-in member is refused backup_token_mint (403)
PASS an administrator still gets the backup download
PASS the scheduled backup answers to its token only, even for a signed-in administrator
PASS the Security page shows the Backups card only to someone allowed to download it
== PWA ==
PASS manifest.json is valid JSON
PASS manifest has required fields
PASS manifest URLs are RELATIVE (subpath-install safe)
PASS manifest icons exist on disk (192 + 512 + apple-touch)
PASS sw.js never caches non-GET
PASS sw.js never caches page navigations (auth HTML can not enter cache storage)
PASS sw.js cache name carries the version (old caches purged on activate)
PASS offline fallback page ships
PASS every app layout links the manifest + registers the SW
-- Own It 2.0 --
PASS v2: tables
PASS v2: totp_cols
PASS v2: smtp_cols
PASS v2: no_rival_login_table
PASS v2: upgrade_from_1x
PASS v2: mint
PASS v2: revoke
PASS v2: totp
PASS v2: recovery
PASS v2: delivery
PASS v2: events_are_expensas
PASS v2: rate
PASS v2: csv_dry
PASS v2: backup_tables
PASS v2: ladder_is_gated
PASS v2: report_state_is_weakest
PASS v2: report_state_completes
PASS v2: currencies_never_summed
PASS v2: serializer_matches_core
PASS v2: unpriced_not_zero
PASS v2: no_file_path_in_api
PASS v2: shared_normalisation
PASS v2: day_first_reaches_manual_paths
PASS v2: index.php calls Database::migrate() at boot — the ONLY upgrade path for an existing install
PASS v2: routes registered (api, 2fa, security, backup, import, healthz)
PASS v2: 2FA intercepts login + login is rate-limited
PASS v2: API reuses the tested core (field normalization, the review ladder, the aggregator)
PASS v2: each shared rule is defined ONCE, in the controller both callers use
PASS v2: the API never re-derives a total — Extraction::aggregate is the only summer
PASS v2: NO receipt file upload or file path over the API (documented limit)
PASS v2: no ternary key re-reads (PLAN item 10) — incl. llm_provider/timezone in settings_save
PASS v2: both extraction failure paths announce themselves through one helper
PASS v2: an import where every row is skipped leaves NO empty report behind
PASS the llm_api_key is redacted from the JSON backup (a live billable credential)
PASS v2: dark palette + zero hardcoded white backgrounds
PASS v2/kit: dark ink flip spares ghost/danger buttons (layout brand override) and native controls follow the theme (kit)
PASS v2: theme.js + qr.js ship and layout loads theme.js + has a toggle
PASS API.md ships + sw cache is v4.1.3
PASS v2: cli-server base guard present (PLAN item 9)
PASS v2: /backup.sqlite is carved out of the blanket .sqlite block in BOTH dev router and .htaccess
PASS v3 db proof ran clean — no fatal, no PHP warning
PASS migrate() adds the role column
PASS migrate() adds every 3.0 settings column
PASS migrate() adds dup_of + dup_dismissed to receipts
PASS migrate() creates audit_log + invites + saved_views + category_rules
PASS migrate() guarantees the settings singleton
PASS UPDATE settings ... WHERE id = 1 actually lands (the settings no-op relay)
PASS the singleton re-inserts at id=1, not the next autoincrement (invora's lesson)
PASS an UPGRADE path works: drop the 3.0 tables and migrate() rebuilds them
PASS the drop actually removed them first (the upgrade test is not vacuous)
PASS a viewer reads and exports reports
PASS a viewer cannot edit, upload or change a report
PASS a member builds reports
PASS a member cannot reach settings or the team
PASS an admin can
PASS an unknown role normalizes to the MOST restrictive
PASS the last active admin cannot be demoted
PASS vendor matching is normalized (case + whitespace)
PASS a duplicate is found ACROSS reports (the real filing case)
PASS a unique receipt is not flagged
PASS a receipt with no amount is never a duplicate (absence is not a match)
PASS ...nor one with no date
PASS FLAGGED, NEVER BLOCKED: the flagged receipt stays in the report
PASS ...and stays in the totals
PASS ...and is listed for the human to judge
PASS "not a duplicate" clears the flag
PASS ...without changing a single total
PASS editing away the collision clears a stale flag
PASS the install has a category list to validate against (not a vacuous check)
PASS a vendor rule is created
PASS ...keyed on the NORMALIZED vendor
PASS a rule can never introduce a category the install does not have
PASS a rule fills an EMPTY category
PASS ...and counts how often it was used
PASS a rule NEVER overwrites what the extractor read off the paper
PASS ...but does replace a fallback "Other"
PASS the shared update parses money through the product's own parser
PASS ...marks the receipt reviewed
PASS ...and normalizes the currency
PASS MCP exposes exactly the five tools the row asked for
PASS MCP has NO upload tool — receipt files stay in the browser
PASS an out-of-enum state lists the legal options
PASS an unknown argument is refused BY NAME
PASS a nested value is refused by the scalar guard
PASS GET /mcp is 405, not 404
PASS a viewer cannot write over MCP
PASS ...nor create a report
PASS ...but can read
PASS report_detail flags duplicates ADVISORY, in words
PASS ...and still returns the flagged receipt in the report
PASS a PARTIAL update does not blank the fields it did not mention
PASS an MCP write is audited with its channel
PASS category_totals agrees with Extraction::aggregate (no second adder)
PASS the report PDF renders
PASS money is formatted by the product's own helper
PASS audit detail redacts the llm_api_key
PASS the audit viewer exports CSV
PASS a scheduled backup writes and names the product
PASS a backup never carries the AI provider key
PASS a backup carries no bcrypt hash
PASS both backup callers share ONE redaction list
PASS a backup parses back
PASS another product's backup is refused
PASS the dry-run diff is zero against itself
PASS a restore brings a deleted row back
PASS a restore does not overwrite a live secret with [REDACTED]
PASS prune deletes exactly the overflow
PASS accent() validates hex and rejects junk
PASS the ink on an accent is derived and AA-safe
PASS there is no anonymize path (export only, by design)
PASS OIDC state is unguessable per request
PASS migrate() is called at boot
PASS every 3.0 core is required in index.php (a missing one is fatal on the first MCP call)
PASS Ical.php is deliberately NOT shipped (P6 N/A for this product)
PASS the edit form delegates to the shared _apply_receipt_update
PASS the edit form writes no receipt UPDATE of its own
PASS the MCP write goes through the same shared path
PASS bulk loops the single-receipt path, never a mass UPDATE
PASS both signature features hook the ONE extraction funnel
PASS the duplicate check never deletes or hides a receipt
PASS nothing in the 3.0 layer re-adds money — totals come from Extraction::aggregate
PASS GET /mcp is routed and answered before the key check
PASS no POST /reports/{id} route exists that could shadow /reports/{id}/bulk
PASS /reports/{id}/export.pdf now serves the REAL pdf renderer
PASS the print-friendly HTML view lives on a URL that does not claim to be a PDF
PASS healthz + api_ping + openapi all report 4.1.3
PASS no 2.0.0 version string survives in sw.js or the API layer
PASS the shareable backup redacts through the shared list, not a local literal
PASS the API settings page no longer claims approval guards this product does not have
PASS Webhook.php documents EXPENSA's events, not another product's
PASS and the documented events match the constant that is actually fired
PASS the layout brands the accent in BOTH :root and the dark selector
PASS ...and overrides the dark button ink, which style.css hard-codes at higher specificity
PASS the layout derives --accent-ink rather than trusting a stored column
PASS the theme toggle is not nested inside the logout button
PASS the report view has no nested <form> (bulk uses the HTML5 form attribute)
PASS ...and the bulk checkboxes are wired to the form below the table
PASS the duplicate flag has a warning style (amber, kit .pill.warn), not the failure red
PASS ...and the report view actually uses it
PASS README documents "New in 3.0"
PASS API.md documents the MCP endpoint and its five tools
PASS API.md says receipt FILES cannot be uploaded through MCP
PASS the deploy kit ships both files
PASS no {PLACEHOLDER} survives anywhere in the deploy kit
PASS compose.yml names expensa, its port and its own volume
PASS the healthcheck PARSES the healthz JSON instead of grepping its formatting
PASS core Mcp.php is byte-identical to the shared core
PASS core AuditLog.php is byte-identical to the shared core
PASS core Pdf.php is byte-identical to the shared core
PASS core BackupRestore.php is byte-identical to the shared core
PASS core Branding.php is byte-identical to the shared core
PASS core Oidc.php is byte-identical to the shared core
PASS core RolesKit.php is byte-identical to the shared core
PASS v3/C2: minting an API key is admin-only — it mints for a POSTed user_id (escalation shape)
PASS v3/C2: the backup routes and the line importer are gated, not merely signed-in
PASS v3/C2: no route outside the user's own account is left on a bare requireLogin
PASS v3/C2: the gate that keeps the bookkeeper seat out does not lock the ADMIN out
PASS v3/C2: /backup.sqlite is an app ROUTE and the dev router does not 403 it
PASS v3/C2: bin/demo.php can run — migrate() calls AuditLog::ensure()
PASS nav: every sidebar link is highlighted by the page it opens
PASS nav: no page highlights a sidebar item that does not exist
PASS ip: a direct visitor's forged headers are ignored
PASS ip: behind a trusted proxy the forwarded visitor is used
PASS ip: CF-Connecting-IP wins over the X-Forwarded-For chain
PASS ip: the chain is read right-to-left, past trusted hops
PASS ip: a Cloudflare edge is not a trusted proxy by default
PASS ip: CIDR maths (v4 boundaries + v6, no cross-family match)
== UI kit: report builder + approve endpoint + dashboard ==
PASS kit: layout loads the kit css+js, groups the nav (>=3 groups), keeps the theme toggle and a who-line
PASS kit: no theme.js; style.css is an app layer with a dark accent; pre-kit scratch file removed
PASS kit: App::asset() is versioned by mtime and the service worker revalidates code
PASS kit: no font or script is fetched from another host (fonts bundled)
PASS routes: POST /receipts/{id}/approve is registered and the handler is gated on receipt.approve (3.2.0: never a claimant's) + CSRF
PASS approve/permission: the viewer seat cannot reach the endpoint, member and admin can
PASS dashboard: first-run checklist + needs-your-action are computed from the DATABASE, not the session
PASS report builder: split pane + inline approve are wired (rc-data JSON, rpane, report.js, bulkform preserved)
[DB: SQLite]
PASS approve/happy: an extracted receipt approves to reviewed and the row persists it
PASS approve/idempotent: approving an approved receipt stays reviewed and reports ok
PASS approve/refused: a pending (unread) receipt is refused with a human sentence and is not touched
PASS approve/report state: the report is NOT reviewed while a receipt is still pending
PASS approve/report state: with every receipt reviewed the report state follows
== v3.1: export presets + claimant email ==
PASS P6/happy: the QuickBooks preset writes exactly Date,Description,Amount (got 'Date,Description,Amount', want 'Date,Description,Amount')
PASS P6/happy: the Xero preset writes its documented five columns (got 'Date,Amount,Payee,Description,Reference', want 'Date,Amount,Payee,Description,Reference')
PASS P6/compat: the generic preset is byte-identical to the pre-v3.1 export (got 'Date,Vendor,Category,Payment Method,Currency,Amount,Tax
2026-03-04,Blue Bottle,Meals,Credit Card,USD,12.75,1.00
2026-03-06,,,,USD,,
', want 'Date,Vendor,Category,Payment Method,Currency,Amount,Tax
2026-03-04,Blue Bottle,Meals,Credit Card,USD,12.75,1.00
2026-03-06,,,,USD,,
')
PASS P6/refusal: an unknown preset falls back to generic rather than inventing columns (got 'Date,Vendor,Category,Payment Method,Currency,Amount,Tax
2026-03-04,Blue Bottle,Meals,Credit Card,USD,12.75,1.00
2026-03-06,,,,USD,,
', want 'Date,Vendor,Category,Payment Method,Currency,Amount,Tax
2026-03-04,Blue Bottle,Meals,Credit Card,USD,12.75,1.00
2026-03-06,,,,USD,,
')
PASS P6/permission: the export route is still gated on report.export, not merely logged in
PASS P6/sign: QuickBooks rows carry spend as a negative amount (got '2026-03-04,Blue Bottle — Meals,-12.75', want '2026-03-04,Blue Bottle — Meals,-12.75')
PASS P6/format: Xero gets dd/mm/yyyy and the reference column (got '04/03/2026,-12.75,Blue Bottle,Blue Bottle — Meals,RCPT-9', want '04/03/2026,-12.75,Blue Bottle,Blue Bottle — Meals,RCPT-9')
PASS P6/edge: a receipt with no amount exports EMPTY, never 0.00 (a zero would import as a real transaction)
PASS P6/safety: presets reuse Extraction::csvField, so formula injection is neutralized everywhere
PASS P1/permission: master switch off → nothing sent, nothing recorded
PASS P1/permission: the per-event flag gates too — the master switch alone sends nothing
PASS P1/refusal: a report with no submitter address is refused with a reason
PASS P1/refusal: unconfigured SMTP refuses honestly and writes no outbox row
PASS P1/happy: an enabled review email is built, recorded and reported
PASS P1/content: the mail names the report and its totals, says reviewed != paid, and leaks no secrets
PASS P1/idempotency: calling reviewed() twice records two attempts and never throws — the review always completes
PASS v3.1: the claimant switches default to OFF in the schema
PASS scope: api_keys gains a scope column defaulting to full — an upgrade never loosens
PASS scope: requireKey reads the key scope and refuses a write on a read-only key
PASS scope: mintKey persists the chosen scope, and anything but read is full access
PASS scope: /mcp hands the key scope to the core, which refuses write tools per tool
PASS scope: the key form lets an administrator mint a read-only key
PASS license: every font ships the SIL OFL text for its own family beside it
PASS license: LICENSE.txt is present and carries the IF WE STOP (CONTINUITY) clause
-- F2b: the demo card, the webhook buttons and events, the JSON backup --
PASS demo card: /reports keeps the admin wording
PASS demo card: /reports/1 keeps the admin wording
PASS demo card: /login keeps the admin wording
PASS demo card: below 600px the script starts it as the small "Own it" pill unless the visitor opened it this session
PASS demo card: output without </body> (JSON, CSV, a PDF) passes through untouched
PASS forms: every POST form on a screen reaches a registered route (44 checked)
PASS webhooks: the Settings page answers and lists every event it offers
PASS webhooks: the Add, Pause, Resume and Delete buttons each reach a route and do what they say
PASS webhooks: Delete also removes that webhook's delivery log, as its confirmation says
PASS webhooks: a paused webhook is sent nothing
PASS webhooks: receipt.reviewed is sent by the action where it happens [302]
PASS webhooks: report.reviewed is sent by the action where it happens [302]
PASS webhooks: extraction.failed is sent by the action where it happens [302]
PASS webhooks: report.paid is sent by the action where it happens [302]
PASS webhooks: every event the Settings page offers is sent somewhere
PASS webhooks: correcting a receipt in a report that is already reviewed does not announce the report again
PASS webhooks: each correction is still announced as a reviewed receipt
PASS backup redaction: a credential was planted in every credential column (14)
PASS backup redaction: /backup.json and the scheduled backup both use the one list, v3_backup_redact()
PASS backup redaction: /backup.json (downloaded by an administrator) carries none of the planted credentials
PASS backup redaction: /backup.json (downloaded by an administrator) shows [REDACTED] in each credential column and leaves none unredacted
PASS backup redaction: the scheduled backup file carries none of the planted credentials
PASS backup redaction: the scheduled backup file shows [REDACTED] in each credential column and leaves none unredacted
PASS backup redaction: the administrator got the download (HTTP 200)
== 3.2.0: Sonnet 5 suggested for messy or handwritten scans ==
PASS the Anthropic default is unchanged (claude-haiku-4-5-20251001) (got 'claude-haiku-4-5-20251001', want 'claude-haiku-4-5-20251001')
PASS the Anthropic model list offers claude-sonnet-5
PASS every provider's list starts with that provider's default
PASS the hint beside the model field says "Sonnet 5 for messy or handwritten scans"
PASS the model field offers the list (a datalist filled from Llm::SUGGESTED_MODELS)
PASS the model label points at the model field (one id, not two)
PASS the security page says the JSON backup leaves out webhook URLs
== 3.2.0: mileage and per-diem rates (the owner's own) ==
PASS rates are read one per line; notes, blanks and unusable lines skipped (got array (
0 => 'Car',
1 => 'Van',
2 => 'Lorry',
), want array (
0 => 'Car',
1 => 'Van',
2 => 'Lorry',
))
PASS a decimal comma and a currency sign are read (got array (
0 => 45,
1 => 55,
2 => 110,
), want array (
0 => 45,
1 => 55,
2 => 110,
))
PASS the unit is mile unless km is written (got array (
0 => 'mile',
1 => 'km',
2 => 'mile',
), want array (
0 => 'mile',
1 => 'km',
2 => 'mile',
))
PASS per-diem rates carry no unit (got array (
0 =>
array (
'label' => 'Overnight away',
'rate_cents' => 2500,
'unit' => '',
),
), want array (
0 =>
array (
'label' => 'Overnight away',
'rate_cents' => 2500,
'unit' => '',
),
))
PASS 42.5 miles is held as 42500 thousandths (got 42500, want 42500)
PASS a zero or negative quantity is refused
PASS amount = distance × rate, half up (42.5 × 0.55 = 23.375 → 23.38) (got 2338, want 2338)
PASS 2.5 days × 25.00 = 62.50 (got 6250, want 6250)
PASS the line says what it is (got 'Mileage — Van: 42.5 mi × 0.55', want 'Mileage — Van: 42.5 mi × 0.55')
PASS ...and one day is "day" (got 'Per diem — Day trip: 1 day × 10.00', want 'Per diem — Day trip: 1 day × 10.00')
== 3.2.0: the claimant role and the finer permissions ==
PASS viewer and member keep every grant they had in 3.1
PASS each permission a route moved to is granted wherever the old one was (no role loses a screen)
PASS a claimant can create reports, add and correct lines, extract, view and export
PASS a claimant can never approve, pay, edit rules, search GDPR, read the audit trail, import, or change settings or the team
PASS every report route loads its report through the ownership check
PASS a claimant's save never approves (the edit passes the approve permission as the review flag)
PASS the invite form and the Team page offer the claimant role
== 3.2.0: database proofs (tests/v320_db_check.php) ==
PASS the database proof ran cleanly
PASS migrate() adds owner, paid, line and rate columns
PASS the paid email is off until switched on
PASS demo data writes example rates only where the owner has none
PASS demo: a fully approved report, marked paid with a reference
PASS demo: this month's mileage and per-diem lines, two waiting for approval
PASS demo: every line is quantity × rate
PASS the paid email says the date, the reference and the total
== 3.2.0: claimants, lines and paid over HTTP (tests/v320_http_check.php) ==
PASS the HTTP proof ran (no PHP warning logged)
PASS a claimant signs in
PASS a claimant's home is their own reports
PASS the report list shows a claimant only their own
PASS a claimant opens their own report
PASS EVERY report and receipt route answers 404 on a colleague's or the office's report (12 routes × 2 reports)
PASS ...and nothing on those reports changed
PASS approve, bulk, dismiss, pay, delete, rules, GDPR, audit, import, settings, team, API, backup: 403 for a claimant
PASS a claimant's own receipt stays "to approve"
PASS a claimant's new report is theirs, and they are its submitter whatever the form said
PASS the report offers mileage and per-diem lines from the owner's rates
PASS a mileage line: 42.5 mi × 0.55 = 23.38, Travel, with its route note, to approve
PASS a per-diem line: 2.5 days × 25.00 = 62.50
PASS a negative distance or an unknown rate adds nothing
PASS the upload page offers the phone camera (several shots, one upload)
PASS a claimant uploads photos to their own report
PASS W5 MCP-door pass: a claimant's REST key lists, opens and adds lines to their own reports only (404 on a colleague's, nothing added); their line waits for approval
PASS W5 MCP-door pass: a report a claimant creates over REST is theirs
PASS W5 MCP-door pass: over MCP a claimant lists, opens and totals their own reports only, cannot edit a colleague's receipt, creates their own report, and a correction is not an approval
PASS next wave: the claim link is off until switched on; then someone with no account files a claim (name needed; a text file refused) as a new report in their name, waiting
PASS next wave: a form-filling robot files nothing
PASS next wave: the claim's report says it came through the claim link, and from whom
PASS round 4: an export from another expense app ("Timestamp" with the time, "Merchant", "Amount") imports; an impossible time is skipped
PASS next wave: a new link retires the old one; off closes it
PASS customer voice: "All receipts (ZIP)" holds every photo (stored, CRC right) and an index listing the mileage line as having no receipt
PASS a claimant's correction does not approve the line
PASS a claimant can take back a line not yet approved
PASS a viewer sees every report but cannot mark one paid
PASS a report with lines to approve cannot be marked paid
PASS a fully approved report offers Mark paid
PASS Mark paid records the date, the reference and who recorded it
PASS the claimant's paid email is attempted and recorded on the report
PASS the Paid tab lists it
PASS a paid report's lines are closed: no new line, upload, edit or delete
PASS Mark unpaid reopens it
PASS the Team page offers the claimant role
PASS Settings shows how many rates it read, and the paid-email switch
PASS pass B: lines over a per-line or per-day limit are flagged "over policy", with the reason
PASS pass B: "Approve within policy" approves only lines under every limit (not the over-limit, other-currency or over-the-day ones)
PASS pass B: Settings takes the limits and says how many categories have one
== 3.2.0 pass B: spending limits per category (pure) ==
PASS limits parse per category, per line and per day; unusable lines skipped (got array (
'meals' =>
array (
'category' => 'Meals',
'line' => 5000,
'day' => 7500,
),
'lodging' =>
array (
'category' => 'Lodging',
'line' => 18000,
'day' => NULL,
),
), want array (
'meals' =>
array (
'category' => 'Meals',
'line' => 5000,
'day' => 7500,
),
'lodging' =>
array (
'category' => 'Lodging',
'line' => 18000,
'day' => NULL,
),
))
PASS two 40.00 meals on one day break the 75.00 day limit (each line says so) (got array (
0 => 'over',
1 => 'over',
), want array (
0 => 'over',
1 => 'over',
))
PASS ...and name the day and its total
PASS a 60.00 meal breaks the 50.00 line limit (got 'over', want 'over')
PASS another currency is not compared, and says why (got array (
0 => 'unchecked',
1 => true,
), want array (
0 => 'unchecked',
1 => true,
))
PASS no amount yet is unchecked; no rule for the category is none (got array (
0 => 'unchecked',
1 => 'none',
), want array (
0 => 'unchecked',
1 => 'none',
))
PASS a flag, never a block: the approve paths do not consult the policy
== 3.2.0 customer voice: every receipt in one ZIP (pure) ==
PASS a ZIP: three stored entries, the central directory where the end record says
PASS names inside the ZIP carry no directory or control characters (got array (
0 => '-.-etc-passwd',
1 => 'a-b-c',
2 => 'file',
), want array (
0 => '-.-etc-passwd',
1 => 'a-b-c',
2 => 'file',
))
PASS the traversal name was written safe
PASS api roles: POST /api/reports refuses every role without the permission (403) and admits the rest
PASS api roles: POST /api/reports/{id}/lines refuses every role without the permission (403) and admits the rest
PASS api roles: POST /api/receipts/{id}/status refuses every role without the permission (403) and admits the rest
————————————————————————————————————————————————————
== 4.0: the Update button is wired ==
PASS updates: the release public key is pinned
PASS updates: Database::migrate() creates the updater table (in code, not in a comment)
PASS updates: the updater table holds exactly one state row
PASS updates: index.php clears stale compiled code before anything else loads
PASS updates: index.php finishes or rolls back an update before any controller loads, with this app's canary
PASS updates: route get /updates is updates_page()
PASS updates: route post /updates/key is updates_key_save()
PASS updates: route post /updates/check is updates_check()
PASS updates: route post /updates/apply is updates_apply()
PASS updates: updates_page() opens with the restore page's admin gate
PASS updates: updates_key_save() opens with the restore page's admin gate
PASS updates: updates_check() opens with the restore page's admin gate
PASS updates: updates_apply() opens with the restore page's admin gate
PASS updates: every POST checks the CSRF token
PASS updates: the page is told why updates are off here (Updater::whyOff, F5 A1 pass)
PASS updates: the menu links to the page
PASS updates: bin/update.php refuses the web and finishes in a fresh process
PASS updates: where updates are off (Cloud, the demo, a missing extension) the page says why and offers no button
PASS updates: with a key and a newer signed release the page offers "Back up and update"
== 4.0 A2: sample data, backups, health ==
PASS A2: the owner's-install checks ran to the end
PASS A2: an owner's own install (no Cloud config, no demo marker)
PASS A2: a fresh install's sample tables are all empty, so the installer's sample data can go in
PASS A2: switched off: an install without "Start with sample data" gets none
PASS A2: first request: the marker is used once and the sample business is there
PASS A2: every one of the owner's settings is theirs, not the sample's
PASS A2: the sample logins cannot sign in: no password matches, and switched off where the product can
PASS A2: the owner's own login is untouched
PASS A2: the load is in the audit log
PASS A2: a second marker on a Expensa with records seeds nothing and changes nothing
PASS A2: real use: one of the owner's own rows among the sample
PASS A2: remove: the owner's own row stays, and a sample row it points at is kept
PASS A2: remove: the report says what went, and what was kept because the owner's records use it
PASS A2: remove: the rest of the sample is gone
PASS A2: remove: every kept sample row keeps all its own parts
PASS A2: remove: no row is left pointing at a row that was removed
PASS A2: remove: the sample logins are gone but the ones kept rows use, the owner stays, and nothing counts as sample any more
PASS A2: remove: the sample's files are gone from disk (0 written; one a row that stays names stays with it)
PASS A2: remove: the owner's own uploaded file is untouched
PASS A2: "Load demo data" on a Expensa with records refuses and deletes nothing
PASS A2: backup: written, sent to the bucket (a signed PUT), and read back
PASS A2: backup: the bucket secret is not in the backup it protects
PASS A2: backup: under the standard keep count (14) every backup is kept
PASS A2: switched off: with automatic backups off, a backup is never due however old the last one
PASS A2: the backup lines for Backup & restore: last backup fine, offsite fine, read-back fine
PASS A2: an offsite endpoint must be https for an owner (plain http only in tests)
PASS A2: all 25 owner's-install checks reported
PASS A2: Database::migrate() creates the sample-data and backup tables (in code, not in a comment)
PASS A2: index.php loads the three cores and the wiring
PASS A2: the first request seeds the installer's sample, and an owner's install backs itself up when due
PASS A2: route post /sample-data/remove is sample_data_remove()
PASS A2: route post /restore/backups is backups_save()
PASS A2: route post /restore/backup-now is backup_now()
PASS A2: route get /health is health_page()
PASS A2: sample_data_remove() opens with the restore page's admin gate
PASS A2: backups_save() opens with the restore page's admin gate
PASS A2: backup_now() opens with the restore page's admin gate
PASS A2: health_page() opens with the restore page's admin gate
PASS A2: every POST checks the CSRF token
PASS A2: off an owner's install (Cloud, the demo) the backup settings and "Back up now" refuse in words
PASS A2: the installer offers sample data, ticked, and only leaves the first-run marker (with every table's row count)
PASS A2: the first run is strictly once (a marker that cannot be removed seeds nothing), and treats the installer's rows as its defaults only while nothing has changed
PASS A2: the scheduled-backup URL runs the same backup (offsite copy and read-back included)
PASS A2: "Load demo data" outside the public demo goes through SampleData (only into an empty Expensa, removable)
PASS A2: the menu links to Health
PASS A2: the sample-data banner shows only to an admin, only while sample data is there, with its Remove button, and stays put (nested: the UI kit toasts a bare .main > .alert away)
PASS A2: Backup & restore shows the automatic backups, "Back up now" and the bucket settings (none on Cloud)
SKIP A2: the Health page render (this suite loads no view helpers in its own process; the owner's-install check and update_e2e.sh run the real app)
PASS 4.1.1: the Subject data export page says plainly what is kept: everything, there is no erase
PASS 4.1.1: MANUAL.md says the same
ALL 577 TESTS PASSED (0 failed)
577 assertions: 577/577 PASS, 0 FAIL
← Back to Expensa · Manual · Quickstart · API