8.1 KiB
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.
v2 fix: stripped stray backticks from SQL comments that were
closing the MIGRATION_SQL template literal early. If you got
"Expected a semicolon" at db.ts:335 with v1, this build is the fix.
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_SQLwith 11 new tables (idempotent — usesCREATE 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,recordSuspensionEventfrom@/lib/db - Resume path: records a
resumedsuspension event afterpatchTenantSpec({suspend: false}) - Provision path: records
recordTenantCreated+ initialenabledevents aftercreateTenant
src/app/api/tenants/[name]/route.ts
- Imports
recordSkillEvents - After
patchTenantSpecsucceeds and the patch touchedpackages, 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
suspendedorresumedafter the patch succeeds
src/app/api/admin/tenants/[name]/delete/route.ts
- Imports
recordTenantDeleted - Stamps
deleted_aton the lifecycle row afterdeleteTenant
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, andstatus.suspendedAtinto 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_lifecyclemirrors 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
- Extract this zip over your
pieced-portal/source tree - Build & push:
./buildanddeploy.sh # or your usual flow - Bump the image tag in
gitops/apps/portal/deployment.yaml, commit, push. ArgoCD picks it up. - On pod boot, the next DB query auto-runs
MIGRATION_SQL(your existingensureSchemapattern). No manualpsqlneeded.
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 a logged-in admin browser tab 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).
Step 3 — Verify backfill data
kubectl -n portal exec -it portal-db-1 -- psql -U portal -d portal
SELECT tenant_name, zitadel_org_id, created_at, deleted_at
FROM tenant_billing_lifecycle ORDER BY created_at;
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
From the customer-facing tenant detail page, enable a package not
previously present (e.g. searxng-local-search):
SELECT * FROM tenant_skill_events
WHERE tenant_name = 'your-test-tenant'
ORDER BY id DESC LIMIT 3;
Expect a fresh enabled row. Disable the package → expect a
disabled row on top.
Step 5 — Live suspend toggle
Cancel a test tenant from the customer-side button:
SELECT * FROM tenant_suspension_events
WHERE tenant_name = 'your-test-tenant'
ORDER BY id DESC LIMIT 3;
Expect a suspended row. Resume via the admin approval flow →
expect a resumed row.
Step 6 — Live delete
Delete a test tenant from the admin panel:
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 rows survive (optional)
Direct-INSERT a price into platform_pricing and skill_pricing,
restart the portal pod, confirm rows survive:
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.
Rollback
The migration is additive — no existing columns/tables touched. To roll back:
- Re-deploy the previous portal image (revert the tag in gitops)
- New tables remain in the DB but are unreferenced. Leave them
in place — Phase 2 will use them again. Or drop them:
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.