Skip to content

Commit

Permalink
refactor: use Stripe embedded checkout form
Browse files Browse the repository at this point in the history
  • Loading branch information
spencerlepine committed Oct 26, 2024
1 parent bef71f8 commit 738833a
Show file tree
Hide file tree
Showing 13 changed files with 572 additions and 451 deletions.
9 changes: 8 additions & 1 deletion next.config.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import './src/envVars.js';

/** @type {import('next').NextConfig} */
const nextConfig = {};
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
output: 'standalone', // for Docker
trailingSlash: false,
};

export default nextConfig;
15 changes: 10 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,25 @@
],
"scripts": {
"dev": "next dev",
"build": "NODE_ENV=production next build",
"build": "NODE_ENV=production next build && npm run copy:assets",
"comment": "TODO_DEPLOYMENT - serve assets from CDN instead",
"copy:assets": "cp -rn public .next/standalone && cp -rn .next/static .next/standalone/.next/static",
"test": "echo 'No tests added yet' && exit 0",
"start": "next start",
"start": "node .next/standalone/server.js",
"lint": "next lint"
},
"dependencies": {
"@stripe/react-stripe-js": "^2.8.1",
"fuse.js": "^7.0.0",
"next": "14.2.11",
"printify-sdk-js": "^1.0.1",
"printify-sdk-js": "1.0.2",
"react": "^18",
"react-dom": "^18",
"sharp": "^0.33.5",
"sharp": "0.32.6",
"stripe": "^16.9.0",
"use-shopping-cart": "^3.2.0"
"use-shopping-cart": "^3.2.0",
"winston": "^3.15.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^20",
Expand Down
92 changes: 11 additions & 81 deletions src/app/api/v1/checkout/route.ts
Original file line number Diff line number Diff line change
@@ -1,98 +1,28 @@
import logger from '@/lib/logger';
import { createDraftOrder } from '@/lib/printify';
import { NextResponse } from 'next/server';
import { createCheckoutSession } from '@/lib/stripe';
import { UserError } from '@/utils/errors';
import logger from '@/lib/logger';
import { retrieveShippingCost } from '@/lib/printify';
import validateCartItems from '@/utils/validateCartItems';
import { NextRequest, NextResponse } from 'next/server';

/**
* @openapi
* /v1/checkout:
* post:
* summary: Creates a Stripe checkout session.
* tags:
* - Checkout
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* cartItems:
* type: array
* items:
* type: object
* properties:
* id:
* type: string
* name:
* type: string
* description:
* type: string
* quantity:
* type: integer
* price:
* type: number
* image:
* type: string
* currency:
* type: string
* price_data:
* type: object
* product_data:
* type: object
* properties:
* size:
* type: string
* productId:
* type: string
* category:
* type: string
* type: string
* responses:
* 200:
* description: Checkout session created successfully.
* content:
* application/json:
* schema:
* type: object
* properties:
* checkoutUrl:
* type: string
* 400:
* description: Error creating checkout session.
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
*/
export const POST = async (request: NextRequest) => {
export async function POST(request: Request) {
const correlationId = request.headers.get('x-correlation-id');

try {
logger.info('[Checkout] Processing checkout request', { correlationId });

const body = await request.json();
const { cartItems: clientCartItems } = body;

logger.info('[Checkout] Validating cart items', { correlationId });
const cartItems = validateCartItems(clientCartItems);

// TODO_PRINTIFY (move this to final webhook)
logger.info('[Printify] Creating Printify draft order', { correlationId });
const { id: printifyOrderId } = await createDraftOrder(cartItems);

// TODO_PRINTIFY (calulateShipping())
logger.info('[Printify] calculating shipping methods', { correlationId });
const shippingMethods = await retrieveShippingCost();

logger.info('[Stripe] Creating checkout session', { correlationId, printifyOrderId });
const session = await createCheckoutSession(cartItems, { printifyOrderId });
const swagOrderId = crypto.randomUUID();
logger.info('[Stripe] Creating checkout session', { correlationId, swagOrderId });
const session = await createCheckoutSession(cartItems, shippingMethods, { swagOrderId });

logger.info('[Checkout] Checkout session created successfully', { checkoutUrl: session.url, correlationId, printifyOrderId, sessionId: session.id });
return NextResponse.json({ checkoutUrl: session.url });
return NextResponse.json({ id: session.id, client_secret: session.client_secret });
} catch (error) {
if (error instanceof UserError) {
return NextResponse.json({ error: error.message }, { status: 400 });
Expand All @@ -101,4 +31,4 @@ export const POST = async (request: NextRequest) => {
logger.error('[Checkout] Error processing checkout request', { error });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
};
}
58 changes: 26 additions & 32 deletions src/app/api/v1/webhook/checkout/route.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,14 @@
import type { Stripe as StripeType } from 'stripe';
import { retrieveCheckoutSession, stripe } from '@/lib/stripe';
import { stripe } from '@/lib/stripe';
import { NextRequest, NextResponse } from 'next/server';
import { sendOrderToProduction } from '@/lib/printify';
import logger from '@/lib/logger';
import { createDraftOrder } from '@/lib/printify';

export const POST = async (request: NextRequest) => {
const correlationId = request.headers.get('x-correlation-id');

try {
logger.info('[Stripe Webhook] Processing Stripe webhook request');

const secret = process.env.STRIPE_WEBHOOK_SECRET || '';
if (!secret) {
logger.error('[Stripe Webhook] Missing STRIPE_WEBHOOK_SECRET environment variable');
throw new Error('Missing STRIPE_WEBHOOK_SECRET environment variable');
}

const body = await (await request.blob()).text();
const signature = request.headers.get('stripe-signature') as string;
const event: StripeType.Event = stripe.webhooks.constructEvent(body, signature, secret);
const event: StripeType.Event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET as string);

const permittedEvents: string[] = ['checkout.session.completed', 'payment_intent.succeeded', 'payment_intent.payment_failed'];
if (permittedEvents.includes(event.type)) {
Expand All @@ -27,45 +17,49 @@ export const POST = async (request: NextRequest) => {
switch (event.type) {
case 'checkout.session.completed':
data = event.data.object as StripeType.Checkout.Session;
logger.info('[Stripe Webhook] Checkout succeeded', { eventType: event.type, sessionId: data.id, eventId: event.id });

const printifyOrderId = event.data.object?.metadata?.printifyOrderId;
if (!printifyOrderId) {
logger.warn(`[Stripe Webhook] Missing printifyOrderId in metadata for session ${data.id}. Unable to fullfil order`, { sessionId: data.id, correlationId });
throw new Error(`missing printifyOrderId on metadata, ${data.id}`);
}
logger.info('[Stripe Webhook] Retrieving line_items', { sessionId: data.id });
const line_items = await stripe.checkout.sessions.listLineItems(data.id, {
expand: ['data.price.product'],
});
const email = data?.customer_details?.email || '';
const phone = data?.customer_details?.phone || '';

logger.info('[Stripe Webhook] Verifying payment status for Checkout Session', { sessionId: data.id, correlationId });
const checkoutSession = await retrieveCheckoutSession(data.id);
if (checkoutSession.payment_status === 'unpaid') {
logger.warn('[Stripe Webhook] Cannot fulfill an unpaid order', { sessionId: data.id, correlationId });
return NextResponse.json({ message: 'Cannot fullfil an unpaid order' }, { status: 400 });
const swagOrderId = data.metadata?.swagOrderId;
if (!swagOrderId) {
logger.warn(`[Stripe Webhook] Missing swagOrderId in metadata for session ${data.id}. Unable to fullfil order`, { sessionId: data.id, eventId: event.id });
throw new Error(`missing swagOrderId on metadata, ${data.id}`);
}

logger.info('[Printify] Sending order to production', { sessionId: data.id, correlationId, printifyOrderId });
await sendOrderToProduction(printifyOrderId);
logger.info('[Printify] Creating Printify draft order');
const { id: printifyOrderId } = await createDraftOrder(line_items.data, data.shipping_details, swagOrderId, data.id, email, phone);

logger.info('[Stripe Webhook] Successfully fulfilled order', { sessionId: data.id, correlationId, printifyOrderId });
return NextResponse.json({ result: event, ok: true });
await new Promise(r => setTimeout(r, 3000));

// TODO_PRINTIFY - validate publish endpoint! - curl request always works..
logger.info('[Printify] Fullfilling order', { eventType: event.type, sessionId: data.id, printifyOrderId, eventId: event.id });
// await sendOrderToProduction(printifyOrderId);
break;

case 'payment_intent.payment_failed':
data = event.data.object as StripeType.PaymentIntent;
logger.error('[Stripe Webhook] Payment failed', { message: data.last_payment_error?.message, sessionId: data.id });
logger.error('[Stripe Webhook] Payment failed', { message: data.last_payment_error?.message, eventType: event.type, sessionId: data.id, eventId: event.id });
break;

case 'payment_intent.succeeded':
data = event.data.object as StripeType.PaymentIntent;
logger.info('[Stripe Webhook] PaymentIntent succeeded', { status: data.status, sessionId: data.id });
logger.info('[Stripe Webhook] PaymentIntent succeeded', { eventType: event.type, status: data.status, sessionId: data.id, eventId: event.id });
break;

default:
// fallback, not used
data = (event.data.object as unknown) || {};
// @ts-expect-error - ignore "Property 'id' does not exist on type '{}'.ts(2339)"
logger.warn('[Stripe Webhook] Unhandled event type', { eventType: event.type, sessionId: data?.id });
return NextResponse.json({ result: event, ok: true });
}
}

logger.info('[Stripe Webhook] Webhook processing complete', { eventId: event.id });
logger.info('[Stripe Webhook] Processed request', { eventType: event.type });
return NextResponse.json({ result: event, ok: true });
} catch (error) {
logger.error('[Stripe Webhook] Error processing webhook request', { error });
Expand Down
39 changes: 37 additions & 2 deletions src/app/cart/page.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
'use client';

import React, { useState } from 'react';
import { loadStripe } from '@stripe/stripe-js';
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js';
import CartItemCard from '@/components/CartItemCard';
import formatPriceForDisplay from '@/utils/formatPriceForDisplay';
import { useShoppingCart } from 'use-shopping-cart';
import { CartItem } from '@/types';
import CheckoutButton from '@/components/CheckoutBtn';

export default function CartPage() {
const { cartCount, cartDetails, removeItem, totalPrice, addItem, decrementItem } = useShoppingCart();
const cartItems = Object.values(cartDetails ?? {});

const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_KEY!);
const [showCheckout, setShowCheckout] = useState(false);

const handleRemove = (cartItem: CartItem) => {
if (cartItem.quantity === 1) {
removeItem(cartItem.id);
Expand All @@ -18,12 +23,42 @@ export default function CartPage() {
}
};

const fetchClientSecret = () => {
return fetch('/api/v1/checkout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ cartItems }),
})
.then(res => res.json())
.then(data => data.client_secret);
};

const options = { fetchClientSecret };

const handleCheckoutClick = () => {
setShowCheckout(true);
};

if (showCheckout) {
return (
<div className="py-4 my-8">
<EmbeddedCheckoutProvider stripe={stripePromise} options={options}>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
</div>
);
}

const subtotal = formatPriceForDisplay(totalPrice);
return (
<div className="my-8 mx-20">
<div className="flex justify-between items-center p-4">
<span className="text-lg font-semibold">Subtotal: {subtotal}</span>
<CheckoutButton cartCount={cartCount} cartItems={cartItems} />
<button disabled={!cartCount} className="btn bg-green-500 text-white px-4 py-2 rounded-md focus:outline-none" onClick={handleCheckoutClick}>
Checkout
</button>
</div>

{!cartItems ||
Expand Down
38 changes: 28 additions & 10 deletions src/app/order-confirmation/page.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,38 @@
import { memo } from 'react';
import OrderConfirmation from '@/components/OrderConfirmation';
import { validateStripeSession } from '@/lib/stripe';
import { stripe } from '@/lib/stripe';
import { notFound } from 'next/navigation';

async function getSession(sessionId: string) {
try {
const session = await stripe.checkout.sessions.retrieve(sessionId!);
return session;
} catch (error) {
return null;
}
}

// Redirect page after successful checkout
// <baseUrl>/order-confirmation?session_id=cs_test_b1FKoQomBOaQFgMqW1lU6oYXRwIruD6AbGV804gMZRrptJr1bF91sDmK5T
const OrderConfirmationPage: React.FC<{ searchParams: { [key: string]: string | undefined } }> = async ({ searchParams }) => {
const sessionId = searchParams['session_id'];
const { validSession } = await validateStripeSession(sessionId);
if (!validSession) return notFound();
if (!sessionId) return notFound();

const session = await getSession(sessionId);
if (!session) return notFound();

if (session?.status === 'open') {
return <p>Payment did not work.</p>;
}

if (session?.status === 'complete') {
return (
<div className="my-8 mx-20">
<OrderConfirmation />
</div>
);
}

return (
<div className="my-8 mx-20">
<OrderConfirmation />
</div>
);
return notFound();
};

export default memo(OrderConfirmationPage);
export default OrderConfirmationPage;
Loading

0 comments on commit 738833a

Please sign in to comment.