This commit is contained in:
18
package-lock.json
generated
18
package-lock.json
generated
@@ -19,6 +19,7 @@
|
||||
"pg": "^8.20.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"stripe": "^22.1.1",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -7530,6 +7531,23 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/stripe": {
|
||||
"version": "22.1.1",
|
||||
"resolved": "https://registry.npmjs.org/stripe/-/stripe-22.1.1.tgz",
|
||||
"integrity": "sha512-cmodIYP27tBkJ8G7DuGgWw0PFuemlFZbuF3Wwr1TrjFjUa3T7NIgCe6TVwX8BO2ynu+xtTuDGfHafNDCPt9lXA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/styled-jsx": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"pg": "^8.20.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"stripe": "^22.1.1",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
105
src/app/api/billing/invoices/[invoiceNumber]/pay/route.ts
Normal file
105
src/app/api/billing/invoices/[invoiceNumber]/pay/route.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
232
src/app/api/stripe/webhook/route.ts
Normal file
232
src/app/api/stripe/webhook/route.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type Stripe from "stripe";
|
||||
import { getStripeClient, getWebhookSecret } from "@/lib/stripe";
|
||||
import {
|
||||
markInvoicePaid,
|
||||
markStripeEventProcessed,
|
||||
setInvoiceStripePaymentIntent,
|
||||
tryRecordStripeEvent,
|
||||
} from "@/lib/db";
|
||||
|
||||
/**
|
||||
* POST /api/stripe/webhook
|
||||
*
|
||||
* Receives signed events from Stripe. The lifecycle:
|
||||
*
|
||||
* 1. Read RAW body (request.text(), NOT request.json() — Stripe's
|
||||
* HMAC is computed over the raw bytes and any JSON re-parse
|
||||
* will subtly mangle whitespace or property ordering and the
|
||||
* signature will fail).
|
||||
* 2. Verify signature against the configured webhook secret. If
|
||||
* verification fails → 400. An attacker forging webhook calls
|
||||
* could otherwise mark our invoices paid.
|
||||
* 3. Idempotency: INSERT the event id into stripe_events. If the
|
||||
* INSERT conflicts (duplicate delivery, which is normal — Stripe
|
||||
* retries failed deliveries for up to 72h), return 200 immediately
|
||||
* so Stripe doesn't keep retrying.
|
||||
* 4. Process the event based on type. Currently we care about:
|
||||
* - checkout.session.completed → flip invoice to paid
|
||||
* - charge.refunded → log; void/credit handling is Phase 7
|
||||
* - payment_intent.payment_failed → log only; the failure is
|
||||
* already shown to the user on
|
||||
* the Stripe page, no action.
|
||||
* Unknown event types are ack'd with 200 (we may have other
|
||||
* events enabled at the Stripe end that we don't yet care about,
|
||||
* and 200 + log is cheaper than 404 + Stripe retries).
|
||||
* 5. Stamp processed_at on success.
|
||||
*
|
||||
* Return contract: 2xx ack → Stripe stops retrying. Any non-2xx →
|
||||
* Stripe retries with exponential backoff up to 72h. We aim for
|
||||
* 200 on every reachable path (verified, deduplicated, or processed),
|
||||
* and only 400 for signature failures (those would never succeed
|
||||
* on retry anyway, so retrying is wasted effort).
|
||||
*
|
||||
* Performance: handlers run synchronously here because PieCed's
|
||||
* event volume is tiny. If/when that changes, the obvious refactor
|
||||
* is to enqueue (Phase 7) and ack first — but at v1 the inline
|
||||
* model is simpler to reason about and harder to lose events with.
|
||||
*/
|
||||
|
||||
// Next.js: explicitly disable static optimization; this route MUST
|
||||
// run on every request and must not be cached.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// 1. Raw body — Stripe verifies the signature over these exact bytes.
|
||||
const rawBody = await request.text();
|
||||
const signature = request.headers.get("stripe-signature");
|
||||
if (!signature) {
|
||||
return new NextResponse("Missing stripe-signature header", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Verify signature.
|
||||
let event: Stripe.Event;
|
||||
try {
|
||||
const stripe = getStripeClient();
|
||||
const secret = getWebhookSecret();
|
||||
event = stripe.webhooks.constructEvent(rawBody, signature, secret);
|
||||
} catch (err) {
|
||||
console.error("Stripe webhook signature verification failed:", err);
|
||||
// 400 — never retry. The webhook configuration is wrong on
|
||||
// either end (rotated secret, wrong endpoint, etc.); retries
|
||||
// won't fix it.
|
||||
return new NextResponse("Invalid signature", { status: 400 });
|
||||
}
|
||||
|
||||
// 3. Idempotency. INSERT event.id → fail-fast on duplicate.
|
||||
let firstDelivery: boolean;
|
||||
try {
|
||||
firstDelivery = await tryRecordStripeEvent(
|
||||
event.id,
|
||||
event.type,
|
||||
event
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Failed to record stripe event ${event.id} (${event.type}):`,
|
||||
err
|
||||
);
|
||||
// 5xx so Stripe retries — this is a DB hiccup, not a logic error.
|
||||
return new NextResponse("DB error", { status: 500 });
|
||||
}
|
||||
if (!firstDelivery) {
|
||||
// Already processed; ack happily.
|
||||
return new NextResponse("Duplicate delivery; acknowledged.", {
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Process. Each handler is responsible for being safe to run
|
||||
// exactly once (we already deduplicated by event.id above).
|
||||
try {
|
||||
switch (event.type) {
|
||||
case "checkout.session.completed":
|
||||
await handleCheckoutCompleted(
|
||||
event.data.object as Stripe.Checkout.Session
|
||||
);
|
||||
break;
|
||||
case "charge.refunded":
|
||||
await handleChargeRefunded(event.data.object as Stripe.Charge);
|
||||
break;
|
||||
case "payment_intent.payment_failed":
|
||||
await handlePaymentFailed(
|
||||
event.data.object as Stripe.PaymentIntent
|
||||
);
|
||||
break;
|
||||
default:
|
||||
// Unknown event — log so we notice if Stripe starts sending
|
||||
// something we should handle, but ack so we don't accumulate
|
||||
// retries forever.
|
||||
console.log(
|
||||
`Stripe webhook: ignoring event type ${event.type} (id ${event.id})`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Stripe webhook handler failed for ${event.type} (id ${event.id}):`,
|
||||
err
|
||||
);
|
||||
// 5xx → Stripe retries. The handler is idempotent because the
|
||||
// stripe_events row already exists, so on the next attempt we'd
|
||||
// short-circuit at step 3. To actually retry the work we'd need
|
||||
// to DELETE the stripe_events row first; for v1 we don't bother
|
||||
// and let a human investigate the logs.
|
||||
return new NextResponse("Handler error", { status: 500 });
|
||||
}
|
||||
|
||||
// 5. Mark processed.
|
||||
try {
|
||||
await markStripeEventProcessed(event.id);
|
||||
} catch (err) {
|
||||
// Non-fatal — the event was already processed, this is just the
|
||||
// bookkeeping flag. Log and move on.
|
||||
console.error(
|
||||
`Failed to mark stripe event ${event.id} processed:`,
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
return new NextResponse("OK", { status: 200 });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleCheckoutCompleted(
|
||||
session: Stripe.Checkout.Session
|
||||
): Promise<void> {
|
||||
// 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
|
||||
// when payment actually cleared.
|
||||
if (session.payment_status !== "paid") {
|
||||
console.log(
|
||||
`Checkout session ${session.id} completed but payment_status=${session.payment_status}; waiting for downstream events.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const invoiceId =
|
||||
session.metadata?.invoice_id ?? session.client_reference_id ?? null;
|
||||
if (!invoiceId) {
|
||||
console.error(
|
||||
`Checkout session ${session.id} completed without invoice_id metadata; cannot link to invoice.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const paymentIntentId =
|
||||
typeof session.payment_intent === "string"
|
||||
? session.payment_intent
|
||||
: session.payment_intent?.id;
|
||||
|
||||
// Persist the PaymentIntent id on the invoice for traceability +
|
||||
// future refund correlation.
|
||||
if (paymentIntentId) {
|
||||
await setInvoiceStripePaymentIntent(invoiceId, paymentIntentId);
|
||||
}
|
||||
|
||||
// Flip status. markInvoicePaid is idempotent — re-running on an
|
||||
// already-paid invoice returns null and we log + skip.
|
||||
const updated = await markInvoicePaid(invoiceId, {
|
||||
paidBy: "stripe",
|
||||
paidMethodDetail: paymentIntentId
|
||||
? `Stripe Checkout (${paymentIntentId})`
|
||||
: "Stripe Checkout",
|
||||
paidAt: session.created ? new Date(session.created * 1000) : undefined,
|
||||
});
|
||||
if (!updated) {
|
||||
// Already paid or void/draft — fine, nothing to do.
|
||||
console.log(
|
||||
`Invoice ${invoiceId} was not in a payable state when Stripe webhook arrived (likely already paid).`
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`Invoice ${invoiceId} marked paid via Stripe (session ${session.id}, intent ${paymentIntentId}).`
|
||||
);
|
||||
}
|
||||
|
||||
async function handleChargeRefunded(charge: Stripe.Charge): Promise<void> {
|
||||
// v1 scope: log only. Refunds always go through Stripe → admin
|
||||
// initiates them in the dashboard. Updating our invoice status
|
||||
// to 'void' or partial-credit needs more product thinking
|
||||
// (partial refunds? credit notes? VAT corrections?). Phase 7.
|
||||
console.log(
|
||||
`Charge ${charge.id} refunded (amount ${charge.amount_refunded} ${charge.currency}); no portal-side state change.`
|
||||
);
|
||||
}
|
||||
|
||||
async function handlePaymentFailed(
|
||||
intent: Stripe.PaymentIntent
|
||||
): Promise<void> {
|
||||
// The Stripe-hosted page already shows the failure to the user.
|
||||
// We log here for support visibility and to surface in Workbench.
|
||||
// No invoice state change — it stays 'open' until paid.
|
||||
console.log(
|
||||
`PaymentIntent ${intent.id} failed: ${
|
||||
intent.last_payment_error?.message ?? "(no message)"
|
||||
}`
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type { Invoice, InvoiceLine } from "@/types";
|
||||
import { PayInvoiceButton } from "./pay-invoice-button";
|
||||
import { PaymentStatusBanner } from "./payment-status-banner";
|
||||
|
||||
interface Props {
|
||||
invoice: Invoice;
|
||||
@@ -29,6 +31,7 @@ export function CustomerInvoiceDetail({ invoice, lines }: Props) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-in">
|
||||
<PaymentStatusBanner />
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
@@ -49,15 +52,24 @@ export function CustomerInvoiceDetail({ invoice, lines }: Props) {
|
||||
{fmt.dateTime(new Date(invoice.periodEnd), { dateStyle: "long" })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 flex-wrap">
|
||||
{/* Phase 4: Pay-with-card available for open + overdue.
|
||||
Paid/void/draft/uncollectible hide the button — the
|
||||
API also enforces this, so client-side hiding is just
|
||||
for the visible affordance. */}
|
||||
{(invoice.status === "open" || invoice.status === "overdue") && (
|
||||
<PayInvoiceButton invoiceNumber={invoice.invoiceNumber} />
|
||||
)}
|
||||
<a
|
||||
href={`/api/billing/invoices/${encodeURIComponent(invoice.invoiceNumber)}/pdf`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors"
|
||||
className="px-4 py-2 rounded-md bg-surface-3 hover:bg-surface-2 border border-border text-sm font-medium transition-colors"
|
||||
>
|
||||
{t("downloadPdf")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="space-y-2 mb-4">
|
||||
|
||||
64
src/components/billing/pay-invoice-button.tsx
Normal file
64
src/components/billing/pay-invoice-button.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface Props {
|
||||
invoiceNumber: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pay-with-card button. Posts to /api/billing/invoices/[n]/pay,
|
||||
* which returns a Stripe Checkout Session URL; we redirect the
|
||||
* browser there.
|
||||
*
|
||||
* The button is rendered only by the parent for status='open' or
|
||||
* 'overdue' invoices — the API enforces this too, but pre-filtering
|
||||
* UI-side keeps the dead state out of the customer's face.
|
||||
*/
|
||||
export function PayInvoiceButton({ invoiceNumber }: Props) {
|
||||
const t = useTranslations("customerBilling");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const onClick = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/billing/invoices/${encodeURIComponent(invoiceNumber)}/pay`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
if (!data.url) {
|
||||
throw new Error("Payment session URL missing from response.");
|
||||
}
|
||||
// Hard navigation, not Next.js router — Stripe Checkout is a
|
||||
// separate origin and the browser needs to fully leave our app.
|
||||
window.location.href = data.url;
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? String(e));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{busy ? t("redirectingToStripe") : t("payWithCard")}
|
||||
</button>
|
||||
{error && (
|
||||
<span className="text-xs text-error max-w-[260px] text-right">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
src/components/billing/payment-status-banner.tsx
Normal file
66
src/components/billing/payment-status-banner.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
/**
|
||||
* Banner shown after a return from Stripe Checkout.
|
||||
*
|
||||
* ?paid=1 → green success banner. The webhook may or may
|
||||
* not have processed yet, so we phrase the message
|
||||
* as "Payment received, status will update shortly"
|
||||
* and don't claim the status is already paid. A
|
||||
* light auto-refresh after a few seconds nudges
|
||||
* the page to pick up the new status badge.
|
||||
*
|
||||
* ?cancelled=1 → neutral grey banner: "Payment cancelled". The
|
||||
* invoice stays in 'open' state.
|
||||
*
|
||||
* The banner cleans up the query params from the URL so a page
|
||||
* reload doesn't repeat the message. We use router.replace() to
|
||||
* keep history clean.
|
||||
*/
|
||||
export function PaymentStatusBanner() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("customerBilling");
|
||||
const [state, setState] = useState<"paid" | "cancelled" | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.has("paid")) {
|
||||
setState("paid");
|
||||
// Reload after 4s so the status badge picks up the webhook's
|
||||
// effect on the invoice row. By then most webhook deliveries
|
||||
// have landed; if not the user just sees "open" and can
|
||||
// manually refresh.
|
||||
const timer = setTimeout(() => {
|
||||
router.refresh();
|
||||
}, 4000);
|
||||
// Strip the query string out of the URL.
|
||||
const cleanUrl = window.location.pathname;
|
||||
window.history.replaceState({}, "", cleanUrl);
|
||||
return () => clearTimeout(timer);
|
||||
} else if (params.has("cancelled")) {
|
||||
setState("cancelled");
|
||||
const cleanUrl = window.location.pathname;
|
||||
window.history.replaceState({}, "", cleanUrl);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
if (state === "paid") {
|
||||
return (
|
||||
<div className="mb-4 p-3 rounded-md border border-success bg-success/10 text-sm text-success">
|
||||
{t("paymentReceived")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state === "cancelled") {
|
||||
return (
|
||||
<div className="mb-4 p-3 rounded-md border border-border bg-surface-2 text-sm text-text-secondary">
|
||||
{t("paymentCancelled")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
138
src/lib/db.ts
138
src/lib/db.ts
@@ -500,7 +500,7 @@ const MIGRATION_SQL = `
|
||||
-- NULL for org-wide items; tenant name for per-tenant breakdowns.
|
||||
tenant_name TEXT,
|
||||
kind TEXT NOT NULL CHECK (kind IN (
|
||||
'tenant_monthly','tenant_setup','ai_usage','threema_messages','skill_usage','adjustment'
|
||||
'tenant_monthly','tenant_setup','ai_usage','threema_messages','skill_usage','skill_setup','adjustment'
|
||||
)),
|
||||
description TEXT NOT NULL,
|
||||
quantity NUMERIC(12,4) NOT NULL DEFAULT 1,
|
||||
@@ -563,6 +563,41 @@ const MIGRATION_SQL = `
|
||||
-- Per-tenant lookup for the customer UI's pending+rejected display.
|
||||
CREATE INDEX IF NOT EXISTS idx_skill_act_tenant
|
||||
ON skill_activation_requests (tenant_name, requested_at DESC);
|
||||
|
||||
-- Phase 3 fix: the original invoice_lines.kind CHECK constraint
|
||||
-- was created without 'skill_setup' (which Phase 2-fix6 added as
|
||||
-- a new line kind for per-skill setup fees). CREATE TABLE IF NOT
|
||||
-- EXISTS doesn't update constraints on existing tables, so we
|
||||
-- explicitly drop and re-add with the full kind set on every
|
||||
-- boot. Idempotent — DROP IF EXISTS swallows the not-yet-exists
|
||||
-- case (fresh installs); ADD always re-creates. Constraint name
|
||||
-- follows Postgres's default <table>_<column>_check.
|
||||
ALTER TABLE invoice_lines
|
||||
DROP CONSTRAINT IF EXISTS invoice_lines_kind_check;
|
||||
ALTER TABLE invoice_lines
|
||||
ADD CONSTRAINT invoice_lines_kind_check
|
||||
CHECK (kind IN (
|
||||
'tenant_monthly','tenant_setup','ai_usage','threema_messages',
|
||||
'skill_usage','skill_setup','adjustment'
|
||||
));
|
||||
|
||||
-- Phase 4: Stripe webhook idempotency. Stripe guarantees at-least-once
|
||||
-- delivery and retries failures with exponential backoff for up to 72h,
|
||||
-- so the same event.id can arrive multiple times. We insert each
|
||||
-- event.id with the PK constraint enforcing uniqueness; INSERT either
|
||||
-- succeeds (first delivery → process the event) or fails with 23505
|
||||
-- (duplicate → ack with 200 and skip). The payload column is invaluable
|
||||
-- when diagnosing a webhook that processed wrong; keep it small and
|
||||
-- prune old rows out-of-band if storage becomes a concern (Phase 7).
|
||||
CREATE TABLE IF NOT EXISTS stripe_events (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
event_type TEXT NOT NULL,
|
||||
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
processed_at TIMESTAMPTZ,
|
||||
payload JSONB
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_stripe_events_type_received
|
||||
ON stripe_events (event_type, received_at DESC);
|
||||
`;
|
||||
|
||||
let migrated = false;
|
||||
@@ -2825,3 +2860,104 @@ export async function updateSkillActivationRequestStatus(
|
||||
? rowToSkillActivationRequest(result.rows[0])
|
||||
: null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 3 diagnostic — single-purpose helper for the /api/admin/billing/debug
|
||||
// endpoint. Returns raw invoice_line rows for a tenant filtered to setup-fee
|
||||
// rows, so a human can verify what the billing emission code's SQL is
|
||||
// actually seeing. Not intended for production use; kept here for shipping
|
||||
// hotfixes when running-total drafts diverge from expected behaviour.
|
||||
// ---------------------------------------------------------------------------
|
||||
export async function debugListSetupLines(
|
||||
tenantName: string
|
||||
): Promise<Array<{
|
||||
id: string;
|
||||
invoice_id: string;
|
||||
tenant_name: string;
|
||||
kind: string;
|
||||
amount_chf: number;
|
||||
description: string;
|
||||
}>> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`SELECT id, invoice_id, tenant_name, kind, amount_chf, description
|
||||
FROM invoice_lines
|
||||
WHERE tenant_name = $1
|
||||
AND kind = 'tenant_setup'`,
|
||||
[tenantName]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stripe — Phase 4
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Attempt to record receipt of a Stripe webhook event. Returns true
|
||||
* when this is the first time we've seen the event (caller should
|
||||
* process it), false when the event_id was already present
|
||||
* (caller should ack with 200 and skip — Stripe retries are
|
||||
* normal and we must be idempotent).
|
||||
*
|
||||
* The whole-payload JSONB is stored so a misbehaving event can be
|
||||
* diagnosed after the fact without re-fetching from Stripe.
|
||||
*/
|
||||
export async function tryRecordStripeEvent(
|
||||
eventId: string,
|
||||
eventType: string,
|
||||
payload: unknown
|
||||
): Promise<boolean> {
|
||||
await ensureSchema();
|
||||
try {
|
||||
await getPool().query(
|
||||
`INSERT INTO stripe_events (event_id, event_type, payload)
|
||||
VALUES ($1, $2, $3::jsonb)`,
|
||||
[eventId, eventType, JSON.stringify(payload)]
|
||||
);
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
// 23505 = unique_violation; the row already exists, meaning we've
|
||||
// seen this event before. That's the normal duplicate-delivery
|
||||
// case — return false so the caller short-circuits.
|
||||
if (e?.code === "23505") return false;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp processed_at on a stripe_events row once the handler has
|
||||
* finished its work successfully. Lets us spot stuck events
|
||||
* (received but not processed) for diagnosis.
|
||||
*/
|
||||
export async function markStripeEventProcessed(eventId: string): Promise<void> {
|
||||
await ensureSchema();
|
||||
await getPool().query(
|
||||
"UPDATE stripe_events SET processed_at = now() WHERE event_id = $1",
|
||||
[eventId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the Stripe PaymentIntent id on an invoice. Used by the
|
||||
* webhook handler once the Checkout Session completes — at that
|
||||
* point Stripe has minted the PaymentIntent and we want to be
|
||||
* able to find the Stripe-side record from the invoice (and vice
|
||||
* versa via metadata).
|
||||
*
|
||||
* Idempotent: re-running with the same value is a no-op. The
|
||||
* column was added in Phase 2 schema; this helper was missing.
|
||||
*/
|
||||
export async function setInvoiceStripePaymentIntent(
|
||||
invoiceId: string,
|
||||
paymentIntentId: string
|
||||
): Promise<void> {
|
||||
await ensureSchema();
|
||||
await getPool().query(
|
||||
`UPDATE invoices
|
||||
SET stripe_payment_intent_id = $2
|
||||
WHERE id = $1
|
||||
AND (stripe_payment_intent_id IS NULL OR stripe_payment_intent_id = $2)`,
|
||||
[invoiceId, paymentIntentId]
|
||||
);
|
||||
}
|
||||
|
||||
245
src/lib/stripe.ts
Normal file
245
src/lib/stripe.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Server-side Stripe client + helpers for Phase 4 (card payments).
|
||||
*
|
||||
* Architecture (see Phase 4 notes):
|
||||
* 1. Customer clicks "Pay with card" on /billing/<number>.
|
||||
* 2. Server creates Stripe Checkout Session (mode='payment') with
|
||||
* the invoice total as a single line item. We pass `customer`
|
||||
* to reuse an existing Stripe Customer if the org already has
|
||||
* one, otherwise we create one and persist its id in
|
||||
* org_billing_config.stripe_customer_id.
|
||||
* 3. Returns session.url; the browser redirects there.
|
||||
* 4. Customer pays; Stripe redirects to success_url with the
|
||||
* session id appended.
|
||||
* 5. /api/stripe/webhook receives `checkout.session.completed`,
|
||||
* verifies signature, looks up the invoice id from metadata,
|
||||
* flips the invoice to 'paid'.
|
||||
*
|
||||
* Env vars:
|
||||
* STRIPE_SECRET_KEY (required) - sk_test_... in sandbox, sk_live_... in prod
|
||||
* STRIPE_WEBHOOK_SECRET (required for webhook) - whsec_...
|
||||
* APP_BASE_URL (required) - e.g. https://app.pieced.ch
|
||||
*
|
||||
* SDK: stripe@22.x (Node SDK v22), pinned API version 2026-03-25.dahlia.
|
||||
* Pinning the API version means a `npm update` of the SDK won't
|
||||
* silently change request/response shapes; we explicitly bump when
|
||||
* we want a new API version.
|
||||
*/
|
||||
|
||||
import Stripe from "stripe";
|
||||
import type { Invoice } from "@/types";
|
||||
|
||||
// Pinned API version. Bump deliberately, not via dependency update.
|
||||
// Keep this matched to the stripe-node major version in package.json.
|
||||
const STRIPE_API_VERSION = "2026-03-25.dahlia" as const;
|
||||
|
||||
// Cache the client across hot reloads / serverless invocations.
|
||||
// We don't instantiate at module load because some build steps run
|
||||
// without runtime env vars set — only fail when actually used.
|
||||
let cachedClient: Stripe | null = null;
|
||||
|
||||
export function getStripeClient(): Stripe {
|
||||
if (cachedClient) return cachedClient;
|
||||
const key = process.env.STRIPE_SECRET_KEY;
|
||||
if (!key) {
|
||||
throw new Error(
|
||||
"STRIPE_SECRET_KEY is not set. Configure it in your environment."
|
||||
);
|
||||
}
|
||||
cachedClient = new Stripe(key, {
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
// Identify ourselves in Stripe's request logs so support can
|
||||
// distinguish PieCed traffic from other integrations on the
|
||||
// same account.
|
||||
appInfo: {
|
||||
name: "PieCed Portal",
|
||||
version: "1.0.0",
|
||||
url: "https://app.pieced.ch",
|
||||
},
|
||||
});
|
||||
return cachedClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the configured webhook secret. Separated so the webhook
|
||||
* handler can fail fast with a clear error message rather than the
|
||||
* generic "STRIPE_SECRET_KEY missing" path above.
|
||||
*/
|
||||
export function getWebhookSecret(): string {
|
||||
const secret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error(
|
||||
"STRIPE_WEBHOOK_SECRET is not set. Get it from the webhook endpoint in your Stripe dashboard."
|
||||
);
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a CHF decimal amount (e.g. 123.45) to integer rappen
|
||||
* (e.g. 12345). Stripe API requires integer amounts in the
|
||||
* currency's smallest unit. Centralised so we don't have rounding
|
||||
* drift between callers.
|
||||
*/
|
||||
export function chfToRappen(amountChf: number): number {
|
||||
// toFixed(2) avoids floating-point ugliness (0.1 + 0.2 = 0.30000000000000004).
|
||||
return Math.round(parseFloat(amountChf.toFixed(2)) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up or create the Stripe Customer for a PieCed org.
|
||||
*
|
||||
* Lazy creation: orgs that only pay by invoice never get a Stripe
|
||||
* Customer. The first "Pay with card" click triggers creation; the
|
||||
* id is persisted in org_billing_config so subsequent invoices
|
||||
* reuse it.
|
||||
*
|
||||
* Returns the Stripe customer id (`cus_...`).
|
||||
*/
|
||||
export async function ensureStripeCustomerForOrg(params: {
|
||||
zitadelOrgId: string;
|
||||
// Snapshot taken at click-time, NOT at invoice issuance — the
|
||||
// org's current address goes on the Stripe customer object.
|
||||
// Stripe's address on file is independent of any one invoice.
|
||||
companyName: string;
|
||||
billingEmail: string;
|
||||
address: {
|
||||
line1: string;
|
||||
postalCode: string;
|
||||
city: string;
|
||||
country: string; // ISO 3166-1 alpha-2 (e.g. "CH")
|
||||
};
|
||||
}): Promise<string> {
|
||||
// Lazy import to avoid pulling pg into edge-runtime modules that
|
||||
// might import this file. Same pattern used elsewhere in lib/.
|
||||
const { getOrgBillingConfig, updateOrgBillingConfig } = await import("./db");
|
||||
const existing = await getOrgBillingConfig(params.zitadelOrgId);
|
||||
if (existing.stripeCustomerId) {
|
||||
return existing.stripeCustomerId;
|
||||
}
|
||||
const stripe = getStripeClient();
|
||||
const customer = await stripe.customers.create({
|
||||
email: params.billingEmail,
|
||||
name: params.companyName,
|
||||
address: {
|
||||
line1: params.address.line1,
|
||||
postal_code: params.address.postalCode,
|
||||
city: params.address.city,
|
||||
country: params.address.country,
|
||||
},
|
||||
metadata: {
|
||||
zitadel_org_id: params.zitadelOrgId,
|
||||
},
|
||||
});
|
||||
await updateOrgBillingConfig(params.zitadelOrgId, {
|
||||
stripeCustomerId: customer.id,
|
||||
});
|
||||
return customer.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Checkout Session for paying a single invoice by card.
|
||||
*
|
||||
* Design notes:
|
||||
*
|
||||
* - Single line item with the invoice total (gross, VAT included).
|
||||
* Our own invoice PDF already breaks down lines + VAT; the Stripe
|
||||
* page is the checkout, not a duplicate of the invoice.
|
||||
*
|
||||
* - `automatic_tax` is disabled because the invoice already has
|
||||
* VAT computed by our pipeline. Letting Stripe re-calculate
|
||||
* would double-charge or contradict our PDF.
|
||||
*
|
||||
* - `payment_method_types` is NOT set, so Stripe surfaces dynamic
|
||||
* payment methods configured on the account (cards, TWINT for
|
||||
* Swiss customers, Apple Pay, Google Pay, etc.) automatically.
|
||||
*
|
||||
* - `metadata` and `payment_intent_data.metadata` BOTH carry the
|
||||
* invoice id. The session-level copy is enough for the
|
||||
* `checkout.session.completed` webhook; the intent-level copy
|
||||
* lets us correlate refunds and disputes which fire on the
|
||||
* PaymentIntent and don't include session metadata.
|
||||
*
|
||||
* - `client_reference_id` is set to our invoice id as a stable
|
||||
* reference. Visible in the Stripe dashboard, useful for support.
|
||||
*
|
||||
* - `locale` follows the invoice's locale so the customer sees
|
||||
* the Stripe page in their language (frozen at invoice issue
|
||||
* time; consistent with PDF + email).
|
||||
*/
|
||||
export async function createCheckoutSessionForInvoice(params: {
|
||||
invoice: Invoice;
|
||||
customerId: string;
|
||||
baseUrl: string;
|
||||
}): Promise<{ url: string; sessionId: string }> {
|
||||
const stripe = getStripeClient();
|
||||
const { invoice, customerId, baseUrl } = params;
|
||||
|
||||
// Stripe Checkout supports a limited set of locales; map our
|
||||
// four to Stripe's codes and fall back to 'auto' if anything
|
||||
// outside the set ever appears.
|
||||
const stripeLocale: Stripe.Checkout.SessionCreateParams.Locale =
|
||||
invoice.locale === "de"
|
||||
? "de"
|
||||
: invoice.locale === "fr"
|
||||
? "fr"
|
||||
: invoice.locale === "it"
|
||||
? "it"
|
||||
: invoice.locale === "en"
|
||||
? "en"
|
||||
: "auto";
|
||||
|
||||
const successUrl = `${baseUrl}/billing/${encodeURIComponent(invoice.invoiceNumber)}?paid=1&session_id={CHECKOUT_SESSION_ID}`;
|
||||
const cancelUrl = `${baseUrl}/billing/${encodeURIComponent(invoice.invoiceNumber)}?cancelled=1`;
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "payment",
|
||||
customer: customerId,
|
||||
client_reference_id: invoice.id,
|
||||
locale: stripeLocale,
|
||||
line_items: [
|
||||
{
|
||||
quantity: 1,
|
||||
price_data: {
|
||||
currency: "chf",
|
||||
unit_amount: chfToRappen(invoice.totalChf),
|
||||
product_data: {
|
||||
name: `Invoice ${invoice.invoiceNumber}`,
|
||||
description: `PieCed IT — ${invoice.periodStart.slice(0, 10)} → ${invoice.periodEnd.slice(0, 10)}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
invoice_id: invoice.id,
|
||||
invoice_number: invoice.invoiceNumber,
|
||||
zitadel_org_id: invoice.zitadelOrgId,
|
||||
},
|
||||
payment_intent_data: {
|
||||
// Mirror invoice id at the PaymentIntent level so refunds &
|
||||
// disputes (which fire on the PI, not the session) can be
|
||||
// correlated to our invoice without an extra lookup.
|
||||
metadata: {
|
||||
invoice_id: invoice.id,
|
||||
invoice_number: invoice.invoiceNumber,
|
||||
zitadel_org_id: invoice.zitadelOrgId,
|
||||
},
|
||||
// Statement descriptor shown on the customer's card
|
||||
// statement. Limited to 22 chars total; we use the prefix
|
||||
// since Stripe will prepend the merchant name from the
|
||||
// account anyway. Keep it short and recognisable.
|
||||
description: `Invoice ${invoice.invoiceNumber}`,
|
||||
},
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
// VAT is already in invoice.totalChf — don't let Stripe touch tax.
|
||||
automatic_tax: { enabled: false },
|
||||
});
|
||||
|
||||
if (!session.url) {
|
||||
throw new Error(
|
||||
`Stripe returned a session without a redirect URL (id=${session.id})`
|
||||
);
|
||||
}
|
||||
return { url: session.url, sessionId: session.id };
|
||||
}
|
||||
@@ -737,6 +737,10 @@
|
||||
"overdue": "Überfällig",
|
||||
"void": "Storniert",
|
||||
"uncollectible": "Uneinbringlich"
|
||||
}
|
||||
},
|
||||
"payWithCard": "Mit Karte bezahlen",
|
||||
"redirectingToStripe": "Weiterleitung…",
|
||||
"paymentReceived": "Zahlung erhalten — vielen Dank. Der Status wird aktualisiert, sobald Stripe bestätigt (wenige Sekunden).",
|
||||
"paymentCancelled": "Zahlung wurde abgebrochen. Die Rechnung ist weiterhin offen; Sie können es jederzeit erneut versuchen."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -737,6 +737,10 @@
|
||||
"overdue": "Overdue",
|
||||
"void": "Void",
|
||||
"uncollectible": "Uncollectible"
|
||||
}
|
||||
},
|
||||
"payWithCard": "Pay with card",
|
||||
"redirectingToStripe": "Redirecting…",
|
||||
"paymentReceived": "Payment received — thank you. The status will update once Stripe confirms (a few seconds).",
|
||||
"paymentCancelled": "Payment was cancelled. The invoice is still open; you can try again whenever."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -737,6 +737,10 @@
|
||||
"overdue": "En retard",
|
||||
"void": "Annulée",
|
||||
"uncollectible": "Irrécouvrable"
|
||||
}
|
||||
},
|
||||
"payWithCard": "Payer par carte",
|
||||
"redirectingToStripe": "Redirection…",
|
||||
"paymentReceived": "Paiement reçu — merci. Le statut sera mis à jour dès la confirmation de Stripe (quelques secondes).",
|
||||
"paymentCancelled": "Le paiement a été annulé. La facture reste ouverte ; vous pouvez réessayer à tout moment."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -737,6 +737,10 @@
|
||||
"overdue": "In ritardo",
|
||||
"void": "Annullata",
|
||||
"uncollectible": "Inesigibile"
|
||||
}
|
||||
},
|
||||
"payWithCard": "Paga con carta",
|
||||
"redirectingToStripe": "Reindirizzamento…",
|
||||
"paymentReceived": "Pagamento ricevuto — grazie. Lo stato si aggiornerà alla conferma di Stripe (pochi secondi).",
|
||||
"paymentCancelled": "Il pagamento è stato annullato. La fattura rimane aperta; puoi riprovare in qualsiasi momento."
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user