Phase1: Schema + skill event tracking
All checks were successful
Build and Push / build (push) Successful in 1m27s

This commit is contained in:
2026-05-23 23:50:42 +02:00
parent ce70fe8480
commit faf49119ea
2 changed files with 36 additions and 89 deletions

113
README.md
View File

@@ -4,6 +4,10 @@ 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
@@ -34,17 +38,17 @@ No `package.json` changes — Phase 1 uses only deps already present.
`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`
- Resume path: records a `resumed` suspension event after
`patchTenantSpec({suspend: false})`
- 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.
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`
@@ -134,19 +138,10 @@ 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:
Run the backfill once. From a logged-in admin browser tab DevTools
console:
```
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:
```
```js
await fetch('/api/admin/billing/backfill', { method: 'POST' })
.then(r => r.json())
```
@@ -163,17 +158,7 @@ Expected response (numbers will vary):
```
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
}
```
(idempotency check).
### Step 3 — Verify backfill data
@@ -182,11 +167,9 @@ 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;
```
@@ -201,9 +184,8 @@ Every package currently in `spec.packages` should have a matching
### 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.
From the customer-facing tenant detail page, enable a package not
previously present (e.g. `searxng-local-search`):
```sql
SELECT * FROM tenant_skill_events
@@ -211,15 +193,12 @@ SELECT * FROM tenant_skill_events
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.
Expect a fresh `enabled` row. Disable the package → expect a
`disabled` row on top.
### Step 5 — Live suspend toggle
From the customer-side cancel button on a test tenant, suspend it:
Cancel a test tenant from the customer-side button:
```sql
SELECT * FROM tenant_suspension_events
@@ -227,14 +206,12 @@ SELECT * FROM tenant_suspension_events
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.
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. Check:
Delete a test tenant from the admin panel:
```sql
SELECT tenant_name, created_at, deleted_at
@@ -244,10 +221,10 @@ SELECT tenant_name, created_at, deleted_at
`deleted_at` should be stamped with roughly "now".
### Step 7 — Pricing helpers (smoke test)
### Step 7 — Pricing rows survive (optional)
Optional sanity check — set a price for a skill and a monthly fee
via direct SQL (Phase 2 will add a UI for this):
Direct-INSERT a price into `platform_pricing` and `skill_pricing`,
restart the portal pod, confirm rows survive:
```sql
UPDATE platform_pricing
@@ -263,36 +240,18 @@ ON CONFLICT (skill_id) DO UPDATE
```
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.
Phase 2 starts computing invoices.
---
## Rollback
If anything misbehaves, the migration is additive — no existing
columns/tables touched. To roll back:
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:
2. New tables remain in the DB but are unreferenced. Leave them
in place — Phase 2 will use them again. Or drop them:
```sql
DROP TABLE IF EXISTS invoice_reminders, invoice_lines, invoices,
invoice_number_counters, org_payment_methods, org_billing_config,
@@ -312,15 +271,3 @@ columns/tables touched. To roll back:
* 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

View File

@@ -332,8 +332,8 @@ const MIGRATION_SQL = `
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- One row per tenant. `created_at` anchors first-month proration;
-- `deleted_at` (nullable, stamped on delete) anchors last-month
-- One row per tenant. created_at anchors first-month proration;
-- deleted_at (nullable, stamped on delete) anchors last-month
-- proration. The PiecedTenant CR is the source of truth for
-- existence, but once the CR is deleted we lose its
-- creationTimestamp — so we mirror those two bookends here.
@@ -352,7 +352,7 @@ const MIGRATION_SQL = `
-- log preserves history for audit and lets us re-bill historical
-- months reproducibly.
--
-- `skill_id` is the package id from PACKAGE_CATALOG. We store
-- skill_id is the package id from PACKAGE_CATALOG. We store
-- events for ALL package toggles, not just skill-category — the
-- channel/core toggles are cheap to record and may become billable
-- in the future without a schema change.
@@ -479,9 +479,9 @@ const MIGRATION_SQL = `
CREATE UNIQUE INDEX IF NOT EXISTS uniq_invoices_org_period
ON invoices(zitadel_org_id, period_start);
-- Invoice line items. `kind` lets the PDF renderer group lines
-- (all monthly fees together, all AI usage together, etc.) and
-- the admin UI filter by category.
-- Invoice line items. The kind column lets the PDF renderer
-- group lines (all monthly fees together, all AI usage together,
-- etc.) and the admin UI filter by category.
CREATE TABLE IF NOT EXISTS invoice_lines (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,