Invora · Test run · run 2026-11-07
Invora Test run, as shipped in the download
479 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.
[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 4.1.3 (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 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
== 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 4.1.3 (App::VERSION; 10 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 (4.1.3) 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 (4.1.3) 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 license
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: estimate.declined 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 webhooks: 3.3 an estimate accepted on its client link is recorded with the name and note
PASS webhooks: 3.3 it becomes exactly one DRAFT invoice with its lines, and a second click adds nothing
PASS webhooks: 3.3 a declined estimate stays declined: a later accept changes nothing
PASS webhooks: 3.3 the online answers are on the audit trail as the client
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 (14 columns: api_keys.token_hash, backup_state.s3_secret, invites.token_hash, settings.smtp_pass, settings.backup_token, settings.ical_token, settings.oidc_client_secret, settings.stripe_webhook_secret, settings.sms_api_key, updater_state.license_api_key, 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
== 3.3 · late fees ==
PASS 3.3/money: with no fees the reference case is unchanged ($1,505.75) (got 150575, want 150575)
PASS 3.3/money: a $25 fee lands after the discount and the tax ($1,530.75) (got 153075, want 153075)
PASS 3.3/money: the fee is not discounted and not taxed (discount $75.00, tax $80.75, fees $25.00)
PASS 3.3/rule: off by default on an upgraded database
PASS 3.3/rule: 20 days overdue under a 14-day rule → fee 1 of $25.00 applies today
PASS 3.3/rule: 5 days overdue waits, and says when (the due date + 14 days)
PASS 3.3/rule: never retroactive — an invoice that fell due before the rule started is left alone
PASS 3.3/rule: a draft is never charged
PASS 3.3/run: applies the fee once, to the right invoice only
PASS 3.3/run: the invoice now owes $1,025.00 (got 102500, want 102500)
PASS 3.3/run: a second run the same day adds nothing (once means once)
PASS 3.3/guard: (invoice, fee number) is UNIQUE — two page loads cannot both add the same fee
PASS 3.3/audit: the fee is on the audit trail with the rule that added it
PASS 3.3/monthly: fee 2 waits until 30 days after fee 1 was applied
PASS 3.3/monthly: at the limit (3) nothing more is added
PASS 3.3/monthly: an install that was off for months adds ONE fee, never a back-dated burst
PASS 3.3/percent: 1.5% of the $1,000.00 unpaid, not of $1,025.00 with the earlier fee (got 1500, want 1500)
PASS 3.3/pause: a paused invoice is held, with the reason
PASS 3.3/paper: the invoice shows the fee as its own line, with the day it was added
PASS 3.3/api: a document states fees_cents and lists the fee
PASS 3.3/reminder: a reminder states the amount with the fee AND names the fee
PASS 3.3/pdf: the PDF carries the fee line too
PASS 3.3/recurring: the next draft from an invoice with a fee carries no fee ($1,000.00)
PASS 3.3/waive: the invoice is back to $1,000.00 (got 100000, want 100000)
PASS 3.3/waive: the row stays, marked, and the next run does not re-add it
PASS 3.3/off: switched off, a run adds nothing
== 3.3 · paid means paid (Stripe) ==
PASS 3.3/verify: a correctly signed body passes
PASS 3.3/verify: another secret, a changed body, or a timestamp 6 minutes old all fail
PASS 3.3/verify: several v1 signatures (Stripe rolls secrets) — one valid is enough
PASS 3.3/link: a buy.stripe.com link carries the invoice reference (got 'https://buy.stripe.com/test_abc?client_reference_id=invora-62', want 'https://buy.stripe.com/test_abc?client_reference_id=invora-62')
PASS 3.3/link: an existing query keeps its parameters (got 'https://buy.stripe.com/abc?prefilled_email=x%40y.z&client_reference_id=invora-62', want 'https://buy.stripe.com/abc?prefilled_email=x%40y.z&client_reference_id=invora-62')
PASS 3.3/link: another host, or a link that already names a reference, is left alone
PASS 3.3/link: the Pay button and every email use the referenced link (got 'https://buy.stripe.com/test_abc?client_reference_id=invora-62', want 'https://buy.stripe.com/test_abc?client_reference_id=invora-62')
PASS 3.3/amount: Stripe cents stay cents; yen gain two places; a three-decimal amount loses one (got array (
0 => 12345,
1 => 100000,
2 => 1234,
3 => NULL,
), want array (
0 => 12345,
1 => 100000,
2 => 1234,
3 => NULL,
))
PASS 3.3/off: while switched off the endpoint answers 404 and records nothing
PASS 3.3/refuse: a forged signature gets 400 and writes nothing
PASS 3.3/record: a signed paid checkout records $300.00 against the invoice and it shows paid
PASS 3.3/record: through the one payment path — on the audit trail as the Stripe webhook
PASS 3.3/reminders: a paid invoice leaves the reminder schedule (paid means no further reminder)
PASS 3.3/replay: Stripe retrying the same event records nothing twice
PASS 3.3/replay: a second event about the same checkout session records nothing twice
PASS 3.3/pending: a bank debit not yet confirmed is logged, not recorded
PASS 3.3/partial: once confirmed, a part-payment is recorded and the invoice shows partial
PASS 3.3/currency: a payment in another currency is logged as a mismatch and records nothing
PASS 3.3/unmatched: an unknown invoice is logged as unmatched
PASS 3.3/metadata: a link that names the invoice number in metadata.invoice finds it
PASS 3.3/ignored: an event Invora does not act on is logged as ignored
PASS 3.3/secret: the signing secret is left out of the shareable JSON backup (the core redacts *_secret)
== 3.3 · client portal ==
PASS 3.3/portal: no link until one is made
PASS 3.3/portal: the token opens its own client and nothing else looks one up
PASS 3.3/portal: open invoices, oldest due first; the estimate waits for an answer
PASS 3.3/portal: never a draft, never another client's invoice
PASS 3.3/portal: outstanding $300.00, of which $200.00 past due (got array (
0 => 30000,
1 => 20000,
), want array (
0 => 30000,
1 => 20000,
))
PASS 3.3/portal: a part-paid invoice past its due date still counts as past due ($150.00) (got array (
0 => 25000,
1 => 15000,
), want array (
0 => 25000,
1 => 15000,
))
PASS 3.3/portal: the page lists their documents, links the statement PDF, and is noindex / no-referrer
PASS 3.3/statement: a real PDF listing the client's invoices and what is outstanding, nobody else's
PASS 3.3/portal: switching the link off closes it at once
== 3.3 · online estimate answers ==
PASS 3.3/estimates: off by default, a sent estimate cannot be answered online
PASS 3.3/estimates: switched on, a sent estimate within its date can be answered
PASS 3.3/estimates: one past its valid-until date cannot
PASS 3.3/estimates: an accepted estimate becomes a DRAFT invoice with the same lines and total
PASS 3.3/estimates: the client's estimate page offers Accept and Decline with their name
PASS 3.3/estimates: switched off, the page shows no answer form
PASS 3.3/webhooks: estimate.declined is an event a webhook can subscribe to
PASS 3.3/wiring: index.php loads the late-fee rule, the Stripe handler and the 3.3 routes
PASS 3.3/copies: extra addresses are cleaned and capped
PASS 3.3/copies: every mail to the client goes to each address and is recorded
PASS 3.3/copies: a refused main send sends no copies
PASS 3.3/copies: the client form refuses a bad address
== 3.4 · custom fields ==
PASS 3.4/fields: none are named on a fresh install, and the paper shows nothing extra
PASS 3.4/fields: names are trimmed, inner spaces folded, blank slots dropped
PASS 3.4/fields: two slots cannot share a name (the API finds a field by name)
PASS 3.4/fields: a name over 40 characters is refused
PASS 3.4/fields: a list where a name should be is refused
PASS 3.4/fields: labels() gives the named slots in slot order
PASS 3.4/fields: slots() gives all five for the Settings form
PASS 3.4/api: fields are matched by name (any case), trimmed and stored
PASS 3.4/api: the document comes back with fields by name
PASS 3.4/api: a document without fields returns an empty object, not a list
PASS 3.4/api: an unknown field name is refused and the refusal names the fields that exist
PASS 3.4/api: fields as a list instead of an object is refused
PASS 3.4/api: a value is clipped to 120 characters
PASS 3.4/api: the REST body accepts a fields object (the nested-value guard lets it through)
PASS 3.4/mcp: create_invoice and create_estimate take fields
PASS 3.4/paper: each filled-in field prints with its name
PASS 3.4/pdf: the PDF carries the fields too
PASS 3.4/rename: renaming a field keeps its value under the new name
PASS 3.4/clear: a cleared field no longer prints
PASS 3.4/clear: the editor keeps a value under a cleared field when the document is saved again
PASS 3.4/editor: a list where a value should be is refused
PASS 3.4/copies: an estimate turned into an invoice keeps its fields
PASS 3.4/copies: the recurring draft carries the fields of the invoice it copies
PASS 3.4/settings: the Custom fields form posts five name boxes to /settings/doc-fields
PASS 3.4/editor: the editor offers a box per named field, filled with the document's value
PASS 3.4/editor: saving writes the values on both the insert and the update path
PASS 3.4/settings: the save is admin-only, checks CSRF, and is audited
PASS 3.4/wiring: index.php loads the fields and the 3.4 routes
== 3.4 · the import's dry run ==
PASS 3.4 · import dry run: counts what the import takes, gives its reason, writes nothing
PASS 3.4 · import dry run: the import then agrees row for row
PASS 3.4 · import dry run: the upload uses the preview and no import skips the row handler
== 4.1 · payment reminders by text (the owner's own SMS gateway) ==
PASS 4.1/off: a fresh install has text messages off, texting hours 09:00 to 20:00, and no client agreed
PASS 4.1/off: with text messages switched off nothing is texted, nothing is logged, and the reason is given
PASS 4.1/off: a gateway address that is not https arms nothing
PASS 4.1/off: with the automatic reminder schedule off there are no texts either
PASS 4.1/hours: at 06:30 nothing goes, and the reason says texts wait for the hours
PASS 4.1/hours: 20:00 is already outside (the window ends at the hour)
PASS 4.1/hours: the hours are the owner's local time (12:00 UTC is before 09:00 in New York)
PASS 4.1/hours: inside the hours the due text goes (09:00, step -3)
PASS 4.1/consent: the query that picks who is texted takes only clients who agreed and have a number
PASS 4.1/consent: the agreement sits inside that query (source)
PASS 4.1/consent: of three clients due, only the one who agreed is texted, at their number
PASS 4.1/once: the text is logged once, as step -3, sent, under its send key
PASS 4.1/text: it names the business, the invoice, what is owed, the due date and the payment link
PASS 4.1/text: it ends with the client's stop link (a page, not a one-click change)
PASS 4.1/fields: by default the gateway gets JSON with to and text, and the key as a Bearer token
PASS 4.1/fields: Twilio's shape: form-encoded To, From and Body, with the key as HTTP Basic
PASS 4.1/fields: "none" sends no key at all
PASS 4.1/fields: a phone number is cleaned to digits with its +, and nonsense is refused
PASS 4.1/text: with no payment link the line that offers one is left out
PASS 4.1/text: wording that leaves out the business still names it
PASS 4.1/once: run again the same day and the next two days, still one text (step -3 is done)
PASS 4.1/once: the due-day step texts once, however often the job runs
PASS 4.1/once: the send key is UNIQUE in the database, so two runs at once cannot both claim a step
PASS 4.1/once: each invoice has its own keys (a new recurring invoice starts fresh)
PASS 4.1/once: two runs racing for one step: the first claim wins, the second gets nothing
PASS 4.1/once: a failed step is not re-claimed the same day; the next day exactly one claim takes it (UPDATE … WHERE, one row)
PASS 4.1/stop: the stop link opens a page with one button and changes nothing by itself
PASS 4.1/stop: stopping turns the agreement off once, and is in the audit trail
PASS 4.1/stop: after the stop, the next step (7 days after) sends no text
PASS 4.1/failure: a refused text is reported with the gateway's answer and never throws
PASS 4.1/failure: it is not tried again the same day
PASS 4.1/failure: the next day it is tried once more, on the same row, and goes
PASS 4.1/failure: an unreachable gateway is an answer, not a crash
PASS 4.1/failure: the dashboard shows a refusal and the invoice page has a text log (source)
PASS 4.1/demo: a demo install records the text as demo and calls no gateway
PASS 4.1/consent: ticking the box records the agreement and when; saving again keeps the first date
PASS 4.1/consent: unticking withdraws it (date cleared, recorded)
PASS 4.1/consent: the client form shows the box unticked until a yes is recorded
PASS 4.1/backup: the gateway key is left out of the shareable backup
PASS 4.1/gdpr: anonymizing a client blanks the numbers in the text log
PASS 4.1/wiring: index.php loads the module, the channel and the controller; the cron route texts too
== 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 Invora 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 Invora 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 Invora, 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 13 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 (26 tables dumped), outside the 2 column(s) kept by ruling (possible legal records)
PASS erase planting · sqlite: the erase page (/clients/1/edit) says what an erase keeps: "Kept: the name and note a client typed when accepting or dec…"
PASS erase planting · sqlite: MANUAL.md says the same: "Kept: the name and note a client typed when accepting or dec…"
================================================
479 passed, 0 failed
================================================
← Back to Invora · Manual · Quickstart · API