@nextsparkjs/core 0.1.0-beta.127 → 0.1.0-beta.129
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/dist/components/billing/ConfirmPlanChangeModal.d.ts +29 -0
- package/dist/components/billing/ConfirmPlanChangeModal.d.ts.map +1 -0
- package/dist/components/billing/ConfirmPlanChangeModal.js +100 -0
- package/dist/components/billing/ManageBillingButton.d.ts +3 -3
- package/dist/components/billing/PricingTable.d.ts +3 -0
- package/dist/components/billing/PricingTable.d.ts.map +1 -1
- package/dist/components/billing/PricingTable.js +146 -71
- package/dist/components/billing/QuotaGate.d.ts +21 -0
- package/dist/components/billing/QuotaGate.d.ts.map +1 -0
- package/dist/components/billing/QuotaGate.js +33 -0
- package/dist/components/billing/index.d.ts +2 -0
- package/dist/components/billing/index.d.ts.map +1 -1
- package/dist/components/billing/index.js +4 -0
- package/dist/components/dashboard/block-editor/dynamic-form.d.ts.map +1 -1
- package/dist/components/dashboard/block-editor/dynamic-form.js +7 -5
- package/dist/contexts/SubscriptionContext.d.ts +2 -0
- package/dist/contexts/SubscriptionContext.d.ts.map +1 -1
- package/dist/contexts/SubscriptionContext.js +2 -0
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/useQuotaCheck.d.ts +26 -0
- package/dist/hooks/useQuotaCheck.d.ts.map +1 -0
- package/dist/hooks/useQuotaCheck.js +33 -0
- package/dist/lib/api/entity/generic-handler.d.ts.map +1 -1
- package/dist/lib/api/entity/generic-handler.js +54 -6
- package/dist/lib/api/rate-limit.d.ts.map +1 -1
- package/dist/lib/api/rate-limit.js +9 -6
- package/dist/lib/billing/config-types.d.ts +2 -5
- package/dist/lib/billing/config-types.d.ts.map +1 -1
- package/dist/lib/billing/gateways/factory.d.ts +13 -2
- package/dist/lib/billing/gateways/factory.d.ts.map +1 -1
- package/dist/lib/billing/gateways/factory.js +13 -6
- package/dist/lib/billing/gateways/interface.d.ts +19 -1
- package/dist/lib/billing/gateways/interface.d.ts.map +1 -1
- package/dist/lib/billing/gateways/polar.d.ts +8 -1
- package/dist/lib/billing/gateways/polar.d.ts.map +1 -1
- package/dist/lib/billing/gateways/polar.js +25 -0
- package/dist/lib/billing/gateways/stripe.d.ts +8 -26
- package/dist/lib/billing/gateways/stripe.d.ts.map +1 -1
- package/dist/lib/billing/gateways/stripe.js +41 -44
- package/dist/lib/billing/gateways/types.d.ts +11 -0
- package/dist/lib/billing/gateways/types.d.ts.map +1 -1
- package/dist/lib/billing/jobs.d.ts +1 -1
- package/dist/lib/billing/polar-webhook.d.ts +38 -0
- package/dist/lib/billing/polar-webhook.d.ts.map +1 -0
- package/dist/lib/billing/polar-webhook.js +0 -0
- package/dist/lib/billing/schema.d.ts +1 -2
- package/dist/lib/billing/schema.d.ts.map +1 -1
- package/dist/lib/billing/schema.js +1 -1
- package/dist/lib/billing/stripe-webhook.d.ts +48 -0
- package/dist/lib/billing/stripe-webhook.d.ts.map +1 -0
- package/dist/lib/billing/stripe-webhook.js +316 -0
- package/dist/lib/billing/types.d.ts +6 -2
- package/dist/lib/billing/types.d.ts.map +1 -1
- package/dist/lib/oauth/index.d.ts +1 -1
- package/dist/lib/rate-limit-redis.d.ts +2 -2
- package/dist/lib/rate-limit-redis.d.ts.map +1 -1
- package/dist/lib/rate-limit-redis.js +22 -4
- package/dist/lib/selectors/core-selectors.d.ts +2 -2
- package/dist/lib/selectors/domains/superadmin.selectors.d.ts +2 -2
- package/dist/lib/selectors/domains/superadmin.selectors.js +2 -2
- package/dist/lib/selectors/selectors.d.ts +4 -4
- package/dist/lib/services/invoice.service.d.ts +3 -3
- package/dist/lib/services/invoice.service.js +2 -2
- package/dist/lib/services/membership.service.d.ts.map +1 -1
- package/dist/lib/services/membership.service.js +29 -0
- package/dist/lib/services/plan.service.d.ts +0 -3
- package/dist/lib/services/plan.service.d.ts.map +1 -1
- package/dist/lib/services/plan.service.js +3 -9
- package/dist/lib/services/subscription.service.d.ts +5 -5
- package/dist/lib/services/subscription.service.d.ts.map +1 -1
- package/dist/lib/services/subscription.service.js +54 -41
- package/dist/messages/de/billing.json +10 -0
- package/dist/messages/de/index.d.ts +9 -0
- package/dist/messages/de/index.d.ts.map +1 -1
- package/dist/messages/en/billing.json +21 -0
- package/dist/messages/en/index.d.ts +19 -0
- package/dist/messages/en/index.d.ts.map +1 -1
- package/dist/messages/es/billing.json +21 -0
- package/dist/messages/es/index.d.ts +19 -0
- package/dist/messages/es/index.d.ts.map +1 -1
- package/dist/messages/fr/billing.json +10 -0
- package/dist/messages/fr/index.d.ts +9 -0
- package/dist/messages/fr/index.d.ts.map +1 -1
- package/dist/messages/it/billing.json +10 -0
- package/dist/messages/it/index.d.ts +9 -0
- package/dist/messages/it/index.d.ts.map +1 -1
- package/dist/messages/pt/billing.json +10 -0
- package/dist/messages/pt/index.d.ts +9 -0
- package/dist/messages/pt/index.d.ts.map +1 -1
- package/dist/migrations/001_better_auth_and_functions.sql +5 -11
- package/dist/migrations/008_team_members_table.sql +27 -23
- package/dist/styles/classes.json +6 -2
- package/dist/styles/ui.css +1 -1
- package/dist/templates/app/api/auth/[...all]/route.ts +35 -0
- package/dist/templates/app/api/health/route.ts +43 -23
- package/dist/templates/app/api/internal/user-metadata/route.ts +10 -0
- package/dist/templates/app/api/superadmin/subscriptions/route.ts +5 -0
- package/dist/templates/app/api/superadmin/teams/[teamId]/route.ts +6 -0
- package/dist/templates/app/api/v1/billing/cancel/route.ts +9 -11
- package/dist/templates/app/api/v1/billing/change-plan/route.ts +3 -3
- package/dist/templates/app/api/v1/billing/check-action/route.ts +7 -6
- package/dist/templates/app/api/v1/billing/checkout/route.ts +40 -14
- package/dist/templates/app/api/v1/billing/portal/route.ts +6 -6
- package/dist/templates/app/api/v1/billing/presets.ts +1 -1
- package/dist/templates/app/api/v1/billing/webhooks/polar/route.ts +83 -6
- package/dist/templates/app/api/v1/billing/webhooks/stripe/route.ts +18 -421
- package/dist/templates/app/dashboard/settings/billing/page.tsx +9 -4
- package/dist/templates/app/dashboard/settings/plans/page.tsx +29 -7
- package/dist/templates/app/layout.tsx +14 -5
- package/dist/templates/app/superadmin/subscriptions/page.tsx +16 -14
- package/dist/templates/app/superadmin/teams/[teamId]/page.tsx +18 -15
- package/dist/templates/blocks/hero/component.tsx +6 -3
- package/dist/templates/blocks/hero/schema.ts +2 -1
- package/dist/templates/blocks/testimonials/component.tsx +2 -2
- package/dist/templates/blocks/testimonials/schema.ts +2 -2
- package/dist/templates/contents/themes/starter/tests/cypress/src/features/SuperadminPOM.ts +2 -2
- package/dist/templates/features/pages/blocks/hero/component.tsx +6 -3
- package/dist/templates/features/pages/blocks/hero/schema.ts +2 -1
- package/dist/templates/lib/billing/polar-webhook-extensions.ts +23 -0
- package/dist/templates/lib/billing/stripe-webhook-extensions.ts +23 -0
- package/dist/types/blocks.d.ts +24 -0
- package/dist/types/blocks.d.ts.map +1 -1
- package/dist/types/blocks.js +17 -1
- package/migrations/001_better_auth_and_functions.sql +5 -11
- package/migrations/008_team_members_table.sql +27 -23
- package/package.json +10 -2
- package/scripts/build/registry/generators/billing-registry.mjs +1 -2
- package/templates/app/api/auth/[...all]/route.ts +35 -0
- package/templates/app/api/health/route.ts +43 -23
- package/templates/app/api/internal/user-metadata/route.ts +10 -0
- package/templates/app/api/superadmin/subscriptions/route.ts +5 -0
- package/templates/app/api/superadmin/teams/[teamId]/route.ts +6 -0
- package/templates/app/api/v1/billing/cancel/route.ts +9 -11
- package/templates/app/api/v1/billing/change-plan/route.ts +3 -3
- package/templates/app/api/v1/billing/check-action/route.ts +7 -6
- package/templates/app/api/v1/billing/checkout/route.ts +40 -14
- package/templates/app/api/v1/billing/portal/route.ts +6 -6
- package/templates/app/api/v1/billing/presets.ts +1 -1
- package/templates/app/api/v1/billing/webhooks/polar/route.ts +83 -6
- package/templates/app/api/v1/billing/webhooks/stripe/route.ts +18 -421
- package/templates/app/dashboard/settings/billing/page.tsx +9 -4
- package/templates/app/dashboard/settings/plans/page.tsx +29 -7
- package/templates/app/layout.tsx +14 -5
- package/templates/app/superadmin/subscriptions/page.tsx +16 -14
- package/templates/app/superadmin/teams/[teamId]/page.tsx +18 -15
- package/templates/blocks/hero/component.tsx +6 -3
- package/templates/blocks/hero/schema.ts +2 -1
- package/templates/blocks/testimonials/component.tsx +2 -2
- package/templates/blocks/testimonials/schema.ts +2 -2
- package/templates/contents/themes/starter/tests/cypress/src/features/SuperadminPOM.ts +2 -2
- package/templates/features/pages/blocks/hero/component.tsx +6 -3
- package/templates/features/pages/blocks/hero/schema.ts +2 -1
- package/templates/lib/billing/polar-webhook-extensions.ts +23 -0
- package/templates/lib/billing/stripe-webhook-extensions.ts +23 -0
- package/tests/jest/__mocks__/@nextsparkjs/registries/billing-registry.ts +7 -8
|
@@ -1,428 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Stripe Webhook Handler
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Handles Stripe subscription lifecycle events.
|
|
5
|
+
* To add one-time payment handling (credit packs, LTD, upsells),
|
|
6
|
+
* override lib/billing/stripe-webhook-extensions.ts in your project.
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* 3. Webhook signature verification provides security at the API level
|
|
8
|
+
* Rate limiting: 500 requests/hour per IP (tier: webhook).
|
|
9
|
+
* Stripe signature verification is the primary security layer;
|
|
10
|
+
* rate limiting protects against extreme flood attacks.
|
|
11
|
+
* NOTE: Rate limiter only reads headers — raw body is NOT consumed here,
|
|
12
|
+
* so Stripe's rawBody requirement is preserved.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { NextRequest } from 'next/server'
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if (!signature) {
|
|
27
|
-
return Response.json({ error: 'No signature provided' }, { status: 400 })
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// 2. Verify webhook signature (MANDATORY for security)
|
|
31
|
-
let event: Stripe.Event
|
|
32
|
-
try {
|
|
33
|
-
event = verifyWebhookSignature(payload, signature)
|
|
34
|
-
} catch (error) {
|
|
35
|
-
console.error('[stripe-webhook] Signature verification failed:', error)
|
|
36
|
-
return Response.json({ error: 'Invalid signature' }, { status: 400 })
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// 3. Check for duplicate events (idempotency)
|
|
40
|
-
const eventId = event.id
|
|
41
|
-
|
|
42
|
-
const existing = await queryOne(
|
|
43
|
-
`SELECT id FROM "billing_events" WHERE metadata->>'stripeEventId' = $1`,
|
|
44
|
-
[eventId]
|
|
45
|
-
)
|
|
46
|
-
|
|
47
|
-
if (existing) {
|
|
48
|
-
console.log(`[stripe-webhook] Event ${eventId} already processed, skipping`)
|
|
49
|
-
return Response.json({ received: true, status: 'duplicate' })
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// 4. Handle events
|
|
53
|
-
try {
|
|
54
|
-
console.log(`[stripe-webhook] Processing event type: ${event.type}`)
|
|
55
|
-
|
|
56
|
-
switch (event.type) {
|
|
57
|
-
case 'checkout.session.completed':
|
|
58
|
-
await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session)
|
|
59
|
-
break
|
|
60
|
-
|
|
61
|
-
case 'invoice.paid':
|
|
62
|
-
await handleInvoicePaid(event.data.object as Stripe.Invoice)
|
|
63
|
-
break
|
|
64
|
-
|
|
65
|
-
case 'invoice.payment_failed':
|
|
66
|
-
await handlePaymentFailed(event.data.object as Stripe.Invoice)
|
|
67
|
-
break
|
|
68
|
-
|
|
69
|
-
case 'customer.subscription.updated':
|
|
70
|
-
await handleSubscriptionUpdated(event.data.object as Stripe.Subscription)
|
|
71
|
-
break
|
|
72
|
-
|
|
73
|
-
case 'customer.subscription.deleted':
|
|
74
|
-
await handleSubscriptionDeleted(event.data.object as Stripe.Subscription)
|
|
75
|
-
break
|
|
76
|
-
|
|
77
|
-
default:
|
|
78
|
-
console.log(`[stripe-webhook] Unhandled event type: ${event.type}`)
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return Response.json({ received: true })
|
|
82
|
-
} catch (error) {
|
|
83
|
-
console.error('[stripe-webhook] Handler error:', error)
|
|
84
|
-
return Response.json({ error: 'Handler failed' }, { status: 500 })
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Handle checkout.session.completed
|
|
90
|
-
* User successfully completed checkout, create or update subscription
|
|
91
|
-
*/
|
|
92
|
-
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
|
93
|
-
const teamId = session.metadata?.teamId || session.client_reference_id
|
|
94
|
-
if (!teamId) {
|
|
95
|
-
throw new Error('No team ID in checkout session')
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const subscriptionId = session.subscription as string
|
|
99
|
-
const customerId = session.customer as string
|
|
100
|
-
const planSlug = session.metadata?.planSlug
|
|
101
|
-
const billingPeriod = session.metadata?.billingPeriod || 'monthly'
|
|
102
|
-
|
|
103
|
-
console.log(`[stripe-webhook] Checkout completed for team ${teamId}, plan: ${planSlug}`)
|
|
104
|
-
|
|
105
|
-
// Get plan ID from slug (CRITICAL: must update plan after checkout!)
|
|
106
|
-
let planId: string | null = null
|
|
107
|
-
if (planSlug) {
|
|
108
|
-
const planResult = await queryOne<{ id: string }>(
|
|
109
|
-
`SELECT id FROM plans WHERE slug = $1 LIMIT 1`,
|
|
110
|
-
[planSlug]
|
|
111
|
-
)
|
|
112
|
-
planId = planResult?.id || null
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
if (!planId) {
|
|
116
|
-
console.warn(`[stripe-webhook] Plan ${planSlug} not found in database, keeping current plan`)
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// Update subscription with Stripe IDs AND new plan (direct query - webhook has no user context)
|
|
120
|
-
if (planId) {
|
|
121
|
-
await query(
|
|
122
|
-
`UPDATE subscriptions
|
|
123
|
-
SET "externalSubscriptionId" = $1,
|
|
124
|
-
"externalCustomerId" = $2,
|
|
125
|
-
"paymentProvider" = 'stripe',
|
|
126
|
-
"planId" = $3,
|
|
127
|
-
"billingInterval" = $4,
|
|
128
|
-
status = 'active',
|
|
129
|
-
"updatedAt" = NOW()
|
|
130
|
-
WHERE "teamId" = $5
|
|
131
|
-
AND status IN ('active', 'trialing', 'past_due')`,
|
|
132
|
-
[subscriptionId, customerId, planId, billingPeriod, teamId]
|
|
133
|
-
)
|
|
134
|
-
} else {
|
|
135
|
-
// Fallback: update without changing plan (should not happen normally)
|
|
136
|
-
await query(
|
|
137
|
-
`UPDATE subscriptions
|
|
138
|
-
SET "externalSubscriptionId" = $1,
|
|
139
|
-
"externalCustomerId" = $2,
|
|
140
|
-
"paymentProvider" = 'stripe',
|
|
141
|
-
status = 'active',
|
|
142
|
-
"updatedAt" = NOW()
|
|
143
|
-
WHERE "teamId" = $3
|
|
144
|
-
AND status IN ('active', 'trialing', 'past_due')`,
|
|
145
|
-
[subscriptionId, customerId, teamId]
|
|
146
|
-
)
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Log billing event
|
|
150
|
-
await logBillingEvent({
|
|
151
|
-
teamId,
|
|
152
|
-
type: 'payment',
|
|
153
|
-
status: 'succeeded',
|
|
154
|
-
amount: session.amount_total || 0,
|
|
155
|
-
currency: session.currency || 'usd',
|
|
156
|
-
stripeEventId: session.id
|
|
157
|
-
})
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Handle invoice.paid
|
|
162
|
-
* Subscription payment succeeded, update period dates and sync invoice
|
|
163
|
-
*/
|
|
164
|
-
async function handleInvoicePaid(invoice: Stripe.Invoice) {
|
|
165
|
-
// Stripe webhook expands subscription field which is not in base type
|
|
166
|
-
const expandedInvoice = invoice as Stripe.Invoice & {
|
|
167
|
-
subscription?: string | Stripe.Subscription | null
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const subscriptionId = typeof expandedInvoice.subscription === 'string'
|
|
171
|
-
? expandedInvoice.subscription
|
|
172
|
-
: expandedInvoice.subscription?.id
|
|
173
|
-
|
|
174
|
-
if (!subscriptionId) {
|
|
175
|
-
console.log('[stripe-webhook] Invoice has no subscription ID, skipping')
|
|
176
|
-
return
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
console.log(`[stripe-webhook] Invoice paid for subscription ${subscriptionId}`)
|
|
180
|
-
|
|
181
|
-
// Only update period if invoice has period info (direct query - webhook has no user context)
|
|
182
|
-
if (invoice.lines?.data?.[0]) {
|
|
183
|
-
const line = invoice.lines.data[0]
|
|
184
|
-
await query(
|
|
185
|
-
`UPDATE subscriptions
|
|
186
|
-
SET status = 'active',
|
|
187
|
-
"currentPeriodStart" = to_timestamp($1),
|
|
188
|
-
"currentPeriodEnd" = to_timestamp($2),
|
|
189
|
-
"updatedAt" = NOW()
|
|
190
|
-
WHERE "externalSubscriptionId" = $3`,
|
|
191
|
-
[line.period.start, line.period.end, subscriptionId]
|
|
192
|
-
)
|
|
193
|
-
} else {
|
|
194
|
-
// Just mark as active without updating periods
|
|
195
|
-
await query(
|
|
196
|
-
`UPDATE subscriptions
|
|
197
|
-
SET status = 'active',
|
|
198
|
-
"updatedAt" = NOW()
|
|
199
|
-
WHERE "externalSubscriptionId" = $1`,
|
|
200
|
-
[subscriptionId]
|
|
201
|
-
)
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// Sync invoice to local database
|
|
205
|
-
await syncInvoiceToDatabase(invoice, 'paid')
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Handle invoice.payment_failed
|
|
210
|
-
* Payment failed, mark subscription as past_due and sync invoice
|
|
211
|
-
*/
|
|
212
|
-
async function handlePaymentFailed(invoice: Stripe.Invoice) {
|
|
213
|
-
// Stripe webhook expands subscription field which is not in base type
|
|
214
|
-
const expandedInvoice = invoice as Stripe.Invoice & {
|
|
215
|
-
subscription?: string | Stripe.Subscription | null
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const subscriptionId = typeof expandedInvoice.subscription === 'string'
|
|
219
|
-
? expandedInvoice.subscription
|
|
220
|
-
: expandedInvoice.subscription?.id
|
|
221
|
-
|
|
222
|
-
if (!subscriptionId) {
|
|
223
|
-
console.log('[stripe-webhook] Invoice has no subscription ID, skipping')
|
|
224
|
-
return
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
console.log(`[stripe-webhook] Payment failed for subscription ${subscriptionId}`)
|
|
228
|
-
|
|
229
|
-
await query(
|
|
230
|
-
`UPDATE subscriptions
|
|
231
|
-
SET status = 'past_due',
|
|
232
|
-
"updatedAt" = NOW()
|
|
233
|
-
WHERE "externalSubscriptionId" = $1`,
|
|
234
|
-
[subscriptionId]
|
|
235
|
-
)
|
|
236
|
-
|
|
237
|
-
// Sync invoice to local database with failed status
|
|
238
|
-
await syncInvoiceToDatabase(invoice, 'failed')
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* Handle customer.subscription.updated
|
|
243
|
-
* Subscription status or settings changed (including plan changes)
|
|
244
|
-
*/
|
|
245
|
-
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
|
|
246
|
-
// Map Stripe status to our status
|
|
247
|
-
const statusMap: Record<string, string> = {
|
|
248
|
-
trialing: 'trialing',
|
|
249
|
-
active: 'active',
|
|
250
|
-
past_due: 'past_due',
|
|
251
|
-
canceled: 'canceled',
|
|
252
|
-
unpaid: 'past_due',
|
|
253
|
-
incomplete: 'past_due',
|
|
254
|
-
incomplete_expired: 'expired',
|
|
255
|
-
paused: 'paused'
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
const ourStatus = statusMap[subscription.status] || 'active'
|
|
259
|
-
|
|
260
|
-
console.log(
|
|
261
|
-
`[stripe-webhook] Subscription updated ${subscription.id}, status: ${subscription.status} -> ${ourStatus}`
|
|
262
|
-
)
|
|
263
|
-
|
|
264
|
-
// Stripe webhook includes current_period_end which is not in base type
|
|
265
|
-
const expandedSubscription = subscription as Stripe.Subscription & {
|
|
266
|
-
current_period_end?: number
|
|
267
|
-
current_period_start?: number
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// Check if plan changed by looking up price ID
|
|
271
|
-
const priceId = subscription.items.data[0]?.price.id
|
|
272
|
-
let planUpdateClause = ''
|
|
273
|
-
const params: (string | number | boolean | null)[] = [
|
|
274
|
-
ourStatus,
|
|
275
|
-
subscription.cancel_at_period_end,
|
|
276
|
-
expandedSubscription.current_period_end ?? null,
|
|
277
|
-
]
|
|
278
|
-
|
|
279
|
-
if (priceId) {
|
|
280
|
-
// Find plan by Stripe price ID (monthly or yearly)
|
|
281
|
-
const planResult = await queryOne<{ id: string }>(
|
|
282
|
-
`SELECT id FROM plans
|
|
283
|
-
WHERE "stripePriceIdMonthly" = $1 OR "stripePriceIdYearly" = $1
|
|
284
|
-
LIMIT 1`,
|
|
285
|
-
[priceId]
|
|
286
|
-
)
|
|
287
|
-
|
|
288
|
-
if (planResult) {
|
|
289
|
-
planUpdateClause = ', "planId" = $5'
|
|
290
|
-
params.push(subscription.id, planResult.id)
|
|
291
|
-
} else {
|
|
292
|
-
params.push(subscription.id)
|
|
293
|
-
}
|
|
294
|
-
} else {
|
|
295
|
-
params.push(subscription.id)
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
await query(
|
|
299
|
-
`UPDATE subscriptions
|
|
300
|
-
SET status = $1,
|
|
301
|
-
"cancelAtPeriodEnd" = $2,
|
|
302
|
-
"currentPeriodEnd" = to_timestamp($3),
|
|
303
|
-
"updatedAt" = NOW()${planUpdateClause}
|
|
304
|
-
WHERE "externalSubscriptionId" = $4`,
|
|
305
|
-
params
|
|
306
|
-
)
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* Handle customer.subscription.deleted
|
|
311
|
-
* Subscription was canceled
|
|
312
|
-
*/
|
|
313
|
-
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
|
|
314
|
-
console.log(`[stripe-webhook] Subscription deleted ${subscription.id}`)
|
|
315
|
-
|
|
316
|
-
await query(
|
|
317
|
-
`UPDATE subscriptions
|
|
318
|
-
SET status = 'canceled',
|
|
319
|
-
"canceledAt" = NOW(),
|
|
320
|
-
"updatedAt" = NOW()
|
|
321
|
-
WHERE "externalSubscriptionId" = $1`,
|
|
322
|
-
[subscription.id]
|
|
323
|
-
)
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
/**
|
|
327
|
-
* Log billing event for audit trail
|
|
328
|
-
* Uses direct query (bypasses RLS) since webhooks have no user context
|
|
329
|
-
*/
|
|
330
|
-
async function logBillingEvent(params: {
|
|
331
|
-
teamId: string
|
|
332
|
-
type: string
|
|
333
|
-
status: string
|
|
334
|
-
amount: number
|
|
335
|
-
currency: string
|
|
336
|
-
stripeEventId: string
|
|
337
|
-
}) {
|
|
338
|
-
// Get subscription ID (direct query - webhook has no user context)
|
|
339
|
-
const sub = await queryOne<{ id: string }>(
|
|
340
|
-
`SELECT id FROM subscriptions WHERE "teamId" = $1 LIMIT 1`,
|
|
341
|
-
[params.teamId]
|
|
342
|
-
)
|
|
343
|
-
|
|
344
|
-
if (!sub) {
|
|
345
|
-
console.warn(`[stripe-webhook] No subscription found for team ${params.teamId}, cannot log billing event`)
|
|
346
|
-
return
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
await query(
|
|
350
|
-
`INSERT INTO "billing_events" ("subscriptionId", type, status, amount, currency, metadata)
|
|
351
|
-
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
352
|
-
[
|
|
353
|
-
sub.id,
|
|
354
|
-
params.type,
|
|
355
|
-
params.status,
|
|
356
|
-
params.amount,
|
|
357
|
-
params.currency,
|
|
358
|
-
JSON.stringify({ stripeEventId: params.stripeEventId })
|
|
359
|
-
]
|
|
360
|
-
)
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
/**
|
|
364
|
-
* Sync Stripe invoice to local database
|
|
365
|
-
* Uses direct query (bypasses RLS) since webhooks have no user context
|
|
366
|
-
*/
|
|
367
|
-
async function syncInvoiceToDatabase(
|
|
368
|
-
invoice: Stripe.Invoice,
|
|
369
|
-
status: InvoiceStatus
|
|
370
|
-
) {
|
|
371
|
-
// Stripe webhook expands subscription field which is not in base type
|
|
372
|
-
const expandedInvoice = invoice as Stripe.Invoice & {
|
|
373
|
-
subscription?: string | Stripe.Subscription | null
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
// Get subscription ID from invoice
|
|
377
|
-
const subscriptionId = typeof expandedInvoice.subscription === 'string'
|
|
378
|
-
? expandedInvoice.subscription
|
|
379
|
-
: expandedInvoice.subscription?.id
|
|
380
|
-
|
|
381
|
-
if (!subscriptionId) {
|
|
382
|
-
console.warn('[stripe-webhook] Invoice has no subscription, cannot sync to invoices table')
|
|
383
|
-
return
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
// Find team from subscription (using direct query - no RLS needed for system operation)
|
|
387
|
-
const subResult = await query<{ teamId: string }>(
|
|
388
|
-
`SELECT "teamId" FROM subscriptions WHERE "externalSubscriptionId" = $1`,
|
|
389
|
-
[subscriptionId]
|
|
390
|
-
)
|
|
391
|
-
|
|
392
|
-
if (!subResult.rows[0]) {
|
|
393
|
-
console.warn(`[stripe-webhook] No subscription found for ${subscriptionId}, cannot sync invoice`)
|
|
394
|
-
return
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
const teamId = subResult.rows[0].teamId
|
|
398
|
-
const invoiceNumber = invoice.number || invoice.id
|
|
399
|
-
|
|
400
|
-
// Upsert invoice (ON CONFLICT for idempotency)
|
|
401
|
-
// Uses direct query to bypass RLS (webhook has no user context)
|
|
402
|
-
// NOTE: invoice.total is in cents from Stripe, invoices.amount is DECIMAL(10,2) in dollars
|
|
403
|
-
const amountInDollars = invoice.total / 100
|
|
404
|
-
|
|
405
|
-
await query(
|
|
406
|
-
`INSERT INTO invoices (
|
|
407
|
-
id, "teamId", "invoiceNumber", date, amount, currency, status, "pdfUrl", description
|
|
408
|
-
) VALUES (
|
|
409
|
-
gen_random_uuid()::text, $1, $2, to_timestamp($3), $4, $5, $6::invoice_status, $7, $8
|
|
410
|
-
)
|
|
411
|
-
ON CONFLICT ("teamId", "invoiceNumber") DO UPDATE SET
|
|
412
|
-
status = EXCLUDED.status,
|
|
413
|
-
"pdfUrl" = EXCLUDED."pdfUrl",
|
|
414
|
-
"updatedAt" = NOW()`,
|
|
415
|
-
[
|
|
416
|
-
teamId,
|
|
417
|
-
invoiceNumber,
|
|
418
|
-
invoice.created,
|
|
419
|
-
amountInDollars, // Convert from cents to dollars for DECIMAL(10,2) column
|
|
420
|
-
invoice.currency.toUpperCase(),
|
|
421
|
-
status,
|
|
422
|
-
invoice.invoice_pdf || invoice.hosted_invoice_url || null,
|
|
423
|
-
invoice.description || `Invoice ${invoiceNumber}`
|
|
424
|
-
]
|
|
425
|
-
)
|
|
426
|
-
|
|
427
|
-
console.log(`[stripe-webhook] Invoice ${invoiceNumber} synced for team ${teamId} with status ${status}`)
|
|
428
|
-
}
|
|
16
|
+
import { handleStripeWebhook } from '@nextsparkjs/core/lib/billing/stripe-webhook'
|
|
17
|
+
import { stripeWebhookExtensions } from '@/lib/billing/stripe-webhook-extensions'
|
|
18
|
+
import { withRateLimitTier } from '@nextsparkjs/core/lib/api/rate-limit'
|
|
19
|
+
|
|
20
|
+
export const POST = withRateLimitTier(
|
|
21
|
+
async (request: NextRequest) => {
|
|
22
|
+
return handleStripeWebhook(request, stripeWebhookExtensions)
|
|
23
|
+
},
|
|
24
|
+
'webhook'
|
|
25
|
+
)
|
|
@@ -18,6 +18,7 @@ import { sel } from '@nextsparkjs/core/selectors'
|
|
|
18
18
|
import { useTranslations } from 'next-intl'
|
|
19
19
|
import { getTemplateOrDefaultClient } from '@nextsparkjs/registries/template-registry.client'
|
|
20
20
|
import { useInvoices } from '@nextsparkjs/core/hooks/useInvoices'
|
|
21
|
+
import { useSubscription } from '@nextsparkjs/core/hooks/useSubscription'
|
|
21
22
|
import { InvoicesTable } from '@nextsparkjs/core/components/billing'
|
|
22
23
|
import { usePermission } from '@nextsparkjs/core/lib/permissions/hooks'
|
|
23
24
|
import type { Permission } from '@nextsparkjs/core/lib/permissions/types'
|
|
@@ -25,9 +26,11 @@ import type { Permission } from '@nextsparkjs/core/lib/permissions/types'
|
|
|
25
26
|
function BillingPage() {
|
|
26
27
|
const router = useRouter()
|
|
27
28
|
const canAccessBilling = usePermission('settings.billing' as Permission)
|
|
29
|
+
const { planSlug, plan, isActive, isReady } = useSubscription()
|
|
28
30
|
const [statusMessage, setStatusMessage] = useState('')
|
|
29
|
-
const [invoicesLimit, setInvoicesLimit] = useState(3)
|
|
31
|
+
const [invoicesLimit, setInvoicesLimit] = useState(3)
|
|
30
32
|
const t = useTranslations('settings')
|
|
33
|
+
const tb = useTranslations('billing')
|
|
31
34
|
|
|
32
35
|
// Fetch invoices for current team (must be before early return)
|
|
33
36
|
const {
|
|
@@ -107,7 +110,9 @@ function BillingPage() {
|
|
|
107
110
|
{t('billing.currentPlan.description')}
|
|
108
111
|
</CardDescription>
|
|
109
112
|
</div>
|
|
110
|
-
<Badge variant=
|
|
113
|
+
<Badge variant={isActive ? 'default' : 'secondary'}>
|
|
114
|
+
{isReady && planSlug ? tb(`plans.${planSlug}.name`) : t('billing.currentPlan.free')}
|
|
115
|
+
</Badge>
|
|
111
116
|
</div>
|
|
112
117
|
</CardHeader>
|
|
113
118
|
<CardContent>
|
|
@@ -136,7 +141,7 @@ function BillingPage() {
|
|
|
136
141
|
{t('billing.upgrade.description')}
|
|
137
142
|
</p>
|
|
138
143
|
</div>
|
|
139
|
-
<Link href="/
|
|
144
|
+
<Link href="/dashboard/settings/plans">
|
|
140
145
|
<Button
|
|
141
146
|
onClick={handleUpgrade}
|
|
142
147
|
data-cy={sel('settings.billing.currentPlan.upgradeButton')}
|
|
@@ -263,7 +268,7 @@ function BillingPage() {
|
|
|
263
268
|
</p>
|
|
264
269
|
</div>
|
|
265
270
|
<div className="flex gap-3">
|
|
266
|
-
<Link href="/
|
|
271
|
+
<Link href="/dashboard/settings/plans">
|
|
267
272
|
<Button variant="outline">
|
|
268
273
|
{t('billing.upgrade.viewPricing')}
|
|
269
274
|
</Button>
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useCallback } from 'react'
|
|
3
|
+
import { useCallback, useState } from 'react'
|
|
4
4
|
import { useTranslations } from 'next-intl'
|
|
5
5
|
import { getTemplateOrDefaultClient } from '@nextsparkjs/registries/template-registry.client'
|
|
6
6
|
import { PricingTable } from '@nextsparkjs/core/components/billing'
|
|
7
|
-
import {
|
|
7
|
+
import { fetchWithTeam } from '@nextsparkjs/core/lib/api/entities'
|
|
8
|
+
import { toast } from 'sonner'
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Plans Settings Page
|
|
@@ -14,12 +15,33 @@ import { useRouter } from 'next/navigation'
|
|
|
14
15
|
*/
|
|
15
16
|
function PlansPage() {
|
|
16
17
|
const t = useTranslations('settings')
|
|
17
|
-
const
|
|
18
|
+
const [loading, setLoading] = useState<string | null>(null)
|
|
18
19
|
|
|
19
|
-
const handleSelectPlan = useCallback((planSlug: string) => {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
const handleSelectPlan = useCallback(async (planSlug: string) => {
|
|
21
|
+
setLoading(planSlug)
|
|
22
|
+
try {
|
|
23
|
+
const res = await fetchWithTeam('/api/v1/billing/checkout', {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: { 'Content-Type': 'application/json' },
|
|
26
|
+
body: JSON.stringify({ planSlug, billingPeriod: 'monthly' }),
|
|
27
|
+
})
|
|
28
|
+
const data = await res.json()
|
|
29
|
+
if (data.success && data.data?.url) {
|
|
30
|
+
// New subscription — redirect to provider checkout
|
|
31
|
+
window.location.href = data.data.url
|
|
32
|
+
} else if (data.success && data.data?.changed) {
|
|
33
|
+
// Plan changed via proration (existing subscription)
|
|
34
|
+
toast.success('Plan changed successfully')
|
|
35
|
+
window.location.reload()
|
|
36
|
+
} else {
|
|
37
|
+
toast.error(data.error || 'Failed to process plan change')
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
toast.error('Failed to start checkout')
|
|
41
|
+
} finally {
|
|
42
|
+
setLoading(null)
|
|
43
|
+
}
|
|
44
|
+
}, [])
|
|
23
45
|
|
|
24
46
|
return (
|
|
25
47
|
<div className="max-w-6xl" data-cy="plans-settings-main">
|
|
@@ -10,6 +10,7 @@ import { NextIntlClientProvider } from 'next-intl'
|
|
|
10
10
|
import { getMessages } from 'next-intl/server'
|
|
11
11
|
|
|
12
12
|
import "./globals.css"
|
|
13
|
+
import { getBillingResourceHints } from "@nextsparkjs/core/lib/billing/gateways/factory"
|
|
13
14
|
import { QueryProvider } from "@nextsparkjs/core/providers/query-provider"
|
|
14
15
|
import { ThemeProvider as NextThemeProvider } from "@nextsparkjs/core/providers/theme-provider"
|
|
15
16
|
import { ThemeProvider as CustomThemeProvider } from "@nextsparkjs/core/lib/theme/ThemeProvider"
|
|
@@ -61,11 +62,19 @@ export default async function RootLayout({
|
|
|
61
62
|
return (
|
|
62
63
|
<html lang={locale} suppressHydrationWarning>
|
|
63
64
|
<head>
|
|
64
|
-
{
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
{(() => {
|
|
66
|
+
const hints = getBillingResourceHints()
|
|
67
|
+
return (
|
|
68
|
+
<>
|
|
69
|
+
{hints.preconnect.map((domain) => (
|
|
70
|
+
<link key={`pre-${domain}`} rel="preconnect" href={domain} />
|
|
71
|
+
))}
|
|
72
|
+
{[...hints.preconnect, ...hints.dnsPrefetch].map((domain) => (
|
|
73
|
+
<link key={`dns-${domain}`} rel="dns-prefetch" href={domain} />
|
|
74
|
+
))}
|
|
75
|
+
</>
|
|
76
|
+
)
|
|
77
|
+
})()}
|
|
69
78
|
</head>
|
|
70
79
|
<body
|
|
71
80
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
|
@@ -79,6 +79,8 @@ interface Subscription {
|
|
|
79
79
|
canceledAt: string | null;
|
|
80
80
|
cancelAtPeriodEnd: boolean;
|
|
81
81
|
externalSubscriptionId: string | null;
|
|
82
|
+
paymentProvider: string | null;
|
|
83
|
+
providerDashboardUrl: string | null;
|
|
82
84
|
createdAt: string;
|
|
83
85
|
}
|
|
84
86
|
|
|
@@ -590,21 +592,21 @@ function SubscriptionsPage() {
|
|
|
590
592
|
View Team
|
|
591
593
|
</Link>
|
|
592
594
|
</Button>
|
|
593
|
-
{sub.
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
>
|
|
600
|
-
<a
|
|
601
|
-
href={`https://dashboard.stripe.com/test/subscriptions/${sub.externalSubscriptionId}`}
|
|
602
|
-
target="_blank"
|
|
603
|
-
rel="noopener noreferrer"
|
|
595
|
+
{sub.providerDashboardUrl && (
|
|
596
|
+
<Button
|
|
597
|
+
variant="ghost"
|
|
598
|
+
size="sm"
|
|
599
|
+
asChild
|
|
600
|
+
className="text-muted-foreground"
|
|
604
601
|
>
|
|
605
|
-
<
|
|
606
|
-
|
|
607
|
-
|
|
602
|
+
<a
|
|
603
|
+
href={sub.providerDashboardUrl}
|
|
604
|
+
target="_blank"
|
|
605
|
+
rel="noopener noreferrer"
|
|
606
|
+
>
|
|
607
|
+
<ExternalLink className="h-4 w-4" />
|
|
608
|
+
</a>
|
|
609
|
+
</Button>
|
|
608
610
|
)}
|
|
609
611
|
</div>
|
|
610
612
|
</TableCell>
|
|
@@ -88,6 +88,9 @@ interface Subscription {
|
|
|
88
88
|
cancelAtPeriodEnd: boolean;
|
|
89
89
|
externalSubscriptionId: string | null;
|
|
90
90
|
externalCustomerId: string | null;
|
|
91
|
+
paymentProvider: string | null;
|
|
92
|
+
providerName: string | null;
|
|
93
|
+
providerDashboardUrl: string | null;
|
|
91
94
|
createdAt: string;
|
|
92
95
|
}
|
|
93
96
|
|
|
@@ -494,21 +497,21 @@ function TeamDetailPage() {
|
|
|
494
497
|
</div>
|
|
495
498
|
</>
|
|
496
499
|
)}
|
|
497
|
-
{teamData.subscription.
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
500
|
+
{teamData.subscription.providerDashboardUrl && (
|
|
501
|
+
<>
|
|
502
|
+
<h4 className="text-sm font-medium text-muted-foreground mb-2">
|
|
503
|
+
{teamData.subscription.providerName || 'Payment Provider'}
|
|
504
|
+
</h4>
|
|
505
|
+
<a
|
|
506
|
+
href={teamData.subscription.providerDashboardUrl}
|
|
507
|
+
target="_blank"
|
|
508
|
+
rel="noopener noreferrer"
|
|
509
|
+
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
|
|
510
|
+
>
|
|
511
|
+
View in {teamData.subscription.providerName || 'Dashboard'}
|
|
512
|
+
<ExternalLink className="h-3 w-3" />
|
|
513
|
+
</a>
|
|
514
|
+
</>
|
|
512
515
|
)}
|
|
513
516
|
</div>
|
|
514
517
|
</div>
|