Skip to content

Commit 59078a7

Browse files
committed
Add webhook for order notification emails with edition, customer, shipping details
1 parent c4dc989 commit 59078a7

1 file changed

Lines changed: 156 additions & 15 deletions

File tree

worker/src/index.js

Lines changed: 156 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,23 @@
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).
1614
const 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`.
2521
const CURRENCY = "usd";
2622
const 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.
3833
const SHIPPING = { label: "Standard shipping", cents: 900 };
3934

4035
const SUCCESS_URL = "https://plotflow.io/success.html?session_id={CHECKOUT_SESSION_ID}";
4136
const CANCEL_URL = "https://plotflow.io/?checkout=cancelled";
42-
43-
// Countries we ship to (collected on the Stripe-hosted page).
4437
const 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+
4642
export 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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"); }
243+
103244
function corsHeaders(origin) {
104245
const allow = ALLOWED_ORIGINS.indexOf(origin) !== -1 ? origin : ALLOWED_ORIGINS[0];
105246
return {

0 commit comments

Comments
 (0)