返回 CodeWhale
server.ts
根目录 / web / lib / merch / server.ts
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
155 lines TYPESCRIPT