From 722ce288cacf9169a52e6544c9f2a32f6c237418 Mon Sep 17 00:00:00 2001 From: Chad Nuttall Date: Sat, 3 Oct 2026 09:39:54 -0400 Subject: [PATCH 01/11] feat(admin): add Stripe payment tool for tours and ad-hoc charges (#155) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Staff-facing payments workspace at /admin/payments for one-off card charges (tours, tastings, extra guests, ad-hoc) that do not fit the B2B beer-sales workflow. Card entry lives entirely on Stripe-hosted Checkout — the app never sees, stores, or logs PAN/CVC. - payments/{clientRequestId} record + events subcollection, deny-all client rules; stripeEvents dedupe markers - /api/admin/payments* behind requireAdminActor; server-side amount validation in integer minor units; Stripe idempotency keyed by the internal payment id - /api/webhooks/stripe verifies signatures and reconciles checkout.session.* transactionally with a terminal-state machine - Take-payment form with canonical tour price quick-picks (staff override), amount confirmation, copy/QR/open payment link, polling + manual refresh, cancel - Payment detail with history, receipt link, and safe card brand/last4 - /pay/complete + /pay/cancelled noindex return pages - stripe@^22 + qrcode deps; docs for staff workflow, ops, rotation Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .env.local.example | 10 + app/(pages)/admin-payments-fixture/page.tsx | 26 + app/(pages)/admin/payments/page.tsx | 19 + app/(pages)/pay/cancelled/page.tsx | 29 + app/(pages)/pay/complete/page.tsx | 30 + app/api/admin/payments/[id]/cancel/route.ts | 51 + app/api/admin/payments/[id]/qr/route.ts | 57 ++ app/api/admin/payments/[id]/refresh/route.ts | 56 ++ app/api/admin/payments/[id]/route.ts | 46 + app/api/admin/payments/route.ts | 77 ++ app/api/webhooks/stripe/route.ts | 59 ++ app/robots.ts | 1 + components/admin-payments-fixture.tsx | 14 + components/admin-payments-page.tsx | 12 + components/admin-payments-summary.tsx | 76 ++ components/admin-payments-workspace.tsx | 975 +++++++++++++++++++ components/admin-workspace.tsx | 3 + docs/TECHNICAL.md | 88 ++ docs/admin/README.md | 2 + docs/admin/payments.md | 67 ++ docs/operations/credential-rotation.md | 37 +- docs/operations/deployment.md | 3 + docs/operations/observability.md | 9 + firestore.rules | 19 + lib/payments-admin.ts | 515 ++++++++++ lib/payments-common.ts | 947 ++++++++++++++++++ lib/stripe-config.ts | 37 + lib/stripe.ts | 13 + next.config.ts | 3 +- package-lock.json | 257 ++++- package.json | 3 + rules-tests/firestore.rules.test.ts | 95 ++ scripts/check-admin-import.mjs | 3 + smoke-tests/admin-accessibility.spec.ts | 130 +++ smoke-tests/fixtures.ts | 10 + tests/lib/payments-common.test.ts | 562 +++++++++++ tests/security-rules.test.ts | 30 + 37 files changed, 4363 insertions(+), 8 deletions(-) create mode 100644 app/(pages)/admin-payments-fixture/page.tsx create mode 100644 app/(pages)/admin/payments/page.tsx create mode 100644 app/(pages)/pay/cancelled/page.tsx create mode 100644 app/(pages)/pay/complete/page.tsx create mode 100644 app/api/admin/payments/[id]/cancel/route.ts create mode 100644 app/api/admin/payments/[id]/qr/route.ts create mode 100644 app/api/admin/payments/[id]/refresh/route.ts create mode 100644 app/api/admin/payments/[id]/route.ts create mode 100644 app/api/admin/payments/route.ts create mode 100644 app/api/webhooks/stripe/route.ts create mode 100644 components/admin-payments-fixture.tsx create mode 100644 components/admin-payments-page.tsx create mode 100644 components/admin-payments-summary.tsx create mode 100644 components/admin-payments-workspace.tsx create mode 100644 docs/admin/payments.md create mode 100644 lib/payments-admin.ts create mode 100644 lib/payments-common.ts create mode 100644 lib/stripe-config.ts create mode 100644 lib/stripe.ts create mode 100644 tests/lib/payments-common.test.ts 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/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]/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..b9fd894 --- /dev/null +++ b/app/api/webhooks/stripe/route.ts @@ -0,0 +1,59 @@ +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 === "unknown_payment") { + logWarn("stripe_webhook.unknown_payment", { + paymentId: result.paymentId, + requestId, + }); + } + // 200 for applied/duplicate/ignored/unknown alike: Stripe must not retry + // deliveries we have durably accounted for. + 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..3f89d50 --- /dev/null +++ b/components/admin-payments-summary.tsx @@ -0,0 +1,76 @@ +"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, 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++; + } + if ( + p.status === "paid" && + p.paidAt && + new Date(p.paidAt).getTime() >= 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..0f13b3c --- /dev/null +++ b/components/admin-payments-workspace.tsx @@ -0,0 +1,975 @@ +"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 type { AdminPanelUser } from "@/components/admin-access"; +import { formatAdminDate, formatAdminDateTime } from "@/lib/admin-format"; +import { + describePaymentEvent, + formatUsdMinor, + isPaymentCancelable, + isPaymentPayable, + parseAmountMinor, + paymentPurposeLabel, + paymentStatusLabel, + suggestedAmountMinor, + PAYMENT_PURPOSES, + 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", + 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); + + 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); + } + } + + // --- 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++; + } + if (p.status === "paid" && paymentMillis(p.paidAt) >= startOfToday.getTime()) { + paidTodayMinor += p.amountMinor; + } + } + return { awaiting, paidTodayMinor }; + }, [payments]); + + const reviewMinor = parseAmountMinor(form.amount); + const shownPayment = detail?.payment ?? 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) && ( + + )} + +
+
+ + {paymentLinkBlock(shownPayment)} + +
+
+
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.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. +
  • + )} +
+
+ + )} +
+ )} +
+ ); +} diff --git a/components/admin-workspace.tsx b/components/admin-workspace.tsx index ba41b3e..69b3169 100644 --- a/components/admin-workspace.tsx +++ b/components/admin-workspace.tsx @@ -8,6 +8,7 @@ import { AdminAccessPanel, type AdminPanelUser, } from "@/components/admin-access"; +import { AdminPaymentsSummary } from "@/components/admin-payments-summary"; import { AdminTradeSummary } from "@/components/admin-trade-summary"; import type { Beer, Venue } from "@/lib/types"; import { VENUE_ISLAND_OPTIONS, type VenueIsland } from "@/lib/venue-islands"; @@ -199,6 +200,8 @@ export function AdminWorkspace({ + +
diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md index 5b83566..f99a267 100644 --- a/docs/TECHNICAL.md +++ b/docs/TECHNICAL.md @@ -322,6 +322,47 @@ in code are listed. enforcement. - **Visibility:** admin-only (rules deny public access). +### `payments` + +- **Purpose:** internal record for one-off card charges taken in the admin + payments workspace (`/admin/payments`, issue #155) — brewery tours and + other charges that do not fit the B2B beer-sales workflow. Stripe is the + payment processor; this collection is the operational record. +- **Key fields (`PaymentRecord` in `lib/payments-common.ts`):** the document + id is the client-generated `clientRequestId` (UUID) — it is also the + Stripe idempotency key, so a retried create can never double-charge. + `purpose` (`brewery_tour | brewery_tour_tasting | additional_guests | + private_tour | other`), `description`, `amountMinor` (integer USD cents — + never a float), `currency` (`"usd"` — fixed), `customerName`, + optional `customerEmail`/`tourDate` (`YYYY-MM-DD` calendar string)/ + `attendeeCount`/`internalNote`, `status` (`created | awaiting_payment | + processing | paid | failed | expired | canceled`), `livemode`, `eventCount`, + `createdByUid`/`createdByName`, `createdAt`/`updatedAt`, Stripe + identifiers (`stripeCheckoutSessionId`, `stripeSessionUrl`, + `stripePaymentIntentId`, `stripeChargeId`, `stripeCustomerId`, + `sessionExpiresAt`), `receiptUrl`, safe card display metadata + (`paymentMethodBrand`/`paymentMethodLast4`), and lifecycle timestamps + (`paidAt`/`failedAt`/`expiredAt`/`canceledAt`). **Never stored:** PAN, + CVC, or raw Stripe payloads — card entry happens only on Stripe's hosted + page. +- **History:** `payments/{id}/events` subcollection — append-only + (`type`, `seq`, `actorUid`/`actorName` for staff actions, `details`, + `createdAt`); `eventCount` is the next `seq`, same pattern as lead + `activities`. +- **Writes:** server-only — `lib/payments-admin.ts` via the Admin SDK, + called by `/api/admin/payments*` (behind `requireAdminActor`) and + `/api/webhooks/stripe` (behind Stripe signature verification). +- **Visibility:** **no client access at all** — `read, write: if false` on + `payments/{paymentId}` and `events/{eventId}`. + +### `stripeEvents` + +- **Purpose:** webhook dedupe markers — one document per processed Stripe + event id (`type`, `paymentId`, `result`, `processedAt`). The webhook + transaction creates the marker and applies the payment update atomically, + so a replayed delivery exits before touching the record. +- **Visibility:** deny-all (`read, write: if false`); Admin SDK only. + ### Firebase Authentication Not a Firestore collection, but part of the data model: each admin is an Auth @@ -686,6 +727,51 @@ Email is a channel on the lead, not a separate inbox: - **Logging** — operational logs carry ids and event names only; email bodies, recipients, and customer addresses are never logged. +### Admin payments (`/admin/payments`, issue #155) + +One-off card charges (tours, ad-hoc) flow through **Stripe Checkout** — +Stripe's hosted payment page — chosen over Payment Element because the card +entry surface lives entirely on Stripe: no `NEXT_PUBLIC_` publishable key, +no Stripe.js in the bundle, no CSP change, and the session `url` doubles as +a shareable customer link (copy/QR/open) covering both counter and remote +collection. The Stripe client is built lazily via `getStripeClient()` +(`lib/stripe.ts`), so `next build` needs no Stripe values; `stripe` is +listed in `serverExternalPackages` alongside `firebase-admin`. + +1. **Create:** `POST /api/admin/payments` validates input + (`parsePaymentCreateBody` — required `clientRequestId` UUID, purpose + enum, integer-minor-unit amount bounded by `PAYMENT_MAX_AMOUNT_MINOR`, + optional email/tour-date/attendees/note bounds), then writes the + `payments/{clientRequestId}` record + `payment_created` event in a + transaction (existing doc → replay/recovery, never overwrite), creates + the Checkout Session with `idempotencyKey: paymentId` and + `metadata.paymentId` + `client_reference_id` (no PII in metadata), and + records the session id/url + `livemode` + `checkout_session_created` + event. Session params are built from the **stored** record + (`buildCheckoutSessionSpec`), so a browser can never tamper with the + charged amount. Success/cancel URLs are the static, noindex + `/pay/complete` and `/pay/cancelled` pages — they carry no state. +2. **Collect:** the workspace (`components/admin-payments-workspace.tsx` + behind `AdminAuthGate`) shows the session URL with copy, QR + (`GET /api/admin/payments/[id]/qr` renders it server-side via `qrcode`), + and open-link actions, and polls the refresh route while unresolved. + Quick-pick tour purposes derive suggestions from the canonical + `TOUR_PRODUCTS` prices; amounts are always staff-editable. +3. **Reconcile:** `POST /api/webhooks/stripe` verifies the signature + (`STRIPE_WEBHOOK_SECRET`, raw body) → `resolveStripeEventOutcome` maps + `checkout.session.{completed,expired,async_payment_succeeded, + async_payment_failed}` to `paid|processing|failed|expired` → + `processStripeEvent` runs the dedupe-marker + transition inside one + Firestore transaction (`planStripeEventApply` is the pure state machine; + Stripe-terminal statuses never regress, `canceled` outranks a late + `expired`). `paid` additionally enriches from the PaymentIntent's latest + charge (`receiptUrl`, brand/last4 — best-effort). Manual + `POST .../[id]/refresh` reconciles through the same planner, and + `POST .../[id]/cancel` expires the session on Stripe then marks + `canceled` (unpaid statuses only). +4. **Mode:** `livemode` from the Stripe object is stored per payment; the + UI badges test-mode payments. No analytics events carry payment data. + ## 12. Analytics and observability - **GA4 via GTM:** `app/layout.tsx` renders `components/gtm-bootstrap.tsx` @@ -785,6 +871,8 @@ Names only — never commit values. Source of truth for names: | `FIREBASE_ADMIN_CLIENT_EMAIL` | Admin SDK credential | Yes for all `/api/admin/*` | | `FIREBASE_ADMIN_PRIVATE_KEY` | Admin SDK credential (PEM; stored with `\n` escapes) | Yes for all `/api/admin/*` | | `SUPER_ADMIN_EMAIL` | Bootstrap allowlist — the only email `admin/bootstrap` will promote | Yes for bootstrap | +| `STRIPE_SECRET_KEY` | Stripe secret key for the admin payments feature — Checkout Session create/expire/retrieve and PaymentIntent retrieve via lazy `getStripeClient()` (`sk_test_*` selects test mode) | Runtime only — required for `/api/admin/payments*` to function; build-safe otherwise | +| `STRIPE_WEBHOOK_SECRET` | Signing secret (`whsec_*`) for `/api/webhooks/stripe` signature verification | Runtime only — required for webhook processing; the route 500s without it rather than accept unsigned events | | `NEXT_PUBLIC_SENTRY_DSN` | Error-monitoring ingest DSN for the server + browser SDKs (`lib/monitoring-shared.ts`); inlined into the client bundle by design — not a credential | Optional — reporting is enabled only when this is set **and** the environment is Vercel Production (`VERCEL_ENV` server-side / `NEXT_PUBLIC_VERCEL_ENV` client-side); preview/dev/CI never emit events | | `SENTRY_ORG` | Sentry org slug for source-map upload (`withSentryConfig`) | Build time, Production scope — absent everywhere else | | `SENTRY_PROJECT` | Sentry project slug for source-map upload | Build time, Production scope | diff --git a/docs/admin/README.md b/docs/admin/README.md index 96152e8..d4158b1 100644 --- a/docs/admin/README.md +++ b/docs/admin/README.md @@ -12,6 +12,7 @@ The admin dashboard lets authorized users: - View, create, and edit venue/partner records. - Upload beer card and hero images to Firebase Storage. - Manage trade leads — statuses, owners, notes/history, and follow-ups — in the Trade leads pipeline at `/admin/trade` (see [Trade inquiries](./trade-inquiries.md)). +- Take card payments for brewery tours and other one-off charges in the Payments workspace at `/admin/payments` (see [Payments](./payments.md)). - Trigger a site rebuild so static pages (such as the beer listing and Where to Buy page) show the latest content. In addition, users with the **superadmin** role can: @@ -81,3 +82,4 @@ For detailed login troubleshooting, see [Login and access](./login-and-access.md - [Managing locations](./managing-locations.md) - [Images and storage](./images-and-storage.md) - [Trade inquiries](./trade-inquiries.md) +- [Payments](./payments.md) diff --git a/docs/admin/payments.md b/docs/admin/payments.md new file mode 100644 index 0000000..cabe73f --- /dev/null +++ b/docs/admin/payments.md @@ -0,0 +1,67 @@ +# Payments + +This guide explains how staff take card payments for brewery tours and other one-off charges from the admin app — and what happens behind the scenes so the record stays trustworthy. + +## Where it lives + +Sign in at https://deepdivebrewing.com/admin, then open **Payments** from the dashboard summary — or go directly to `/admin/payments`. + +The workspace has two halves: + +- **Take payment** — create a new charge. +- **Recent payments** — the latest payments with their status. Select a row for the full detail and history. + +## Taking a payment + +1. Choose a **Purpose** — Brewery Tour, Brewery Tour + Tasting, Additional guest(s), Private tour, or Other. The tour purposes carry the published per-person prices, so entering an **Attendees** count suggests the amount automatically. You can always edit the amount — staff judgment wins for private tours, partial payments, and custom arrangements. +2. Fill in the **Description** (prefilled from the purpose), **Amount (USD)**, and **Customer name**. Receipt email, tour date, attendee count, and an internal note are all optional. +3. Click **Review payment** — the app shows the exact charge on a confirmation panel. Check the amount carefully. +4. Click **Create payment**. This creates the payment record and a secure Stripe payment link. + +> The customer **never types their card into this app**. Card entry happens only on Stripe's hosted page — the app never sees, stores, or logs card numbers. + +## Getting the customer to pay + +Once created, the payment shows a live **payment link** with three ways to reach it: + +- **Show QR** — best at the counter: the customer scans with their phone and pays on their own device. +- **Copy link** — paste it into WhatsApp, SMS, or email for a customer who isn't present. +- **Open payment page** — opens Stripe's hosted page in a new tab, useful on a shared tablet. + +The status updates automatically while the customer pays (the app checks Stripe for a few minutes). **Refresh status** reconciles on demand — Stripe is the source of truth, so a slow notification never leaves a paid payment looking unpaid. + +## Payment states + +| Status | Meaning | +|---|---| +| **Setting up** | The record exists but the payment link is still being created — usually only for a second. | +| **Awaiting payment** | The link is live; the customer has not finished paying. | +| **Processing** | Stripe accepted the payment but the bank has not confirmed yet (rare for cards). | +| **Paid** | Money captured. Final — the Stripe webhook confirmed it. | +| **Failed** | The customer's attempt failed (e.g. a declined card). | +| **Expired** | The payment link timed out (~24 hours) unpaid. | +| **Canceled** | Staff canceled the payment before the customer paid. | + +A payment is never marked paid because a browser landed on a success page — Stripe tells the server directly, and only then does the record become **Paid**. + +## Canceling a payment + +While a payment is **Awaiting payment** (or still setting up), use **Cancel payment** — it kills the payment link so it can no longer be used, and records the cancellation in the history. Paid, failed, and expired payments cannot be canceled from here. Refunds are not part of this tool yet — see an owner if a paid charge needs to be refunded (they are done in the Stripe Dashboard for now). + +## Receipts + +If you entered the customer's **Receipt email**, Stripe emails them a receipt automatically when the payment succeeds. After a payment is **Paid**, the detail view also shows a **View receipt** link to Stripe's hosted receipt page, plus the card brand and last four digits — the only card details ever stored, and only display metadata. + +## Payment detail + +Selecting a payment shows everything the app recorded: amount, purpose, description, customer, receipt email, tour date, attendees, who created it and when, the Stripe identifiers, receipt link, and an append-only **History** (created, link issued, succeeded/failed/expired, canceled). + +## Test mode + +When the app runs with Stripe **test** credentials, a **Test mode** badge appears on created payments. Test payments use Stripe's test cards and never move real money. If you see the badge in normal use, tell an owner — it means the deployment is configured with test keys. + +## Troubleshooting + +- **Payment stays "Awaiting payment" after the customer says they paid** — click **Refresh status**; the app asks Stripe directly. +- **The link doesn't work** — links expire after about 24 hours or when canceled. Create a fresh payment. +- **A charge happened twice** — it shouldn't: retries reuse the same payment record and Stripe session. Report it to an owner with the payment's Stripe session id from the detail view. diff --git a/docs/operations/credential-rotation.md b/docs/operations/credential-rotation.md index 6891ac7..745f795 100644 --- a/docs/operations/credential-rotation.md +++ b/docs/operations/credential-rotation.md @@ -19,6 +19,7 @@ credential rotation unless a procedure below explicitly says so. | `FIREBASE_ADMIN_*` | Firebase console → Project settings → Service accounts (key pair lives in Google Cloud IAM) | | `VERCEL_DEPLOY_HOOK_URL` | Vercel project → Settings → Git → Deploy Hooks | | `SUPER_ADMIN_EMAIL`, cooldowns, `TRADE_NOTIFICATION_EMAIL`, `TRADE_INQUIRY_TO_EMAIL`, `TRADE_REPLY_DOMAIN` | Vercel environment variables (no external issuer) | +| `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` | Stripe Dashboard → Developers (API keys; webhook signing secrets) | | `NEXT_PUBLIC_*` | Firebase console → web app config; GTM admin for `NEXT_PUBLIC_GTM_ID` | - **Vercel environment variables** (Project → Settings → Environment @@ -268,8 +269,12 @@ shell history, or committed files. not require rotation solely because it was visible — it is public by design. Actual secrets and capability URLs — `RESEND_API_KEY`, `FIREBASE_ADMIN_PRIVATE_KEY`, `FIREBASE_ADMIN_CLIENT_EMAIL`, - `VERCEL_DEPLOY_HOOK_URL`/`VERCEL_REBUILD_DEPLOY_HOOK_URL` — should be - rotated whenever disclosure risk is credible. + `VERCEL_DEPLOY_HOOK_URL`/`VERCEL_REBUILD_DEPLOY_HOOK_URL`, + `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` — should be + rotated whenever disclosure risk is credible. **Exposure of a + `sk_live_*` key is a payments incident — rotate immediately and + contact the owner; Stripe support can also restrict the key's + permissions.** 3. **Create the replacement** per sections A–C above. 4. **Update consumers** — Vercel env vars (all needed scopes), `.env.local` files, any other store holding the value. @@ -285,6 +290,8 @@ shell history, or committed files. - Resend dashboard/logs for unexpected sends (`RESEND_API_KEY`). - `adminAuditLogs` and Firebase Auth users for unexpected admin activity (Admin SDK credentials). + - Stripe Dashboard → Payments and Events for unexpected charges or + API calls (Stripe key exposure). 9. **Write a brief incident note** — what was exposed, when, where, and what was rotated. Keep it outside the repository if it contains sensitive details. @@ -301,6 +308,32 @@ shell history, or committed files. | `VERCEL_DEPLOY_HOOK_URL` (+ `VERCEL_REBUILD_DEPLOY_HOOK_URL`) | Admin rebuild triggers a new Vercel production deployment; old hook deleted | | `SUPER_ADMIN_EMAIL` | New account bootstraps to superadmin; protected-account checks follow the new email; at least one active superadmin remains | | `NEXT_PUBLIC_*` (only if values actually changed) | Google sign-in works; public pages render Firestore data; Storage images load; no CSP/`frame-src` errors for the Auth domain | +| `STRIPE_SECRET_KEY` | Admin creates a test payment at `/admin/payments`; Stripe Dashboard shows the Checkout Session; no `payment.*_failed` logs | +| `STRIPE_WEBHOOK_SECRET` | Test payment reaches `Paid` via the webhook (or resend a recent event in the Dashboard); no `stripe_webhook.bad_signature` warnings | + +--- + +## H. Stripe credentials (`STRIPE_*`) + +`STRIPE_SECRET_KEY` powers the admin payments feature +(`/admin/payments` — Checkout Session create/retrieve/expire). `STRIPE_WEBHOOK_SECRET` verifies signatures on `POST /api/webhooks/stripe`. Both are server-only; there is deliberately **no** publishable key — card entry happens only on Stripe's hosted Checkout page. + +**Test vs live:** `sk_test_*`/`sk_live_*` keys and their corresponding `whsec_*` webhook secrets are independent. Development/Preview should use test credentials; Production uses live. Payments record Stripe's `livemode` flag, and the UI badges test-mode payments — a mismatch between key mode and deployment is visible, not silent. + +### Steps — secret key + +1. Stripe Dashboard → **Developers → API keys** → create/roll the secret key (choose the right mode — test and live have separate keys). +2. Update `STRIPE_SECRET_KEY` in Vercel (per scope: test key for Preview/Development, live for Production) and `.env.local`. +3. Redeploy. +4. Verify: sign in as an admin, create a small payment at `/admin/payments`, and confirm the Checkout Session appears in the Stripe Dashboard (Payments → Checkout Sessions) for the matching mode. +5. Revoke the old key in the Stripe Dashboard. + +### Steps — webhook signing secret + +1. The signing secret belongs to a specific registered webhook endpoint. To rotate it: Stripe Dashboard → **Developers → Webhooks** → select the endpoint → **Roll secret**. +2. Update `STRIPE_WEBHOOK_SECRET` for that endpoint's deployment scope and redeploy. +3. There is a delivery gap between rolling the secret and redeploying — Stripe retries failed deliveries for days, so briefly-failed events are not lost; resend any still-failing events from the Dashboard after the redeploy verifies. +4. Verify: complete a test payment and confirm it flips to **Paid** via `stripe_webhook.processed` logs, or use the Dashboard's "Resend" on a recent event and confirm no `stripe_webhook.bad_signature` warnings. ## Related documents diff --git a/docs/operations/deployment.md b/docs/operations/deployment.md index 2bd1235..9bc5f4c 100644 --- a/docs/operations/deployment.md +++ b/docs/operations/deployment.md @@ -30,6 +30,7 @@ https://deepdivebrewing--.vercel.app - Preview deployments use the same Firebase project as production if the environment variables point to it. - Preview domains must be added to **Firebase Authentication > Settings > Authorized domains** if you need to test Google sign-in on a preview URL. - The `frame-src` Content-Security-Policy is built at deploy time using the value of `NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN`. If that environment variable is not set for the preview, the fallback domain is used. +- Payments work in preview deployments when `STRIPE_SECRET_KEY` (test mode) is set for the Preview scope. Webhook delivery needs a Stripe webhook endpoint registered for that preview URL — previews are ephemeral, so register a `stripe listen --forward-to` target or a dedicated staging webhook rather than relying on the production endpoint. Payment creation, QR/copy links, and manual **Refresh status** still work without a webhook, because refresh reconciles directly with Stripe. ## Production deployment @@ -74,6 +75,8 @@ Examples: - `FIREBASE_ADMIN_PRIVATE_KEY` - `ADMIN_REBUILD_COOLDOWN_MS` - `SUPER_ADMIN_EMAIL` — the verified Google account that receives the initial superadmin role +- `STRIPE_SECRET_KEY` — Stripe secret key for the admin payments feature; use the `sk_test_*` key in Development/Preview and `sk_live_*` in Production +- `STRIPE_WEBHOOK_SECRET` — Stripe webhook signing secret for `POST /api/webhooks/stripe` (one signing secret per registered endpoint) ## How to verify a preview with populated Firebase data diff --git a/docs/operations/observability.md b/docs/operations/observability.md index b122f84..51a0a77 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -242,6 +242,15 @@ emits **single-line JSON** so Vercel's log search can filter reliably: | `admin_invitation.{accept_rollback,accept,resend}_failed` | Invitation lifecycle failures | | `admin_invitation_email.{misconfigured,send_rejected,send_exception}` | Invitation email send-path failures (`missing` names the unset var) | | `admin_bootstrap.failed` / `admin_me.unexpected` | Bootstrap and identity-probe failures | +| `payment.created` | Internal payment record + Stripe Checkout Session created (`paymentId`, `amountMinor` — no customer data) | +| `payment.canceled` / `payment.refreshed` | Staff canceled an unpaid payment, or a manual status refresh applied a state change | +| `payment.{create,get,cancel,refresh,qr}_failed` / `payments.list_failed` | Unhandled errors in the payments API routes | +| `payment.enrichment_failed` | Paid-payment receipt/card-brand enrichment from Stripe failed — the payment stays `paid`; only display metadata is missing (warn) | +| `payment.refresh_id_mismatch` | A Stripe session retrieved during refresh had the wrong `paymentId` — treated as a defensive anomaly, not an update (warn) | +| `stripe_webhook.processed` | Signed Stripe event applied to a payment (`eventType`, `paymentId`) | +| `stripe_webhook.unknown_payment` | Signed event referenced a `paymentId` with no record — acked 200 so Stripe does not retry it forever (warn) | +| `stripe_webhook.bad_signature` | Signature verification failed — rejected 400 (warn, security-relevant) | +| `stripe_webhook.processing_failed` | Unhandled webhook error — 500 returned so Stripe retries delivery | Routine validation and authorization denials (missing bearer token, bad input, rate limits, honeypot hits) are **not logged** — they are expected diff --git a/firestore.rules b/firestore.rules index 96a7f88..8dba3e7 100644 --- a/firestore.rules +++ b/firestore.rules @@ -81,6 +81,25 @@ service cloud.firestore { } } + // Payment records (#155) carry customer PII and Stripe identifiers and + // are read/written exclusively server-side — /api/admin/payments* behind + // requireAdminActor and /api/webhooks/stripe behind signature + // verification, all via the Admin SDK. No client may read or write. + match /payments/{paymentId} { + allow read, write: if false; + + // Per-payment audit trail (creation, state transitions, cancellations). + match /events/{eventId} { + allow read, write: if false; + } + } + + // Stripe webhook dedupe markers — written and read only inside Admin SDK + // transactions in the webhook handler. + match /stripeEvents/{eventId} { + allow read, write: if false; + } + match /meta/{docId} { allow read, write: if hasActiveAdmin(); } diff --git a/lib/payments-admin.ts b/lib/payments-admin.ts new file mode 100644 index 0000000..1e57cce --- /dev/null +++ b/lib/payments-admin.ts @@ -0,0 +1,515 @@ +import "server-only"; +import { FieldValue, Timestamp } from "firebase-admin/firestore"; +import { getFirebaseAdminDb } from "@/lib/firebase-admin-db"; +import { getStripeClient } from "@/lib/stripe"; +import { getStripeWebhookSecret } from "@/lib/stripe-config"; +import { siteUrl } from "@/lib/site"; +import { logInfo, logWarn } from "@/lib/log"; +import { + buildCheckoutSessionSpec, + enrichmentFromPaymentIntent, + isPaymentCancelable, + normalizePaymentStatus, + outcomeFromSession, + PaymentError, + PaymentNotFoundError, + planStripeEventApply, + processStripeEvent, + resolveStripeEventOutcome, + PAYMENTS_COLLECTION, + PAYMENT_EVENTS_SUBCOLLECTION, + STRIPE_EVENTS_COLLECTION, + type PaymentCreateInput, + type PaymentEventDraft, + type StripeEventStore, + type StripeOutcome, +} from "@/lib/payments-common"; +import type Stripe from "stripe"; + +// Identity the event timeline records — same shape as the trade-lead +// pipeline's actor (display name snapshotted at write time). +export interface PaymentActor { + uid: string; + name: string; +} + +export function paymentActorOf(actor: { + token: { uid: string; name?: string; email?: string }; + record: { displayName?: string; email: string }; +}): PaymentActor { + return { + uid: actor.token.uid, + name: + actor.record.displayName?.trim() || + actor.token.name?.trim() || + actor.token.email?.trim() || + actor.record.email, + }; +} + +// Admin SDK reads/writes — never imported by client components. `payments`, +// `payments/*/events`, and `stripeEvents` are deny-all in firestore.rules; +// these functions are the only data path and every caller-facing route sits +// behind requireAdminActor (or Stripe signature verification, for the +// webhook). + +export function getPaymentsCollection() { + return getFirebaseAdminDb().collection(PAYMENTS_COLLECTION); +} + +// Recent payments only — the workspace is a counter tool, not a ledger. +// Single-field ordering: no composite index needed. +export const PAYMENT_LIST_LIMIT = 100; + +export async function listPayments(): Promise< + { id: string; data: Record }[] +> { + const snapshot = await getPaymentsCollection() + .orderBy("createdAt", "desc") + .limit(PAYMENT_LIST_LIMIT) + .get(); + return snapshot.docs.map((doc) => ({ id: doc.id, data: doc.data() })); +} + +export async function getPayment( + id: string +): Promise<{ id: string; data: Record } | null> { + const doc = await getPaymentsCollection().doc(id).get(); + if (!doc.exists) return null; + return { id: doc.id, data: doc.data() ?? {} }; +} + +export async function listPaymentEvents( + paymentId: string +): Promise<{ id: string; data: Record }[]> { + const snapshot = await getPaymentsCollection() + .doc(paymentId) + .collection(PAYMENT_EVENTS_SUBCOLLECTION) + .orderBy("seq", "asc") + .get(); + return snapshot.docs.map((doc) => ({ id: doc.id, data: doc.data() })); +} + +function eventDoc( + draft: PaymentEventDraft, + seq: number, + actor: PaymentActor | null +) { + return { + type: draft.type, + seq, + ...(draft.details ? { details: draft.details } : {}), + ...(actor ? { actorUid: actor.uid, actorName: actor.name } : {}), + createdAt: FieldValue.serverTimestamp(), + }; +} + +// --- Payment creation --- + +export interface CreatedPayment { + id: string; + data: Record; + // True when the POST was an idempotent replay of an earlier request (the + // document already existed for the clientRequestId). + replayed: boolean; +} + +// Creates the internal record + Stripe Checkout Session. +// +// Duplicate-charge protection is structural: the clientRequestId is both +// the document id and the Stripe idempotency key. A retried POST either +// returns the existing record or resumes a half-finished creation (doc +// exists, session missing) — it can never mint a second session for the +// same logical action. +export async function createAdminPayment( + input: PaymentCreateInput, + actor: PaymentActor +): Promise { + const db = getFirebaseAdminDb(); + const ref = getPaymentsCollection().doc(input.clientRequestId); + + const baseRecord: Record = { + purpose: input.purpose, + description: input.description, + amountMinor: input.amountMinor, + currency: "usd", + customerName: input.customerName, + ...(input.customerEmail ? { customerEmail: input.customerEmail } : {}), + ...(input.tourDate ? { tourDate: input.tourDate } : {}), + ...(input.attendeeCount ? { attendeeCount: input.attendeeCount } : {}), + ...(input.internalNote ? { internalNote: input.internalNote } : {}), + status: "created", + eventCount: 1, + createdByUid: actor.uid, + createdByName: actor.name, + createdAt: FieldValue.serverTimestamp(), + updatedAt: FieldValue.serverTimestamp(), + }; + + // Read-then-conditional-write inside a transaction so a replay never + // overwrites: if the doc exists we resume/replay instead of failing. + const seed = await db.runTransaction(async (tx) => { + const snap = await tx.get(ref); + if (snap.exists) { + return { existed: true, data: snap.data() as Record }; + } + tx.set(ref, baseRecord); + tx.set( + ref.collection(PAYMENT_EVENTS_SUBCOLLECTION).doc(), + eventDoc({ type: "payment_created" }, 0, actor) + ); + return { existed: false, data: baseRecord }; + }); + + if (seed.existed) { + const status = normalizePaymentStatus(seed.data.status); + if (status !== "created") { + // Replay: the earlier request already issued a session (or the payment + // has since resolved). Return the current record unchanged. + return { id: ref.id, data: seed.data, replayed: true }; + } + // Recovery path: the earlier request died after the doc write but before + // the session was recorded. Continue from the stored record — its fields + // (not the retried payload) are authoritative for the charge. + } + + const stored = seed.data; + const spec = buildCheckoutSessionSpec({ + paymentId: ref.id, + purpose: String(stored.purpose ?? input.purpose), + description: String(stored.description ?? input.description), + amountMinor: Number(stored.amountMinor ?? input.amountMinor), + customerEmail: + typeof stored.customerEmail === "string" + ? stored.customerEmail + : undefined, + successUrl: `${siteUrl}/pay/complete`, + cancelUrl: `${siteUrl}/pay/cancelled`, + }); + + // Idempotency key = payment id: a Stripe retry of this call returns the + // same session instead of creating a second hosted page. + const session = await getStripeClient().checkout.sessions.create( + spec as Stripe.Checkout.SessionCreateParams, + { idempotencyKey: ref.id } + ); + if (!session.url) { + throw new PaymentError( + "Stripe did not return a payment page. Please try again.", + 502 + ); + } + + const batch = db.batch(); + batch.update(ref, { + status: "awaiting_payment", + stripeCheckoutSessionId: session.id, + stripeSessionUrl: session.url, + ...(session.payment_intent + ? { + stripePaymentIntentId: + typeof session.payment_intent === "string" + ? session.payment_intent + : session.payment_intent.id, + } + : {}), + ...(session.customer + ? { + stripeCustomerId: + typeof session.customer === "string" + ? session.customer + : session.customer.id, + } + : {}), + livemode: session.livemode, + sessionExpiresAt: Timestamp.fromMillis(session.expires_at * 1000), + eventCount: 2, + updatedAt: FieldValue.serverTimestamp(), + }); + batch.set( + ref.collection(PAYMENT_EVENTS_SUBCOLLECTION).doc(), + eventDoc( + { + type: "checkout_session_created", + details: { sessionId: session.id }, + }, + 1, + actor + ) + ); + await batch.commit(); + + const created = await getPayment(ref.id); + return { id: ref.id, data: created?.data ?? {}, replayed: seed.existed }; +} + +// --- Staff cancel --- + +// Cancels an unpaid payment: expires the hosted session on Stripe first so +// the link dies even if our write fails, then records `canceled`. A Stripe +// `checkout.session.expired` webhook may still arrive later — the state +// machine treats `canceled` as outranking `expired`, so the record keeps +// the staff-canceled meaning. +export async function cancelAdminPayment( + id: string, + actor: PaymentActor +): Promise { + const db = getFirebaseAdminDb(); + const ref = getPaymentsCollection().doc(id); + const doc = await ref.get(); + if (!doc.exists) throw new PaymentNotFoundError(); + + const data = (doc.data() ?? {}) as Record; + const status = normalizePaymentStatus(data.status); + if (!isPaymentCancelable(status)) { + throw new PaymentError( + `A ${status === "paid" ? "paid" : "finished"} payment cannot be canceled.`, + 409 + ); + } + + const sessionId = + typeof data.stripeCheckoutSessionId === "string" + ? data.stripeCheckoutSessionId + : null; + + if (sessionId) { + try { + await getStripeClient().checkout.sessions.expire(sessionId); + } catch (expireError) { + // "Already expired" is the benign race — cancel proceeds. Anything + // else (e.g. the customer already completed) reconciles via the + // refresh path so the caller sees the true state. + const session = + await getStripeClient().checkout.sessions.retrieve(sessionId); + if (session.status === "expired") { + // Fall through — the hosted page is dead either way. + } else if (session.status === "complete") { + const { paymentId, outcome } = outcomeFromSession( + session as unknown as Record, + session.payment_status === "paid" ? "paid" : "processing" + ); + await applyOutcome(id, paymentId === id ? outcome : null, "manual_refresh"); + throw new PaymentError( + "This payment already completed and cannot be canceled.", + 409 + ); + } else { + throw expireError; + } + } + } + + const base = typeof data.eventCount === "number" ? data.eventCount : 0; + const batch = db.batch(); + batch.update(ref, { + status: "canceled", + canceledAt: FieldValue.serverTimestamp(), + eventCount: base + 1, + updatedAt: FieldValue.serverTimestamp(), + }); + batch.set( + ref.collection(PAYMENT_EVENTS_SUBCOLLECTION).doc(), + eventDoc({ type: "payment_canceled" }, base, actor) + ); + await batch.commit(); +} + +// --- Reconciliation (manual refresh + webhook shared path) --- + +// Applies a Stripe outcome to the stored record inside a transaction — +// same planner the webhook uses, so manual refresh and webhook delivery +// can never diverge. +async function applyOutcome( + id: string, + outcome: StripeOutcome | null, + source: "webhook" | "manual_refresh", + extraUpdates?: Record +): Promise { + if (!outcome) return false; + const ref = getPaymentsCollection().doc(id); + return getFirebaseAdminDb().runTransaction(async (tx) => { + const snap = await tx.get(ref); + if (!snap.exists) throw new PaymentNotFoundError(); + const record = (snap.data() ?? {}) as Record; + const plan = planStripeEventApply(record, outcome, source); + if (!plan.apply) return false; + const base = + typeof record.eventCount === "number" ? record.eventCount : 0; + const updates = { ...plan.updates, ...(extraUpdates ?? {}) }; + tx.update(ref, { + ...updates, + eventCount: base + plan.events.length, + }); + plan.events.forEach((draft, index) => { + tx.set( + ref.collection(PAYMENT_EVENTS_SUBCOLLECTION).doc(), + eventDoc(draft, base + index, null) + ); + }); + return true; + }); +} + +// Best-effort receipt/card enrichment for a `paid` transition. Failures are +// logged and skipped — the paid transition itself must not be held hostage +// to a secondary lookup; the refresh action can backfill later. +async function fetchPaidEnrichment( + paymentIntentId: string +): Promise> { + try { + const intent = await getStripeClient().paymentIntents.retrieve( + paymentIntentId, + { expand: ["latest_charge"] } + ); + return enrichmentFromPaymentIntent( + intent as unknown as Record + ); + } catch (error) { + logWarn("payment.enrichment_failed", { + paymentIntentId, + errorName: error instanceof Error ? error.name : "Error", + }); + return {}; + } +} + +// Staff-facing "refresh status": re-reads the Checkout Session from Stripe +// and applies the same outcome mapping the webhook would. Idempotent — the +// planner ignores transitions that do not advance the record. +export async function refreshAdminPayment(id: string): Promise { + const doc = await getPaymentsCollection().doc(id).get(); + if (!doc.exists) throw new PaymentNotFoundError(); + const data = (doc.data() ?? {}) as Record; + const sessionId = + typeof data.stripeCheckoutSessionId === "string" + ? data.stripeCheckoutSessionId + : null; + if (!sessionId) return; // Nothing issued yet — nothing to reconcile. + + const session = await getStripeClient().checkout.sessions.retrieve(sessionId); + const transition = + session.status === "complete" + ? session.payment_status === "paid" + ? "paid" + : "processing" + : session.status === "expired" + ? "expired" + : null; + if (!transition) return; // Still open — no state change. + + const { paymentId, outcome } = outcomeFromSession( + session as unknown as Record, + transition + ); + const targetId = paymentId ?? id; + if (targetId !== id) { + logWarn("payment.refresh_id_mismatch", { paymentId: id }); + return; + } + + const extra = + transition === "paid" && outcome.paymentIntentId + ? await fetchPaidEnrichment(outcome.paymentIntentId) + : {}; + await applyOutcome(id, outcome, "manual_refresh", extra); +} + +// --- Stripe webhook --- + +// Firestore-backed StripeEventStore: dedupe marker + payment update land in +// one transaction. Reads happen before writes by construction (the +// orchestrator checks the marker, then the payment, then writes). +function makeStripeEventStore(): StripeEventStore { + const db = getFirebaseAdminDb(); + const markers = db.collection(STRIPE_EVENTS_COLLECTION); + const payments = db.collection(PAYMENTS_COLLECTION); + + return { + transact: (work) => + db.runTransaction(async (tx) => { + // getPayment caches the snapshot so updatePayment can continue the + // `seq` sequence without a second read (all tx reads must precede + // writes anyway). + const cache = new Map | null>(); + return work({ + stripeEventProcessed: async (eventId) => + (await tx.get(markers.doc(eventId))).exists, + getPayment: async (paymentId) => { + const snap = await tx.get(payments.doc(paymentId)); + const data = snap.exists ? (snap.data() ?? {}) : null; + cache.set(paymentId, data as Record | null); + return data as Record | null; + }, + updatePayment: async (paymentId, updates, events) => { + const record = cache.get(paymentId); + const base = + typeof record?.eventCount === "number" ? record.eventCount : 0; + tx.update(payments.doc(paymentId), { + ...updates, + eventCount: base + events.length, + }); + events.forEach((draft, index) => { + tx.set( + payments + .doc(paymentId) + .collection(PAYMENT_EVENTS_SUBCOLLECTION) + .doc(), + eventDoc(draft, base + index, null) + ); + }); + }, + markStripeEventProcessed: async (eventId, result, meta) => { + tx.set(markers.doc(eventId), { + type: meta.eventType, + ...(meta.paymentId ? { paymentId: meta.paymentId } : {}), + result, + processedAt: FieldValue.serverTimestamp(), + }); + }, + }); + }), + }; +} + +export interface WebhookResult { + status: "applied" | "duplicate" | "ignored" | "unknown_payment"; + paymentId: string | null; + toStatus?: string; +} + +// Verifies + processes one webhook delivery. Throws on an invalid signature +// (the route answers 400) — everything else resolves through +// processStripeEvent inside a single Firestore transaction. +export async function handleStripeWebhook( + rawBody: string, + signatureHeader: string +): Promise { + const event = getStripeClient().webhooks.constructEvent( + rawBody, + signatureHeader, + getStripeWebhookSecret() + ); + + const resolved = resolveStripeEventOutcome(event); + + // Paid transitions pick up receipt/card display data before the commit so + // it lands atomically with the status change. + if ( + resolved.outcome?.transition === "paid" && + resolved.outcome.paymentIntentId + ) { + resolved.outcome.extraUpdates = await fetchPaidEnrichment( + resolved.outcome.paymentIntentId + ); + } + + const result = await processStripeEvent(resolved, makeStripeEventStore()); + logInfo("stripe_webhook.processed", { + eventType: resolved.eventType, + result: result.status, + paymentId: result.paymentId, + toStatus: result.toStatus, + }); + return result; +} diff --git a/lib/payments-common.ts b/lib/payments-common.ts new file mode 100644 index 0000000..bfce135 --- /dev/null +++ b/lib/payments-common.ts @@ -0,0 +1,947 @@ +// Shared, dependency-free domain logic for the admin payment workspace +// (Issue #155). Same split as the trade-lead modules: this file is pure and +// safe for client components (no firebase/stripe imports); lib/payments-admin.ts +// wires the server-only Admin SDK and Stripe SDK access. +// +// Money rules: every amount is an integer count of USD cents. The browser +// may suggest an amount (tour product × attendees) but the server always +// re-validates the submitted value — client arithmetic is never trusted. +import { isValidEmail } from "@/lib/email"; +import { toIsoString } from "@/lib/admin-serializers"; +import { parseCalendarDate, TOUR_PRODUCTS } from "@/lib/whatsapp"; + +export const PAYMENTS_COLLECTION = "payments"; +export const PAYMENT_EVENTS_SUBCOLLECTION = "events"; +export const STRIPE_EVENTS_COLLECTION = "stripeEvents"; + +// The brewery transacts in USD (tour prices are published in USD — see +// TOUR_PRODUCTS). Fixed deliberately: staff never pick a currency, so there +// is nothing to get wrong. +export const PAYMENT_CURRENCY = "usd"; +export const PAYMENT_CURRENCY_LABEL = "USD"; + +// --- Money --- + +// Sanity ceiling for a single ad-hoc charge. Well above any realistic tour +// or counter charge; it exists to catch typos like an extra zero. +export const PAYMENT_MAX_AMOUNT_MINOR = 1_000_000; // $10,000.00 + +const usdFormatter = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", +}); + +export function formatUsdMinor(minor: number | undefined | null): string { + if (typeof minor !== "number" || !Number.isFinite(minor)) return "—"; + return usdFormatter.format(minor / 100); +} + +// Accepts staff-entered dollar strings ("40", "40.00", "$20") or numbers +// that are exact to the cent. Returns integer minor units or a staff-facing +// error. Rejects anything fractional below a cent. +export function parseAmountMinor( + raw: unknown +): { ok: true; minor: number } | { ok: false; error: string } { + let minor: number | null = null; + + if (typeof raw === "string") { + const value = raw.trim().replace(/^\$\s*/, ""); + const match = /^(\d+)(?:\.(\d{1,2}))?$/.exec(value); + if (match) { + minor = + Number(match[1]) * 100 + Number((match[2] ?? "0").padEnd(2, "0")); + } + } else if (typeof raw === "number" && Number.isFinite(raw)) { + const cents = Math.round(raw * 100); + if (Math.abs(raw * 100 - cents) < 1e-9) minor = cents; + } + + if (minor === null || !Number.isInteger(minor)) { + return { ok: false, error: "Enter a valid amount (e.g. 40 or 40.00)." }; + } + if (minor <= 0) { + return { ok: false, error: "Amount must be greater than zero." }; + } + if (minor > PAYMENT_MAX_AMOUNT_MINOR) { + return { + ok: false, + error: `Amount cannot exceed ${formatUsdMinor(PAYMENT_MAX_AMOUNT_MINOR)}.`, + }; + } + return { ok: true, minor }; +} + +// --- Purpose / quick-picks --- + +export interface PaymentPurpose { + value: string; + label: string; + // Suggested per-person price where a canonical one exists (the published + // tour products in lib/whatsapp.ts). Staff can always override the amount. + perPersonMinor?: number; +} + +// `label` and `perPersonMinor` for the tour purposes are derived from +// TOUR_PRODUCTS — the same canonical prices the public site advertises — so +// the payment quick-picks can never drift from published pricing. +export const PAYMENT_PURPOSES: readonly PaymentPurpose[] = [ + { + value: "brewery_tour", + label: TOUR_PRODUCTS.breweryTour.label, + perPersonMinor: TOUR_PRODUCTS.breweryTour.priceUsd * 100, + }, + { + value: "brewery_tour_tasting", + label: TOUR_PRODUCTS.breweryTourTasting.label, + perPersonMinor: TOUR_PRODUCTS.breweryTourTasting.priceUsd * 100, + }, + { value: "additional_guests", label: "Additional guest(s)" }, + { value: "private_tour", label: "Private tour" }, + { value: "other", label: "Other" }, +]; + +const PAYMENT_PURPOSE_VALUES: ReadonlySet = new Set( + PAYMENT_PURPOSES.map((p) => p.value) +); + +export function isPaymentPurpose(value: unknown): value is string { + return typeof value === "string" && PAYMENT_PURPOSE_VALUES.has(value); +} + +export function paymentPurposeLabel(value: unknown): string { + return ( + PAYMENT_PURPOSES.find((p) => p.value === value)?.label ?? + (typeof value === "string" && value ? value : "Other") + ); +} + +// Quick-fill suggestion only — never submitted back as an authoritative +// total. Returns null when the purpose has no canonical per-person price or +// the attendee count is absent. +export function suggestedAmountMinor( + purpose: string, + attendeeCount: number | undefined +): number | null { + const entry = PAYMENT_PURPOSES.find((p) => p.value === purpose); + if (!entry?.perPersonMinor || !attendeeCount || attendeeCount < 1) { + return null; + } + return entry.perPersonMinor * attendeeCount; +} + +// --- Status model --- + +// `created` is the brief window where the internal record exists but the +// Stripe Checkout Session is still being made — normally invisible, but it +// is what a mid-creation failure recovery path resumes from. +export const PAYMENT_STATUSES = [ + { value: "created", label: "Setting up" }, + { value: "awaiting_payment", label: "Awaiting payment" }, + { value: "processing", label: "Processing" }, + { value: "paid", label: "Paid" }, + { value: "failed", label: "Failed" }, + { value: "expired", label: "Expired" }, + { value: "canceled", label: "Canceled" }, +] as const; + +export type PaymentStatus = (typeof PAYMENT_STATUSES)[number]["value"]; + +const PAYMENT_STATUS_VALUES: ReadonlySet = new Set( + PAYMENT_STATUSES.map((s) => s.value) +); + +export function isPaymentStatus(value: unknown): value is PaymentStatus { + return typeof value === "string" && PAYMENT_STATUS_VALUES.has(value); +} + +export function normalizePaymentStatus(value: unknown): PaymentStatus { + return isPaymentStatus(value) ? value : "created"; +} + +export function paymentStatusLabel(value: unknown): string { + const status = normalizePaymentStatus(value); + return PAYMENT_STATUSES.find((s) => s.value === status)?.label ?? status; +} + +// A live Checkout Session exists and the customer can still pay on it. +export function isPaymentPayable(status: PaymentStatus): boolean { + return status === "awaiting_payment"; +} + +// Staff may cancel only while no money is moving: the record exists and a +// session may or may not have been issued yet. +export function isPaymentCancelable(status: PaymentStatus): boolean { + return status === "created" || status === "awaiting_payment"; +} + +// Statuses a Stripe transition may never overwrite. `paid` is also +// absorbing for Stripe events but can later be superseded by refund states +// (future work) — refunds are deliberately not in this model yet. +function isStripeTerminal(status: PaymentStatus): boolean { + return status === "paid" || status === "expired" || status === "canceled"; +} + +// --- Records / views --- + +export interface PaymentRecord { + purpose?: string; + description?: string; + amountMinor?: number; + currency?: string; + customerName?: string; + customerEmail?: string; + tourDate?: string; + attendeeCount?: number; + internalNote?: string; + status?: string; + livemode?: boolean; + eventCount?: number; + createdByUid?: string; + createdByName?: string; + createdAt?: unknown; + updatedAt?: unknown; + stripeCheckoutSessionId?: string; + stripeSessionUrl?: string; + stripePaymentIntentId?: string; + stripeChargeId?: string; + stripeCustomerId?: string; + sessionExpiresAt?: unknown; + receiptUrl?: string; + paymentMethodBrand?: string; + paymentMethodLast4?: string; + paidAt?: unknown; + failedAt?: unknown; + expiredAt?: unknown; + canceledAt?: unknown; + failureMessage?: string; +} + +// Serialized view returned to the admin client — ISO strings, no +// Firestore-shaped values cross the API boundary. +export interface PaymentView { + id: string; + purpose: string; + description: string; + amountMinor: number; + currency: string; + customerName: string; + customerEmail?: string; + tourDate?: string; + attendeeCount?: number; + internalNote?: string; + status: PaymentStatus; + livemode: boolean; + createdByUid: string; + createdByName: string; + createdAt?: string; + updatedAt?: string; + stripeCheckoutSessionId?: string; + stripeSessionUrl?: string; + stripePaymentIntentId?: string; + stripeChargeId?: string; + stripeCustomerId?: string; + sessionExpiresAt?: string; + receiptUrl?: string; + paymentMethodBrand?: string; + paymentMethodLast4?: string; + paidAt?: string; + failedAt?: string; + expiredAt?: string; + canceledAt?: string; + failureMessage?: string; +} + +export function serializePayment( + id: string, + data: Record +): PaymentView { + const str = (key: string) => { + const v = data[key]; + return typeof v === "string" ? v : ""; + }; + const optStr = (key: string) => { + const v = data[key]; + return typeof v === "string" && v ? v : undefined; + }; + return { + id, + purpose: str("purpose") || "other", + description: str("description"), + amountMinor: + typeof data.amountMinor === "number" ? data.amountMinor : 0, + currency: str("currency") || PAYMENT_CURRENCY, + customerName: str("customerName"), + customerEmail: optStr("customerEmail"), + tourDate: optStr("tourDate"), + attendeeCount: + typeof data.attendeeCount === "number" ? data.attendeeCount : undefined, + internalNote: optStr("internalNote"), + status: normalizePaymentStatus(data.status), + livemode: data.livemode === true, + createdByUid: str("createdByUid"), + createdByName: str("createdByName"), + createdAt: toIsoString(data.createdAt), + updatedAt: toIsoString(data.updatedAt), + stripeCheckoutSessionId: optStr("stripeCheckoutSessionId"), + stripeSessionUrl: optStr("stripeSessionUrl"), + stripePaymentIntentId: optStr("stripePaymentIntentId"), + stripeChargeId: optStr("stripeChargeId"), + stripeCustomerId: optStr("stripeCustomerId"), + sessionExpiresAt: toIsoString(data.sessionExpiresAt), + receiptUrl: optStr("receiptUrl"), + paymentMethodBrand: optStr("paymentMethodBrand"), + paymentMethodLast4: optStr("paymentMethodLast4"), + paidAt: toIsoString(data.paidAt), + failedAt: toIsoString(data.failedAt), + expiredAt: toIsoString(data.expiredAt), + canceledAt: toIsoString(data.canceledAt), + failureMessage: optStr("failureMessage"), + }; +} + +// --- Payment events (audit trail) --- + +export const PAYMENT_EVENT_TYPES = [ + "payment_created", + "checkout_session_created", + "payment_processing", + "payment_succeeded", + "payment_failed", + "session_expired", + "payment_canceled", +] as const; + +export type PaymentEventType = (typeof PAYMENT_EVENT_TYPES)[number]; + +export interface PaymentEventDraft { + type: PaymentEventType; + details?: Record; +} + +export interface PaymentEventView { + id: string; + type: string; + seq?: number; + actorUid?: string; + actorName?: string; + details?: Record; + createdAt?: string; +} + +export function serializePaymentEvent( + id: string, + data: Record +): PaymentEventView { + const optStr = (key: string) => { + const v = data[key]; + return typeof v === "string" && v ? v : undefined; + }; + const details = data.details; + return { + id, + type: optStr("type") ?? "payment_created", + seq: typeof data.seq === "number" ? data.seq : undefined, + actorUid: optStr("actorUid"), + actorName: optStr("actorName"), + details: + details && typeof details === "object" && !Array.isArray(details) + ? (details as Record) + : undefined, + createdAt: toIsoString(data.createdAt), + }; +} + +// One readable line per history entry for the detail timeline. +export function describePaymentEvent( + event: Pick +): string { + const details = event.details ?? {}; + const detailStr = (key: string) => + typeof details[key] === "string" ? (details[key] as string) : null; + const source = detailStr("source") === "manual_refresh" ? " (manual refresh)" : ""; + switch (event.type) { + case "payment_created": + return "Payment created"; + case "checkout_session_created": + return "Payment link created"; + case "payment_processing": + return `Payment submitted — waiting for the bank to confirm${source}`; + case "payment_succeeded": + return `Payment succeeded${source}`; + case "payment_failed": { + const msg = detailStr("message"); + return msg ? `Payment failed — ${msg}` : `Payment failed${source}`; + } + case "session_expired": + return `Payment link expired${source}`; + case "payment_canceled": + return "Payment canceled by staff"; + default: + return event.type; + } +} + +// --- Creation input validation --- + +export class PaymentError extends Error { + public readonly clientSafe = true; + constructor( + message: string, + public status: number = 400 + ) { + super(message); + } +} + +export class PaymentNotFoundError extends PaymentError { + constructor() { + super("Payment not found.", 404); + } +} + +export const PAYMENT_FIELD_LIMITS = { + customerName: 200, + customerEmail: 320, + description: 250, + internalNote: 1000, +} as const; + +export const PAYMENT_MAX_ATTENDEES = 200; +// A tour date is a calendar-day hint for reconciliation, not a booking — +// allow generous slack in either direction (a charge can follow the tour). +const TOUR_DATE_MAX_YEARS_AWAY = 2; + +// The idempotency handle the browser generates once per "take payment" +// session. It is the payment's document id, so a retried POST can never +// create a second charge for the same logical action. +export const CLIENT_REQUEST_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export interface PaymentCreateInput { + clientRequestId: string; + purpose: string; + description: string; + amountMinor: number; + customerName: string; + customerEmail?: string; + tourDate?: string; + attendeeCount?: number; + internalNote?: string; +} + +export function parsePaymentCreateBody( + body: unknown +): { ok: true; input: PaymentCreateInput } | { ok: false; error: string } { + if (!body || typeof body !== "object" || Array.isArray(body)) { + return { ok: false, error: "Invalid request body." }; + } + const raw = body as Record; + const str = (key: string) => + typeof raw[key] === "string" ? (raw[key] as string).trim() : ""; + + const clientRequestId = str("clientRequestId"); + if (!CLIENT_REQUEST_ID_PATTERN.test(clientRequestId)) { + return { ok: false, error: "Missing or invalid payment request id." }; + } + + const purpose = str("purpose"); + if (!isPaymentPurpose(purpose)) { + return { ok: false, error: "Choose a payment purpose." }; + } + + const description = str("description"); + if (!description) { + return { ok: false, error: "Description is required." }; + } + if (description.length > PAYMENT_FIELD_LIMITS.description) { + return { ok: false, error: "Description is too long." }; + } + + const amount = parseAmountMinor(raw.amount); + if (!amount.ok) return amount; + + const customerName = str("customerName"); + if (!customerName) { + return { ok: false, error: "Customer name is required." }; + } + if (customerName.length > PAYMENT_FIELD_LIMITS.customerName) { + return { ok: false, error: "Customer name is too long." }; + } + + const customerEmail = str("customerEmail"); + if (customerEmail) { + if (!isValidEmail(customerEmail)) { + return { ok: false, error: "Enter a valid email address." }; + } + if (customerEmail.length > PAYMENT_FIELD_LIMITS.customerEmail) { + return { ok: false, error: "Email is too long." }; + } + } + + const internalNote = str("internalNote"); + if (internalNote.length > PAYMENT_FIELD_LIMITS.internalNote) { + return { ok: false, error: "Internal note is too long." }; + } + + let tourDate: string | undefined; + const tourDateRaw = str("tourDate"); + if (tourDateRaw) { + const date = parseCalendarDate(tourDateRaw); + if (!date) { + return { ok: false, error: "Tour date must be a valid date." }; + } + const today = new Date(); + const earliest = today.getFullYear() - TOUR_DATE_MAX_YEARS_AWAY; + const latest = today.getFullYear() + TOUR_DATE_MAX_YEARS_AWAY; + if (date.year < earliest || date.year > latest) { + return { ok: false, error: "Tour date is outside the allowed range." }; + } + // Canonical re-serialization keeps storage strictly YYYY-MM-DD. + const pad = (n: number) => String(n).padStart(2, "0"); + tourDate = `${date.year}-${pad(date.month)}-${pad(date.day)}`; + } + + let attendeeCount: number | undefined; + if (raw.attendeeCount !== undefined && raw.attendeeCount !== null && raw.attendeeCount !== "") { + const count = Number(raw.attendeeCount); + if (!Number.isInteger(count) || count < 1 || count > PAYMENT_MAX_ATTENDEES) { + return { + ok: false, + error: `Attendee count must be a whole number between 1 and ${PAYMENT_MAX_ATTENDEES}.`, + }; + } + attendeeCount = count; + } + + return { + ok: true, + input: { + clientRequestId, + purpose, + description, + amountMinor: amount.minor, + customerName, + customerEmail: customerEmail || undefined, + tourDate, + attendeeCount, + internalNote: internalNote || undefined, + }, + }; +} + +// --- Checkout Session construction --- + +// Minimal structural shape of the Stripe SessionCreateParams we build — +// the server layer casts it onto the SDK type. Declared here (without the +// Stripe import) so the exact payload sent to Stripe is unit-testable. +export interface CheckoutSessionSpec { + mode: "payment"; + client_reference_id: string; + customer_email?: string; + line_items: { + quantity: number; + price_data: { + currency: string; + unit_amount: number; + product_data: { name: string }; + }; + }[]; + metadata: Record; + payment_intent_data: { + metadata: Record; + description: string; + }; + success_url: string; + cancel_url: string; +} + +// Builds the hosted Checkout Session parameters from the *stored* payment +// record — never from browser-submitted totals. Metadata carries only the +// internal id + purpose: the id is the canonical correlation handle and no +// customer PII belongs in Stripe metadata. +export function buildCheckoutSessionSpec(args: { + paymentId: string; + purpose: string; + description: string; + amountMinor: number; + customerEmail?: string; + successUrl: string; + cancelUrl: string; +}): CheckoutSessionSpec { + return { + mode: "payment", + client_reference_id: args.paymentId, + ...(args.customerEmail ? { customer_email: args.customerEmail } : {}), + line_items: [ + { + quantity: 1, + price_data: { + currency: PAYMENT_CURRENCY, + unit_amount: args.amountMinor, + product_data: { name: args.description }, + }, + }, + ], + metadata: { paymentId: args.paymentId, purpose: args.purpose }, + payment_intent_data: { + metadata: { paymentId: args.paymentId, purpose: args.purpose }, + description: args.description, + }, + success_url: args.successUrl, + cancel_url: args.cancelUrl, + }; +} + +// --- Stripe event → internal outcome --- + +export type StripeTransition = "paid" | "processing" | "failed" | "expired"; + +// What a Stripe-side object state means for our record. The webhook and the +// manual "refresh status" action both produce this shape, so reconciliation +// follows one path regardless of how the state arrived. +export interface StripeOutcome { + transition: StripeTransition; + sessionId?: string; + paymentIntentId?: string; + stripeCustomerId?: string; + sessionExpiresAtMillis?: number; + failureMessage?: string; + // Additional safe fields merged into the document when the transition + // applies — e.g. receipt URL / card brand+last4 fetched from the + // PaymentIntent on a `paid` transition (see enrichmentFromPaymentIntent). + extraUpdates?: Record; +} + +export interface StripeEventOutcome { + eventId: string; + eventType: string; + paymentId: string | null; + outcome?: StripeOutcome; + // Why the event does not map to a transition (unhandled type, no payment + // reference). Still dedupe-marked so replays stay quiet. + ignoredReason?: string; +} + +// Stripe object fields are read defensively — ids may arrive as strings or +// expanded objects depending on the API surface. +function stripeIdOf(value: unknown): string | undefined { + if (typeof value === "string" && value) return value; + if ( + value && + typeof value === "object" && + typeof (value as { id?: unknown }).id === "string" + ) { + return (value as { id: string }).id; + } + return undefined; +} + +// Maps a checkout.session-shaped object to the internal outcome. Shared by +// the webhook (event.data.object) and the refresh action (retrieved session) +// so both reconcile identically. +export function outcomeFromSession( + session: Record, + transition: StripeTransition, + failureMessage?: string +): { paymentId: string | null; outcome: StripeOutcome } { + const metadata = session.metadata; + const paymentId = + (metadata && + typeof metadata === "object" && + typeof (metadata as Record).paymentId === "string" + ? ((metadata as Record).paymentId as string) + : undefined) ?? + (typeof session.client_reference_id === "string" + ? session.client_reference_id + : undefined) ?? + null; + + const expiresAt = + typeof session.expires_at === "number" && session.expires_at > 0 + ? session.expires_at * 1000 + : undefined; + + return { + paymentId, + outcome: { + transition, + sessionId: stripeIdOf(session.id) ?? (typeof session.id === "string" ? session.id : undefined), + paymentIntentId: stripeIdOf(session.payment_intent), + stripeCustomerId: stripeIdOf(session.customer), + sessionExpiresAtMillis: expiresAt, + failureMessage, + }, + }; +} + +// Resolves a verified Stripe webhook event to the outcome it implies. +// Only the events we subscribe to produce transitions; everything else is +// acknowledged-and-ignored so stray subscriptions cannot corrupt state. +export function resolveStripeEventOutcome(event: { + id?: unknown; + type?: unknown; + data?: unknown; +}): StripeEventOutcome { + const eventId = typeof event.id === "string" ? event.id : ""; + const eventType = typeof event.type === "string" ? event.type : ""; + const session = + event.data && + typeof event.data === "object" && + (event.data as { object?: unknown }).object && + typeof (event.data as { object?: unknown }).object === "object" + ? ((event.data as { object: Record }) + .object as Record) + : {}; + + let transition: StripeTransition | null = null; + if (eventType === "checkout.session.completed") { + transition = + session.payment_status === "paid" ? "paid" : "processing"; + } else if (eventType === "checkout.session.async_payment_succeeded") { + transition = "paid"; + } else if (eventType === "checkout.session.async_payment_failed") { + transition = "failed"; + } else if (eventType === "checkout.session.expired") { + transition = "expired"; + } + + if (!transition) { + return { + eventId, + eventType, + paymentId: null, + ignoredReason: "unhandled_event_type", + }; + } + + const { paymentId, outcome } = outcomeFromSession(session, transition); + if (!paymentId) { + return { + eventId, + eventType, + paymentId: null, + ignoredReason: "no_payment_reference", + }; + } + return { eventId, eventType, paymentId, outcome }; +} + +// --- Transition planning --- + +export interface StripeApplyPlan { + apply: boolean; + updates: Record; + events: PaymentEventDraft[]; + // The status the plan would produce — for logging/response context. + toStatus?: PaymentStatus; +} + +// Pure state machine: given the stored record and a Stripe outcome, decide +// the field updates + audit events. Out-of-order and duplicate deliveries +// are absorbed here — a Stripe-terminal record never regresses, and a +// transition to the current status is a no-op. +export function planStripeEventApply( + record: PaymentRecord, + outcome: StripeOutcome, + source: "webhook" | "manual_refresh" = "webhook", + now: Date = new Date() +): StripeApplyPlan { + const current = normalizePaymentStatus(record.status); + const target = outcome.transition; + const sourceDetail: Record = + source === "manual_refresh" ? { source } : {}; + + // Statuses each transition may legitimately overwrite. `paid` wins over + // everything except an existing `paid` — money captured is authoritative. + const allowed: Record> = { + paid: new Set([ + "created", + "awaiting_payment", + "processing", + "failed", + "expired", + "canceled", + ]), + processing: new Set(["created", "awaiting_payment", "failed"]), + failed: new Set([ + "created", + "awaiting_payment", + "processing", + ]), + // `canceled` outranks `expired`: staff canceled the link before Stripe + // reported the session expired, and both mean "cannot be paid". + expired: new Set(["created", "awaiting_payment", "failed"]), + }; + + if (isStripeTerminal(current) && target !== "paid") { + return { apply: false, updates: {}, events: [] }; + } + if (!allowed[target].has(current)) { + return { apply: false, updates: {}, events: [] }; + } + + const updates: Record = { status: target, updatedAt: now }; + const events: PaymentEventDraft[] = []; + + if (outcome.sessionId && !record.stripeCheckoutSessionId) { + updates.stripeCheckoutSessionId = outcome.sessionId; + } + if (outcome.paymentIntentId && record.stripePaymentIntentId !== outcome.paymentIntentId) { + updates.stripePaymentIntentId = outcome.paymentIntentId; + } + if (outcome.stripeCustomerId && !record.stripeCustomerId) { + updates.stripeCustomerId = outcome.stripeCustomerId; + } + if (outcome.sessionExpiresAtMillis && !record.sessionExpiresAt) { + updates.sessionExpiresAt = new Date(outcome.sessionExpiresAtMillis); + } + if (outcome.extraUpdates) { + Object.assign(updates, outcome.extraUpdates); + } + + switch (target) { + case "paid": + updates.paidAt = now; + events.push({ type: "payment_succeeded", details: sourceDetail }); + break; + case "processing": + events.push({ type: "payment_processing", details: sourceDetail }); + break; + case "failed": + updates.failedAt = now; + if (outcome.failureMessage) { + updates.failureMessage = outcome.failureMessage; + } + events.push({ + type: "payment_failed", + details: { + ...sourceDetail, + ...(outcome.failureMessage + ? { message: outcome.failureMessage } + : {}), + }, + }); + break; + case "expired": + updates.expiredAt = now; + events.push({ type: "session_expired", details: sourceDetail }); + break; + } + + return { apply: true, updates, events, toStatus: target }; +} + +// --- Payment enrichment (receipt/card display data) --- + +// Safe display metadata extracted from a retrieved PaymentIntent's latest +// charge. Only brand/last4 + receipt URL are stored — Stripe's own hosted +// receipt page; never PAN/CVC or raw payloads. +export function enrichmentFromPaymentIntent( + intent: Record +): Record { + const updates: Record = {}; + const charge = intent.latest_charge; + if (charge && typeof charge === "object") { + const c = charge as Record; + const chargeId = stripeIdOf(c.id); + if (chargeId) updates.stripeChargeId = chargeId; + if (typeof c.receipt_url === "string" && c.receipt_url) { + updates.receiptUrl = c.receipt_url; + } + const details = c.payment_method_details; + if (details && typeof details === "object") { + const card = (details as Record).card; + if (card && typeof card === "object") { + const cardObj = card as Record; + if (typeof cardObj.brand === "string" && cardObj.brand) { + updates.paymentMethodBrand = cardObj.brand; + } + if (typeof cardObj.last4 === "string" && cardObj.last4) { + updates.paymentMethodLast4 = cardObj.last4; + } + } + } + } + return updates; +} + +// --- Webhook orchestration (injected store for testability) --- + +// The transaction surface the pure orchestrator needs. The server +// implementation (lib/payments-admin.ts) backs it with a Firestore +// transaction — reads-then-writes order is preserved by construction. +export interface StripeEventTx { + stripeEventProcessed(eventId: string): Promise; + getPayment(paymentId: string): Promise | null>; + updatePayment( + paymentId: string, + updates: Record, + events: PaymentEventDraft[] + ): Promise; + markStripeEventProcessed( + eventId: string, + result: "applied" | "ignored" | "unknown_payment", + meta: { eventType: string; paymentId: string | null } + ): Promise; +} + +export interface StripeEventStore { + transact(work: (tx: StripeEventTx) => Promise): Promise; +} + +export interface StripeEventResult { + status: "applied" | "duplicate" | "ignored" | "unknown_payment"; + paymentId: string | null; + toStatus?: PaymentStatus; +} + +// Single delivery path for verified webhook events: dedupe → load record → +// plan → apply → mark. Idempotent end to end: a replayed event finds its +// marker and exits before touching the payment. +export async function processStripeEvent( + resolved: StripeEventOutcome, + store: StripeEventStore +): Promise { + return store.transact(async (tx) => { + if (!resolved.eventId) { + return { status: "ignored", paymentId: resolved.paymentId }; + } + if (await tx.stripeEventProcessed(resolved.eventId)) { + return { status: "duplicate", paymentId: resolved.paymentId }; + } + + const meta = { eventType: resolved.eventType, paymentId: resolved.paymentId }; + + if (!resolved.paymentId || !resolved.outcome) { + await tx.markStripeEventProcessed(resolved.eventId, "ignored", meta); + return { status: "ignored", paymentId: resolved.paymentId }; + } + + const record = await tx.getPayment(resolved.paymentId); + if (!record) { + // The event belongs to some other Stripe object (or a deleted record) + // — acknowledge it so Stripe does not retry forever. + await tx.markStripeEventProcessed( + resolved.eventId, + "unknown_payment", + meta + ); + return { status: "unknown_payment", paymentId: resolved.paymentId }; + } + + const plan = planStripeEventApply(record, resolved.outcome, "webhook"); + if (plan.apply) { + await tx.updatePayment(resolved.paymentId, plan.updates, plan.events); + } + await tx.markStripeEventProcessed( + resolved.eventId, + plan.apply ? "applied" : "ignored", + meta + ); + return { + status: plan.apply ? "applied" : "ignored", + paymentId: resolved.paymentId, + toStatus: plan.toStatus, + }; + }); +} diff --git a/lib/stripe-config.ts b/lib/stripe-config.ts new file mode 100644 index 0000000..d338b8b --- /dev/null +++ b/lib/stripe-config.ts @@ -0,0 +1,37 @@ +// Stripe environment configuration. Pure env reads — no SDK import — so +// config validation stays unit-testable and free of the server-only +// dependency chain. Only server modules (lib/stripe.ts, the payments API +// routes, the webhook route) import these; the secret key must never reach +// a client component or a NEXT_PUBLIC_* variable. + +export function getStripeSecretKey(): string { + const key = process.env.STRIPE_SECRET_KEY?.trim(); + if (!key) { + throw new Error( + "Missing Stripe credentials. Set STRIPE_SECRET_KEY (server-only)." + ); + } + return key; +} + +// Webhook signing secret is required at the verification boundary — the +// route refuses to run without it rather than accepting unsigned events. +export function getStripeWebhookSecret(): string { + const secret = process.env.STRIPE_WEBHOOK_SECRET?.trim(); + if (!secret) { + throw new Error( + "Missing Stripe webhook secret. Set STRIPE_WEBHOOK_SECRET (server-only)." + ); + } + return secret; +} + +// Test/live mode is derived from the configured secret key so the admin UI +// can badge "test mode" without any key material leaving the server. Live +// Stripe objects also carry `livemode` — the stored record keeps the +// authoritative per-payment flag. +export function isStripeTestMode( + secretKey: string = getStripeSecretKey() +): boolean { + return secretKey.startsWith("sk_test_") || secretKey.startsWith("rk_test_"); +} diff --git a/lib/stripe.ts b/lib/stripe.ts new file mode 100644 index 0000000..7153231 --- /dev/null +++ b/lib/stripe.ts @@ -0,0 +1,13 @@ +import "server-only"; +import Stripe from "stripe"; +import { getStripeSecretKey } from "@/lib/stripe-config"; + +let stripeClient: Stripe | undefined; + +// Lazily construct and cache the Stripe client so importing modules never +// require STRIPE_SECRET_KEY at module evaluation (e.g. during `next build` +// page-data collection). The key is validated here, at the API boundary. +export function getStripeClient(): Stripe { + stripeClient ??= new Stripe(getStripeSecretKey()); + return stripeClient; +} diff --git a/next.config.ts b/next.config.ts index bc39b58..4ca0733 100644 --- a/next.config.ts +++ b/next.config.ts @@ -45,7 +45,8 @@ const nextConfig: NextConfig = { // jwks-rsa -> jose, and bundling the Node-specific chain into a // serverless chunk caused a Vercel runtime outage class (ERR_REQUIRE_ESM) // in other projects. Load the real node_modules at runtime instead. - serverExternalPackages: ["firebase-admin"], + // `stripe` rides along for the same reason — a Node-only server SDK. + serverExternalPackages: ["firebase-admin", "stripe"], images: { formats: ["image/avif", "image/webp"], deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], diff --git a/package-lock.json b/package-lock.json index 357b380..4bbf682 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,10 +21,12 @@ "firebase": "^12.9.0", "lucide-react": "^0.563.0", "next": "^16.3.5", + "qrcode": "^1.5.4", "radix-ui": "^1.6.7", "react": "19.3.0", "react-dom": "19.3.0", "resend": "^6.9.1", + "stripe": "^22.6.2", "tailwind-merge": "^3.4.0" }, "devDependencies": { @@ -32,6 +34,7 @@ "@firebase/rules-unit-testing": "5.0.2", "@tailwindcss/postcss": "^4.3.3", "@types/node": "^24", + "@types/qrcode": "^1.5.6", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", @@ -8508,6 +8511,16 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.13", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz", @@ -11871,6 +11884,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -12132,6 +12154,12 @@ "node": ">=0.3.1" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/discontinuous-range": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", @@ -19989,6 +20017,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pac-proxy-agent": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", @@ -20392,6 +20429,15 @@ "node": ">=20" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/portfinder": { "version": "1.0.38", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", @@ -20753,6 +20799,177 @@ "node": ">=8" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.16.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", @@ -21373,6 +21590,12 @@ "node": ">=9.3.0 || >=8.10.0 <9.0.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resend": { "version": "6.17.2", "resolved": "https://registry.npmjs.org/resend/-/resend-6.17.2.tgz", @@ -21913,6 +22136,12 @@ "dev": true, "license": "MIT" }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -22823,6 +23052,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "22.6.2", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.6.2.tgz", + "integrity": "sha512-PwRE2scocvXqDKPiskMa5vRJEAEkq46W69XyUYmAwrNSLr675lQnfqLwnsndAWizwgihOWw/irLmoFRo3ez7ew==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/strnum": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz", @@ -24427,6 +24673,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.20", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", @@ -24589,7 +24841,6 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -24668,7 +24919,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -24678,14 +24928,12 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/wrap-ansi/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -24700,7 +24948,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" diff --git a/package.json b/package.json index a965cef..98a3054 100644 --- a/package.json +++ b/package.json @@ -36,10 +36,12 @@ "firebase": "^12.9.0", "lucide-react": "^0.563.0", "next": "^16.3.5", + "qrcode": "^1.5.4", "radix-ui": "^1.6.7", "react": "19.3.0", "react-dom": "19.3.0", "resend": "^6.9.1", + "stripe": "^22.6.2", "tailwind-merge": "^3.4.0" }, "devDependencies": { @@ -47,6 +49,7 @@ "@firebase/rules-unit-testing": "5.0.2", "@tailwindcss/postcss": "^4.3.3", "@types/node": "^24", + "@types/qrcode": "^1.5.6", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", diff --git a/rules-tests/firestore.rules.test.ts b/rules-tests/firestore.rules.test.ts index c3efbe3..1b20c5e 100644 --- a/rules-tests/firestore.rules.test.ts +++ b/rules-tests/firestore.rules.test.ts @@ -409,3 +409,98 @@ describe("tradeLeads denies all client access", () => { ); }); }); + +const validPayment = { + purpose: "brewery_tour", + description: "Brewery Tour", + amountMinor: 4000, + currency: "usd", + customerName: "Test Customer", + status: "awaiting_payment", + createdAt: 0, + updatedAt: 0, +}; + +describe("payments deny all client access (Issue #155)", () => { + it("denies unauthenticated reads and writes", async () => { + const db = testEnv.unauthenticatedContext().firestore(); + await assertFails(setDoc(doc(db, "payments", "p1"), validPayment)); + await assertFails(getDoc(doc(db, "payments", "p1"))); + await assertFails(getDocs(collection(db, "payments"))); + }); + + it("denies authenticated non-admin access", async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), "payments", "p1"), validPayment); + }); + const db = testEnv.authenticatedContext("visitor1").firestore(); + await assertFails(getDoc(doc(db, "payments", "p1"))); + await assertFails(setDoc(doc(db, "payments", "p2"), validPayment)); + }); + + it("denies even active admins — payments are server-only via the Admin SDK", async () => { + await seedAdminRecord("admin1", "admin", "active"); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), "payments", "p1"), validPayment); + }); + const db = adminContext("admin1", "admin").firestore(); + await assertFails(getDoc(doc(db, "payments", "p1"))); + await assertFails(setDoc(doc(db, "payments", "p2"), validPayment)); + await assertFails( + updateDoc(doc(db, "payments", "p1"), { status: "paid" }) + ); + await assertFails(deleteDoc(doc(db, "payments", "p1"))); + }); + + it("denies all client access to the payment events subcollection", async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), "payments", "p1"), validPayment); + await setDoc( + doc(ctx.firestore(), "payments", "p1", "events", "e1"), + { type: "payment_created", seq: 0 } + ); + }); + + const anonDb = testEnv.unauthenticatedContext().firestore(); + await assertFails(getDoc(doc(anonDb, "payments", "p1", "events", "e1"))); + await assertFails( + getDocs(collection(anonDb, "payments", "p1", "events")) + ); + await assertFails( + addDoc(collection(anonDb, "payments", "p1", "events"), { + type: "payment_created", + }) + ); + + await seedAdminRecord("admin1", "admin", "active"); + const db = adminContext("admin1", "admin").firestore(); + await assertFails(getDoc(doc(db, "payments", "p1", "events", "e1"))); + await assertFails( + addDoc(collection(db, "payments", "p1", "events"), { + type: "payment_created", + }) + ); + }); + + it("denies all client access to stripeEvents dedupe markers", async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), "stripeEvents", "evt_1"), { + result: "applied", + }); + }); + + const anonDb = testEnv.unauthenticatedContext().firestore(); + await assertFails(getDoc(doc(anonDb, "stripeEvents", "evt_1"))); + await assertFails( + setDoc(doc(anonDb, "stripeEvents", "evt_2"), { result: "applied" }) + ); + + await seedAdminRecord("admin1", "admin", "active"); + const db = adminContext("admin1", "admin").firestore(); + await assertFails(getDoc(doc(db, "stripeEvents", "evt_1"))); + await assertFails( + setDoc(doc(db, "stripeEvents", "evt_2"), { result: "applied" }) + ); + await assertFails(deleteDoc(doc(db, "stripeEvents", "evt_1"))); + }); +}); diff --git a/scripts/check-admin-import.mjs b/scripts/check-admin-import.mjs index b468149..a28a788 100644 --- a/scripts/check-admin-import.mjs +++ b/scripts/check-admin-import.mjs @@ -16,6 +16,9 @@ const entrypoints = { "firebase-admin/app": ["initializeApp", "cert", "getApps"], "firebase-admin/auth": ["getAuth"], "firebase-admin/firestore": ["getFirestore"], + // stripe (payments, #155): the class export is what lib/stripe.ts + // instantiates — a CJS/ESM packaging break would only surface at runtime. + stripe: ["default"], }; // firebase-admin's exports map does not expose ./package.json, so locate diff --git a/smoke-tests/admin-accessibility.spec.ts b/smoke-tests/admin-accessibility.spec.ts index 1a85c50..7625dba 100644 --- a/smoke-tests/admin-accessibility.spec.ts +++ b/smoke-tests/admin-accessibility.spec.ts @@ -546,3 +546,133 @@ test("trade-lead note entry is labeled and announces its result", async ({ page.getByRole("status").filter({ hasText: "Note added." }) ).toBeVisible(); }); + +// --- Payments workspace fixture (Issue #155) --- + +const PAYMENTS_FIXTURE = "/admin-payments-fixture"; + +const PAYMENT = { + id: "f47ac10b-58cc-4372-a567-0e02b2c3d479", + purpose: "brewery_tour_tasting", + description: "Brewery Tour + Tasting", + amountMinor: 8000, + currency: "usd", + customerName: "Dana Guest", + customerEmail: "dana@example.com", + tourDate: "2026-10-15", + attendeeCount: 2, + status: "awaiting_payment", + livemode: false, + createdByUid: "u-1", + createdByName: "Chad", + createdAt: "2026-10-03T12:00:00.000Z", + stripeCheckoutSessionId: "cs_test_fixture", + stripeSessionUrl: "https://checkout.stripe.com/c/pay/cs_test_fixture", + stripePaymentIntentId: "pi_fixture", +}; + +const PAYMENT_EVENTS = [ + { + id: "e-0", + type: "payment_created", + seq: 0, + actorUid: "u-1", + actorName: "Chad", + createdAt: "2026-10-03T12:00:00.000Z", + }, + { + id: "e-1", + type: "checkout_session_created", + seq: 1, + actorUid: "u-1", + actorName: "Chad", + createdAt: "2026-10-03T12:00:01.000Z", + }, +]; + +function mockPaymentsApi(page: import("playwright/test").Page) { + return page.route(/\/api\/admin\/payments/, (route) => { + const url = route.request().url(); + const method = route.request().method(); + const json = (body: unknown, status = 200) => + route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify(body), + }); + + if (method === "GET" && url.endsWith("/api/admin/payments")) { + return json({ ok: true, payments: [PAYMENT] }); + } + if (method === "GET" && url.endsWith("/qr")) { + return json({ ok: false, error: "QR not needed in fixture" }, 400); + } + if (method === "GET" && url.includes("/api/admin/payments/")) { + return json({ ok: true, payment: PAYMENT, events: PAYMENT_EVENTS }); + } + if (method === "POST" && url.endsWith("/api/admin/payments")) { + return json({ ok: true, payment: PAYMENT, replayed: false }); + } + if (method === "POST") { + return json({ ok: true, payment: PAYMENT, events: PAYMENT_EVENTS }); + } + return json({ ok: false, error: "Unexpected fixture request" }); + }); +} + +test("axe: payments workspace has no serious/critical violations", async ({ + page, +}) => { + await mockPaymentsApi(page); + await page.goto(PAYMENTS_FIXTURE); + await waitForAnimations(page); + await expect(page.getByText("Dana Guest")).toBeVisible(); + + let blocking = await scanAxe(page, `${PAYMENTS_FIXTURE} (form + list)`); + expect( + blocking, + formatBlocking(`${PAYMENTS_FIXTURE} (form + list)`, blocking) + ).toEqual([]); + + // Detail panel with the event history. + await page.getByRole("button", { name: /Dana Guest/ }).press("Enter"); + await expect(page.getByText("Payment link created")).toBeVisible(); + blocking = await scanAxe(page, `${PAYMENTS_FIXTURE} (detail)`); + expect( + blocking, + formatBlocking(`${PAYMENTS_FIXTURE} (detail)`, blocking) + ).toEqual([]); +}); + +test("take-payment form validates the amount before review", async ({ + page, +}) => { + await mockPaymentsApi(page); + await page.goto(PAYMENTS_FIXTURE); + await expect(page.getByText("Dana Guest")).toBeVisible(); + + await page.getByLabel("Customer name").fill("Fixture Person"); + await page.getByLabel("Amount (USD)").fill("0"); + await page.getByRole("button", { name: "Review payment" }).press("Enter"); + await expect( + page + .getByRole("alert") + .filter({ hasText: "Amount must be greater than zero." }) + ).toBeVisible(); +}); + +test("payment rows expose list semantics and the form is labeled", async ({ + page, +}) => { + await mockPaymentsApi(page); + await page.goto(PAYMENTS_FIXTURE); + const row = page.getByRole("button", { name: /Dana Guest/ }); + await expect(row).toBeVisible(); + expect(await row.evaluate((el) => el.closest("ul > li") !== null)).toBe( + true + ); + + await expect(page.getByLabel("Purpose")).toBeVisible(); + await expect(page.getByLabel(/Amount \(USD\)/)).toBeVisible(); + await expect(page.getByLabel(/Description/)).toBeVisible(); +}); diff --git a/smoke-tests/fixtures.ts b/smoke-tests/fixtures.ts index dee5933..59fbd15 100644 --- a/smoke-tests/fixtures.ts +++ b/smoke-tests/fixtures.ts @@ -81,6 +81,16 @@ export const test = base.extend({ }) ); + // Same for the payments summary card (#155): default to an empty list so + // unrelated specs stay hermetic. + await page.route(/\/api\/admin\/payments/, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ ok: true, payments: [] }), + }) + ); + page.on("pageerror", (err) => problems.push(`pageerror: ${err.message}`)); page.on("console", (msg) => { if ( diff --git a/tests/lib/payments-common.test.ts b/tests/lib/payments-common.test.ts new file mode 100644 index 0000000..fdf0faa --- /dev/null +++ b/tests/lib/payments-common.test.ts @@ -0,0 +1,562 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { + buildCheckoutSessionSpec, + describePaymentEvent, + enrichmentFromPaymentIntent, + formatUsdMinor, + isPaymentCancelable, + isPaymentPayable, + normalizePaymentStatus, + outcomeFromSession, + parseAmountMinor, + parsePaymentCreateBody, + paymentStatusLabel, + planStripeEventApply, + processStripeEvent, + resolveStripeEventOutcome, + serializePayment, + suggestedAmountMinor, + PAYMENT_MAX_AMOUNT_MINOR, + type StripeEventStore, + type StripeEventTx, +} from "@/lib/payments-common"; + +const VALID_ID = "f47ac10b-58cc-4372-a567-0e02b2c3d479"; + +const baseBody = { + clientRequestId: VALID_ID, + purpose: "brewery_tour_tasting", + description: "Brewery Tour + Tasting", + amount: "80.00", + customerName: "Jane Diver", +}; + +// --- Money --- + +describe("parseAmountMinor", () => { + it("accepts whole-dollar, cents, and $-prefixed strings", () => { + assert.deepStrictEqual(parseAmountMinor("40"), { ok: true, minor: 4000 }); + assert.deepStrictEqual(parseAmountMinor("40.00"), { ok: true, minor: 4000 }); + assert.deepStrictEqual(parseAmountMinor("12.50"), { ok: true, minor: 1250 }); + assert.deepStrictEqual(parseAmountMinor("$20"), { ok: true, minor: 2000 }); + assert.deepStrictEqual(parseAmountMinor(" 0.05 "), { ok: true, minor: 5 }); + }); + + it("accepts cent-exact numbers and rejects fractional cents", () => { + assert.deepStrictEqual(parseAmountMinor(40), { ok: true, minor: 4000 }); + assert.deepStrictEqual(parseAmountMinor(19.99), { ok: true, minor: 1999 }); + assert.strictEqual(parseAmountMinor(19.999).ok, false); + assert.strictEqual(parseAmountMinor(0.001).ok, false); + }); + + it("rejects zero, negative, malformed, and implausible amounts", () => { + for (const bad of ["0", "0.00", "-5", "-5.00", "abc", "", "1.234", "12.34.5", null, undefined, NaN, Infinity]) { + assert.strictEqual(parseAmountMinor(bad).ok, false, `expected ${String(bad)} rejected`); + } + // Exactly at the cap is allowed; one cent over is not. + assert.deepStrictEqual(parseAmountMinor("10000"), { + ok: true, + minor: PAYMENT_MAX_AMOUNT_MINOR, + }); + assert.strictEqual(parseAmountMinor("10000.01").ok, false); + assert.strictEqual(parseAmountMinor(10001).ok, false); + }); +}); + +describe("formatUsdMinor", () => { + it("formats minor units as USD", () => { + assert.strictEqual(formatUsdMinor(4000), "$40.00"); + assert.strictEqual(formatUsdMinor(1999), "$19.99"); + assert.strictEqual(formatUsdMinor(undefined), "—"); + }); +}); + +describe("suggestedAmountMinor", () => { + it("derives tour prices from canonical TOUR_PRODUCTS", () => { + assert.strictEqual(suggestedAmountMinor("brewery_tour", 2), 4000); + assert.strictEqual(suggestedAmountMinor("brewery_tour_tasting", 3), 12000); + }); + it("returns null without a per-person price or attendee count", () => { + assert.strictEqual(suggestedAmountMinor("private_tour", 4), null); + assert.strictEqual(suggestedAmountMinor("brewery_tour", undefined), null); + assert.strictEqual(suggestedAmountMinor("brewery_tour", 0), null); + }); +}); + +// --- Create-body validation --- + +describe("parsePaymentCreateBody", () => { + it("accepts a minimal valid body", () => { + const result = parsePaymentCreateBody(baseBody); + assert.strictEqual(result.ok, true); + if (result.ok) { + assert.strictEqual(result.input.amountMinor, 8000); + assert.strictEqual(result.input.purpose, "brewery_tour_tasting"); + assert.strictEqual(result.input.customerEmail, undefined); + } + }); + + it("accepts the full field set", () => { + const result = parsePaymentCreateBody({ + ...baseBody, + customerEmail: "jane@example.com", + tourDate: "2026-10-15", + attendeeCount: 4, + internalNote: "Cruise group", + }); + assert.strictEqual(result.ok, true); + if (result.ok) { + assert.strictEqual(result.input.tourDate, "2026-10-15"); + assert.strictEqual(result.input.attendeeCount, 4); + } + }); + + it("rejects a missing or malformed clientRequestId", () => { + for (const id of [undefined, "", "not-a-uuid", "12345"]) { + assert.strictEqual( + parsePaymentCreateBody({ ...baseBody, clientRequestId: id }).ok, + false, + `expected id ${String(id)} rejected` + ); + } + }); + + it("rejects unknown purposes", () => { + assert.strictEqual( + parsePaymentCreateBody({ ...baseBody, purpose: "free_beer" }).ok, + false + ); + }); + + it("rejects missing description, name, and bad amounts", () => { + assert.strictEqual( + parsePaymentCreateBody({ ...baseBody, description: " " }).ok, + false + ); + assert.strictEqual( + parsePaymentCreateBody({ ...baseBody, customerName: "" }).ok, + false + ); + assert.strictEqual( + parsePaymentCreateBody({ ...baseBody, amount: "-10" }).ok, + false + ); + assert.strictEqual( + parsePaymentCreateBody({ ...baseBody, amount: "99999999" }).ok, + false + ); + }); + + it("rejects malformed email and impossible tour dates", () => { + assert.strictEqual( + parsePaymentCreateBody({ ...baseBody, customerEmail: "not-an-email" }).ok, + false + ); + assert.strictEqual( + parsePaymentCreateBody({ ...baseBody, tourDate: "2026-02-30" }).ok, + false + ); + assert.strictEqual( + parsePaymentCreateBody({ ...baseBody, tourDate: "1999-01-01" }).ok, + false + ); + }); + + it("rejects non-integer or out-of-range attendee counts", () => { + for (const count of [0, -2, 1.5, 9999, "abc"]) { + assert.strictEqual( + parsePaymentCreateBody({ ...baseBody, attendeeCount: count }).ok, + false, + `expected count ${String(count)} rejected` + ); + } + assert.strictEqual( + parsePaymentCreateBody({ ...baseBody, attendeeCount: "6" }).ok, + true + ); + }); +}); + +// --- Checkout session spec --- + +describe("buildCheckoutSessionSpec", () => { + const spec = buildCheckoutSessionSpec({ + paymentId: VALID_ID, + purpose: "brewery_tour", + description: "Brewery Tour", + amountMinor: 2000, + customerEmail: "jane@example.com", + successUrl: "https://deepdivebrewing.com/pay/complete", + cancelUrl: "https://deepdivebrewing.com/pay/cancelled", + }); + + it("carries the trusted server-side amount and USD currency", () => { + assert.strictEqual(spec.mode, "payment"); + assert.strictEqual(spec.line_items[0].price_data.unit_amount, 2000); + assert.strictEqual(spec.line_items[0].price_data.currency, "usd"); + assert.strictEqual(spec.line_items[0].quantity, 1); + }); + + it("correlates by internal payment id without PII in metadata", () => { + assert.strictEqual(spec.client_reference_id, VALID_ID); + assert.strictEqual(spec.metadata.paymentId, VALID_ID); + assert.strictEqual(spec.payment_intent_data.metadata.paymentId, VALID_ID); + for (const value of Object.values(spec.metadata)) { + assert.ok(!value.includes("@"), "metadata must not carry PII"); + } + // Receipt email goes to the dedicated Stripe field, not metadata. + assert.strictEqual(spec.customer_email, "jane@example.com"); + }); +}); + +// --- Stripe event resolution --- + +const sessionObject = { + id: "cs_test_123", + object: "checkout.session", + client_reference_id: VALID_ID, + metadata: { paymentId: VALID_ID }, + payment_status: "paid", + status: "complete", + payment_intent: "pi_123", + customer: "cus_123", + expires_at: 1_800_000_000, +}; + +describe("resolveStripeEventOutcome", () => { + it("maps a paid checkout.session.completed to a paid outcome", () => { + const resolved = resolveStripeEventOutcome({ + id: "evt_1", + type: "checkout.session.completed", + data: { object: sessionObject }, + }); + assert.strictEqual(resolved.paymentId, VALID_ID); + assert.strictEqual(resolved.outcome?.transition, "paid"); + assert.strictEqual(resolved.outcome?.paymentIntentId, "pi_123"); + assert.strictEqual(resolved.outcome?.stripeCustomerId, "cus_123"); + assert.strictEqual( + resolved.outcome?.sessionExpiresAtMillis, + 1_800_000_000 * 1000 + ); + }); + + it("maps an unpaid completion to processing", () => { + const resolved = resolveStripeEventOutcome({ + id: "evt_2", + type: "checkout.session.completed", + data: { object: { ...sessionObject, payment_status: "unpaid" } }, + }); + assert.strictEqual(resolved.outcome?.transition, "processing"); + }); + + it("maps async outcomes and expiry", () => { + const succeeded = resolveStripeEventOutcome({ + id: "evt_3", + type: "checkout.session.async_payment_succeeded", + data: { object: sessionObject }, + }); + assert.strictEqual(succeeded.outcome?.transition, "paid"); + + const failed = resolveStripeEventOutcome({ + id: "evt_4", + type: "checkout.session.async_payment_failed", + data: { object: sessionObject }, + }); + assert.strictEqual(failed.outcome?.transition, "failed"); + + const expired = resolveStripeEventOutcome({ + id: "evt_5", + type: "checkout.session.expired", + data: { object: sessionObject }, + }); + assert.strictEqual(expired.outcome?.transition, "expired"); + }); + + it("ignores unhandled event types and sessions without a payment reference", () => { + const unhandled = resolveStripeEventOutcome({ + id: "evt_6", + type: "customer.created", + data: { object: sessionObject }, + }); + assert.strictEqual(unhandled.ignoredReason, "unhandled_event_type"); + + const noRef = resolveStripeEventOutcome({ + id: "evt_7", + type: "checkout.session.completed", + data: { + object: { ...sessionObject, client_reference_id: null, metadata: {} }, + }, + }); + assert.strictEqual(noRef.ignoredReason, "no_payment_reference"); + }); +}); + +// --- Transition planning --- + +describe("planStripeEventApply", () => { + const record = (status: string) => ({ + status, + stripeCheckoutSessionId: "cs_test_123", + eventCount: 2, + }); + const outcome = (transition: "paid" | "processing" | "failed" | "expired") => ({ + transition, + paymentIntentId: "pi_123", + }); + const NOW = new Date("2026-10-03T12:00:00.000Z"); + + it("advances awaiting_payment → paid with an audit event", () => { + const plan = planStripeEventApply(record("awaiting_payment"), outcome("paid"), "webhook", NOW); + assert.strictEqual(plan.apply, true); + assert.strictEqual(plan.updates.status, "paid"); + assert.strictEqual(plan.updates.paidAt, NOW); + assert.strictEqual(plan.events[0]?.type, "payment_succeeded"); + }); + + it("advances awaiting_payment → processing → paid across out-of-order-safe steps", () => { + const processing = planStripeEventApply(record("awaiting_payment"), outcome("processing"), "webhook", NOW); + assert.strictEqual(processing.apply, true); + assert.strictEqual(processing.updates.status, "processing"); + + const paid = planStripeEventApply(record("processing"), outcome("paid"), "webhook", NOW); + assert.strictEqual(paid.apply, true); + assert.strictEqual(paid.updates.status, "paid"); + }); + + it("marks failure from processing and expiry from awaiting", () => { + const failed = planStripeEventApply(record("processing"), outcome("failed"), "webhook", NOW); + assert.strictEqual(failed.updates.status, "failed"); + assert.strictEqual(failed.events[0]?.type, "payment_failed"); + + const expired = planStripeEventApply(record("awaiting_payment"), outcome("expired"), "webhook", NOW); + assert.strictEqual(expired.updates.status, "expired"); + assert.strictEqual(expired.events[0]?.type, "session_expired"); + }); + + it("never regresses Stripe-terminal records on late/duplicate events", () => { + // paid absorbs everything except a (harmless) repeated paid. + for (const t of ["processing", "failed", "expired"] as const) { + assert.strictEqual( + planStripeEventApply(record("paid"), outcome(t), "webhook", NOW).apply, + false, + `paid must not regress on ${t}` + ); + } + // expired and canceled are likewise terminal for Stripe events. + assert.strictEqual( + planStripeEventApply(record("expired"), outcome("failed"), "webhook", NOW).apply, + false + ); + // staff-canceled outranks a late session_expired. + assert.strictEqual( + planStripeEventApply(record("canceled"), outcome("expired"), "webhook", NOW).apply, + false + ); + // but a payment Stripe says was captured wins even over our cancel flag. + assert.strictEqual( + planStripeEventApply(record("canceled"), outcome("paid"), "webhook", NOW).apply, + true + ); + }); + + it("is a no-op when the transition does not advance the record", () => { + assert.strictEqual( + planStripeEventApply(record("paid"), outcome("paid"), "webhook", NOW).apply, + false + ); + assert.strictEqual( + planStripeEventApply(record("expired"), outcome("processing"), "webhook", NOW).apply, + false + ); + }); + + it("merges enrichment updates on apply", () => { + const plan = planStripeEventApply( + record("awaiting_payment"), + { ...outcome("paid"), extraUpdates: { receiptUrl: "https://pay.stripe.com/receipts/x" } }, + "webhook", + NOW + ); + assert.strictEqual(plan.updates.receiptUrl, "https://pay.stripe.com/receipts/x"); + }); +}); + +// --- Webhook orchestration --- + +// In-memory StripeEventStore — same closure shape as the Firestore-backed +// one in lib/payments-admin.ts, no emulator needed. +function fakeStore(seed?: Record>) { + const payments = new Map>( + Object.entries(seed ?? {}) + ); + const markers = new Map(); + const writes: { id: string; updates: Record }[] = []; + const store: StripeEventStore = { + transact: async (work: (tx: StripeEventTx) => Promise) => + work({ + stripeEventProcessed: async (id) => markers.has(id), + getPayment: async (id) => payments.get(id) ?? null, + updatePayment: async (id, updates, events) => { + writes.push({ id, updates }); + payments.set(id, { ...payments.get(id), ...updates }); + void events; + }, + markStripeEventProcessed: async (id, result) => { + markers.set(id, result); + }, + }), + }; + return { store, payments, markers, writes }; +} + +describe("processStripeEvent", () => { + const paidEvent = { + id: "evt_paid", + type: "checkout.session.completed", + data: { object: sessionObject }, + }; + + it("applies a valid event once and dedupes the replay", async () => { + const { store, payments, markers } = fakeStore({ + [VALID_ID]: { status: "awaiting_payment", eventCount: 2 }, + }); + const resolved = resolveStripeEventOutcome(paidEvent); + + const first = await processStripeEvent(resolved, store); + assert.strictEqual(first.status, "applied"); + assert.strictEqual(payments.get(VALID_ID)?.status, "paid"); + + const second = await processStripeEvent(resolved, store); + assert.strictEqual(second.status, "duplicate"); + assert.strictEqual(markers.get("evt_paid"), "applied"); + }); + + it("acknowledges events for unknown payments without touching records", async () => { + const { store, markers, writes } = fakeStore(); + const resolved = resolveStripeEventOutcome(paidEvent); + const result = await processStripeEvent(resolved, store); + assert.strictEqual(result.status, "unknown_payment"); + assert.strictEqual(writes.length, 0); + assert.strictEqual(markers.get("evt_paid"), "unknown_payment"); + }); + + it("acknowledges ignored event types without payment writes", async () => { + const { store, writes } = fakeStore({ + [VALID_ID]: { status: "awaiting_payment" }, + }); + const resolved = resolveStripeEventOutcome({ + id: "evt_other", + type: "customer.created", + data: { object: sessionObject }, + }); + const result = await processStripeEvent(resolved, store); + assert.strictEqual(result.status, "ignored"); + assert.strictEqual(writes.length, 0); + }); + + it("does not regress a paid record when a late expired event arrives", async () => { + const { store, payments } = fakeStore({ + [VALID_ID]: { status: "paid", eventCount: 3 }, + }); + const resolved = resolveStripeEventOutcome({ + id: "evt_expired", + type: "checkout.session.expired", + data: { object: sessionObject }, + }); + const result = await processStripeEvent(resolved, store); + assert.strictEqual(result.status, "ignored"); + assert.strictEqual(payments.get(VALID_ID)?.status, "paid"); + }); +}); + +// --- Serialization + misc --- + +describe("serializePayment", () => { + it("produces ISO-timestamped, defaulted views", () => { + const view = serializePayment(VALID_ID, { + purpose: "brewery_tour", + description: "Brewery Tour", + amountMinor: 4000, + currency: "usd", + customerName: "Jane", + status: "awaiting_payment", + livemode: false, + createdAt: { toDate: () => new Date("2026-10-03T10:00:00.000Z") }, + paidAt: 1_800_000_000_000, + }); + assert.strictEqual(view.status, "awaiting_payment"); + assert.strictEqual(view.livemode, false); + assert.strictEqual(view.createdAt, "2026-10-03T10:00:00.000Z"); + assert.strictEqual(view.paidAt, new Date(1_800_000_000_000).toISOString()); + assert.strictEqual(view.receiptUrl, undefined); + }); + + it("normalizes missing/unknown statuses", () => { + assert.strictEqual(normalizePaymentStatus(undefined), "created"); + assert.strictEqual(normalizePaymentStatus("garbage"), "created"); + assert.strictEqual(paymentStatusLabel("awaiting_payment"), "Awaiting payment"); + }); +}); + +describe("enrichmentFromPaymentIntent", () => { + it("extracts only safe display metadata", () => { + const updates = enrichmentFromPaymentIntent({ + id: "pi_123", + latest_charge: { + id: "ch_123", + receipt_url: "https://pay.stripe.com/receipts/x", + payment_method_details: { + card: { brand: "visa", last4: "4242" }, + }, + }, + }); + assert.strictEqual(updates.stripeChargeId, "ch_123"); + assert.strictEqual(updates.receiptUrl, "https://pay.stripe.com/receipts/x"); + assert.strictEqual(updates.paymentMethodBrand, "visa"); + assert.strictEqual(updates.paymentMethodLast4, "4242"); + // No PAN or raw payload fields are ever emitted. + assert.strictEqual(Object.keys(updates).length, 4); + }); +}); + +describe("status helpers", () => { + it("only awaiting_payment is payable; only created/awaiting are cancelable", () => { + assert.strictEqual(isPaymentPayable("awaiting_payment"), true); + assert.strictEqual(isPaymentPayable("paid"), false); + assert.strictEqual(isPaymentCancelable("created"), true); + assert.strictEqual(isPaymentCancelable("awaiting_payment"), true); + assert.strictEqual(isPaymentCancelable("paid"), false); + assert.strictEqual(isPaymentCancelable("expired"), false); + }); +}); + +describe("describePaymentEvent", () => { + it("renders readable lines", () => { + assert.strictEqual( + describePaymentEvent({ type: "payment_succeeded" }), + "Payment succeeded" + ); + assert.strictEqual( + describePaymentEvent({ + type: "payment_failed", + details: { message: "card declined" }, + }), + "Payment failed — card declined" + ); + assert.strictEqual( + describePaymentEvent({ type: "payment_canceled" }), + "Payment canceled by staff" + ); + }); +}); + +describe("outcomeFromSession", () => { + it("falls back to client_reference_id when metadata is absent", () => { + const { paymentId } = outcomeFromSession( + { ...sessionObject, metadata: null }, + "paid" + ); + assert.strictEqual(paymentId, VALID_ID); + }); +}); diff --git a/tests/security-rules.test.ts b/tests/security-rules.test.ts index 30f96e4..6b4605d 100644 --- a/tests/security-rules.test.ts +++ b/tests/security-rules.test.ts @@ -113,6 +113,36 @@ describe("firestore.rules", () => { "Expected the communications subcollection to deny all client access" ); }); + + it("keeps payments, their events, and stripeEvents fully server-side (Issue #155)", () => { + const paymentsStart = rules.indexOf("match /payments/{paymentId}"); + const stripeEventsStart = rules.indexOf("match /stripeEvents/{eventId}"); + const metaStart = rules.indexOf("match /meta/{docId}"); + assert.ok(paymentsStart > -1, "Expected the payments deny-all match"); + assert.ok( + stripeEventsStart > paymentsStart, + "Expected the stripeEvents match after payments" + ); + assert.ok(metaStart > stripeEventsStart, "Expected meta after stripeEvents"); + + const paymentsSection = rules.slice(paymentsStart, stripeEventsStart); + assert.ok( + paymentsSection.includes("allow read, write: if false"), + "Expected payments to deny all client access" + ); + const eventsStart = paymentsSection.indexOf("match /events/{eventId}"); + assert.ok(eventsStart > -1, "Expected the nested payments events match"); + assert.ok( + paymentsSection.slice(eventsStart).includes("allow read, write: if false"), + "Expected the events subcollection to deny all client access" + ); + + const stripeEventsSection = rules.slice(stripeEventsStart, metaStart); + assert.ok( + stripeEventsSection.includes("allow read, write: if false"), + "Expected stripeEvents to deny all client access" + ); + }); }); describe("storage.rules", () => { From e789e508ff12996792a49ad6922f7a50fa37c09c Mon Sep 17 00:00:00 2001 From: Chad Nuttall Date: Sat, 3 Oct 2026 09:54:33 -0400 Subject: [PATCH 02/11] fix(admin): address PR #156 review findings on payment state machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - failed records can no longer regress to processing (a completed, unpaid Checkout Session cannot be retried); covered for both webhook and manual-refresh paths - a replayed clientRequestId carrying a different payload now conflicts (409) instead of silently charging the stored amount — staff who edit the form after a lost response must start a fresh payment - manual refresh backfills receipt/card enrichment on paid records that missed it at webhook time (paid→paid never applies extraUpdates) - payment_intent_data.receipt_email set when a receipt email is provided, guaranteeing Stripe's receipt email rather than relying on Dashboard settings - test tour date derived from the current year (was hardcoded 2026) - docs: event-name table matches emitted fields; STRIPE_SECRET_KEY row scoped to operations that call getStripeClient() Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- docs/TECHNICAL.md | 2 +- docs/operations/observability.md | 4 +-- lib/payments-admin.ts | 44 ++++++++++++++++++++++++++++++- lib/payments-common.ts | 9 ++++++- tests/lib/payments-common.test.ts | 40 ++++++++++++++++++++++++++-- 5 files changed, 92 insertions(+), 7 deletions(-) diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md index f99a267..b0b9993 100644 --- a/docs/TECHNICAL.md +++ b/docs/TECHNICAL.md @@ -871,7 +871,7 @@ Names only — never commit values. Source of truth for names: | `FIREBASE_ADMIN_CLIENT_EMAIL` | Admin SDK credential | Yes for all `/api/admin/*` | | `FIREBASE_ADMIN_PRIVATE_KEY` | Admin SDK credential (PEM; stored with `\n` escapes) | Yes for all `/api/admin/*` | | `SUPER_ADMIN_EMAIL` | Bootstrap allowlist — the only email `admin/bootstrap` will promote | Yes for bootstrap | -| `STRIPE_SECRET_KEY` | Stripe secret key for the admin payments feature — Checkout Session create/expire/retrieve and PaymentIntent retrieve via lazy `getStripeClient()` (`sk_test_*` selects test mode) | Runtime only — required for `/api/admin/payments*` to function; build-safe otherwise | +| `STRIPE_SECRET_KEY` | Stripe secret key for the admin payments feature — Checkout Session create/expire/retrieve and PaymentIntent retrieve via lazy `getStripeClient()` (`sk_test_*` selects test mode) | Runtime only — required when an operation calls `getStripeClient()`; build-safe otherwise | | `STRIPE_WEBHOOK_SECRET` | Signing secret (`whsec_*`) for `/api/webhooks/stripe` signature verification | Runtime only — required for webhook processing; the route 500s without it rather than accept unsigned events | | `NEXT_PUBLIC_SENTRY_DSN` | Error-monitoring ingest DSN for the server + browser SDKs (`lib/monitoring-shared.ts`); inlined into the client bundle by design — not a credential | Optional — reporting is enabled only when this is set **and** the environment is Vercel Production (`VERCEL_ENV` server-side / `NEXT_PUBLIC_VERCEL_ENV` client-side); preview/dev/CI never emit events | | `SENTRY_ORG` | Sentry org slug for source-map upload (`withSentryConfig`) | Build time, Production scope — absent everywhere else | diff --git a/docs/operations/observability.md b/docs/operations/observability.md index 51a0a77..bcf39b7 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -242,12 +242,12 @@ emits **single-line JSON** so Vercel's log search can filter reliably: | `admin_invitation.{accept_rollback,accept,resend}_failed` | Invitation lifecycle failures | | `admin_invitation_email.{misconfigured,send_rejected,send_exception}` | Invitation email send-path failures (`missing` names the unset var) | | `admin_bootstrap.failed` / `admin_me.unexpected` | Bootstrap and identity-probe failures | -| `payment.created` | Internal payment record + Stripe Checkout Session created (`paymentId`, `amountMinor` — no customer data) | +| `payment.created` | Internal payment record + Stripe Checkout Session created (`paymentId`, `replayed`, `requestId` — no customer data) | | `payment.canceled` / `payment.refreshed` | Staff canceled an unpaid payment, or a manual status refresh applied a state change | | `payment.{create,get,cancel,refresh,qr}_failed` / `payments.list_failed` | Unhandled errors in the payments API routes | | `payment.enrichment_failed` | Paid-payment receipt/card-brand enrichment from Stripe failed — the payment stays `paid`; only display metadata is missing (warn) | | `payment.refresh_id_mismatch` | A Stripe session retrieved during refresh had the wrong `paymentId` — treated as a defensive anomaly, not an update (warn) | -| `stripe_webhook.processed` | Signed Stripe event applied to a payment (`eventType`, `paymentId`) | +| `stripe_webhook.processed` | Signed Stripe event handled (`eventType`, `paymentId`, `result` — `applied`, `duplicate`, `ignored`, or `unknown_payment`) | | `stripe_webhook.unknown_payment` | Signed event referenced a `paymentId` with no record — acked 200 so Stripe does not retry it forever (warn) | | `stripe_webhook.bad_signature` | Signature verification failed — rejected 400 (warn, security-relevant) | | `stripe_webhook.processing_failed` | Unhandled webhook error — 500 returned so Stripe retries delivery | diff --git a/lib/payments-admin.ts b/lib/payments-admin.ts index 1e57cce..1aeb87a 100644 --- a/lib/payments-admin.ts +++ b/lib/payments-admin.ts @@ -162,6 +162,27 @@ export async function createAdminPayment( }); if (seed.existed) { + // The stored record is authoritative for the charge. A replayed + // clientRequestId carrying *different* details (staff edited the + // amount after a lost response) must not silently charge the stored + // amount — surface a conflict so staff start a fresh payment. + const stored = seed.data; + const mismatch = + stored.purpose !== input.purpose || + stored.description !== input.description || + stored.amountMinor !== input.amountMinor || + stored.customerName !== input.customerName || + stored.customerEmail !== input.customerEmail || + stored.tourDate !== input.tourDate || + stored.attendeeCount !== input.attendeeCount || + stored.internalNote !== input.internalNote; + if (mismatch) { + throw new PaymentError( + "A payment with this reference already exists with different details. Start a new payment instead.", + 409 + ); + } + const status = normalizePaymentStatus(seed.data.status); if (status !== "created") { // Replay: the earlier request already issued a session (or the payment @@ -412,7 +433,28 @@ export async function refreshAdminPayment(id: string): Promise { transition === "paid" && outcome.paymentIntentId ? await fetchPaidEnrichment(outcome.paymentIntentId) : {}; - await applyOutcome(id, outcome, "manual_refresh", extra); + const applied = await applyOutcome(id, outcome, "manual_refresh", extra); + + // paid→paid is a no-op for the planner, so a paid record whose + // enrichment lookup failed at webhook time backfills here. + if (!applied && data.status === "paid" && !data.receiptUrl) { + const intentId = + outcome.paymentIntentId ?? + (typeof data.stripePaymentIntentId === "string" + ? data.stripePaymentIntentId + : null); + const enrich = + Object.keys(extra).length > 0 + ? extra + : intentId + ? await fetchPaidEnrichment(intentId) + : {}; + if (Object.keys(enrich).length > 0) { + await getPaymentsCollection() + .doc(id) + .update({ ...enrich, updatedAt: FieldValue.serverTimestamp() }); + } + } } // --- Stripe webhook --- diff --git a/lib/payments-common.ts b/lib/payments-common.ts index bfce135..2f54c5f 100644 --- a/lib/payments-common.ts +++ b/lib/payments-common.ts @@ -550,6 +550,7 @@ export interface CheckoutSessionSpec { payment_intent_data: { metadata: Record; description: string; + receipt_email?: string; }; success_url: string; cancel_url: string; @@ -586,6 +587,9 @@ export function buildCheckoutSessionSpec(args: { payment_intent_data: { metadata: { paymentId: args.paymentId, purpose: args.purpose }, description: args.description, + // Guarantees Stripe emails the receipt — `customer_email` alone only + // prefills the checkout form and does not promise delivery. + ...(args.customerEmail ? { receipt_email: args.customerEmail } : {}), }, success_url: args.successUrl, cancel_url: args.cancelUrl, @@ -762,7 +766,10 @@ export function planStripeEventApply( "expired", "canceled", ]), - processing: new Set(["created", "awaiting_payment", "failed"]), + // `failed` is not a source for `processing`: an async payment that + // failed left its Checkout Session complete+unpaid and cannot be + // retried — a failed record must never regress to pending. + processing: new Set(["created", "awaiting_payment"]), failed: new Set([ "created", "awaiting_payment", diff --git a/tests/lib/payments-common.test.ts b/tests/lib/payments-common.test.ts index fdf0faa..c29e313 100644 --- a/tests/lib/payments-common.test.ts +++ b/tests/lib/payments-common.test.ts @@ -98,16 +98,19 @@ describe("parsePaymentCreateBody", () => { }); it("accepts the full field set", () => { + // Derived from the current year — a hardcoded year goes stale once it + // falls outside TOUR_DATE_MAX_YEARS_AWAY. + const tourDate = `${new Date().getFullYear() + 1}-10-15`; const result = parsePaymentCreateBody({ ...baseBody, customerEmail: "jane@example.com", - tourDate: "2026-10-15", + tourDate, attendeeCount: 4, internalNote: "Cruise group", }); assert.strictEqual(result.ok, true); if (result.ok) { - assert.strictEqual(result.input.tourDate, "2026-10-15"); + assert.strictEqual(result.input.tourDate, tourDate); assert.strictEqual(result.input.attendeeCount, 4); } }); @@ -208,6 +211,26 @@ describe("buildCheckoutSessionSpec", () => { // Receipt email goes to the dedicated Stripe field, not metadata. assert.strictEqual(spec.customer_email, "jane@example.com"); }); + + it("sets receipt_email so Stripe emails the receipt automatically", () => { + // customer_email only prefills the form — receipt_email is what + // guarantees Stripe sends the customer a receipt on success. + assert.strictEqual( + spec.payment_intent_data.receipt_email, + "jane@example.com" + ); + + const noEmail = buildCheckoutSessionSpec({ + paymentId: VALID_ID, + purpose: "other", + description: "Ad hoc charge", + amountMinor: 500, + successUrl: "https://deepdivebrewing.com/pay/complete", + cancelUrl: "https://deepdivebrewing.com/pay/cancelled", + }); + assert.strictEqual(noEmail.payment_intent_data.receipt_email, undefined); + assert.strictEqual(noEmail.customer_email, undefined); + }); }); // --- Stripe event resolution --- @@ -360,6 +383,19 @@ describe("planStripeEventApply", () => { ); }); + it("never sends a failed record back to processing", () => { + // A Checkout Session that async-failed is complete+unpaid and cannot + // be retried — neither a late webhook nor a manual refresh (which maps + // that session state to "processing") may resurrect it to pending. + for (const source of ["webhook", "manual_refresh"] as const) { + assert.strictEqual( + planStripeEventApply(record("failed"), outcome("processing"), source, NOW).apply, + false, + `failed must not regress to processing via ${source}` + ); + } + }); + it("is a no-op when the transition does not advance the record", () => { assert.strictEqual( planStripeEventApply(record("paid"), outcome("paid"), "webhook", NOW).apply, From 3c6a38257614968d02e604e510ab4a8823f73700 Mon Sep 17 00:00:00 2001 From: Chad Nuttall Date: Sat, 3 Oct 2026 21:12:15 -0400 Subject: [PATCH 03/11] fix(admin): reconcile payments against canonical Stripe state, not webhook payloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring PR #156 up to the shared payment standard (app-foundations standards/payments.md): - Webhook events are now hints only: after signature verification we extract just the event/session ids, re-fetch the canonical Checkout Session, and let canonical status/payment_status drive the transition. Unsettled canonical state defers with a 500 so Stripe redelivers. - Settlement reconciliation: a paid transition applies only when the canonical session id/amount/currency match the stored snapshot. Mismatches quarantine — reconciliationIssue flag + audit event, never silently paid, never auto-corrected; the flag clears when a verified paid lands. - Checkout URLs are pinned to https://checkout.stripe.com/. - Stripe SDK failures cross the boundary as PaymentProviderError (safe message + machine code only; no raw payloads in responses or logs). - sessions.expire carries an idempotency key; webhook replays short- circuit before the canonical fetch via a cheap marker pre-check. - Replay-mismatch comparison extracted to a tested pure helper. - Live Stripe keys are refused in dev/preview environments. - Docs corrected: receipt email pre-fills Checkout; automatic receipts require Stripe Dashboard settings. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/api/webhooks/stripe/route.ts | 21 +- components/admin-payments-workspace.tsx | 8 + docs/TECHNICAL.md | 48 ++- docs/admin/payments.md | 5 +- docs/operations/observability.md | 4 +- lib/payments-admin.ts | 192 +++++++--- lib/payments-common.ts | 395 +++++++++++++++++--- lib/stripe-config.ts | 14 + tests/lib/payments-common.test.ts | 474 ++++++++++++++++++++---- tests/lib/stripe-config.test.ts | 71 ++++ 10 files changed, 1042 insertions(+), 190 deletions(-) create mode 100644 tests/lib/stripe-config.test.ts diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts index b9fd894..cac8d5c 100644 --- a/app/api/webhooks/stripe/route.ts +++ b/app/api/webhooks/stripe/route.ts @@ -23,14 +23,31 @@ export async function POST(req: NextRequest) { 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, }); } - // 200 for applied/duplicate/ignored/unknown alike: Stripe must not retry - // deliveries we have durably accounted for. + 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. return NextResponse.json({ ok: true, result: result.status }); } catch (error) { // Signature-construction failures are client errors — 400, no retry. diff --git a/components/admin-payments-workspace.tsx b/components/admin-payments-workspace.tsx index 0f13b3c..fe125d6 100644 --- a/components/admin-payments-workspace.tsx +++ b/components/admin-payments-workspace.tsx @@ -858,6 +858,14 @@ export function AdminPaymentsWorkspace({ user }: { user: AdminPanelUser }) {
+ {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)}
diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md index b0b9993..a4f1155 100644 --- a/docs/TECHNICAL.md +++ b/docs/TECHNICAL.md @@ -341,10 +341,12 @@ in code are listed. identifiers (`stripeCheckoutSessionId`, `stripeSessionUrl`, `stripePaymentIntentId`, `stripeChargeId`, `stripeCustomerId`, `sessionExpiresAt`), `receiptUrl`, safe card display metadata - (`paymentMethodBrand`/`paymentMethodLast4`), and lifecycle timestamps - (`paidAt`/`failedAt`/`expiredAt`/`canceledAt`). **Never stored:** PAN, - CVC, or raw Stripe payloads — card entry happens only on Stripe's hosted - page. + (`paymentMethodBrand`/`paymentMethodLast4`), `reconciliationIssue` (set + when Stripe's canonical report contradicted the stored snapshot — + settlement refused, flag cleared by a later verified `paid`), and + lifecycle timestamps (`paidAt`/`failedAt`/`expiredAt`/`canceledAt`). + **Never stored:** PAN, CVC, or raw Stripe payloads — card entry happens + only on Stripe's hosted page. - **History:** `payments/{id}/events` subcollection — append-only (`type`, `seq`, `actorUid`/`actorName` for staff actions, `details`, `createdAt`); `eventCount` is the next `seq`, same pattern as lead @@ -358,9 +360,10 @@ in code are listed. ### `stripeEvents` - **Purpose:** webhook dedupe markers — one document per processed Stripe - event id (`type`, `paymentId`, `result`, `processedAt`). The webhook - transaction creates the marker and applies the payment update atomically, - so a replayed delivery exits before touching the record. + event id (`type`, `paymentId`, `result`, `processedAt`); `result` is + `applied | ignored | unknown_payment | mismatch`. The webhook transaction + creates the marker and applies the payment update atomically, so a + replayed delivery exits before touching the record. - **Visibility:** deny-all (`read, write: if false`); Admin SDK only. ### Firebase Authentication @@ -758,17 +761,28 @@ listed in `serverExternalPackages` alongside `firebase-admin`. Quick-pick tour purposes derive suggestions from the canonical `TOUR_PRODUCTS` prices; amounts are always staff-editable. 3. **Reconcile:** `POST /api/webhooks/stripe` verifies the signature - (`STRIPE_WEBHOOK_SECRET`, raw body) → `resolveStripeEventOutcome` maps + (`STRIPE_WEBHOOK_SECRET`, raw body). A signed event is treated as a + *hint*, not financial truth — `readStripeEventRefs` extracts only the + event id/type + session id from `checkout.session.{completed,expired,async_payment_succeeded, - async_payment_failed}` to `paid|processing|failed|expired` → - `processStripeEvent` runs the dedupe-marker + transition inside one - Firestore transaction (`planStripeEventApply` is the pure state machine; - Stripe-terminal statuses never regress, `canceled` outranks a late - `expired`). `paid` additionally enriches from the PaymentIntent's latest - charge (`receiptUrl`, brand/last4 — best-effort). Manual - `POST .../[id]/refresh` reconciles through the same planner, and - `POST .../[id]/cancel` expires the session on Stripe then marks - `canceled` (unpaid statuses only). + async_payment_failed}`, then the Checkout Session is **re-fetched from + Stripe** and canonical `status`/`payment_status`/`amount_total`/ + `currency` drive the decision (`canonicalSessionDecision`; unsettled + sessions defer with a 500 so Stripe redelivers). Before a `paid` + transition applies, `reconcilePaymentSnapshot` compares the canonical + amount/currency/session id against the stored snapshot — a mismatch is + quarantined (`reconciliation_mismatch` audit event + + `reconciliationIssue` flag, never silently paid). `processStripeEvent` + runs the dedupe-marker + transition inside one Firestore transaction + (`planStripeEventApply` is the pure state machine; Stripe-terminal + statuses never regress, `canceled` outranks a late `expired`). `paid` + additionally enriches from the PaymentIntent's latest charge + (`receiptUrl`, brand/last4 — best-effort). Session URLs are pinned to + `https://checkout.stripe.com/` (`assertCheckoutSessionUrl`) and Stripe + SDK failures cross the boundary only as `PaymentProviderError` (safe + code + message). Manual `POST .../[id]/refresh` reconciles through the + same canonical planner, and `POST .../[id]/cancel` expires the session + on Stripe then marks `canceled` (unpaid statuses only). 4. **Mode:** `livemode` from the Stripe object is stored per payment; the UI badges test-mode payments. No analytics events carry payment data. diff --git a/docs/admin/payments.md b/docs/admin/payments.md index cabe73f..ed96895 100644 --- a/docs/admin/payments.md +++ b/docs/admin/payments.md @@ -50,11 +50,11 @@ While a payment is **Awaiting payment** (or still setting up), use **Cancel paym ## Receipts -If you entered the customer's **Receipt email**, Stripe emails them a receipt automatically when the payment succeeds. After a payment is **Paid**, the detail view also shows a **View receipt** link to Stripe's hosted receipt page, plus the card brand and last four digits — the only card details ever stored, and only display metadata. +The **Receipt email** field pre-fills the customer's email on the Stripe Checkout page and associates it with the payment. Stripe emails a receipt automatically when the payment succeeds **if** automatic receipts for successful payments are enabled in the Stripe Dashboard (Dashboard → Settings → Emails → "Email customers about successful payments"). After a payment is **Paid**, the detail view also shows a **View receipt** link to Stripe's hosted receipt page — usable regardless of email settings — plus the card brand and last four digits, the only card details ever stored, and only as display metadata. ## Payment detail -Selecting a payment shows everything the app recorded: amount, purpose, description, customer, receipt email, tour date, attendees, who created it and when, the Stripe identifiers, receipt link, and an append-only **History** (created, link issued, succeeded/failed/expired, canceled). +Selecting a payment shows everything the app recorded: amount, purpose, description, customer, receipt email, tour date, attendees, who created it and when, the Stripe identifiers, receipt link, and an append-only **History** (created, link issued, succeeded/failed/expired, canceled, flagged-for-review). ## Test mode @@ -64,4 +64,5 @@ When the app runs with Stripe **test** credentials, a **Test mode** badge appear - **Payment stays "Awaiting payment" after the customer says they paid** — click **Refresh status**; the app asks Stripe directly. - **The link doesn't work** — links expire after about 24 hours or when canceled. Create a fresh payment. +- **A payment shows "Needs attention"** — Stripe's report contradicted what was created (for example a different amount or currency), so the app refused to mark it paid. Check the session in the Stripe Dashboard and tell an owner. - **A charge happened twice** — it shouldn't: retries reuse the same payment record and Stripe session. Report it to an owner with the payment's Stripe session id from the detail view. diff --git a/docs/operations/observability.md b/docs/operations/observability.md index bcf39b7..4d3ca9b 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -247,7 +247,9 @@ emits **single-line JSON** so Vercel's log search can filter reliably: | `payment.{create,get,cancel,refresh,qr}_failed` / `payments.list_failed` | Unhandled errors in the payments API routes | | `payment.enrichment_failed` | Paid-payment receipt/card-brand enrichment from Stripe failed — the payment stays `paid`; only display metadata is missing (warn) | | `payment.refresh_id_mismatch` | A Stripe session retrieved during refresh had the wrong `paymentId` — treated as a defensive anomaly, not an update (warn) | -| `stripe_webhook.processed` | Signed Stripe event handled (`eventType`, `paymentId`, `result` — `applied`, `duplicate`, `ignored`, or `unknown_payment`) | +| `stripe_webhook.processed` | Signed Stripe event handled (`eventType`, `paymentId`, `result` — `applied`, `duplicate`, `ignored`, `unknown_payment`, or `quarantined`) | +| `stripe_webhook.deferred` | Canonical Checkout Session state had not settled for the delivered event type — answered 500 so Stripe redelivers (warn) | +| `stripe_webhook.quarantined` | Canonical session contradicted the stored snapshot (session/amount/currency) — settlement refused, `reconciliation_mismatch` event written, needs staff review (warn) | | `stripe_webhook.unknown_payment` | Signed event referenced a `paymentId` with no record — acked 200 so Stripe does not retry it forever (warn) | | `stripe_webhook.bad_signature` | Signature verification failed — rejected 400 (warn, security-relevant) | | `stripe_webhook.processing_failed` | Unhandled webhook error — 500 returned so Stripe retries delivery | diff --git a/lib/payments-admin.ts b/lib/payments-admin.ts index 1aeb87a..c498c7f 100644 --- a/lib/payments-admin.ts +++ b/lib/payments-admin.ts @@ -6,21 +6,29 @@ import { getStripeWebhookSecret } from "@/lib/stripe-config"; import { siteUrl } from "@/lib/site"; import { logInfo, logWarn } from "@/lib/log"; import { + assertCheckoutSessionUrl, buildCheckoutSessionSpec, + canonicalSessionDecision, enrichmentFromPaymentIntent, isPaymentCancelable, normalizePaymentStatus, outcomeFromSession, + paymentCreateMatchesRecord, + paymentIdFromSession, PaymentError, PaymentNotFoundError, planStripeEventApply, processStripeEvent, - resolveStripeEventOutcome, + readStripeEventRefs, + resolveCanonicalEventOutcome, + toPaymentProviderError, + HANDLED_STRIPE_EVENT_TYPES, PAYMENTS_COLLECTION, PAYMENT_EVENTS_SUBCOLLECTION, STRIPE_EVENTS_COLLECTION, type PaymentCreateInput, type PaymentEventDraft, + type StripeEventOutcome, type StripeEventStore, type StripeOutcome, } from "@/lib/payments-common"; @@ -166,17 +174,7 @@ export async function createAdminPayment( // clientRequestId carrying *different* details (staff edited the // amount after a lost response) must not silently charge the stored // amount — surface a conflict so staff start a fresh payment. - const stored = seed.data; - const mismatch = - stored.purpose !== input.purpose || - stored.description !== input.description || - stored.amountMinor !== input.amountMinor || - stored.customerName !== input.customerName || - stored.customerEmail !== input.customerEmail || - stored.tourDate !== input.tourDate || - stored.attendeeCount !== input.attendeeCount || - stored.internalNote !== input.internalNote; - if (mismatch) { + if (!paymentCreateMatchesRecord(seed.data, input)) { throw new PaymentError( "A payment with this reference already exists with different details. Start a new payment instead.", 409 @@ -210,22 +208,23 @@ export async function createAdminPayment( // Idempotency key = payment id: a Stripe retry of this call returns the // same session instead of creating a second hosted page. - const session = await getStripeClient().checkout.sessions.create( - spec as Stripe.Checkout.SessionCreateParams, - { idempotencyKey: ref.id } - ); - if (!session.url) { - throw new PaymentError( - "Stripe did not return a payment page. Please try again.", - 502 + let session: Stripe.Checkout.Session; + try { + session = await getStripeClient().checkout.sessions.create( + spec as Stripe.Checkout.SessionCreateParams, + { idempotencyKey: ref.id } ); + } catch (error) { + throw toPaymentProviderError(error); } + // Only Stripe-hosted Checkout URLs may ever reach staff/customers. + const sessionUrl = assertCheckoutSessionUrl(session.url); const batch = db.batch(); batch.update(ref, { status: "awaiting_payment", stripeCheckoutSessionId: session.id, - stripeSessionUrl: session.url, + stripeSessionUrl: sessionUrl, ...(session.payment_intent ? { stripePaymentIntentId: @@ -296,19 +295,37 @@ export async function cancelAdminPayment( if (sessionId) { try { - await getStripeClient().checkout.sessions.expire(sessionId); + await getStripeClient().checkout.sessions.expire( + sessionId, + {}, + { idempotencyKey: `${id}:expire` } + ); } catch (expireError) { // "Already expired" is the benign race — cancel proceeds. Anything // else (e.g. the customer already completed) reconciles via the - // refresh path so the caller sees the true state. - const session = - await getStripeClient().checkout.sessions.retrieve(sessionId); - if (session.status === "expired") { + // canonical session so the caller sees the true state. + let session: Stripe.Checkout.Session; + try { + session = + await getStripeClient().checkout.sessions.retrieve(sessionId); + } catch (error) { + throw toPaymentProviderError(error); + } + const decision = canonicalSessionDecision( + null, + session as unknown as Record + ); + if ( + decision.kind === "transition" && + decision.transition === "expired" + ) { // Fall through — the hosted page is dead either way. } else if (session.status === "complete") { + const transition = + decision.kind === "transition" ? decision.transition : "processing"; const { paymentId, outcome } = outcomeFromSession( session as unknown as Record, - session.payment_status === "paid" ? "paid" : "processing" + transition ); await applyOutcome(id, paymentId === id ? outcome : null, "manual_refresh"); throw new PaymentError( @@ -316,7 +333,7 @@ export async function cancelAdminPayment( 409 ); } else { - throw expireError; + throw toPaymentProviderError(expireError); } } } @@ -389,7 +406,7 @@ async function fetchPaidEnrichment( } catch (error) { logWarn("payment.enrichment_failed", { paymentIntentId, - errorName: error instanceof Error ? error.name : "Error", + providerCode: toPaymentProviderError(error).code, }); return {}; } @@ -408,20 +425,24 @@ export async function refreshAdminPayment(id: string): Promise { : null; if (!sessionId) return; // Nothing issued yet — nothing to reconcile. - const session = await getStripeClient().checkout.sessions.retrieve(sessionId); - const transition = - session.status === "complete" - ? session.payment_status === "paid" - ? "paid" - : "processing" - : session.status === "expired" - ? "expired" - : null; - if (!transition) return; // Still open — no state change. + let session: Stripe.Checkout.Session; + try { + session = + await getStripeClient().checkout.sessions.retrieve(sessionId); + } catch (error) { + throw toPaymentProviderError(error); + } + // Manual refresh has no delivery semantics — an open/unsettled session + // is a no-op rather than a retryable failure. + const decision = canonicalSessionDecision( + null, + session as unknown as Record + ); + if (decision.kind !== "transition") return; const { paymentId, outcome } = outcomeFromSession( session as unknown as Record, - transition + decision.transition ); const targetId = paymentId ?? id; if (targetId !== id) { @@ -430,7 +451,7 @@ export async function refreshAdminPayment(id: string): Promise { } const extra = - transition === "paid" && outcome.paymentIntentId + decision.transition === "paid" && outcome.paymentIntentId ? await fetchPaidEnrichment(outcome.paymentIntentId) : {}; const applied = await applyOutcome(id, outcome, "manual_refresh", extra); @@ -514,26 +535,103 @@ function makeStripeEventStore(): StripeEventStore { }; } +// Cheap pre-transaction dedupe check — a replayed delivery exits before the +// canonical Stripe fetch. The authoritative recheck still happens inside +// processStripeEvent's transaction (concurrent deliveries converge there). +async function isStripeEventProcessed(eventId: string): Promise { + const snap = await getFirebaseAdminDb() + .collection(STRIPE_EVENTS_COLLECTION) + .doc(eventId) + .get(); + return snap.exists; +} + export interface WebhookResult { - status: "applied" | "duplicate" | "ignored" | "unknown_payment"; + status: + | "applied" + | "duplicate" + | "ignored" + | "unknown_payment" + | "quarantined" + | "retry"; paymentId: string | null; toStatus?: string; } -// Verifies + processes one webhook delivery. Throws on an invalid signature -// (the route answers 400) — everything else resolves through -// processStripeEvent inside a single Firestore transaction. +// Verifies + processes one webhook delivery. +// +// Trust model: the signature proves Stripe sent the event — nothing more. +// Only the event id/type and the session object id are read from the +// payload; the session is then re-fetched from Stripe and *canonical* state +// decides the transition. A payload can therefore never mark a payment +// paid on its own. +// +// Throws on an invalid signature (the route answers 400). Returns "retry" +// when canonical state has not settled (the route answers 500 so Stripe +// redelivers). Everything else resolves through processStripeEvent inside +// a single Firestore transaction. export async function handleStripeWebhook( rawBody: string, signatureHeader: string ): Promise { - const event = getStripeClient().webhooks.constructEvent( + const stripe = getStripeClient(); + const event = stripe.webhooks.constructEvent( rawBody, signatureHeader, getStripeWebhookSecret() ); - const resolved = resolveStripeEventOutcome(event); + const refs = readStripeEventRefs(event); + let resolved: StripeEventOutcome; + + if (!HANDLED_STRIPE_EVENT_TYPES.has(refs.eventType)) { + resolved = { + eventId: refs.eventId, + eventType: refs.eventType, + paymentId: null, + ignoredReason: "unhandled_event_type", + }; + } else if (!refs.sessionId) { + resolved = { + eventId: refs.eventId, + eventType: refs.eventType, + paymentId: null, + ignoredReason: "no_session_reference", + }; + } else { + if (refs.eventId && (await isStripeEventProcessed(refs.eventId))) { + // Replay of a durably processed event — the transaction below sees + // the marker and exits as "duplicate" without a provider call. + resolved = { + eventId: refs.eventId, + eventType: refs.eventType, + paymentId: null, + }; + } else { + // Canonical re-fetch: the event payload only told us which Checkout + // Session to look at. + let session: Stripe.Checkout.Session; + try { + session = await stripe.checkout.sessions.retrieve(refs.sessionId); + } catch (error) { + throw toPaymentProviderError(error); + } + const resolution = resolveCanonicalEventOutcome( + refs, + session as unknown as Record + ); + if (resolution.kind === "retry") { + logWarn("stripe_webhook.deferred", { + eventType: refs.eventType, + paymentId: paymentIdFromSession( + session as unknown as Record + ), + }); + return { status: "retry", paymentId: null }; + } + resolved = resolution.resolved; + } + } // Paid transitions pick up receipt/card display data before the commit so // it lands atomically with the status change. diff --git a/lib/payments-common.ts b/lib/payments-common.ts index 2f54c5f..fa92aae 100644 --- a/lib/payments-common.ts +++ b/lib/payments-common.ts @@ -214,6 +214,9 @@ export interface PaymentRecord { expiredAt?: unknown; canceledAt?: unknown; failureMessage?: string; + // Set when Stripe's canonical state contradicted the stored snapshot + // (session/amount/currency) — settlement refused until staff review. + reconciliationIssue?: string; } // Serialized view returned to the admin client — ISO strings, no @@ -249,6 +252,7 @@ export interface PaymentView { expiredAt?: string; canceledAt?: string; failureMessage?: string; + reconciliationIssue?: string; } export function serializePayment( @@ -296,6 +300,7 @@ export function serializePayment( expiredAt: toIsoString(data.expiredAt), canceledAt: toIsoString(data.canceledAt), failureMessage: optStr("failureMessage"), + reconciliationIssue: optStr("reconciliationIssue"), }; } @@ -309,6 +314,7 @@ export const PAYMENT_EVENT_TYPES = [ "payment_failed", "session_expired", "payment_canceled", + "reconciliation_mismatch", ] as const; export type PaymentEventType = (typeof PAYMENT_EVENT_TYPES)[number]; @@ -376,6 +382,10 @@ export function describePaymentEvent( return `Payment link expired${source}`; case "payment_canceled": return "Payment canceled by staff"; + case "reconciliation_mismatch": { + const reason = detailStr("reason"); + return `Payment flagged — Stripe's report did not match this charge${reason ? ` (${reason})` : ""}. Do not treat it as paid without review`; + } default: return event.type; } @@ -384,7 +394,9 @@ export function describePaymentEvent( // --- Creation input validation --- export class PaymentError extends Error { - public readonly clientSafe = true; + // Annotated `boolean` so subclasses (e.g. PaymentProviderError) may + // deliberately opt out of client-safe exposure. + public readonly clientSafe: boolean = true; constructor( message: string, public status: number = 400 @@ -399,6 +411,40 @@ export class PaymentNotFoundError extends PaymentError { } } +// Provider failures are normalized into this shape before they cross the +// server boundary — only Stripe's machine-readable `code` is preserved. +// The raw error (message, request/response payloads) is never forwarded to +// the client, the audit trail, or structured log context. +// +// Deliberately NOT clientSafe: apiErrorResponse logs it through the +// normalized error path (safe name/message/code — observable in ops) and +// answers the generic fallback instead of a provider-flavored message. +export class PaymentProviderError extends PaymentError { + public readonly clientSafe = false; + // Carried on `code` so lib/log's normalizeError surfaces it as the safe + // machine-level category (e.g. "rate_limit", "resource_missing"). + public readonly code: string; + constructor(providerCode?: string | null) { + super( + "The payment provider could not complete the request. Please try again.", + 502 + ); + this.name = "PaymentProviderError"; + this.code = providerCode || "unexpected_provider_error"; + } +} + +export function toPaymentProviderError(error: unknown): PaymentProviderError { + if (error instanceof PaymentProviderError) return error; + const code = + error && + typeof error === "object" && + typeof (error as { code?: unknown }).code === "string" + ? (error as { code: string }).code + : null; + return new PaymentProviderError(code); +} + export const PAYMENT_FIELD_LIMITS = { customerName: 200, customerEmail: 320, @@ -529,6 +575,28 @@ export function parsePaymentCreateBody( }; } +// Idempotent-replay check: a clientRequestId may be retried only when the +// payment-relevant payload matches the stored record exactly. Any drift +// (amount, description, customer, tour details, note) means the submit was +// a *different* action reusing the id — a conflict, not a replay. +export function paymentCreateMatchesRecord( + record: Record, + input: PaymentCreateInput +): boolean { + const str = (v: unknown) => (typeof v === "string" ? v : undefined); + const num = (v: unknown) => (typeof v === "number" ? v : undefined); + return ( + str(record.purpose) === input.purpose && + str(record.description) === input.description && + num(record.amountMinor) === input.amountMinor && + str(record.customerName) === input.customerName && + str(record.customerEmail) === input.customerEmail && + str(record.tourDate) === input.tourDate && + num(record.attendeeCount) === input.attendeeCount && + str(record.internalNote) === input.internalNote + ); +} + // --- Checkout Session construction --- // Minimal structural shape of the Stripe SessionCreateParams we build — @@ -596,6 +664,26 @@ export function buildCheckoutSessionSpec(args: { }; } +// Hosted Checkout URLs are surfaced to staff (copy/QR) — only ever accept +// Stripe's own secure origin. Anything else (open redirect, lookalike +// domain, plain http) fails closed instead of reaching a customer's screen. +export const CHECKOUT_SESSION_URL_ORIGIN = "https://checkout.stripe.com"; + +export function assertCheckoutSessionUrl(url: unknown): string { + let parsed: URL | null = null; + if (typeof url === "string" && url) { + try { + parsed = new URL(url); + } catch { + parsed = null; + } + } + if (!parsed || parsed.origin !== CHECKOUT_SESSION_URL_ORIGIN) { + throw new PaymentProviderError("invalid_checkout_url"); + } + return url as string; +} + // --- Stripe event → internal outcome --- export type StripeTransition = "paid" | "processing" | "failed" | "expired"; @@ -610,6 +698,11 @@ export interface StripeOutcome { stripeCustomerId?: string; sessionExpiresAtMillis?: number; failureMessage?: string; + // Canonical provider-reported money facts — read only from a re-fetched + // Checkout Session (never the webhook payload) and compared against the + // stored snapshot before a `paid` transition may apply. + amountMinor?: number; + currency?: string; // Additional safe fields merged into the document when the transition // applies — e.g. receipt URL / card brand+last4 fetched from the // PaymentIntent on a `paid` transition (see enrichmentFromPaymentIntent). @@ -640,16 +733,13 @@ function stripeIdOf(value: unknown): string | undefined { return undefined; } -// Maps a checkout.session-shaped object to the internal outcome. Shared by -// the webhook (event.data.object) and the refresh action (retrieved session) -// so both reconcile identically. -export function outcomeFromSession( - session: Record, - transition: StripeTransition, - failureMessage?: string -): { paymentId: string | null; outcome: StripeOutcome } { +// The internal payment id embedded on a Checkout Session at creation. +// Read from the canonical retrieved object; absent on foreign sessions. +export function paymentIdFromSession( + session: Record +): string | null { const metadata = session.metadata; - const paymentId = + return ( (metadata && typeof metadata === "object" && typeof (metadata as Record).paymentId === "string" @@ -658,15 +748,25 @@ export function outcomeFromSession( (typeof session.client_reference_id === "string" ? session.client_reference_id : undefined) ?? - null; + null + ); +} +// Maps a checkout.session-shaped object to the internal outcome. Shared by +// the webhook (canonical retrieved session) and the refresh action so both +// reconcile identically. +export function outcomeFromSession( + session: Record, + transition: StripeTransition, + failureMessage?: string +): { paymentId: string | null; outcome: StripeOutcome } { const expiresAt = typeof session.expires_at === "number" && session.expires_at > 0 ? session.expires_at * 1000 : undefined; return { - paymentId, + paymentId: paymentIdFromSession(session), outcome: { transition, sessionId: stripeIdOf(session.id) ?? (typeof session.id === "string" ? session.id : undefined), @@ -674,21 +774,43 @@ export function outcomeFromSession( stripeCustomerId: stripeIdOf(session.customer), sessionExpiresAtMillis: expiresAt, failureMessage, + amountMinor: + typeof session.amount_total === "number" + ? session.amount_total + : undefined, + currency: + typeof session.currency === "string" ? session.currency : undefined, }, }; } -// Resolves a verified Stripe webhook event to the outcome it implies. -// Only the events we subscribe to produce transitions; everything else is -// acknowledged-and-ignored so stray subscriptions cannot corrupt state. -export function resolveStripeEventOutcome(event: { +// --- Webhook event references + canonical-state decisions --- + +// The Checkout Session event types this app subscribes to. Everything else +// is acknowledged-and-ignored so stray subscriptions cannot corrupt state. +export const HANDLED_STRIPE_EVENT_TYPES: ReadonlySet = new Set([ + "checkout.session.completed", + "checkout.session.expired", + "checkout.session.async_payment_succeeded", + "checkout.session.async_payment_failed", +]); + +export interface StripeEventRefs { + eventId: string; + eventType: string; + sessionId: string | null; +} + +// A signed webhook event proves Stripe sent it — it is NOT proof of the +// financial fields embedded in it. Only identifiers are extracted here; +// the session's status, payment_status, and amounts must come from the +// canonical re-fetched object (see app-foundations payment standard). +export function readStripeEventRefs(event: { id?: unknown; type?: unknown; data?: unknown; -}): StripeEventOutcome { - const eventId = typeof event.id === "string" ? event.id : ""; - const eventType = typeof event.type === "string" ? event.type : ""; - const session = +}): StripeEventRefs { + const object = event.data && typeof event.data === "object" && (event.data as { object?: unknown }).object && @@ -696,38 +818,113 @@ export function resolveStripeEventOutcome(event: { ? ((event.data as { object: Record }) .object as Record) : {}; + return { + eventId: typeof event.id === "string" ? event.id : "", + eventType: typeof event.type === "string" ? event.type : "", + sessionId: + object.object === "checkout.session" + ? stripeIdOf(object.id) ?? null + : null, + }; +} + +export type CanonicalSessionDecision = + | { kind: "transition"; transition: StripeTransition } + | { kind: "ignored"; reason: string } + | { kind: "retry" }; - let transition: StripeTransition | null = null; - if (eventType === "checkout.session.completed") { - transition = - session.payment_status === "paid" ? "paid" : "processing"; - } else if (eventType === "checkout.session.async_payment_succeeded") { - transition = "paid"; - } else if (eventType === "checkout.session.async_payment_failed") { - transition = "failed"; - } else if (eventType === "checkout.session.expired") { - transition = "expired"; +// Decides the internal transition from the *canonical* Checkout Session. +// The event type only disambiguates `complete`+`unpaid` (async failure vs. +// still-processing) — every financial fact is read from the session. +// +// `eventType` is null for the manual-refresh path, which has no delivery +// semantics: an open/unsettled session means "no change" rather than a +// retryable failure. +export function canonicalSessionDecision( + eventType: string | null, + session: Record +): CanonicalSessionDecision { + const status = typeof session.status === "string" ? session.status : ""; + const paymentStatus = + typeof session.payment_status === "string" ? session.payment_status : ""; + + if (status === "expired") { + return { kind: "transition", transition: "expired" }; } + if (status === "complete") { + if (paymentStatus === "paid" || paymentStatus === "no_payment_required") { + return { kind: "transition", transition: "paid" }; + } + if (paymentStatus === "unpaid") { + if (eventType === "checkout.session.async_payment_failed") { + return { kind: "transition", transition: "failed" }; + } + if (eventType === "checkout.session.async_payment_succeeded") { + // The event claims success but canonical state has not caught up — + // ask Stripe to redeliver rather than settle (or fail) on a race. + return { kind: "retry" }; + } + return { kind: "transition", transition: "processing" }; + } + return { kind: "ignored", reason: "unexpected_payment_status" }; + } + if (status === "open") { + // Session object has not settled for an event claiming a terminal + // state — transient on webhook delivery, a no-op on manual refresh. + return eventType === null + ? { kind: "ignored", reason: "session_open" } + : { kind: "retry" }; + } + return { kind: "ignored", reason: "unexpected_session_state" }; +} - if (!transition) { +// Resolves a verified webhook event + its canonical retrieved session into +// the outcome the event implies. Returns kind "retry" when canonical state +// has not settled — the delivery must be reattempted, not acknowledged. +export function resolveCanonicalEventOutcome( + refs: StripeEventRefs, + session: Record +): + | { kind: "resolved"; resolved: StripeEventOutcome } + | { kind: "retry" } { + const decision = canonicalSessionDecision(refs.eventType, session); + const paymentId = paymentIdFromSession(session); + + if (decision.kind === "retry") { + return { kind: "retry" }; + } + if (decision.kind === "ignored") { return { - eventId, - eventType, - paymentId: null, - ignoredReason: "unhandled_event_type", + kind: "resolved", + resolved: { + eventId: refs.eventId, + eventType: refs.eventType, + paymentId, + ignoredReason: decision.reason, + }, }; } - - const { paymentId, outcome } = outcomeFromSession(session, transition); + const { outcome } = outcomeFromSession(session, decision.transition); if (!paymentId) { return { - eventId, - eventType, - paymentId: null, - ignoredReason: "no_payment_reference", + kind: "resolved", + resolved: { + eventId: refs.eventId, + eventType: refs.eventType, + paymentId: null, + ignoredReason: "no_payment_reference", + }, }; } - return { eventId, eventType, paymentId, outcome }; + return { + kind: "resolved", + resolved: { + eventId: refs.eventId, + eventType: refs.eventType, + paymentId, + outcome, + }, + }; } // --- Transition planning --- @@ -738,6 +935,58 @@ export interface StripeApplyPlan { events: PaymentEventDraft[]; // The status the plan would produce — for logging/response context. toStatus?: PaymentStatus; + // Reconciliation code when settlement was refused — the payment keeps its + // current status but is flagged for staff attention instead of marking + // paid on contradicted provider evidence. + quarantined?: string; +} + +// Compares the canonical provider state embedded in an outcome against the +// immutable snapshot stored at initiation. Any disagreement — or money +// facts Stripe failed to report — means the settlement cannot be verified; +// returns the mismatch code and safe expected/actual strings for the audit +// event, or null when the outcome is consistent with the record. +export function reconcilePaymentSnapshot( + record: PaymentRecord, + outcome: StripeOutcome +): { code: string; expected: string; actual: string } | null { + const storedSessionId = + typeof record.stripeCheckoutSessionId === "string" && + record.stripeCheckoutSessionId + ? record.stripeCheckoutSessionId + : null; + if (storedSessionId && outcome.sessionId !== storedSessionId) { + return { + code: "session_mismatch", + expected: storedSessionId, + actual: outcome.sessionId ?? "missing", + }; + } + const storedCurrency = + typeof record.currency === "string" && record.currency + ? record.currency + : PAYMENT_CURRENCY; + if ( + typeof outcome.currency !== "string" || + outcome.currency.toUpperCase() !== storedCurrency.toUpperCase() + ) { + return { + code: "currency_mismatch", + expected: storedCurrency.toUpperCase(), + actual: outcome.currency ?? "missing", + }; + } + if ( + typeof outcome.amountMinor !== "number" || + outcome.amountMinor !== record.amountMinor + ) { + return { + code: "amount_mismatch", + expected: String(record.amountMinor ?? "missing"), + actual: String(outcome.amountMinor ?? "missing"), + }; + } + return null; } // Pure state machine: given the stored record and a Stripe outcome, decide @@ -787,6 +1036,46 @@ export function planStripeEventApply( return { apply: false, updates: {}, events: [] }; } + // Money may be marked settled only when canonical provider state matches + // the snapshot taken at initiation. A mismatch is quarantined — flagged + // for attention, never silently paid and never auto-corrected. + if (target === "paid") { + const mismatch = reconcilePaymentSnapshot(record, outcome); + if (mismatch) { + if (record.reconciliationIssue === mismatch.code) { + // Same mismatch already recorded — stay quarantined without + // appending duplicate audit events. + return { + apply: false, + updates: {}, + events: [], + toStatus: current, + quarantined: mismatch.code, + }; + } + return { + apply: true, + updates: { + reconciliationIssue: mismatch.code, + updatedAt: now, + }, + events: [ + { + type: "reconciliation_mismatch", + details: { + ...sourceDetail, + reason: mismatch.code, + expected: mismatch.expected, + actual: mismatch.actual, + }, + }, + ], + toStatus: current, + quarantined: mismatch.code, + }; + } + } + const updates: Record = { status: target, updatedAt: now }; const events: PaymentEventDraft[] = []; @@ -809,6 +1098,9 @@ export function planStripeEventApply( switch (target) { case "paid": updates.paidAt = now; + // A cleanly reconciled settlement clears a prior flag (e.g. canonical + // state corrected itself after an early delivery raced). + updates.reconciliationIssue = null; events.push({ type: "payment_succeeded", details: sourceDetail }); break; case "processing": @@ -887,7 +1179,7 @@ export interface StripeEventTx { ): Promise; markStripeEventProcessed( eventId: string, - result: "applied" | "ignored" | "unknown_payment", + result: "applied" | "ignored" | "unknown_payment" | "mismatch", meta: { eventType: string; paymentId: string | null } ): Promise; } @@ -897,9 +1189,15 @@ export interface StripeEventStore { } export interface StripeEventResult { - status: "applied" | "duplicate" | "ignored" | "unknown_payment"; + status: + | "applied" + | "duplicate" + | "ignored" + | "unknown_payment" + | "quarantined"; paymentId: string | null; toStatus?: PaymentStatus; + quarantined?: string; } // Single delivery path for verified webhook events: dedupe → load record → @@ -942,13 +1240,18 @@ export async function processStripeEvent( } await tx.markStripeEventProcessed( resolved.eventId, - plan.apply ? "applied" : "ignored", + plan.quarantined ? "mismatch" : plan.apply ? "applied" : "ignored", meta ); return { - status: plan.apply ? "applied" : "ignored", + status: plan.quarantined + ? "quarantined" + : plan.apply + ? "applied" + : "ignored", paymentId: resolved.paymentId, toStatus: plan.toStatus, + quarantined: plan.quarantined, }; }); } diff --git a/lib/stripe-config.ts b/lib/stripe-config.ts index d338b8b..dc71b2d 100644 --- a/lib/stripe-config.ts +++ b/lib/stripe-config.ts @@ -4,6 +4,8 @@ // routes, the webhook route) import these; the secret key must never reach // a client component or a NEXT_PUBLIC_* variable. +const LIVE_KEY_PREFIXES = ["sk_live_", "rk_live_"]; + export function getStripeSecretKey(): string { const key = process.env.STRIPE_SECRET_KEY?.trim(); if (!key) { @@ -11,6 +13,18 @@ export function getStripeSecretKey(): string { "Missing Stripe credentials. Set STRIPE_SECRET_KEY (server-only)." ); } + // Live keys belong to production only — preview deployments and local + // dev must run on test keys so a staging payment can never move real + // money. `next build` never evaluates this (the client is lazy), so the + // check cannot break a production build. + const nonProduction = + process.env.VERCEL_ENV === "preview" || + process.env.NODE_ENV === "development"; + if (nonProduction && LIVE_KEY_PREFIXES.some((p) => key.startsWith(p))) { + throw new Error( + "Live Stripe keys are not allowed outside production. Configure a test key (sk_test_*) for this environment." + ); + } return key; } diff --git a/tests/lib/payments-common.test.ts b/tests/lib/payments-common.test.ts index c29e313..befb67a 100644 --- a/tests/lib/payments-common.test.ts +++ b/tests/lib/payments-common.test.ts @@ -1,7 +1,9 @@ import { describe, it } from "node:test"; import assert from "node:assert"; import { + assertCheckoutSessionUrl, buildCheckoutSessionSpec, + canonicalSessionDecision, describePaymentEvent, enrichmentFromPaymentIntent, formatUsdMinor, @@ -11,12 +13,16 @@ import { outcomeFromSession, parseAmountMinor, parsePaymentCreateBody, + paymentCreateMatchesRecord, paymentStatusLabel, planStripeEventApply, processStripeEvent, - resolveStripeEventOutcome, + readStripeEventRefs, + resolveCanonicalEventOutcome, serializePayment, suggestedAmountMinor, + toPaymentProviderError, + PaymentProviderError, PAYMENT_MAX_AMOUNT_MINOR, type StripeEventStore, type StripeEventTx, @@ -233,9 +239,12 @@ describe("buildCheckoutSessionSpec", () => { }); }); -// --- Stripe event resolution --- +// --- Stripe event references + canonical-session resolution --- -const sessionObject = { +// Canonical session shape — mirrors a re-fetched Checkout Session. The +// webhook handler fetches this from Stripe after signature verification; +// the signed event payload itself only contributes ids. +const canonicalSession = { id: "cs_test_123", object: "checkout.session", client_reference_id: VALID_ID, @@ -244,88 +253,195 @@ const sessionObject = { status: "complete", payment_intent: "pi_123", customer: "cus_123", + amount_total: 8000, + currency: "usd", expires_at: 1_800_000_000, }; -describe("resolveStripeEventOutcome", () => { - it("maps a paid checkout.session.completed to a paid outcome", () => { - const resolved = resolveStripeEventOutcome({ - id: "evt_1", - type: "checkout.session.completed", - data: { object: sessionObject }, - }); - assert.strictEqual(resolved.paymentId, VALID_ID); - assert.strictEqual(resolved.outcome?.transition, "paid"); - assert.strictEqual(resolved.outcome?.paymentIntentId, "pi_123"); - assert.strictEqual(resolved.outcome?.stripeCustomerId, "cus_123"); - assert.strictEqual( - resolved.outcome?.sessionExpiresAtMillis, - 1_800_000_000 * 1000 - ); +// Event payload embedded object — deliberately carries *wrong* financial +// fields to prove they are never read: only the session id is extracted. +const payloadObject = { + id: "cs_test_123", + object: "checkout.session", + client_reference_id: VALID_ID, + metadata: { paymentId: VALID_ID }, + payment_status: "paid", + status: "complete", + payment_intent: "pi_123", + amount_total: 1, + currency: "jpy", +}; + +const paidEvent = { + id: "evt_paid", + type: "checkout.session.completed", + data: { object: payloadObject }, +}; + +function resolveEvent( + event: { id: string; type: string }, + canonical: Record +) { + const refs = readStripeEventRefs({ ...event, data: { object: canonical } }); + return resolveCanonicalEventOutcome(refs, canonical); +} + +describe("readStripeEventRefs", () => { + it("extracts only ids — the payload's financial fields are inert", () => { + const refs = readStripeEventRefs(paidEvent); + assert.strictEqual(refs.eventId, "evt_paid"); + assert.strictEqual(refs.eventType, "checkout.session.completed"); + assert.strictEqual(refs.sessionId, "cs_test_123"); }); - it("maps an unpaid completion to processing", () => { - const resolved = resolveStripeEventOutcome({ - id: "evt_2", - type: "checkout.session.completed", - data: { object: { ...sessionObject, payment_status: "unpaid" } }, + it("ignores non-session objects and malformed events", () => { + const foreign = readStripeEventRefs({ + id: "evt_x", + type: "customer.created", + data: { object: { id: "cus_9", object: "customer" } }, }); - assert.strictEqual(resolved.outcome?.transition, "processing"); + assert.strictEqual(foreign.sessionId, null); + + const empty = readStripeEventRefs({ id: "evt_y", type: "x", data: {} }); + assert.strictEqual(empty.sessionId, null); }); +}); - it("maps async outcomes and expiry", () => { - const succeeded = resolveStripeEventOutcome({ - id: "evt_3", - type: "checkout.session.async_payment_succeeded", - data: { object: sessionObject }, - }); - assert.strictEqual(succeeded.outcome?.transition, "paid"); +describe("canonicalSessionDecision", () => { + it("pays on canonical complete+paid regardless of event type", () => { + for (const type of [ + "checkout.session.completed", + "checkout.session.async_payment_succeeded", + "checkout.session.async_payment_failed", + "checkout.session.expired", + ]) { + assert.deepStrictEqual( + canonicalSessionDecision(type, canonicalSession), + { kind: "transition", transition: "paid" }, + `${type} must follow canonical paid state` + ); + } + }); - const failed = resolveStripeEventOutcome({ - id: "evt_4", - type: "checkout.session.async_payment_failed", - data: { object: sessionObject }, + it("maps complete+unpaid via the event type's disambiguation", () => { + const unpaid = { ...canonicalSession, payment_status: "unpaid" }; + assert.deepStrictEqual( + canonicalSessionDecision("checkout.session.completed", unpaid), + { kind: "transition", transition: "processing" } + ); + assert.deepStrictEqual( + canonicalSessionDecision("checkout.session.async_payment_failed", unpaid), + { kind: "transition", transition: "failed" } + ); + // Manual refresh has no event — unpaid means still processing. + assert.deepStrictEqual(canonicalSessionDecision(null, unpaid), { + kind: "transition", + transition: "processing", }); - assert.strictEqual(failed.outcome?.transition, "failed"); + }); + + it("defers when the success event races canonical state", () => { + const unpaid = { ...canonicalSession, payment_status: "unpaid" }; + assert.deepStrictEqual( + canonicalSessionDecision( + "checkout.session.async_payment_succeeded", + unpaid + ), + { kind: "retry" } + ); + }); - const expired = resolveStripeEventOutcome({ - id: "evt_5", - type: "checkout.session.expired", - data: { object: sessionObject }, + it("maps canonical expiry and defers still-open sessions", () => { + assert.deepStrictEqual( + canonicalSessionDecision("checkout.session.expired", { + ...canonicalSession, + status: "expired", + }), + { kind: "transition", transition: "expired" } + ); + // Canonical wins even over an expiry event's claim. + assert.deepStrictEqual( + canonicalSessionDecision("checkout.session.expired", canonicalSession), + { kind: "transition", transition: "paid" } + ); + const open = { ...canonicalSession, status: "open" }; + assert.strictEqual( + canonicalSessionDecision("checkout.session.completed", open).kind, + "retry" + ); + assert.deepStrictEqual(canonicalSessionDecision(null, open), { + kind: "ignored", + reason: "session_open", }); - assert.strictEqual(expired.outcome?.transition, "expired"); }); +}); - it("ignores unhandled event types and sessions without a payment reference", () => { - const unhandled = resolveStripeEventOutcome({ - id: "evt_6", - type: "customer.created", - data: { object: sessionObject }, +describe("resolveCanonicalEventOutcome", () => { + it("builds the outcome from the canonical session, not the payload", () => { + const result = resolveEvent(paidEvent, canonicalSession); + assert.strictEqual(result.kind, "resolved"); + if (result.kind !== "resolved") return; + assert.strictEqual(result.resolved.paymentId, VALID_ID); + assert.strictEqual(result.resolved.outcome?.transition, "paid"); + assert.strictEqual(result.resolved.outcome?.paymentIntentId, "pi_123"); + assert.strictEqual(result.resolved.outcome?.stripeCustomerId, "cus_123"); + assert.strictEqual(result.resolved.outcome?.amountMinor, 8000); + assert.strictEqual(result.resolved.outcome?.currency, "usd"); + assert.strictEqual( + result.resolved.outcome?.sessionExpiresAtMillis, + 1_800_000_000 * 1000 + ); + }); + + it("a payload claiming paid settles nothing when canonical is unpaid", () => { + const result = resolveEvent(paidEvent, { + ...canonicalSession, + payment_status: "unpaid", }); - assert.strictEqual(unhandled.ignoredReason, "unhandled_event_type"); + assert.strictEqual(result.kind, "resolved"); + if (result.kind !== "resolved") return; + assert.strictEqual(result.resolved.outcome?.transition, "processing"); + }); - const noRef = resolveStripeEventOutcome({ - id: "evt_7", - type: "checkout.session.completed", - data: { - object: { ...sessionObject, client_reference_id: null, metadata: {} }, - }, + it("defers instead of settling when canonical has not caught up", () => { + const result = resolveEvent( + { id: "evt_async", type: "checkout.session.async_payment_succeeded" }, + { ...canonicalSession, payment_status: "unpaid" } + ); + assert.strictEqual(result.kind, "retry"); + }); + + it("ignores sessions with no payment reference", () => { + const result = resolveEvent(paidEvent, { + ...canonicalSession, + client_reference_id: null, + metadata: {}, }); - assert.strictEqual(noRef.ignoredReason, "no_payment_reference"); + assert.strictEqual(result.kind, "resolved"); + if (result.kind !== "resolved") return; + assert.strictEqual(result.resolved.ignoredReason, "no_payment_reference"); + assert.strictEqual(result.resolved.paymentId, null); }); }); // --- Transition planning --- describe("planStripeEventApply", () => { + // Records/outcomes carry the matching snapshot fields a canonical + // session always provides — settlement without them is not testable. const record = (status: string) => ({ status, stripeCheckoutSessionId: "cs_test_123", + amountMinor: 8000, + currency: "usd", eventCount: 2, }); const outcome = (transition: "paid" | "processing" | "failed" | "expired") => ({ transition, + sessionId: "cs_test_123", paymentIntentId: "pi_123", + amountMinor: 8000, + currency: "usd", }); const NOW = new Date("2026-10-03T12:00:00.000Z"); @@ -416,6 +532,49 @@ describe("planStripeEventApply", () => { ); assert.strictEqual(plan.updates.receiptUrl, "https://pay.stripe.com/receipts/x"); }); + + it("quarantines a paid claim when canonical amount/currency/session disagree", () => { + for (const [name, mutated] of [ + ["amount", { ...outcome("paid"), amountMinor: 8001 }], + ["currency", { ...outcome("paid"), currency: "eur" }], + ["session", { ...outcome("paid"), sessionId: "cs_test_other" }], + // Stripe not reporting money facts at all must also fail closed. + ["missing amount", { ...outcome("paid"), amountMinor: undefined }], + ["missing currency", { ...outcome("paid"), currency: undefined }], + ] as const) { + const plan = planStripeEventApply(record("awaiting_payment"), mutated, "webhook", NOW); + assert.strictEqual(plan.apply, true, `${name}: quarantine still writes`); + assert.strictEqual(plan.quarantined !== undefined, true, `${name} quarantined`); + assert.strictEqual(plan.updates.status, undefined, `${name}: never paid`); + assert.strictEqual( + plan.updates.reconciliationIssue, + plan.quarantined, + `${name}: record flagged` + ); + assert.strictEqual(plan.events[0]?.type, "reconciliation_mismatch"); + } + }); + + it("does not duplicate the quarantine audit event on repeat deliveries", () => { + const flagged = { ...record("awaiting_payment"), reconciliationIssue: "amount_mismatch" }; + const plan = planStripeEventApply( + flagged, + { ...outcome("paid"), amountMinor: 8001 }, + "webhook", + NOW + ); + assert.strictEqual(plan.apply, false); + assert.strictEqual(plan.quarantined, "amount_mismatch"); + assert.strictEqual(plan.events.length, 0); + }); + + it("a clean settlement clears a stale reconciliation flag", () => { + const flagged = { ...record("awaiting_payment"), reconciliationIssue: "amount_mismatch" }; + const plan = planStripeEventApply(flagged, outcome("paid"), "webhook", NOW); + assert.strictEqual(plan.apply, true); + assert.strictEqual(plan.updates.status, "paid"); + assert.strictEqual(plan.updates.reconciliationIssue, null); + }); }); // --- Webhook orchestration --- @@ -447,17 +606,31 @@ function fakeStore(seed?: Record>) { } describe("processStripeEvent", () => { - const paidEvent = { - id: "evt_paid", - type: "checkout.session.completed", - data: { object: sessionObject }, + // Records carry the same snapshot the canonical session reports — a + // paid transition only applies when they agree. + const awaitingRecord = { + status: "awaiting_payment", + stripeCheckoutSessionId: "cs_test_123", + amountMinor: 8000, + currency: "usd", + eventCount: 2, }; - it("applies a valid event once and dedupes the replay", async () => { + function resolvedFrom( + event: { id: string; type: string }, + canonical: Record = canonicalSession + ) { + const result = resolveEvent(event, canonical); + assert.strictEqual(result.kind, "resolved"); + if (result.kind !== "resolved") throw new Error("unreachable"); + return result.resolved; + } + + it("applies a canonical-paid event once and dedupes the replay", async () => { const { store, payments, markers } = fakeStore({ - [VALID_ID]: { status: "awaiting_payment", eventCount: 2 }, + [VALID_ID]: { ...awaitingRecord }, }); - const resolved = resolveStripeEventOutcome(paidEvent); + const resolved = resolvedFrom(paidEvent); const first = await processStripeEvent(resolved, store); assert.strictEqual(first.status, "applied"); @@ -468,9 +641,41 @@ describe("processStripeEvent", () => { assert.strictEqual(markers.get("evt_paid"), "applied"); }); + it("a second successful event for the same payment does not settle twice", async () => { + const { store, payments } = fakeStore({ + [VALID_ID]: { ...awaitingRecord, status: "paid", eventCount: 3 }, + }); + const resolved = resolvedFrom({ + id: "evt_async", + type: "checkout.session.async_payment_succeeded", + }); + const result = await processStripeEvent(resolved, store); + assert.strictEqual(result.status, "ignored"); + assert.strictEqual(payments.get(VALID_ID)?.status, "paid"); + }); + + it("quarantines — never pays — when canonical amounts disagree", async () => { + const { store, payments, markers } = fakeStore({ + [VALID_ID]: { ...awaitingRecord }, + }); + const resolved = resolvedFrom(paidEvent, { + ...canonicalSession, + amount_total: 9000, + }); + const result = await processStripeEvent(resolved, store); + assert.strictEqual(result.status, "quarantined"); + assert.strictEqual(result.quarantined, "amount_mismatch"); + assert.strictEqual(payments.get(VALID_ID)?.status, "awaiting_payment"); + assert.strictEqual( + payments.get(VALID_ID)?.reconciliationIssue, + "amount_mismatch" + ); + assert.strictEqual(markers.get("evt_paid"), "mismatch"); + }); + it("acknowledges events for unknown payments without touching records", async () => { const { store, markers, writes } = fakeStore(); - const resolved = resolveStripeEventOutcome(paidEvent); + const resolved = resolvedFrom(paidEvent); const result = await processStripeEvent(resolved, store); assert.strictEqual(result.status, "unknown_payment"); assert.strictEqual(writes.length, 0); @@ -479,13 +684,13 @@ describe("processStripeEvent", () => { it("acknowledges ignored event types without payment writes", async () => { const { store, writes } = fakeStore({ - [VALID_ID]: { status: "awaiting_payment" }, - }); - const resolved = resolveStripeEventOutcome({ - id: "evt_other", - type: "customer.created", - data: { object: sessionObject }, + [VALID_ID]: { ...awaitingRecord }, }); + // Unhandled event types produce a resolved outcome with no transition. + const resolved = resolvedFrom( + { id: "evt_other", type: "customer.created" }, + { object: "customer", id: "cus_9" } + ); const result = await processStripeEvent(resolved, store); assert.strictEqual(result.status, "ignored"); assert.strictEqual(writes.length, 0); @@ -493,13 +698,13 @@ describe("processStripeEvent", () => { it("does not regress a paid record when a late expired event arrives", async () => { const { store, payments } = fakeStore({ - [VALID_ID]: { status: "paid", eventCount: 3 }, - }); - const resolved = resolveStripeEventOutcome({ - id: "evt_expired", - type: "checkout.session.expired", - data: { object: sessionObject }, + [VALID_ID]: { ...awaitingRecord, status: "paid", eventCount: 3 }, }); + // Canonical session says expired — the transition is still refused. + const resolved = resolvedFrom( + { id: "evt_expired", type: "checkout.session.expired" }, + { ...canonicalSession, status: "expired" } + ); const result = await processStripeEvent(resolved, store); assert.strictEqual(result.status, "ignored"); assert.strictEqual(payments.get(VALID_ID)?.status, "paid"); @@ -584,15 +789,134 @@ describe("describePaymentEvent", () => { describePaymentEvent({ type: "payment_canceled" }), "Payment canceled by staff" ); + const flagged = describePaymentEvent({ + type: "reconciliation_mismatch", + details: { reason: "amount_mismatch" }, + }); + assert.ok(flagged.includes("flagged")); + assert.ok(flagged.includes("amount_mismatch")); }); }); describe("outcomeFromSession", () => { it("falls back to client_reference_id when metadata is absent", () => { const { paymentId } = outcomeFromSession( - { ...sessionObject, metadata: null }, + { ...canonicalSession, metadata: null }, "paid" ); assert.strictEqual(paymentId, VALID_ID); }); }); + +// --- Checkout URL allowlist --- + +describe("assertCheckoutSessionUrl", () => { + it("accepts Stripe-hosted checkout URLs", () => { + const url = "https://checkout.stripe.com/c/pay/cs_test_abc#key"; + assert.strictEqual(assertCheckoutSessionUrl(url), url); + // Live-mode hosted pages share the same origin. + assert.strictEqual( + assertCheckoutSessionUrl( + "https://checkout.stripe.com/c/pay/cs_live_xyz" + ), + "https://checkout.stripe.com/c/pay/cs_live_xyz" + ); + }); + + it("rejects insecure, non-Stripe, and malformed URLs", () => { + for (const bad of [ + "http://checkout.stripe.com/c/pay/cs_test_x", + "https://checkout.stripe.com.evil.example/pay", + "https://stripe.com.evil.example/checkout", + "https://pay.example.com/checkout", + "javascript:alert(1)", + "not a url", + "", + null, + undefined, + 42, + ]) { + assert.throws( + () => assertCheckoutSessionUrl(bad), + (error) => error instanceof PaymentProviderError, + `expected ${String(bad)} rejected` + ); + } + }); +}); + +// --- Idempotent-replay payload matching --- + +describe("paymentCreateMatchesRecord", () => { + const input = { + clientRequestId: VALID_ID, + purpose: "brewery_tour_tasting", + description: "Brewery Tour + Tasting", + amountMinor: 8000, + customerName: "Jane Diver", + customerEmail: "jane@example.com", + tourDate: "2026-10-15", + attendeeCount: 4, + internalNote: "Cruise group", + }; + const record = { + purpose: "brewery_tour_tasting", + description: "Brewery Tour + Tasting", + amountMinor: 8000, + customerName: "Jane Diver", + customerEmail: "jane@example.com", + tourDate: "2026-10-15", + attendeeCount: 4, + internalNote: "Cruise group", + status: "awaiting_payment", + }; + + it("an identical retry matches", () => { + assert.strictEqual(paymentCreateMatchesRecord(record, input), true); + }); + + it("any payment-relevant drift conflicts", () => { + const drifts: [string, Record][] = [ + ["amount", { ...record, amountMinor: 8001 }], + ["purpose", { ...record, purpose: "other" }], + ["description", { ...record, description: "Other" }], + ["customer name", { ...record, customerName: "Joan" }], + ["receipt email", { ...record, customerEmail: "joan@example.com" }], + ["missing email", { ...record, customerEmail: undefined }], + ["tour date", { ...record, tourDate: "2026-10-16" }], + ["attendees", { ...record, attendeeCount: 5 }], + ["note", { ...record, internalNote: "Walk-in" }], + ]; + for (const [name, stored] of drifts) { + assert.strictEqual( + paymentCreateMatchesRecord(stored, input), + false, + `${name} drift must conflict` + ); + } + }); +}); + +// --- Provider error normalization --- + +describe("toPaymentProviderError", () => { + it("keeps only the provider's machine code — never the raw message", () => { + const raw = new Error( + "Stripe raw detail: card 4242 declined; request req_123" + ); + (raw as Error & { code?: string }).code = "card_declined"; + const normalized = toPaymentProviderError(raw); + assert.ok(normalized instanceof PaymentProviderError); + assert.strictEqual(normalized.code, "card_declined"); + assert.strictEqual(normalized.status, 502); + assert.ok(!normalized.message.includes("4242")); + assert.ok(!normalized.message.includes("req_123")); + }); + + it("handles non-Stripe failures and passes through already-normalized errors", () => { + assert.strictEqual(toPaymentProviderError("boom").code, "unexpected_provider_error"); + assert.strictEqual(toPaymentProviderError(null).code, "unexpected_provider_error"); + const already = new PaymentProviderError("rate_limit"); + assert.strictEqual(toPaymentProviderError(already), already); + }); +}); diff --git a/tests/lib/stripe-config.test.ts b/tests/lib/stripe-config.test.ts new file mode 100644 index 0000000..8b4511d --- /dev/null +++ b/tests/lib/stripe-config.test.ts @@ -0,0 +1,71 @@ +import { describe, it, afterEach } from "node:test"; +import assert from "node:assert"; +import { + getStripeSecretKey, + getStripeWebhookSecret, + isStripeTestMode, +} from "@/lib/stripe-config"; + +const KEYS = ["STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET", "VERCEL_ENV", "NODE_ENV"] as const; + +function snapshot() { + return Object.fromEntries(KEYS.map((k) => [k, process.env[k]])); +} + +// NODE_ENV is typed read-only; assign via Object.assign (runtime mutable). +function setEnv(key: (typeof KEYS)[number], value: string | undefined) { + if (value === undefined) delete process.env[key]; + else Object.assign(process.env, { [key]: value }); +} + +function restore(saved: Record) { + for (const [key, value] of Object.entries(saved)) { + setEnv(key as (typeof KEYS)[number], value); + } +} + +describe("stripe-config", () => { + const saved = snapshot(); + afterEach(() => restore(saved)); + + it("fails clearly when secrets are missing", () => { + setEnv("STRIPE_SECRET_KEY", undefined); + setEnv("STRIPE_WEBHOOK_SECRET", undefined); + assert.throws(() => getStripeSecretKey(), /STRIPE_SECRET_KEY/); + assert.throws(() => getStripeWebhookSecret(), /STRIPE_WEBHOOK_SECRET/); + }); + + it("accepts test keys anywhere and live keys only in production", () => { + // Key strings are assembled, never literal — push protection must not + // see anything shaped like a real credential in the repo. + const testKey = "sk_" + "test_fixture"; + const liveKey = "sk_" + "live_fixture"; + const liveRestricted = "rk_" + "live_fixture"; + + setEnv("STRIPE_SECRET_KEY", testKey); + setEnv("NODE_ENV", "development"); + assert.strictEqual(getStripeSecretKey(), testKey); + + // Live key in local dev must fail closed. + setEnv("STRIPE_SECRET_KEY", liveKey); + assert.throws(() => getStripeSecretKey(), /not allowed outside production/); + + // ... and in Vercel preview deployments. + setEnv("NODE_ENV", "production"); + setEnv("VERCEL_ENV", "preview"); + assert.throws(() => getStripeSecretKey(), /not allowed outside production/); + setEnv("STRIPE_SECRET_KEY", liveRestricted); + assert.throws(() => getStripeSecretKey(), /not allowed outside production/); + + // Production allows the live key. + setEnv("VERCEL_ENV", undefined); + setEnv("STRIPE_SECRET_KEY", liveKey); + assert.strictEqual(getStripeSecretKey(), liveKey); + }); + + it("derives test mode from the key prefix", () => { + assert.strictEqual(isStripeTestMode("sk_" + "test_x"), true); + assert.strictEqual(isStripeTestMode("rk_" + "test_x"), true); + assert.strictEqual(isStripeTestMode("sk_" + "live_x"), false); + }); +}); From b0ff61e4f2cc302e233280b078d7d488f8884cb5 Mon Sep 17 00:00:00 2001 From: Chad Nuttall Date: Sat, 3 Oct 2026 21:14:37 -0400 Subject: [PATCH 04/11] docs(ops): link deployment env vars to credential-rotation guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on PR #156 — deployment.md repeated the per-scope Stripe key and webhook-secret guidance; link to the authoritative rotation doc instead. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- docs/operations/deployment.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/operations/deployment.md b/docs/operations/deployment.md index 9bc5f4c..29b1ce6 100644 --- a/docs/operations/deployment.md +++ b/docs/operations/deployment.md @@ -75,8 +75,8 @@ Examples: - `FIREBASE_ADMIN_PRIVATE_KEY` - `ADMIN_REBUILD_COOLDOWN_MS` - `SUPER_ADMIN_EMAIL` — the verified Google account that receives the initial superadmin role -- `STRIPE_SECRET_KEY` — Stripe secret key for the admin payments feature; use the `sk_test_*` key in Development/Preview and `sk_live_*` in Production -- `STRIPE_WEBHOOK_SECRET` — Stripe webhook signing secret for `POST /api/webhooks/stripe` (one signing secret per registered endpoint) +- `STRIPE_SECRET_KEY` — Stripe secret key for the admin payments feature (scope and rotation: see [credential-rotation.md](credential-rotation.md)) +- `STRIPE_WEBHOOK_SECRET` — Stripe webhook signing secret for `POST /api/webhooks/stripe` (see [credential-rotation.md](credential-rotation.md)) ## How to verify a preview with populated Firebase data From 5707c398553bbc2d48b2e8969722e403d1e04693 Mon Sep 17 00:00:00 2001 From: Chad Nuttall Date: Sat, 3 Oct 2026 21:34:33 -0400 Subject: [PATCH 05/11] fix(admin): preview-aware Checkout return URLs and card-only payments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sandbox E2E on the preview deployment exposed two gaps: - Checkout success/cancel URLs always pointed at the canonical production host, where /pay/* does not exist until this branch ships, so a preview test landed on a 404 after paying. Add resolveCheckoutReturnBaseUrl() which derives the return origin from trusted Vercel deployment env (VERCEL_BRANCH_URL ?? VERCEL_URL on previews), keeps production canonical, and never accepts caller-supplied origins. - Checkout inherited the Stripe account's automatic payment methods, offering delayed-settlement options (BNPL, bank debit) that a counter tool cannot safely expose — a customer could leave while the charge is still pending. Constrain payment_method_types to ["card"], matching the Sea Saba reference; the async_payment_* webhook handlers stay wired defensively. Also documents the Vercel Deployment Protection lesson for preview webhooks (third-party deliveries get 401 until a narrow bypass is added) and the webhook-vs-manual-refresh history attribution. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- docs/TECHNICAL.md | 14 +++- docs/admin/payments.md | 2 +- docs/operations/credential-rotation.md | 17 ++++- lib/payments-admin.ts | 13 ++-- lib/payments-common.ts | 7 ++ lib/stripe-config.ts | 42 ++++++++++++ tests/lib/payments-common.test.ts | 4 ++ tests/lib/stripe-config.test.ts | 89 +++++++++++++++++++++++++- 8 files changed, 178 insertions(+), 10 deletions(-) diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md index a4f1155..23b8d51 100644 --- a/docs/TECHNICAL.md +++ b/docs/TECHNICAL.md @@ -747,13 +747,21 @@ listed in `serverExternalPackages` alongside `firebase-admin`. optional email/tour-date/attendees/note bounds), then writes the `payments/{clientRequestId}` record + `payment_created` event in a transaction (existing doc → replay/recovery, never overwrite), creates - the Checkout Session with `idempotencyKey: paymentId` and + the Checkout Session with `idempotencyKey: paymentId`, + `payment_method_types: ["card"]` (counter charges must settle + immediately — async/delayed methods are deliberately not offered; the + async_payment_* webhook handlers stay wired defensively), and `metadata.paymentId` + `client_reference_id` (no PII in metadata), and records the session id/url + `livemode` + `checkout_session_created` event. Session params are built from the **stored** record (`buildCheckoutSessionSpec`), so a browser can never tamper with the - charged amount. Success/cancel URLs are the static, noindex - `/pay/complete` and `/pay/cancelled` pages — they carry no state. + charged amount. Success/cancel URLs point at the static, noindex + `/pay/complete` and `/pay/cancelled` pages — they carry no state — on + the origin returned by `resolveCheckoutReturnBaseUrl` + (`lib/stripe-config.ts`): the Vercel preview host on preview + deployments (`VERCEL_BRANCH_URL ?? VERCEL_URL`), the canonical site + origin in production, localhost in dev. Server-derived only — no + caller-supplied return origin. 2. **Collect:** the workspace (`components/admin-payments-workspace.tsx` behind `AdminAuthGate`) shows the session URL with copy, QR (`GET /api/admin/payments/[id]/qr` renders it server-side via `qrcode`), diff --git a/docs/admin/payments.md b/docs/admin/payments.md index ed96895..ee1dab4 100644 --- a/docs/admin/payments.md +++ b/docs/admin/payments.md @@ -54,7 +54,7 @@ The **Receipt email** field pre-fills the customer's email on the Stripe Checkou ## Payment detail -Selecting a payment shows everything the app recorded: amount, purpose, description, customer, receipt email, tour date, attendees, who created it and when, the Stripe identifiers, receipt link, and an append-only **History** (created, link issued, succeeded/failed/expired, canceled, flagged-for-review). +Selecting a payment shows everything the app recorded: amount, purpose, description, customer, receipt email, tour date, attendees, who created it and when, the Stripe identifiers, receipt link, and an append-only **History** (created, link issued, succeeded/failed/expired, canceled, flagged-for-review). History entries marked "(manual refresh)" were applied by a staff refresh; unmarked status changes came from Stripe's webhook. ## Test mode diff --git a/docs/operations/credential-rotation.md b/docs/operations/credential-rotation.md index 745f795..b6f795c 100644 --- a/docs/operations/credential-rotation.md +++ b/docs/operations/credential-rotation.md @@ -318,7 +318,22 @@ shell history, or committed files. `STRIPE_SECRET_KEY` powers the admin payments feature (`/admin/payments` — Checkout Session create/retrieve/expire). `STRIPE_WEBHOOK_SECRET` verifies signatures on `POST /api/webhooks/stripe`. Both are server-only; there is deliberately **no** publishable key — card entry happens only on Stripe's hosted Checkout page. -**Test vs live:** `sk_test_*`/`sk_live_*` keys and their corresponding `whsec_*` webhook secrets are independent. Development/Preview should use test credentials; Production uses live. Payments record Stripe's `livemode` flag, and the UI badges test-mode payments — a mismatch between key mode and deployment is visible, not silent. +**Test vs live:** `sk_test_*`/`sk_live_*` keys and their corresponding `whsec_*` webhook secrets are independent. Development/Preview **must** use test credentials — the server refuses live keys outside production (`getStripeSecretKey` throws in dev/preview). Production uses live. Payments record Stripe's `livemode` flag, and the UI badges test-mode payments — a mismatch between key mode and deployment is visible, not silent. + +**Preview webhook endpoints:** Stripe cannot reach a Vercel preview +deployment while Deployment Protection is on — deliveries return `401 +Protected by Vercel Authentication` before reaching the app. To test the +webhook on a preview branch, register a *separate* Stripe webhook endpoint +(test mode) pointing at the preview/branch alias URL +(`https://.vercel.app/api/webhooks/stripe`) and add a narrow +protection exception for that path (Vercel → Deployment Protection → +protection bypass or trusted path), never disable protection globally. The +endpoint itself stays protected by Stripe signature verification — it +rejects unsigned or badly-signed POSTs with 400 regardless. Verify a +delivery in Stripe Dashboard → Webhooks → the endpoint's deliveries list; +a `200` with `result` of `applied`/`duplicate` means the event was durably +recorded. Checkout return/cancel URLs on previews already point back at +the preview host automatically (`resolveCheckoutReturnBaseUrl`). ### Steps — secret key diff --git a/lib/payments-admin.ts b/lib/payments-admin.ts index c498c7f..bbb95c9 100644 --- a/lib/payments-admin.ts +++ b/lib/payments-admin.ts @@ -2,8 +2,10 @@ import "server-only"; import { FieldValue, Timestamp } from "firebase-admin/firestore"; import { getFirebaseAdminDb } from "@/lib/firebase-admin-db"; import { getStripeClient } from "@/lib/stripe"; -import { getStripeWebhookSecret } from "@/lib/stripe-config"; -import { siteUrl } from "@/lib/site"; +import { + getStripeWebhookSecret, + resolveCheckoutReturnBaseUrl, +} from "@/lib/stripe-config"; import { logInfo, logWarn } from "@/lib/log"; import { assertCheckoutSessionUrl, @@ -193,6 +195,9 @@ export async function createAdminPayment( } const stored = seed.data; + // Return/cancel URLs go to the *deployment's* origin — a preview + // deployment's /pay/* routes exist only on the preview host. + const returnBase = resolveCheckoutReturnBaseUrl(); const spec = buildCheckoutSessionSpec({ paymentId: ref.id, purpose: String(stored.purpose ?? input.purpose), @@ -202,8 +207,8 @@ export async function createAdminPayment( typeof stored.customerEmail === "string" ? stored.customerEmail : undefined, - successUrl: `${siteUrl}/pay/complete`, - cancelUrl: `${siteUrl}/pay/cancelled`, + successUrl: `${returnBase}/pay/complete`, + cancelUrl: `${returnBase}/pay/cancelled`, }); // Idempotency key = payment id: a Stripe retry of this call returns the diff --git a/lib/payments-common.ts b/lib/payments-common.ts index fa92aae..cc0fe68 100644 --- a/lib/payments-common.ts +++ b/lib/payments-common.ts @@ -606,6 +606,12 @@ export interface CheckoutSessionSpec { mode: "payment"; client_reference_id: string; customer_email?: string; + // Card-only, intentionally: this is a counter tool that must answer + // "paid now?" — async/delayed methods (bank debits, BNPL) settle days + // later and would leave a charge looking unpaid while the customer + // walks out. The async_payment_* webhook handlers stay wired + // defensively in case account settings ever broaden this. + payment_method_types: ["card"]; line_items: { quantity: number; price_data: { @@ -640,6 +646,7 @@ export function buildCheckoutSessionSpec(args: { return { mode: "payment", client_reference_id: args.paymentId, + payment_method_types: ["card"], ...(args.customerEmail ? { customer_email: args.customerEmail } : {}), line_items: [ { diff --git a/lib/stripe-config.ts b/lib/stripe-config.ts index dc71b2d..2e22319 100644 --- a/lib/stripe-config.ts +++ b/lib/stripe-config.ts @@ -3,6 +3,7 @@ // dependency chain. Only server modules (lib/stripe.ts, the payments API // routes, the webhook route) import these; the secret key must never reach // a client component or a NEXT_PUBLIC_* variable. +import { resolveSiteUrl, siteUrl } from "@/lib/site"; const LIVE_KEY_PREFIXES = ["sk_live_", "rk_live_"]; @@ -49,3 +50,44 @@ export function isStripeTestMode( ): boolean { return secretKey.startsWith("sk_test_") || secretKey.startsWith("rk_test_"); } + +// The origin Stripe-hosted Checkout returns the customer's browser to +// after paying or cancelling. Server-derived from deployment env only — +// never caller-supplied — so a crafted request can never turn the +// return/cancel links into an open redirect. +// +// - Vercel preview: the deployment's own host (VERCEL_BRANCH_URL prefers +// the stable branch alias, VERCEL_URL the per-deployment host). The +// canonical host's /pay/* routes belong to production; a preview +// Checkout must return to the preview deployment being tested. +// - Local dev (`next dev` or `vercel dev`): the configured site URL when +// deliberately set (e.g. a tunnel for end-to-end webhook testing), +// otherwise the dev server origin — the only place http is permitted. +// - Everything else (production, `next start`, non-Vercel): the canonical +// site origin, which is always HTTPS. +export function resolveCheckoutReturnBaseUrl(): string { + if (process.env.VERCEL_ENV === "preview") { + const host = process.env.VERCEL_BRANCH_URL || process.env.VERCEL_URL; + if (host) return `https://${host}`; + // A preview without a Vercel host is impossible in practice — fall + // through to the canonical origin rather than emit a malformed URL. + } + if ( + process.env.VERCEL_ENV === "development" || + process.env.NODE_ENV === "development" + ) { + if (process.env.NEXT_PUBLIC_SITE_URL) { + // Re-resolve at call time — the module-level `siteUrl` constant is + // frozen at import and cannot see per-process overrides. + const configured = resolveSiteUrl(); + if ( + configured.startsWith("https://") || + configured.startsWith("http://localhost") + ) { + return configured; + } + } + return "http://localhost:3000"; + } + return siteUrl; +} diff --git a/tests/lib/payments-common.test.ts b/tests/lib/payments-common.test.ts index befb67a..a19c3c5 100644 --- a/tests/lib/payments-common.test.ts +++ b/tests/lib/payments-common.test.ts @@ -207,6 +207,10 @@ describe("buildCheckoutSessionSpec", () => { assert.strictEqual(spec.line_items[0].quantity, 1); }); + it("offers card payments only — async/delayed methods are not wired", () => { + assert.deepStrictEqual(spec.payment_method_types, ["card"]); + }); + it("correlates by internal payment id without PII in metadata", () => { assert.strictEqual(spec.client_reference_id, VALID_ID); assert.strictEqual(spec.metadata.paymentId, VALID_ID); diff --git a/tests/lib/stripe-config.test.ts b/tests/lib/stripe-config.test.ts index 8b4511d..88bde13 100644 --- a/tests/lib/stripe-config.test.ts +++ b/tests/lib/stripe-config.test.ts @@ -4,9 +4,18 @@ import { getStripeSecretKey, getStripeWebhookSecret, isStripeTestMode, + resolveCheckoutReturnBaseUrl, } from "@/lib/stripe-config"; -const KEYS = ["STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET", "VERCEL_ENV", "NODE_ENV"] as const; +const KEYS = [ + "STRIPE_SECRET_KEY", + "STRIPE_WEBHOOK_SECRET", + "VERCEL_ENV", + "VERCEL_URL", + "VERCEL_BRANCH_URL", + "NODE_ENV", + "NEXT_PUBLIC_SITE_URL", +] as const; function snapshot() { return Object.fromEntries(KEYS.map((k) => [k, process.env[k]])); @@ -69,3 +78,81 @@ describe("stripe-config", () => { assert.strictEqual(isStripeTestMode("sk_" + "live_x"), false); }); }); + +describe("resolveCheckoutReturnBaseUrl", () => { + const saved = snapshot(); + afterEach(() => restore(saved)); + + it("production always returns the canonical https origin", () => { + setEnv("VERCEL_ENV", "production"); + setEnv("NODE_ENV", "production"); + setEnv("VERCEL_URL", "ddb-abc123.vercel.app"); + setEnv("VERCEL_BRANCH_URL", "ddb-branch.vercel.app"); + setEnv("NEXT_PUBLIC_SITE_URL", undefined); + assert.strictEqual( + resolveCheckoutReturnBaseUrl(), + "https://deepdivebrewing.com" + ); + }); + + it("preview deployments return to their own host, branch alias preferred", () => { + setEnv("VERCEL_ENV", "preview"); + setEnv("NODE_ENV", "production"); + setEnv("VERCEL_URL", "ddb-abc123.vercel.app"); + setEnv( + "VERCEL_BRANCH_URL", + "deepdivebrewing-web-git-feat-155-x.vercel.app" + ); + assert.strictEqual( + resolveCheckoutReturnBaseUrl(), + "https://deepdivebrewing-web-git-feat-155-x.vercel.app" + ); + + // Falls back to the per-deployment host when no branch alias exists. + setEnv("VERCEL_BRANCH_URL", undefined); + assert.strictEqual( + resolveCheckoutReturnBaseUrl(), + "https://ddb-abc123.vercel.app" + ); + }); + + it("preview return URLs are always https and never user-supplied", () => { + setEnv("VERCEL_ENV", "preview"); + setEnv("VERCEL_BRANCH_URL", "evil.example.com"); + // The host itself comes from Vercel-provided env (trusted deployment + // config, not request input) and always gets the https scheme. + const url = resolveCheckoutReturnBaseUrl(); + assert.ok(url.startsWith("https://")); + assert.ok(!url.startsWith("http://")); + }); + + it("local dev defaults to the dev server origin", () => { + setEnv("VERCEL_ENV", undefined); + setEnv("NODE_ENV", "development"); + setEnv("NEXT_PUBLIC_SITE_URL", undefined); + assert.strictEqual( + resolveCheckoutReturnBaseUrl(), + "http://localhost:3000" + ); + }); + + it("local dev honors a deliberately configured https site URL (e.g. a tunnel)", () => { + setEnv("VERCEL_ENV", undefined); + setEnv("NODE_ENV", "development"); + setEnv("NEXT_PUBLIC_SITE_URL", "https://ddb-tunnel.example.com"); + assert.strictEqual( + resolveCheckoutReturnBaseUrl(), + "https://ddb-tunnel.example.com" + ); + }); + + it("a non-localhost http override is ignored in dev", () => { + setEnv("VERCEL_ENV", undefined); + setEnv("NODE_ENV", "development"); + setEnv("NEXT_PUBLIC_SITE_URL", "http://insecure.example.com"); + assert.strictEqual( + resolveCheckoutReturnBaseUrl(), + "http://localhost:3000" + ); + }); +}); From 43f4f2649ed535316d347388c7b4b537e33859a7 Mon Sep 17 00:00:00 2001 From: Chad Nuttall Date: Sat, 3 Oct 2026 21:45:23 -0400 Subject: [PATCH 06/11] test(admin): assert production return base against resolved siteUrl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit nit: clearing NEXT_PUBLIC_SITE_URL in the test cannot undo the import-time module constant — assert the contract (production returns the canonical resolved site origin) instead. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/lib/stripe-config.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/lib/stripe-config.test.ts b/tests/lib/stripe-config.test.ts index 88bde13..3c84d9d 100644 --- a/tests/lib/stripe-config.test.ts +++ b/tests/lib/stripe-config.test.ts @@ -6,6 +6,7 @@ import { isStripeTestMode, resolveCheckoutReturnBaseUrl, } from "@/lib/stripe-config"; +import { siteUrl } from "@/lib/site"; const KEYS = [ "STRIPE_SECRET_KEY", @@ -83,16 +84,17 @@ describe("resolveCheckoutReturnBaseUrl", () => { const saved = snapshot(); afterEach(() => restore(saved)); - it("production always returns the canonical https origin", () => { + it("production always returns the canonical site origin", () => { setEnv("VERCEL_ENV", "production"); setEnv("NODE_ENV", "production"); setEnv("VERCEL_URL", "ddb-abc123.vercel.app"); setEnv("VERCEL_BRANCH_URL", "ddb-branch.vercel.app"); setEnv("NEXT_PUBLIC_SITE_URL", undefined); - assert.strictEqual( - resolveCheckoutReturnBaseUrl(), - "https://deepdivebrewing.com" - ); + // `siteUrl` is resolved once at module load, so assert the contract + // (production returns the canonical site origin), not a recomputation + // of the env var the module already froze. + assert.strictEqual(resolveCheckoutReturnBaseUrl(), siteUrl); + assert.ok(siteUrl.startsWith("https://")); }); it("preview deployments return to their own host, branch alias preferred", () => { From ea1805dec0a92576b38a8d18f7a3048c95718c6d Mon Sep 17 00:00:00 2001 From: Chad Nuttall Date: Sun, 4 Oct 2026 08:34:34 -0400 Subject: [PATCH 07/11] docs(payments): align Stripe wording with card-rail policy, add ops runbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reword "card-only" comments/tests to the card-payment-rail policy: Link/Apple Pay/Google Pay ride the card rail and may appear by eligibility; delayed/BNPL/bank methods stay unwired. - Clarify receipt_email supplies the address only — delivery is governed by Stripe account email settings (test mode never sends mail). - Document ignored-vs-duplicate webhook semantics at the route and add a regression test for the manual-refresh race: first delivery of an event on an already-paid record is `ignored` (no writes), a resend of the same event id is `duplicate` (still no writes, no regression). - Add docs/operations/payments.md: payment-method policy, receipts, webhook result semantics, end-to-end webhook verification procedure, API-version pinning note, Stripe branding checklist, and the manual production activation checklist. Cross-link from admin/deployment/ credential-rotation/observability docs and README; record the two-API- version split in TECHNICAL.md. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 2 + app/api/webhooks/stripe/route.ts | 5 +- docs/TECHNICAL.md | 22 ++- docs/admin/payments.md | 8 +- docs/operations/credential-rotation.md | 2 + docs/operations/deployment.md | 5 +- docs/operations/observability.md | 2 +- docs/operations/payments.md | 181 +++++++++++++++++++++++++ lib/payments-common.ts | 19 ++- tests/lib/payments-common.test.ts | 40 +++++- 10 files changed, 267 insertions(+), 19 deletions(-) create mode 100644 docs/operations/payments.md 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/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts index cac8d5c..0310a9c 100644 --- a/app/api/webhooks/stripe/route.ts +++ b/app/api/webhooks/stripe/route.ts @@ -47,7 +47,10 @@ export async function POST(req: NextRequest) { }); } // 200 for applied/duplicate/ignored/unknown/quarantined alike: Stripe - // must not retry deliveries we have durably accounted for. + // 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. diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md index 23b8d51..8e2bb6f 100644 --- a/docs/TECHNICAL.md +++ b/docs/TECHNICAL.md @@ -748,9 +748,13 @@ listed in `serverExternalPackages` alongside `firebase-admin`. `payments/{clientRequestId}` record + `payment_created` event in a transaction (existing doc → replay/recovery, never overwrite), creates the Checkout Session with `idempotencyKey: paymentId`, - `payment_method_types: ["card"]` (counter charges must settle - immediately — async/delayed methods are deliberately not offered; the - async_payment_* webhook handlers stay wired defensively), and + `payment_method_types: ["card"]` — Checkout stays on the card payment + rail so charges settle immediately (accelerated card methods such as + Link, Apple Pay, and Google Pay ride this rail and may appear by + customer device/account eligibility; delayed/BNPL/bank methods are + deliberately not offered, and an explicit `payment_method_types` list + overrides Dashboard dynamic payment-method settings so none can leak + in). The async_payment_* webhook handlers stay wired defensively, and `metadata.paymentId` + `client_reference_id` (no PII in metadata), and records the session id/url + `livemode` + `checkout_session_created` event. Session params are built from the **stored** record @@ -794,6 +798,18 @@ listed in `serverExternalPackages` alongside `firebase-admin`. 4. **Mode:** `livemode` from the Stripe object is stored per payment; the UI badges test-mode payments. No analytics events carry payment data. +Two Stripe API versions coexist by design: the registered webhook +endpoint is pinned to `2023-10-16` (payloads arrive under it) while the +installed `stripe` SDK pins its own version for outbound calls +(`2026-08-26.dahlia` at stripe v22). This is safe because the payload +contributes only ids — every financial fact is re-fetched canonically +under the SDK version. Receipt emails are governed by the Stripe +account's email settings (test mode never sends mail); the stored hosted +`receiptUrl` is the staff-visible fallback. Operational setup — +payment-method policy, webhook-verification procedure, branding, and the +production activation checklist — lives in +[docs/operations/payments.md](operations/payments.md). + ## 12. Analytics and observability - **GA4 via GTM:** `app/layout.tsx` renders `components/gtm-bootstrap.tsx` diff --git a/docs/admin/payments.md b/docs/admin/payments.md index ee1dab4..bb39e89 100644 --- a/docs/admin/payments.md +++ b/docs/admin/payments.md @@ -20,6 +20,8 @@ The workspace has two halves: > The customer **never types their card into this app**. Card entry happens only on Stripe's hosted page — the app never sees, stores, or logs card numbers. +On Stripe's page the customer can pay by card. Depending on their device and Stripe account they may also see faster card-based options — Apple Pay, Google Pay, or Link — which settle immediately just like a card. Bank transfers and pay-later options are intentionally not offered: the brewery needs to know the money arrived before the customer walks out. + ## Getting the customer to pay Once created, the payment shows a live **payment link** with three ways to reach it: @@ -50,7 +52,7 @@ While a payment is **Awaiting payment** (or still setting up), use **Cancel paym ## Receipts -The **Receipt email** field pre-fills the customer's email on the Stripe Checkout page and associates it with the payment. Stripe emails a receipt automatically when the payment succeeds **if** automatic receipts for successful payments are enabled in the Stripe Dashboard (Dashboard → Settings → Emails → "Email customers about successful payments"). After a payment is **Paid**, the detail view also shows a **View receipt** link to Stripe's hosted receipt page — usable regardless of email settings — plus the card brand and last four digits, the only card details ever stored, and only as display metadata. +The **Receipt email** field pre-fills the customer's email on the Stripe Checkout page and associates it with the payment. Stripe emails a receipt automatically when the payment succeeds **if** automatic receipts for successful payments are enabled in the Stripe Dashboard (Dashboard → Settings → Emails → "Email customers about successful payments"). Stripe never sends email in test mode, so use **View receipt** when checking a test payment. After a payment is **Paid**, the detail view also shows a **View receipt** link to Stripe's hosted receipt page — the reliable fallback regardless of email settings — plus the card brand and last four digits, the only card details ever stored, and only as display metadata. ## Payment detail @@ -66,3 +68,7 @@ When the app runs with Stripe **test** credentials, a **Test mode** badge appear - **The link doesn't work** — links expire after about 24 hours or when canceled. Create a fresh payment. - **A payment shows "Needs attention"** — Stripe's report contradicted what was created (for example a different amount or currency), so the app refused to mark it paid. Check the session in the Stripe Dashboard and tell an owner. - **A charge happened twice** — it shouldn't: retries reuse the same payment record and Stripe session. Report it to an owner with the payment's Stripe session id from the detail view. + +## Operations reference + +Setup, webhook verification, receipts/branding configuration, and the production activation checklist live in [docs/operations/payments.md](../operations/payments.md). diff --git a/docs/operations/credential-rotation.md b/docs/operations/credential-rotation.md index b6f795c..cea434b 100644 --- a/docs/operations/credential-rotation.md +++ b/docs/operations/credential-rotation.md @@ -355,6 +355,8 @@ the preview host automatically (`resolveCheckoutReturnBaseUrl`). - [SECURITY.md](../../SECURITY.md) — reporting, public-vs-secret classification, boundary summary. - [Deployment guide](./deployment.md) — env-var scopes, rebuild behavior. +- [Payments operations](./payments.md) — Stripe webhook registration, + verification, and production activation checklist. - [Troubleshooting](./troubleshooting.md) — including the admin-lockout recovery path. - [docs/TECHNICAL.md](../TECHNICAL.md) §13 — full variable reference. diff --git a/docs/operations/deployment.md b/docs/operations/deployment.md index 29b1ce6..ff318c5 100644 --- a/docs/operations/deployment.md +++ b/docs/operations/deployment.md @@ -75,8 +75,8 @@ Examples: - `FIREBASE_ADMIN_PRIVATE_KEY` - `ADMIN_REBUILD_COOLDOWN_MS` - `SUPER_ADMIN_EMAIL` — the verified Google account that receives the initial superadmin role -- `STRIPE_SECRET_KEY` — Stripe secret key for the admin payments feature (scope and rotation: see [credential-rotation.md](credential-rotation.md)) -- `STRIPE_WEBHOOK_SECRET` — Stripe webhook signing secret for `POST /api/webhooks/stripe` (see [credential-rotation.md](credential-rotation.md)) +- `STRIPE_SECRET_KEY` — Stripe secret key for the admin payments feature (scope and rotation: see [credential-rotation.md](credential-rotation.md); activation checklist: [payments.md](payments.md)) +- `STRIPE_WEBHOOK_SECRET` — Stripe webhook signing secret for `POST /api/webhooks/stripe` (see [credential-rotation.md](credential-rotation.md) and [payments.md](payments.md)) ## How to verify a preview with populated Firebase data @@ -201,6 +201,7 @@ You can also check the response headers of the live site for build information, ## Related guides - [Post-deployment checklist](./post-deployment-checklist.md) +- [Payments operations](./payments.md) — Stripe activation, webhooks, receipts - [Credential rotation runbook](./credential-rotation.md) - [Troubleshooting](./troubleshooting.md) - [Admin handbook](../admin/README.md) diff --git a/docs/operations/observability.md b/docs/operations/observability.md index 4d3ca9b..8454e43 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -247,7 +247,7 @@ emits **single-line JSON** so Vercel's log search can filter reliably: | `payment.{create,get,cancel,refresh,qr}_failed` / `payments.list_failed` | Unhandled errors in the payments API routes | | `payment.enrichment_failed` | Paid-payment receipt/card-brand enrichment from Stripe failed — the payment stays `paid`; only display metadata is missing (warn) | | `payment.refresh_id_mismatch` | A Stripe session retrieved during refresh had the wrong `paymentId` — treated as a defensive anomaly, not an update (warn) | -| `stripe_webhook.processed` | Signed Stripe event handled (`eventType`, `paymentId`, `result` — `applied`, `duplicate`, `ignored`, `unknown_payment`, or `quarantined`) | +| `stripe_webhook.processed` | Signed Stripe event handled (`eventType`, `paymentId`, `result` — `applied`, `duplicate`, `ignored`, `unknown_payment`, or `quarantined`; see [payments.md](./payments.md#webhook-result-semantics) for what each means) | | `stripe_webhook.deferred` | Canonical Checkout Session state had not settled for the delivered event type — answered 500 so Stripe redelivers (warn) | | `stripe_webhook.quarantined` | Canonical session contradicted the stored snapshot (session/amount/currency) — settlement refused, `reconciliation_mismatch` event written, needs staff review (warn) | | `stripe_webhook.unknown_payment` | Signed event referenced a `paymentId` with no record — acked 200 so Stripe does not retry it forever (warn) | diff --git a/docs/operations/payments.md b/docs/operations/payments.md new file mode 100644 index 0000000..11c5de3 --- /dev/null +++ b/docs/operations/payments.md @@ -0,0 +1,181 @@ +# Payments operations + +Operational reference for the admin payments feature (`/admin/payments`, +issue #155). Staff-facing usage lives in +[docs/admin/payments.md](../admin/payments.md); the implementation is +described in [docs/TECHNICAL.md](../TECHNICAL.md#admin-payments-adminpayments-issue-155) +§11. This page covers configuration and verification only — it is not app +code. + +## Payment-method policy + +Stripe Checkout is restricted to the card payment rail +(`payment_method_types: ["card"]` in `buildCheckoutSessionSpec`). The policy: + +- **Accepted:** immediate card-based methods Stripe presents through the + card rail — normal card entry, plus eligible accelerated card methods + such as Link, Apple Pay, and Google Pay, which appear depending on + customer device/account eligibility. All of these settle immediately. +- **Not intentionally enabled:** delayed/asynchronous methods — ACH/bank + debit, Klarna, Affirm, and similar BNPL or bank-payment methods. Counter + charges must settle while the customer is present; a method that + confirms days later would leave a charge looking unpaid. + +Specifying `payment_method_types` explicitly overrides the Dashboard's +dynamic payment-method settings, so enabling e.g. Klarna in the Stripe +Dashboard does **not** surface it — only methods listed in code are +offered. Do not switch to Dashboard-managed (dynamic) payment methods +without re-deciding this policy, and do not try to suppress Link or +wallets that ride the card rail. + +The `checkout.session.async_payment_succeeded` / +`checkout.session.async_payment_failed` webhook handlers stay wired as +defensive coverage: they cannot fire under the current card-only +configuration, but they keep the state machine correct if the account's +payment-method settings are ever broadened, and the canonical-session +decision treats them identically to other Checkout events either way. + +## Receipts + +- The app passes the customer's email to Stripe (`customer_email` + + `payment_intent_data.receipt_email`) and stores Stripe's hosted + `receipt_url` once the payment settles. Staff see a **View receipt** + link on a paid payment's detail view — this is the reliable fallback + and works regardless of email settings. +- **Automatic receipt emails depend on the Stripe account's email + settings**, not just the app. For customers to receive a receipt email + in production, enable it in Stripe Dashboard → Settings → Emails → + "Email customers about successful payments". `receipt_email` supplies + the address; it does not force the account-level feature on. +- **Stripe test mode never sends email.** No receipt email arriving for a + test payment is expected behavior, not a bug — use **View receipt** to + verify. +- Stripe remains the receipt provider for Stripe payments. The app does + not send its own receipts (no Resend receipt flow exists or is wanted). + +## Webhook result semantics + +`POST /api/webhooks/stripe` answers `{ ok: true, result: ... }` with one +of: + +| Result | Meaning | +| --- | --- | +| `applied` | The event produced a recorded state transition (and any `paid` enrichment) atomically with its dedupe marker. | +| `ignored` | A valid delivery that needed no state transition — unhandled event type, no payment reference, or the record was already at/past the target state. **Valid outcome, not an error.** A common cause: a staff **Refresh status** settled the payment moments before the webhook arrived. | +| `duplicate` | This exact Stripe **event id** was already durably processed — a Stripe resend/retry. Exits before touching the payment. | +| `unknown_payment` | The event's `paymentId` has no record (foreign session or deleted record) — acked so Stripe stops retrying; logged as a warning. | +| `quarantined` | Canonical session contradicted the stored snapshot (session/amount/currency) — settlement refused, `reconciliation_mismatch` recorded, needs staff review. | +| `retry` (HTTP 500) | Canonical session state had not settled for the delivered event type — deliberately unanswered so Stripe redelivers. | + +Resend model: the **first** delivery of an event id resolves to +`applied`/`ignored`/`unknown_payment`/`quarantined` depending on state; +every later delivery of the **same event id** resolves to `duplicate`. +Neither `ignored` nor `duplicate` writes payment updates or a second +`payment_succeeded` history entry — the dedupe marker and the state +machine together make double settlement impossible. Manual refresh and +the webhook share one planner, so whichever path reaches canonical Stripe +state first wins, and the loser safely no-ops; the history shows +"(manual refresh)" attribution when staff action settled the payment. + +## Verifying webhook delivery end-to-end (manual) + +The workspace polls Stripe while a payment detail view is open, so the +webhook path itself is proven only when nothing is polling. To confirm +webhook-driven settlement on a deployed environment: + +1. Create a fresh payment in `/admin/payments` — note its id, then + navigate away (do **not** keep its detail view open, or the view's own + polling may reconcile first). +2. Complete the Stripe Checkout in a separate tab/device (test card in + test mode). +3. Confirm the delivery in Stripe Dashboard → Developers → Webhooks → the + endpoint → the event shows a 2xx response. +4. Open the payment detail afterward and confirm the status is **Paid**. +5. In the history, `Payment succeeded` appears **without** the + "(manual refresh)" suffix — proof the webhook path applied it. +6. Optional: resend the same event from the Dashboard and confirm the + response is `duplicate` with no second `payment_succeeded` entry. + +If a manual refresh legitimately beats the webhook by a moment, the +webhook resolves `ignored` — see the semantics table above; that is +correct behavior, not a missed delivery. + +## Stripe API versions + +Two versions coexist deliberately — do not read a dashboard "Latest API +version" banner as a prompt to change either: + +- **Webhook endpoint** (Stripe Dashboard → Developers → Webhooks): pinned + to `2023-10-16`. Event payloads arrive serialized under that version. +- **SDK calls** (`getStripeClient()`): pinned by the installed `stripe` + package (`2026-08-26.dahlia` at stripe v22) — this is why most + Dashboard request logs show the newer version. + +This is safe by design: the webhook reads only the event id/type and the +Checkout Session id from the payload — fields stable across versions — +then **re-fetches the canonical session** through the SDK, so every +financial fact is read under the SDK's own version. Do not casually +upgrade the webhook endpoint's API version; if it is ever changed, verify +`readStripeEventRefs` fields still exist in the new payload shape. + +## Production activation checklist + +One-time, manual steps to take the feature live. All of it is Stripe +Dashboard + Vercel configuration — no code change. + +### Vercel (Production scope) + +- [ ] `STRIPE_SECRET_KEY` = the **live** key (`sk_live_*`) — test keys + (`sk_test_*`) must not be present in Production, and live keys must + not be present in Preview/Development (the server refuses them). +- [ ] `STRIPE_WEBHOOK_SECRET` = the signing secret (`whsec_*`) of the + **production** webhook endpoint below — each endpoint has its own + secret; do not reuse a test or `stripe listen` secret. +- [ ] No publishable-key env var is needed — Checkout is fully hosted, so + card entry happens only on Stripe's page. + +### Stripe Dashboard (live mode) + +- [ ] Webhook endpoint registered: `https://deepdivebrewing.com/api/webhooks/stripe`, + API version `2023-10-16`, subscribed to + `checkout.session.completed`, `checkout.session.expired`, + `checkout.session.async_payment_succeeded`, + `checkout.session.async_payment_failed` (the async pair is + defensive coverage — see Payment-method policy). +- [ ] Branding (Settings → Branding / Business settings): + - [ ] DDB logo/mark and icon uploaded (see `THEME_AND_BRANDING.md` for + the brand assets and approved usage). + - [ ] Brand/accent color set to the DDB palette. + - [ ] Business name, statement descriptor, and support details + (email/phone/address) are current — these appear on receipts and + card statements. +- [ ] Checkout/Customer Portal branding reviewed (Settings → Checkout and + Payment Links) so the hosted page shows the DDB brand. +- [ ] Receipts: "Email customers about successful payments" enabled + (Settings → Emails) if automatic customer receipts are wanted, and + the receipt email branding reviewed. + +### Post-deploy live verification (intentionally manual) + +Real-money check after merge/deploy — perform once, with a small amount: + +1. Create a small real payment in `/admin/payments` (e.g. $1–2; refund it + afterward in the Stripe Dashboard — refunds are not in the app). +2. Complete Checkout with a real card. +3. Confirm the browser returns to `/pay/complete`. +4. Confirm the live webhook delivery returns 2xx in the Stripe Dashboard. +5. Confirm the payment shows **Paid** (with no "(manual refresh)" tag if + you followed the webhook-verification procedure above). +6. Confirm **View receipt** opens Stripe's hosted receipt. +7. Confirm the customer receipt email arrives if enabled above. +8. Confirm card brand/last4 appear on the payment detail. +9. Resend the webhook event from the Dashboard and confirm `duplicate` + with no second `payment_succeeded` history entry. + +## Related documents + +- [docs/admin/payments.md](../admin/payments.md) — staff guide. +- [docs/TECHNICAL.md](../TECHNICAL.md) §11 — implementation. +- [credential-rotation.md](./credential-rotation.md) §H — Stripe key and + webhook-secret rotation, preview webhook testing. +- [deployment.md](./deployment.md) — env-var scopes. diff --git a/lib/payments-common.ts b/lib/payments-common.ts index cc0fe68..369a295 100644 --- a/lib/payments-common.ts +++ b/lib/payments-common.ts @@ -606,11 +606,14 @@ export interface CheckoutSessionSpec { mode: "payment"; client_reference_id: string; customer_email?: string; - // Card-only, intentionally: this is a counter tool that must answer - // "paid now?" — async/delayed methods (bank debits, BNPL) settle days - // later and would leave a charge looking unpaid while the customer - // walks out. The async_payment_* webhook handlers stay wired - // defensively in case account settings ever broaden this. + // Restricted to the card payment rail, intentionally: this is a + // counter tool that must answer "paid now?" — delayed methods (bank + // debits, BNPL) settle days later and would leave a charge looking + // unpaid while the customer walks out. The card rail still surfaces + // Stripe's accelerated card methods — Link, Apple Pay, Google Pay — + // on eligible devices/accounts; those settle immediately like cards. + // The async_payment_* webhook handlers stay wired defensively in + // case account payment-method settings ever broaden this. payment_method_types: ["card"]; line_items: { quantity: number; @@ -662,8 +665,10 @@ export function buildCheckoutSessionSpec(args: { payment_intent_data: { metadata: { paymentId: args.paymentId, purpose: args.purpose }, description: args.description, - // Guarantees Stripe emails the receipt — `customer_email` alone only - // prefills the checkout form and does not promise delivery. + // Supplies the address Stripe emails receipts to — `customer_email` + // alone only prefills the checkout form. Delivery itself is governed + // by the Stripe account's email settings (and test mode never sends + // email), so the stored hosted receipt URL stays the staff fallback. ...(args.customerEmail ? { receipt_email: args.customerEmail } : {}), }, success_url: args.successUrl, diff --git a/tests/lib/payments-common.test.ts b/tests/lib/payments-common.test.ts index a19c3c5..8ba4d6d 100644 --- a/tests/lib/payments-common.test.ts +++ b/tests/lib/payments-common.test.ts @@ -207,7 +207,11 @@ describe("buildCheckoutSessionSpec", () => { assert.strictEqual(spec.line_items[0].quantity, 1); }); - it("offers card payments only — async/delayed methods are not wired", () => { + it("restricts Checkout to the card payment rail — delayed methods are not wired", () => { + // ["card"] keeps Checkout on the card rail: bank debits, BNPL, and + // other delayed/async methods are never offered. Accelerated card + // methods (Link, Apple Pay, Google Pay) ride this rail and may still + // appear by customer device/account eligibility — that is intended. assert.deepStrictEqual(spec.payment_method_types, ["card"]); }); @@ -222,9 +226,11 @@ describe("buildCheckoutSessionSpec", () => { assert.strictEqual(spec.customer_email, "jane@example.com"); }); - it("sets receipt_email so Stripe emails the receipt automatically", () => { - // customer_email only prefills the form — receipt_email is what - // guarantees Stripe sends the customer a receipt on success. + it("sets receipt_email so Stripe can email the customer a receipt", () => { + // customer_email only prefills the form — receipt_email supplies the + // address Stripe emails receipts to when the account's email settings + // allow it (and never in test mode; the hosted receipt URL is the + // staff-visible fallback either way). assert.strictEqual( spec.payment_intent_data.receipt_email, "jane@example.com" @@ -658,6 +664,32 @@ describe("processStripeEvent", () => { assert.strictEqual(payments.get(VALID_ID)?.status, "paid"); }); + it("a webhook that loses the manual-refresh race is ignored once, then deduped on resend", async () => { + // The record is already `paid` (manual refresh settled it before + // Stripe's delivery arrived). First processing of this event id is a + // valid no-op — `ignored`, with a marker so Stripe does not retry. + const { store, payments, markers, writes } = fakeStore({ + [VALID_ID]: { ...awaitingRecord, status: "paid", eventCount: 3 }, + }); + const resolved = resolvedFrom(paidEvent); + + const first = await processStripeEvent(resolved, store); + assert.strictEqual(first.status, "ignored"); + assert.strictEqual(markers.get("evt_paid"), "ignored"); + assert.strictEqual(payments.get(VALID_ID)?.status, "paid"); + // No payment write: no second payment_succeeded, no re-enrichment. + assert.strictEqual(writes.length, 0); + assert.strictEqual(payments.get(VALID_ID)?.eventCount, 3); + + // A resend of the same Stripe event id exits as `duplicate` — again + // with no writes and no state regression. + const second = await processStripeEvent(resolved, store); + assert.strictEqual(second.status, "duplicate"); + assert.strictEqual(payments.get(VALID_ID)?.status, "paid"); + assert.strictEqual(writes.length, 0); + assert.strictEqual(payments.get(VALID_ID)?.eventCount, 3); + }); + it("quarantines — never pays — when canonical amounts disagree", async () => { const { store, payments, markers } = fakeStore({ [VALID_ID]: { ...awaitingRecord }, From a743cbfd92adeb051a9fdf7c7ba867c9539acf54 Mon Sep 17 00:00:00 2001 From: Chad Nuttall Date: Sun, 4 Oct 2026 08:48:14 -0400 Subject: [PATCH 08/11] docs(payments): address CodeRabbit closeout findings - Qualify that View receipt depends on best-effort enrichment; point staff to the Stripe Dashboard receipt when the link is absent. - Make test-mode end-to-end verification an explicit prerequisite of the production activation checklist. - De-duplicate policy/receipt/API-version detail between TECHNICAL.md (implementation facts) and operations/payments.md (operator guidance), cross-linking instead of repeating. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- docs/TECHNICAL.md | 24 +++++++++++----------- docs/admin/payments.md | 2 +- docs/operations/payments.md | 41 +++++++++++++++++++++---------------- 3 files changed, 36 insertions(+), 31 deletions(-) diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md index 8e2bb6f..852bf33 100644 --- a/docs/TECHNICAL.md +++ b/docs/TECHNICAL.md @@ -748,13 +748,13 @@ listed in `serverExternalPackages` alongside `firebase-admin`. `payments/{clientRequestId}` record + `payment_created` event in a transaction (existing doc → replay/recovery, never overwrite), creates the Checkout Session with `idempotencyKey: paymentId`, - `payment_method_types: ["card"]` — Checkout stays on the card payment - rail so charges settle immediately (accelerated card methods such as - Link, Apple Pay, and Google Pay ride this rail and may appear by - customer device/account eligibility; delayed/BNPL/bank methods are - deliberately not offered, and an explicit `payment_method_types` list - overrides Dashboard dynamic payment-method settings so none can leak - in). The async_payment_* webhook handlers stay wired defensively, and + `payment_method_types: ["card"]` — Checkout is restricted to the card + payment rail so counter charges settle immediately; an explicit + `payment_method_types` list also overrides Dashboard dynamic + payment-method settings, so nothing else can leak in. The full + policy — including which accelerated card methods may still appear — + is in [operations/payments.md](operations/payments.md#payment-method-policy). + The async_payment_* webhook handlers stay wired defensively, and `metadata.paymentId` + `client_reference_id` (no PII in metadata), and records the session id/url + `livemode` + `checkout_session_created` event. Session params are built from the **stored** record @@ -803,11 +803,11 @@ endpoint is pinned to `2023-10-16` (payloads arrive under it) while the installed `stripe` SDK pins its own version for outbound calls (`2026-08-26.dahlia` at stripe v22). This is safe because the payload contributes only ids — every financial fact is re-fetched canonically -under the SDK version. Receipt emails are governed by the Stripe -account's email settings (test mode never sends mail); the stored hosted -`receiptUrl` is the staff-visible fallback. Operational setup — -payment-method policy, webhook-verification procedure, branding, and the -production activation checklist — lives in +under the SDK version. Receipt behavior (account email settings, the +best-effort `receiptUrl` fallback) is covered in +[operations/payments.md](operations/payments.md#receipts). Operational +setup — payment-method policy, webhook-verification procedure, branding, +and the production activation checklist — lives in [docs/operations/payments.md](operations/payments.md). ## 12. Analytics and observability diff --git a/docs/admin/payments.md b/docs/admin/payments.md index bb39e89..6e159af 100644 --- a/docs/admin/payments.md +++ b/docs/admin/payments.md @@ -52,7 +52,7 @@ While a payment is **Awaiting payment** (or still setting up), use **Cancel paym ## Receipts -The **Receipt email** field pre-fills the customer's email on the Stripe Checkout page and associates it with the payment. Stripe emails a receipt automatically when the payment succeeds **if** automatic receipts for successful payments are enabled in the Stripe Dashboard (Dashboard → Settings → Emails → "Email customers about successful payments"). Stripe never sends email in test mode, so use **View receipt** when checking a test payment. After a payment is **Paid**, the detail view also shows a **View receipt** link to Stripe's hosted receipt page — the reliable fallback regardless of email settings — plus the card brand and last four digits, the only card details ever stored, and only as display metadata. +The **Receipt email** field pre-fills the customer's email on the Stripe Checkout page and associates it with the payment. Stripe emails a receipt automatically when the payment succeeds **if** automatic receipts for successful payments are enabled in the Stripe Dashboard (Dashboard → Settings → Emails → "Email customers about successful payments"). Stripe never sends email in test mode, so use **View receipt** when checking a test payment. After a payment is **Paid**, the detail view also shows a **View receipt** link to Stripe's hosted receipt page — the fallback regardless of email settings (if it is ever missing on a paid payment, find the charge's receipt in the Stripe Dashboard) — plus the card brand and last four digits, the only card details ever stored, and only as display metadata. ## Payment detail diff --git a/docs/operations/payments.md b/docs/operations/payments.md index 11c5de3..5580b94 100644 --- a/docs/operations/payments.md +++ b/docs/operations/payments.md @@ -10,7 +10,9 @@ code. ## Payment-method policy Stripe Checkout is restricted to the card payment rail -(`payment_method_types: ["card"]` in `buildCheckoutSessionSpec`). The policy: +(`payment_method_types: ["card"]` — see +[Admin payments](../TECHNICAL.md#admin-payments-adminpayments-issue-155) +in the technical reference for how the session is built). The policy: - **Accepted:** immediate card-based methods Stripe presents through the card rail — normal card entry, plus eligible accelerated card methods @@ -40,8 +42,11 @@ decision treats them identically to other Checkout events either way. - The app passes the customer's email to Stripe (`customer_email` + `payment_intent_data.receipt_email`) and stores Stripe's hosted `receipt_url` once the payment settles. Staff see a **View receipt** - link on a paid payment's detail view — this is the reliable fallback - and works regardless of email settings. + link on a paid payment's detail view — the fallback that works + regardless of email settings. Enrichment is best-effort, so if a paid + payment shows no receipt link, open the charge's hosted receipt from + the Stripe Dashboard instead (and check for `payment.enrichment_failed` + warnings — see [observability.md](./observability.md)). - **Automatic receipt emails depend on the Stripe account's email settings**, not just the app. For customers to receive a receipt email in production, enable it in Stripe Dashboard → Settings → Emails → @@ -102,27 +107,27 @@ correct behavior, not a missed delivery. ## Stripe API versions -Two versions coexist deliberately — do not read a dashboard "Latest API -version" banner as a prompt to change either: - -- **Webhook endpoint** (Stripe Dashboard → Developers → Webhooks): pinned - to `2023-10-16`. Event payloads arrive serialized under that version. -- **SDK calls** (`getStripeClient()`): pinned by the installed `stripe` - package (`2026-08-26.dahlia` at stripe v22) — this is why most - Dashboard request logs show the newer version. - -This is safe by design: the webhook reads only the event id/type and the -Checkout Session id from the payload — fields stable across versions — -then **re-fetches the canonical session** through the SDK, so every -financial fact is read under the SDK's own version. Do not casually -upgrade the webhook endpoint's API version; if it is ever changed, verify -`readStripeEventRefs` fields still exist in the new payload shape. +Two API versions coexist deliberately — the webhook endpoint is pinned to +`2023-10-16` while the installed `stripe` SDK pins its own version for +outbound calls; the mechanism and current values are in +[Admin payments](../TECHNICAL.md#admin-payments-adminpayments-issue-155). +A Dashboard "Latest API version" banner — and request logs showing the +newer version — are expected, not a prompt to change anything. Do not +casually upgrade the webhook endpoint's API version; if it is ever +changed, verify the fields `readStripeEventRefs` reads still exist in the +new payload shape. ## Production activation checklist One-time, manual steps to take the feature live. All of it is Stripe Dashboard + Vercel configuration — no code change. +**Prerequisite:** the full flow — Checkout completion, webhook-driven +**Paid**, receipt link, manual refresh fallback, and webhook resend → +`duplicate` — must already be verified in **test mode** on a preview +deployment (see "Verifying webhook delivery end-to-end" above). Live keys +are the last step, not the way to prove the flow works. + ### Vercel (Production scope) - [ ] `STRIPE_SECRET_KEY` = the **live** key (`sk_live_*`) — test keys From 09a5c24526cfe11e28d4547d659de9c41bacd878 Mon Sep 17 00:00:00 2001 From: Chad Nuttall Date: Sun, 4 Oct 2026 09:11:16 -0400 Subject: [PATCH 09/11] feat(admin): full refunds for paid payments within 1 hour Adds a deliberately narrow, server-authorized full-refund workflow: a durable Firestore claim (paid -> refunding, retries resume), canonical PaymentIntent verification before any provider call, a deterministic refund: Stripe idempotency key, and a commit/revert phase that converges on already-refunded Stripe state instead of double-refunding. Includes the typed-REFUND confirmation dialog, refund status/history UI, collected-today exclusion for refunded payments, 29 domain tests, a Playwright dialog test, and refund docs in admin/ops/technical guides. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/api/admin/payments/[id]/refund/route.ts | 63 +++ components/admin-payments-summary.tsx | 16 +- components/admin-payments-workspace.tsx | 270 ++++++++++- docs/TECHNICAL.md | 37 +- docs/admin/payments.md | 23 +- docs/operations/observability.md | 5 +- docs/operations/payments.md | 45 +- lib/payments-admin.ts | 323 +++++++++++++ lib/payments-common.ts | 438 +++++++++++++++++- smoke-tests/admin-accessibility.spec.ts | 123 +++++ tests/lib/payments-common.test.ts | 479 ++++++++++++++++++++ 11 files changed, 1805 insertions(+), 17 deletions(-) create mode 100644 app/api/admin/payments/[id]/refund/route.ts 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/components/admin-payments-summary.tsx b/components/admin-payments-summary.tsx index 3f89d50..c6b80a5 100644 --- a/components/admin-payments-summary.tsx +++ b/components/admin-payments-summary.tsx @@ -4,7 +4,11 @@ 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, type PaymentView } from "@/lib/payments-common"; +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 @@ -36,10 +40,14 @@ export function AdminPaymentsSummary({ user }: { user: AdminPanelUser }) { 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 ( - p.status === "paid" && - p.paidAt && - new Date(p.paidAt).getTime() >= startOfToday.getTime() + isCollectedForDailyTotal({ + status: p.status, + paidAtMillis: p.paidAt ? new Date(p.paidAt).getTime() : null, + dayStartMillis: startOfToday.getTime(), + }) ) { paidTodayMinor += p.amountMinor; } diff --git a/components/admin-payments-workspace.tsx b/components/admin-payments-workspace.tsx index fe125d6..bdef2c2 100644 --- a/components/admin-payments-workspace.tsx +++ b/components/admin-payments-workspace.tsx @@ -4,18 +4,29 @@ 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, @@ -56,6 +67,8 @@ const STATUS_BADGE_CLASS: Record = { 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", @@ -110,6 +123,14 @@ export function AdminPaymentsWorkspace({ user }: { user: AdminPanelUser }) { 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(""); + const apiFetch = useCallback( async (path: string, init?: RequestInit) => { const idToken = await user.getIdToken(); @@ -406,6 +427,47 @@ export function AdminPaymentsWorkspace({ user }: { user: AdminPanelUser }) { } } + function openRefund(payment: PaymentView) { + setRefundReason(""); + setRefundConfirm(""); + setRefundError(""); + setRefundTarget(payment); + } + + async function submitRefund() { + const payment = refundTarget; + if (!payment || refundBusy) return; // single-flight like createPayment + 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); + setStatusMessage("Payment refunded."); + setStatusIsError(false); + } catch (error) { + setRefundError( + error instanceof Error ? error.message : "Could not complete the refund." + ); + } finally { + setRefundBusy(false); + } + } + // --- Derived view --- const summary = useMemo(() => { @@ -417,7 +479,15 @@ export function AdminPaymentsWorkspace({ user }: { user: AdminPanelUser }) { if (p.status === "awaiting_payment" || p.status === "processing") { awaiting++; } - if (p.status === "paid" && paymentMillis(p.paidAt) >= startOfToday.getTime()) { + // `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; } } @@ -426,6 +496,18 @@ export function AdminPaymentsWorkspace({ user }: { user: AdminPanelUser }) { 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: Date.now(), + }) + : null; const timeline = detail ? [...detail.events].sort((a, b) => (b.seq ?? 0) - (a.seq ?? 0)) : []; @@ -868,6 +950,30 @@ export function AdminPaymentsWorkspace({ user }: { user: AdminPanelUser }) { {paymentLinkBlock(shownPayment)} + {refundEligibility?.ok && ( +
+ +
+ )} + {refundEligibility && !refundEligibility.ok && shownPayment.status === "paid" && ( +

+ {refundEligibility.message} +

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

+ Refunding… +

+ )} +
Purpose
@@ -938,6 +1044,49 @@ export function AdminPaymentsWorkspace({ user }: { user: AdminPanelUser }) { {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
@@ -978,6 +1127,125 @@ export function AdminPaymentsWorkspace({ user }: { user: AdminPanelUser }) { )} )} + + { + // 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(); + }} + > +