Skip to content
Draft
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
6 changes: 5 additions & 1 deletion .env
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ MEETING_SCHEDULER_URL=
NEXT_PUBLIC_GOOGLE_ANALYTICS_ID=
NEXT_PUBLIC_POSTHOG_KEY=
NEXT_PUBLIC_POSTHOG_HOST=
NEXT_PUBLIC_SNIPCART_KEY=
SENDGRID_API_KEY=
MAILGUN_API_KEY=
MAILGUN_DOMAIN=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
# Optional: Stripe Dashboard shipping rate ID (shr_...) applied at checkout.
STRIPE_SHIPPING_RATE_ID=
ORDER_NOTIFICATION_EMAIL=
397 changes: 397 additions & 0 deletions docs-internal/stripe-checkout-design.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"sonner": "^1.3.1",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.6",
"stripe": "^22.3.0",
"tailwind-merge": "^2.1.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.22.4"
Expand Down
15 changes: 15 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

111 changes: 111 additions & 0 deletions src/app/api/checkout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { NextRequest } from "next/server";
import Stripe from "stripe";
import { photoPricing } from "@/constants/photoPricing";
import { CartItem, MAX_ITEM_QTY } from "@/utils/cart/cartCookie";
import { castPhotoID } from "@/utils/cdn/cdnAssets";
import { PAGES } from "@/utils/pages";
import { getPrintProduct } from "@/utils/printProduct";
import { siteMetadata } from "@/constants/siteMetadata";

export type CheckoutRequest = {
items: CartItem[];
};

/**
* Stripe Checkout's own cap on line items per session.
*/
const MAX_LINE_ITEMS = 100;

/**
* Creates a Stripe Checkout Session for the given cart. The client only sends
* `{photoID, variantId, qty}` — prices are always re-derived server-side from
* `photoPricing`, so cart tampering is not possible.
*/
export async function POST(request: NextRequest) {
try {
if (!process.env.STRIPE_SECRET_KEY) {
throw new Error("No Stripe secret key found");
}
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

const body: CheckoutRequest = await request.json();
if (!Array.isArray(body.items) || body.items.length === 0) {
return new Response("Cart is empty", { status: 400 });
}
if (body.items.length > MAX_LINE_ITEMS) {
return new Response("Too many items in cart", { status: 400 });
}

const line_items: Stripe.Checkout.SessionCreateParams.LineItem[] =
body.items.map((item) => {
const photoID = castPhotoID(item.photoID);
if (!photoID) {
throw new CheckoutValidationError(`Unknown photo: ${item.photoID}`);
}

const variant = photoPricing.find((v) => v.id === item.variantId);
if (!variant || !variant.inStock) {
throw new CheckoutValidationError(
`Unavailable print variant: ${item.variantId}`,
);
}

if (!Number.isInteger(item.qty) || item.qty < 1) {
throw new CheckoutValidationError(
`Invalid quantity for ${item.variantId}`,
);
}

const product = getPrintProduct(photoID, variant);
return {
quantity: Math.min(item.qty, MAX_ITEM_QTY),
price_data: {
currency: "usd",
unit_amount: Math.round(variant.price * 100),
product_data: {
name: product.name,
description: product.description,
images: [product.image],
metadata: {
photoID: item.photoID,
variantId: item.variantId,
},
},
},
};
});

// In dev, redirect back to the origin the checkout started on (eg.
// localhost) so local test payments don't land on the production site.
const baseUrl =
process.env.NODE_ENV === "development"
? request.nextUrl.origin
: siteMetadata.siteUrl;
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items,
success_url: `${baseUrl}${PAGES.PHOTOGRAPHY.CHECKOUT_SUCCESS}?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${baseUrl}${PAGES.PHOTOGRAPHY.CART}`,
allow_promotion_codes: true,
shipping_address_collection: { allowed_countries: ["US"] },
// Shipping cost is configured in the Stripe Dashboard (optional).
...(process.env.STRIPE_SHIPPING_RATE_ID
? {
shipping_options: [
{ shipping_rate: process.env.STRIPE_SHIPPING_RATE_ID },
],
}
: {}),
});

return Response.json({ url: session.url });
} catch (error) {
console.error("Error creating checkout session:", error);
if (error instanceof CheckoutValidationError) {
return new Response(error.message, { status: 400 });
}
return new Response("Failed to create checkout session", { status: 500 });
}
}

class CheckoutValidationError extends Error {}
66 changes: 0 additions & 66 deletions src/app/api/product/[photo_id]/pricing.json/route.ts

This file was deleted.

140 changes: 140 additions & 0 deletions src/app/api/stripe/webhook/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { NextRequest } from "next/server";
import Stripe from "stripe";
import { EmailServiceFactory } from "@/services/email/EmailServiceFactory";

/**
* Stripe webhook. On a paid Checkout Session, sends an order notification
* email so fulfillment can be handled manually (order history lives in the
* Stripe Dashboard — no database).
*/
export async function POST(request: NextRequest) {
if (!process.env.STRIPE_SECRET_KEY || !process.env.STRIPE_WEBHOOK_SECRET) {
console.error("Stripe webhook env vars missing");
return new Response("Stripe is not configured", { status: 500 });
}
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

// The raw body is required for signature verification.
const body = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) {
return new Response("Missing stripe-signature header", { status: 400 });
}

let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET,
);
} catch (error) {
console.error("Webhook signature verification failed:", error);
return new Response("Invalid signature", { status: 400 });
}

if (
event.type === "checkout.session.completed" ||
event.type === "checkout.session.async_payment_succeeded"
) {
try {
// Event payloads follow the webhook endpoint's configured API version,
// not the SDK's — re-retrieve the session so the shape (eg.
// `collected_information`) always matches the SDK's pinned version.
const session = await stripe.checkout.sessions.retrieve(
event.data.object.id,
);

// Delayed-notification payment methods (eg. ACH) fire
// `checkout.session.completed` while `payment_status` is still
// "unpaid"; the `async_payment_succeeded` event arrives once the
// payment actually clears and triggers the email instead.
if (session.payment_status !== "paid") {
return new Response("ok (payment not yet complete)");
}

await sendOrderNotification(stripe, session);
} catch (error) {
// Return 500 so Stripe retries the webhook until the email succeeds.
console.error("Failed to send order notification:", error);
return new Response("Failed to send order notification", {
status: 500,
});
}
}

if (event.type === "checkout.session.async_payment_failed") {
// No email — the order never happened. Logged for visibility only.
console.warn("Async payment failed for session:", event.data.object.id);
}

return new Response("ok");
}

const sendOrderNotification = async (
stripe: Stripe,
session: Stripe.Checkout.Session,
) => {
if (!process.env.ORDER_NOTIFICATION_EMAIL) {
throw new Error("No order notification email found");
}

// Line items are not included on the session; fetch them (with the inline
// product expanded so the exact photoID is available) so each order email
// is self-describing for fulfillment.
const lineItems = await stripe.checkout.sessions.listLineItems(session.id, {
limit: 100,
expand: ["data.price.product"],
});
const itemSummary = lineItems.data
.map((item) => {
const product = item.price?.product;
const photoID =
product && typeof product !== "string" && !product.deleted
? product.metadata.photoID
: undefined;
return `- ${item.quantity}× ${item.description}${
photoID ? ` (photoID: ${photoID})` : ""
}`;
})
.join("\n");

const shipping = session.collected_information?.shipping_details;
const address = shipping?.address;
const shippingSummary = shipping
? `${shipping.name}
${address?.line1 ?? ""}${address?.line2 ? `\n${address.line2}` : ""}
${address?.city ?? ""}, ${address?.state ?? ""} ${address?.postal_code ?? ""}
${address?.country ?? ""}`
: "(no shipping address collected)";

const total = ((session.amount_total ?? 0) / 100).toFixed(2);

const emailService = EmailServiceFactory.create();
await emailService.sendEmail({
to: {
name: "Print Orders",
email: process.env.ORDER_NOTIFICATION_EMAIL,
},
from: {
name: "Print Orders",
email: process.env.ORDER_NOTIFICATION_EMAIL,
},
subject: `📸 New print order — $${total}`,
content: `New order received!

Customer: ${session.customer_details?.name ?? "?"} <${
session.customer_details?.email ?? "?"
}>
Total: $${total}

Items:
${itemSummary}

Ship to:
${shippingSummary}

Stripe session: ${session.id}
Manage this order in the Stripe Dashboard.`,
});
};
2 changes: 0 additions & 2 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { HydrationOverlay } from "@builder.io/react-hydration-overlay";
import { Cutive_Mono, Montserrat, Mulish } from "next/font/google";
import "./globals.css";
import dynamic from "next/dynamic";
import { Snipcart } from "../components/organisms/Snipcart/Snipcart";
import { PHProvider } from "./providers";
import { siteMetadata } from "@/constants/siteMetadata";
import { NavBar } from "@/components/organisms/NavBar/NavBar";
Expand Down Expand Up @@ -95,7 +94,6 @@ export default function RootLayout({
)}
>
<PostHogPageView />
<Snipcart />
{isDev ? (
<HydrationOverlay>
<NavBar />
Expand Down
Loading