Billing rework
Some checks failed
Build and Push / build (push) Failing after 41s

This commit is contained in:
2026-05-02 00:04:23 +02:00
parent 46369fda01
commit 392b0991a5
17 changed files with 1070 additions and 16 deletions

View File

@@ -1,5 +1,5 @@
import { Pool } from "pg";
import type { BillingAddress, TenantRequest, TenantRequestStatus } from "@/types";
import type { BillingAddress, OrgBilling, TenantRequest, TenantRequestStatus } from "@/types";
import { listTenants, getTenant } from "./k8s";
// ---------------------------------------------------------------------------
@@ -161,6 +161,35 @@ const MIGRATION_SQL = `
);
CREATE INDEX IF NOT EXISTS idx_tua_user ON tenant_user_assignments(zitadel_user_id);
CREATE INDEX IF NOT EXISTS idx_tua_org ON tenant_user_assignments(zitadel_org_id);
-- Bug 35: org-scoped billing. One row per ZITADEL org; captured by
-- the first tenant request inline, editable afterwards via
-- /settings/billing. Subsequent tenant requests in the same org read
-- this and skip the billing step entirely.
--
-- vat_number is nullable: required at write time for company orgs
-- (enforced by the API, not the schema, because "company-or-personal"
-- isn't expressible as a column constraint). Notes is free-form
-- accounting context — VAT exemption reasons, special invoicing
-- arrangements, etc.
--
-- We do NOT migrate data from tenant_requests.billing_address into
-- this table automatically. Existing customers re-enter on next
-- tenant or via settings — the data set is small (single-digit
-- customers in pilot) and re-entering is the simplest path.
CREATE TABLE IF NOT EXISTS org_billing (
zitadel_org_id TEXT PRIMARY KEY,
company_name TEXT NOT NULL,
street_address TEXT NOT NULL,
postal_code TEXT NOT NULL,
city TEXT NOT NULL,
country TEXT NOT NULL,
vat_number TEXT,
billing_email TEXT NOT NULL,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`;
let migrated = false;
@@ -788,6 +817,88 @@ function mapRow(row: any): TenantRequest {
};
}
// ---------------------------------------------------------------------------
// Bug 35: org-scoped billing
// ---------------------------------------------------------------------------
function rowToOrgBilling(row: any): OrgBilling {
return {
zitadelOrgId: row.zitadel_org_id,
companyName: row.company_name,
streetAddress: row.street_address,
postalCode: row.postal_code,
city: row.city,
country: row.country,
vatNumber: row.vat_number ?? null,
billingEmail: row.billing_email,
notes: row.notes ?? null,
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
};
}
/**
* Fetch org billing if it exists. Returns null when the org has never
* captured billing — that's the signal the wizard uses to know
* whether to render the inline billing step on the first tenant
* request.
*/
export async function getOrgBilling(
zitadelOrgId: string
): Promise<OrgBilling | null> {
await ensureSchema();
const result = await getPool().query(
"SELECT * FROM org_billing WHERE zitadel_org_id = $1",
[zitadelOrgId]
);
return result.rows.length > 0 ? rowToOrgBilling(result.rows[0]) : null;
}
/**
* Insert or update org billing. Single function for both because the
* UI flow makes the "first time vs editing" distinction in a single
* settings page that doesn't need to know which one it's doing.
*
* VAT-required-for-companies isn't enforced here — that's an API
* concern (the API knows whether the caller is a company org).
* Keeping the DB layer dumb.
*/
export async function upsertOrgBilling(
data: Omit<OrgBilling, "createdAt" | "updatedAt">
): Promise<OrgBilling> {
await ensureSchema();
const result = await getPool().query(
`INSERT INTO org_billing (
zitadel_org_id, company_name, street_address, postal_code,
city, country, vat_number, billing_email, notes
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (zitadel_org_id) DO UPDATE SET
company_name = EXCLUDED.company_name,
street_address = EXCLUDED.street_address,
postal_code = EXCLUDED.postal_code,
city = EXCLUDED.city,
country = EXCLUDED.country,
vat_number = EXCLUDED.vat_number,
billing_email = EXCLUDED.billing_email,
notes = EXCLUDED.notes,
updated_at = now()
RETURNING *`,
[
data.zitadelOrgId,
data.companyName,
data.streetAddress,
data.postalCode,
data.city,
data.country,
data.vatNumber ?? null,
data.billingEmail,
data.notes ?? null,
]
);
return rowToOrgBilling(result.rows[0]);
}
// ---------------------------------------------------------------------------
// Slice 6: tenant ↔ user assignments
// ---------------------------------------------------------------------------

View File

@@ -86,6 +86,13 @@ export const billingAddressSchema = z
country: z.enum(SUPPORTED_COUNTRIES, {
message: "Please choose a country from the list",
}),
// Bug 35: VAT identifier. Required for company customers (B2B);
// omitted entirely for personal customers (B2C — private
// individuals don't have a VAT number). The schema marks it
// optional because the same schema is used for both flows;
// company-vs-personal enforcement happens at the API layer where
// `user.isPersonal` is known.
vatNumber: z.string().trim().max(50).optional(),
})
.superRefine((data, ctx) => {
const pattern = POSTAL_CODE_PATTERNS[data.country];
@@ -123,6 +130,12 @@ export const billingStepSchema = z.object({
* Full onboarding payload. Used by the API route and by the wizard's
* submit handler. `packageSecrets` is a free-shape map that gets
* encrypted by the server before it touches the DB.
*
* Bug 35: `billingAddress` is now optional at the schema level. The
* wizard omits it entirely when the org already has an `org_billing`
* record. The API enforces "billing must exist by the end" by either
* looking up the existing org_billing row OR validating the supplied
* payload — neither path can be skipped without a 400.
*/
export const onboardingSchema = z.object({
instanceName: z
@@ -139,7 +152,7 @@ export const onboardingSchema = z.object({
packageSecrets: z
.record(z.string(), z.record(z.string(), z.string()))
.optional(),
billingAddress: billingAddressSchema,
billingAddress: billingAddressSchema.optional(),
billingNotes: z.string().max(2_000).optional(),
});