CluPilotCloud/docs/handoff-plan-catalogue.md

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

  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

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