diff --git a/src/app/[locale]/admin/billing/invoices/[id]/page.tsx b/src/app/[locale]/admin/billing/invoices/[id]/page.tsx index f263e97..7d90988 100644 --- a/src/app/[locale]/admin/billing/invoices/[id]/page.tsx +++ b/src/app/[locale]/admin/billing/invoices/[id]/page.tsx @@ -1,7 +1,7 @@ import { notFound, redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { getSessionUser } from "@/lib/session"; -import { getInvoiceDetail } from "@/lib/db"; +import { getInvoiceDetail, listCreditNotesForInvoice } from "@/lib/db"; import { BackLink } from "@/components/ui/back-link"; import { InvoiceDetailView } from "@/components/admin/billing/invoice-detail-view"; @@ -9,8 +9,12 @@ import { InvoiceDetailView } from "@/components/admin/billing/invoice-detail-vie * /admin/billing/invoices/[id] — full detail of one invoice. * * Server-renders the static body (header, lines, totals, billing - * snapshot); the action bar (mark-paid, delete, PDF download) is - * a client component for the interactive bits. + * snapshot); the action bar (mark-paid, void, refund, delete, PDF + * download) is a client component for the interactive bits. + * + * Phase 7: also passes any linked credit notes so the detail view + * can show the "this invoice was voided / partially refunded" panel + * without an extra round-trip. */ export default async function AdminInvoiceDetailPage({ params, @@ -25,11 +29,12 @@ export default async function AdminInvoiceDetailPage({ const { id } = await params; const detail = await getInvoiceDetail(id); if (!detail) notFound(); + const creditNotes = await listCreditNotesForInvoice(id); return (
- +
); } diff --git a/src/app/[locale]/billing/page.tsx b/src/app/[locale]/billing/page.tsx index 343e86e..5ef18e3 100644 --- a/src/app/[locale]/billing/page.tsx +++ b/src/app/[locale]/billing/page.tsx @@ -1,23 +1,30 @@ import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { getSessionUser } from "@/lib/session"; -import { listInvoices, syncOverdueInvoices } from "@/lib/db"; +import { + listCreditNotesForOrg, + listInvoices, + syncOverdueInvoices, +} from "@/lib/db"; import { CustomerInvoiceList } from "@/components/billing/customer-invoice-list"; +import { CustomerCreditNoteList } from "@/components/billing/customer-credit-note-list"; import { RunningTotalWidget } from "@/components/billing/running-total-widget"; /** * /billing — customer's billing home. * - * Shows two things: + * Shows three things: * 1. RunningTotalWidget — current calendar month's accruing cost * (or the already-issued invoice for the current month, if * that ran early). * 2. CustomerInvoiceList — every issued invoice for this org, * newest first. Status is reflected with a colored badge. + * 3. CustomerCreditNoteList — Phase 7. Credit notes (voids and + * refunds) for this org, with PDF download links. Hidden + * entirely when there are none (the common case). * * Anyone signed in can view this. The data is org-scoped; even - * non-owner team members see the same view. Phase 4 will add a - * "settings.payByInvoice" toggle visibility-gated to owners only. + * non-owner team members see the same view. */ export default async function CustomerBillingPage() { const user = await getSessionUser(); @@ -31,10 +38,11 @@ export default async function CustomerBillingPage() { console.warn("syncOverdueInvoices failed in /billing:", e); } - const invoices = await listInvoices({ - zitadelOrgId: user.orgId, - limit: 200, - }); + // Parallel fetch — invoices + credit notes are independent. + const [invoices, creditNotes] = await Promise.all([ + listInvoices({ zitadelOrgId: user.orgId, limit: 200 }), + listCreditNotesForOrg(user.orgId, 200), + ]); return (
@@ -54,12 +62,24 @@ export default async function CustomerBillingPage() { -
+

{t("historyHeading")}

+ + {/* Phase 7: credit-note section. CustomerCreditNoteList itself + returns null when there are no credit notes, so this whole + section disappears for orgs in normal operation. */} + {creditNotes.length > 0 && ( +
+

+ {t("creditNotesHeading")} +

+ +
+ )}
); } diff --git a/src/app/api/admin/billing/invoices/[id]/refund/route.ts b/src/app/api/admin/billing/invoices/[id]/refund/route.ts new file mode 100644 index 0000000..75450fd --- /dev/null +++ b/src/app/api/admin/billing/invoices/[id]/refund/route.ts @@ -0,0 +1,85 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { requirePlatformRole, getSessionUser } from "@/lib/session"; +import { refundInvoice, RefundNotAllowedError } from "@/lib/billing"; +import { safeError } from "@/lib/errors"; + +/** + * POST /api/admin/billing/invoices/[id]/refund + * + * Phase 7. Refunds a paid invoice (full or partial) and issues a + * credit note. For Stripe-paid invoices, calls Stripe's Refund API + * before any local recording. For invoice-paid customers (bank + * transfer), records the refund locally and assumes the admin + * handled the actual money movement out-of-band. + * + * Body: + * { + * amountChf: number, // positive, <= remaining refundable + * reason: string // required, free-text, max 500 + * } + * + * Authorization: platform admin. + * + * Status codes: + * 200 — refund issued, credit note returned + * 400 — bad request (zero/negative amount, etc.) + * 401 / 403 — not authenticated / not platform admin + * 409 — invoice not in a refundable state, or amount exceeds remaining + * 500 — Stripe call failed or another internal error + * + * Idempotency caveats: this endpoint is NOT idempotent against + * client retries. Issuing two refunds quickly will result in two + * Stripe refund calls (and two credit notes). The admin UI should + * disable the submit button while the request is in flight to + * prevent accidental double-clicks. The Stripe charge.refunded + * webhook is idempotent and will not double-count if it fires + * after this endpoint already recorded the refund. + */ + +const bodySchema = z.object({ + amountChf: z.number().positive().multipleOf(0.01), + reason: z.string().trim().min(1).max(500), +}); + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + let user; + try { + await requirePlatformRole(); + user = await getSessionUser(); + } catch { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const { id } = await params; + 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 { + const creditNote = await refundInvoice({ + invoiceId: id, + amountChf: parsed.data.amountChf, + reason: parsed.data.reason, + refundedBy: user.id, + }); + return NextResponse.json({ creditNote }); + } catch (e) { + if (e instanceof RefundNotAllowedError) { + return NextResponse.json( + { error: e.message, currentStatus: e.currentStatus }, + { status: 409 } + ); + } + return NextResponse.json(safeError(e), { status: 500 }); + } +} diff --git a/src/app/api/admin/billing/invoices/[id]/void/route.ts b/src/app/api/admin/billing/invoices/[id]/void/route.ts new file mode 100644 index 0000000..3148cce --- /dev/null +++ b/src/app/api/admin/billing/invoices/[id]/void/route.ts @@ -0,0 +1,74 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { requirePlatformRole, getSessionUser } from "@/lib/session"; +import { voidInvoice, VoidNotAllowedError } from "@/lib/billing"; +import { safeError } from "@/lib/errors"; + +/** + * POST /api/admin/billing/invoices/[id]/void + * + * Phase 7. Voids an unpaid invoice and issues a credit note. + * + * Body: + * { + * reason: string // required, free-text, max 500 + * } + * + * Authorization: platform admin (same as mark-paid, generate, etc.). + * The acting user's ID lands in invoices.voided_by and on the + * credit_notes.issued_by audit columns. + * + * Status codes: + * 200 — voided, credit note returned in body + * 400 — bad request (missing reason etc.) + * 401 / 403 — not authenticated / not platform admin + * 409 — invoice not in a voidable state + * 500 — anything else (Stripe shouldn't apply here, but if PDF + * render fails the void still went through — see body + * payload for the credit-note number to re-render later) + */ + +const bodySchema = z.object({ + reason: z.string().trim().min(1).max(500), +}); + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + let user; + try { + await requirePlatformRole(); + user = await getSessionUser(); + } catch { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const { id } = await params; + 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 { + const creditNote = await voidInvoice({ + invoiceId: id, + reason: parsed.data.reason, + voidedBy: user.id, + }); + return NextResponse.json({ creditNote }); + } catch (e) { + if (e instanceof VoidNotAllowedError) { + return NextResponse.json( + { error: e.message, currentStatus: e.currentStatus }, + { status: 409 } + ); + } + return NextResponse.json(safeError(e), { status: 500 }); + } +} diff --git a/src/app/api/credit-notes/[number]/pdf/route.ts b/src/app/api/credit-notes/[number]/pdf/route.ts new file mode 100644 index 0000000..961fefc --- /dev/null +++ b/src/app/api/credit-notes/[number]/pdf/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from "next/server"; +import { getSessionUser } from "@/lib/session"; +import { + getCreditNoteByNumber, + getCreditNoteByNumberForOrg, + getCreditNotePdf, +} from "@/lib/db"; + +/** + * GET /api/credit-notes/[number]/pdf + * + * Phase 7. Customer-facing PDF download for a credit note. Returns + * the binary PDF with Content-Disposition: inline so the browser + * renders it in-tab (matching the invoice download behaviour). The + * customer's email links here. + * + * Authorization: + * - The caller must be authenticated. + * - For customer-org callers, the credit note must belong to their + * org (orgId-scoped lookup). + * - Platform admins can fetch any credit note (cross-org lookup). + * + * Returns 404 in both "doesn't exist" and "exists but not yours" + * cases — leak-safe identical to invoice lookup. + */ +export async function GET( + _request: Request, + { params }: { params: Promise<{ number: string }> } +) { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const { number } = await params; + // URL-decoded number — the route param comes URL-encoded. + const decodedNumber = decodeURIComponent(number); + const cn = user.isPlatform + ? await getCreditNoteByNumber(decodedNumber) + : await getCreditNoteByNumberForOrg(decodedNumber, user.orgId); + if (!cn) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + const pdf = await getCreditNotePdf(cn.id); + if (!pdf) { + // The credit note exists but the PDF was never attached. Most + // likely a render failure during issuance — the credit note + // row is still authoritative, the PDF needs re-rendering. + return NextResponse.json( + { + error: + "Credit note exists but its PDF has not been rendered. Please contact support.", + }, + { status: 502 } + ); + } + return new NextResponse(new Uint8Array(pdf.data), { + status: 200, + headers: { + "Content-Type": "application/pdf", + "Content-Disposition": `inline; filename="${pdf.filename}"`, + "Cache-Control": "private, no-cache", + }, + }); +} diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts index 3eb0e30..0f0f0a1 100644 --- a/src/app/api/stripe/webhook/route.ts +++ b/src/app/api/stripe/webhook/route.ts @@ -2,11 +2,14 @@ import { NextResponse } from "next/server"; import type Stripe from "stripe"; import { getStripeClient, getWebhookSecret } from "@/lib/stripe"; import { + getInvoiceByStripePaymentIntent, + isStripeRefundRecorded, markInvoicePaid, markStripeEventProcessed, setInvoiceStripePaymentIntent, tryRecordStripeEvent, } from "@/lib/db"; +import { refundInvoice, RefundNotAllowedError } from "@/lib/billing"; /** * POST /api/stripe/webhook @@ -209,13 +212,103 @@ async function handleCheckoutCompleted( } async function handleChargeRefunded(charge: Stripe.Charge): Promise { - // 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.` - ); + // Phase 7: mirror Stripe refunds into the portal so credit notes + // are issued for refunds initiated in the Stripe Dashboard. For + // refunds initiated via /api/admin/.../refund, this handler is a + // no-op (each refund's stripe_refund_id is already recorded + // before the webhook lands — refundInvoice records it + // synchronously after the Stripe API call). + // + // A charge can have multiple refund objects (multiple partial + // refunds against the same charge accumulate here). We iterate + // and process any that aren't yet recorded in our DB. + const paymentIntentId = + typeof charge.payment_intent === "string" + ? charge.payment_intent + : charge.payment_intent?.id; + if (!paymentIntentId) { + console.error( + `charge.refunded for charge ${charge.id} has no payment_intent; cannot link to invoice.` + ); + return; + } + const invoice = await getInvoiceByStripePaymentIntent(paymentIntentId); + if (!invoice) { + console.error( + `charge.refunded for payment_intent ${paymentIntentId} has no matching invoice; ignoring.` + ); + return; + } + const refundsList = charge.refunds?.data ?? []; + if (refundsList.length === 0) { + // Some charge.refunded events fire with the refunds list + // collapsed (the object includes the aggregated amount_refunded + // but the data array can be omitted depending on Stripe's + // expansion choices). In that case there's nothing for us to + // iterate over here; the actual `refund.created` / + // `refund.updated` events carry per-refund detail and we'd need + // to enable those in Stripe to handle them. For v1 we log and + // rely on the in-portal admin path (refundInvoice) being the + // only refund initiator. + console.log( + `charge.refunded for charge ${charge.id} arrived without refund objects in data; in-portal flow assumed.` + ); + return; + } + for (const refund of refundsList) { + try { + // Idempotency: skip refunds we already recorded (either via + // portal admin action or a prior webhook delivery). + if (await isStripeRefundRecorded(refund.id)) { + continue; + } + const amountChf = (refund.amount ?? 0) / 100; + if (amountChf <= 0) continue; + // Map Stripe refund status to ours. Anything other than the + // canonical four falls through to 'pending' so we don't lose + // the record entirely. + let status: "pending" | "succeeded" | "failed" | "canceled" = "pending"; + if (refund.status === "succeeded") status = "succeeded"; + else if (refund.status === "failed") status = "failed"; + else if (refund.status === "canceled") status = "canceled"; + // For refunds that originated in Stripe Dashboard we don't + // have a reason to display. Use a sentinel string so the + // credit note PDF has something to print. Admin can edit + // post-hoc if needed (no UI for that today, but the DB row + // is reachable). + const reason = refund.reason + ? `Stripe Dashboard: ${refund.reason}` + : "Refund issued via Stripe Dashboard"; + // refundInvoice with existingStripeRefund: don't call Stripe + // again (we'd error since the refund already exists), just + // mirror the record into our DB and issue the credit note. + await refundInvoice({ + invoiceId: invoice.id, + amountChf, + reason, + refundedBy: "stripe-webhook", + existingStripeRefund: { id: refund.id, status }, + }); + console.log( + `Mirrored Stripe refund ${refund.id} for invoice ${invoice.invoiceNumber} (CHF ${amountChf.toFixed(2)}).` + ); + } catch (e) { + if (e instanceof RefundNotAllowedError) { + // The invoice was already fully refunded by an earlier + // webhook delivery or by an in-portal action. That's fine. + console.log( + `Stripe refund ${refund.id}: ${e.message} (already accounted for).` + ); + continue; + } + // For any other error, log but continue to the next refund — + // we don't want one bad refund to block the rest. + console.error( + `Failed to mirror Stripe refund ${refund.id} for invoice ${invoice.invoiceNumber}:`, + e + ); + } + } } async function handlePaymentFailed( diff --git a/src/components/admin/billing/invoice-detail-view.tsx b/src/components/admin/billing/invoice-detail-view.tsx index ea5d5c7..57a6390 100644 --- a/src/components/admin/billing/invoice-detail-view.tsx +++ b/src/components/admin/billing/invoice-detail-view.tsx @@ -4,33 +4,61 @@ import { useState, Fragment } from "react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { Card, CardHeader } from "@/components/ui/card"; -import type { InvoiceDetail, InvoiceStatus } from "@/types"; +import type { CreditNote, InvoiceDetail, InvoiceStatus } from "@/types"; interface Props { detail: InvoiceDetail; + /** + * Phase 7: credit notes linked to this invoice (voids + refunds). + * Empty array when none. Passed from the server page; client + * doesn't re-fetch — router.refresh() rebuilds after actions. + */ + creditNotes?: CreditNote[]; } /** * Renders the invoice header (status, totals, action bar) then * line items grouped by tenant, then billing snapshot. Actions are - * mark-paid (POST), delete (DELETE), PDF download (link to /pdf). + * mark-paid (POST), void (POST), refund (POST), delete (DELETE), + * PDF download (link to /pdf). + * + * Phase 7 adds void + refund. The action bar shows: + * - status open/overdue → Mark paid, Void, Delete + * - status paid → Refund, Delete + * - status partially_refunded → Refund (for remainder), Delete + * - status fully_refunded / void → Delete only (read-only otherwise) * * On successful action we router.refresh() — the server-side page - * re-renders against the new DB state. For delete we navigate - * away first. + * re-renders against the new DB state, including any new credit + * notes. */ -export function InvoiceDetailView({ detail }: Props) { +export function InvoiceDetailView({ detail, creditNotes = [] }: Props) { const t = useTranslations("adminBilling"); const router = useRouter(); const { invoice, lines } = detail; - const [busyAction, setBusyAction] = useState( - null - ); + const [busyAction, setBusyAction] = useState< + null | "mark-paid" | "delete" | "void" | "refund" + >(null); const [actionError, setActionError] = useState(""); const [noteInput, setNoteInput] = useState(""); const [noteOpen, setNoteOpen] = useState(false); + // Phase 7 — void modal state + const [voidOpen, setVoidOpen] = useState(false); + const [voidReason, setVoidReason] = useState(""); + + // Phase 7 — refund modal state. Amount defaults to the full + // remaining refundable on open. + const [refundOpen, setRefundOpen] = useState(false); + const [refundAmount, setRefundAmount] = useState(""); + const [refundReason, setRefundReason] = useState(""); + + const remainingRefundable = + Math.round( + (invoice.totalChf - invoice.refundedTotalChf) * 100 + ) / 100; + const markPaid = async () => { setActionError(""); setBusyAction("mark-paid"); @@ -75,6 +103,84 @@ export function InvoiceDetailView({ detail }: Props) { } }; + // Phase 7 — void: marks an unpaid invoice as cancelled and issues + // a credit note. Backend rejects if the invoice is paid (use + // refund) or already voided/refunded. + const voidInvoice = async () => { + if (!voidReason.trim()) { + setActionError(t("voidReasonRequired")); + return; + } + setActionError(""); + setBusyAction("void"); + try { + const res = await fetch( + `/api/admin/billing/invoices/${invoice.id}/void`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reason: voidReason }), + } + ); + const j = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`); + setVoidOpen(false); + setVoidReason(""); + router.refresh(); + } catch (e: any) { + setActionError(e.message); + } finally { + setBusyAction(null); + } + }; + + // Phase 7 — refund: paid invoices only. Amount may be partial; + // backend caps at remaining refundable. + const refundInvoice = async () => { + const amt = parseFloat(refundAmount); + if (!isFinite(amt) || amt <= 0) { + setActionError(t("refundAmountInvalid")); + return; + } + if (amt - remainingRefundable > 0.005) { + setActionError( + t("refundAmountExceeds", { + max: remainingRefundable.toFixed(2), + }) + ); + return; + } + if (!refundReason.trim()) { + setActionError(t("refundReasonRequired")); + return; + } + setActionError(""); + setBusyAction("refund"); + try { + const res = await fetch( + `/api/admin/billing/invoices/${invoice.id}/refund`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + amountChf: Math.round(amt * 100) / 100, + reason: refundReason, + }), + } + ); + const j = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`); + setRefundOpen(false); + setRefundAmount(""); + setRefundReason(""); + router.refresh(); + } catch (e: any) { + setActionError(e.message); + } finally { + setBusyAction(null); + } + }; + // Group lines by tenant for display (matches PDF layout). const linesByTenant = new Map(); for (const ln of lines) { @@ -171,6 +277,129 @@ export function InvoiceDetailView({ detail }: Props) { )} )} + {/* Phase 7 — Void: visible only for open/overdue invoices. + Same gating as Mark Paid but mutually exclusive with it + via the chosen action. Opens a small inline form so + the admin can enter a reason; reason is required and + lands on the credit-note PDF. */} + {(invoice.status === "open" || invoice.status === "overdue") && ( + <> + {!voidOpen ? ( + + ) : ( +
+ setVoidReason(e.target.value)} + maxLength={500} + className="flex-grow px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm" + autoFocus + /> + + +
+ )} + + )} + {/* Phase 7 — Refund: paid invoices, including ones already + partially refunded (as long as some refundable amount + remains). Opens an inline form with amount + reason. + The remaining-refundable hint helps admin pick the + right number. */} + {(invoice.status === "paid" || + invoice.status === "partially_refunded") && + remainingRefundable > 0 && ( + <> + {!refundOpen ? ( + + ) : ( +
+
+ {t("refundRemainingHint", { + max: remainingRefundable.toFixed(2), + })} +
+
+ setRefundAmount(e.target.value)} + className="w-28 px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm font-mono" + autoFocus + /> + setRefundReason(e.target.value)} + maxLength={500} + className="flex-grow px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm" + /> + + +
+
+ )} + + )}