import { NextResponse } from "next/server"; import { getSessionUser } from "@/lib/session"; import { getInvoiceByNumberForOrg, getOrgBilling, } from "@/lib/db"; import { createCheckoutSessionForInvoice, ensureStripeCustomerForOrg, } from "@/lib/stripe"; import { safeError } from "@/lib/errors"; /** * POST /api/billing/invoices/[invoiceNumber]/pay * * Initiates a Stripe Checkout Session for an open invoice. Returns * `{ url }` — the browser is expected to navigate to that URL, * where Stripe hosts the payment UI. * * Authorization: caller must belong to the invoice's org (the DB * query enforces this — wrong-org returns 404, indistinguishable * from a non-existent invoice). * * Preconditions enforced server-side: * - Invoice exists for caller's org * - Invoice status is 'open' or 'overdue' (paid/void/draft/uncollectible * all reject — already-paid invoices in particular must not * create a second Checkout Session, even though Stripe would * deduplicate the actual charge) * * The Stripe Customer for the org is lazily ensured here — first * card click on an org creates the customer; subsequent clicks * reuse the persisted stripe_customer_id. */ export async function POST( _request: Request, { params }: { params: Promise<{ invoiceNumber: string }> } ) { const user = await getSessionUser(); if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const { invoiceNumber } = await params; const detail = await getInvoiceByNumberForOrg(invoiceNumber, user.orgId); if (!detail) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } const inv = detail.invoice; if (inv.status !== "open" && inv.status !== "overdue") { return NextResponse.json( { error: inv.status === "paid" ? "This invoice has already been paid." : `This invoice cannot be paid online (status: ${inv.status}).`, }, { status: 409 } ); } // We need org_billing for the customer creation address. The // invoice has a SNAPSHOT but that's frozen at issue time; for // creating/updating the Stripe customer we want the current // address (which may have been corrected since the invoice). // Snapshot is still authoritative on the invoice PDF and total. const orgBilling = await getOrgBilling(user.orgId); if (!orgBilling) { return NextResponse.json( { error: "Billing details are not configured for your organization." }, { status: 400 } ); } try { 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, }, }); const baseUrl = process.env.APP_BASE_URL ?? "https://app.pieced.ch"; const { url } = await createCheckoutSessionForInvoice({ invoice: inv, customerId, baseUrl, }); return NextResponse.json({ url }); } catch (e) { console.error( `Failed to create Checkout Session for invoice ${invoiceNumber}:`, e ); return NextResponse.json( { error: safeError(e, "Failed to start card payment.") }, { status: 500 } ); } }