1// Gumroad → sale (Ping)
2// Fires for every sale, including subscription renewals, free downloads and pre-orders.
3// Docs: https://gumroad.com/ping
4//
5// Gumroad POSTs form-encoded fields such as product_name=…, price=4900, card[type]=visa.
6// Depending on how the body was parsed, it can arrive as a nested object, a flat object
7// with bracket keys, or only as the raw string. Every value is a string ("true", "4900").
8
9const TOPIC = "Gumroad"
10
11// Keys that must never become object properties.
12const UNSAFE = new Set(["__proto__", "constructor", "prototype"])
13
14// Put a value at a bracket path such as card[visual] or purchase_ids[].
15function setPath(target, key, value) {
16 const list = /\[\]$/.test(key)
17 const parts = String(key).replace(/\[\]$/, "").replace(/\]/g, "").split("[")
18 if (parts.some((p) => p === "" || UNSAFE.has(p))) return
19 let node = target
20 for (const part of parts.slice(0, -1)) {
21 if (!Object.prototype.hasOwnProperty.call(node, part) || !node[part] || typeof node[part] !== "object" || Array.isArray(node[part])) node[part] = {}
22 node = node[part]
23 }
24 const last = parts[parts.length - 1]
25 if (!list) node[last] = value
26 else if (Array.isArray(node[last]) && Object.prototype.hasOwnProperty.call(node, last)) node[last].push(value)
27 else node[last] = [value]
28}
29
30// Decode one form-encoded piece; a bad escape shouldn't break the whole body.
31function decode(value) {
32 try {
33 return decodeURIComponent(String(value).replace(/\+/g, " "))
34 } catch {
35 return String(value)
36 }
37}
38
39// Turn whatever Studio gives us (object, JSON string or raw form string) into one object.
40function readForm(request) {
41 let body = request.body
42 if (typeof body === "string" && body.trim().startsWith("{")) {
43 try {
44 body = JSON.parse(body)
45 } catch {
46 body = null
47 }
48 }
49 const out = {}
50 if (body && typeof body === "object" && !Array.isArray(body)) {
51 for (const [key, value] of Object.entries(body)) {
52 if (key.includes("[")) setPath(out, key, value)
53 else if (!UNSAFE.has(key)) out[key] = value
54 }
55 if (Object.keys(out).length) return out
56 }
57 // Fall back to the raw form-encoded string.
58 const raw = typeof body === "string" ? body : typeof request.raw === "string" ? request.raw : ""
59 for (const pair of raw.split("&")) {
60 if (!pair) continue
61 const at = pair.indexOf("=")
62 setPath(out, decode(at < 0 ? pair : pair.slice(0, at)), at < 0 ? "" : decode(pair.slice(at + 1)))
63 }
64 return out
65}
66
67// Read an own field only, so names like "toString" never resolve to something inherited.
68function field(form, key) {
69 return Object.prototype.hasOwnProperty.call(form, key) ? form[key] : undefined
70}
71
72// Form fields are strings; treat blanks as missing.
73function text(value) {
74 if (typeof value === "number" && Number.isFinite(value)) return String(value)
75 return typeof value === "string" && value.trim() ? value.trim() : null
76}
77
78// Booleans arrive as "true"/"false" in form bodies and as true/false in JSON.
79function flag(value) {
80 return value === true || value === "true"
81}
82
83// Gumroad's price is always in USD cents, whatever the product's currency field says.
84function usd(cents) {
85 const n = typeof cents === "number" ? cents : /^-?\d+(\.\d+)?$/.test(text(cents) ?? "") ? Number(cents) : NaN
86 if (!Number.isFinite(n)) return null
87 try {
88 return new Intl.NumberFormat("en-GB", { style: "currency", currency: "USD" }).format(n / 100)
89 } catch {
90 return `US$${(n / 100).toFixed(2)}`
91 }
92}
93
94// Only link to real http(s) URLs.
95function link(url, cta) {
96 return typeof url === "string" && /^https?:\/\//.test(url) ? [{ cta, url }] : []
97}
98
99// Look a key up without tripping over inherited names such as "toString".
100function pick(table, key, fallback) {
101 return Object.prototype.hasOwnProperty.call(table, key) ? table[key] : fallback
102}
103
104// What kind of sale it is decides the title and how loud the push is.
105const KINDS = {
106 test: { title: "🧪 Test sale", priority: -1 }, // Low — you bought your own product
107 preorder: { title: "🕒 New pre-order", priority: 0 }, // Normal — the card is only authorised for now
108 renewal: { title: "🔁 Subscription renewal", priority: -1 }, // Low — routine recurring income
109 free: { title: "📥 New free download", priority: -1 }, // Low — nice, but no money in
110 sale: { title: "💰 New sale", priority: 0 }, // Normal — money in is good news, not an emergency
111}
112
113function handleRequest(request) {
114 const form = readForm(request)
115 const resource = text(field(form, "resource_name"))
116 const test = flag(field(form, "test"))
117
118 // "Send test ping to URL" in Settings → Advanced posts your latest sale with test=true
119 // and, unlike a real ping, no resource_name.
120 if (!resource && test && text(field(form, "sale_id"))) {
121 return {
122 title: "🔔 Gumroad connected",
123 message: `Test ping for ${text(field(form, "product_name")) ?? "your latest sale"} received`,
124 topic: TOPIC,
125 priority: -1, // Low — just confirms the setup works
126 }
127 }
128
129 // Guard: only sales. Real pings name their resource; without one, fall back to the shape.
130 const isSale = resource
131 ? resource === "sale"
132 : Boolean(text(field(form, "sale_id"))) && !flag(field(form, "refunded")) && !flag(field(form, "disputed"))
133 if (!isSale) {
134 console.log(`Ignoring Gumroad "${resource ?? "unknown"}" ping`)
135 return null
136 }
137
138 // ?renewals=0 on the ping URL skips subscription renewals.
139 const renewal = flag(field(form, "is_recurring_charge"))
140 if (renewal && ["0", "false", "no"].includes(String(request.query?.renewals ?? "").toLowerCase())) {
141 console.log("Skipping subscription renewal (?renewals=0)")
142 return null
143 }
144
145 const gift = flag(field(form, "is_gift_receiver_purchase"))
146 const cents = gift ? field(form, "gift_price") ?? field(form, "price") : field(form, "price")
147 const amount = usd(cents)
148 const free = amount !== null && Number(cents) === 0
149 const kind = test ? "test" : flag(field(form, "is_preorder_authorization")) ? "preorder" : renewal ? "renewal" : free ? "free" : "sale"
150 const { title, priority } = pick(KINDS, kind, KINDS.sale)
151
152 const variants = field(form, "variants")
153 const options = variants && typeof variants === "object" ? Object.values(variants).map(text).filter(Boolean).join(", ") : ""
154 const quantity = Number(text(field(form, "quantity")))
155 const product =
156 (text(field(form, "product_name")) ?? "a product") +
157 (options ? ` (${options})` : "") +
158 (quantity > 1 ? ` ×${quantity}` : "")
159 const buyer = text(field(form, "full_name")) ?? text(field(form, "email"))
160 // On a gift, the ping is the receiver's purchase; the gifter is the one who paid.
161 const who = gift ? text(field(form, "gifter_email")) ?? buyer : buyer
162
163 return {
164 title,
165 message:
166 (amount && !free ? `${amount} from ${who ?? "a customer"}` : who ?? "A customer") +
167 ` — ${product}` +
168 (gift ? (buyer && buyer !== who ? ` (gift for ${buyer})` : " (gift)") : ""),
169 topic: TOPIC,
170 priority,
171 // Cash register only when money actually came in.
172 ...(kind === "sale" || kind === "renewal" ? { sound: "cashregister" } : {}),
173 buttons: link("https://app.gumroad.com/customers", "View sales"),
174 }
175}