7.7 KiB
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
- Create the subscription when an order is paid (
StartCustomerProvisioning/ the Stripe webhook path), snapshotting throughSubscription::snapshotFrom(). - Point the pipeline steps at the subscription's frozen values instead of
config('provisioning.plans'). - 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 idplan_versions— immutable capabilities and resources, feature presentation, availability windowplan_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
subscriptionswith an immutability guard,tier, terms, period boundariesPlanChange: upgrade immediate + pro rata, downgrade only at term end, direction from rank not price, no free upgrade in the last hours, nothing after cancellationTaxTreatment: 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
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/<name>.jswithNODE_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 <sha>". 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.