@nextsparkjs/core 0.1.0-beta.110 → 0.1.0-beta.111

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextsparkjs/core",
3
- "version": "0.1.0-beta.110",
3
+ "version": "0.1.0-beta.111",
4
4
  "description": "NextSpark - The complete SaaS framework for Next.js",
5
5
  "license": "MIT",
6
6
  "author": "NextSpark <hello@nextspark.dev>",
@@ -454,7 +454,7 @@
454
454
  "tailwind-merge": "^3.3.1",
455
455
  "uuid": "^13.0.0",
456
456
  "zod": "^4.1.5",
457
- "@nextsparkjs/testing": "0.1.0-beta.110"
457
+ "@nextsparkjs/testing": "0.1.0-beta.111"
458
458
  },
459
459
  "scripts": {
460
460
  "postinstall": "node scripts/postinstall.mjs || true",
@@ -0,0 +1,410 @@
1
+ /**
2
+ * Polar.sh Webhook Handler
3
+ *
4
+ * Processes Polar webhook events for subscription lifecycle management.
5
+ * CRITICAL: Verifies webhook signatures using ALL request headers.
6
+ *
7
+ * Polar event types:
8
+ * - checkout.created / checkout.updated
9
+ * - subscription.created / subscription.updated / subscription.canceled
10
+ * - order.created / order.paid
11
+ *
12
+ * NOTE: This handler uses direct query() calls (bypassing RLS) because:
13
+ * 1. Webhooks have no user context (no session, no auth)
14
+ * 2. RLS policies require user membership which webhooks can't satisfy
15
+ * 3. Webhook signature verification provides security at the API level
16
+ */
17
+
18
+ import { NextRequest } from 'next/server'
19
+ import { query, queryOne } from '@nextsparkjs/core/lib/db'
20
+
21
+ // Polar webhook verification - import from gateway
22
+ import { getBillingGateway } from '@nextsparkjs/core/lib/billing/gateways/factory'
23
+
24
+ export async function POST(request: NextRequest) {
25
+ // 1. Get raw body and ALL headers (Polar needs full headers for verification)
26
+ const payload = await request.text()
27
+ const headers: Record<string, string> = {}
28
+
29
+ request.headers.forEach((value, key) => {
30
+ headers[key] = value
31
+ })
32
+
33
+ // Verify required Polar webhook headers
34
+ if (!headers['webhook-id'] || !headers['webhook-signature'] || !headers['webhook-timestamp']) {
35
+ return Response.json(
36
+ { error: 'Missing required webhook headers (webhook-id, webhook-signature, webhook-timestamp)' },
37
+ { status: 400 }
38
+ )
39
+ }
40
+
41
+ // 2. Verify webhook signature (MANDATORY for security)
42
+ let event: { id: string; type: string; data: Record<string, unknown> }
43
+ try {
44
+ const gateway = getBillingGateway()
45
+ event = gateway.verifyWebhookSignature(payload, headers)
46
+ } catch (error) {
47
+ console.error('[polar-webhook] Signature verification failed:', error)
48
+ return Response.json({ error: 'Invalid signature' }, { status: 400 })
49
+ }
50
+
51
+ // 3. Check for duplicate events (idempotency)
52
+ const eventId = event.id || headers['webhook-id']
53
+
54
+ const existing = await queryOne(
55
+ `SELECT id FROM "billing_events" WHERE metadata->>'polarEventId' = $1`,
56
+ [eventId]
57
+ )
58
+
59
+ if (existing) {
60
+ console.log(`[polar-webhook] Event ${eventId} already processed, skipping`)
61
+ return Response.json({ received: true, status: 'duplicate' })
62
+ }
63
+
64
+ // 4. Handle events
65
+ try {
66
+ console.log(`[polar-webhook] Processing event type: ${event.type}`)
67
+
68
+ switch (event.type) {
69
+ case 'checkout.updated':
70
+ await handleCheckoutUpdated(event.data, eventId)
71
+ break
72
+
73
+ case 'subscription.created':
74
+ await handleSubscriptionCreated(event.data, eventId)
75
+ break
76
+
77
+ case 'subscription.updated':
78
+ await handleSubscriptionUpdated(event.data, eventId)
79
+ break
80
+
81
+ case 'subscription.canceled':
82
+ await handleSubscriptionCanceled(event.data, eventId)
83
+ break
84
+
85
+ case 'order.paid':
86
+ await handleOrderPaid(event.data, eventId)
87
+ break
88
+
89
+ default:
90
+ console.log(`[polar-webhook] Unhandled event type: ${event.type}`)
91
+ }
92
+
93
+ return Response.json({ received: true })
94
+ } catch (error) {
95
+ console.error('[polar-webhook] Handler error:', error)
96
+ return Response.json({ error: 'Handler failed' }, { status: 500 })
97
+ }
98
+ }
99
+
100
+ // ===========================================
101
+ // POLAR EVENT HANDLERS
102
+ // ===========================================
103
+
104
+ /**
105
+ * Handle checkout.updated
106
+ * Polar fires this when a checkout session is completed (status changes to 'succeeded')
107
+ */
108
+ async function handleCheckoutUpdated(data: Record<string, unknown>, eventId: string) {
109
+ const status = data.status as string
110
+ if (status !== 'succeeded') {
111
+ console.log(`[polar-webhook] Checkout status: ${status}, ignoring (only process succeeded)`)
112
+ return
113
+ }
114
+
115
+ const metadata = data.metadata as Record<string, string> | undefined
116
+ const teamId = metadata?.teamId
117
+ if (!teamId) {
118
+ console.warn('[polar-webhook] No teamId in checkout metadata')
119
+ return
120
+ }
121
+
122
+ const subscriptionId = data.subscriptionId as string | undefined
123
+ const customerId = data.customerId as string | undefined
124
+ const planSlug = metadata?.planSlug
125
+ const billingPeriod = metadata?.billingPeriod || 'monthly'
126
+
127
+ console.log(`[polar-webhook] Checkout completed for team ${teamId}, plan: ${planSlug}`)
128
+
129
+ // Get plan ID from slug
130
+ let planId: string | null = null
131
+ if (planSlug) {
132
+ const planResult = await queryOne<{ id: string }>(
133
+ `SELECT id FROM plans WHERE slug = $1 LIMIT 1`,
134
+ [planSlug]
135
+ )
136
+ planId = planResult?.id || null
137
+ }
138
+
139
+ if (!planId) {
140
+ console.warn(`[polar-webhook] Plan ${planSlug} not found in database, keeping current plan`)
141
+ }
142
+
143
+ // Update subscription with Polar IDs
144
+ if (planId) {
145
+ await query(
146
+ `UPDATE subscriptions
147
+ SET "externalSubscriptionId" = $1,
148
+ "externalCustomerId" = $2,
149
+ "paymentProvider" = 'polar',
150
+ "planId" = $3,
151
+ "billingInterval" = $4,
152
+ status = 'active',
153
+ "updatedAt" = NOW()
154
+ WHERE "teamId" = $5
155
+ AND status IN ('active', 'trialing', 'past_due')`,
156
+ [subscriptionId || null, customerId || null, planId, billingPeriod, teamId]
157
+ )
158
+ } else {
159
+ await query(
160
+ `UPDATE subscriptions
161
+ SET "externalSubscriptionId" = $1,
162
+ "externalCustomerId" = $2,
163
+ "paymentProvider" = 'polar',
164
+ status = 'active',
165
+ "updatedAt" = NOW()
166
+ WHERE "teamId" = $3
167
+ AND status IN ('active', 'trialing', 'past_due')`,
168
+ [subscriptionId || null, customerId || null, teamId]
169
+ )
170
+ }
171
+
172
+ // Log billing event
173
+ const amount = data.amount as number | undefined
174
+ const currency = data.currency as string | undefined
175
+ await logBillingEvent({
176
+ teamId,
177
+ type: 'payment',
178
+ status: 'succeeded',
179
+ amount: amount || 0,
180
+ currency: currency || 'usd',
181
+ polarEventId: eventId,
182
+ })
183
+ }
184
+
185
+ /**
186
+ * Handle subscription.created
187
+ * New subscription was created in Polar
188
+ */
189
+ async function handleSubscriptionCreated(data: Record<string, unknown>, eventId: string) {
190
+ const polarSubId = data.id as string
191
+ const polarCustomerId = data.customerId as string | undefined
192
+ const status = mapPolarStatus(data.status as string)
193
+
194
+ console.log(`[polar-webhook] Subscription created: ${polarSubId}, status: ${status}`)
195
+
196
+ // Try to find existing subscription by customer ID
197
+ if (polarCustomerId) {
198
+ await query(
199
+ `UPDATE subscriptions
200
+ SET "externalSubscriptionId" = $1,
201
+ status = $2,
202
+ "paymentProvider" = 'polar',
203
+ "updatedAt" = NOW()
204
+ WHERE "externalCustomerId" = $3`,
205
+ [polarSubId, status, polarCustomerId]
206
+ )
207
+
208
+ // Log webhook event for idempotency tracking
209
+ await logWebhookEvent(polarCustomerId, 'subscription.created', eventId)
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Handle subscription.updated
215
+ * Subscription status or plan changed
216
+ */
217
+ async function handleSubscriptionUpdated(data: Record<string, unknown>, eventId: string) {
218
+ const polarSubId = data.id as string
219
+ const status = mapPolarStatus(data.status as string)
220
+ const cancelAtPeriodEnd = (data.cancelAtPeriodEnd as boolean) ?? false
221
+
222
+ console.log(`[polar-webhook] Subscription updated ${polarSubId}, status: ${status}`)
223
+
224
+ // Update subscription status
225
+ await query(
226
+ `UPDATE subscriptions
227
+ SET status = $1,
228
+ "cancelAtPeriodEnd" = $2,
229
+ "updatedAt" = NOW()
230
+ WHERE "externalSubscriptionId" = $3`,
231
+ [status, cancelAtPeriodEnd, polarSubId]
232
+ )
233
+
234
+ // Log webhook event for idempotency tracking
235
+ await logWebhookEventBySubId(polarSubId, 'subscription.updated', eventId)
236
+ }
237
+
238
+ /**
239
+ * Handle subscription.canceled
240
+ * Subscription was canceled (revoked) in Polar
241
+ */
242
+ async function handleSubscriptionCanceled(data: Record<string, unknown>, eventId: string) {
243
+ const polarSubId = data.id as string
244
+
245
+ console.log(`[polar-webhook] Subscription canceled ${polarSubId}`)
246
+
247
+ await query(
248
+ `UPDATE subscriptions
249
+ SET status = 'canceled',
250
+ "canceledAt" = NOW(),
251
+ "updatedAt" = NOW()
252
+ WHERE "externalSubscriptionId" = $1`,
253
+ [polarSubId]
254
+ )
255
+
256
+ // Log webhook event for idempotency tracking
257
+ await logWebhookEventBySubId(polarSubId, 'subscription.canceled', eventId)
258
+ }
259
+
260
+ /**
261
+ * Handle order.paid
262
+ * Payment was completed for an order (Polar's equivalent of invoice.paid)
263
+ */
264
+ async function handleOrderPaid(data: Record<string, unknown>, eventId: string) {
265
+ const subscriptionId = data.subscriptionId as string | undefined
266
+ const amount = data.amount as number | undefined
267
+ const currency = data.currency as string | undefined
268
+
269
+ console.log(`[polar-webhook] Order paid: ${eventId}`)
270
+
271
+ if (subscriptionId) {
272
+ // Mark subscription as active
273
+ await query(
274
+ `UPDATE subscriptions
275
+ SET status = 'active',
276
+ "updatedAt" = NOW()
277
+ WHERE "externalSubscriptionId" = $1`,
278
+ [subscriptionId]
279
+ )
280
+
281
+ // Log billing event for audit trail (recurring payments)
282
+ const sub = await queryOne<{ id: string }>(
283
+ `SELECT id FROM subscriptions WHERE "externalSubscriptionId" = $1 LIMIT 1`,
284
+ [subscriptionId]
285
+ )
286
+ if (sub) {
287
+ await query(
288
+ `INSERT INTO "billing_events" ("subscriptionId", type, status, amount, currency, metadata)
289
+ VALUES ($1, $2, $3, $4, $5, $6)`,
290
+ [
291
+ sub.id,
292
+ 'payment',
293
+ 'succeeded',
294
+ amount || 0,
295
+ currency || 'usd',
296
+ JSON.stringify({ polarEventId: eventId })
297
+ ]
298
+ )
299
+ }
300
+ }
301
+ }
302
+
303
+ // ===========================================
304
+ // HELPERS
305
+ // ===========================================
306
+
307
+ /**
308
+ * Map Polar subscription status to our internal status
309
+ */
310
+ function mapPolarStatus(polarStatus: string): string {
311
+ const statusMap: Record<string, string> = {
312
+ active: 'active',
313
+ trialing: 'trialing',
314
+ past_due: 'past_due',
315
+ canceled: 'canceled',
316
+ incomplete: 'past_due',
317
+ incomplete_expired: 'expired',
318
+ unpaid: 'past_due',
319
+ revoked: 'canceled',
320
+ }
321
+ return statusMap[polarStatus] || polarStatus
322
+ }
323
+
324
+ /**
325
+ * Log billing event for audit trail.
326
+ * Also serves as idempotency record (polarEventId in metadata).
327
+ */
328
+ async function logBillingEvent(params: {
329
+ teamId: string
330
+ type: string
331
+ status: string
332
+ amount: number
333
+ currency: string
334
+ polarEventId: string
335
+ }) {
336
+ const sub = await queryOne<{ id: string }>(
337
+ `SELECT id FROM subscriptions WHERE "teamId" = $1 LIMIT 1`,
338
+ [params.teamId]
339
+ )
340
+
341
+ if (!sub) {
342
+ console.warn(`[polar-webhook] No subscription found for team ${params.teamId}, cannot log billing event`)
343
+ return
344
+ }
345
+
346
+ await query(
347
+ `INSERT INTO "billing_events" ("subscriptionId", type, status, amount, currency, metadata)
348
+ VALUES ($1, $2, $3, $4, $5, $6)`,
349
+ [
350
+ sub.id,
351
+ params.type,
352
+ params.status,
353
+ params.amount,
354
+ params.currency,
355
+ JSON.stringify({ polarEventId: params.polarEventId })
356
+ ]
357
+ )
358
+ }
359
+
360
+ /**
361
+ * Log a webhook event by customer ID for idempotency tracking.
362
+ * Used by subscription handlers that don't involve payments.
363
+ */
364
+ async function logWebhookEvent(
365
+ polarCustomerId: string,
366
+ eventType: string,
367
+ polarEventId: string
368
+ ) {
369
+ const sub = await queryOne<{ id: string }>(
370
+ `SELECT id FROM subscriptions WHERE "externalCustomerId" = $1 LIMIT 1`,
371
+ [polarCustomerId]
372
+ )
373
+
374
+ if (!sub) {
375
+ console.warn(`[polar-webhook] No subscription found for customer ${polarCustomerId}, cannot log webhook event`)
376
+ return
377
+ }
378
+
379
+ await query(
380
+ `INSERT INTO "billing_events" ("subscriptionId", type, status, amount, currency, metadata)
381
+ VALUES ($1, $2, $3, $4, $5, $6)`,
382
+ [sub.id, 'webhook', eventType, 0, 'usd', JSON.stringify({ polarEventId })]
383
+ )
384
+ }
385
+
386
+ /**
387
+ * Log a webhook event by external subscription ID for idempotency tracking.
388
+ * Used by subscription handlers that identify by subscription ID.
389
+ */
390
+ async function logWebhookEventBySubId(
391
+ polarSubId: string,
392
+ eventType: string,
393
+ polarEventId: string
394
+ ) {
395
+ const sub = await queryOne<{ id: string }>(
396
+ `SELECT id FROM subscriptions WHERE "externalSubscriptionId" = $1 LIMIT 1`,
397
+ [polarSubId]
398
+ )
399
+
400
+ if (!sub) {
401
+ console.warn(`[polar-webhook] No subscription found for polar sub ${polarSubId}, cannot log webhook event`)
402
+ return
403
+ }
404
+
405
+ await query(
406
+ `INSERT INTO "billing_events" ("subscriptionId", type, status, amount, currency, metadata)
407
+ VALUES ($1, $2, $3, $4, $5, $6)`,
408
+ [sub.id, 'webhook', eventType, 0, 'usd', JSON.stringify({ polarEventId })]
409
+ )
410
+ }