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 This zip mirrors the `pieced-portal/` repo root — extract over your
existing source tree to apply. 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 ## 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` `src/app/api/admin/requests/[id]/approve/route.ts`
- Imports `recordTenantCreated`, `recordSkillEvents`, - Imports `recordTenantCreated`, `recordSkillEvents`,
`recordSuspensionEvent` from `@/lib/db` `recordSuspensionEvent` from `@/lib/db`
- Resume path: records a `resumed` suspension event after the - Resume path: records a `resumed` suspension event after
`patchTenantSpec({suspend: false})` call `patchTenantSpec({suspend: false})`
- Provision path: records `recordTenantCreated` + initial `enabled` - Provision path: records `recordTenantCreated` + initial
events after `createTenant` `enabled` events after `createTenant`
`src/app/api/tenants/[name]/route.ts` `src/app/api/tenants/[name]/route.ts`
- Imports `recordSkillEvents` - Imports `recordSkillEvents`
- After `patchTenantSpec` succeeds and the patch touched - After `patchTenantSpec` succeeds and the patch touched
`packages`, computes the diff (added/removed) and writes events. `packages`, computes the diff (added/removed) and writes events.
Diff is computed against the patched CR (the returned state) so Diff is computed against the patched CR (the returned state)
events match what K8s committed. so events match what K8s committed.
`src/app/api/tenants/[name]/suspend/route.ts` `src/app/api/tenants/[name]/suspend/route.ts`
- Imports `recordSuspensionEvent` - Imports `recordSuspensionEvent`
@@ -134,19 +138,10 @@ Expected: one row, all zeros, vat_rate_chli=8.10.
### Step 2 — Backfill existing tenants ### Step 2 — Backfill existing tenants
Run the backfill once. From your laptop (with a logged-in admin Run the backfill once. From a logged-in admin browser tab DevTools
session cookie) or from another pod with cluster access: console:
``` ```js
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' }) await fetch('/api/admin/billing/backfill', { method: 'POST' })
.then(r => r.json()) .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 Run it a SECOND time — all three "Inserted" counts should be 0
(idempotency check): (idempotency check).
```
{
"message": "Backfill complete.",
"tenantsExamined": 4,
"lifecycleInserted": 0,
"eventsInserted": 0,
"suspensionEventsInserted": 0
}
```
### Step 3 — Verify backfill data ### Step 3 — Verify backfill data
@@ -182,11 +167,9 @@ kubectl -n portal exec -it portal-db-1 -- psql -U portal -d portal
``` ```
```sql ```sql
-- All tenants have lifecycle rows
SELECT tenant_name, zitadel_org_id, created_at, deleted_at SELECT tenant_name, zitadel_org_id, created_at, deleted_at
FROM tenant_billing_lifecycle ORDER BY created_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 SELECT tenant_name, skill_id, event_kind, occurred_at
FROM tenant_skill_events ORDER BY tenant_name, 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 ### Step 4 — Live skill toggle
Open the customer-facing tenant detail page for a test tenant. From the customer-facing tenant detail page, enable a package not
Enable a package (e.g. `searxng-local-search`) that wasn't on previously present (e.g. `searxng-local-search`):
before.
```sql ```sql
SELECT * FROM tenant_skill_events SELECT * FROM tenant_skill_events
@@ -211,15 +193,12 @@ SELECT * FROM tenant_skill_events
ORDER BY id DESC LIMIT 3; ORDER BY id DESC LIMIT 3;
``` ```
You should see a fresh `enabled` row with the package id and a Expect a fresh `enabled` row. Disable the package → expect a
timestamp matching the toggle. `disabled` row on top.
Disable the same package, re-check — you should now see a
`disabled` row added on top.
### Step 5 — Live suspend toggle ### 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 ```sql
SELECT * FROM tenant_suspension_events SELECT * FROM tenant_suspension_events
@@ -227,14 +206,12 @@ SELECT * FROM tenant_suspension_events
ORDER BY id DESC LIMIT 3; ORDER BY id DESC LIMIT 3;
``` ```
Expect a `suspended` row. Expect a `suspended` row. Resume via the admin approval flow →
expect a `resumed` row.
As platform admin, resume it via the resume-request flow. Expect a
`resumed` row to land.
### Step 6 — Live delete ### Step 6 — Live delete
Delete a test tenant from the admin panel. Check: Delete a test tenant from the admin panel:
```sql ```sql
SELECT tenant_name, created_at, deleted_at 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". `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 Direct-INSERT a price into `platform_pricing` and `skill_pricing`,
via direct SQL (Phase 2 will add a UI for this): restart the portal pod, confirm rows survive:
```sql ```sql
UPDATE platform_pricing UPDATE platform_pricing
@@ -263,36 +240,18 @@ ON CONFLICT (skill_id) DO UPDATE
``` ```
No application behaviour changes from these — they're inert until No application behaviour changes from these — they're inert until
Phase 2 starts computing invoices. The check is just "the rows Phase 2 starts computing invoices.
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 ## Rollback
If anything misbehaves, the migration is additive — no existing The migration is additive — no existing columns/tables touched.
columns/tables touched. To roll back: To roll back:
1. Re-deploy the previous portal image (revert the tag in gitops) 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 2. New tables remain in the DB but are unreferenced. Leave them
in place — Phase 2 will use them again. Or drop them if you in place — Phase 2 will use them again. Or drop them:
want a clean slate:
```sql ```sql
DROP TABLE IF EXISTS invoice_reminders, invoice_lines, invoices, DROP TABLE IF EXISTS invoice_reminders, invoice_lines, invoices,
invoice_number_counters, org_payment_methods, org_billing_config, invoice_number_counters, org_payment_methods, org_billing_config,
@@ -312,15 +271,3 @@ columns/tables touched. To roll back:
* No reminders or cron * No reminders or cron
These are Phases 2-6. 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() updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
-- One row per tenant. `created_at` anchors first-month proration; -- One row per tenant. created_at anchors first-month proration;
-- `deleted_at` (nullable, stamped on delete) anchors last-month -- deleted_at (nullable, stamped on delete) anchors last-month
-- proration. The PiecedTenant CR is the source of truth for -- proration. The PiecedTenant CR is the source of truth for
-- existence, but once the CR is deleted we lose its -- existence, but once the CR is deleted we lose its
-- creationTimestamp — so we mirror those two bookends here. -- 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 -- log preserves history for audit and lets us re-bill historical
-- months reproducibly. -- 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 -- events for ALL package toggles, not just skill-category — the
-- channel/core toggles are cheap to record and may become billable -- channel/core toggles are cheap to record and may become billable
-- in the future without a schema change. -- in the future without a schema change.
@@ -479,9 +479,9 @@ const MIGRATION_SQL = `
CREATE UNIQUE INDEX IF NOT EXISTS uniq_invoices_org_period CREATE UNIQUE INDEX IF NOT EXISTS uniq_invoices_org_period
ON invoices(zitadel_org_id, period_start); ON invoices(zitadel_org_id, period_start);
-- Invoice line items. `kind` lets the PDF renderer group lines -- Invoice line items. The kind column lets the PDF renderer
-- (all monthly fees together, all AI usage together, etc.) and -- group lines (all monthly fees together, all AI usage together,
-- the admin UI filter by category. -- etc.) and the admin UI filter by category.
CREATE TABLE IF NOT EXISTS invoice_lines ( CREATE TABLE IF NOT EXISTS invoice_lines (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE, invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,