Skip to content

feat(provisioning): a settled first payment opens the registry PR itself - #102

Merged
mahmutkaya merged 1 commit into
developfrom
feature/payment-triggered-provisioning
Jul 30, 2026
Merged

mahmutkaya merged 1 commit into
developfrom
feature/payment-triggered-provisioning

Conversation

@mahmutkaya

Copy link
Copy Markdown
Contributor

Summary

O3's second half: a settled first payment now opens the registry PR itself. The founder reviews and merges as before — and since deploy #72 that merge 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

The plan 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 = tenantSlugsoft, 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>); an unattended path has no human.

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 — ~80s typically, 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 — 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 disagreeing 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 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 that 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 (O3 put them 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 !billing no longer reports "created by hand" about a row that vanished.

The migration's non-uniqueness rationale was a non-sequitur — tenantSlug @unique prevents two plans per slug, not per lead. Decision kept (the state is unreachable, and the constraint would only add a P2002 surface to the signup transaction where the existing catch is narrowed on tenantSlug), reasoning corrected.

Test plan

tsc --noEmit clean · eslint --max-warnings=0 clean · 235 unit tests · coverage floor 100 / 98.24 / 100 / 100 · next build clean · prisma migrate deploy plus the real CI drift gate (migrate diff --from-config-datasource) against a throwaway Postgres 16 → "No difference detected."

12 policy tests pin the precedence explicitly (already-proposed first — re-tested against every other verdict; gate before configuration; slug-mismatch as its own answer; token last, including the two ineligible guards), the name guard against real injection strings plus non-ASCII names that must pass, and the classifier against both real refusal messages. The note-coverage test enumerates the union rather than iterating the record, which was vacuous. Mutation-checked: inverting the precedence reds the suite.

Deploy notes

Schema change — run the migrate one-off BEFORE rolling the app:

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 up -d sofra

Both columns are additive and nullable, so existing rows are unaffected and every pre-existing plan reads as founder-created (which is what they are). Nothing changes for /admin/onboard, the reseller flow, or RUMI.

Not covered by the E2E suite: the auto-open runs inside the webhook's first+paid branch, which tests/e2e/billing-mollie.spec.ts does exercise with a real test_ payment — but that suite has no PROVISION_GITHUB_TOKEN, so it will take the failed path. That is correct behaviour and worth a follow-up assertion rather than a mock.

Refs: SOFRA-ONBOARDING-PLAN O3 · ADR-012 amendment · deploy #72

🤖 Generated with Claude Code

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".
@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 d662e36 into develop Jul 30, 2026
16 checks passed
@mahmutkaya
mahmutkaya deleted the feature/payment-triggered-provisioning branch July 30, 2026 07:22
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