| 1 | import StripeClient from "stripe"; |
| 2 | import { randomUUID } from "node:crypto"; |
| 3 | import { getEnv } from "../kv"; |
| 4 | import { readBoundedBody, BodyReadError } from "../bounded-body"; |
| 5 | import { MERCH_PRODUCTS } from "./catalog"; |
| 6 | import { MerchError, object, parseConfig, validateQuoteInput, reviewedRoute, priceQuote } from "./model"; |
| 7 | import { destinationRate, yoycolRequest } from "./yoycol"; |
| 8 | import type { MerchEnv, Quote, MerchConfig, MerchDatabase } from "./types"; |
| 9 | |
| 10 | type OrderRow = { id: string; quote_json: string; expires_at: number; stripe_session_id: string | null; checkout_url: string | null; payment_state: string; fulfillment_state: string; supplier_order_id: string | null; tracking_json: string | null }; |
| 11 | export async function merchEnv(): Promise<MerchEnv> { |
| 12 | return await getEnv(); |
| 13 | } |
| 14 | function stripe(env: MerchEnv) { |
| 15 | if (!env.MERCH_STRIPE_KEY) throw new MerchError(503, "checkout_unavailable", "Checkout is not configured yet."); |
| 16 | return new StripeClient(env.MERCH_STRIPE_KEY, { httpClient: StripeClient.createFetchHttpClient(), maxNetworkRetries: 1, timeout: 15000 }); |
| 17 | } |
| 18 | function paymentEnvironment(env: MerchEnv) { |
| 19 | const config = parseConfig(env.MERCH_CONFIG_JSON); |
| 20 | if (!config || !env.MERCH_DB || !env.MERCH_STRIPE_KEY || !env.MERCH_STRIPE_WEBHOOK_SECRET) throw new MerchError(503, "checkout_unavailable", "The first drop is awaiting samples and checkout setup."); |
| 21 | const liveKey = /^(?:sk|rk)_live_/.test(env.MERCH_STRIPE_KEY); |
| 22 | const testKey = /^(?:sk|rk)_test_/.test(env.MERCH_STRIPE_KEY); |
| 23 | if (!(config.mode === "live" ? liveKey : testKey)) throw new MerchError(503, "checkout_unavailable", "Checkout configuration needs review."); |
| 24 | return { config, db: env.MERCH_DB }; |
| 25 | } |
| 26 | function configured(env: MerchEnv) { |
| 27 | const result = paymentEnvironment(env); |
| 28 | if (!result.config.checkoutEnabled || !env.MERCH_RATE_LIMITER || !env.MERCH_PAYMENT_CONFIGURATION || !env.YOYCOL_ACCESS_KEY || !env.YOYCOL_SECRET_KEY) throw new MerchError(503, "checkout_unavailable", "The first drop is awaiting samples and checkout setup."); |
| 29 | return result; |
| 30 | } |
| 31 | export function merchStatus(env: MerchEnv) { |
| 32 | try { |
| 33 | const { config } = configured(env); |
| 34 | const readyProductIds = MERCH_PRODUCTS.filter(p => p.kind !== "concept" && p.collection === "launch" && Object.entries(config.products[p.id] ?? {}).some(([country, product]) => { |
| 35 | const route = config.countries[country]; |
| 36 | return product.approved === true && (product.designCode || (p.id === "contributor" && Object.keys(product.contributorDesigns ?? {}).length > 0)) && Object.keys(product.skuBySize ?? {}).length && route?.taxReviewed === true && route.addressPolicyReviewed === true && route.supplierPricesUsdReviewed === true && route.allowedPostalPrefixes?.length; |
| 37 | })).map(p => p.id); |
| 38 | return { checkoutConfigured: readyProductIds.length > 0, readyProductIds }; |
| 39 | } catch { return { checkoutConfigured: false, readyProductIds: [] }; } |
| 40 | } |
| 41 | function json(data: unknown, status = 200) { |
| 42 | return Response.json(data, { status, headers: { "Cache-Control": "no-store" } }); |
| 43 | } |
| 44 | async function limited(env: MerchEnv, request: Request, config: MerchConfig) { |
| 45 | const origin = request.headers.get("origin"); |
| 46 | if (!origin || origin !== new URL(config.siteOrigin).origin || !request.headers.get("content-type")?.startsWith("application/json")) throw new MerchError(403, "request_blocked", "Refresh the page before trying again."); |
| 47 | // Shared limiter never becomes a permissive per-isolate in-memory fallback. |
| 48 | const result = await env.MERCH_RATE_LIMITER!.limit({ key: "codewhale-merch:public" }); |
| 49 | if (!result.success) throw new MerchError(429, "try_later", "Please wait a minute and try again."); |
| 50 | } |
| 51 | async function body(request: Request) { |
| 52 | try { return JSON.parse(new TextDecoder().decode(await readBoundedBody(request, 8192))) as unknown; } |
| 53 | catch (error) { |
| 54 | if (error instanceof BodyReadError) throw new MerchError(error.status, "invalid_request", error.message); |
| 55 | throw new MerchError(422, "invalid_request", "Check the form and try again."); |
| 56 | } |
| 57 | } |
| 58 | async function row(db: MerchDatabase, id: string) { |
| 59 | return db.prepare("SELECT * FROM merch_orders WHERE id = ?").bind(id).first<OrderRow>(); |
| 60 | } |
| 61 | function safeId(value: unknown): string { |
| 62 | if (typeof value !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value)) throw new MerchError(422, "invalid_quote", "Request a fresh shipping quote."); |
| 63 | return value; |
| 64 | } |
| 65 | function publicQuote(quote: Quote) { |
| 66 | const { quoteId, expiresAt, currency, merchandise, shipping, processing, total, shippingName, taxNote } = quote; |
| 67 | return { quoteId, expiresAt, currency, merchandise, shipping, processing, total, shippingName, taxNote }; |
| 68 | } |
| 69 | async function quote(request: Request, env: MerchEnv) { |
| 70 | const { config, db } = configured(env); await limited(env, request, config); |
| 71 | const input = validateQuoteInput(await body(request)), { route, product, designCode } = reviewedRoute(config, input); |
| 72 | // API provides destination/SKU rates, not exact-address or mixed-cart landed quotes. |
| 73 | const skuCode = product.skuBySize[input.size]; |
| 74 | const rate = destinationRate(await yoycolRequest(env, "GET", "/api/2025/open/v4/shipping/sku_quotes", { sku_code: skuCode }), route.regionCode, route.shippingLevelCode, input.quantity); |
| 75 | const prices = priceQuote(input, route, product.productionCostUsd * input.quantity, rate.shippingUsd); |
| 76 | const value: Quote = { quoteId: randomUUID(), mode: config.mode, routeApproval: JSON.stringify([route, product]), expiresAt: Date.now() + 15 * 60 * 1000, currency: route.currency, ...prices, shippingName: rate.name, taxNote: route.taxNote, input, skuCode, designCode, shippingLevelCode: route.shippingLevelCode, vendorProductionUsd: product.productionCostUsd * input.quantity, vendorShippingUsd: rate.shippingUsd }; |
| 77 | await db.prepare("INSERT INTO merch_orders (id,quote_json,expires_at,updated_at) VALUES (?,?,?,?)").bind(value.quoteId, JSON.stringify(value), value.expiresAt, Date.now()).run(); |
| 78 | return json(publicQuote(value)); |
| 79 | } |
| 80 | async function checkout(request: Request, env: MerchEnv) { |
| 81 | const { config, db } = configured(env); await limited(env, request, config); |
| 82 | const id = safeId(object(await body(request)).quoteId), order = await row(db, id); |
| 83 | if (!order || order.expires_at <= Date.now() || order.payment_state !== "quoted") throw new MerchError(409, "quote_expired", "Please request a fresh quote."); |
| 84 | const value = JSON.parse(order.quote_json) as Quote; |
| 85 | if (value.mode !== config.mode) throw new MerchError(409, "quote_expired", "Please request a fresh quote."); |
| 86 | const reviewed = reviewedRoute(config, value.input); |
| 87 | if (value.routeApproval !== JSON.stringify([reviewed.route, reviewed.product])) throw new MerchError(409, "quote_expired", "The print or delivery review changed. Please request a fresh quote."); |
| 88 | if (order.checkout_url) return json({ url: order.checkout_url }); |
| 89 | const product = MERCH_PRODUCTS.find(p => p.id === value.input.productId)!; |
| 90 | const client = stripe(env); |
| 91 | const session = await client.checkout.sessions.create({ |
| 92 | mode: "payment", client_reference_id: id, expires_at: Math.floor((value.expiresAt + 30 * 60 * 1000) / 1000), |
| 93 | integration_identifier: "codewhale_merch_" + Array.from(id.replaceAll("-", "").slice(0, 8), n => String.fromCharCode(97 + parseInt(n, 16))).join(""), |
| 94 | payment_method_configuration: env.MERCH_PAYMENT_CONFIGURATION, |
| 95 | line_items: [ |
| 96 | { price_data: { currency: value.currency, unit_amount: value.merchandise + value.processing, product_data: { name: "Codewhale · " + product.title, description: value.input.size + " · " + value.input.color + " · " + value.input.quantity + " item(s)" + (value.processing ? " · cost-recovery processing included" : "") } }, quantity: 1 }, |
| 97 | { price_data: { currency: value.currency, unit_amount: value.shipping, product_data: { name: "Delivery · " + value.shippingName } }, quantity: 1 }, |
| 98 | ], |
| 99 | // Delivery is an explicit line item: no dependency on Stripe's address collector. |
| 100 | // Do not collect a different shipping address after the destination quote. |
| 101 | payment_intent_data: { shipping: { name: value.input.address.name, phone: value.input.address.phone, address: { line1: value.input.address.line1, line2: value.input.address.line2 || undefined, city: value.input.address.city, state: value.input.address.region || undefined, postal_code: value.input.address.postalCode || undefined, country: value.input.country } }, metadata: { merch_order_id: id } }, |
| 102 | metadata: { merch_order_id: id, merch_product_id: product.id }, |
| 103 | custom_text: { submit: { message: "Ships to the address provided on Codewhale. " + value.taxNote } }, |
| 104 | success_url: new URL("/en/merch?payment=received", config.siteOrigin).href, |
| 105 | cancel_url: new URL("/en/merch?payment=cancelled", config.siteOrigin).href, |
| 106 | }, { idempotencyKey: "codewhale-merch:" + id }); |
| 107 | if (session.livemode !== (config.mode === "live") || !session.url || new URL(session.url).hostname !== "checkout.stripe.com" || !session.url.startsWith("https:")) throw new MerchError(503, "checkout_unavailable", "Checkout needs review."); |
| 108 | await db.prepare("UPDATE merch_orders SET stripe_session_id=?,checkout_url=?,updated_at=? WHERE id=? AND payment_state='quoted'").bind(session.id, session.url, Date.now(), id).run(); |
| 109 | return json({ url: session.url }); |
| 110 | } |
| 111 | async function webhook(request: Request, env: MerchEnv) { |
| 112 | // Pausing new sales must not discard payment events for existing sessions. |
| 113 | const { config, db } = paymentEnvironment(env), client = stripe(env); |
| 114 | let event: StripeClient.Event; |
| 115 | try { |
| 116 | const raw = new TextDecoder().decode(await readBoundedBody(request, 262144)); |
| 117 | event = await client.webhooks.constructEventAsync(raw, request.headers.get("stripe-signature") ?? "", env.MERCH_STRIPE_WEBHOOK_SECRET!, undefined, StripeClient.createSubtleCryptoProvider()); |
| 118 | } catch { throw new MerchError(400, "invalid_signature", "Webhook signature verification failed."); } |
| 119 | if (event.livemode !== (config.mode === "live")) throw new MerchError(400, "wrong_mode", "Webhook mode mismatch."); |
| 120 | if (!["checkout.session.completed", "checkout.session.async_payment_succeeded", "checkout.session.async_payment_failed", "checkout.session.expired"].includes(event.type)) return json({ received: true }); |
| 121 | const snapshot = event.data.object as StripeClient.Checkout.Session; |
| 122 | const id = snapshot.metadata?.merch_order_id; |
| 123 | if (!id) return json({ received: true }); |
| 124 | const order = await row(db, safeId(id)); |
| 125 | if (!order || order.stripe_session_id !== snapshot.id) throw new MerchError(409, "order_mismatch", "The payment needs reconciliation."); |
| 126 | const value = JSON.parse(order.quote_json) as Quote; |
| 127 | const session = await client.checkout.sessions.retrieve(snapshot.id, { expand: ["line_items"] }); |
| 128 | if (value.mode !== config.mode || session.livemode !== (config.mode === "live") || session.client_reference_id !== id || session.metadata?.merch_order_id !== id || session.metadata?.merch_product_id !== value.input.productId || session.currency !== value.currency || session.amount_total !== value.total) throw new MerchError(409, "amount_mismatch", "The payment needs reconciliation."); |
| 129 | if (session.payment_status === "paid") { |
| 130 | await db.batch([ |
| 131 | db.prepare("INSERT OR IGNORE INTO merch_webhook_events (id,order_id,kind,received_at) VALUES (?,?,?,?)").bind(event.id, id, event.type, Date.now()), |
| 132 | db.prepare("UPDATE merch_orders SET payment_state='paid',fulfillment_state='awaiting_supplier_review',updated_at=? WHERE id=? AND payment_state IN ('quoted','failed','expired')").bind(Date.now(), id), |
| 133 | ]); |
| 134 | } else if (event.type === "checkout.session.async_payment_failed" || event.type === "checkout.session.expired") { |
| 135 | await db.batch([ |
| 136 | db.prepare("INSERT OR IGNORE INTO merch_webhook_events (id,order_id,kind,received_at) VALUES (?,?,?,?)").bind(event.id, id, event.type, Date.now()), |
| 137 | db.prepare("UPDATE merch_orders SET payment_state=?,updated_at=? WHERE id=? AND payment_state='quoted'").bind(event.type.endsWith("expired") ? "expired" : "failed", Date.now(), id), |
| 138 | ]); |
| 139 | } |
| 140 | return json({ received: true }); |
| 141 | } |
| 142 | /** Source-ready entrypoints. No supplier order/payment is triggered by a redirect or webhook. */ |
| 143 | export async function handleMerch(request: Request, action: "status" | "quote" | "checkout" | "webhook", suppliedEnv?: MerchEnv): Promise<Response> { |
| 144 | try { |
| 145 | const env = suppliedEnv ?? await merchEnv(); |
| 146 | if (action === "status") return json(merchStatus(env)); |
| 147 | if (action === "quote") return await quote(request, env); |
| 148 | if (action === "checkout") return await checkout(request, env); |
| 149 | return await webhook(request, env); |
| 150 | } catch (error) { |
| 151 | if (error instanceof MerchError) return json({ error: error.message, code: error.code }, error.status); |
| 152 | return json({ error: "This request could not be completed. Please try again later.", code: "temporarily_unavailable" }, 503); |
| 153 | } |
| 154 | } |
| 155 |