@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.
|
|
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.
|
|
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
|
+
}
|