Ownware
Home›Invora›Test run
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

Affiliate program
Recommend tools people own — earn 35% on every sale. 90-day tracking, instant delivery, payouts by Lemon Squeezy.
Become an affiliate →