Commissa · Test run · run 2026-11-07
Commissa Test run, as shipped in the download
464 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. flatCents: pure integer round-half-up commission ==
PASS $1000 @ 5% = $50 (got 5000, want 5000)
PASS $1234.56 @ 7% (got 8642, want 8642)
PASS flatCents(123456,700) == 8642 (got 8642, want 8642)
PASS 1c @ 50% rounds up to 1c (got 1, want 1)
PASS 1c @ 49.99% rounds to 0c (got 0, want 0)
PASS 0 volume -> 0 (got 0, want 0)
PASS 0% rate -> 0 (got 0, want 0)
PASS 100% rate -> whole (got 4200, want 4200)
PASS rate clamps above 100% (got 1000, want 1000)
== 2. Tiered payout: progressive bands, deal straddles a boundary ==
PASS cumulative $6,000 = $300 (got 30000, want 30000)
PASS cumulative $10,000 = $500 (got 50000, want 50000)
PASS cumulative $13,000 = $740 (got 74000, want 74000)
PASS cumulative $25,000 = $1,700 (got 170000, want 170000)
PASS cumulative $27,000 = $1,900 (got 190000, want 190000)
PASS straddle $6k->$13k = $440 (got 44000, want 44000)
PASS straddle $22k->$27k = $440 (got 44000, want 44000)
PASS $13k->$22k all @8% = $720 (got 72000, want 72000)
PASS $0->$6k all @5% = $300 (got 30000, want 30000)
PASS zero deal earns 0 (got 0, want 0)
== 3. Reconciliation: statement lines sum EXACTLY to the period total ==
PASS 4 lines produced (got 4, want 4)
PASS line commissions [30000,44000,72000,44000] (got array (
0 => 30000,
1 => 44000,
2 => 72000,
3 => 44000,
), want array (
0 => 30000,
1 => 44000,
2 => 72000,
3 => 44000,
))
PASS lines sum = $1,900 (got 190000, want 190000)
PASS sum == cumulative(total volume) EXACTLY (got 190000, want 190000)
PASS running commission ends at gross (got 190000, want 190000)
PASS running volume ends at total (got 2700000, want 2700000)
PASS reconciliation invariant holds across 25 tier×sequence combos (no penny lost, none negative)
-- flat reconciliation --
PASS flat lines sum == flatCents(total,733) (got 74516, want 74516)
== 4. Category scheme: per-category rate + default ==
PASS Software -> 10% (got 1000, want 1000)
PASS case-insensitive match (got 1000, want 1000)
PASS unknown -> default 4% (got 400, want 400)
PASS blank -> default (got 400, want 400)
PASS category commissions [120000,30000,6000,4000] (got array (
0 => 120000,
1 => 30000,
2 => 6000,
3 => 4000,
), want array (
0 => 120000,
1 => 30000,
2 => 6000,
3 => 4000,
))
PASS category gross = $1,600 (got 160000, want 160000)
== 5. Only WON deals earn commission ==
PASS pending deal excluded (2 lines) (got 2, want 2)
PASS only won volume counted ($2000 @10% = $200) (got 20000, want 20000)
== 6. Rate labels + tier normalization ==
PASS single-band label (got '5%', want '5%')
PASS straddle label 5%->8% (got '5% → 8%', want '5% → 8%')
PASS band-2 only label (got '8%', want '8%')
PASS straddle 8%->10% (got '8% → 10%', want '8% → 10%')
PASS normalizeTiers sorts ascending, open band last (got array (
0 =>
array (
0 => 1000000,
1 => 500,
),
1 =>
array (
0 => 2500000,
1 => 800,
),
2 =>
array (
0 => NULL,
1 => 1000,
),
), want array (
0 =>
array (
0 => 1000000,
1 => 500,
),
1 =>
array (
0 => 2500000,
1 => 800,
),
2 =>
array (
0 => NULL,
1 => 1000,
),
))
== 7. Basis-point + cents parsing / formatting (never floats) ==
PASS parse "5" -> 500bp (got 500, want 500)
PASS parse "8.5" -> 850bp (got 850, want 850)
PASS parse "6.25" -> 625bp (got 625, want 625)
PASS parse "12.5%" -> 1250bp (got 1250, want 1250)
PASS parse "abc" -> null (got NULL, want NULL)
PASS parse "1.234" -> null (got NULL, want NULL)
PASS fmt_bp 500 -> "5%" (got '5%', want '5%')
PASS fmt_bp 625 -> "6.25%" (got '6.25%', want '6.25%')
PASS fmt_bp 850 -> "8.5%" (got '8.5%', want '8.5%')
PASS fmt_bp 0 -> "0%" (got '0%', want '0%')
PASS bp round-trip 733 (got 733, want 733)
PASS parse "$6,000.00" cents (got 600000, want 600000)
PASS fmt_cents 600000 (got '6,000.00', want '6,000.00')
PASS fmt_cents -25000 (got '-250.00', want '-250.00')
PASS clamp_bp caps at 10000 (got 10000, want 10000)
PASS clamp_bp floors at 0 (got 0, want 0)
PASS clamp_signed_cents keeps negative (got -25000, want -25000)
PASS clamp_cents floors at 0 (got 0, want 0)
PASS clamp_int caps 15-digit (got 2000000000, want 2000000000)
== 8. Period date helpers (inclusive, no off-by-one) ==
PASS monthBounds 2026-02 (leap? no) end 28 (got array (
0 => '2026-02-01',
1 => '2026-02-28',
), want array (
0 => '2026-02-01',
1 => '2026-02-28',
))
PASS monthBounds 2024-02 leap end 29 (got array (
0 => '2024-02-01',
1 => '2024-02-29',
), want array (
0 => '2024-02-01',
1 => '2024-02-29',
))
PASS monthBounds 2026-07 end 31 (got array (
0 => '2026-07-01',
1 => '2026-07-31',
), want array (
0 => '2026-07-01',
1 => '2026-07-31',
))
PASS biweekly 14-day inclusive (start..+13) (got array (
0 => '2026-05-01',
1 => '2026-05-14',
), want array (
0 => '2026-05-01',
1 => '2026-05-14',
))
PASS validRange ok (got true, want true)
PASS validRange reversed rejected (got false, want false)
PASS validRange bad date rejected (got false, want false)
== 9. CSV: numeric-aware formula-injection guard ==
PASS =SUM neutralized (got '\'=SUM(A1)', want '\'=SUM(A1)')
PASS +1 neutralized (got '\'+1', want '\'+1')
PASS -SUM neutralized (got '\'-SUM(A1)', want '\'-SUM(A1)')
PASS @cmd neutralized (got '\'@cmd', want '\'@cmd')
PASS leading TAB neutralized (got '\' x', want '\' x')
PASS -45.00 kept numeric (got '-45.00', want '-45.00')
PASS -250 kept numeric (got '-250', want '-250')
PASS 1250 kept numeric (got '1250', want '1250')
PASS plain text untouched (got 'Maya Rivera', want 'Maya Rivera')
PASS comma/quote RFC-4180 (got '"Rivera, ""the closer"""', want '"Rivera, ""the closer"""')
PASS header row (got 'Rep,Net', want 'Rep,Net')
PASS CRLF endings
PASS formula defused in body
PASS negative money stays numeric
== 10. CSRF: empty stored token must never validate ==
PASS empty stored + empty given -> REJECT
PASS empty stored + any given -> REJECT
PASS matching token -> accept
PASS mismatched -> reject
== 11. Pdf: valid, non-trivial document ==
PASS %PDF header
PASS %%EOF
PASS xref table
PASS page object
PASS non-trivial length
== 12. Payout end-to-end (SQLite throwaway DB) — hand-derived cents ==
PASS Maya rule is tiered (got 'tiered', want 'tiered')
PASS Maya won deals = 4 (pending excluded) (got 4, want 4)
PASS Maya volume = $27,000 (got 2700000, want 2700000)
PASS Maya gross = $1,900 (got 190000, want 190000)
PASS Maya lines sum to gross (got 190000, want 190000)
PASS Maya adjustments = +$500 (got 50000, want 50000)
PASS Maya NET = $2,400 (got 240000, want 240000)
PASS Lena rule tiered (global) (got 'tiered', want 'tiered')
PASS Lena gross = $350 (got 35000, want 35000)
PASS Lena net = $350 (no adj) (got 35000, want 35000)
PASS Diego rep rule flat (got 'flat', want 'flat')
PASS Diego gross = $910 (got 91000, want 91000)
PASS Diego adjustments = -$250 (got -25000, want -25000)
PASS Diego NET = $660 (got 66000, want 66000)
PASS Priya rule category (got 'category', want 'category')
PASS Priya gross = $1,560 (got 156000, want 156000)
PASS summary covers 4 reps (got 4, want 4)
PASS period gross total = $4,720 (got 472000, want 472000)
PASS period volume total = $66,000 (got 6600000, want 6600000)
PASS period adjustments net = +$250 (got 25000, want 25000)
PASS period NET total = $4,970 (got 497000, want 497000)
PASS gross + adjustments == net (reconciles) (got 497000, want 497000)
== 13. Rule resolution: rep beats global, latest effective date wins ==
PASS Maya (no rep rule) -> global tiered (got 'Standard tiered', want 'Standard tiered')
PASS Diego -> rep flat rule (got 'Diego 7%', want 'Diego 7%')
PASS Val on 2026-05-31 -> latest rep rule (9%) (got 'Val 9% (May 20)', want 'Val 9% (May 20)')
PASS Val on 2026-05-17 -> earlier rep rule (6%) (got 'Val 6% (May 15)', want 'Val 6% (May 15)')
PASS Val on 2026-05-10 -> falls back to global tiered (got 'Standard tiered', want 'Standard tiered')
PASS Val before any global rule -> null (got NULL, want NULL)
== 14. Period range inclusive; no off-by-one ==
PASS boundary days included, outside excluded (2 deals) (got 2, want 2)
PASS single-day range includes that day (got 1, want 1)
PASS empty range excludes all (got 0, want 0)
== 15. A rep with no applicable rule earns 0 but volume is shown ==
PASS no rule -> null (got NULL, want NULL)
PASS no rule -> gross 0 (got 0, want 0)
PASS no rule -> volume still surfaced ($4,000) (got 400000, want 400000)
== 16. Auth (password hashing) ==
PASS password verifies
PASS wrong password fails
PASS hash is not plaintext
== 17. Oversize/adversarial writes land clamped ==
PASS 9000-char rep name clipped to 190 (got 190, want 190)
PASS 15-digit amount clamped to 2e9 (got 2000000000, want 2000000000)
PASS product clipped to 190 (got 190, want 190)
PASS category clipped to 120 (got 120, want 120)
PASS huge negative adjustment clamped to -2e9 (got -2000000000, want -2000000000)
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
--------------------------------------------------------
== 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_block_whole
PASS v2: no_rival_login_table
PASS v2: mint
PASS v2: revoke
PASS v2: totp
PASS v2: recovery
PASS v2: delivery
PASS v2: events_are_commissas
PASS v2: rate
PASS v2: csv_dry
PASS v2: backup_tables
PASS v2: upgrade_from_1x
PASS v2: payout_penny_exact
PASS v2: lines_telescope
PASS v2: adjustment_moves_net_only
PASS v2: serializer_matches_core
PASS v2: pending_excluded
PASS v2: index.php calls Database::migrate() at boot — the ONLY upgrade path for an existing install
PASS v2: routes registered (api, 2fa, security, backup, import, healthz)
PASS v2: 2FA intercepts login + login is rate-limited
PASS v2: API reuses the tested core (deal guard, deal insert, Commission::payout)
PASS v2: the deal guard is defined ONCE, in the controller both callers share
PASS v2: the API never re-adds money — no arithmetic on *_cents in the API controller
PASS v2: statementOut reads the keys Commission::statementLines actually returns
PASS v2: no ternary key re-reads (PLAN item 10)
PASS v2: a malformed date is REFUSED, not silently stamped with today
PASS v2: the deal import never invents a rep
PASS v2: dark palette + zero hardcoded white backgrounds
PASS v2/kit: dark ink flip spares ghost/danger buttons (layout brand override) and native controls follow the theme (kit)
PASS v2: theme.js + qr.js ship and layout loads theme.js + has a toggle
PASS v2: API.md ships + sw cache is v4.1.3
PASS v2: cli-server base guard present (PLAN item 9)
PASS v2: /backup.sqlite is carved out of the blanket .sqlite block in BOTH dev router and .htaccess
PASS v2: the API serializer is Commissa's, not a copied one from another product
== Own It 3.0 ==
PASS v3: every copied core is byte-identical to the shared core
PASS v3: the statement renders on the core Pdf and the legacy writer is gone
PASS v3: index.php calls Database::migrate() after the schemaExists guard
PASS v3: every column the API-keys view prints is actually selected
PASS v3: every MCP tool carries a role guard
PASS v3: admin still administers, manager records deals, viewer writes nothing
PASS v3: Database::migrate() ensures the settings singleton
PASS v3: the installer writes its settings even though the row already exists
PASS v3: v3_mcp_spec() provides every key Mcp::handle() reads
PASS v3: GET /mcp reaches the transport hint WITHOUT the key check in front of it
PASS v3: MCP reads money through Commission::, never a second computation
PASS v3: every adjustment goes through commissa_post_adjustment()
PASS v3: a dispute is decided ONCE, and the refusal says why
PASS v3: an upheld dispute records WHICH adjustment settled it
PASS v3: every admin-surface handler is permission-gated, not merely login-gated
PASS v3: commission rules are not reachable through the manager's grants
PASS v3: the role ladder means what the Users screen says it means
PASS v3: every POST route that mutates is audited
PASS v3: the audit CSV reads the same rows the screen does, with the core's writer
PASS v3: the shareable backup redacts credentials, not just the SMTP password
PASS v3: password_hash and token_hash are in the redaction list
PASS v3: restore reports what it actually wrote
PASS v3: the restore page states the JSON-vs-.sqlite trade-off
PASS v3: anonymize refuses while a rep is active or still owed money
PASS v3/v3.1: rep email exists but only through the operator's own SMTP, off by default, and never automatically
PASS v3/kit: commissa_adjust_inline() routes through the ONE adjustment writer (so the audit exemption above is earned)
PASS v3: the accent AND the AA-safe ink are published to BOTH theme selectors
PASS v3: no rule hardcodes the light value of a token the dark theme redefines
PASS v3/kit: dark mode reads its label and button ink from tokens, not literals
PASS v3: the uploaded logo is RENDERED, with a URL /media can serve
PASS v3: the statement PDF is built from Commission::payout, not re-derived
PASS v3: P6 is a clean opt-out — no Ical core shipped, no dead calendar route
PASS v3: every view gets the variables it reads from its controller
PASS v3: deploy kit ships
PASS v3: the deploy healthcheck is the frozen template's, not a hand-written substitute
PASS v3: no placeholder survived the fill
PASS v3: README documents the 3.0 release
PASS v3: API.md documents the MCP surface
PASS v3: no 2.0.0 version string survives outside the tests
PASS v3: no REST handler writes on the bare key guard
== UI kit: commission run + inline adjust + dashboard ==
PASS kit: layout loads the kit css+js, groups the nav (>=3 groups), keeps the theme toggle and a who-line
PASS kit: no theme.js; style.css is an app layer with a dark accent; pre-kit scratch file removed
PASS kit: App::asset() is versioned by mtime and the service worker revalidates code
PASS kit: no font or script is fetched from another host (fonts bundled)
PASS routes: POST /periods/{id}/reps/{rep}/adjust is registered, role-gated on adjustment.edit and CSRF-checked
PASS hero/js: the inline POST sets Content-Type — without it the browser sends text/plain and PHP never fills $_POST
PASS adjust/permission: a viewer may not adjust; a manager and an admin may
PASS hero: the run is a rep x period matrix showing the rule applied, with inline adjust wired
PASS hero: a rep with no won deals is still listed at zero rather than silently dropped
PASS dashboard: the first-run checklist and needs-your-action are computed from the DATABASE
PASS adjust/happy: a bonus lands, and the row comes back with the engine-computed net
PASS adjust/sign rule: a clawback stores NEGATIVE however the amount was typed
PASS adjust/refused: a zero amount is refused with a human sentence and writes nothing
PASS adjust/refused: an adjustment with no reason is refused — the audit trail needs the why
PASS adjust/totals: the period totals returned match a fresh engine run over the whole period
PASS adjust/totals: the returned period net equals the engine total, so the matrix cannot drift
[audit] write failed: SQLSTATE[HY000]: General error: 1 no such table: audit_log
[audit] write failed: SQLSTATE[HY000]: General error: 1 no such table: audit_log
PASS close/audit: the close writes its audit row (the period route delegates to this writer)
PASS close/happy: closing freezes the period, stamped with when
PASS close/frozen: editing a deal after the close does not change the closed statement
PASS close/frozen: the period run, the rep statement and the API read the same frozen figures
PASS close/guard: nothing is added to a closed period — the run, the form and a dispute settlement all refuse
PASS close/guard: a closed period cannot be closed twice, and a period that has not ended cannot be closed
PASS close/permission: a manager may close; only an administrator may reopen; a viewer may do neither
PASS close/routes: close and reopen are registered, and deleting or editing a closed period is refused
PASS close/reopen: a reopened period computes from today's deals again
PASS nav: every sidebar link is highlighted by the page it opens
PASS nav: no page highlights a sidebar item that does not exist
PASS ip: a direct visitor's forged headers are ignored
PASS ip: behind a trusted proxy the forwarded visitor is used
PASS ip: CF-Connecting-IP wins over the X-Forwarded-For chain
PASS ip: the chain is read right-to-left, past trusted hops
PASS ip: a Cloudflare edge is not a trusted proxy by default
PASS ip: CIDR maths (v4 boundaries + v6, no cross-family match)
== v3.1: rep statement email + CRM import presets ==
PASS P6/happy: a raw HubSpot deal export maps without editing the header row
PASS P6/happy: a raw Pipedrive deal export maps without editing the header row
PASS P6/happy: a raw Salesforce deal export maps without editing the header row
PASS P6/happy: a raw Spreadsheet deal export maps without editing the header row
PASS P6/refusal: a file that is not a deal export is refused and NAMES the columns it needs
PASS P6/edge: a rep is still matched by name and never created from an import
PASS P1/permission: master switch off → nothing sent, nothing recorded
PASS P1/permission: the per-event flag gates too
PASS P1/refusal: a rep with no address is refused with a reason — reps have no logins, so there is nowhere else to send
PASS P1/refusal: unconfigured SMTP refuses honestly and writes no outbox row
PASS P1/happy: an enabled send is built, recorded and reported
PASS P1/content: the statement mail carries THIS rep's figures, says Commissa pays nobody, and names no other rep
PASS P1/idempotency: sending the same statement twice records two attempts and changes no figure
PASS P1/never automatic: the ONLY trigger is an explicit POST — no cron route, no send inside a status transition
PASS P1/permission: the endpoint is role-gated and CSRF-checked like every other mutation here
PASS v3.1 audit-fix: statement_email calls v3_audit(action, entity, id, detail, $me) — the $me-first call fataled live (found in review, 2026-08-20)
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 docs: README and QUICKSTART say PHP 8.1+, and the README names the Single or Extended license
PASS demo seed: last month is closed with its statements frozen, this month is open
PASS demo guard: the hourly reseed runs after the controllers load, so the seed can close a period
PASS webhooks: a deal recorded by the AI tool sends deal.recorded
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: deal.recorded is sent by the browser action where it happens [302]
PASS webhooks: every event the Settings page offers is sent somewhere
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 viewer gets 403 on /backup.json and /backup.sqlite, and nothing is served
PASS backup gate: a signed-in manager 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 viewer gets 403 on the restore page and on POST /restore/upload, /restore/commit, /restore/token [{"page":403,"posts":{"\/restore\/upload":403,"\/restore\/commit":403,"\/restore\/token":403},"csrf":true}]
PASS backup gate: a signed-in viewer without the token is refused by the scheduled-backup route
PASS backup gate: a signed-in manager gets 403 on the restore page and on POST /restore/upload, /restore/commit, /restore/token [{"page":403,"posts":{"\/restore\/upload":403,"\/restore\/commit":403,"\/restore\/token":403},"csrf":true}]
PASS backup gate: a signed-in manager 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\/upload":302,"\/restore\/commit":302,"\/restore\/token":302}}]
PASS backup gate: the JSON backup carries no reusable credential from any table (12 columns: api_keys.token_hash, backup_state.s3_secret, invites.token_hash, settings.oidc_client_secret, settings.backup_token_hash, settings.smtp_pass, statement_links.token_hash, 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: every page carries the admin wording (the product has no public page)
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.2: statement links and the close, completed ==
PASS 3.2: a closed statement has a fingerprint an open one none
PASS 3.2: the fingerprint is stable
PASS 3.2: the difference shows per rep
PASS 3.2: a stale amount carries nothing
PASS 3.2: the carry lands in the next open period with a note
PASS 3.2: a second click carries nothing
PASS 3.2: then it shows settled
PASS 3.2: the closed statement is still what was paid
PASS 3.2: the carry is audited
PASS 3.2: carry arithmetic
PASS 3.2: a link is a 40 hex token stored only as its hash
PASS 3.2: the link opens that rep and period only
PASS 3.2: the rep sees their lines and can question each
PASS 3.2: the rep view has no owner controls
PASS 3.2: a question files one dispute on that line only
PASS 3.2: an expired link stops working
PASS 3.2: a switched off link stops working
PASS 3.2: the email names the fingerprint and the link
PASS 3.2: the link token is redacted from the shareable backup
PASS 3.2: index.php loads the 3.2 controller
PASS 3.2: a carry posts through the ONE adjustment writer (commissa_post_adjustment), never its own INSERT
PASS 3.2: the rep's link routes check the link, every time, and the question form carries a CSRF token
== 3.2 pass B: quotas, payout approval, the payroll file ==
PASS 3.2 pass B: quota bands become volume tiers with the quota
PASS 3.2 pass B: attainment in basis points
PASS 3.2 pass B: the stepped preset pays each band its rate
PASS 3.2 pass B: quota lines telescope to the period total
PASS 3.2 pass B: the rule form refuses bad bands
PASS 3.2 pass B: the rule form saves bands in order
PASS 3.2 pass B: the presets are offered on the form
PASS 3.2 pass B: a typo in the quota table saves nothing
PASS 3.2 pass B: quotas are saved copied and audited
PASS 3.2 pass B: saving the same quota changes nothing
PASS 3.2 pass B: the accelerator pays the higher rate only above quota
PASS 3.2 pass B: no quota means the first band on all sales and says so
PASS 3.2 pass B: attainment shows on any scheme without changing its pay
PASS 3.2 pass B: the api carries quota and attainment
PASS 3.2 pass B: the statement shows attainment
PASS 3.2 pass B: the rep email names quota and attainment
PASS 3.2 pass B: approval is off by default
PASS 3.2 pass B: with approval on a period cannot close unapproved
PASS 3.2 pass B: only an administrator may approve
PASS 3.2 pass B: approval takes the total the page showed once
PASS 3.2 pass B: a change after approval voids it
PASS 3.2 pass B: undoing the change restores it
PASS 3.2 pass B: approved again the manager closes it
PASS 3.2 pass B: a period is approved once it has ended
PASS 3.2 pass B: a quota changed after close is a difference to carry
PASS 3.2 pass B: the payroll file needs a closed period
PASS 3.2 pass B: the payroll file carries the frozen net per rep
PASS 3.2 pass B: plain payroll amounts
PASS 3.2 pass B: doors carry their permission and csrf
PASS 3.2 pass B: the period page carries the approval card, the quota table and the payroll link
PASS 3.2 pass B: the close asks the approval rule, in the one close function
== 3.2: split deals ==
PASS 3.2 splits: the parts of a split always add up to the deal
PASS 3.2 splits: the form refuses a split that does not add up
PASS 3.2 splits: each rep is paid on their share at their own rates
PASS 3.2 splits: a rep with only a share is on the run and the volume adds up
PASS 3.2 splits: the api line carries the share
PASS 3.2 splits: the statement says whose deal and what share
PASS 3.2 splits: a rep with a share can question that line and no one else
PASS 3.2 splits: a split changed after close shows for both reps
PASS 3.2 splits: every save path goes through one parser
== 3.3: hold commission until the customer pays ==
PASS 3.3 paid: a blank payment date stays blank and a bad one is refused
PASS 3.3 paid: off by default every won deal counts on its own date
PASS 3.3 paid: on a period earns only what was paid in it
PASS 3.3 paid: unpaid won deals are held and listed at the reps share
PASS 3.3 paid: a deal paid later earns in the period of its payment
PASS 3.3 paid: a split paid later moves both shares and both reps are on the run
PASS 3.3 paid: a closed period keeps its frozen statement
PASS 3.3 paid: the api deal carries paid on
PASS 3.3 paid: the ai connection records paid on and refuses a bad one
PASS 3.3 paid: the form and settings carry the date and the switch
PASS 3.3 paid: the import reads a paid date and refuses a bad one
PASS 3.3 paid: the paid endpoint is routed permission checked and audited
PASS 3.3 paid: the statement lists what is held
== 3.3: the plan's change log ==
PASS 3.3 change log: the change log table exists and migrates twice
PASS 3.3 change log: a rule reads as plain lines for every kind
PASS 3.3 change log: the difference reads before to after added and removed
PASS 3.3 change log: the one writer logs what changed and why and nothing when nothing changed
PASS 3.3 change log: the reason is one clean line of at most 500
PASS 3.3 change log: a rep is named when the log is read so anonymising reaches it
PASS 3.3 change log: over HTTP: server up
PASS 3.3 change log: over HTTP: the form asks why and creating a rule is logged
PASS 3.3 change log: over HTTP: a change is logged before to after with its reason and an unchanged save is not
PASS 3.3 change log: over HTTP: deleting a rule and removing a rep with their rule are logged as they were
PASS 3.3 change log: over HTTP: the rules page shows the log newest first to a viewer
PASS 3.3 change log: over HTTP: a manager cannot change a rule so nothing is logged
PASS 3.3 change log: over HTTP: no php error in the server log
PASS 3.3 change log: index.php loads it, and create, edit, delete and removing a rep each write it through the one writer
== 3.3: the deals import's dry run ==
PASS 3.3 import dry run: the dry run counts only what the import takes and writes nothing
PASS 3.3 import dry run: the dry run gives the imports own reasons
PASS 3.3 import dry run: the import then agrees row for row
PASS 3.3 import dry run: the upload uses the preview and no import skips the row checks
== 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 Commissa 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 Commissa 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 Commissa, 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 11 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 (30 tables dumped), outside the 4 column(s) kept by ruling (possible legal records)
PASS erase planting · sqlite: the subject-access export, taken before the erase, shows every value the erase removes (9)
PASS erase planting · sqlite: the erase page (/gdpr) says what an erase keeps: "Kept: the text of the pay disputes they raised and the decis…"
PASS erase planting · sqlite: MANUAL.md says the same: "Kept: the text of the pay disputes they raised and the decis…"
464/464 PASS, 0 FAIL