feat(provisioning): a settled first payment opens the registry PR itself - #102
Merged
Merged
Conversation
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".
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



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:
SignupRequesthad no FK toTenantBilling, and the only join wasdesiredSlug = tenantSlug— soft, because everyleadOnlyoutcome writes a lead, so two rows can share adesiredSlugwhile 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).slugProvisionVerdictmoved tolib/provisioning-facts.tsnow that it has two callers — it could not stay in the action, which is"use server".notifyFoundermoved tolib/billing-notify.tsso the founder email stopped growing a file that is already over limit.Load-bearing decisions
MandateNotReadyErrorto 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.provisioningPrUrlis 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.PROVISION_GITHUB_TOKENis 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
build-tenant-image.yml's newline-delimitedbuild-args:.provisionSchemahad it;signupSchemadid not, and the auto path never touchesprovisionSchema. A craftedrestaurantNamecould 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.openProvisioningPrfailure 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.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
provisioningPrUrltoo, so a founder-opened PR is visible to the auto path; and!billingno longer reports "created by hand" about a row that vanished.The migration's non-uniqueness rationale was a non-sequitur —
tenantSlug @uniqueprevents 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 ontenantSlug), reasoning corrected.Test plan
tsc --noEmitclean ·eslint --max-warnings=0clean · 235 unit tests · coverage floor 100 / 98.24 / 100 / 100 ·next buildclean ·prisma migrate deployplus 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 sofraBoth 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+paidbranch, whichtests/e2e/billing-mollie.spec.tsdoes exercise with a realtest_payment — but that suite has noPROVISION_GITHUB_TOKEN, so it will take thefailedpath. 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