Home›Expensa›Test run
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