Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)

Expand Down
26 changes: 26 additions & 0 deletions app/(pages)/admin-payments-fixture/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main id="main-content" tabIndex={-1} className="mx-auto max-w-300 px-6 pb-20 md:pb-30">
<AdminPaymentsFixture />
</main>
);
}
19 changes: 19 additions & 0 deletions app/(pages)/admin/payments/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main id="main-content" tabIndex={-1} className="mx-auto max-w-300 px-6 pb-20 md:pb-30">
<AdminPaymentsPage />
</main>
);
}
29 changes: 29 additions & 0 deletions app/(pages)/pay/cancelled/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main id="main-content" tabIndex={-1} className="mx-auto max-w-200 px-6 pb-20 md:pb-30">
<div className="rounded-lg border border-stone bg-paper p-8 text-center">
<h1 className="text-2xl font-bold tracking-tight">Payment canceled</h1>
<p className="mt-3 text-muted-foreground">
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.
</p>
<p className="mt-2 text-sm text-muted-foreground">
You can close this window.
</p>
</div>
</main>
);
}
30 changes: 30 additions & 0 deletions app/(pages)/pay/complete/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main id="main-content" tabIndex={-1} className="mx-auto max-w-200 px-6 pb-20 md:pb-30">
<div className="rounded-lg border border-stone bg-paper p-8 text-center">
<h1 className="text-2xl font-bold tracking-tight">Payment received</h1>
<p className="mt-3 text-muted-foreground">
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.
</p>
<p className="mt-2 text-sm text-muted-foreground">
You can close this window.
</p>
</div>
</main>
);
}
51 changes: 51 additions & 0 deletions app/api/admin/payments/[id]/cancel/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
});
}
}
57 changes: 57 additions & 0 deletions app/api/admin/payments/[id]/qr/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
});
}
}
56 changes: 56 additions & 0 deletions app/api/admin/payments/[id]/refresh/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
});
}
}
63 changes: 63 additions & 0 deletions app/api/admin/payments/[id]/refund/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
});
}
}
Loading
Loading