From 0dde76ad5590e8a171dc953dade1f2750c42dde3 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sun, 26 Jul 2026 10:43:17 +0200 Subject: [PATCH] docs: handoff for the plan catalogue rebuild Design is decided and Codex-reviewed; none of it is built. Records the live split-brain bug to fix first, the three-table catalogue, computed availability, the proof register with frozen add-ons, the Stripe split, what already exists, and the working rules that cost time this session. Co-Authored-By: Claude Opus 4.8 --- ..env.swp | Bin 1024 -> 1024 bytes docs/handoff-plan-catalogue.md | 176 +++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 docs/handoff-plan-catalogue.md diff --git a/..env.swp b/..env.swp index dc5dd685b7cac6e3b44cd80a0f9318251258d65b..9f4258ed7174e33fbb2e58c4d463018de63b5822 100644 GIT binary patch delta 14 VcmZqRXyBM2!MssVV59U?W&j_11YZCE delta 14 VcmZqRXyBM2!MyMh_eSZb%m5=Q1pWX3 diff --git a/docs/handoff-plan-catalogue.md b/docs/handoff-plan-catalogue.md new file mode 100644 index 0000000..a28c709 --- /dev/null +++ b/docs/handoff-plan-catalogue.md @@ -0,0 +1,176 @@ +# Handoff — plan catalogue rebuild + +Written 2026-07-26 at the end of a long session. Everything here is decided and +reviewed; none of it is built. Work the phases in order and report only when all +five are done — that is what the owner asked for. + +Branch `feat/portal-design`, last pushed commit `6119044`, 359 tests green, CI +green on the self-hosted Gitea. + +--- + +## Why this rebuild exists + +Plans live in `config/provisioning.php` as a static array. Those values drive two +unrelated things: **commerce** (price, cart, upgrade rules) and **provisioning** +(`disk_gb`, `ram_mb`, `cores`, `template_vmid`, read by the pipeline steps). The +owner needs to create and schedule plans from the console, and existing customers +must never be affected by a later price or resource change. + +The design below was reviewed by Codex, which rejected three of the original +proposals. Do not quietly re-introduce them — the reasons are recorded. + +--- + +## Phase 1 — Close the split-brain (start here, it is a live bug) + +**Symptom:** the pipeline does not read the subscription snapshot. It re-resolves +the mutable catalogue by `order.plan`: + +- `app/Provisioning/Steps/Customer/CustomerStep.php` +- `.../ReserveResources.php`, `.../CloneVirtualMachine.php`, `.../ConfigureCloudInit.php` + +So the snapshot protects prices but **not resources**. Editing `team` from 8 GB +to 4 GB RAM changes an existing customer's VM on its next run. + +**Complication:** nothing creates a `Subscription` row yet. The table, the +immutability guard and `PlanChange` exist (`app/Models/Subscription.php`, +`app/Services/Billing/PlanChange.php`, tests in +`tests/Feature/Billing/PlanChangeTest.php`), but no code writes one. + +**Deliverable** +1. Create the subscription when an order is paid (`StartCustomerProvisioning` / + the Stripe webhook path), snapshotting through `Subscription::snapshotFrom()`. +2. Point the pipeline steps at the subscription's frozen values instead of + `config('provisioning.plans')`. +3. Test that changing the catalogue after provisioning does not change what a + re-run gives an existing customer. + +--- + +## Phase 2 — Three catalogue tables + +Not one table. Codex was explicit and the reasoning holds: + +- **`plan_families`** — stable key (`team`), display identity, tier/rank, Stripe + Product id +- **`plan_versions`** — immutable capabilities and resources, feature + presentation, availability window +- **`plan_prices`** — per version *and term*, amount plus **its own** Stripe + Price id + +A Stripe Price carries its own recurring interval, so monthly and yearly cannot +share one id. + +**Availability is computed at read time**, never flipped by a job: +`available_from <= now < available_until`, half-open, UTC, plus a manual +`sales_enabled` kill switch. What does not run cannot fail to run. Lock the +family while scheduling, reject overlapping windows, and resolve with `sole()` +so an overlap crashes loudly instead of silently picking one. + +A version is immutable **from publication**, not from first sale. Unique +`(plan_family_id, version)`. Never resolve a historical reference by family key — +always by version id. + +**Rollout:** seed from today's config, run a shadow comparison, then switch +atomically and **fail closed**. Do **not** build a "fall back to config if the DB +has no row" path: it can resurrect a deactivated plan and mixes DB commerce with +config provisioning. + +--- + +## Phase 3 — Admin UI + +Create and edit plan families and versions, tick the features to expose, set the +availability window, flip the kill switch. Deactivated plans disappear from the +customer dashboard **and** the public website. + +Follow the existing console conventions: full-page class-based Livewire, design +tokens only, destructive actions behind a modal component (see +`ConfirmDeleteVpnPeer`), a capability checked via Gate (`plans.manage`, seeded in +a migration like `2026_07_25_220001_add_site_manage_capability.php`), DE and EN +lang files with identical keys. + +--- + +## Phase 4 — Proof register and frozen add-ons + +**`subscription_records`** — append-only, one row per commercial event +(purchase, upgrade, downgrade, cancellation). **Flat columns** for everything +searched or relied on as evidence — event type, customer, subscription, +family/version/term, net/tax/gross, currency, Stripe ids — **plus** a versioned +JSON copy of the whole snapshot. JSON alone was rejected: you cannot query it and +it is poor evidence. + +**`subscription_addons`** — the owner's requirement, and easy to miss: + +> Booked modules belong in the snapshot **at the price they were booked at**, so +> the customer's total is subscription + modules, all frozen. A module the +> customer has **not** booked is shown and sold at today's price. + +So each booked add-on carries its own frozen price; the live catalogue is only +consulted for what is not yet booked. + +--- + +## Phase 5 — Stripe + +Stripe owns recurring billing (decided 2026-07-26): retries and dunning, +off-session SCA, invoice numbering. Our side owns capability, because Stripe does +not know how big the VM should be. + +Create a Stripe Product per family and a Price per `plan_prices` row; store the +ids. A Stripe Price is immutable, so a price rise means a new Price and existing +subscriptions keep the old one — grandfathering enforced on their side too. + +The webhook endpoint is `https://api.dev.clupilot.com/webhooks/stripe`, +`STRIPE_WEBHOOK_SECRET` is set, and these events are already subscribed: +`checkout.session.completed`, `checkout.session.async_payment_succeeded/failed`, +`invoice.paid`, `invoice.payment_failed`, `customer.subscription.updated`, +`customer.subscription.deleted`. Only the first two are handled in code today +(`app/Http/Controllers/StripeWebhookController.php`) — the rest are ignored and +need implementing here. + +`PlanChange` becomes the **preview** shown before confirming; Stripe's invoice is +the authority for the amount. + +--- + +## Already built — do not rebuild + +- `subscriptions` with an immutability guard, `tier`, terms, period boundaries +- `PlanChange`: upgrade immediate + pro rata, downgrade only at term end, + direction from rank not price, no free upgrade in the last hours, nothing + after cancellation +- `TaxTreatment`: per-customer VAT, reverse charge only for a **verified** EU + VAT ID whose verified *value* matches the current one. VIES verification + itself is not built; unverified means domestic rate. +- Cart with one plan change at a time, net/gross and period on every price +- Traffic metering, warn/throttle, VPN access management, site visibility switch, + deploy scripts, Gitea Actions CI + +--- + +## How to work here + +```bash +docker compose exec app ./vendor/bin/pest # 359 green before you start +docker compose exec app npm run build # after any view/JS change +``` + +Rules that bit us this session, so they are worth repeating: + +- **Restart the queue workers** after changing a job class — they are + long-running and hold the old code (`docker compose restart queue-provisioning`). +- **Modals authorise themselves.** They are reachable without the page's guards. +- Livewire posts to `/livewire/update`; a path-based guard misses it. +- Standalone pages (`errors/503`, `coming-soon`) must not use `@vite` — they are + shown when no build may exist. +- Verify in the browser, not only in tests. Puppeteer image and probe scripts are + in `/tmp/cprobe-run` (`node /probe/.js` with `NODE_PATH=/home/pptruser/node_modules`). +- Run the Codex review loop until clean before pushing: + `node "$CLAUDE_PLUGIN_ROOT/scripts/codex-companion.mjs" review "--scope branch --base "`. + It found a real defect in nearly every round of this session; take it seriously + and verify each claim rather than accepting or dismissing it wholesale. +- Push with the token from `.env` (`GIT_ACCESS_TOKEN`); there is no credential + helper configured.