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 <noreply@anthropic.com>feat/portal-design
parent
6119044669
commit
0dde76ad55
|
|
@ -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/<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.
|
||||
Loading…
Reference in New Issue