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