Ownware
Home›Commissa›Test run
Commissa · Test run · run 2026-09-25

Commissa Test run, as shipped in the download

299 checks passed, 0 failed, when the suite that ships in the zip was run on 2026-09-25 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 normalisation ==
  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 neutralised (got '\'=SUM(A1)', want '\'=SUM(A1)')
  PASS  +1 neutralised (got '\'+1', want '\'+1')
  PASS  -SUM neutralised (got '\'-SUM(A1)', want '\'-SUM(A1)')
  PASS  @cmd neutralised (got '\'@cmd', want '\'@cmd')
  PASS  leading TAB neutralised (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  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

--------------------------------------------------------

== 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 v3.1.5
  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: anonymise 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 licence
  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 (9 columns: api_keys.token_hash, invites.token_hash, settings.oidc_client_secret, settings.backup_token_hash, settings.smtp_pass, 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
299/299 PASS, 0 FAIL

← Back to Commissa · 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 →