1// Square → payment.updated (payment failed)
2// Fires when a payment ends in FAILED: a declined card, or an ACH bank transfer that
3// didn't clear.
4// Docs: https://developer.squareup.com/docs/payments-api/webhooks
5
6// Square doesn't send an event-name header; the event type is in the body's "type".
7// A declined card can arrive as payment.created (already FAILED); an ACH transfer that
8// bounces arrives as payment.updated.
9const EVENTS = ["payment.updated", "payment.created"]
10
11// Square error codes on card_details.errors / bank_account_details.errors, in plain words.
12const REASONS = {
13 GENERIC_DECLINE: "Card declined",
14 CARD_DECLINED: "Card declined",
15 CARD_DECLINED_CALL_ISSUER: "Declined, the customer should call their bank",
16 CARD_DECLINED_VERIFICATION_REQUIRED: "Declined, verification required",
17 INSUFFICIENT_FUNDS: "Insufficient funds",
18 CVV_FAILURE: "Wrong CVV",
19 VERIFY_CVV_FAILURE: "Wrong CVV",
20 ADDRESS_VERIFICATION_FAILURE: "Address check failed",
21 CARD_EXPIRED: "Card expired",
22 INVALID_EXPIRATION: "Invalid expiry date",
23 BAD_EXPIRATION: "Invalid expiry date",
24 EXPIRATION_FAILURE: "Invalid expiry date",
25 INVALID_CARD: "Invalid card",
26 INVALID_CARD_DATA: "Invalid card",
27 TRANSACTION_LIMIT: "Over the card's transaction limit",
28 PAYMENT_LIMIT_EXCEEDED: "Over Square's payment limit",
29 CARDHOLDER_INSUFFICIENT_PERMISSIONS: "Card not allowed for this purchase",
30 READER_DECLINED: "Declined by the card reader",
31}
32
33// Friendlier names for Square's card brands and payment sources.
34const BRANDS = {
35 VISA: "Visa",
36 MASTERCARD: "Mastercard",
37 AMERICAN_EXPRESS: "American Express",
38 DISCOVER: "Discover",
39 DISCOVER_DINERS: "Diners Club",
40 JCB: "JCB",
41 CHINA_UNIONPAY: "UnionPay",
42 SQUARE_GIFT_CARD: "Square gift card",
43 INTERAC: "Interac",
44 EFTPOS: "eftpos",
45 FELICA: "FeliCa",
46 EBT: "EBT",
47}
48
49const WALLETS = {
50 CASH_APP: "Cash App Pay",
51 PAYPAY: "PayPay",
52 ALIPAY: "Alipay",
53 RAKUTEN_PAY: "Rakuten Pay",
54 AU_PAY: "au PAY",
55 D_BARAI: "d払い",
56 MERPAY: "Merpay",
57 WECHAT_PAY: "WeChat Pay",
58 LIGHTNING: "Bitcoin Lightning",
59}
60
61const BNPL = {
62 AFTERPAY: "Afterpay",
63 CLEARPAY: "Clearpay",
64}
65
66const SOURCES = {
67 CASH: "cash",
68 BANK_ACCOUNT: "bank transfer",
69 SQUARE_ACCOUNT: "Square account",
70 EXTERNAL: "an external method",
71}
72
73// Header names can arrive in any case.
74function header(request, name) {
75 const headers = request.headers || {}
76 const key = Object.keys(headers).find((k) => k.toLowerCase() === name)
77 const value = key ? headers[key] : null
78 return Array.isArray(value) ? value[0] : value
79}
80
81// Table lookup that ignores inherited names such as "toString".
82function pick(table, key, fallback) {
83 return typeof key === "string" && Object.prototype.hasOwnProperty.call(table, key) ? table[key] : fallback
84}
85
86// Square Money is { amount, currency } with amount in the currency's smallest unit
87// (cents for USD/EUR/GBP, whole yen for JPY). Returns null when there's no amount.
88function money(value) {
89 const amount = value?.amount
90 const currency = value?.currency
91
92 if (typeof amount !== "number" || !Number.isFinite(amount) || typeof currency !== "string" || !currency) return null
93
94 try {
95 const format = new Intl.NumberFormat("en-GB", { style: "currency", currency })
96 return format.format(amount / 10 ** format.resolvedOptions().maximumFractionDigits)
97 } catch {
98 return null
99 }
100}
101
102// "via Visa •••• 1111", "via cash", "via Cash App Pay" …
103function method(payment) {
104 if (payment.source_type === "CARD") {
105 const card = payment.card_details?.card ?? {}
106 return pick(BRANDS, card.card_brand, "card") + (card.last_4 ? ` •••• ${card.last_4}` : "")
107 }
108 if (payment.source_type === "WALLET") return pick(WALLETS, payment.wallet_details?.brand, "a digital wallet")
109 if (payment.source_type === "BUY_NOW_PAY_LATER") return pick(BNPL, payment.buy_now_pay_later_details?.brand, "buy now, pay later")
110 return pick(SOURCES, payment.source_type, null)
111}
112
113// The first error code Square attached to the payment, if any.
114function reason(payment) {
115 const errors = payment.card_details?.errors ?? payment.bank_account_details?.errors ?? payment.buy_now_pay_later_details?.errors
116 const code = Array.isArray(errors) ? errors.find((e) => typeof e?.code === "string")?.code : null
117 if (!code) return null
118 const text = code.toLowerCase().replace(/_/g, " ")
119 return pick(REASONS, code, text.charAt(0).toUpperCase() + text.slice(1))
120}
121
122function handleRequest(request) {
123 const body = request.body && typeof request.body === "object" ? request.body : {}
124 const payment = body.data?.object?.payment
125
126 // Guard: only payment events that carry the payment object.
127 if (!EVENTS.includes(body.type) || !payment || typeof payment !== "object") {
128 console.log(`Ignoring Square event "${typeof body.type === "string" ? body.type : "unknown"}"`)
129 return null
130 }
131
132 // payment.updated fires for every change; only a failed payment is worth this push.
133 if (payment.status !== "FAILED") {
134 console.log(`Skipping payment with status "${payment.status ?? "unknown"}"`)
135 return null
136 }
137
138 const amount = money(payment.total_money) ?? money(payment.amount_money) ?? "A payment"
139 const via = method(payment)
140 const why = reason(payment)
141 const sandbox = String(header(request, "square-environment") ?? "").toLowerCase() === "sandbox" ? " (sandbox)" : ""
142
143 return {
144 title: "🚫 Payment failed",
145 message:
146 amount +
147 (via ? ` via ${via}` : "") +
148 (payment.buyer_email_address ? ` from ${payment.buyer_email_address}` : "") +
149 (why ? `. ${why}.` : "") +
150 sandbox,
151 topic: "Square",
152 priority: 1, // High — a customer tried to pay and couldn't
153 }
154}