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

This commit is contained in:
2026-05-27 20:41:17 +02:00
parent 9939f75c03
commit 8e7691d38a
13 changed files with 944 additions and 7 deletions

View File

@@ -0,0 +1,51 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser } from "@/lib/session";
import { setAutoChargeEnabled } from "@/lib/db";
import { safeError } from "@/lib/errors";
/**
* POST /api/billing/auto-charge
*
* Phase 9. Toggle the auto_charge_enabled flag on the caller's
* org. The body is `{ enabled: boolean }`.
*
* When OFF: invoices issued for this org won't trigger an
* auto-charge against the saved card. The customer pays
* manually (or admin marks paid) — same flow as a bank-transfer
* customer.
*
* When ON: future invoice issuance attempts the auto-charge.
* No effect if there's no saved card on file.
*
* Idempotent: setting OFF on an already-OFF flag is a no-op
* (same outcome).
*/
const bodySchema = z.object({
enabled: z.boolean(),
});
export async function POST(request: Request) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json().catch(() => ({}));
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid request", details: parsed.error.flatten() },
{ status: 400 }
);
}
try {
await setAutoChargeEnabled(user.orgId, parsed.data.enabled);
return NextResponse.json({ enabled: parsed.data.enabled });
} catch (e) {
return NextResponse.json(
{ error: safeError(e, "Failed to update auto-charge setting") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,46 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { clearSavedPaymentMethod, getOrgBillingConfig } from "@/lib/db";
import { detachPaymentMethod } from "@/lib/stripe";
import { safeError } from "@/lib/errors";
/**
* DELETE /api/billing/saved-card
*
* Phase 9. Remove the saved card for the caller's org. Detaches
* the PaymentMethod in Stripe (so it can't be charged again) and
* clears the four display columns + the pm_id reference locally.
*
* Idempotent: calling on an org with no saved card returns 200
* (the desired end-state is already reached).
*
* Auth: any signed-in member of the org. Same reasoning as the
* setup endpoint — card removal is a customer-visible action; it
* doesn't leak anything, and a non-owner needing to remove a
* stolen-card-on-file shouldn't be blocked by role gating.
*/
export async function DELETE() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const cfg = await getOrgBillingConfig(user.orgId);
if (!cfg || !cfg.stripeDefaultPaymentMethodId) {
// Already empty — no-op, return success.
return NextResponse.json({ removed: false });
}
// Stripe detach first. If it fails for a real reason (network,
// 500 from Stripe), we don't clear the DB — admin can retry.
// 404 is treated as success by detachPaymentMethod (PM already
// gone), so we proceed to clear the DB regardless.
await detachPaymentMethod(cfg.stripeDefaultPaymentMethodId);
await clearSavedPaymentMethod(user.orgId);
return NextResponse.json({ removed: true });
} catch (e) {
return NextResponse.json(
{ error: safeError(e, "Failed to remove card") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,71 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { getOrgBilling } from "@/lib/db";
import {
createSetupCheckoutSession,
ensureStripeCustomerForOrg,
} from "@/lib/stripe";
import { safeError } from "@/lib/errors";
/**
* POST /api/billing/setup-card
*
* Phase 9. Customer-initiated "Set up auto-pay" / "Update card"
* flow. Creates a Checkout session in setup mode and returns its
* URL — the caller redirects the browser. On completion, the
* webhook handler saves the resulting PaymentMethod's display
* fields against this org's billing config.
*
* Auth: any signed-in member of the org. We don't owner-gate this
* because non-owners might legitimately need to update payment
* (e.g., for a team they administer). The actual card data is
* collected by Stripe, not us — there's nothing to leak from
* misuse here.
*
* Requires an existing billing snapshot (org_billing row). If
* absent, returns 400 — the customer hasn't set their billing
* address yet, and Stripe needs the address for the customer
* object.
*/
export async function POST(request: Request) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const orgBilling = await getOrgBilling(user.orgId);
if (!orgBilling) {
return NextResponse.json(
{ error: "Billing address required before saving a card." },
{ status: 400 }
);
}
try {
// Ensure the Stripe customer exists. Idempotent — if we
// already created one for this org (e.g. from a prior
// "Pay by Card" Checkout), it's reused.
const customerId = await ensureStripeCustomerForOrg({
zitadelOrgId: user.orgId,
companyName: orgBilling.companyName,
billingEmail: orgBilling.billingEmail,
address: {
line1: orgBilling.streetAddress,
postalCode: orgBilling.postalCode,
city: orgBilling.city,
country: orgBilling.country,
},
});
// Pick the base URL from the request's origin so redirects
// work in dev (localhost), staging, and prod without env vars.
const origin = new URL(request.url).origin;
const session = await createSetupCheckoutSession({
customerId,
baseUrl: origin,
});
return NextResponse.json({ url: session.url });
} catch (e) {
return NextResponse.json(
{ error: safeError(e, "Failed to start card setup") },
{ status: 500 }
);
}
}

View File

@@ -1,12 +1,18 @@
import { NextResponse } from "next/server";
import type Stripe from "stripe";
import { getStripeClient, getWebhookSecret } from "@/lib/stripe";
import {
getPaymentMethodDisplay,
getStripeClient,
getWebhookSecret,
} from "@/lib/stripe";
import {
getInvoiceByStripePaymentIntent,
getOrgIdByStripeCustomerId,
isStripeRefundRecorded,
markInvoicePaid,
markStripeEventProcessed,
setInvoiceStripePaymentIntent,
setSavedPaymentMethod,
tryRecordStripeEvent,
} from "@/lib/db";
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
@@ -161,6 +167,14 @@ export async function POST(request: Request) {
async function handleCheckoutCompleted(
session: Stripe.Checkout.Session
): Promise<void> {
// Phase 9: setup-mode sessions don't pay anything — they
// authorize a card for off-session future charges. The
// PaymentMethod is attached to the customer and the session's
// setup_intent.payment_method holds the id we save.
if (session.mode === "setup") {
await handleSetupCompleted(session);
return;
}
// Defensive: paid sessions are what we want; sessions can also
// complete in "unpaid" state (rare for mode=payment, more common
// for async/delayed methods like SEPA). Only flip the invoice
@@ -211,6 +225,97 @@ async function handleCheckoutCompleted(
);
}
/**
* Phase 9: handle setup-mode Checkout completion. The customer
* authorized a card for future off-session charges; persist the
* display fields against their org so the portal can show the
* saved card and use it for auto-charge.
*
* The session carries:
* - mode: 'setup'
* - customer: 'cus_xxx' (the Stripe customer id we created)
* - setup_intent: 'seti_xxx' (the SetupIntent — has payment_method)
*
* We look up which org owns the customer (via
* org_billing_config.stripe_customer_id), fetch the SetupIntent
* to find the resulting PaymentMethod id, then fetch the PM for
* its display fields. Three Stripe round-trips total — acceptable
* for a one-off setup event.
*/
async function handleSetupCompleted(
session: Stripe.Checkout.Session
): Promise<void> {
const customerId =
typeof session.customer === "string"
? session.customer
: session.customer?.id;
if (!customerId) {
console.error(
`Setup session ${session.id} completed without a customer; cannot link to org.`
);
return;
}
const orgId = await getOrgIdByStripeCustomerId(customerId);
if (!orgId) {
console.error(
`Setup session ${session.id} for customer ${customerId} has no matching org.`
);
return;
}
const setupIntentId =
typeof session.setup_intent === "string"
? session.setup_intent
: session.setup_intent?.id;
if (!setupIntentId) {
console.error(
`Setup session ${session.id} completed without a setup_intent id.`
);
return;
}
// Read the SetupIntent for the resulting PaymentMethod id.
const stripe = getStripeClient();
const setupIntent = await stripe.setupIntents.retrieve(setupIntentId);
const paymentMethodId =
typeof setupIntent.payment_method === "string"
? setupIntent.payment_method
: setupIntent.payment_method?.id;
if (!paymentMethodId) {
console.error(
`Setup session ${session.id}: setup_intent ${setupIntentId} has no payment_method.`
);
return;
}
// Fetch the PM details for display columns.
const display = await getPaymentMethodDisplay(paymentMethodId);
await setSavedPaymentMethod({
zitadelOrgId: orgId,
stripeCustomerId: customerId,
paymentMethodId,
brand: display.brand,
last4: display.last4,
expMonth: display.expMonth,
expYear: display.expYear,
});
// Also tell Stripe this PM is the customer's default for invoice
// payments — so a future stripe.paymentIntents.create against
// this customer without an explicit payment_method picks it up.
// Best-effort: a failure here doesn't undo the save (we have the
// pm id, we can pass it explicitly when charging in Phase 9b).
try {
await stripe.customers.update(customerId, {
invoice_settings: { default_payment_method: paymentMethodId },
});
} catch (e) {
console.warn(
`Setup session ${session.id}: failed to set default_payment_method on customer ${customerId}; will pass pm id explicitly on charges.`,
e
);
}
console.log(
`Saved PaymentMethod ${paymentMethodId} (${display.brand} ${display.last4}) for org ${orgId}.`
);
}
async function handleChargeRefunded(charge: Stripe.Charge): Promise<void> {
// Phase 7: mirror Stripe refunds into the portal so credit notes
// are issued for refunds initiated in the Stripe Dashboard. For