Invora · Test run · run 2026-09-24
Invora Test run, as shipped in the download
262 checks passed, 0 failed, when the suite that ships in the zip was run on 2026-09-24 with php tests/run.php. Run it yourself after you download.
[DB: SQLite]
== 1. THE reference case (verified against the Node original) ==
PASS subtotal = $1,500.00 (got 150000, want 150000)
PASS discount = $75.00 (got 7500, want 7500)
PASS tax after discount = $80.75 (got 8075, want 8075)
PASS total = $1,505.75 (got 150575, want 150575)
PASS balance = total (unpaid) (got 150575, want 150575)
== 2. Discount variants ==
PASS fixed discount applied (got 20000, want 20000)
PASS tax = 10% of (1000-200) = $80 (got 8000, want 8000)
PASS total = $880 (got 88000, want 88000)
PASS fixed discount clamped to subtotal (got 5000, want 5000)
PASS total floors at 0 (got 0, want 0)
PASS 100% discount -> total 0 (tax follows to 0) (got 0, want 0)
== 3. Fractional quantities (milli-qty integer math) ==
PASS 1.5 x $120 = $180 (got 18000, want 18000)
PASS 0.333 x $300 = $99.90 (got 9990, want 9990)
== 4. Per-line tax rates mix ==
PASS mixed per-line tax sums (got 2850, want 2850)
== 5. Status derivation (invoices) ==
PASS draft stays draft even if paid (got 'draft', want 'draft')
PASS sent + not due -> sent (got 'sent', want 'sent')
PASS sent + past due + unpaid -> overdue (got 'overdue', want 'overdue')
PASS partial beats overdue (reference precedence) (got 'partial', want 'partial')
PASS two payments to zero balance -> paid (got 'paid', want 'paid')
PASS overpaid still 'paid' (got 'paid', want 'paid')
== 6. Estimates: status is manual, payments ignored ==
PASS estimate past 'valid until' stays sent (got 'sent', want 'sent')
PASS estimate accepted (got 'accepted', want 'accepted')
== 7. Numbering sequence (atomic) ==
PASS invoice numbers increment (INV-0014 -> INV-0015)
PASS estimate sequence independent (EST-0003)
== 8. Rounding edges ==
PASS odd-rate rounding: discount (got 333, want 333)
PASS odd-rate rounding: tax after (got 701, want 701)
PASS odd-rate rounding: total (got 10367, want 10367)
== 9. Hydrate + token loading ==
PASS loadByToken finds the same doc
PASS empty token never matches
== 10. Oversize-input clamping (MySQL strict-mode INT/VARCHAR guards) ==
PASS to_cents clamps to INT max (got 2147483647, want 2147483647)
PASS to_milli clamps to INT max (got 2147483647, want 2147483647)
PASS to_bp clamps to INT max (got 2147483647, want 2147483647)
PASS clamp survives absurd float (no int-cast UB) (got 2147483647, want 2147483647)
PASS normal values pass through (got 10000, want 10000)
PASS capped huge line stores + loads (got 2147483647, want 2147483647)
PASS capped huge payment stored
PASS clip truncates to column limit (got 190, want 190)
PASS clip leaves short strings alone (got 'hi', want 'hi')
== 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: csv_dry
PASS v2: backup_tables
PASS v2: routes registered (api, 2fa, security, backup, import, healthz)
PASS v2: 2FA intercepts login + login is rate-limited
PASS v2: notifications fire on paid + accepted, never fatally
PASS v2: dark palette + zero hardcoded white backgrounds
PASS v2/kit: the UI kit ships (css+js+fonts) + qr.js, and layout loads the kit (theme lives in ownware-ui.js)
PASS v2: API.md ships + sw cache carries the CURRENT app version (bump both together or a stale cache survives an upgrade)
== v3 ==
PASS v3: dropped v3 tables restored by migrate()
PASS v3: role/branding/token/oidc/recurring columns present
PASS v3: settings singleton ensured by migrate()
PASS v3: index.php migrates at boot
PASS v3: bin/demo.php migrates (not just install)
PASS v3: 9 shared cores byte-identical to the reference copy
PASS v3: healthz/ping/openapi all say 3.2.1 (App::VERSION)
PASS v3: README has New in 3.0 + API.md has the MCP section
PASS v3: API.md carries no copy-paste artifacts from another app
PASS v3: INVORA_BACKUP_REDACT covers credential material
PASS v3: backup.json uses the widened list (not [smtp_pass])
PASS v3: scheduled backups use the same list
PASS v3: GET /mcp route registered
PASS v3: deploy kit filled (invora/acme/8080, zero placeholders)
PASS v3: kit healthcheck is the json_decode probe with $$ escape
PASS v3: one recurring draft made (got 1, want 1)
PASS v3: instance is a draft (got 'draft', want 'draft')
PASS v3: instance total matches source (no recompute drift) (got 55000, want 55000)
PASS v3: idempotent until the next slot (got 0, want 0)
PASS v3: on MySQL the recurring generator runs under a named lock and releases it
PASS v3: schedule advanced past the drafted slot
PASS v3: recurring never sends (no mail action in audit)
PASS v3: invite token verifies + wrong token refused
PASS v3: expired invite refused
PASS v3: last-admin guard math
PASS C2R: nested value inside lines[].description refused by name
PASS C2R: nested value inside lines[].qty refused by name
PASS C2R: a scalar where a line object belongs is refused
PASS C2R: a scalar `lines` argument is refused
PASS C2R: not one of those refusals wrote a document (got 20, want 20)
PASS C2R: no line row anywhere holds the literal "Array" (got 0, want 0)
PASS C2R: demo_reset() is admin-gated, not bare requireLogin
PASS C2R: demo_load() is admin-gated, not bare requireLogin
PASS nav: sidebar guard NOT decidable here (3 computed nav key(s)) — covered by the live audit
== v3.1: client email, recurring idempotency, payment link ==
PASS P5/happy: an https payment link survives (got 'https://buy.stripe.com/abc', want 'https://buy.stripe.com/abc')
PASS P5/refusal: javascript: is dropped, not stored (got '', want '')
PASS P5/refusal: a header-injection attempt is dropped (got '', want '')
PASS P5/default: a document with no override uses the account link (got 'https://acct.example/pay', want 'https://acct.example/pay')
PASS P5/override: the per-document link wins over the account default (got 'https://one-off.example/pay', want 'https://one-off.example/pay')
PASS P1/permission: with the master switch off nothing is sent and nothing is recorded
PASS P1/permission: the master switch alone is not enough — the per-event flag also gates
PASS P1/refusal: a client with no address is refused with a reason, not a silent no-op
PASS P1/refusal: unconfigured SMTP refuses with "not-configured" and writes no outbox row
PASS P1/happy: an enabled send records the attempt and reports it
PASS P1/content: the mail carries the number, the total, the due date and the client link — and no internals
PASS P1/edge: the reminder wording differs from the first send and names the due date
PASS P2/happy: a due schedule drafts one invoice, stamped with the period it belongs to
PASS P2/idempotency: the same period can never produce a second invoice
PASS P2/idempotency: and the schedule still moved on rather than sticking on the filled slot
PASS P2/happy: a 7-day cadence drafts and advances by exactly seven days
PASS P2/refusal: a recurring child is still only ever a DRAFT — nothing is emailed by a schedule
PASS P2/edge: a schedule with no line items is skipped rather than drafting an empty invoice
PASS v3.1: POST /documents/{id}/send is registered, requires login and checks CSRF
PASS v3.1: settings default the client-mail switches to OFF in the schema
PASS scope: api_keys gains a scope column defaulting to full — an upgrade never loosens
PASS scope: requireKey reads the key scope and refuses a write on a read-only key
PASS scope: mintKey persists the chosen scope, and anything but read is full access
PASS scope: /mcp hands the key scope to the core, which refuses write tools per tool
PASS scope: the key form lets an administrator mint a read-only key
PASS licence: every font ships the SIL OFL text for its own family beside it
PASS licence: LICENSE.txt is present and carries the IF WE STOP (CONTINUITY) clause
== v3.2: automatic payment reminders ==
PASS R2/schema: fresh DB: the automatic switch exists and is OFF
PASS R2/schema: fresh DB: the steps default to -3,0,7,14
PASS R2/schema: fresh DB: the daily cap defaults to 20
PASS R2/schema: fresh DB: the templates column exists
PASS R2/schema: fresh DB: invoices and clients carry a pause flag defaulting to 0
PASS R2/schema: fresh DB: the outbox records the step and the claim
PASS R2/schema: fresh DB: a claim is unique, while mails a person sent (NULL claim) never collide
PASS R2/schema: 3.1 DB: the fixture really is the 3.1 schema (no 3.2 columns before migrating)
PASS R2/schema: 3.1 DB: migrate() upgrades it and is safe to run twice
PASS R2/schema: 3.1 DB: existing settings survive the upgrade untouched
PASS R2/schema: 3.1 DB: client mail ON before the upgrade does NOT switch automatic reminders on
PASS R2/schema: 3.1 DB: the existing settings row gains the default steps and cap
PASS R2/schema: 3.1 DB: existing invoices and clients arrive unpaused and unchanged
PASS R2/schema: 3.1 DB: a reminder sent by hand before the upgrade keeps its row, with no step and no claim
PASS R2/schema: 3.1 DB: the claim index exists after the upgrade
PASS R2/schema: fresh DB: the four default messages are written into the settings row (U5)
PASS R2/schema: fresh DB: wording the owner saved is never rewritten by a later migrate (U5)
PASS R2/schema: 3.1 DB: the upgrade writes the same four default messages (U5)
PASS R2/schema: the migration columns are declared for BOTH engines through the same typed ALTER list
PASS R3/off: with the switch off nothing runs, nothing is recorded, and the reason is given
PASS R3/off: switched on but with client email (the master switch) off, still nothing
PASS R3/off: switched on, but no SMTP of the owner's own and no demo mode: nothing, and it says why
PASS R3/step: the heads-up goes three days before the due date, recorded as step -3 in demo mode
PASS R3/step: running again the same day, and the days before the next step, sends nothing more
PASS R3/step: each step fires once, in order: -3, 0, 7, 14, and nothing after the last
PASS R3/step: every scheduled attempt carries its own claim for its own day
PASS R3/content: the mail is the owner's wording with every placeholder filled, plus the client's own link
PASS R3/content: an automatic reminder is recorded as kind "reminder", like the button's
PASS R3/paid: once paid in full, no further step goes (step 0 went; 7 and 14 never did)
PASS R3/paid: a paid invoice is not even listed by the preview
PASS R3/paid: an invoice with nothing to pay (fully discounted, balance 0) is never reminded
PASS R3/partial: a part-paid invoice is reminded for the remaining balance, and says so
PASS R3/catch-up: an invoice first seen 16 days late gets the step it is at (14), not -3, 0 and 7 as well
PASS R3/catch-up: an invoice long past the last step is never mailed automatically
PASS R3/issue: no heads-up for a step whose day fell before the invoice was issued
PASS R3/issue: the invoice's first reminder is the next step after its issue date (0, on the due date)
PASS R3/issue: a due-on-receipt invoice is not "due today"-ed on the day it was issued; its first reminder is at 7
PASS R3/pause: a paused invoice is skipped while its unpaused sibling goes
PASS R3/pause: every invoice of a paused client is skipped
PASS R3/pause: a client with no address is held with that reason, never attempted
PASS R3/pause: resuming lets the held step go on a later day inside its window
PASS R3/wording: step 0 sent ON the due date says it is due today
PASS R3/wording: the same step sent two days late says "was due on" the date, never "due today"
PASS R3/wording: the owner's own wording is never rewritten, late or not; a late default heads-up says "was due on" too
PASS R3/cap: a cap of 2 sends two and holds the third, even when run again the same day
PASS R3/cap: the held one goes the next day, and the two already sent do not repeat
PASS R3/refused: a refused send is recorded as failed, with the server's reason and its step
PASS R3/refused: the first refusal ends the run (the sibling waits for the next run)
PASS R3/refused: never twice the same day: more runs that day try only the sibling, once
PASS R3/refused: retried the next day, under that day's own claim
PASS R3/lazy: a page load sends at most LAZY_BATCH (3); the next load sends the rest
PASS R3/lazy: the dashboard runs the reminders right after the recurring drafts (the same lazy cron)
PASS R3/race: the same claim twice: the first sends, the second is refused as claimed and records nothing
PASS R3/race: a claim that finds the day's cap full is given back and sends nothing
PASS R3/race: a mail without options still behaves as in 3.1 (the reminder button stays gated by its own flag)
PASS R3/never: an estimate and a draft invoice are never reminded
PASS R3/cron: GET /cron/reminders is registered and guarded by the same ops token as /cron/recurring
PASS R3/wiring: index.php loads the engine
PASS R5/wording: four defaults, one per default step (-3, 0, 7, 14)
PASS R5/wording: no default contains a threat, legal language, a fee or interest claim, or a determination word
PASS R5/wording: no shouting -- no exclamation mark and no word in capitals
PASS R5/wording: every default greets the client, names the invoice, the amount still owed and the due date, offers the pay link and signs off as the business
PASS R5/wording: before the due date it is a friendly heads-up; on the day it allows that payment may be on its way
PASS R5/wording: two weeks late it is polite but clear, and offers to talk about a problem
PASS R5/wording: British English in the defaults (no American spellings of the usual suspects)
PASS R5/wording: with no payment link on file, the line offering one is left out rather than left dangling
PASS R4/view: the settings page carries the reminders card as its own form, posting to /settings/reminders
PASS R4/view: the switch renders OFF by default
PASS R4/view: the step list, the daily cap and one editable subject + message per step
PASS R4/view: every placeholder is named on the screen
PASS R4/view: it says the mail goes through the owner's own SMTP and that Invora never touches payments
PASS R4/view: a "Preview today" link to /reminders/preview sits beside the save button
PASS R4/view: the client-email card no longer claims nothing is ever sent automatically
PASS R4/view: switched on while client email is off, the card says nothing can go out yet, and why
PASS R4/preview: the preview lists the invoice under "Goes out today" with its step, its subject and its full message
PASS R4/preview: preview sends nothing -- the outbox and the audit log are unchanged
PASS R4/preview: the page says so, and names what holds the rest
PASS R4/preview: with the switch off the preview still shows the day, under a banner saying nothing will be sent
PASS R4/steps: "-3, 0, 7, 14" parses, and order and repeats do not matter
PASS R4/steps: words, out-of-range days, too many steps and an empty list are refused
PASS R4/save: a filled field is the owner's words (a subject loses its line break); an emptied one goes back to the default
PASS R4/save: a step added in the same save, with no field yet, gets the wording it had (here the default)
PASS R4/routes: saving the schedule is POST /settings/reminders, admin-only, CSRF-checked, and refuses a bad step list
PASS R4/routes: switching it on lands on the preview, and the approval is written to the audit trail
PASS R4/routes: the preview is GET /reminders/preview and admin-only
PASS R4/routes: pausing an invoice or a client is a POST with login and CSRF
PASS R4/invoice: the send log names each automatic reminder and its step
PASS R4/invoice: an open invoice shows where the schedule stands and a pause button posting to its own route
PASS R4/invoice: a paused invoice offers to resume
PASS R4/client: a paused client offers to resume, on its own route
PASS R6/schedule: the switch, the steps and the cap are stated
PASS R6/schedule: for each open invoice, which step it is at and when the next one goes
PASS R6/schedule: an invoice part-way through shows its last step and date, and the next
PASS R6/schedule: it carries no addresses and no message text
PASS R6/api: GET /api/reminders answers with the schedule under the API key
PASS R6/api: no API route can send, pause or change a reminder (no POST route mentions one)
PASS R6/mcp: reminder_schedule is a tool, marked read-only, and a read-only key sees it
PASS R6/mcp: no tool sends, pauses or edits a reminder -- every tool that mentions one is read-only, and none sends mail
PASS R6/mcp: the server now carries ten tools (the nine of 3.1 and reminder_schedule)
PASS R6/mcp: calling it with a read-only key returns the same schedule the API does
PASS R6/mcp: the server tells the agent that reminders go only on the owner's schedule and no tool can send one
PASS R7/version: every version the app states is 3.2.1 (App::VERSION; 9 places)
PASS R7/version: the PDF caption names the release (App::VERSION), not the 3.0 line
PASS R7/docs: README carries the feature line, the changelog opens with the current version (3.2.1) and keeps the 3.2.0 reminders entry
PASS R7/docs: API.md documents GET /api/reminders, the reminder_schedule tool and /cron/reminders, and that none of them sends
PASS R7/docs: QUICKSTART says the reminders are off until switched on and points at Preview today
PASS R7/docs: the FAQ answers "Will it spam my customers?", "What if they already paid?" and "Can I turn it off for one client?"
PASS R7/docs: every [src: file:line] in the FAQ and the manual names a file that exists and a line inside it
PASS R7/docs: the FAQ and the manual state the current version (3.2.1) and the new route counts
PASS dashboard: the setup card counts its own steps (six items, "six steps")
PASS docs: README and QUICKSTART say PHP 8.1+, and the README names the Single or Extended licence
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: a paused webhook is sent nothing
PASS webhooks: invoice.created is sent by the browser action where it happens [302]
PASS webhooks: estimate.created is sent by the browser action where it happens [302]
PASS webhooks: estimate.accepted is sent by the browser action where it happens [302]
PASS webhooks: payment.recorded is sent by the browser action where it happens [302]
PASS webhooks: invoice.paid is sent by the browser action where it happens [302]
PASS webhooks: every event the Settings page offers is sent somewhere
PASS webhooks: converting an estimate also sends invoice.created for the new invoice
PASS backup gate: the app answered under PHP's built-in server with no PHP error in its log
PASS backup gate: a signed-in clerk gets 403 on /backup.json and /backup.sqlite, and nothing is served
PASS backup gate: the administrator gets both backups (JSON and the SQLite file)
PASS backup gate: the JSON backup leaves out the 2FA seed, SMTP password, webhook secret and SSO secret
PASS backup gate: the scheduled-backup route refuses a missing and a wrong token
PASS backup gate: a signed-in clerk gets 403 on the restore page and on POST /restore, /restore/commit, /settings/backup-token [{"page":403,"posts":{"\/restore":403,"\/restore\/commit":403,"\/settings\/backup-token":403},"csrf":true}]
PASS backup gate: a signed-in clerk without the token is refused by the scheduled-backup route
PASS backup gate: the administrator opens the restore page and is not refused on any restore or backup-token route [{"page":200,"posts":{"\/restore":302,"\/restore\/commit":302,"\/settings\/backup-token":302}}]
PASS backup gate: the JSON backup carries no reusable credential from any table (10 columns: api_keys.token_hash, invites.token_hash, settings.smtp_pass, settings.backup_token, settings.ical_token, settings.oidc_client_secret, users.password_hash, users.totp_secret, users.totp_recovery, webhooks.secret)
PASS backup gate: each planted credential row is in the backup with the cell redacted, not dropped
PASS links: https behind a trusted TLS proxy (X-Forwarded-Proto or CF-Visitor); a direct client cannot claim it; plain http stays http
PASS links: nothing builds a scheme from $_SERVER[HTTPS] alone any more (src, controllers, views)
PASS demo ribbon: the client's invoice or estimate page (and its print view) says what the page is, never "signed in as the admin"
PASS demo ribbon: every admin page keeps the admin wording
PASS demo ribbon: below 600px it starts as the small "Own it" pill; a tap opens the card, and that choice holds for the session
PASS demo ribbon: injected just before </body>; output with no </body> (JSON, CSV, PDF) passes through untouched
================================================
262 passed, 0 failed
================================================
← Back to Invora · Manual · Quickstart · API