Phase1: Schema + skill event tracking
All checks were successful
Build and Push / build (push) Successful in 1m27s
All checks were successful
Build and Push / build (push) Successful in 1m27s
This commit is contained in:
113
README.md
113
README.md
@@ -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
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user