This commit is contained in:
163
src/lib/db.ts
163
src/lib/db.ts
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user