Files
route-commerce/src/actions/billing/retail-payment-intent.ts
T
tyler 015b1cf7b5 feat(checkout): real Stripe Express + Elements on /checkout
- Add @stripe/stripe-js + @stripe/react-stripe-js
- New src/lib/stripe-client.ts: cached loadStripe helper
- New src/actions/billing/retail-payment-intent.ts: server action
  that creates a PaymentIntent with automatic_payment_methods
- New src/components/storefront/StripeExpressCheckout.tsx: embedded
  ExpressCheckoutElement (Apple Pay, Google Pay, Link, PayPal) +
  PaymentElement (card) + hosted-checkout fallback
- /checkout form is now controlled; StripeExpressCheckout reads
  name/email/stop from form state and confirms in-page via
  stripe.confirmPayment
- /checkout/success handles both ?session_id= and ?payment_intent=
  so embedded + hosted flows both land on the same order-creation
  page using the pending_checkout sessionStorage payload
- Export StopInfo from CartContext and select 'time' on the stops
  fetch so the local Stop shape matches the context's StopInfo
- QuickCartSheet express buttons are visual shortcuts only;
  /checkout auto-renders the real Apple Pay / Google Pay buttons

Requires NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY for the embedded path;
if missing the embedded section falls back to the hosted
Stripe Checkout button. npx tsc --noEmit passes.
2026-06-04 18:38:21 +00:00

124 lines
4.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use server";
import { svcHeaders } from "@/lib/svc-headers";
/**
* Creates a Stripe PaymentIntent for the supplied cart so the browser
* can confirm the payment with embedded Stripe Elements (Apple Pay /
* Google Pay / card) without redirecting to a hosted page.
*
* Mirrors `createRetailStripeCheckoutSession` in `retail-checkout.ts`:
* the PaymentIntent is the embedded equivalent of the Checkout Session.
* Order creation itself still happens on `/checkout/success` so the
* webhooks and order pipeline don't need to know which path the buyer
* used.
*/
type LineItem = {
id: string;
name: string;
price: number;
quantity: number;
};
type CustomerInfo = {
name?: string;
email?: string;
};
type CreatePaymentIntentResult =
| { success: true; clientSecret: string; paymentIntentId: string; amount: number }
| { success: false; error: string };
export async function createRetailPaymentIntent(
items: LineItem[],
customer: CustomerInfo,
brandId: string | null,
stopId: string | null,
shippingAddress?: { state?: string; postal_code?: string; city?: string } | null
): Promise<CreatePaymentIntentResult> {
const stripeKey = process.env.STRIPE_SECRET_KEY;
if (!stripeKey) {
return { success: false, error: "Stripe not configured on this server." };
}
if (!Array.isArray(items) || items.length === 0) {
return { success: false, error: "Cart is empty." };
}
const Stripe = (await import("stripe")).default;
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
// Compute the subtotal in cents. We don't compute sales tax here —
// Stripe's `automatic_tax` would be ideal but requires address collection
// client-side; for this single-product corn box the price is tax-inclusive
// (see Tuxedo Corn product card) so we treat the line total as final.
const amount = items.reduce((sum, item) => {
const qty = Math.max(1, Math.floor(item.quantity || 1));
const unit = Math.max(0, Math.round(Number(item.price) * 100));
return sum + unit * qty;
}, 0);
if (amount <= 0) {
return { success: false, error: "Cart total must be greater than $0." };
}
// Pull the brand name for Stripe receipts + metadata
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
let brandName = "Route Commerce";
if (supabaseUrl && supabaseKey && brandId) {
try {
const brandRes = await fetch(
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name`,
{ headers: { ...svcHeaders(supabaseKey) } }
);
const brands = (await brandRes.json()) as Array<{ name: string }>;
if (brands?.[0]?.name) brandName = brands[0].name;
} catch {
// ignore — use default
}
}
// Build a short human-readable description for the Stripe dashboard
const description = items
.slice(0, 3)
.map((i) => `${i.quantity}× ${i.name}`)
.join(", ");
try {
const intent = await stripe.paymentIntents.create({
amount,
currency: "usd",
automatic_payment_methods: { enabled: true },
receipt_email: customer.email || undefined,
description,
metadata: {
brand_id: brandId ?? "unknown",
brand_name: brandName,
stop_id: stopId ?? "",
customer_name: customer.name ?? "",
customer_email: customer.email ?? "",
shipping_state: shippingAddress?.state ?? "",
shipping_postal_code: shippingAddress?.postal_code ?? "",
shipping_city: shippingAddress?.city ?? "",
item_count: String(items.reduce((s, i) => s + i.quantity, 0)),
source: "storefront_express",
},
});
if (!intent.client_secret) {
return { success: false, error: "Stripe did not return a client secret." };
}
return {
success: true,
clientSecret: intent.client_secret,
paymentIntentId: intent.id,
amount: intent.amount,
};
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create payment intent.";
return { success: false, error: message };
}
}