106 lines
3.3 KiB
TypeScript
106 lines
3.3 KiB
TypeScript
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 }
|
|
);
|
|
}
|
|
}
|