Phase7: Void/Refund logic
Some checks failed
Build and Push / build (push) Failing after 52s

This commit is contained in:
2026-05-25 21:54:51 +02:00
parent 9cd9879a18
commit e15a668f8e
19 changed files with 2679 additions and 41 deletions

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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",
},
});
}

View File

@@ -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<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.`
);
// 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(