Extracta · Test run · run 2026-11-07
Extracta Test run, as shipped in the download
517 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 US big "1,234,567.89" (got 123456789, want 123456789)
PASS EU "€1.000,00" (got 100000, want 100000)
PASS empty -> null (got NULL, want NULL)
PASS "N/A" -> null (got NULL, want NULL)
PASS null -> null (got NULL, want NULL)
== 2. centsToDecimal round-trips ==
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 '')
== 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 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 3.3.0: a model reply with a datetime keeps its date (got '2026-03-04', want '2026-03-04')
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 textual "3 April 2026" (got '2026-04-03', want '2026-04-03')
PASS year-first 2026/04/03 (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 bad ISO -> null (got NULL, want NULL)
PASS garbage -> null (got NULL, want NULL)
PASS empty -> null (got NULL, want NULL)
== 4. currencyCode from code or symbol ==
PASS code "usd" (got 'USD', want 'USD')
PASS symbol "€" (got 'EUR', want 'EUR')
PASS symbol "£" (got 'GBP', want 'GBP')
PASS empty -> "" (got '', want '')
== 5. normalize: raw LLM JSON -> canonical record ==
PASS vendor via "supplier" (got 'Acme Supplies Ltd', want 'Acme Supplies Ltd')
PASS invoice_number via "invoice_no" (got 'INV-2026-0042', want 'INV-2026-0042')
PASS invoice_date normalized (got '2026-04-03', want '2026-04-03')
PASS due_date from textual (got '2026-04-17', want '2026-04-17')
PASS currency from "$" (got 'USD', want 'USD')
PASS subtotal cents (got 100000, want 100000)
PASS tax via "vat" (got 8500, want 8500)
PASS total via "grand_total" (got 108500, want 108500)
PASS two line items (got 2, want 2)
PASS line 1 desc (got 'Widget A', want 'Widget A')
PASS line 1 qty (float) (got 2.0, want 2.0)
PASS line 1 amount cents (got 60000, want 60000)
PASS line 2 desc via "name" (got 'Service B', want 'Service B')
PASS empty raw -> blank vendor (got '', want '')
PASS empty raw -> null date (got NULL, want NULL)
PASS empty raw -> [] items (got array (
), want array (
))
== 6. reconcile: arithmetic cross-checks ==
PASS clean invoice reconciles (no warnings)
PASS subtotal+tax != total is flagged
PASS line items not summing to subtotal is flagged
PASS within-tolerance rounding does not warn
PASS nothing to check -> ok
== 7. toCsv: RFC-4180 escaping round-trips ==
PASS header row present (3.2.0: Account last) (got 'Vendor,Invoice #,Invoice Date,Due Date,Currency,Subtotal,Tax,Total,Account', want 'Vendor,Invoice #,Invoice Date,Due Date,Currency,Subtotal,Tax,Total,Account')
PASS vendor with comma/quote/newline round-trips
PASS CRLF line endings
== 8. toJson: valid, decodes back, decimals ==
PASS valid JSON
PASS vendor survives (got 'Acme Supplies Ltd', want 'Acme Supplies Ltd')
PASS total as decimal (got '1085.00', want '1085.00')
PASS line item unit price decimal (got '300.00', want '300.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"')
== Security hardening regressions ==
PASS dashboard week query is engine-portable (no SQLite datetime())
PASS settings_save blocks writes in public demo mode
PASS file_serve sends X-Content-Type-Options: nosniff
PASS installer rejects empty install token (hash_equals empty-token bypass)
PASS LLM client never follows redirects (key cannot leak to a redirect target)
PASS settings view never echoes the stored API key value
== 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 document)
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-5-sonnet (the old accuracy hint) 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
== Possible duplicate invoices are flagged ==
PASS same vendor (any case, punctuation) and invoice number -> flagged with the other document (got array (
0 => 7,
1 => 'same vendor and invoice number',
), want array (
0 => 7,
1 => 'same vendor and invoice number',
))
PASS no invoice number, same vendor + total + date -> flagged (got array (
0 => 9,
1 => 'same vendor, total and invoice date',
), want array (
0 => 9,
1 => 'same vendor, total and invoice date',
))
PASS same invoice number from a DIFFERENT vendor is not a duplicate (got NULL, want NULL)
PASS same vendor, same total, different date and number -> not flagged (monthly retainers) (got NULL, want NULL)
PASS no vendor read -> nothing to compare (got NULL, want NULL)
PASS the review page compares against every other extracted document and passes the verdict to the view
PASS the review view shows the warning with a link to the other document
== 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
== 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: mint
PASS v2: revoke
PASS v2: totp
PASS v2: recovery
PASS v2: delivery
PASS v2: rate
PASS v2: backup_tables
PASS v2: reconciling_doc_needs_no_review
PASS v2: non_reconciling_doc_needs_review
PASS v2: needs_review_is_reconcile_verdict
PASS v2: money_is_cents_and_decimal
PASS v2: line_items_surfaced
PASS v2: extract_hook_fires_event
PASS v2: review_hook_fires_event
PASS v2: export_includes_reviewed
PASS v2: backup_redacts_llm_key
PASS v2: db_files_cleaned
PASS v2: routes registered (api, 2fa, security, backup, healthz)
PASS v2: migrate() runs on every boot (Extracta had NO migrate() before 2.0)
PASS v2: 2FA intercepts login + login is rate-limited
PASS v2: BOTH extraction paths fire the hook (demo + real) — since 3.2.0 through the one store both call
PASS v2: the review hook fires from the UI too, not just the API
PASS v2: needs_review reuses Extraction::reconcile, it does not re-derive a rule
PASS v2: API corrections use the app own normalizers, not ad-hoc parsing
PASS v2: the fields object is declared nested, so the documented payload is accepted
PASS v2: the API knows the product REAL status vocabulary (incl. reviewed)
PASS v3: backup redacts every reusable credential, incl. the buyer's own LLM key
PASS v3: both backup callers share ONE redaction list
PASS v2: no PHP-8 ternary key re-reads (PLAN item 10)
PASS v2: cli-server base guard (PLAN item 9)
PASS v2: core modules stay dependency-free (no product helpers inside them)
PASS v2: clip() matches the family implementation (copied code must not drift)
PASS v2: light accent is dark enough for white button text (WCAG AA)
PASS v2/kit: dark palette (kit + app layer) + kit js + qr.js + layout wiring
PASS v2: no themed background paired with an un-overridden hardcoded color
PASS v2/kit: palette tokens are WIRED (kit css) and the app layer overrides the accent
PASS v3: sw cache is v4.1.3 + API.md ships
PASS v3: /healthz, api_ping and openapi all report 4.1.3
-- Own It 3.0 --
PASS v3: v3_tables
PASS v3: v3_columns
PASS v3: upgrade_never_loosens
PASS v3: settings_singleton_ensured
PASS v3: upgrade_from_2x
PASS v3: migrate_called_at_boot
PASS v3: roles
PASS v3: whitelist_drops_undeclared
PASS v3: stored_keys_are_exactly_declared
PASS v3: values_go_through_the_parsers
PASS v3: ambiguous_date_is_parsed
PASS v3: db_matches_and_marks_reviewed
PASS v3: reconcile_is_the_products
PASS v3: line_items_narrowed
PASS v3: mcp_tools
PASS v3: mcp_write_tools_declare_it
PASS v3: mcp_arg_guard
PASS v3: mcp_get_is_405
PASS v3: mcp_never_returns_file_path
PASS v3: reject_keeps_values
PASS v3: approve_marks_reviewed
PASS v3: template_narrows_only
PASS v3: template_null_is_full_set
PASS v3: template_empty_falls_back
PASS v3: pdf_renders
PASS v3: gdpr_search_finds_values
PASS v3: gdpr_search_is_case_insensitive
PASS v3: gdpr_search_misses_non_matches
PASS v3: audit_redacts_llm_key
PASS v3: audit_records_mcp_writes
PASS v3: branding_content_decides
PASS v3: restore_refuses_other_product
PASS v3: routes registered, LITERALS before {id} patterns
PASS v3: MCP writes go through the SAME whitelist builder as the review form
PASS v3: the record builder NAMES every field — it never copies keys from its input
PASS v3: every path parses currency AND dates with Extraction (one field, one meaning)
PASS v3: a template can only NARROW the declared field set
PASS v3: the extraction report renders Extraction::reconcile, never a second arithmetic check
PASS v3: subject search is gated the SAME way as the nav link that offers it
PASS v3: GDPR redaction keeps the money and never logs the search term
PASS v3: core Mcp is byte-identical to the shared core
PASS v3: core AuditLog is byte-identical to the shared core
PASS v3: core Pdf is byte-identical to the shared core
PASS v3: core BackupRestore is byte-identical to the shared core
PASS v3: core Branding is byte-identical to the shared core
PASS v3: core RolesKit is byte-identical to the shared core
PASS v3: core Oidc is byte-identical to the shared core
PASS v3/C2: a nested value where a value belongs is REFUSED, never cast to the string "Array"
PASS v3/C2: _rec_from_input runs the guard over every declared field AND over line items
PASS v3/C2: every path that writes extracted values refuses a nested value
PASS v3/C2: the viewer seat is read-only in the map and cannot spend the buyer's AI credits
PASS v3/C2: the pre-3.0 screens are role-gated, not merely signed-in
PASS v3/C2: minting an API key and downloading the register are admin acts
PASS v3/C2: no route outside the user's own account is left on a bare requireLogin
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)
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: /documents keeps the admin wording
PASS demo card: /review/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 (41 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: document.extracted is sent by the action where it happens [302]
PASS webhooks: document.reviewed is sent by the action where it happens [302]
PASS webhooks: every event the Settings page offers is sent somewhere
PASS webhooks: approving several documents at once announces each one
PASS backup redaction: a credential was planted in every credential column (15)
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)
————————————————————————————————————————————————————
== v3.1 P6: accounting export presets ==
PASS P6: QuickBooks preset carries the documented header and one row PER LINE ITEM (2 items + 1 summary = 3 data rows)
PASS P6: Xero preset carries the starred header, quantity 1, Tax Exclusive, currency passthrough
PASS P6: unknown preset falls back to the generic CSV (never errors, never invents a format)
PASS P6: the route accepts only the named presets
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
== 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: vendor rules (pure) ==
PASS a vendor normalizes to upper-case words (got 'METRO OFFICE SUPPLIES LTD', want 'METRO OFFICE SUPPLIES LTD')
PASS a learned pattern drops the legal form (got 'METRO OFFICE SUPPLIES', want 'METRO OFFICE SUPPLIES')
PASS ...every trailing one (GmbH & Co KG) (got 'HARTMANN BAUER', want 'HARTMANN BAUER')
PASS a pattern under three letters is refused (got '', want '')
PASS the longest matching pattern wins (got '6420 Software', want '6420 Software')
PASS the general rule still catches the rest (got '6000 General', want '6000 General')
PASS matching is whole-word: ACME does not code ACMESOFT
PASS a rule codes an empty account (got array (
0 => '6100',
1 => 'rule',
2 => 3,
), want array (
0 => '6100',
1 => 'rule',
2 => 3,
))
PASS a rule re-codes an account a rule set (got array (
0 => '6100',
1 => 'rule',
2 => 3,
), want array (
0 => '6100',
1 => 'rule',
2 => 3,
))
PASS a rule NEVER overwrites an account a person set (got array (
0 => '7000 Mine',
1 => 'manual',
2 => NULL,
), want array (
0 => '7000 Mine',
1 => 'manual',
2 => NULL,
))
PASS no match leaves the account alone (got array (
0 => '',
1 => '',
2 => NULL,
), want array (
0 => '',
1 => '',
2 => NULL,
))
== 3.2.0: exports carry the account ==
PASS plain CSV: Account is the last column, filled
PASS QuickBooks: the Account column carries it (got '6100 Office supplies', want '6100 Office supplies')
PASS Xero: *AccountCode carries it (got '6100 Office supplies', want '6100 Office supplies')
PASS JSON: "account" is there (got '6100 Office supplies', want '6100 Office supplies')
PASS a record with no account exports an empty cell, not an error
== 3.2.0: the email inbox reads attachments (pure MIME) ==
PASS three attachments found (the bodies are not attachments) (got 3, want 3)
PASS the folded Content-Disposition filename wins over the type name (got 'NWL-89377.pdf', want 'NWL-89377.pdf')
PASS base64 decodes to the original bytes
PASS an RFC 2231 UTF-8 filename is decoded (got 'Reçu.png', want 'Reçu.png')
PASS quoted-printable decodes (got 'A=B', want 'A=B')
PASS the PDF is a document (type read from the bytes) (got 'application/pdf', want 'application/pdf')
PASS the PNG is a document (got 'image/png', want 'image/png')
PASS a text attachment is not
PASS the sender address is read from From (got 'billing@northwind.example', want 'billing@northwind.example')
PASS an RFC 2047 filename is decoded (got 'Facture été.pdf', want 'Facture été.pdf')
PASS a path in a filename is stripped (got 'x.pdf', want 'x.pdf')
PASS the summary line counts what happened (got '2 messages read, 3 documents added, 1 attachment ignored (not a PDF or image).', want '2 messages read, 3 documents added, 1 attachment ignored (not a PDF or image).')
== 3.2.0: the screens and routes ==
PASS batch upload: the picker takes several files (files[] multiple)
PASS batch upload: up to 50 per upload, and the old single "file" field still works
PASS batch extraction: the route exists and is offered on the documents list
PASS one extraction path for the button, the batch and the inbox
PASS vendor rules: page, add, apply, delete routes
PASS vendor rules: the review screen has the Account field and saving it teaches a rule
PASS vendor rules: each extraction codes a known vendor on both paths (the one store both call)
PASS inbox: Settings offers it switched off, with test and check buttons
PASS inbox: the cron script exists and refuses the web
PASS inbox: the public demo never reads a mailbox
PASS the inbox password is a secret the shareable backup leaves out
== 3.2.0: database proofs (tests/v320_db_check.php) ==
PASS the database proof ran cleanly
PASS migrate() adds the account, source and inbox columns and vendor_rules (twice, no error)
PASS the email inbox is off on a fresh install
PASS a correction teaches a rule on the vendor without its legal form
PASS the same vendor and account again teaches nothing new
PASS an extracted document from a known vendor is coded, and the rule counts it
PASS a rule never overwrites an account a person set
PASS "Apply rules" reports what it coded and what it left to people
PASS saving a review without changing the account keeps it a rule's
PASS changing the account re-points the rule and makes it the person's
PASS clearing the account leaves it blank
PASS inbox: a refused login says so without repeating the password the server echoed
PASS inbox: the connection test reports the unread count
PASS inbox: no server configured points at Settings
PASS demo: a register of 14+ documents and 5 rules
PASS demo: documents coded by rules, one by a person, one uncoded
PASS demo: two documents arrived by email, sender shown
PASS demo: documents waiting for review and for extraction
PASS demo: the history spans at least five months
PASS demo: documents carry their kind, and the ones awaiting review show a doubtful field
PASS demo: dated relative to today, not years ago
PASS round 4 demo: one reviewed invoice shows a recipient status, as recorded
PASS demo: every history document has its rendered image, or (without GD) no file at all — never a broken image
PASS "Reset all data" removes documents and vendor rules
PASS pass B: a file holding three documents becomes three rows on the one stored file, named "document N of 3"
PASS pass B: each is classified (invoice, receipt, credit note)
PASS pass B: split documents go through the vendor rules
PASS pass B: each field's confidence is stored (04/03 at 0.6, a number in prose at 0.65)
PASS pass B: the API carries the kind, the split and the confidence
PASS pass B: an ordinary file stays one row with its own name
PASS pass B: a person's review clears the confidence (the fields are now checked)
== 3.2.0: over HTTP (tests/v320_http_check.php) ==
PASS the HTTP proof ran (app, stand-in model and scripted mailbox up; no PHP warning logged)
PASS the upload page takes many files
PASS one post of three files adds the two images and skips the text file
PASS the documents list offers "Extract waiting documents (2)"
PASS "Extract waiting documents" reads both through the model
PASS an account set on the review screen is the person's
PASS ...and teaches the vendor rule
PASS the old single-file field still uploads and opens the review
PASS the vendor's next invoice arrives coded by the rule
PASS the review screen shows the account and says a rule set it
PASS the dashboard renders
PASS pass B: the review screen marks a field read with less certainty, and says what kind of document it is
PASS pass B: the documents list shows the kind and "check a field"
PASS "Apply rules" leaves a person's account alone
PASS the QuickBooks export carries the accounts
PASS the rules page lists the rule
PASS deleting a rule keeps the accounts it already set
PASS Settings offers the inbox, unticked
PASS the inbox saves, and its password is never written back into the page
PASS "Test the inbox connection" answers
PASS "Check inbox now" collects the mailed PDF and image, with the sender
PASS ...and extracts them on arrival when that is ticked
PASS ...and Settings records what it did
PASS ...and marks the message read
PASS a second check adds nothing
PASS the documents list marks mailed-in documents
PASS round 4: an agent's review with "2026-03-04T08:10:00Z" stores 2026-03-04 (it blanked the date before)
PASS API: a viewer's key gets 403 on POST /api/documents/{id}/review and changes nothing; an admin's key reviews
PASS customer voice: the drop-box is off until switched on; then a client sends a photo (a text file refused), with a name, shown as via drop-box
PASS customer voice: a form-filling robot adds nothing
PASS customer voice: the hourly cap holds
PASS customer voice: a new link retires the old one; off closes it
PASS next wave: a record whose file is not on the server shows no Open link (it was a 404), and says so
PASS round 4: an XRechnung UBL file and a Factur-X PDF are read from their XML with no AI key; confidence 1.0; kind invoice
PASS round 4: the review page says the fields came from the XML (inside the PDF), shows the seller VAT ID and the file hash
PASS round 4: the viewer shows an XML e-invoice as a person reads it (parties, lines, VAT, totals, IBAN), from the file not the edited working copy; below a hybrid PDF too; nothing for a photo
PASS round 4: the XML is served byte for byte as a download, sandboxed, never rendered in the browser
PASS round 4: the status reported for a received invoice (accepted, date, platform reference) is recorded; a rejection without its reason and an impossible date are refused
PASS round 4: the recorded status rides the JSON and CSV exports (three columns, only when one is recorded), the REST API and the MCP export
PASS round 4: the CSV and JSON exports carry the e-invoice VAT ID, IBAN and VAT per rate
== 3.2.0 pass B: one file, several documents; what kind; how sure (pure) ==
PASS a file holding several documents splits into them (non-objects dropped) (got array (
0 => 'A Ltd',
1 => 'B Ltd',
), want array (
0 => 'A Ltd',
1 => 'B Ltd',
))
PASS an ordinary reading is one document (got 1, want 1)
PASS kinds are recognized (got array (
0 => 'invoice',
1 => 'receipt',
2 => 'credit_note',
3 => 'other',
4 => '',
5 => '',
), want array (
0 => 'invoice',
1 => 'receipt',
2 => 'credit_note',
3 => 'other',
4 => '',
5 => '',
))
PASS a clean vendor is sure (got 1.0, want 1.0)
PASS a missing invoice number is doubtful, not fatal (got 0.7, want 0.7)
PASS 04/03 could be either way (got 0.6, want 0.6)
PASS an ISO due date is sure (got 1.0, want 1.0)
PASS a clean subtotal is sure (got 1.0, want 1.0)
PASS a number found inside prose is less sure (got 0.65, want 0.65)
PASS a negative total is doubtful (got 0.4, want 0.4)
PASS a placeholder vendor, and a missing total and date, are flagged
PASS the model is asked for the kind and told how to return several documents
PASS the upload page offers the phone camera, several shots then one upload
== 3.3.0: e-invoices are read from their XML, not by a model (pure) ==
PASS UBL 2.1 invoice: seller, number, dates, currency, net / VAT / gross, both lines, and the EN 16931 extras
PASS UBL credit note: its kind, its lines by CreditedQuantity
PASS CII (ZUGFeRD / Factur-X): dates from format 102, totals from the summation, the VAT ID by its scheme
PASS the XML inside a Factur-X PDF is found and read; a plain PDF and a picture are not e-invoices
PASS a file with a DOCTYPE or an entity is refused (how XML entity attacks arrive); other XML is not an e-invoice
PASS the result goes through the same normalizer as a model reply
== 3.3.0: an e-invoice's VAT data reaches the exports and the API ==
PASS the VAT breakdown per rate, from both syntaxes (got array (
0 =>
array (
0 =>
array (
'category' => 'S',
'rate' => '19',
'basis' => '200.00',
'tax' => '38.00',
),
),
1 =>
array (
0 =>
array (
'category' => 'S',
'rate' => '5.5',
'basis' => '74.00',
'tax' => '4.07',
),
),
), want array (
0 =>
array (
0 =>
array (
'category' => 'S',
'rate' => '19',
'basis' => '200.00',
'tax' => '38.00',
),
),
1 =>
array (
0 =>
array (
'category' => 'S',
'rate' => '5.5',
'basis' => '74.00',
'tax' => '4.07',
),
),
))
PASS CSV: with an e-invoice in the file, three columns more at the end; the photo row leaves them empty
PASS ...and a file without one is exactly as before
PASS JSON: the e-invoice block (format, VAT ID, VAT per rate), null for other documents
== sec3 D8: a Factur-X PDF cannot inflate without limit (p3ka F4) ==
PASS sec3 D8: a PDF of 64 KB whose embedded stream inflates to 64 MB: no e-invoice (null), no fatal error, at 128 MB
PASS sec3 D8: … and reading it holds at most the cap (10 MB) beside the file, never the inflated 64 MB (grew 11.2 MB)
PASS sec3 D8: … and a normal Factur-X PDF still reads in the same process
PASS sec3 D8: a PDF of 255 KB whose embedded stream inflates to 256 MB: no e-invoice (null), no fatal error, at 128 MB
PASS sec3 D8: … and reading it holds at most the cap (10 MB) beside the file, never the inflated 256 MB (grew 11.1 MB)
PASS sec3 D8: … and a normal Factur-X PDF still reads in the same process
== 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)
SKIP updates: the state row (this suite keeps no open database in its own process; update_e2e.sh installs, migrates and updates for real)
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 Extracta 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 Extracta 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 Extracta, 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 erase planting · sqlite: the subject is created and a marker is planted in all 4 personal columns
PASS erase planting · sqlite: the app's own erase runs through its route and reports no failure
PASS erase planting · sqlite: no planted marker survives anywhere (18 tables dumped)
PASS erase planting · sqlite: the amounts and the date stay (the transaction did happen)
PASS erase planting · sqlite: Subject search finds a drop-box upload not extracted yet, by the name typed on the drop box
PASS erase planting · sqlite: the erase page (/gdpr) says what an erase keeps: "Kept: the source document itself, the file as it was uploade…"
PASS erase planting · sqlite: MANUAL.md says the same: "Kept: the source document itself, the file as it was uploade…"
ALL 517 TESTS PASSED (0 failed)