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 ? (
+
+ ) : (
+