also looking at this
feat(billing): issue the license from Stripe, not only from the thank-you page
#49
9 files
+439
-2
| @@ -0,0 +1,34 @@ | ||
| 1 | import type { RequestInstance } from '@stacksjs/types' | |
| 2 | import { Action } from '@stacksjs/actions' | |
| 3 | import { response } from '@stacksjs/router' | |
| 4 | import { licenses } from '../../Billing/license-store' | |
| 5 | import { 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 | */ | |
| 15 | export 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 | }) | |