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