Home›Ledgira›Test run
Ledgira · Test run · run 2026-11-07

Ledgira Test run, as shipped in the download

529 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. reused money/date parsing (Extraction) ==
  PASS  US "$1,234.56" (got 123456, want 123456)
  PASS  EU "1.234,56 €" (got 123456, want 123456)
  PASS  parens negative (got -4500, want -4500)
  PASS  minus negative (got -145000, want -145000)
  PASS  date m/d/Y (got '2024-06-01', want '2024-06-01')
  PASS  date ISO passthrough (got '2024-06-30', want '2024-06-30')
  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,
))
  PASS  cents -> decimal (neg) (got '-1450.00', want '-1450.00')
  PASS  cents -> decimal (got '975.50', want '975.50')

== 2. normalizeDirection ==
  PASS  "debit" (got 'debit', want 'debit')
  PASS  "Credit " trims/cases (got 'credit', want 'credit')
  PASS  "DR" -> debit (got 'debit', want 'debit')
  PASS  "cr" -> credit (got 'credit', want 'credit')
  PASS  "withdrawal" -> debit (got 'debit', want 'debit')
  PASS  "deposit" -> credit (got 'credit', want 'credit')
  PASS  "debit transaction" pfx (got 'debit', want 'debit')
  PASS  empty -> null (got NULL, want NULL)
  PASS  garbage -> null (got NULL, want NULL)

== 3. signedCents: debit negative, credit positive ==
  PASS  debit 45.00 -> -4500 (got -4500, want -4500)
  PASS  credit 45.00 -> +4500 (got 4500, want 4500)
  PASS  credit overrides printed minus (got 4500, want 4500)
  PASS  debit overrides printed plus (got -4500, want -4500)
  PASS  no dir keeps parens-negative (got -4500, want -4500)
  PASS  no dir keeps plain positive (got 4500, want 4500)
  PASS  DR token -> negative (got -4500, want -4500)
  PASS  CR token -> positive (got 123456, want 123456)
  PASS  empty amount -> null (got NULL, want NULL)

== 4. directionOf ==
  PASS  negative -> debit (got 'debit', want 'debit')
  PASS  positive -> credit (got 'credit', want 'credit')
  PASS  zero -> credit (got 'credit', want 'credit')

== 5. last4 ==
  PASS  masked "**** 8899" (got '8899', want '8899')
  PASS  full card number (got '1234', want '1234')
  PASS  short "12" (got '12', want '12')
  PASS  empty -> null (got NULL, want NULL)
  PASS  null -> null (got NULL, want NULL)

== 6. normalize: raw statement JSON -> canonical record ==
  PASS  bank via "institution" (got 'First Northern Bank', want 'First Northern Bank')
  PASS  last4 via account_number (got '8899', want '8899')
  PASS  period_start (got '2024-06-01', want '2024-06-01')
  PASS  period_end (got '2024-06-30', want '2024-06-30')
  PASS  currency from "$" (got 'USD', want 'USD')
  PASS  opening cents (got 100000, want 100000)
  PASS  closing cents (got 118000, want 118000)
  PASS  amount-less row skipped (got 3, want 3)
  PASS  t0 signed debit (got -20000, want -20000)
  PASS  t0 running balance (got 80000, want 80000)
  PASS  t1 desc via "payee" (got 'Client Payment', want 'Client Payment')
  PASS  t1 signed credit (got 40000, want 40000)
  PASS  t2 date via "posted" (got '2024-06-10', want '2024-06-10')
  PASS  t2 desc via "narrative" (got 'Card Fee', want 'Card Fee')
  PASS  t2 debit-column -> signed (got -2000, want -2000)
  PASS  t2 direction label (got 'debit', want 'debit')
  PASS  sort assigned in order (got 2, want 2)

== 7. reconcile: opening + Σ == closing (clean, discrepant, incomplete) ==
  PASS  clean status (got 'reconciled', want 'reconciled')
  PASS  clean ok
  PASS  clean sum (got 25000, want 25000)
  PASS  clean computed (got 125000, want 125000)
  PASS  clean delta 0 (got 0, want 0)
  PASS  discrepant status (got 'discrepant', want 'discrepant')
  PASS  discrepant not ok
  PASS  discrepant exact delta (got -50, want -50)
  PASS  incomplete status (got 'incomplete', want 'incomplete')
  PASS  incomplete delta null (got NULL, want NULL)
  PASS  incomplete not ok
  PASS  normalized statement reconciles (got 'reconciled', want 'reconciled')

== 8. runningBalanceCheck: absolute (opening known) + delta (opening unknown) ==
  PASS  one row flagged (got 1, want 1)
  PASS  flagged index (got 2, want 2)
  PASS  flagged expected (got 125000, want 125000)
  PASS  flagged stated (got 124000, want 124000)
  PASS  flagged delta (got -1000, want -1000)
  PASS  consistent rows -> no flags (got 0, want 0)
  PASS  delta-mode one flag (got 1, want 1)
  PASS  delta-mode flagged idx (got 2, want 2)
  PASS  no running balances -> no flags (got 0, want 0)

== 9. status transitions ==
  PASS  pending +extract (got 'extracted', want 'extracted')
  PASS  extracted +review (got 'reviewed', want 'reviewed')
  PASS  reviewed +extract (got 'extracted', want 'extracted')
  PASS  pending +review noop (got 'pending', want 'pending')
  PASS  extracted +fail (got 'failed', want 'failed')
  PASS  failed +extract (got 'extracted', want 'extracted')
  PASS  reviewed +review (got 'reviewed', want 'reviewed')
  PASS  unknown event noop (got 'pending', want 'pending')

== 10. toCsv: RFC-4180 escaping + signed amounts ==
  PASS  CSV header row (got 'Date,Description,Amount,Direction,Balance,Category', want 'Date,Description,Amount,Direction,Balance,Category')
  PASS  CRLF line endings
  PASS  comma/quote/newline field round-trips
  PASS  signed debit amount -45.00 present
  PASS  direction debit present

== 11. toQif: valid QIF structure ==
  PASS  Bank header
  PASS  date field D06/02/2024
  PASS  amount field T-1450.00
  PASS  amount field T975.50
  PASS  payee field P
  PASS  category field L
  PASS  two record terminators (got 2, want 2)
  PASS  only one category line (got 1, want 1)
  PASS  CCard header

== 12. toJson: statement + reconciliation + transactions ==
  PASS  valid JSON
  PASS  opening decimal (got '1000.00', want '1000.00')
  PASS  closing decimal (got '1180.00', want '1180.00')
  PASS  recon status (got 'reconciled', want 'reconciled')
  PASS  recon delta 0.00 (got '0.00', want '0.00')
  PASS  three transactions (got 3, want 3)
  PASS  t0 amount decimal (got '-200.00', want '-200.00')
  PASS  t0 direction (got 'debit', want 'debit')
  PASS  t1 running balance (got '1200.00', want '1200.00')
  PASS  t2 running balance (got '1180.00', want '1180.00')

== CSV formula-injection guard ==
  PASS  Extraction =-formula neutralized (got '\'=1+2', want '\'=1+2')
  PASS  Extraction @-formula neutralized (got '\'@SUM(A1)', want '\'@SUM(A1)')
  PASS  Extraction leading-TAB neutralized (got '\'	X', want '\'	X')
  PASS  Extraction -text neutralized (got '\'-abc', want '\'-abc')
  PASS  Extraction negative number kept (got '-45.00', want '-45.00')
  PASS  Extraction plain value untouched (got 'Safe Name', want 'Safe Name')
  PASS  Extraction quoting still applies (got '"\'=a,b"', want '"\'=a,b"')
  PASS  Statement =-formula neutralized (got '\'=1+2', want '\'=1+2')
  PASS  Statement @-formula neutralized (got '\'@SUM(A1)', want '\'@SUM(A1)')
  PASS  Statement leading-TAB neutralized (got '\'	X', want '\'	X')
  PASS  Statement -text neutralized (got '\'-abc', want '\'-abc')
  PASS  Statement negative number kept (got '-45.00', want '-45.00')
  PASS  Statement plain value untouched (got 'Safe Name', want 'Safe Name')
  PASS  Statement quoting still applies (got '"\'=a,b"', want '"\'=a,b"')

== QIF/JSON export-injection guards ==
  PASS  QIF: exactly one record terminator (got 1, want 1)
  PASS  QIF: exactly one T amount line (got 1, want 1)
  PASS  QIF: newlines collapsed in payee
  PASS  QIF: category newline collapsed (got 1, want 1)
  PASS  JSON: hostile name stays valid JSON
  PASS  JSON: name round-trips verbatim (got '"},"x":{"', want '"},"x":{"')
  PASS  JSON: description round-trips verbatim (got '=HYPERLINK("evil")', want '=HYPERLINK("evil")')
  PASS  QIF: hostile date — one record terminator (got 1, want 1)
  PASS  QIF: hostile date — one T amount line (got 1, want 1)
  PASS  QIF: date newlines collapsed
  PASS  QIF: ISO date still converts

== LLM truncation guard ==
  PASS  truncated (openai/ollama length) -> clear error
  PASS  truncated (anthropic max_tokens) -> same error
  PASS  error names the 8192-token ceiling
  PASS  error tells the user what to DO (split the statement)
  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 (3.3.0: one loop checks each reply's stop reason, then parses or continues)

== 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 AI provider, model, company name and currency as they were
  PASS  ...and still loads the demo records
  PASS  ...and adds no payee rules to a real install (the demo's sample rules are demo-only)
  PASS  the public demo seeds a few payee rules, so "Payee rules learned" is not 0 there

== 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_preview (403)
  PASS  a signed-in member is refused restore_preview (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: payload_is_text
  PASS  v2: upgrade_from_1x
  PASS  v2: mint
  PASS  v2: revoke
  PASS  v2: totp
  PASS  v2: recovery
  PASS  v2: delivery
  PASS  v2: events_are_ledgiras
  PASS  v2: rate
  PASS  v2: csv_dry
  PASS  v2: backup_tables
  PASS  v2: reconciles_to_the_cent
  PASS  v2: one_cent_is_a_discrepancy
  PASS  v2: incomplete_is_not_reconciled
  PASS  v2: badge_matches_core
  PASS  v2: serializer_matches_core
  PASS  v2: row_audit_catches_what_totals_hide
  PASS  v2: both_csv_shapes_sign_correctly
  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: the API is READ-ONLY — no POST/PUT/DELETE route under /api/ (documented limit)
  PASS  v2: 2FA intercepts login + login is rate-limited
  PASS  v2: API reuses the tested core (reconcile, the row audit, the badge helper)
  PASS  v2: the API never re-sums transactions itself
  PASS  v2: reconciliation is announced only when it CHANGES, and from one place
  PASS  v2: the transactions import accepts BOTH bank CSV shapes and refuses a both-ways row
  PASS  v2: imported rows honor the configured date order, never guessing
  PASS  v2: no ternary key re-reads (PLAN item 10) — incl. llm_provider/timezone in settings_save
  PASS  v2: 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 + the sw cache name carries a version
  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

== Own It 3.0: MCP · roles · audit · categorization rules · duplicates ==
  PASS  migrate() creates category_rules + audit_log
  PASS  transactions gain category_source + duplicate_of
  PASS  migrate() creates the settings row
  PASS  UPGRADE PATH: drop every v3 table, migrate, they come back
  PASS  a member does the books but cannot run extraction or delete a statement
  PASS  a viewer reads and cannot edit
  PASS  a viewer canNOT read the audit trail or team roster
  PASS  an admin holds extraction and settings (the AI key is the spend)
  PASS  correcting a category LEARNS a payee rule
  PASS  the learned pattern drops store numbers and ids ("SQ BLUE BOTTLE")
  PASS  the same correction twice does not duplicate the rule
  PASS  correcting a rule's own output re-points the rule
  PASS  a description with nothing durable learns NO rule
  PASS  AMAZON WEB SERVICES beats AMAZON — the specific rule takes the row
  PASS  **A RULE NEVER OVERWRITES A CATEGORY A PERSON TYPED**
  PASS  a rule fills a blank category and marks the source as "rule"
  PASS  a rule MAY correct its own earlier guess
  PASS  rule usage is counted, so a dead rule is visible
  PASS  a same-date/amount/payee row in ANOTHER statement is flagged
  PASS  a repeat inside ONE statement is left alone (buying coffee twice is ordinary)
  PASS  the flag points at the earlier row as the original
  PASS  DUPLICATE DETECTION DELETES NOTHING
  PASS  re-scanning flags nothing new
  PASS  the duplicates list names both statements
  PASS  MCP exposes exactly the row's six tools, in order
  PASS  there is deliberately NO extraction tool (it spends the buyer's AI credits)
  PASS  every tool description declares READ-ONLY or WRITES
  PASS  GET /mcp is refused with 405
  PASS  a nested argument that is not allowlisted is refused
  PASS  transaction_update DERIVES direction from the sign, as the UI does
  PASS  a category set via MCP is marked as a human decision
  PASS  and it learns a payee rule, like the form does
  PASS  that manual category survives a later rule run
  PASS  category_totals reports uncategorized separately rather than hiding it
  PASS  export_rows uses the product's own QIF exporter
  PASS  MCP role gate matches the UI: a viewer key cannot edit
  PASS  MCP writes land in the audit trail
  PASS  the statement summary renders as a real PDF
  PASS  inkOn() clears 4.5:1
  PASS  a PHP script named .png is refused
  PASS  another product's backup is refused by name stamp
  PASS  OIDC refuses alg=none

== Own It 3.0: wiring (static) ==
  PASS  Database::migrate() runs at boot
  PASS  POST and GET /mcp are both routed
  PASS  src includes are idempotent
  PASS  install() reaches the v3 schema without recursing through migrate()
  PASS  extraction marks LLM-guessed categories so they are distinguishable from a human decision
  PASS  extraction applies rules and flags duplicates
  PASS  the edit form marks a typed category as manual and learns from it
  PASS  bulk categorize reuses per-row writes in one transaction and learns rules
  PASS  nothing in the product deletes a flagged duplicate automatically
  PASS  destructive + spending actions are role-gated
  PASS  mutations write audit entries
  PASS  button ink follows the branding contrast helper
  PASS  the uploaded logo is actually RENDERED in the layout (not just stored)
  PASS  the branding accent overrides BOTH themes
  PASS  src/Mcp.php is byte-identical to the shared core copy of Mcp.php
  PASS  src/AuditLog.php is byte-identical to the shared core copy of AuditLog.php
  PASS  src/Pdf.php is byte-identical to the shared core copy of Pdf.php
  PASS  src/BackupRestore.php is byte-identical to the shared core copy of BackupRestore.php
  PASS  src/Branding.php is byte-identical to the shared core copy of Branding.php
  PASS  src/RolesKit.php is byte-identical to the shared core copy of RolesKit.php
  PASS  src/Oidc.php is byte-identical to the shared core copy of Oidc.php
  PASS  src/Gdpr.php is byte-identical to the shared core copy of Gdpr.php
  PASS  src/Ical.php is byte-identical to the shared core copy of Ical.php
  PASS  every /api-settings handler is role-gated, not merely logged-in
  PASS  creating or deleting a webhook is ADMIN-only (it is an off-box data feed)
  PASS  revoking an API key is ADMIN-only (it takes every integration down)
  PASS  the settings page itself is role-gated, not merely logged-in
  PASS  the nav never links a role to a page it will be refused
  PASS  every /api/* WRITE route role-checks the key, not just its existence
  PASS  the roles map keeps admin able to administer, and keeps settings.manage away from the rest
  PASS  an MCP tool refusal THROWS McpRefusal — never a result the agent reads as success
  PASS  wiping the register is admin-only, not merely logged-in
  PASS  the API-keys view is handed every column it prints
  PASS  sw.js cache carries the CURRENT app version (bump both together or a stale cache survives an upgrade)
  PASS  healthz + api_ping + openapi report 4.1.3 (App::VERSION)
  PASS  API.md documents the MCP endpoint AND why extraction is absent
  PASS  README documents "New in 3.0"
  PASS  the deploy kit is filled in
  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: statement review + inline categorize + 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: the inline categorize + apply-rule endpoints are registered and role-gated
  PASS  categorise/permission: a viewer may not categorize or manage rules; a member (bookkeeper) may
  PASS  review: the hero carries provenance chips, the inline category cell and its script
  PASS  dashboard: the first-run checklist and needs-your-action are computed from the DATABASE
  PASS  categorise/happy: the row is categorized, marked as a human decision, and the payee rule is learned
  PASS  categorise/similar: the offer counts the OTHER rows this payee still leaves uncategorized
  PASS  categorise/refused: a blank category is refused with a human sentence and nothing is written
  PASS  categorise/rule reach: applying the learned rule catches the sibling row and marks it rule-authored
  PASS  categorise/never overwrites a person: a rule cannot take back a row a human corrected

== v3.1: export presets ==
  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: generic is byte-identical to the pre-v3.1 export (got 'Date,Description,Amount,Direction,Balance,Category
2026-03-04,SQ *BLUE BOTTLE 4471,-12.75,debit,9987.25,Meals
2026-03-06,CLIENT DEPOSIT,2500.00,credit,12487.25,
', want 'Date,Description,Amount,Direction,Balance,Category
2026-03-04,SQ *BLUE BOTTLE 4471,-12.75,debit,9987.25,Meals
2026-03-06,CLIENT DEPOSIT,2500.00,credit,12487.25,
')
  PASS  P6/refusal: an unknown preset falls back to generic rather than inventing columns (got 'Date,Description,Amount,Direction,Balance,Category
2026-03-04,SQ *BLUE BOTTLE 4471,-12.75,debit,9987.25,Meals
2026-03-06,CLIENT DEPOSIT,2500.00,credit,12487.25,
', want 'Date,Description,Amount,Direction,Balance,Category
2026-03-04,SQ *BLUE BOTTLE 4471,-12.75,debit,9987.25,Meals
2026-03-06,CLIENT DEPOSIT,2500.00,credit,12487.25,
')
  PASS  P6/permission: the export route still requires a login
  PASS  P6/sign: a debit stays negative through the QuickBooks preset (got '2026-03-04,SQ *BLUE BOTTLE 4471,-12.75', want '2026-03-04,SQ *BLUE BOTTLE 4471,-12.75')
  PASS  P6/sign: a credit stays positive (got '2026-03-06,CLIENT DEPOSIT,2500.00', want '2026-03-06,CLIENT DEPOSIT,2500.00')
  PASS  P6/format: Xero gets dd/mm/yyyy, the payee, and the row id as its reference (got '04/03/2026,-12.75,SQ *BLUE BOTTLE 4471,Meals,41', want '04/03/2026,-12.75,SQ *BLUE BOTTLE 4471,Meals,41')
  PASS  P6/edge: an uncategorized row falls back to the payee for Xero's description rather than exporting blank
  PASS  P6/safety: presets reuse Statement::csvCell, so formula injection is neutralized in every layout
  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: /statements keeps the admin wording
  PASS  demo card: /statements/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 (42 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: statement.reconciled is sent by the action where it happens [302]
  PASS  webhooks: statement.discrepant is sent by the action where it happens [302]
  PASS  webhooks: every event the Settings page offers is sent somewhere
  PASS  webhooks: deleting the line that broke a statement announces it reconciled
  PASS  webhooks: a correction made through /mcp announces the change too
  PASS  webhooks: each change is announced once
  PASS  backup redaction: a credential was planted in every credential column (13)
  PASS  backup redaction: /backup.json and the scheduled backup both use the one list, _lg_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: OFX and QBO exports ==
  PASS  OFX 1.02 SGML header, then <OFX>
  PASS  a bank statement goes in the bank block, with the currency and the account number read
  PASS  the period is the statement's
  PASS  one STMTTRN per transaction (got 3, want 3)
  PASS  the sign decides DEBIT or CREDIT, and the amount keeps it
  PASS  the amounts add up to the statement's movement (got 249545, want 249545)
  PASS  FITIDs are unique and stable (statement-transaction), so a re-import adds nothing
  PASS  NAME is cut to 32 characters and SGML-escaped; MEMO keeps the whole line and the category
  PASS  a newline in a description cannot start a forged record
  PASS  the file is Windows-1252 as its header says (é is one byte)
  PASS  the closing balance is the ledger balance
  PASS  no INTU.BID without a bank ID (plain OFX)
  PASS  QBO: the owner's Intuit bank ID goes in the sign-on block, digits only
  PASS  a card statement goes in the credit-card block; no last four → the statement's own id; bad currency → USD
  PASS  with no period, the transactions' own first and last dates are used
  PASS  routes: /statements/{id}/export.qbo and export.ofx
  PASS  the statement page offers QBO and OFX
  PASS  Settings takes the QuickBooks bank ID

== 3.2.0: QBO and OFX over HTTP (tests/v320_http_check.php) ==
  PASS  the HTTP proof ran (no PHP warning logged)
  PASS  signed out, no statement file is served
  PASS  the .qbo download: QuickBooks type, the saved Intuit bank ID, the account, every transaction, the balance
  PASS  the .ofx download: OFX type, no Intuit bank ID, signed amounts
  PASS  the statement's Export menu offers QBO and OFX
  PASS  Settings shows the saved QuickBooks bank ID
  PASS  pass B: Settings offers category suggestions, on by default
  PASS  pass B: the .xlsx download is an Excel workbook
  PASS  pass B: one post of three files adds the two statements and skips the text file
  PASS  pass B: the list offers "Extract waiting statements (2)"
  PASS  pass B: both are read, each line's suggested category marked as the model's
  PASS  pass B: the statement offers to accept the model's suggestion
  PASS  pass B: accepting makes it yours and teaches the payee rule
  PASS  pass B: a supplier statement offers "Check against your books"
  PASS  pass B: the check reports what agrees, what they lack and what you lack
  PASS  pass B: an empty ledger paste is refused with the format
  PASS  a supplier statement stays one when its header is saved (it turned into bank)
  PASS  customer voice: a receipt attaches to a payment out, is served to the signed-in user, counts; the missing-receipts CSV lists the rest
  PASS  customer voice: after a re-extraction the receipt is back on the line with the same date and amount
  PASS  customer voice: removing a receipt deletes its file
  PASS  round 4: a bank export whose dates carry their time ("Timestamp", ISO with Z) imports with the dates shown; an impossible time is skipped
  PASS  customer voice: a supplier statement has no receipts to attach
  PASS  MCP export_rows writes a card statement's QIF as !Type:CCard, byte-for-byte the download

== 3.2.0 pass B: Excel without an extension (pure) ==
  PASS  column letters (got array (
  0 => 'A',
  1 => 'Z',
  2 => 'AA',
  3 => 'ZZ',
), want array (
  0 => 'A',
  1 => 'Z',
  2 => 'AA',
  3 => 'ZZ',
))
  PASS  the sheet has the CSV's columns, money as numbers (got array (
  0 => '2026-09-03',
  1 => '=HYPERLINK("x") & <co>',
  2 => -4.55,
  3 => 'debit',
  4 => 95.45,
  5 => 'Meals',
), want array (
  0 => '2026-09-03',
  1 => '=HYPERLINK("x") & <co>',
  2 => -4.55,
  3 => 'debit',
  4 => 95.45,
  5 => 'Meals',
))
  PASS  it is a ZIP (local header first, end-of-directory last)
  PASS  six parts in the central directory (got 6, want 6)
  PASS  every part is stored with the right CRC-32 and size
  PASS  amounts are numbers; text, even starting with "=", is an inline string (never a formula)
  PASS  an empty balance is an empty cell; the header row is bold
  PASS  the sheet name is made legal

== 3.2.0 pass B: category suggestions in the prompt (pure) ==
  PASS  off: the prompt is unchanged
  PASS  on: a category per line, from the owner's own when one fits (sanitized)
  PASS  on with no categories yet: examples, not an instruction to invent

== 3.2.0 pass B: a supplier's statement against your books (pure) ==
  PASS  the ledger parses by named columns, any order, any delimiter (got array (
  0 => 'INV-1001',
  1 => 'INV-1004',
  2 => 'INV-1007',
), want array (
  0 => 'INV-1001',
  1 => 'INV-1004',
  2 => 'INV-1007',
))
  PASS  ...with EU decimals (got 45000, want 45000)
  PASS  a reference matches as a whole token, however it is spaced
  PASS  four outcomes (got array (
  'agree' => 1,
  'amount_differs' => 1,
  'not_in_books' => 1,
  'not_on_statement' => 1,
), want array (
  'agree' => 1,
  'amount_differs' => 1,
  'not_in_books' => 1,
  'not_on_statement' => 1,
))
  PASS  a payment line is listed apart, not called missing

== 3.2.0 customer voice: a description that wraps onto the next line is joined, not lost ==
  PASS  the wrapped text joins its transaction; a balance/total row, a row with a balance, a dated row and a leading orphan are not joined (got array (
  0 => 
  array (
    0 => 'CARD PAYMENT TO THE CORNER BAKERY LTD LONDON',
    1 => 'SALARY',
  ),
  1 => 1,
), want array (
  0 => 
  array (
    0 => 'CARD PAYMENT TO THE CORNER BAKERY LTD LONDON',
    1 => 'SALARY',
  ),
  1 => 1,
))
  PASS  the prompt tells the model to join wrapped lines

== 3.2.0 customer voice: receipts on statement lines (pure) ==
  PASS  spending lines: a bank's money out, a card's charges, none on a supplier statement (got array (
  0 => 
  array (
    0 => 1,
    1 => 3,
    2 => 4,
  ),
  1 => 
  array (
    0 => 2,
  ),
  2 => 
  array (
  ),
), want array (
  0 => 
  array (
    0 => 1,
    1 => 3,
    2 => 4,
  ),
  1 => 
  array (
    0 => 2,
  ),
  2 => 
  array (
  ),
))
  PASS  relink: a live link stands; a lost one finds the single free line with its date and amount; two candidates, or none free, leave it unlinked (got array (
  10 => 1,
  11 => 0,
  12 => 0,
), want array (
  10 => 1,
  11 => 0,
  12 => 0,
))
  PASS  after a re-extraction (new ids) each receipt returns to its line (got array (
  10 => 21,
  11 => 0,
  12 => 23,
), want array (
  10 => 21,
  11 => 0,
  12 => 23,
))
  PASS  missing: spending lines with no linked receipt (got array (
  0 => 3,
  1 => 4,
), want array (
  0 => 3,
  1 => 4,
))
  PASS  salvage: the header and every line written whole are kept; the cut-off line is not
  PASS  salvage: a brace or quote inside a description does not end the line early
  PASS  a statement longer than one reply: the second call continues after the last line read, and the seam repeat is dropped
  PASS  the same on Anthropic (stop_reason max_tokens): the text block follows the document

Warning: Undefined array key "llm_base_url" in src/Llm.php on line 347

Warning: Undefined array key "llm_base_url" in src/Llm.php on line 347

Warning: Undefined array key "llm_base_url" in src/Llm.php on line 347

Warning: Undefined array key "llm_base_url" in src/Llm.php on line 347

Warning: Undefined array key "llm_base_url" in src/Llm.php on line 347

Warning: Undefined array key "llm_base_url" in src/Llm.php on line 347

Warning: Undefined array key "llm_base_url" in src/Llm.php on line 347

Warning: Undefined array key "llm_base_url" in src/Llm.php on line 347
  PASS  it stops after 8 replies and says how far it got

Warning: Undefined array key "llm_base_url" in src/Llm.php on line 347
  PASS  a reply cut off before one whole line gives the old clear error

Warning: Undefined array key "llm_base_url" in src/Llm.php on line 347
  PASS  a statement that fits one reply is one call, as before

————————————————————————————————————————————————————

== 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 Ledgira 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 Ledgira 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 Ledgira, 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)
ALL 529 TESTS PASSED (0 failed)

← Back to Ledgira · Manual · Quickstart · API