"use node"; import Stripe from "stripe"; import { action, internalAction } from "./_generated/server"; import { internal } from "./_generated/api"; import { v } from "convex/values"; import { getOrCreateStripeCustomer } from "./model/stripe"; import type { Id } from "./_generated/dataModel"; import type { CheckoutSessionResult, CheckoutSessionStatus, CartValidationResult, } from "./model/checkout"; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); export const createCheckoutSession = action({ args: { addressId: v.id("addresses"), shipmentObjectId: v.string(), shippingRate: v.object({ provider: v.string(), serviceName: v.string(), serviceToken: v.string(), amount: v.number(), currency: v.string(), estimatedDays: v.union(v.number(), v.null()), durationTerms: v.string(), carrierAccount: v.string(), }), sessionId: v.optional(v.string()), }, handler: async (ctx, args): Promise => { const identity = await ctx.auth.getUserIdentity(); if (!identity) { throw new Error("You must be signed in to checkout."); } const userId: Id<"users"> = await ctx.runQuery( internal.checkout.getCurrentUserId, ); const user = await ctx.runQuery(internal.users.getById, { userId }); const stripeCustomerId = await getOrCreateStripeCustomer({ stripeCustomerId: user.stripeCustomerId, email: user.email, name: user.name, convexUserId: userId, }); if (!user.stripeCustomerId) { await ctx.runMutation(internal.users.setStripeCustomerId, { userId, stripeCustomerId, }); } const address = await ctx.runQuery(internal.checkout.getAddressById, { addressId: args.addressId, }); if (address.userId !== userId) { throw new Error("Address does not belong to the current user."); } const cartResult: CartValidationResult | null = await ctx.runQuery( internal.checkout.validateCartInternal, { userId, sessionId: args.sessionId }, ); if (!cartResult) { throw new Error("Your cart is empty."); } if (!cartResult.valid) { throw new Error("Your cart has issues that need to be resolved."); } const lineItems: Stripe.Checkout.SessionCreateParams.LineItem[] = cartResult.items.map((item) => ({ price_data: { currency: "gbp", product_data: { name: `${item.productName} — ${item.variantName}`, }, unit_amount: Math.round(item.unitPrice), }, quantity: item.quantity, })); const STOREFRONT_URL = process.env.STOREFRONT_URL; if (!STOREFRONT_URL) { throw new Error("STOREFRONT_URL environment variable is not set."); } const session: Stripe.Checkout.Session = await stripe.checkout.sessions.create({ mode: "payment", ui_mode: "custom", customer: stripeCustomerId, line_items: lineItems, shipping_options: [ { shipping_rate_data: { type: "fixed_amount", fixed_amount: { amount: Math.round(args.shippingRate.amount * 100), currency: "gbp", }, display_name: `${args.shippingRate.provider} — ${args.shippingRate.serviceName}`, ...(args.shippingRate.estimatedDays != null && { delivery_estimate: { minimum: { unit: "business_day" as const, value: args.shippingRate.estimatedDays, }, maximum: { unit: "business_day" as const, value: args.shippingRate.estimatedDays, }, }, }), }, }, ], return_url: `${STOREFRONT_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`, metadata: { convexUserId: userId, addressId: args.addressId, shipmentObjectId: args.shipmentObjectId, shippingMethod: `${args.shippingRate.provider} — ${args.shippingRate.serviceName}`, shippingServiceCode: args.shippingRate.serviceToken, carrier: args.shippingRate.provider, carrierAccount: args.shippingRate.carrierAccount, }, }); if (!session.client_secret) { throw new Error("Stripe session missing client_secret."); } return { clientSecret: session.client_secret }; }, }); export const getCheckoutSessionStatus = action({ args: { sessionId: v.string(), }, handler: async (_ctx, args): Promise => { const session: Stripe.Checkout.Session = await stripe.checkout.sessions.retrieve(args.sessionId); return { status: session.status as "complete" | "expired" | "open", paymentStatus: session.payment_status, customerEmail: session.customer_details?.email ?? null, }; }, }); export const handleWebhook = internalAction({ args: { payload: v.string(), signature: v.string(), }, handler: async (ctx, args): Promise<{ success: boolean }> => { const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; if (!webhookSecret) { throw new Error("STRIPE_WEBHOOK_SECRET environment variable is not set."); } let event: Stripe.Event; try { event = stripe.webhooks.constructEvent( args.payload, args.signature, webhookSecret, ); } catch (err) { console.error("Webhook signature verification failed:", err); return { success: false }; } switch (event.type) { case "checkout.session.completed": case "checkout.session.async_payment_succeeded": { const session = event.data.object as Stripe.Checkout.Session; const metadata = session.metadata; if ( !metadata?.convexUserId || !metadata?.addressId || !metadata?.shipmentObjectId || !metadata?.shippingMethod || !metadata?.shippingServiceCode || !metadata?.carrier ) { console.error( "Missing required metadata on checkout session:", session.id, ); return { success: false }; } await ctx.runMutation(internal.orders.fulfillFromCheckout, { stripeCheckoutSessionId: session.id, stripePaymentIntentId: typeof session.payment_intent === "string" ? session.payment_intent : session.payment_intent?.id ?? null, convexUserId: metadata.convexUserId, addressId: metadata.addressId, shipmentObjectId: metadata.shipmentObjectId, shippingMethod: metadata.shippingMethod, shippingServiceCode: metadata.shippingServiceCode, carrier: metadata.carrier, amountTotal: session.amount_total, amountShipping: session.shipping_cost?.amount_total ?? session.total_details?.amount_shipping ?? 0, currency: session.currency, }); break; } case "checkout.session.expired": console.warn( "Checkout session expired:", (event.data.object as Stripe.Checkout.Session).id, ); break; default: console.log("Unhandled Stripe event type:", event.type); } return { success: true }; }, });