Phase8: Auto bill credit card
Some checks failed
Build and Push / build (push) Failing after 42s

This commit is contained in:
2026-05-27 22:06:32 +02:00
parent ad4f614130
commit ee6bb89fb6
20 changed files with 1857 additions and 122 deletions

View File

@@ -93,6 +93,18 @@ const MIGRATION_SQL = `
-- is only meaningful for rejected and cancelled rows.
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS dismissed_at TIMESTAMPTZ;
-- Phase 9b: link a provision request to the paid setup-fee invoice
-- it was charged against at order time. Null on requests created
-- before Phase 9b, on resume requests, and during the brief
-- 'pending_payment' window before the Stripe webhook fires. The
-- admin reject flow refunds this invoice via the existing
-- refundInvoice helper.
ALTER TABLE tenant_requests
ADD COLUMN IF NOT EXISTS setup_invoice_id UUID REFERENCES invoices(id);
CREATE INDEX IF NOT EXISTS idx_tenant_requests_setup_invoice
ON tenant_requests(setup_invoice_id)
WHERE setup_invoice_id IS NOT NULL;
-- Feature 6: free-form customer note attached to the request.
-- Currently surfaced only by resume requests (where the customer
-- explains why they want reactivation), but the column is generic
@@ -1000,13 +1012,18 @@ export async function listTenantRequests(
status?: TenantRequestStatus
): Promise<TenantRequest[]> {
await ensureSchema();
// Phase 9b: 'pending_payment' rows are pre-Checkout: the customer
// submitted the wizard but hasn't paid the setup fee yet. They're
// invisible to admin until the webhook flips them to 'pending'.
// The explicit filter path still allows querying them (e.g.
// ?status=pending_payment) for debugging.
const result = status
? await getPool().query<TenantRequest>(
"SELECT * FROM tenant_requests WHERE status = $1 ORDER BY created_at DESC",
[status]
)
: await getPool().query<TenantRequest>(
"SELECT * FROM tenant_requests ORDER BY created_at DESC"
"SELECT * FROM tenant_requests WHERE status <> 'pending_payment' ORDER BY created_at DESC"
);
return result.rows.map(mapRow);
}
@@ -1431,6 +1448,7 @@ function mapRow(row: any): TenantRequest {
status: row.status as TenantRequestStatus,
adminNotes: row.admin_notes,
tenantName: row.tenant_name,
setupInvoiceId: row.setup_invoice_id ?? null,
encryptedSecrets: row.encrypted_secrets ?? null,
isPersonal: row.is_personal ?? false,
dismissedAt:
@@ -4131,3 +4149,146 @@ export async function getOrgIdByStripeCustomerId(
);
return result.rows.length > 0 ? result.rows[0].zitadel_org_id : null;
}
// ---------------------------------------------------------------------------
// Phase 9b — tenant order with setup-fee charge
// ---------------------------------------------------------------------------
/**
* Phase 9b: invoked by the Stripe webhook when the setup-fee
* Checkout for a tenant order completes. Atomically:
* - flips the request status from 'pending_payment' → 'pending'
* (admin queue now sees it)
* - sets tenant_name to the derived value (so monthly cron's
* setup-fee dedup works)
* - links the paid invoice via setup_invoice_id (so admin reject
* can refund it via the existing refund flow)
*
* Idempotent on the request side: if the webhook re-fires after
* the row already has status='pending', the UPDATE is a no-op
* (same values). On the rare case of webhook retry happening after
* admin already approved/rejected, the WHERE clause guards against
* regressing the status.
*/
export async function linkTenantRequestSetupPayment(params: {
requestId: string;
tenantName: string;
setupInvoiceId: string;
}): Promise<boolean> {
const result = await getPool().query(
`UPDATE tenant_requests
SET status = 'pending',
tenant_name = $2,
setup_invoice_id = $3,
updated_at = now()
WHERE id = $1
AND status = 'pending_payment'
RETURNING id`,
[params.requestId, params.tenantName, params.setupInvoiceId]
);
return (result.rowCount ?? 0) > 0;
}
/**
* Look up a tenant request by id without restricting by status —
* used by the webhook + reject handler. Caller is responsible for
* any role-gating; this is a pure read.
*/
export async function getTenantRequestForSetupFlow(
requestId: string
): Promise<TenantRequest | null> {
await ensureSchema();
const result = await getPool().query(
`SELECT * FROM tenant_requests WHERE id = $1`,
[requestId]
);
return result.rows.length > 0
? rowToTenantRequest(result.rows[0])
: null;
}
/**
* Insert a tenant request row in the 'pending_payment' status —
* used at order time, before the Stripe Checkout completes. Once
* payment succeeds the webhook flips it to 'pending' via
* linkTenantRequestSetupPayment.
*
* tenant_name stays NULL throughout pending_payment so the unique
* partial index uniq_tenant_requests_tenant_name_provision
* (WHERE tenant_name IS NOT NULL) doesn't block retries from
* abandoned Checkout sessions. The derived tenant_name is computed
* by the caller from the inserted row's id and stored only at
* webhook time.
*/
export async function createTenantRequestPendingPayment(params: {
zitadelOrgId: string;
zitadelUserId: string;
companyName: string;
instanceName?: string | null;
contactName: string;
contactEmail: string;
agentName: string;
soulMd?: string;
agentsMd?: string | null;
packages: string[];
billingAddress: Record<string, unknown>;
billingNotes?: string;
encryptedSecrets?: Buffer | null;
isPersonal: boolean;
}): Promise<TenantRequest> {
await ensureSchema();
const result = await getPool().query(
`INSERT INTO tenant_requests (
zitadel_org_id, zitadel_user_id,
company_name, instance_name, contact_name, contact_email,
agent_name, soul_md, agents_md, packages,
billing_address, billing_notes,
encrypted_secrets, is_personal,
status, request_type
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12,
$13, $14, 'pending_payment', 'provision'
)
RETURNING *`,
[
params.zitadelOrgId,
params.zitadelUserId,
params.companyName,
params.instanceName ?? null,
params.contactName,
params.contactEmail,
params.agentName,
params.soulMd ?? null,
params.agentsMd ?? null,
params.packages,
JSON.stringify(params.billingAddress),
params.billingNotes ?? null,
params.encryptedSecrets ?? null,
params.isPersonal,
]
);
return rowToTenantRequest(result.rows[0]);
}
/**
* Delete a pending_payment row — used when admin or system needs
* to clean up an abandoned order (e.g. Checkout session expired
* before the customer completed payment). Guarded: only deletes
* if status is still 'pending_payment' so we never accidentally
* delete a request that admin has already approved.
*
* Also nulls any setup_invoice_id reference before deleting so we
* don't leave dangling FK refs (we don't have ON DELETE behavior
* defined on the column).
*/
export async function deletePendingPaymentRequest(
requestId: string
): Promise<boolean> {
const result = await getPool().query(
`DELETE FROM tenant_requests
WHERE id = $1 AND status = 'pending_payment'
RETURNING id`,
[requestId]
);
return (result.rowCount ?? 0) > 0;
}