ReviewOS

also looking at this

stacks/uplink

feat(billing): issue the license from Stripe, not only from the thank-you page

#49
Merged glennmichael123 wants to merge feat/stripe-webhook into main
9 files +439 -2
.env.examplemodified+2-0
Changes to .env.example
@@ -35,6 +35,8 @@ DB_USERNAME=root
3535DB_PASSWORD=
3636
3737STRIPE_SECRET_KEY=""
38# Signing secret for the /api/billing/webhook endpoint, from the Stripe dashboard.
39STRIPE_WEBHOOK_SECRET=""
3840
3941AWS_ACCOUNT_ID=
4042AWS_ACCESS_KEY_ID=
app/Actions/Billing/StripeWebhookAction.tsadded+34-0
Changes to app/Actions/Billing/StripeWebhookAction.ts
@@ -0,0 +1,34 @@
1import type { RequestInstance } from '@stacksjs/types'
2import { Action } from '@stacksjs/actions'
3import { response } from '@stacksjs/router'
4import { licenses } from '../../Billing/license-store'
5import { bodyFor, receiveWebhook, statusFor, webhookSecret } from '../../Billing/webhook'
6
7/**
8 * POST /api/billing/webhook - Stripe's copy of what happened, so a license is
9 * issued even when the buyer never reaches the thank-you page (#48).
10 *
11 * `rawBody()` rather than the parsed body: Stripe signs the bytes it sent, and
12 * a re-serialized JSON body does not match the HMAC. The router keeps them for
13 * exactly this.
14 */
15export default new Action({
16 name: 'Stripe Webhook',
17 description: 'Issue a license from a signed Stripe checkout event',
18 method: 'POST',
19
20 async handle(request: RequestInstance) {
21 const outcome = await receiveWebhook(
22 await request.rawBody?.() ?? null,
23 request.headers.get('stripe-signature'),
24 { secret: webhookSecret(), issue: async sessionId => (await licenses()).issue(sessionId) },
25 )
26
27 // Stripe shows these in the dashboard, and they are the only trace of a
28 // purchase that did not become a license.
29 if (outcome.kind !== 'licensed')
30 console.error(`[stripe-webhook] ${outcome.kind}: ${JSON.stringify(bodyFor(outcome))}`)
31
32 return response.json(bodyFor(outcome), statusFor(outcome))
33 },
34})
app/Billing/webhook.tsadded+155-0
Changes to app/Billing/webhook.ts
@@ -0,0 +1,155 @@
1import type { LicenseRecord } from './licenses'
2import process from 'node:process'
3
4/**
5 * The Stripe webhook that issues a license when the thank-you page does not.
6 *
7 * Until this existed, `issue()` had exactly one caller: rendering
8 * `/thanks?session_id=...`, which is checkout's `success_url`. So the redirect
9 * after payment was the only event in the system that ever created a license,
10 * and a buyer who closed the tab, lost the network at that moment, or paid by
11 * a method that settles after the redirect, was charged and got nothing. There
12 * was no resend path and no way for them to ask (#48).
13 *
14 * Nothing about issuing is reimplemented here. `issue()` is idempotent by
15 * construction: it returns the existing row for a session it has already seen,
16 * and only emails a key for one created just now. So the webhook and the page
17 * can arrive in either order, or twice, and the buyer ends up with one license
18 * and one email.
19 */
20
21/** The events that mean a checkout is paid for and a license is owed. */
22const ISSUING_EVENTS = new Set([
23 'checkout.session.completed',
24 // Payment methods that settle after the redirect. Stripe sends `completed`
25 // first, while `payment_status` is still unpaid, and this once it clears.
26 'checkout.session.async_payment_succeeded',
27])
28
29export type WebhookOutcome =
30 /** There is a license for this checkout, issued now or already there. */
31 | { kind: 'licensed', key: string }
32 /** Verified, and there is nothing to issue: another event, or not paid yet. */
33 | { kind: 'ignored', reason: string }
34 /** Not from Stripe, or not signed with this endpoint's secret. */
35 | { kind: 'rejected', reason: string }
36 /** No signing secret, so nothing can be verified. Never issue on this. */
37 | { kind: 'unconfigured' }
38 /** Verified, and it should have worked. Stripe retries a 500. */
39 | { kind: 'failed', error: string }
40
41export interface WebhookDeps {
42 secret: string
43 issue: (sessionId: string) => Promise<LicenseRecord | null>
44 /** Seconds of clock skew allowed between Stripe and this server. */
45 tolerance?: number
46}
47
48export function webhookSecret(env = process.env): string {
49 return (env.STRIPE_WEBHOOK_SECRET ?? '').trim()
50}
51
52/**
53 * What to do with one delivery. `raw` must be the bytes Stripe sent: the
54 * signature is over those, and a re-serialized JSON body will not match.
55 */
56export async function receiveWebhook(raw: string | null, signature: string | null, deps: WebhookDeps): Promise<WebhookOutcome> {
57 if (!deps.secret)
58 return { kind: 'unconfigured' }
59 if (!raw)
60 // The router hands the action `rawBody()`; without it there is nothing to
61 // verify, and issuing on an unverified body is worse than not issuing.
62 return { kind: 'rejected', reason: 'No request body to verify.' }
63 if (!signature)
64 return { kind: 'rejected', reason: 'No Stripe-Signature header.' }
65
66 try {
67 // `@stacksjs/security` rather than the payments package's `constructEvent`:
68 // this one is a plain HMAC check with no Stripe client behind it, so it
69 // cannot hit the SubtleCrypto trap that made the SDK path 401 every real
70 // delivery on Bun (stacksjs/stacks#2355).
71 const { verifyStripe } = await import('@stacksjs/security')
72 verifyStripe(deps.secret, signature, raw, deps.tolerance === undefined ? undefined : { toleranceSeconds: deps.tolerance })
73 }
74 catch (error) {
75 return { kind: 'rejected', reason: message(error) }
76 }
77
78 const event = parse(raw)
79 if (!event)
80 return { kind: 'rejected', reason: 'Signed, but the body is not a Stripe event.' }
81 if (!ISSUING_EVENTS.has(event.type))
82 return { kind: 'ignored', reason: `Nothing to do for ${event.type}.` }
83
84 const session = event.data?.object
85 if (session?.object !== 'checkout.session' || typeof session.id !== 'string')
86 return { kind: 'rejected', reason: `${event.type} carried no checkout session.` }
87
88 try {
89 const license = await deps.issue(session.id)
90 // Null is Stripe saying the session is not paid, or naming a plan this
91 // build does not have. Neither improves by being retried: for the first,
92 // `async_payment_succeeded` is already on its way.
93 return license
94 ? { kind: 'licensed', key: license.key }
95 : { kind: 'ignored', reason: `${session.id} is not a paid checkout for a plan Uplink sells.` }
96 }
97 catch (error) {
98 // A database or Stripe failure, which a retry may well fix, and the
99 // purchase is real either way.
100 return { kind: 'failed', error: message(error) }
101 }
102}
103
104/**
105 * Stripe retries anything that is not a 2xx, so the code says whether trying
106 * again could help. A bad signature never improves; a database that was down
107 * might.
108 */
109export function statusFor(outcome: WebhookOutcome): 200 | 400 | 500 {
110 switch (outcome.kind) {
111 case 'licensed':
112 case 'ignored':
113 return 200
114 case 'rejected':
115 return 400
116 case 'unconfigured':
117 case 'failed':
118 return 500
119 }
120}
121
122/** What to send back. Never the license key: this answers Stripe, not a buyer. */
123export function bodyFor(outcome: WebhookOutcome): Record<string, unknown> {
124 switch (outcome.kind) {
125 case 'licensed':
126 return { received: true, licensed: true }
127 case 'ignored':
128 return { received: true, licensed: false, reason: outcome.reason }
129 case 'rejected':
130 return { error: outcome.reason }
131 case 'unconfigured':
132 return { error: 'This server has no Stripe webhook secret, so it cannot verify deliveries.' }
133 case 'failed':
134 return { error: outcome.error }
135 }
136}
137
138interface StripeEvent {
139 type: string
140 data?: { object?: Record<string, any> }
141}
142
143function parse(raw: string): StripeEvent | null {
144 try {
145 const body = JSON.parse(raw) as StripeEvent
146 return body && typeof body.type === 'string' ? body : null
147 }
148 catch {
149 return null
150 }
151}
152
153function message(error: unknown): string {
154 return error instanceof Error ? error.message : String(error)
155}
config/env.tsmodified+5-0
Changes to config/env.ts
@@ -243,6 +243,11 @@ const envSchema = defineEnv({
243243 default: '',
244244 },
245245
246 STRIPE_WEBHOOK_SECRET: {
247 validation: schema.string(),
248 default: '',
249 },
250
246251 STRIPE_CONNECT_ENABLED: {
247252 validation: schema.boolean(),
248253 default: false,
config/payment.tsmodified+7-0
Changes to config/payment.ts
@@ -14,6 +14,13 @@ export default {
1414 stripe: {
1515 publishableKey: env.STRIPE_PUBLISHABLE_KEY || '',
1616 secretKey: env.STRIPE_SECRET_KEY || '',
17 /**
18 * The signing secret of the account webhook endpoint,
19 * `/api/billing/webhook`, which is what issues a license for a checkout
20 * whose buyer never reached the thank-you page. Stripe shows it as
21 * `whsec_...` when the endpoint is created.
22 */
23 webhookSecret: env.STRIPE_WEBHOOK_SECRET || '',
1724 },
1825
1926 /**
config/saas.tsmodified+6-2
Changes to config/saas.ts
@@ -1,4 +1,5 @@
11import type { SaasConfig } from '@stacksjs/types'
2import process from 'node:process'
23import { PLANS, PRODUCT_NAME, SIX_MONTHS_FREE } from '../app/Billing/plans'
34
45/**
@@ -27,9 +28,12 @@ export default {
2728 },
2829 },
2930 ],
31 // Where Stripe sends what happened. `STRIPE_WEBHOOK_SECRET` is the signing
32 // secret Stripe shows when the endpoint is created; without it the endpoint
33 // verifies nothing and refuses every delivery, which is the safe direction.
3034 webhook: {
31 endpoint: 'your-webhook-endpoint',
32 secret: 'your-webhook-secret',
35 endpoint: '/api/billing/webhook',
36 secret: process.env.STRIPE_WEBHOOK_SECRET ?? '',
3337 },
3438 currencies: ['usd'],
3539 // Six months of Monthly, free: 100% off six monthly invoices. The code is
routes/api.tsmodified+4-0
Changes to routes/api.ts
@@ -27,6 +27,10 @@ route.get('/hello', () => response.text('hello world'))
2727// there is nothing for a CSRF token to protect, and the app has none to send.
2828route.post('/license/check', 'Actions/License/CheckLicenseAction').skipCsrf()
2929route.post('/billing/portal', 'Actions/Billing/OpenPortalAction').skipCsrf()
30// Stripe's own copy of a completed checkout. It carries a signature rather
31// than a session cookie, so it skips CSRF like the two above, and it is the
32// reason a buyer who never loads /thanks still gets a key.
33route.post('/billing/webhook', 'Actions/Billing/StripeWebhookAction').skipCsrf()
3034
3135// `/coming-soon` is served as an STX view from
3236// `storage/framework/defaults/resources/views/coming-soon.stx`. The
storage/framework/auto-imports/actions.tsmodified+1-0
Changes to storage/framework/auto-imports/actions.ts
@@ -6,6 +6,7 @@
66// would make every compilation that touches a name resolve every module.
77export const actions = {
88 'Actions/Billing/OpenPortalAction': '../../../app/Actions/Billing/OpenPortalAction.ts',
9 'Actions/Billing/StripeWebhookAction': '../../../app/Actions/Billing/StripeWebhookAction.ts',
910 'Actions/License/CheckLicenseAction': '../../../app/Actions/License/CheckLicenseAction.ts',
1011 'Actions/NotifyUser': '../../../app/Actions/NotifyUser.ts',
1112 'Actions/SendWelcomeEmail': '../../../app/Actions/SendWelcomeEmail.ts',
tests/unit/billing/webhook.test.tsadded+225-0
Changes to tests/unit/billing/webhook.test.ts
@@ -0,0 +1,225 @@
1import type { LicenseRecord, LicenseStore, LicenseStripe } from '../../../app/Billing/licenses'
2import { describe, expect, it } from 'bun:test'
3import { createHmac } from 'node:crypto'
4import process from 'node:process'
5import { Licenses } from '../../../app/Billing/licenses'
6import { bodyFor, receiveWebhook, statusFor, webhookSecret } from '../../../app/Billing/webhook'
7
8const SECRET = 'whsec_test_secret'
9const PERIOD_END = 1_800_000_000
10
11function memoryStore(): LicenseStore & { rows: LicenseRecord[] } {
12 const rows: LicenseRecord[] = []
13 return {
14 rows,
15 byKey: async key => rows.find(r => r.key === key) ?? null,
16 bySession: async id => rows.find(r => r.stripeCheckoutSessionId === id) ?? null,
17 create: async (license) => { rows.push({ ...license }) },
18 update: async (key, patch) => { Object.assign(rows.find(r => r.key === key)!, patch) },
19 }
20}
21
22function fakeStripe(): LicenseStripe {
23 const sessions: Record<string, any> = {
24 cs_test_monthly: { status: 'complete', payment_status: 'paid', metadata: { plan: 'monthly' }, subscription: 'sub_1', customer: 'cus_1', customer_details: { email: 'a@example.com' } },
25 // Paid by a method that settles after the redirect: `completed` arrives
26 // while this is still unpaid, and `async_payment_succeeded` follows.
27 cs_test_pending: { status: 'complete', payment_status: 'unpaid', metadata: { plan: 'monthly' }, subscription: 'sub_1', customer: 'cus_2', customer_details: { email: 'b@example.com' } },
28 }
29 return {
30 checkoutSession: async (id: string) => sessions[id],
31 subscription: async () => ({ status: 'active', items: { data: [{ current_period_end: PERIOD_END }] }, cancel_at_period_end: false }) as any,
32 }
33}
34
35/** A delivery as Stripe sends it: the signature is over these exact bytes. */
36function delivery(type: string, sessionId: string, at = Math.floor(Date.now() / 1000)): { raw: string, signature: string } {
37 const raw = JSON.stringify({ id: 'evt_1', type, data: { object: { object: 'checkout.session', id: sessionId } } })
38 return { raw, signature: sign(raw, at) }
39}
40
41function sign(raw: string, at: number, secret = SECRET): string {
42 return `t=${at},v1=${createHmac('sha256', secret).update(`${at}.${raw}`).digest('hex')}`
43}
44
45/** The webhook and the thank-you page both go through `issue`. */
46function billing() {
47 const store = memoryStore()
48 const emails: string[] = []
49 const licenses = new Licenses(store, fakeStripe(), undefined, async (license) => { emails.push(license.key) })
50 return {
51 store,
52 emails,
53 page: (sessionId: string) => licenses.issue(sessionId),
54 post: (sent: { raw: string, signature: string }, secret = SECRET) =>
55 receiveWebhook(sent.raw, sent.signature, { secret, issue: id => licenses.issue(id) }),
56 }
57}
58
59describe('the Stripe webhook', () => {
60 it('issues the license for a paid checkout, and says nothing about the key', async () => {
61 const { post, store, emails } = billing()
62 const outcome = await post(delivery('checkout.session.completed', 'cs_test_monthly'))
63
64 expect(outcome.kind).toBe('licensed')
65 expect(statusFor(outcome)).toBe(200)
66 expect(store.rows).toHaveLength(1)
67 expect(emails).toHaveLength(1)
68 // The reply goes to Stripe, not to a buyer, so it carries no key.
69 expect(JSON.stringify(bodyFor(outcome))).not.toContain(store.rows[0].key)
70 })
71
72 it('issues on the event that follows a payment settling after the redirect', async () => {
73 const { post, store } = billing()
74 // `completed` arrives first, while Stripe still says unpaid. Retrying that
75 // would not help, so it is acknowledged rather than failed.
76 const early = await post(delivery('checkout.session.completed', 'cs_test_pending'))
77 expect(early.kind).toBe('ignored')
78 expect(statusFor(early)).toBe(200)
79 expect(store.rows).toHaveLength(0)
80 })
81
82 /**
83 * The acceptance for #48: the webhook and the thank-you page can arrive in
84 * either order, or twice, and the buyer ends up with one license and one
85 * email. `issue` was already idempotent, so this pins it rather than adding
86 * anything.
87 */
88 it('produces one license and one email however the two arrive', async () => {
89 for (const order of ['webhook first', 'page first'] as const) {
90 const { post, page, store, emails } = billing()
91 const sent = delivery('checkout.session.completed', 'cs_test_monthly')
92
93 if (order === 'webhook first') {
94 await post(sent)
95 await post(sent)
96 await page('cs_test_monthly')
97 }
98 else {
99 await page('cs_test_monthly')
100 await post(sent)
101 await post(sent)
102 }
103
104 expect(store.rows).toHaveLength(1)
105 expect(emails).toHaveLength(1)
106 }
107 })
108
109 it('acknowledges an event it has nothing to do with, rather than failing it', async () => {
110 const { post, store } = billing()
111 const outcome = await post(delivery('invoice.paid', 'cs_test_monthly'))
112 expect(outcome.kind).toBe('ignored')
113 expect(statusFor(outcome)).toBe(200)
114 expect(store.rows).toHaveLength(0)
115 })
116
117 it('refuses anything it cannot verify, and issues nothing', async () => {
118 const { post, store, emails } = billing()
119 const sent = delivery('checkout.session.completed', 'cs_test_monthly')
120
121 const cases = [
122 ['signed with another secret', { ...sent, signature: sign(sent.raw, Math.floor(Date.now() / 1000), 'whsec_someone_else') }],
123 ['no signature at all', { ...sent, signature: '' }],
124 ['a body swapped after signing', { raw: sent.raw.replace('cs_test_monthly', 'cs_test_other'), signature: sent.signature }],
125 ['an empty body', { raw: '', signature: sent.signature }],
126 ['replayed from an hour ago', delivery('checkout.session.completed', 'cs_test_monthly', Math.floor(Date.now() / 1000) - 3600)],
127 ] as const
128
129 for (const [what, sentCase] of cases) {
130 const outcome = await post(sentCase)
131 expect(`${what}: ${outcome.kind}`).toBe(`${what}: rejected`)
132 expect(statusFor(outcome)).toBe(400)
133 }
134 expect(store.rows).toHaveLength(0)
135 expect(emails).toHaveLength(0)
136 })
137
138 it('refuses a signed body that is not a Stripe event', async () => {
139 const { post } = billing()
140 for (const raw of ['not json', '{}', '[]', JSON.stringify({ type: 'checkout.session.completed', data: { object: { object: 'invoice', id: 'in_1' } } })]) {
141 const outcome = await post({ raw, signature: sign(raw, Math.floor(Date.now() / 1000)) })
142 expect(outcome.kind).toBe('rejected')
143 }
144 })
145
146 /**
147 * No secret means nothing can be verified, so nothing may be issued. A 500
148 * rather than a 200, because Stripe retries a 500 for days and this is a
149 * misconfiguration someone can still fix in that time.
150 */
151 it('issues nothing when the server has no signing secret', async () => {
152 const { post, store } = billing()
153 const outcome = await post(delivery('checkout.session.completed', 'cs_test_monthly'), '')
154 expect(outcome.kind).toBe('unconfigured')
155 expect(statusFor(outcome)).toBe(500)
156 expect(store.rows).toHaveLength(0)
157 })
158
159 it('asks Stripe to try again when issuing fails for a reason that might pass', async () => {
160 const sent = delivery('checkout.session.completed', 'cs_test_monthly')
161 const outcome = await receiveWebhook(sent.raw, sent.signature, {
162 secret: SECRET,
163 issue: async () => { throw new Error('the database is down') },
164 })
165 expect(outcome.kind).toBe('failed')
166 expect(statusFor(outcome)).toBe(500)
167 expect(bodyFor(outcome)).toEqual({ error: 'the database is down' })
168 })
169
170 it('reads its secret from the environment, trimmed', () => {
171 expect(webhookSecret({ STRIPE_WEBHOOK_SECRET: ' whsec_x \n' })).toBe('whsec_x')
172 expect(webhookSecret({})).toBe('')
173 })
174})
175
176/**
177 * The action itself, not the module under it. The one thing that cannot be
178 * caught by reading the code is whether it verifies the bytes Stripe sent or a
179 * re-serialized body, and a re-serialized one never matches the HMAC.
180 */
181describe('the webhook action', () => {
182 function fakeRequest(raw: string, signature: string | null) {
183 const reads = { rawBody: 0, parsed: 0 }
184 return {
185 reads,
186 request: {
187 headers: new Headers(signature === null ? {} : { 'stripe-signature': signature }),
188 rawBody: async () => { reads.rawBody++; return raw },
189 // If the action ever reached for these, the signature would be over
190 // bytes it never saw.
191 get all() { reads.parsed++; return () => JSON.parse(raw) },
192 get jsonBody() { reads.parsed++; return JSON.parse(raw) },
193 } as any,
194 }
195 }
196
197 it('verifies the bytes on the wire, not the parsed body', async () => {
198 const action = (await import('../../../app/Actions/Billing/StripeWebhookAction')).default
199 const sent = delivery('checkout.session.completed', 'cs_test_monthly')
200 // Signed correctly, but this server has no secret, so it stops before
201 // anything touches the database.
202 const { request, reads } = fakeRequest(sent.raw, sent.signature)
203
204 const answer = await action.handle(request)
205 expect(answer.status).toBe(500)
206 expect(reads.rawBody).toBe(1)
207 expect(reads.parsed).toBe(0)
208 })
209
210 it('answers 400 for a delivery it cannot verify', async () => {
211 const action = (await import('../../../app/Actions/Billing/StripeWebhookAction')).default
212 const sent = delivery('checkout.session.completed', 'cs_test_monthly')
213 process.env.STRIPE_WEBHOOK_SECRET = SECRET
214 try {
215 const { request } = fakeRequest(sent.raw, 't=1,v1=deadbeef')
216 expect((await action.handle(request)).status).toBe(400)
217
218 const missing = fakeRequest(sent.raw, null)
219 expect((await action.handle(missing.request)).status).toBe(400)
220 }
221 finally {
222 delete process.env.STRIPE_WEBHOOK_SECRET
223 }
224 })
225})