Files
pieced-portal/README.md
admin ce70fe8480
Some checks failed
Build and Push / build (push) Failing after 38s
Phase1: Schema + skill event tracking
2026-05-23 23:45:04 +02:00

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


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: <your authjs session 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
-- 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.

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:

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:

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):

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:

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