11/* ============================================================
2- PLOTFLOW · Checkout Worker (Cloudflare)
3- Turns the site's cart into a Stripe Checkout Session and returns
4- the hosted-checkout URL.
2+ PLOTFLOW · Checkout + Order Notification Worker (Cloudflare)
53
6- Prices live HERE, server-side (see CATALOG), built as ad-hoc
7- `price_data` line items — so there is nothing to create in the
8- Stripe dashboard and nothing for the browser to tamper with. The
9- client only ever sends {items:[{key,qty}]}.
4+ POST / → creates a Stripe Checkout Session
5+ POST /webhook → handles Stripe webhook (checkout.session.completed)
6+ and emails order details to the shop owner
107
11- Secret: wrangler secret put STRIPE_SECRET_KEY (paste sk_test_… / sk_live_…)
12- Deploy: wrangler deploy (see ../README.md)
8+ Secrets (set via `wrangler secret put`):
9+ STRIPE_SECRET_KEY sk_test_… or sk_live_…
10+ STRIPE_WEBHOOK_SECRET whsec_… (from Stripe Dashboard → Webhooks)
11+ RESEND_API_KEY re_… (from resend.com)
1312 ============================================================ */
1413
15- // Origins allowed to call this endpoint (the live site + local dev).
1614const ALLOWED_ORIGINS = [
1715 "https://plotflow.io" ,
1816 "https://www.plotflow.io" ,
1917 "http://localhost:8000" ,
2018 "http://127.0.0.1:8000"
2119] ;
2220
23- // edition key -> { name shown on Stripe checkout, price in cents }.
24- // Keys must match data/editions.js. Change a price by editing `cents`.
2521const CURRENCY = "usd" ;
2622const CATALOG = {
2723 zaku : { name : "Zaku II — MS-06" , cents : 4500 } ,
@@ -34,17 +30,25 @@ const CATALOG = {
3430 guntank : { name : "Guntank — RX-75" , cents : 4500 }
3531} ;
3632
37- // Flat shipping fee added on top, all countries. Set cents = 0 for free shipping.
3833const SHIPPING = { label : "Standard shipping" , cents : 900 } ;
3934
4035const SUCCESS_URL = "https://plotflow.io/success.html?session_id={CHECKOUT_SESSION_ID}" ;
4136const CANCEL_URL = "https://plotflow.io/?checkout=cancelled" ;
42-
43- // Countries we ship to (collected on the Stripe-hosted page).
4437const SHIP_COUNTRIES = [ "US" , "CA" , "GB" , "AU" , "DE" , "FR" , "JP" ] ;
4538
39+ const NOTIFY_EMAIL = "devin@plotflow.io" ;
40+ const FROM_EMAIL = "orders@plotflow.io" ;
41+
4642export default {
4743 async fetch ( request , env ) {
44+ const url = new URL ( request . url ) ;
45+
46+ // ---- webhook endpoint ----
47+ if ( url . pathname === "/webhook" && request . method === "POST" ) {
48+ return handleWebhook ( request , env ) ;
49+ }
50+
51+ // ---- checkout endpoint ----
4852 const origin = request . headers . get ( "Origin" ) || "" ;
4953 const cors = corsHeaders ( origin ) ;
5054
@@ -59,6 +63,7 @@ export default {
5963 const form = new URLSearchParams ( ) ;
6064 form . set ( "mode" , "payment" ) ;
6165
66+ const orderSummary = [ ] ;
6267 let n = 0 ;
6368 for ( const it of items ) {
6469 const key = String ( ( it && it . key ) || "" ) ;
@@ -69,6 +74,7 @@ export default {
6974 form . set ( `line_items[${ n } ][price_data][unit_amount]` , String ( prod . cents ) ) ;
7075 form . set ( `line_items[${ n } ][price_data][product_data][name]` , prod . name ) ;
7176 form . set ( `line_items[${ n } ][quantity]` , String ( qty ) ) ;
77+ orderSummary . push ( `${ qty } × ${ prod . name } ` ) ;
7278 n ++ ;
7379 }
7480 if ( ! n ) return json ( { error : "Cart is empty" } , 400 , cors ) ;
@@ -86,6 +92,8 @@ export default {
8692 form . set ( "shipping_options[0][shipping_rate_data][display_name]" , SHIPPING . label ) ;
8793 }
8894
95+ form . set ( "metadata[editions]" , orderSummary . join ( ", " ) ) ;
96+
8997 const resp = await fetch ( "https://api.stripe.com/v1/checkout/sessions" , {
9098 method : "POST" ,
9199 headers : {
@@ -100,6 +108,139 @@ export default {
100108 }
101109} ;
102110
111+ // ---- Stripe Webhook Handler ----
112+
113+ async function handleWebhook ( request , env ) {
114+ const payload = await request . text ( ) ;
115+ const sig = request . headers . get ( "stripe-signature" ) ;
116+
117+ if ( env . STRIPE_WEBHOOK_SECRET && sig ) {
118+ const valid = await verifyStripeSignature ( payload , sig , env . STRIPE_WEBHOOK_SECRET ) ;
119+ if ( ! valid ) return new Response ( "Invalid signature" , { status : 400 } ) ;
120+ }
121+
122+ let event ;
123+ try { event = JSON . parse ( payload ) ; } catch ( e ) { return new Response ( "Bad JSON" , { status : 400 } ) ; }
124+
125+ if ( event . type === "checkout.session.completed" ) {
126+ const session = event . data . object ;
127+
128+ const sessionDetail = await fetch (
129+ `https://api.stripe.com/v1/checkout/sessions/${ session . id } ?expand[]=line_items&expand[]=customer_details` ,
130+ { headers : { "Authorization" : "Bearer " + env . STRIPE_SECRET_KEY } }
131+ ) . then ( r => r . json ( ) ) ;
132+
133+ const items = ( sessionDetail . line_items && sessionDetail . line_items . data ) || [ ] ;
134+ const cust = sessionDetail . customer_details || { } ;
135+ const ship = sessionDetail . shipping_details || sessionDetail . collected_information ?. shipping_details || { } ;
136+ const addr = ship . address || { } ;
137+ const meta = sessionDetail . metadata || { } ;
138+
139+ const itemLines = items . map ( li =>
140+ `${ li . quantity } × ${ li . description } — $${ ( li . amount_total / 100 ) . toFixed ( 2 ) } `
141+ ) . join ( "\n" ) ;
142+
143+ const total = sessionDetail . amount_total ? `$${ ( sessionDetail . amount_total / 100 ) . toFixed ( 2 ) } ` : "—" ;
144+
145+ const shipAddr = [
146+ ship . name || cust . name || "" ,
147+ addr . line1 , addr . line2 ,
148+ [ addr . city , addr . state , addr . postal_code ] . filter ( Boolean ) . join ( ", " ) ,
149+ addr . country
150+ ] . filter ( Boolean ) . join ( "\n" ) ;
151+
152+ const text = [
153+ "NEW ORDER — PLOTFLOW" ,
154+ "═" . repeat ( 40 ) ,
155+ "" ,
156+ `Customer: ${ cust . name || "—" } ` ,
157+ `Email: ${ cust . email || "—" } ` ,
158+ `Phone: ${ cust . phone || "—" } ` ,
159+ "" ,
160+ "EDITIONS" ,
161+ "─" . repeat ( 20 ) ,
162+ itemLines || meta . editions || "—" ,
163+ "" ,
164+ `Subtotal + shipping: ${ total } ` ,
165+ "" ,
166+ "SHIP TO" ,
167+ "─" . repeat ( 20 ) ,
168+ shipAddr || "No address collected" ,
169+ "" ,
170+ `Session: ${ session . id } ` ,
171+ `Payment: ${ session . payment_intent } ` ,
172+ `Dashboard: https://dashboard.stripe.com/payments/${ session . payment_intent } ` ,
173+ ] . join ( "\n" ) ;
174+
175+ const html = `
176+ <div style="font-family:monospace;max-width:600px;margin:0 auto;padding:20px">
177+ <h2 style="margin:0 0 4px">NEW ORDER — PLOTFLOW*</h2>
178+ <hr style="border:2px solid #e8351f;margin:8px 0 20px">
179+ <table style="font-size:14px;line-height:1.6">
180+ <tr><td style="color:#888;padding-right:12px">Customer</td><td><strong>${ esc ( cust . name || "—" ) } </strong></td></tr>
181+ <tr><td style="color:#888;padding-right:12px">Email</td><td>${ esc ( cust . email || "—" ) } </td></tr>
182+ <tr><td style="color:#888;padding-right:12px">Phone</td><td>${ esc ( cust . phone || "—" ) } </td></tr>
183+ </table>
184+ <h3 style="margin:20px 0 6px;font-size:13px;letter-spacing:2px;text-transform:uppercase;color:#e8351f">Editions</h3>
185+ <div style="background:#f5f4ef;padding:12px 16px;border-left:3px solid #e8351f;font-size:14px">
186+ ${ items . map ( li => `<div>${ li . quantity } × <strong>${ esc ( li . description ) } </strong> — $${ ( li . amount_total / 100 ) . toFixed ( 2 ) } </div>` ) . join ( "" ) || esc ( meta . editions || "—" ) }
187+ </div>
188+ <p style="font-size:15px;margin:12px 0"><strong>Total: ${ esc ( total ) } </strong></p>
189+ <h3 style="margin:20px 0 6px;font-size:13px;letter-spacing:2px;text-transform:uppercase;color:#e8351f">Ship To</h3>
190+ <div style="background:#f5f4ef;padding:12px 16px;border-left:3px solid #e8351f;font-size:14px;white-space:pre-line">${ esc ( shipAddr || "No address collected" ) } </div>
191+ <p style="margin-top:20px;font-size:12px;color:#888">
192+ <a href="https://dashboard.stripe.com/payments/${ session . payment_intent } " style="color:#e8351f">View in Stripe →</a>
193+ </p>
194+ </div>` ;
195+
196+ if ( env . RESEND_API_KEY ) {
197+ await fetch ( "https://api.resend.com/emails" , {
198+ method : "POST" ,
199+ headers : {
200+ "Authorization" : "Bearer " + env . RESEND_API_KEY ,
201+ "Content-Type" : "application/json"
202+ } ,
203+ body : JSON . stringify ( {
204+ from : `PlotFlow Orders <${ FROM_EMAIL } >` ,
205+ to : [ NOTIFY_EMAIL ] ,
206+ subject : `New order: ${ meta . editions || "PlotFlow edition" } ` ,
207+ html : html ,
208+ text : text
209+ } )
210+ } ) ;
211+ }
212+ }
213+
214+ return new Response ( "ok" , { status : 200 } ) ;
215+ }
216+
217+ // ---- Stripe signature verification (HMAC-SHA256) ----
218+
219+ async function verifyStripeSignature ( payload , header , secret ) {
220+ try {
221+ const pairs = Object . fromEntries ( header . split ( "," ) . map ( p => {
222+ const [ k , v ] = p . split ( "=" ) ; return [ k . trim ( ) , v ] ;
223+ } ) ) ;
224+ const timestamp = pairs . t ;
225+ const sig = pairs . v1 ;
226+ if ( ! timestamp || ! sig ) return false ;
227+
228+ const age = Math . floor ( Date . now ( ) / 1000 ) - parseInt ( timestamp ) ;
229+ if ( Math . abs ( age ) > 300 ) return false ;
230+
231+ const key = await crypto . subtle . importKey (
232+ "raw" , new TextEncoder ( ) . encode ( secret ) , { name : "HMAC" , hash : "SHA-256" } , false , [ "sign" ]
233+ ) ;
234+ const signed = await crypto . subtle . sign ( "HMAC" , key , new TextEncoder ( ) . encode ( `${ timestamp } .${ payload } ` ) ) ;
235+ const expected = Array . from ( new Uint8Array ( signed ) ) . map ( b => b . toString ( 16 ) . padStart ( 2 , "0" ) ) . join ( "" ) ;
236+ return expected === sig ;
237+ } catch ( e ) { return false ; }
238+ }
239+
240+ // ---- Helpers ----
241+
242+ function esc ( s ) { return String ( s ) . replace ( / & / g, "&" ) . replace ( / < / g, "<" ) . replace ( / > / g, ">" ) . replace ( / " / g, """ ) ; }
243+
103244function corsHeaders ( origin ) {
104245 const allow = ALLOWED_ORIGINS . indexOf ( origin ) !== - 1 ? origin : ALLOWED_ORIGINS [ 0 ] ;
105246 return {
0 commit comments