Ledgira · Test run · run 2026-09-25
Ledgira Test run, as shipped in the download
382 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. 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 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
== 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 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 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_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 honour 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 · categorisation 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 uncategorised 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 categorise 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 3.1.5 (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 categorise + 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 categorise + apply-rule endpoints are registered and role-gated
PASS categorise/permission: a viewer may not categorise 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 categorised, marked as a human decision, and the payee rule is learned
PASS categorise/similar: the offer counts the OTHER rows this payee still leaves uncategorised
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 uncategorised 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 neutralised 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 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: /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 (31 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 (10)
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)
————————————————————————————————————————————————————
ALL 382 TESTS PASSED (0 failed)
← Back to Ledgira · Manual · Quickstart · API