Skip to content

release: payment-triggered provisioning + the owner dashboard (O3 + O4 part 1) - #104

Merged
mahmutkaya merged 3 commits into
mainfrom
develop
Jul 30, 2026
Merged

mahmutkaya merged 3 commits into
mainfrom
develop

Conversation

@mahmutkaya

Copy link
Copy Markdown
Contributor

Release — sofra developmain

Merge ≠ deploy here. This merge publishes :latest + :migrate; the box roll is a separate, ordered manual step (below). It ships a migration, so the order is not optional.

PR What
#103 Owner dashboard — "your app is ready, set your admin password" + the plan detail an ACTIVE owner could not see (O4 part 1)
#102 A settled first payment opens the registry PR itself (O3) — carries the migration
#101 ADR-012 amendment: the merge chains provisioning

Why this matters now

The tenant side is already live: restaurant-app-backend#272 and restaurant-app-frontend#353 went to prod today, so a provisioned owner can now set their own password and gets a guided first-run checklist. What they still cannot do is find out any of that happened — the readiness panel that tells them lives here. Until this ships, a paying owner sees "Your subscription is active — nothing to do here right now", with no amount, no next-charge date, and no mention of the app they are paying for.

Deploy — in this order, on the staging box (deploy/.ssh/staging.sh)

:migrate and :latest are both gated on refs/heads/main, so they only exist once this merges.

cd /opt/rumi/deploy
docker pull ghcr.io/piwas-21/sofra:migrate
docker run --rm --network deploy_rumi \
  -e DATABASE_URL="postgresql://sofra:<SOFRA_DB_PASSWORD>@postgres:5432/sofra" \
  ghcr.io/piwas-21/sofra:migrate
docker compose -f docker-compose.prod.yml pull sofra
docker compose -f docker-compose.prod.yml up -d sofra

Migration must run first. 20260730060000_billing_signup_link adds TenantBilling.signupRequestId + .provisioningPrUrl; the new app code reads both. Rolling the app first means a control plane querying columns that do not exist.

The migration is additive and nullable — two ADD COLUMN, one index, one FK with ON DELETE SET NULL. No backfill, no rewrite, safe on every existing row (they all get NULL, which is exactly the "founder-created, not self-serve" signal the payment gate already keys on).

One operational consequence to check after rolling

#102 makes a settled first payment attempt to open a registry PR by itself, from the Mollie webhook. That path needs PROVISION_GITHUB_TOKEN on the sofra box. Without it the auto-open takes its failed branch and reports — which is correct behaviour and is what the E2E suite asserts, but it means the first paying customer's proposal would need opening by hand.

That token expires silently/admin/provision degrades to a "not configured" banner rather than erroring. Worth confirming it is present and unexpired as part of this rollout.

Gate evidence

  • 258 vitest, coverage floor green (100 / 98.6 / 100 / 100)
  • npm run test:e2e:full — 23/23 unmocked, including a real Mollie first payment on the test_ key, plus 7 new owner-dashboard specs
  • typecheck · eslint --max-warnings=0 · i18n parity 6 locales / 664 keys · file-length · next build · prisma migrations-apply + drift · playwright login smoke · gitleaks · TruffleHog · Trivy · npm audit · OSV · semgrep
  • Sonar: 0 open issues on every constituent PR; all three merged through scripts/pr-merge-gate.sh
  • Reviewer pass before each PR; feat(control): show the owner their app and their plan (O4 part 1) #103's caught that "next charge" rendered startDate, which is the FIRST recurring charge and never advances — so from month two every owner and every partner saw a date in the past

Ancestry

git log develop..main is empty. Merge as a merge commit, not a squash, so develop stays an ancestor of main.

Rollback

App: docker compose -f docker-compose.prod.yml up -d sofra against the prior :sha-<...>, or the retag-mutable-tags workflow to re-point :latest. The migration is additive, so the previous app version runs against the new schema unchanged — no down-migration needed.

🤖 Generated with Claude Code

mahmutkaya and others added 3 commits July 30, 2026 07:16
…#101)

The deploy repo's provision-on-registry-merge.yml makes merging a registry PR
stand up the tenant (SOFRA-ONBOARDING-PLAN §2 option B). Two things here had to
follow it.

ADR-012 first. Its status still read "proposed — no code ships until a mechanism is
chosen" four days after D + A shipped and was proven end to end, so that is
corrected to accepted, and an amendment records the chain. The amendment is
smaller than the plan implied because it is not a reversal: none of the three
invariants ever said a merge cannot provision — invariant 3 asks for a human
review checkpoint, which the merge now is — and the recommendation already read
"a founder (or a `workflow_run`-chained Action) then runs the script". The strong
claim lived only in operating prose (the runbook, a workflow comment), never in
the decision record.

Then the PR body, which is what the founder actually reads at the moment that now
matters. It leads with "merging this PR provisions the tenant" and a pre-merge
checklist, and keeps the two commands as the fallback.

Three fixes the reviewer caught in that body, all real:

- The ⚠️ header was emitted for every box, but the chain is staging-only. A
  `box: prod` entry was promised hands-off provisioning that will never happen; it
  now gets the opposite header and the commands as a requirement.
- The backend_tag checklist item was inverted. `buildTenantRegistryEntry` pins the
  tag FROM the box, so "a staging-box tenant on :latest" cannot be generated —
  warning about it was an unfalsifiable checkbox on 100% of real PRs, which trains
  the reader to tick without looking. The live risk is the reverse and is the one
  judgement no generator can make: every self-serve tenant lands on the staging box
  and therefore rides the *develop* build. Right for a showcase, a decision for
  someone paying.
- A newline in the tenant name broke the markdown fence around the fallback
  command and handed the founder a command with an unterminated quote. `shq()`
  escapes apostrophes, and `trim()` only strips the ends.

That last one is fixed at the edge rather than escaped downstream: provisionSchema
now refuses control characters in `name`. It was flowing into three formats where a
newline changes meaning — the registry YAML (already safe via yaml.stringify), this
PR body, and build-tenant-image.yml's `build-args:`, which is a newline-delimited
list, so a second line there injects a build arg into the tenant's own bundle. The
body also collapses whitespace itself, so it stays safe as a standalone function.

Tests are behavioural rather than wording-pinned where possible: staging vs prod
headers, which backend_tag risk each box gets, and the newline case asserting the
invariant markdown actually keys on (fence delimiters at line start, not a raw
count of ``` in the document).
…elf (#102)

O3's second half. `recordPayment`'s `first`+`paid` branch now calls
`autoProposeProvisioning`; the founder reviews and merges as before, which since the
merge chain is what stands the tenant up. This automates the typing, not the judgement.

THE PLAN'S PREMISE WAS FALSE, and finding that was most of the work. It said to
auto-open "from the stored configurator answers" as though they were reachable from the
billing row. They were not: SignupRequest had no FK to TenantBilling, and the only join
was desiredSlug = tenantSlug — soft, because every leadOnly outcome writes a lead, so two
rows can share a desiredSlug while only one minted the account. Matching on it could hand
a paying customer another lead's MODULE LIST. The founder path never hit this because a
human passes the id (?from=<id>). Hence the migration: TenantBilling.signupRequestId
(+ provisioningPrUrl), written at intake.

Shape follows the payment gate's: a pure policy (lib/auto-provision-policy.ts, unit-tested
without the mocks §7 forbids) plus a thin shell (lib/auto-provision.ts). slugProvisionVerdict
moved to lib/provisioning-facts.ts now that it has two callers — it could not stay in the
action, which is "use server". notifyFounder moved to lib/billing-notify.ts so the founder
EMAIL stopped growing a file that is already over limit.

Load-bearing decisions:
- Proposed BEFORE activation. Activation throws MandateNotReadyError to force a webhook
  503 during the mandate race, a window of ~80s typically and up to ~26h. The customer has
  paid and the gate treats a settled first payment as sufficient, so waiting on the mandate
  would be waiting on the wrong thing.
- It cannot throw. The webhook turns an exception into a non-2xx and Mollie then
  redelivers for ~26h; a GitHub outage must not become a retry loop on a paid customer.
- Idempotency is our own row. provisioningPrUrl is checked first, because redelivery is
  the ORDINARY case, and "already proposed" outranks every other verdict so a redelivery
  can never be reported as a fresh skip.
- It refuses to invent. No template/currency/modules/languages, or a lead slug that
  disagrees with the billing anchor, hands back to the founder with a note saying what to
  do. A missing PROVISION_GITHUB_TOKEN is a FAILURE (it expires silently) but is checked
  last, so an ineligible plan cannot raise a token alarm.

Eight reviewer findings fixed, four of which were the interesting kind:

1. The automatic path ROUTED AROUND the control-character guard added last PR — the one
   that exists because the name reaches build-tenant-image.yml's newline-delimited
   `build-args:`. provisionSchema had it; signupSchema did not, and the auto path never
   touches provisionSchema. A crafted restaurantName could have injected a build arg into
   the tenant's own bundle. Now a shared refinement on both schemas, plus the policy
   refusing it for rows captured before the guard existed.
2. A partial openProvisioningPr failure wedged the path permanently. It creates the branch
   before committing and opening the PR, so a death in between left an orphan branch —
   after which every retry matched "already open" and reported "nothing to do" while a paid
   customer had no tenant, forever. Now: re-read the row, and only call it a duplicate if a
   URL was actually recorded.
3. The refusal classifier conflated "this slug is already a LIVE tenant" with "a proposal is
   open" — so money taken for someone else's subdomain read as benign. Split, and moved
   into the pure policy: it was a string decision with a correctness bug sitting on the
   untested side of the very split the module argues for.
4. A failed proposal was reported nowhere if activation then threw. The payment email was
   the only carrier, and it is sent after activation. Failures now get their own message
   and every outcome is audited.

Also: a 15s timeout on the GitHub calls, which O3 put in the webhook's critical path ahead
of activation, where a HANG (not an error) would stall activation on a dependency
unrelated to billing; the manual path now records provisioningPrUrl too, so a
founder-opened PR is visible to the auto path; and the migration's non-uniqueness
rationale was a non-sequitur (tenantSlug @unique prevents two plans per SLUG, not per
LEAD) — decision kept, reasoning corrected.

Verified: tsc + eslint clean, 235 unit tests, coverage floor 100/98.24/100/100, next build
clean, and `prisma migrate deploy` + the real CI drift gate against a throwaway Postgres 16
-> "No difference detected".
)

* feat(control): show the owner their app and their plan

O4, part 1 — the owner-facing half of first-run setup, on the sofra
dashboard. Two things this page could not say before, in one pass so they
share their 6-locale keys instead of arriving as two lone panels.

**"Your app is ready — set your admin password."** O3 closed the credential
story mechanically: the tenant frontend now has /forgot-password and
/reset-password, so the owner sets their own password and the bootstrap
password never leaves the box. Nothing TOLD them. `TenantReadyPanel` does,
and every claim it makes is earned rather than defaulted:

  - `liveSince` is a date the founder typed, and on the self-serve path
    nobody types it; the registry's `status: active` is a manual follow-up
    commit nothing automatic writes or reads; `provisioningPrUrl` proves a
    proposal was opened, not merged, built or booted; a registry ENTRY
    proves the merge started a ~15-minute build that can still fail.
  - So the only evidence the app is serving is asking it. `tenant-health.ts`
    probes the tenant's own /api/health and asserts the body's `service`
    field — a bare 200 proves nothing, the same lesson O5 learned from a
    bare 404: a wildcard subdomain, a Caddy default and a parked domain all
    answer 200.
  - Everything short of that degrades to a weaker claim, never a stronger
    one. The probe fails CLOSED, the opposite of the tenant frontend's
    getTenantModules — there a blip must not take features away; here the
    only thing riding on it is a link we tell a paying customer to click.

**Their actual plan.** An owner with an ACTIVE subscription saw one
sentence — "nothing to do here right now" — with no amount, no next-charge
date and no history, because /dashboard/billing is requirePartner(). They
now get their own card. Not by loosening that page: it is the reseller's
book, and an owner needs their plan plus the panel above, which is one card.

Found while building, fixed here:

  - **"Next charge" was a date in the past from month two onward.**
    `BillingSubscription.startDate` is the FIRST recurring charge, written
    once at activation and never advanced — `recordPayment` only inserts
    payment rows. Both surfaces printed it as the next charge. `nextChargeDate`
    derives it instead; the partner page gets the same fix.
  - **Every locale's `activating.stepProvision` still promised "we send your
    login details".** False in all six since O3.
  - `ownerAllSet` / `notLiveYet` removed — orphaned by the above.

Verified: 258 vitest, 23/23 unmocked Playwright including a real Mollie
first payment on the test_ key, typecheck, eslint, i18n parity 664x6,
file-length, next build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(billing): drop the redundant getTime() in nextChargeDate

Sonar: Date supports relational comparison directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* perf(control): one history query for the owner's plans, not one per plan

The review gate blocked this as an N+1, correctly. The per-row query was
justified as "an owner holds one plan in practice", which is true and is
still not a reason to write a loop that issues a round-trip per row.

Batched into a single `billingId IN (...)` read, grouped in memory and
sliced to the same 10 per plan. `HISTORY_ROW_CAP` exists so the query cannot
grow with the age of an account; the one case where the cap is lossy — an
owner holding several plans, one far busier than the rest — is written down
next to it rather than left to be discovered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(billing): clone the anchor Date directly

`new Date(d)` clones; the `.getTime()` round-trip was the same unnecessary
call Sonar flagged one line below.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@sonarqubecloud

Copy link
Copy Markdown

@mahmutkaya
mahmutkaya merged commit 0137795 into main Jul 30, 2026
32 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant