# PieCed Portal — Billing Phase 1 (drop-in replacement) Schema + event tracking. No UI yet (that lands in Phase 2). This zip mirrors the `pieced-portal/` repo root — extract over your existing source tree to apply. --- ## Files in this drop ``` src/lib/db.ts MODIFIED src/types/index.ts MODIFIED src/app/api/admin/requests/[id]/approve/route.ts MODIFIED src/app/api/tenants/[name]/route.ts MODIFIED src/app/api/tenants/[name]/suspend/route.ts MODIFIED src/app/api/admin/tenants/[name]/delete/route.ts MODIFIED src/app/api/admin/billing/backfill/route.ts NEW ``` No `package.json` changes — Phase 1 uses only deps already present. ### What changed `src/lib/db.ts` - Extended `MIGRATION_SQL` with 11 new tables (idempotent — uses `CREATE TABLE IF NOT EXISTS`) - Added a new "Billing — Phase 1" section at the bottom with ~25 helper functions `src/types/index.ts` - 6 new interfaces appended at the bottom `src/app/api/admin/requests/[id]/approve/route.ts` - Imports `recordTenantCreated`, `recordSkillEvents`, `recordSuspensionEvent` from `@/lib/db` - Resume path: records a `resumed` suspension event after the `patchTenantSpec({suspend: false})` call - Provision path: records `recordTenantCreated` + initial `enabled` events after `createTenant` `src/app/api/tenants/[name]/route.ts` - Imports `recordSkillEvents` - After `patchTenantSpec` succeeds and the patch touched `packages`, computes the diff (added/removed) and writes events. Diff is computed against the patched CR (the returned state) so events match what K8s committed. `src/app/api/tenants/[name]/suspend/route.ts` - Imports `recordSuspensionEvent` - Records `suspended` or `resumed` after the patch succeeds `src/app/api/admin/tenants/[name]/delete/route.ts` - Imports `recordTenantDeleted` - Stamps `deleted_at` on the lifecycle row after `deleteTenant` `src/app/api/admin/billing/backfill/route.ts` (new) - `POST /api/admin/billing/backfill` — platform-only, idempotent - Reads every live PiecedTenant CR, mirrors creationTimestamp, current `spec.packages`, and `status.suspendedAt` into the new tables. Run once after deploy to bootstrap historical data. ### Tables added (Postgres, all idempotent) ``` platform_pricing single-row pricing config skill_pricing per-package daily price (optional) tenant_billing_lifecycle per-tenant created_at + deleted_at tenant_skill_events append-only enable/disable log tenant_suspension_events append-only suspend/resume log org_billing_config per-org billing posture (pay-by-invoice, stripe id, auto-cron toggles) org_payment_methods Stripe payment methods (Phase 4) invoice_number_counters gapless per-year counter invoices immutable issued invoices (Phase 2) invoice_lines invoice line items (Phase 2) invoice_reminders sent reminders + their PDFs (Phase 6) ``` The invoice/lines/reminders tables ship now so Phase 2 doesn't need a second migration, but no code writes to them until Phase 2. ### Design properties * Every billing-tracking call is wrapped in `try/catch`. A logging failure never blocks the K8s operation. * PATCH-diff is computed against the *returned* CR state, not the pre-patch state, so events match what K8s actually committed. * Event tables are append-only. Historical billing can be recomputed reproducibly. * `tenant_billing_lifecycle` mirrors created_at + deleted_at so deleted tenants still have a final-invoice anchor. * All money is `NUMERIC`: 10,2 for CHF amounts, 10,5 for per-unit prices. --- ## Deploy 1. Extract this zip over your `pieced-portal/` source tree 2. Build & push: ``` ./buildanddeploy.sh # or your usual flow ``` 3. Bump the image tag in `gitops/apps/portal/deployment.yaml`, commit, push. ArgoCD picks it up. 4. On pod boot, the next DB query auto-runs `MIGRATION_SQL` (your existing `ensureSchema` pattern). No manual `psql` needed. --- ## Testing (in order — don't skip steps) ### Step 1 — Migration ran After the new pod is `Ready`, exec into the portal DB and verify all 11 new tables exist: ``` kubectl -n portal exec -it portal-db-1 -- \ psql -U portal -d portal -c "\dt" ``` You should see the new tables alongside the existing ones. Sanity-check the single-row pricing config seed: ``` kubectl -n portal exec -it portal-db-1 -- \ psql -U portal -d portal -c "SELECT * FROM platform_pricing;" ``` Expected: one row, all zeros, vat_rate_chli=8.10. ### Step 2 — Backfill existing tenants Run the backfill once. From your laptop (with a logged-in admin session cookie) or from another pod with cluster access: ``` curl -X POST \ -H "Cookie: " \ https://app.pieced.ch/api/admin/billing/backfill ``` Or, easier, hit the URL from a logged-in browser tab and use the DevTools console: ``` await fetch('/api/admin/billing/backfill', { method: 'POST' }) .then(r => r.json()) ``` Expected response (numbers will vary): ``` { "message": "Backfill complete.", "tenantsExamined": 4, "lifecycleInserted": 4, "eventsInserted": 12, "suspensionEventsInserted": 0 } ``` Run it a SECOND time — all three "Inserted" counts should be 0 (idempotency check): ``` { "message": "Backfill complete.", "tenantsExamined": 4, "lifecycleInserted": 0, "eventsInserted": 0, "suspensionEventsInserted": 0 } ``` ### Step 3 — Verify backfill data ``` kubectl -n portal exec -it portal-db-1 -- psql -U portal -d portal ``` ```sql -- All tenants have lifecycle rows SELECT tenant_name, zitadel_org_id, created_at, deleted_at FROM tenant_billing_lifecycle ORDER BY created_at; -- Initial skill events match each tenant's current spec.packages SELECT tenant_name, skill_id, event_kind, occurred_at FROM tenant_skill_events ORDER BY tenant_name, occurred_at; ``` Cross-check against the live CR: ``` kubectl get piecedtenants -o jsonpath='{range .items[*]}{.metadata.name}{": "}{.spec.packages}{"\n"}{end}' ``` Every package currently in `spec.packages` should have a matching `enabled` event row. ### Step 4 — Live skill toggle Open the customer-facing tenant detail page for a test tenant. Enable a package (e.g. `searxng-local-search`) that wasn't on before. ```sql SELECT * FROM tenant_skill_events WHERE tenant_name = 'your-test-tenant' ORDER BY id DESC LIMIT 3; ``` You should see a fresh `enabled` row with the package id and a timestamp matching the toggle. Disable the same package, re-check — you should now see a `disabled` row added on top. ### Step 5 — Live suspend toggle From the customer-side cancel button on a test tenant, suspend it: ```sql SELECT * FROM tenant_suspension_events WHERE tenant_name = 'your-test-tenant' ORDER BY id DESC LIMIT 3; ``` Expect a `suspended` row. As platform admin, resume it via the resume-request flow. Expect a `resumed` row to land. ### Step 6 — Live delete Delete a test tenant from the admin panel. Check: ```sql SELECT tenant_name, created_at, deleted_at FROM tenant_billing_lifecycle WHERE tenant_name = 'your-deleted-tenant'; ``` `deleted_at` should be stamped with roughly "now". ### Step 7 — Pricing helpers (smoke test) Optional sanity check — set a price for a skill and a monthly fee via direct SQL (Phase 2 will add a UI for this): ```sql UPDATE platform_pricing SET tenant_monthly_fee_chf = 49.00, tenant_setup_fee_chf = 99.00, threema_message_chf = 0.005 WHERE id = 1; INSERT INTO skill_pricing (skill_id, daily_price_chf) VALUES ('searxng-local-search', 0.10) ON CONFLICT (skill_id) DO UPDATE SET daily_price_chf = EXCLUDED.daily_price_chf; ``` No application behaviour changes from these — they're inert until Phase 2 starts computing invoices. The check is just "the rows land and survive a restart". ### Step 8 — Org billing config auto-create Verify the org_billing_config auto-creates on first read. Call the helper indirectly: open the admin panel (which will eventually read this); for now, force it via SQL: ```sql -- Pick any org id from your tenant labels SELECT zitadel_org_id, pay_by_invoice, auto_invoice_enabled, auto_reminders_enabled FROM org_billing_config; ``` At this point this table is empty (Phase 2's UI will populate it). That's expected and fine. --- ## Rollback If anything misbehaves, the migration is additive — no existing columns/tables touched. To roll back: 1. Re-deploy the previous portal image (revert the tag in gitops) 2. The new tables remain in the DB but are unreferenced. Leave them in place — Phase 2 will use them again. Or drop them if you want a clean slate: ```sql DROP TABLE IF EXISTS invoice_reminders, invoice_lines, invoices, invoice_number_counters, org_payment_methods, org_billing_config, tenant_suspension_events, tenant_skill_events, tenant_billing_lifecycle, skill_pricing, platform_pricing CASCADE; ``` --- ## What's NOT in this phase (by design) * No customer-facing /billing page * No admin pricing UI * No invoice generation * No PDF rendering * No Stripe wiring * No reminders or cron These are Phases 2-6. ## What to expect in Phase 2 * `/admin/billing` page: platform pricing editor, per-skill pricing, per-org config * "Generate invoice for org X / month M" admin button (testing tool + foundation for the Phase 6 monthly cron) * `lib/billing.ts` introducing the invoice computation pipeline (LiteLLM spend pull, Threema usage pull, skill day collapse, monthly-fee proration, suspended-day exclusion, VAT calc) * PDF rendering via `@react-pdf/renderer` (adds one dep) * Admin "mark paid" action for the bill-pay flow