# 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.