Expensa · Test run · run 2026-09-25
Expensa Test run, as shipped in the download
419 checks passed, 0 failed, when the suite that ships in the zip was run on 2026-09-25 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)
== 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 any other provider error keeps the provider text (got 'LLM API error: invalid x-api-key', want 'LLM API error: invalid x-api-key')
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 organisation, the app, the role, the link and the 7-day expiry
PASS a line break in the organisation 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 normalisation, 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 v3.1.5
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 normalised (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 NORMALISED 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 normalises 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 anonymise 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 3.1.5
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.edit + 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 neutralised 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 licence: every font ships the SIL OFL text for its own family beside it
PASS licence: 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 (30 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: 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 (10)
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)
————————————————————————————————————————————————————
ALL 419 TESTS PASSED (0 failed)
419 assertions: 419/419 PASS, 0 FAIL
← Back to Expensa · Manual · Quickstart · API