diff --git a/.env.local.example b/.env.local.example index 1896d90..67e9a77 100644 --- a/.env.local.example +++ b/.env.local.example @@ -65,6 +65,16 @@ FIREBASE_ADMIN_CLIENT_EMAIL= FIREBASE_ADMIN_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n" ADMIN_REBUILD_COOLDOWN_MS=600000 +# Stripe — admin payments (issue #155). Server-only secrets: never expose +# through NEXT_PUBLIC_*. Use sk_test_* / whsec_* values from the Stripe +# Dashboard's test mode for development and preview; live keys only in +# production. The key prefix decides the mode the admin UI badges. +# STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxx +# Signing secret for the Stripe webhook at /api/webhooks/stripe. Create the +# endpoint in the Dashboard (Developers → Webhooks) subscribed to +# checkout.session.* events, or use `stripe listen --forward-to` locally. +# STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxx + # Bootstrap superadmin email. Must be a verified Google account. # This value is server-only; do not expose it to the browser. SUPER_ADMIN_EMAIL=your-superadmin@example.com \ No newline at end of file diff --git a/README.md b/README.md index 7e101a0..aa3533b 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,7 @@ See [docs/operations/deployment.md](./docs/operations/deployment.md) for branch - [Managing locations](./docs/admin/managing-locations.md) - [Images and storage](./docs/admin/images-and-storage.md) - [Trade inquiries](./docs/admin/trade-inquiries.md) +- [Payments](./docs/admin/payments.md) ### For developers and operators @@ -148,6 +149,7 @@ See [docs/operations/deployment.md](./docs/operations/deployment.md) for branch - [Observability & error handling](./docs/operations/observability.md) - [Accessibility](./docs/operations/accessibility.md) - [Credential rotation runbook](./docs/operations/credential-rotation.md) +- [Payments operations](./docs/operations/payments.md) - [Troubleshooting guide](./docs/operations/troubleshooting.md) - [Post-deployment checklist](./docs/operations/post-deployment-checklist.md) diff --git a/app/(pages)/admin-payments-fixture/page.tsx b/app/(pages)/admin-payments-fixture/page.tsx new file mode 100644 index 0000000..9655f1e --- /dev/null +++ b/app/(pages)/admin-payments-fixture/page.tsx @@ -0,0 +1,26 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { AdminPaymentsFixture } from "@/components/admin-payments-fixture"; + +// Test-only rendering of the admin payments workspace — same gate as +// /admin-fixture (ADMIN_A11Y_FIXTURE is set only by the Playwright webServer +// config; normal deployments 404). It grants no access to real data: the +// workspace's API calls are intercepted by Playwright route mocks. +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { + title: "Admin payments fixture", + robots: { index: false, follow: false }, +}; + +export default function AdminPaymentsFixturePage() { + if (process.env.ADMIN_A11Y_FIXTURE !== "1") { + notFound(); + } + + return ( +
+ +
+ ); +} diff --git a/app/(pages)/admin/payments/page.tsx b/app/(pages)/admin/payments/page.tsx new file mode 100644 index 0000000..4769558 --- /dev/null +++ b/app/(pages)/admin/payments/page.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import { AdminPaymentsPage } from "@/components/admin-payments-page"; + +export const metadata: Metadata = { + title: "Payments — Admin", + description: "Deep Dive Brewing Co admin payment workspace.", + robots: { + index: false, + follow: false, + }, +}; + +export default function AdminPaymentsRoute() { + return ( +
+ +
+ ); +} diff --git a/app/(pages)/pay/cancelled/page.tsx b/app/(pages)/pay/cancelled/page.tsx new file mode 100644 index 0000000..fd89a7e --- /dev/null +++ b/app/(pages)/pay/cancelled/page.tsx @@ -0,0 +1,29 @@ +import type { Metadata } from "next"; + +// Landing page customers reach if they back out of Stripe's hosted payment +// page (Checkout cancel_url). Static and data-free by design. +export const metadata: Metadata = { + title: "Payment canceled", + description: "Your payment was canceled before it completed.", + robots: { + index: false, + follow: false, + }, +}; + +export default function PayCancelledPage() { + return ( +
+
+

Payment canceled

+

+ No charge was made. If you meant to complete the payment, ask the + brewery for the payment link again — it may need to be re-sent. +

+

+ You can close this window. +

+
+
+ ); +} diff --git a/app/(pages)/pay/complete/page.tsx b/app/(pages)/pay/complete/page.tsx new file mode 100644 index 0000000..a3c3a0a --- /dev/null +++ b/app/(pages)/pay/complete/page.tsx @@ -0,0 +1,30 @@ +import type { Metadata } from "next"; + +// Landing page customers reach after paying on Stripe's hosted page +// (Checkout success_url). Static and data-free by design — payment state is +// reconciled by the Stripe webhook, never by this page load. +export const metadata: Metadata = { + title: "Payment received", + description: "Your payment to Deep Dive Brewing Co was received.", + robots: { + index: false, + follow: false, + }, +}; + +export default function PayCompletePage() { + return ( +
+
+

Payment received

+

+ Thank you — your payment to Deep Dive Brewing Co was received. If + you asked for a receipt, it is on its way to your inbox. +

+

+ You can close this window. +

+
+
+ ); +} diff --git a/app/api/admin/payments/[id]/cancel/route.ts b/app/api/admin/payments/[id]/cancel/route.ts new file mode 100644 index 0000000..3a9f717 --- /dev/null +++ b/app/api/admin/payments/[id]/cancel/route.ts @@ -0,0 +1,51 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { requireAdminActor } from "@/lib/admin-auth"; +import { getBearerToken, unauthorizedResponse } from "@/lib/api-auth"; +import { apiErrorResponse } from "@/lib/api-error"; +import { getRequestId, logInfo } from "@/lib/log"; +import { + cancelAdminPayment, + getPayment, + listPaymentEvents, + paymentActorOf, +} from "@/lib/payments-admin"; +import { + serializePayment, + serializePaymentEvent, +} from "@/lib/payments-common"; + +interface RouteParams { + params: Promise<{ id: string }>; +} + +// Staff cancel: expires the hosted Stripe page and marks the payment +// canceled. Only unpaid payments may be canceled — enforced server-side +// inside the mutation. +export async function POST(req: NextRequest, { params }: RouteParams) { + const requestId = getRequestId(req.headers); + const idToken = getBearerToken(req); + if (!idToken) return unauthorizedResponse(); + + try { + const actor = await requireAdminActor(idToken); + const { id } = await params; + await cancelAdminPayment(id, paymentActorOf(actor)); + logInfo("payment.canceled", { paymentId: id, requestId }); + + const [payment, events] = await Promise.all([ + getPayment(id), + listPaymentEvents(id), + ]); + return NextResponse.json({ + ok: true, + payment: payment ? serializePayment(payment.id, payment.data) : null, + events: events.map((e) => serializePaymentEvent(e.id, e.data)), + }); + } catch (error) { + return apiErrorResponse(error, { + fallback: "Failed to cancel payment.", + event: "payment.cancel_failed", + context: { requestId }, + }); + } +} diff --git a/app/api/admin/payments/[id]/qr/route.ts b/app/api/admin/payments/[id]/qr/route.ts new file mode 100644 index 0000000..b3a8062 --- /dev/null +++ b/app/api/admin/payments/[id]/qr/route.ts @@ -0,0 +1,57 @@ +import { type NextRequest, NextResponse } from "next/server"; +import QRCode from "qrcode"; +import { requireAdminActor } from "@/lib/admin-auth"; +import { + badRequestResponse, + getBearerToken, + unauthorizedResponse, +} from "@/lib/api-auth"; +import { apiErrorResponse } from "@/lib/api-error"; +import { getRequestId } from "@/lib/log"; +import { getPayment } from "@/lib/payments-admin"; +import { normalizePaymentStatus, isPaymentPayable } from "@/lib/payments-common"; + +interface RouteParams { + params: Promise<{ id: string }>; +} + +// QR code for the live hosted payment page — the counter flow: the customer +// scans and pays on their own phone. Generated on demand (derived from the +// stored session URL) rather than persisted. +export async function GET(req: NextRequest, { params }: RouteParams) { + const requestId = getRequestId(req.headers); + const idToken = getBearerToken(req); + if (!idToken) return unauthorizedResponse(); + + try { + await requireAdminActor(idToken); + const { id } = await params; + const payment = await getPayment(id); + if (!payment) { + return NextResponse.json( + { ok: false, error: "Payment not found." }, + { status: 404 } + ); + } + const url = + typeof payment.data.stripeSessionUrl === "string" + ? payment.data.stripeSessionUrl + : null; + if (!url || !isPaymentPayable(normalizePaymentStatus(payment.data.status))) { + return badRequestResponse("No live payment link for this payment."); + } + + const dataUrl = await QRCode.toDataURL(url, { + errorCorrectionLevel: "M", + margin: 1, + width: 320, + }); + return NextResponse.json({ ok: true, dataUrl }); + } catch (error) { + return apiErrorResponse(error, { + fallback: "Failed to generate QR code.", + event: "payment.qr_failed", + context: { requestId }, + }); + } +} diff --git a/app/api/admin/payments/[id]/refresh/route.ts b/app/api/admin/payments/[id]/refresh/route.ts new file mode 100644 index 0000000..c77f9d3 --- /dev/null +++ b/app/api/admin/payments/[id]/refresh/route.ts @@ -0,0 +1,56 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { requireAdminActor } from "@/lib/admin-auth"; +import { getBearerToken, unauthorizedResponse } from "@/lib/api-auth"; +import { apiErrorResponse } from "@/lib/api-error"; +import { getRequestId, logInfo } from "@/lib/log"; +import { + getPayment, + listPaymentEvents, + refreshAdminPayment, +} from "@/lib/payments-admin"; +import { + serializePayment, + serializePaymentEvent, +} from "@/lib/payments-common"; + +interface RouteParams { + params: Promise<{ id: string }>; +} + +// Staff "refresh status" action: reconciles the record directly against +// Stripe (same transition planner the webhook uses) so a slow webhook never +// leaves a paid payment looking unpaid at the counter. +export async function POST(req: NextRequest, { params }: RouteParams) { + const requestId = getRequestId(req.headers); + const idToken = getBearerToken(req); + if (!idToken) return unauthorizedResponse(); + + try { + await requireAdminActor(idToken); + const { id } = await params; + await refreshAdminPayment(id); + + const [payment, events] = await Promise.all([ + getPayment(id), + listPaymentEvents(id), + ]); + if (!payment) { + return NextResponse.json( + { ok: false, error: "Payment not found." }, + { status: 404 } + ); + } + logInfo("payment.refreshed", { paymentId: id, requestId }); + return NextResponse.json({ + ok: true, + payment: serializePayment(payment.id, payment.data), + events: events.map((e) => serializePaymentEvent(e.id, e.data)), + }); + } catch (error) { + return apiErrorResponse(error, { + fallback: "Failed to refresh payment status.", + event: "payment.refresh_failed", + context: { requestId }, + }); + } +} diff --git a/app/api/admin/payments/[id]/refund/route.ts b/app/api/admin/payments/[id]/refund/route.ts new file mode 100644 index 0000000..858da23 --- /dev/null +++ b/app/api/admin/payments/[id]/refund/route.ts @@ -0,0 +1,63 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { requireAdminActor } from "@/lib/admin-auth"; +import { getBearerToken, badRequestResponse, unauthorizedResponse } from "@/lib/api-auth"; +import { apiErrorResponse } from "@/lib/api-error"; +import { getRequestId, logInfo } from "@/lib/log"; +import { + getPayment, + listPaymentEvents, + paymentActorOf, + refundAdminPayment, +} from "@/lib/payments-admin"; +import { + parseRefundBody, + serializePayment, + serializePaymentEvent, +} from "@/lib/payments-common"; + +interface RouteParams { + params: Promise<{ id: string }>; +} + +// Staff full refund. The request carries intent only — a reason and the +// typed confirmation phrase; the amount is always derived from the stored +// record server-side, and eligibility (paid, inside the 1-hour window, +// not already refunded) is enforced inside the mutation. +export async function POST(req: NextRequest, { params }: RouteParams) { + const requestId = getRequestId(req.headers); + const idToken = getBearerToken(req); + if (!idToken) return unauthorizedResponse(); + + let body: unknown; + try { + body = await req.json(); + } catch { + return badRequestResponse("Invalid JSON body."); + } + + try { + const actor = await requireAdminActor(idToken); + const parsed = parseRefundBody(body); + if (!parsed.ok) return badRequestResponse(parsed.error); + + const { id } = await params; + await refundAdminPayment(id, parsed.input, paymentActorOf(actor)); + logInfo("payment.refunded", { paymentId: id, requestId }); + + const [payment, events] = await Promise.all([ + getPayment(id), + listPaymentEvents(id), + ]); + return NextResponse.json({ + ok: true, + payment: payment ? serializePayment(payment.id, payment.data) : null, + events: events.map((e) => serializePaymentEvent(e.id, e.data)), + }); + } catch (error) { + return apiErrorResponse(error, { + fallback: "Failed to refund payment.", + event: "payment.refund_failed", + context: { requestId }, + }); + } +} diff --git a/app/api/admin/payments/[id]/route.ts b/app/api/admin/payments/[id]/route.ts new file mode 100644 index 0000000..1f5abdb --- /dev/null +++ b/app/api/admin/payments/[id]/route.ts @@ -0,0 +1,46 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { requireAdminActor } from "@/lib/admin-auth"; +import { getBearerToken, unauthorizedResponse } from "@/lib/api-auth"; +import { apiErrorResponse } from "@/lib/api-error"; +import { getRequestId } from "@/lib/log"; +import { getPayment, listPaymentEvents } from "@/lib/payments-admin"; +import { + serializePayment, + serializePaymentEvent, +} from "@/lib/payments-common"; + +interface RouteParams { + params: Promise<{ id: string }>; +} + +export async function GET(req: NextRequest, { params }: RouteParams) { + const requestId = getRequestId(req.headers); + const idToken = getBearerToken(req); + if (!idToken) return unauthorizedResponse(); + + try { + await requireAdminActor(idToken); + const { id } = await params; + const [payment, events] = await Promise.all([ + getPayment(id), + listPaymentEvents(id), + ]); + if (!payment) { + return NextResponse.json( + { ok: false, error: "Payment not found." }, + { status: 404 } + ); + } + return NextResponse.json({ + ok: true, + payment: serializePayment(payment.id, payment.data), + events: events.map((e) => serializePaymentEvent(e.id, e.data)), + }); + } catch (error) { + return apiErrorResponse(error, { + fallback: "Failed to load payment.", + event: "payment.get_failed", + context: { requestId }, + }); + } +} diff --git a/app/api/admin/payments/route.ts b/app/api/admin/payments/route.ts new file mode 100644 index 0000000..5266be6 --- /dev/null +++ b/app/api/admin/payments/route.ts @@ -0,0 +1,77 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { requireAdminActor } from "@/lib/admin-auth"; +import { getBearerToken, badRequestResponse, unauthorizedResponse } from "@/lib/api-auth"; +import { apiErrorResponse } from "@/lib/api-error"; +import { getRequestId, logInfo } from "@/lib/log"; +import { + createAdminPayment, + listPayments, + paymentActorOf, +} from "@/lib/payments-admin"; +import { + parsePaymentCreateBody, + serializePayment, +} from "@/lib/payments-common"; + +export async function GET(req: NextRequest) { + const requestId = getRequestId(req.headers); + const idToken = getBearerToken(req); + if (!idToken) return unauthorizedResponse(); + + try { + await requireAdminActor(idToken); + const payments = await listPayments(); + return NextResponse.json({ + ok: true, + payments: payments.map((p) => serializePayment(p.id, p.data)), + }); + } catch (error) { + return apiErrorResponse(error, { + fallback: "Failed to load payments.", + event: "payments.list_failed", + context: { requestId }, + }); + } +} + +export async function POST(req: NextRequest) { + const requestId = getRequestId(req.headers); + const idToken = getBearerToken(req); + if (!idToken) return unauthorizedResponse(); + + let body: unknown; + try { + body = await req.json(); + } catch { + return badRequestResponse("Invalid JSON body."); + } + + try { + const actor = await requireAdminActor(idToken); + const parsed = parsePaymentCreateBody(body); + if (!parsed.ok) return badRequestResponse(parsed.error); + + const created = await createAdminPayment( + parsed.input, + paymentActorOf(actor) + ); + // Payment id and replay flag only — never the customer PII in the body. + logInfo("payment.created", { + paymentId: created.id, + replayed: created.replayed, + requestId, + }); + + return NextResponse.json({ + ok: true, + payment: serializePayment(created.id, created.data), + replayed: created.replayed, + }); + } catch (error) { + return apiErrorResponse(error, { + fallback: "Failed to create payment.", + event: "payment.create_failed", + context: { requestId }, + }); + } +} diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts new file mode 100644 index 0000000..0310a9c --- /dev/null +++ b/app/api/webhooks/stripe/route.ts @@ -0,0 +1,79 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getRequestId, logError, logWarn } from "@/lib/log"; +import { handleStripeWebhook } from "@/lib/payments-admin"; + +// Stripe webhook endpoint — the authoritative reconciliation path for +// payment state (#155). Stripe delivers with a `Stripe-Signature` header +// over the *raw* body; `req.text()` preserves the exact bytes so +// constructEvent can verify it. Subscribe (Stripe Dashboard → Developers → +// Webhooks) to: checkout.session.completed, checkout.session.expired, +// checkout.session.async_payment_succeeded, +// checkout.session.async_payment_failed. +export async function POST(req: NextRequest) { + const requestId = getRequestId(req.headers); + const signature = req.headers.get("stripe-signature"); + if (!signature) { + return NextResponse.json( + { ok: false, error: "Missing Stripe signature." }, + { status: 400 } + ); + } + + const rawBody = await req.text(); + + try { + const result = await handleStripeWebhook(rawBody, signature); + if (result.status === "retry") { + // Canonical state had not settled — ask Stripe to redeliver rather + // than acknowledge a transition we could not verify. + return NextResponse.json( + { ok: false, error: "Event deferred — retry." }, + { status: 500 } + ); + } + if (result.status === "unknown_payment") { + logWarn("stripe_webhook.unknown_payment", { + paymentId: result.paymentId, + requestId, + }); + } + if (result.status === "quarantined") { + // Canonical state contradicted the stored snapshot — acknowledged + // durably so Stripe stops retrying, but the payment is NOT paid and + // needs staff review. + logWarn("stripe_webhook.quarantined", { + paymentId: result.paymentId, + requestId, + }); + } + // 200 for applied/duplicate/ignored/unknown/quarantined alike: Stripe + // must not retry deliveries we have durably accounted for. `ignored` + // means a valid delivery needed no state transition (e.g. a manual + // refresh already settled the record); `duplicate` means this exact + // Stripe event id was already processed. Neither settles twice. + return NextResponse.json({ ok: true, result: result.status }); + } catch (error) { + // Signature-construction failures are client errors — 400, no retry. + const isSignatureError = + (error && + typeof error === "object" && + (error as { type?: unknown }).type === + "StripeSignatureVerificationError") || + (error instanceof Error && + error.message.toLowerCase().includes("signature")); + if (isSignatureError) { + logWarn("stripe_webhook.bad_signature", { requestId }); + return NextResponse.json( + { ok: false, error: "Invalid signature." }, + { status: 400 } + ); + } + // Anything else (Firestore down, Stripe enrichment outage, …) gets a 500 + // so Stripe's retry schedule replays the event later. + logError("stripe_webhook.processing_failed", error, { requestId }); + return NextResponse.json( + { ok: false, error: "Webhook processing failed." }, + { status: 500 } + ); + } +} diff --git a/app/robots.ts b/app/robots.ts index cedea24..12cd9d6 100644 --- a/app/robots.ts +++ b/app/robots.ts @@ -19,6 +19,7 @@ export default function robots(): MetadataRoute.Robots { "/trade/login", "/trade/order", "/trade/orders", + "/pay", "/api/", ], }, diff --git a/components/admin-payments-fixture.tsx b/components/admin-payments-fixture.tsx new file mode 100644 index 0000000..c49e656 --- /dev/null +++ b/components/admin-payments-fixture.tsx @@ -0,0 +1,14 @@ +"use client"; + +// Deterministic rendering of the payments workspace for the Playwright +// accessibility suite — same pattern as admin-trade-fixture.tsx. Rendered +// only by /admin-payments-fixture (gated server-side by +// ADMIN_A11Y_FIXTURE); it never touches Firebase or Stripe, and +// /api/admin/payments* calls are intercepted by Playwright route mocks. +import { AdminPaymentsWorkspace } from "@/components/admin-payments-workspace"; + +export function AdminPaymentsFixture() { + return ( + "fixture-token" }} /> + ); +} diff --git a/components/admin-payments-page.tsx b/components/admin-payments-page.tsx new file mode 100644 index 0000000..27e1d93 --- /dev/null +++ b/components/admin-payments-page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { AdminAuthGate } from "@/components/admin-auth-gate"; +import { AdminPaymentsWorkspace } from "@/components/admin-payments-workspace"; + +export function AdminPaymentsPage() { + return ( + + {(user) => } + + ); +} diff --git a/components/admin-payments-summary.tsx b/components/admin-payments-summary.tsx new file mode 100644 index 0000000..c6b80a5 --- /dev/null +++ b/components/admin-payments-summary.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import type { AdminPanelUser } from "@/components/admin-access"; +import { + formatUsdMinor, + isCollectedForDailyTotal, + type PaymentView, +} from "@/lib/payments-common"; + +// Compact entry point into /admin/payments (#155). Loads the same authorized +// list endpoint the workspace uses and derives the actionable numbers; a +// failed load still leaves the working links rather than a broken card. +export function AdminPaymentsSummary({ user }: { user: AdminPanelUser }) { + const [summary, setSummary] = useState<{ + awaiting: number; + paidTodayMinor: number; + } | null>(null); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const idToken = await user.getIdToken(); + const res = await fetch("/api/admin/payments", { + headers: { Authorization: `Bearer ${idToken}` }, + }); + const data = (await res.json().catch(() => ({}))) as { + ok?: boolean; + payments?: PaymentView[]; + }; + if (cancelled || !res.ok || !data.ok || !data.payments) return; + const startOfToday = new Date(); + startOfToday.setHours(0, 0, 0, 0); + let awaiting = 0; + let paidTodayMinor = 0; + for (const p of data.payments) { + if (p.status === "awaiting_payment" || p.status === "processing") { + awaiting++; + } + // `refunding` still counts (the money may yet stay); a + // `refunded` payment drops out — its net position is zero. + if ( + isCollectedForDailyTotal({ + status: p.status, + paidAtMillis: p.paidAt ? new Date(p.paidAt).getTime() : null, + dayStartMillis: startOfToday.getTime(), + }) + ) { + paidTodayMinor += p.amountMinor; + } + } + setSummary({ awaiting, paidTodayMinor }); + } catch { + // Deliberately quiet — this card is a convenience entry point, not + // the operational surface itself. + } + })(); + return () => { + cancelled = true; + }; + }, [user]); + + return ( +
+
+

Payments

+

+ {summary + ? `${summary.awaiting} awaiting · ${formatUsdMinor(summary.paidTodayMinor)} collected today` + : "Card payments for tours and other one-off charges."} +

+
+ +
+ ); +} diff --git a/components/admin-payments-workspace.tsx b/components/admin-payments-workspace.tsx new file mode 100644 index 0000000..e3238cb --- /dev/null +++ b/components/admin-payments-workspace.tsx @@ -0,0 +1,1278 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import Link from "next/link"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogTitle, +} from "@/components/ui/dialog"; +import type { AdminPanelUser } from "@/components/admin-access"; +import { formatAdminDate, formatAdminDateTime } from "@/lib/admin-format"; +import { + describePaymentEvent, + formatUsdMinor, + isCollectedForDailyTotal, + isPaymentCancelable, + isPaymentPayable, + parseAmountMinor, + paymentPurposeLabel, + paymentRefundEligibility, + paymentStatusLabel, + suggestedAmountMinor, + PAYMENT_PURPOSES, + REFUND_CONFIRMATION_PHRASE, + REFUND_REASON_MAX_LENGTH, + type PaymentEventView, + type PaymentStatus, + type PaymentView, +} from "@/lib/payments-common"; + +// Input/select borders use ink at 50% so every form control boundary is +// visible against the paper background (WCAG 1.4.11 non-text contrast). +const fieldClass = "w-full rounded-md border border-ink/50 px-3 py-2"; +const sectionLabelClass = + "text-xs font-medium uppercase tracking-wide text-muted-foreground"; + +interface PaymentDetail { + payment: PaymentView; + events: PaymentEventView[]; +} + +const EMPTY_FORM = { + purpose: "brewery_tour", + // Prefilled from the default purpose so the form never shows "Brewery + // Tour" selected with a blank required description. + description: paymentPurposeLabel("brewery_tour"), + amount: "", + customerName: "", + customerEmail: "", + tourDate: "", + attendeeCount: "", + internalNote: "", +}; + +type FormState = typeof EMPTY_FORM; + +// Form → confirm → created. `confirm` forces an explicit look at the amount +// before the Stripe session exists. +type TakeStep = "form" | "confirm" | "created"; + +const STATUS_BADGE_CLASS: Record = { + created: "border-border text-muted-foreground", + awaiting_payment: "border-ocean/40 bg-ocean/10 text-ocean", + processing: "border-amber-500/40 bg-amber-100/70 text-amber-900", + paid: "border-moss/40 bg-moss/10 text-moss", + refunding: "border-amber-500/40 bg-amber-100/70 text-amber-900", + refunded: "border-stone bg-stone/50 text-muted-foreground", + failed: "border-ember/40 bg-ember/10 text-ember", + expired: "border-border text-muted-foreground", + canceled: "border-border text-muted-foreground", +}; + +function StatusBadge({ status }: { status: PaymentStatus }) { + return ( + + {paymentStatusLabel(status)} + + ); +} + +function paymentMillis(iso?: string): number { + if (!iso) return 0; + const t = new Date(iso).getTime(); + return Number.isNaN(t) ? 0 : t; +} + +// Poll a created payment until it resolves or the customer has had a +// reasonable window to pay; afterwards the explicit Refresh button stays. +const POLL_INTERVAL_MS = 5000; +const POLL_MAX_ATTEMPTS = 30; + +export function AdminPaymentsWorkspace({ user }: { user: AdminPanelUser }) { + const [payments, setPayments] = useState([]); + const [loading, setLoading] = useState(true); + const [statusMessage, setStatusMessage] = useState(""); + const [statusIsError, setStatusIsError] = useState(false); + + const [form, setForm] = useState(EMPTY_FORM); + const [amountTouched, setAmountTouched] = useState(false); + const [step, setStep] = useState("form"); + const [formError, setFormError] = useState(""); + const [creating, setCreating] = useState(false); + // Idempotency handle for the whole "take payment" action: generated once + // per flow so a double-click, timeout retry, or accidental resubmit can + // never mint a second charge. + const clientRequestIdRef = useRef(crypto.randomUUID()); + const [activePayment, setActivePayment] = useState( + null + ); + + const [selectedId, setSelectedId] = useState(null); + const [detail, setDetail] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const selectedIdRef = useRef(null); + + const [busy, setBusy] = useState(null); + const [copied, setCopied] = useState(false); + const [qrUrl, setQrUrl] = useState(null); + const [qrForId, setQrForId] = useState(null); + const pollCountRef = useRef(0); + + // Refund dialog state — target payment plus the two explicit gates + // (non-empty reason + typed REFUND) before the destructive button arms. + const [refundTarget, setRefundTarget] = useState(null); + const [refundReason, setRefundReason] = useState(""); + const [refundConfirm, setRefundConfirm] = useState(""); + const [refundBusy, setRefundBusy] = useState(false); + const [refundError, setRefundError] = useState(""); + // Ticking clock for the advisory refund-window check — render-time + // Date.now() is impure and a detail view sitting open across the 1-hour + // boundary must not keep offering a stale action. + const [nowMillis, setNowMillis] = useState(() => Date.now()); + useEffect(() => { + const timer = setInterval(() => setNowMillis(Date.now()), 15_000); + return () => clearInterval(timer); + }, []); + + const apiFetch = useCallback( + async (path: string, init?: RequestInit) => { + const idToken = await user.getIdToken(); + const res = await fetch(path, { + ...init, + headers: { + Authorization: `Bearer ${idToken}`, + ...(init?.body ? { "Content-Type": "application/json" } : {}), + }, + }); + const data = (await res.json().catch(() => ({}))) as Record< + string, + unknown + > & { ok?: boolean; error?: string }; + if (!res.ok || data.ok !== true) { + throw new Error(data.error ?? "Request failed."); + } + return data; + }, + [user] + ); + + const load = useCallback(async () => { + setLoading(true); + try { + const data = await apiFetch("/api/admin/payments"); + setPayments((data.payments as PaymentView[]) ?? []); + } catch (error) { + console.error(error); + setStatusMessage("Failed to load payments."); + setStatusIsError(true); + } finally { + setLoading(false); + } + }, [apiFetch]); + + useEffect(() => { + void load(); + }, [load]); + + // Keeps the shared list truthful whenever a fresh record arrives. + const upsertPayment = useCallback((payment: PaymentView) => { + setPayments((prev) => { + const exists = prev.some((p) => p.id === payment.id); + if (exists) { + return prev.map((p) => (p.id === payment.id ? payment : p)); + } + return [payment, ...prev]; + }); + }, []); + + const openPayment = useCallback( + async (id: string) => { + selectedIdRef.current = id; + setSelectedId(id); + setDetailLoading(true); + setDetail(null); + setQrUrl(null); + setQrForId(null); + try { + const data = await apiFetch(`/api/admin/payments/${id}`); + const next = { + payment: data.payment as PaymentView, + events: (data.events as PaymentEventView[]) ?? [], + }; + upsertPayment(next.payment); + if (selectedIdRef.current !== id) return; + setDetail(next); + } catch (error) { + if (selectedIdRef.current !== id) return; + console.error(error); + setStatusMessage("Failed to load the selected payment."); + setStatusIsError(true); + } finally { + if (selectedIdRef.current === id) setDetailLoading(false); + } + }, + [apiFetch, upsertPayment] + ); + + function closeDetail() { + selectedIdRef.current = null; + setSelectedId(null); + setDetail(null); + setDetailLoading(false); + } + + // Manual/server-side reconcile used by both the Refresh button and the + // auto-poll after creation. Applies to whichever detail object is live. + const refreshPayment = useCallback( + async (id: string): Promise => { + try { + const data = await apiFetch(`/api/admin/payments/${id}/refresh`, { + method: "POST", + }); + const next = { + payment: data.payment as PaymentView, + events: (data.events as PaymentEventView[]) ?? [], + }; + upsertPayment(next.payment); + setActivePayment((prev) => + prev?.payment.id === id ? next : prev + ); + if (selectedIdRef.current === id) setDetail(next); + return next; + } catch { + return null; + } + }, + [apiFetch, upsertPayment] + ); + + // While a freshly created payment is unresolved, poll the refresh + // endpoint — a paid card usually lands within seconds. + useEffect(() => { + const id = activePayment?.payment.id; + const status = activePayment?.payment.status; + if (!id || (status !== "awaiting_payment" && status !== "processing")) { + return; + } + pollCountRef.current = 0; + const timer = setInterval(() => { + pollCountRef.current += 1; + if (pollCountRef.current > POLL_MAX_ATTEMPTS) { + clearInterval(timer); + return; + } + void refreshPayment(id); + }, POLL_INTERVAL_MS); + return () => clearInterval(timer); + }, [activePayment?.payment.id, activePayment?.payment.status, refreshPayment]); + + // --- Take payment --- + + const attendeeNumber = Number(form.attendeeCount); + const suggestedMinor = suggestedAmountMinor( + form.purpose, + Number.isInteger(attendeeNumber) && attendeeNumber > 0 + ? attendeeNumber + : undefined + ); + + function updateField(key: K, value: string) { + setForm((prev) => { + const next = { ...prev, [key]: value }; + // Purpose drives the default description; attendee count drives the + // suggested amount until staff edits the amount by hand. + if (key === "purpose") { + next.description = paymentPurposeLabel(value); + if (!amountTouched) { + const count = Number(next.attendeeCount); + const minor = suggestedAmountMinor( + value, + Number.isInteger(count) && count > 0 ? count : undefined + ); + next.amount = minor === null ? "" : (minor / 100).toFixed(2); + } + } + if (key === "attendeeCount" && !amountTouched) { + const count = Number(value); + const minor = suggestedAmountMinor( + prev.purpose, + Number.isInteger(count) && count > 0 ? count : undefined + ); + next.amount = minor === null ? "" : (minor / 100).toFixed(2); + } + return next; + }); + } + + function reviewPayment(e: React.FormEvent) { + e.preventDefault(); + setFormError(""); + const amount = parseAmountMinor(form.amount); + if (!amount.ok) { + setFormError(amount.error); + return; + } + setStep("confirm"); + } + + async function createPayment() { + if (creating) return; // single-flight: no parallel session creation + setCreating(true); + setFormError(""); + try { + const data = await apiFetch("/api/admin/payments", { + method: "POST", + body: JSON.stringify({ + clientRequestId: clientRequestIdRef.current, + purpose: form.purpose, + description: form.description, + amount: form.amount, + customerName: form.customerName, + customerEmail: form.customerEmail, + tourDate: form.tourDate, + attendeeCount: form.attendeeCount, + internalNote: form.internalNote, + }), + }); + const payment = data.payment as PaymentView; + const detailData = await apiFetch(`/api/admin/payments/${payment.id}`); + const next = { + payment: detailData.payment as PaymentView, + events: (detailData.events as PaymentEventView[]) ?? [], + }; + upsertPayment(payment); + setActivePayment(next); + setStep("created"); + setStatusMessage("Payment link created."); + setStatusIsError(false); + } catch (error) { + setFormError( + error instanceof Error ? error.message : "Could not create the payment." + ); + setStep("form"); + } finally { + setCreating(false); + } + } + + // Done/reset: a new request id so the next take-payment action is a fresh + // logical operation. + function resetTakePayment() { + clientRequestIdRef.current = crypto.randomUUID(); + setForm(EMPTY_FORM); + setAmountTouched(false); + setStep("form"); + setFormError(""); + setActivePayment(null); + setQrUrl(null); + setQrForId(null); + } + + async function copyLink(payment: PaymentView) { + if (!payment.stripeSessionUrl) return; + try { + await navigator.clipboard.writeText(payment.stripeSessionUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + setStatusMessage("Could not copy — copy the link by hand."); + setStatusIsError(true); + } + } + + async function showQr(payment: PaymentView) { + if (qrForId === payment.id && qrUrl) { + setQrUrl(null); + setQrForId(null); + return; + } + try { + const data = await apiFetch(`/api/admin/payments/${payment.id}/qr`); + setQrUrl((data.dataUrl as string) ?? null); + setQrForId(payment.id); + } catch (error) { + setStatusMessage( + error instanceof Error ? error.message : "Could not create the QR code." + ); + setStatusIsError(true); + } + } + + async function cancelPayment(payment: PaymentView) { + if (busy) return; + const confirmed = window.confirm( + `Cancel this ${formatUsdMinor(payment.amountMinor)} payment for ${payment.customerName}? The payment link will stop working.` + ); + if (!confirmed) return; + setBusy("cancel"); + try { + const data = await apiFetch(`/api/admin/payments/${payment.id}/cancel`, { + method: "POST", + }); + const next = { + payment: data.payment as PaymentView, + events: (data.events as PaymentEventView[]) ?? [], + }; + upsertPayment(next.payment); + setActivePayment((prev) => + prev?.payment.id === payment.id ? next : prev + ); + if (selectedIdRef.current === payment.id) setDetail(next); + setStatusMessage("Payment canceled."); + setStatusIsError(false); + } catch (error) { + setStatusMessage( + error instanceof Error ? error.message : "Could not cancel the payment." + ); + setStatusIsError(true); + } finally { + setBusy(null); + } + } + + function openRefund(payment: PaymentView) { + setRefundReason(""); + setRefundConfirm(""); + setRefundError(""); + setRefundTarget(payment); + } + + async function submitRefund() { + const payment = refundTarget; + if (!payment || refundBusy) return; // single-flight like createPayment + // Advisory re-check at submit time — the server is authoritative, but + // a dialog left open across the window boundary should fail cleanly + // here rather than round-trip a known-ineligible request. + const eligibility = paymentRefundEligibility({ + status: payment.status, + paidAtMillis: payment.paidAt ? paymentMillis(payment.paidAt) : null, + hasStripePaymentRef: !!payment.stripePaymentIntentId, + nowMillis: Date.now(), + }); + if (!eligibility.ok) { + setRefundError(eligibility.message); + return; + } + setRefundBusy(true); + setRefundError(""); + try { + const data = await apiFetch(`/api/admin/payments/${payment.id}/refund`, { + method: "POST", + body: JSON.stringify({ + reason: refundReason.trim(), + confirmation: refundConfirm, + }), + }); + const next = { + payment: data.payment as PaymentView, + events: (data.events as PaymentEventView[]) ?? [], + }; + if (next.payment) upsertPayment(next.payment); + setActivePayment((prev) => + prev?.payment.id === payment.id ? next : prev + ); + if (selectedIdRef.current === payment.id) setDetail(next); + setRefundTarget(null); + // A `refunding` result means Stripe is still processing the refund — + // it reconciles on the next check rather than pretending it landed. + setStatusMessage( + next.payment?.status === "refunded" + ? "Payment refunded." + : "Refund is processing — check back shortly." + ); + setStatusIsError(false); + } catch (error) { + setRefundError( + error instanceof Error ? error.message : "Could not complete the refund." + ); + } finally { + setRefundBusy(false); + } + } + + // --- Derived view --- + + const summary = useMemo(() => { + let awaiting = 0; + let paidTodayMinor = 0; + const startOfToday = new Date(); + startOfToday.setHours(0, 0, 0, 0); + for (const p of payments) { + if (p.status === "awaiting_payment" || p.status === "processing") { + awaiting++; + } + // `refunding` still counts (the money may yet stay); `refunded` + // drops out — a refunded charge's net position is zero. + if ( + isCollectedForDailyTotal({ + status: p.status, + paidAtMillis: p.paidAt ? paymentMillis(p.paidAt) : null, + dayStartMillis: startOfToday.getTime(), + }) + ) { + paidTodayMinor += p.amountMinor; + } + } + return { awaiting, paidTodayMinor }; + }, [payments]); + + const reviewMinor = parseAmountMinor(form.amount); + const shownPayment = detail?.payment ?? null; + // Advisory only — the server re-checks eligibility inside the claim + // transaction; this just decides what the detail view offers. + const refundEligibility = shownPayment + ? paymentRefundEligibility({ + status: shownPayment.status, + paidAtMillis: shownPayment.paidAt + ? paymentMillis(shownPayment.paidAt) + : null, + hasStripePaymentRef: !!shownPayment.stripePaymentIntentId, + nowMillis, + }) + : null; + const timeline = detail + ? [...detail.events].sort((a, b) => (b.seq ?? 0) - (a.seq ?? 0)) + : []; + + function paymentLinkBlock(payment: PaymentView) { + if (!isPaymentPayable(payment.status) || !payment.stripeSessionUrl) { + return null; + } + return ( +
+

Customer payment link

+

+ {payment.stripeSessionUrl} +

+
+ + + +
+ {qrForId === payment.id && qrUrl && ( + // eslint-disable-next-line @next/next/no-img-element -- data URL QR, not a routed image + {`QR + )} +
+ ); + } + + return ( +
+
+
+
+

Payments

+

+ + Admin dashboard + {" "} + → Payments +

+
+ {!loading && ( +

+ {summary.awaiting} awaiting ·{" "} + {formatUsdMinor(summary.paidTodayMinor)} collected today +

+ )} +
+ {statusMessage && ( +

+ {statusMessage} +

+ )} +
+ +
+ {/* --- Take payment --- */} +
+ {step === "form" && ( + <> +

Take payment

+
+ +
+ + +
+ + +
+ + +
+ + {formError && ( +

+ {formError} +

+ )} + +
+ + )} + + {step === "confirm" && reviewMinor.ok && ( + <> +

Confirm charge

+
+

+ {formatUsdMinor(reviewMinor.minor)} +

+

+ {form.description} — {form.customerName} +

+
+ {formError && ( +

+ {formError} +

+ )} +
+ + +
+

+ The customer enters their card on Stripe's secure page — + card details never touch this app. +

+ + )} + + {step === "created" && activePayment && ( + <> +
+

Payment created

+
+ {activePayment.payment.livemode === false && ( + + Test mode + + )} + +
+
+

+ {formatUsdMinor(activePayment.payment.amountMinor)} +

+

+ {activePayment.payment.description} —{" "} + {activePayment.payment.customerName} +

+ {activePayment.payment.status === "paid" ? ( +

+ Payment received. +

+ ) : ( +

+ Waiting for the customer to pay… +

+ )} + {paymentLinkBlock(activePayment.payment)} + {isPaymentCancelable(activePayment.payment.status) && ( + + )} +
+ +
+ + )} +
+ + {/* --- Recent payments --- */} +
+
+

Recent payments

+ +
+ {loading && payments.length === 0 ? ( +

+ Loading… +

+ ) : payments.length === 0 ? ( +

+ No payments yet. +

+ ) : ( +
    + {payments.map((p) => ( +
  • + +
  • + ))} +
+ )} +
+
+ + {/* --- Selected payment detail --- */} + {selectedId && ( +
+ {detailLoading || !shownPayment ? ( +

+ Loading payment… +

+ ) : ( + <> +
+
+
+

+ {shownPayment.customerName} +

+ {shownPayment.livemode === false && ( + + Test mode + + )} + +
+

+ {formatUsdMinor(shownPayment.amountMinor)} ·{" "} + {shownPayment.description} +

+
+
+ + {isPaymentCancelable(shownPayment.status) && ( + + )} + +
+
+ + {shownPayment.reconciliationIssue && ( +

+ Needs attention — Stripe's report did not match the values + this charge was created with. Verify the payment in the + Stripe Dashboard before treating it as paid. +

+ )} + + {paymentLinkBlock(shownPayment)} + + {refundEligibility?.ok && ( +
+ +
+ )} + {refundEligibility && !refundEligibility.ok && shownPayment.status === "paid" && ( +

+ {refundEligibility.message} +

+ )} + {shownPayment.status === "refunding" && ( +

+ Refunding… +

+ )} + +
+
+
Purpose
+
{paymentPurposeLabel(shownPayment.purpose)}
+
+
+
Created
+
{formatAdminDateTime(shownPayment.createdAt)}
+
+
+
Created by
+
{shownPayment.createdByName || "—"}
+
+
+
Receipt email
+
{shownPayment.customerEmail || "—"}
+
+
+
Tour date
+
+ {shownPayment.tourDate + ? formatAdminDate(shownPayment.tourDate) + : "—"} +
+
+
+
Attendees
+
{shownPayment.attendeeCount ?? "—"}
+
+
+
Paid at
+
{formatAdminDateTime(shownPayment.paidAt)}
+
+
+
Card
+
+ {shownPayment.paymentMethodBrand + ? `${shownPayment.paymentMethodBrand} ···· ${shownPayment.paymentMethodLast4 ?? ""}` + : "—"} +
+
+
+
Receipt
+
+ {shownPayment.receiptUrl ? ( + + View receipt + + ) : ( + "—" + )} +
+
+
+
Stripe session
+
+ {shownPayment.stripeCheckoutSessionId ?? "—"} +
+
+
+
PaymentIntent
+
+ {shownPayment.stripePaymentIntentId ?? "—"} +
+
+ {shownPayment.status === "refunded" && ( + <> +
+
Refunded
+
+ {formatUsdMinor( + shownPayment.refundAmountMinor ?? + shownPayment.amountMinor + )} + {shownPayment.refundedAt + ? ` · ${formatAdminDateTime(shownPayment.refundedAt)}` + : ""} +
+
+
+
Refund reason
+
{shownPayment.refundReason || "—"}
+
+
+
Refunded by
+
{shownPayment.refundedByName || "—"}
+
+
+
Stripe refund
+
+ {shownPayment.stripeRefundId ?? "—"} +
+
+ + )} + {shownPayment.status === "paid" && + shownPayment.refundFailureMessage && ( +
+
+ Last refund attempt +
+
+ Failed ({shownPayment.refundFailureMessage}) — you can + try again while the payment is inside the refund + window. +
+
+ )} + {shownPayment.internalNote && ( +
+
Internal note
+
+ {shownPayment.internalNote} +
+
+ )} + {shownPayment.failureMessage && ( +
+
Failure
+
{shownPayment.failureMessage}
+
+ )} +
+ +
+

History

+
    + {timeline.map((e) => ( +
  • + + {formatAdminDateTime(e.createdAt)} + + {" — "} + {describePaymentEvent(e)} + {e.actorName ? ` (${e.actorName})` : ""} +
  • + ))} + {timeline.length === 0 && ( +
  • + No history yet. +
  • + )} +
+
+ + )} +
+ )} + + { + // Ignore dismiss while the request is in flight — the form is + // already disabled; closing would only hide the outcome. + if (!open && !refundBusy) setRefundTarget(null); + }} + > + + + Refund payment + + + A full refund returns the entire charge to the customer's + card. This is permanent — the payment and its history are kept, + but the money does not come back through this tool. + + {refundTarget && ( + <> +
+
+
Customer
+
+ {refundTarget.customerName || refundTarget.customerEmail} +
+
+
+
Purpose
+
+ {paymentPurposeLabel(refundTarget.purpose) || + refundTarget.description} +
+
+
+
Paid at
+
{formatAdminDateTime(refundTarget.paidAt)}
+
+
+
Refund amount
+
+ {formatUsdMinor(refundTarget.amountMinor)} +
+
+
+
{ + e.preventDefault(); + void submitRefund(); + }} + > +