Phase4: Stripe
Some checks failed
Build and Push / build (push) Failing after 38s

This commit is contained in:
2026-05-24 23:37:48 +02:00
parent 4a5ae0bb8b
commit a680d6de9f
13 changed files with 908 additions and 13 deletions

View File

@@ -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,14 +52,23 @@ export function CustomerInvoiceDetail({ invoice, lines }: Props) {
{fmt.dateTime(new Date(invoice.periodEnd), { dateStyle: "long" })}
</p>
</div>
<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"
>
{t("downloadPdf")}
</a>
<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-surface-3 hover:bg-surface-2 border border-border text-sm font-medium transition-colors"
>
{t("downloadPdf")}
</a>
</div>
</div>
<Card>

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

View 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;
}