@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.
Files changed (157) hide show
  1. package/dist/components/billing/ConfirmPlanChangeModal.d.ts +29 -0
  2. package/dist/components/billing/ConfirmPlanChangeModal.d.ts.map +1 -0
  3. package/dist/components/billing/ConfirmPlanChangeModal.js +100 -0
  4. package/dist/components/billing/ManageBillingButton.d.ts +3 -3
  5. package/dist/components/billing/PricingTable.d.ts +3 -0
  6. package/dist/components/billing/PricingTable.d.ts.map +1 -1
  7. package/dist/components/billing/PricingTable.js +146 -71
  8. package/dist/components/billing/QuotaGate.d.ts +21 -0
  9. package/dist/components/billing/QuotaGate.d.ts.map +1 -0
  10. package/dist/components/billing/QuotaGate.js +33 -0
  11. package/dist/components/billing/index.d.ts +2 -0
  12. package/dist/components/billing/index.d.ts.map +1 -1
  13. package/dist/components/billing/index.js +4 -0
  14. package/dist/components/dashboard/block-editor/dynamic-form.d.ts.map +1 -1
  15. package/dist/components/dashboard/block-editor/dynamic-form.js +7 -5
  16. package/dist/contexts/SubscriptionContext.d.ts +2 -0
  17. package/dist/contexts/SubscriptionContext.d.ts.map +1 -1
  18. package/dist/contexts/SubscriptionContext.js +2 -0
  19. package/dist/hooks/index.d.ts +1 -0
  20. package/dist/hooks/index.d.ts.map +1 -1
  21. package/dist/hooks/index.js +1 -0
  22. package/dist/hooks/useQuotaCheck.d.ts +26 -0
  23. package/dist/hooks/useQuotaCheck.d.ts.map +1 -0
  24. package/dist/hooks/useQuotaCheck.js +33 -0
  25. package/dist/lib/api/entity/generic-handler.d.ts.map +1 -1
  26. package/dist/lib/api/entity/generic-handler.js +54 -6
  27. package/dist/lib/api/rate-limit.d.ts.map +1 -1
  28. package/dist/lib/api/rate-limit.js +9 -6
  29. package/dist/lib/billing/config-types.d.ts +2 -5
  30. package/dist/lib/billing/config-types.d.ts.map +1 -1
  31. package/dist/lib/billing/gateways/factory.d.ts +13 -2
  32. package/dist/lib/billing/gateways/factory.d.ts.map +1 -1
  33. package/dist/lib/billing/gateways/factory.js +13 -6
  34. package/dist/lib/billing/gateways/interface.d.ts +19 -1
  35. package/dist/lib/billing/gateways/interface.d.ts.map +1 -1
  36. package/dist/lib/billing/gateways/polar.d.ts +8 -1
  37. package/dist/lib/billing/gateways/polar.d.ts.map +1 -1
  38. package/dist/lib/billing/gateways/polar.js +25 -0
  39. package/dist/lib/billing/gateways/stripe.d.ts +8 -26
  40. package/dist/lib/billing/gateways/stripe.d.ts.map +1 -1
  41. package/dist/lib/billing/gateways/stripe.js +41 -44
  42. package/dist/lib/billing/gateways/types.d.ts +11 -0
  43. package/dist/lib/billing/gateways/types.d.ts.map +1 -1
  44. package/dist/lib/billing/jobs.d.ts +1 -1
  45. package/dist/lib/billing/polar-webhook.d.ts +38 -0
  46. package/dist/lib/billing/polar-webhook.d.ts.map +1 -0
  47. package/dist/lib/billing/polar-webhook.js +0 -0
  48. package/dist/lib/billing/schema.d.ts +1 -2
  49. package/dist/lib/billing/schema.d.ts.map +1 -1
  50. package/dist/lib/billing/schema.js +1 -1
  51. package/dist/lib/billing/stripe-webhook.d.ts +48 -0
  52. package/dist/lib/billing/stripe-webhook.d.ts.map +1 -0
  53. package/dist/lib/billing/stripe-webhook.js +316 -0
  54. package/dist/lib/billing/types.d.ts +6 -2
  55. package/dist/lib/billing/types.d.ts.map +1 -1
  56. package/dist/lib/oauth/index.d.ts +1 -1
  57. package/dist/lib/rate-limit-redis.d.ts +2 -2
  58. package/dist/lib/rate-limit-redis.d.ts.map +1 -1
  59. package/dist/lib/rate-limit-redis.js +22 -4
  60. package/dist/lib/selectors/core-selectors.d.ts +2 -2
  61. package/dist/lib/selectors/domains/superadmin.selectors.d.ts +2 -2
  62. package/dist/lib/selectors/domains/superadmin.selectors.js +2 -2
  63. package/dist/lib/selectors/selectors.d.ts +4 -4
  64. package/dist/lib/services/invoice.service.d.ts +3 -3
  65. package/dist/lib/services/invoice.service.js +2 -2
  66. package/dist/lib/services/membership.service.d.ts.map +1 -1
  67. package/dist/lib/services/membership.service.js +29 -0
  68. package/dist/lib/services/plan.service.d.ts +0 -3
  69. package/dist/lib/services/plan.service.d.ts.map +1 -1
  70. package/dist/lib/services/plan.service.js +3 -9
  71. package/dist/lib/services/subscription.service.d.ts +5 -5
  72. package/dist/lib/services/subscription.service.d.ts.map +1 -1
  73. package/dist/lib/services/subscription.service.js +54 -41
  74. package/dist/messages/de/billing.json +10 -0
  75. package/dist/messages/de/index.d.ts +9 -0
  76. package/dist/messages/de/index.d.ts.map +1 -1
  77. package/dist/messages/en/billing.json +21 -0
  78. package/dist/messages/en/index.d.ts +19 -0
  79. package/dist/messages/en/index.d.ts.map +1 -1
  80. package/dist/messages/es/billing.json +21 -0
  81. package/dist/messages/es/index.d.ts +19 -0
  82. package/dist/messages/es/index.d.ts.map +1 -1
  83. package/dist/messages/fr/billing.json +10 -0
  84. package/dist/messages/fr/index.d.ts +9 -0
  85. package/dist/messages/fr/index.d.ts.map +1 -1
  86. package/dist/messages/it/billing.json +10 -0
  87. package/dist/messages/it/index.d.ts +9 -0
  88. package/dist/messages/it/index.d.ts.map +1 -1
  89. package/dist/messages/pt/billing.json +10 -0
  90. package/dist/messages/pt/index.d.ts +9 -0
  91. package/dist/messages/pt/index.d.ts.map +1 -1
  92. package/dist/migrations/001_better_auth_and_functions.sql +5 -11
  93. package/dist/migrations/008_team_members_table.sql +27 -23
  94. package/dist/styles/classes.json +6 -2
  95. package/dist/styles/ui.css +1 -1
  96. package/dist/templates/app/api/auth/[...all]/route.ts +35 -0
  97. package/dist/templates/app/api/health/route.ts +43 -23
  98. package/dist/templates/app/api/internal/user-metadata/route.ts +10 -0
  99. package/dist/templates/app/api/superadmin/subscriptions/route.ts +5 -0
  100. package/dist/templates/app/api/superadmin/teams/[teamId]/route.ts +6 -0
  101. package/dist/templates/app/api/v1/billing/cancel/route.ts +9 -11
  102. package/dist/templates/app/api/v1/billing/change-plan/route.ts +3 -3
  103. package/dist/templates/app/api/v1/billing/check-action/route.ts +7 -6
  104. package/dist/templates/app/api/v1/billing/checkout/route.ts +40 -14
  105. package/dist/templates/app/api/v1/billing/portal/route.ts +6 -6
  106. package/dist/templates/app/api/v1/billing/presets.ts +1 -1
  107. package/dist/templates/app/api/v1/billing/webhooks/polar/route.ts +83 -6
  108. package/dist/templates/app/api/v1/billing/webhooks/stripe/route.ts +18 -421
  109. package/dist/templates/app/dashboard/settings/billing/page.tsx +9 -4
  110. package/dist/templates/app/dashboard/settings/plans/page.tsx +29 -7
  111. package/dist/templates/app/layout.tsx +14 -5
  112. package/dist/templates/app/superadmin/subscriptions/page.tsx +16 -14
  113. package/dist/templates/app/superadmin/teams/[teamId]/page.tsx +18 -15
  114. package/dist/templates/blocks/hero/component.tsx +6 -3
  115. package/dist/templates/blocks/hero/schema.ts +2 -1
  116. package/dist/templates/blocks/testimonials/component.tsx +2 -2
  117. package/dist/templates/blocks/testimonials/schema.ts +2 -2
  118. package/dist/templates/contents/themes/starter/tests/cypress/src/features/SuperadminPOM.ts +2 -2
  119. package/dist/templates/features/pages/blocks/hero/component.tsx +6 -3
  120. package/dist/templates/features/pages/blocks/hero/schema.ts +2 -1
  121. package/dist/templates/lib/billing/polar-webhook-extensions.ts +23 -0
  122. package/dist/templates/lib/billing/stripe-webhook-extensions.ts +23 -0
  123. package/dist/types/blocks.d.ts +24 -0
  124. package/dist/types/blocks.d.ts.map +1 -1
  125. package/dist/types/blocks.js +17 -1
  126. package/migrations/001_better_auth_and_functions.sql +5 -11
  127. package/migrations/008_team_members_table.sql +27 -23
  128. package/package.json +10 -2
  129. package/scripts/build/registry/generators/billing-registry.mjs +1 -2
  130. package/templates/app/api/auth/[...all]/route.ts +35 -0
  131. package/templates/app/api/health/route.ts +43 -23
  132. package/templates/app/api/internal/user-metadata/route.ts +10 -0
  133. package/templates/app/api/superadmin/subscriptions/route.ts +5 -0
  134. package/templates/app/api/superadmin/teams/[teamId]/route.ts +6 -0
  135. package/templates/app/api/v1/billing/cancel/route.ts +9 -11
  136. package/templates/app/api/v1/billing/change-plan/route.ts +3 -3
  137. package/templates/app/api/v1/billing/check-action/route.ts +7 -6
  138. package/templates/app/api/v1/billing/checkout/route.ts +40 -14
  139. package/templates/app/api/v1/billing/portal/route.ts +6 -6
  140. package/templates/app/api/v1/billing/presets.ts +1 -1
  141. package/templates/app/api/v1/billing/webhooks/polar/route.ts +83 -6
  142. package/templates/app/api/v1/billing/webhooks/stripe/route.ts +18 -421
  143. package/templates/app/dashboard/settings/billing/page.tsx +9 -4
  144. package/templates/app/dashboard/settings/plans/page.tsx +29 -7
  145. package/templates/app/layout.tsx +14 -5
  146. package/templates/app/superadmin/subscriptions/page.tsx +16 -14
  147. package/templates/app/superadmin/teams/[teamId]/page.tsx +18 -15
  148. package/templates/blocks/hero/component.tsx +6 -3
  149. package/templates/blocks/hero/schema.ts +2 -1
  150. package/templates/blocks/testimonials/component.tsx +2 -2
  151. package/templates/blocks/testimonials/schema.ts +2 -2
  152. package/templates/contents/themes/starter/tests/cypress/src/features/SuperadminPOM.ts +2 -2
  153. package/templates/features/pages/blocks/hero/component.tsx +6 -3
  154. package/templates/features/pages/blocks/hero/schema.ts +2 -1
  155. package/templates/lib/billing/polar-webhook-extensions.ts +23 -0
  156. package/templates/lib/billing/stripe-webhook-extensions.ts +23 -0
  157. package/tests/jest/__mocks__/@nextsparkjs/registries/billing-registry.ts +7 -8
@@ -7,6 +7,7 @@ import { isPublicSignupRestricted } from "@nextsparkjs/core/lib/teams/helpers";
7
7
  // Currently domain validation happens in auth.ts databaseHooks
8
8
  import { TeamService } from "@nextsparkjs/core/lib/services";
9
9
  import { wrapAuthHandlerWithCors, handleCorsPreflightRequest, addCorsHeaders } from "@nextsparkjs/core/lib/api/helpers";
10
+ import { checkDistributedRateLimit } from "@nextsparkjs/core/lib/api/rate-limit";
10
11
 
11
12
  const handlers = toNextJsHandler(auth);
12
13
 
@@ -45,6 +46,40 @@ export async function GET(req: NextRequest, context: { params: Promise<{ all: st
45
46
 
46
47
  // Intercept signup requests to validate registration mode
47
48
  export async function POST(req: NextRequest) {
49
+ // Rate limiting: 5 requests per 15 minutes per IP (tier: auth).
50
+ // Protects login/signup against brute-force and credential stuffing attacks.
51
+ // IP extraction strategy:
52
+ // - Cloudflare: cf-connecting-ip (set by Cloudflare, not spoofable behind CF)
53
+ // - Vercel/trusted proxies: rightmost non-private IP in x-forwarded-for
54
+ // - Fallback: x-real-ip or 'unknown'
55
+ const clientIp = (() => {
56
+ // Cloudflare sets this header and it cannot be spoofed when behind CF
57
+ const cfIp = req.headers.get('cf-connecting-ip')
58
+ if (cfIp) return cfIp
59
+
60
+ // x-forwarded-for: use rightmost entry (last proxy-appended value is most trustworthy)
61
+ const forwardedFor = req.headers.get('x-forwarded-for')
62
+ if (forwardedFor) {
63
+ const ips = forwardedFor.split(',').map(ip => ip.trim()).filter(Boolean)
64
+ if (ips.length > 0) return ips[ips.length - 1]
65
+ }
66
+
67
+ return req.headers.get('x-real-ip') || 'unknown'
68
+ })()
69
+ const rateLimitResult = await checkDistributedRateLimit(`auth:ip:${clientIp}`, 'auth')
70
+ if (!rateLimitResult.allowed) {
71
+ return new NextResponse(JSON.stringify({ error: 'Too many requests' }), {
72
+ status: 429,
73
+ headers: {
74
+ 'Content-Type': 'application/json',
75
+ 'Retry-After': '900',
76
+ 'X-RateLimit-Limit': rateLimitResult.limit.toString(),
77
+ 'X-RateLimit-Remaining': '0',
78
+ 'X-RateLimit-Reset': rateLimitResult.resetTime.toString(),
79
+ },
80
+ })
81
+ }
82
+
48
83
  const pathname = req.nextUrl.pathname;
49
84
 
50
85
  // Determine request type
@@ -1,31 +1,51 @@
1
1
  import { queryWithRLS } from "@nextsparkjs/core/lib/db";
2
2
  import { withRateLimitTier } from "@nextsparkjs/core/lib/api/rate-limit";
3
- import { NextResponse } from "next/server";
3
+ import { isRedisConfigured } from "@nextsparkjs/core/lib/rate-limit-redis";
4
+ import { authenticateRequest } from "@nextsparkjs/core/lib/api/auth/dual-auth";
5
+ import { NextRequest, NextResponse } from "next/server";
6
+
7
+ export const GET = withRateLimitTier(async (request: NextRequest) => {
8
+ let dbStatus: 'connected' | 'disconnected' = 'disconnected';
9
+ let dbError: string | undefined;
4
10
 
5
- export const GET = withRateLimitTier(async () => {
6
11
  try {
7
- // Test database connection
8
12
  await queryWithRLS('SELECT 1');
9
-
10
- return NextResponse.json({
11
- status: 'healthy',
12
- timestamp: new Date().toISOString(),
13
- services: {
14
- database: 'connected',
15
- api: 'operational'
16
- }
17
- });
13
+ dbStatus = 'connected';
18
14
  } catch (error) {
19
- console.error('Health check failed:', error);
20
- return NextResponse.json({
21
- status: 'unhealthy',
22
- timestamp: new Date().toISOString(),
23
- error: 'Database connection failed',
24
- services: {
25
- database: 'disconnected',
26
- api: 'operational'
27
- }
28
- }, { status: 503 });
15
+ dbError = error instanceof Error ? error.message : 'Unknown error';
16
+ console.error('[health] Database connection failed:', error);
29
17
  }
30
- }, 'read');
31
18
 
19
+ const healthy = dbStatus === 'connected';
20
+
21
+ // Public response: only healthy/unhealthy status
22
+ const publicResponse = {
23
+ status: healthy ? 'healthy' : 'unhealthy',
24
+ timestamp: new Date().toISOString(),
25
+ };
26
+
27
+ // Check if caller is authenticated for detailed info
28
+ const authResult = await authenticateRequest(request);
29
+ const isAuthenticated = authResult.success && authResult.user;
30
+
31
+ if (!isAuthenticated) {
32
+ return NextResponse.json(publicResponse, { status: healthy ? 200 : 503 });
33
+ }
34
+
35
+ // Authenticated callers get detailed service status
36
+ const redisConfigured = await isRedisConfigured();
37
+
38
+ if (!redisConfigured) {
39
+ console.error('[health] CRITICAL: Redis not configured — rate limiting is DISABLED. Set UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN in environment variables.');
40
+ }
41
+
42
+ return NextResponse.json({
43
+ ...publicResponse,
44
+ services: {
45
+ database: dbStatus,
46
+ rateLimit: redisConfigured ? 'redis' : 'disabled',
47
+ api: 'operational',
48
+ },
49
+ ...(dbError ? { error: dbError } : {}),
50
+ }, { status: healthy ? 200 : 503 });
51
+ }, 'read');
@@ -1,10 +1,16 @@
1
1
  import { NextRequest, NextResponse } from 'next/server'
2
2
  import { MetaService } from '@nextsparkjs/core/lib/services/meta.service'
3
3
  import { withRateLimitTier } from '@nextsparkjs/core/lib/api/rate-limit'
4
+ import { authenticateRequest } from '@nextsparkjs/core/lib/api/auth/dual-auth'
4
5
 
5
6
  // Endpoint interno para crear metadata default después del signup
6
7
  export const POST = withRateLimitTier(async (req: NextRequest) => {
7
8
  try {
9
+ const authResult = await authenticateRequest(req)
10
+ if (!authResult.success || !authResult.user) {
11
+ return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
12
+ }
13
+
8
14
  const body = await req.json()
9
15
  const { userId, metadata } = body
10
16
 
@@ -12,6 +18,10 @@ export const POST = withRateLimitTier(async (req: NextRequest) => {
12
18
  return NextResponse.json({ error: 'User ID is required' }, { status: 400 })
13
19
  }
14
20
 
21
+ if (userId !== authResult.user.id) {
22
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
23
+ }
24
+
15
25
  if (!metadata || typeof metadata !== 'object') {
16
26
  return NextResponse.json({ error: 'Metadata is required' }, { status: 400 })
17
27
  }
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
2
2
  import { getTypedSession } from '@nextsparkjs/core/lib/auth';
3
3
  import { queryWithRLS } from '@nextsparkjs/core/lib/db';
4
4
  import { withRateLimitTier } from '@nextsparkjs/core/lib/api/rate-limit';
5
+ import { getBillingGateway } from '@nextsparkjs/core/lib/billing/gateways/factory';
5
6
 
6
7
  interface SubscriptionResult {
7
8
  id: string;
@@ -23,6 +24,7 @@ interface SubscriptionResult {
23
24
  canceledAt: string | null;
24
25
  cancelAtPeriodEnd: boolean;
25
26
  externalSubscriptionId: string | null;
27
+ paymentProvider: string | null;
26
28
  createdAt: string;
27
29
  }
28
30
 
@@ -181,6 +183,7 @@ export const GET = withRateLimitTier(async (request: NextRequest) => {
181
183
  s."canceledAt",
182
184
  s."cancelAtPeriodEnd",
183
185
  s."externalSubscriptionId",
186
+ s."paymentProvider",
184
187
  s."createdAt"
185
188
  FROM "subscriptions" s
186
189
  LEFT JOIN "teams" t ON s."teamId" = t.id
@@ -253,6 +256,8 @@ export const GET = withRateLimitTier(async (request: NextRequest) => {
253
256
  canceledAt: sub.canceledAt,
254
257
  cancelAtPeriodEnd: sub.cancelAtPeriodEnd,
255
258
  externalSubscriptionId: sub.externalSubscriptionId,
259
+ paymentProvider: sub.paymentProvider,
260
+ providerDashboardUrl: getBillingGateway().getSubscriptionDashboardUrl(sub.externalSubscriptionId),
256
261
  createdAt: sub.createdAt
257
262
  }));
258
263
 
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
2
2
  import { getTypedSession } from '@nextsparkjs/core/lib/auth';
3
3
  import { queryWithRLS } from '@nextsparkjs/core/lib/db';
4
4
  import { withRateLimitTier } from '@nextsparkjs/core/lib/api/rate-limit';
5
+ import { getBillingGateway } from '@nextsparkjs/core/lib/billing/gateways/factory';
5
6
 
6
7
  interface TeamResult {
7
8
  id: string;
@@ -37,6 +38,7 @@ interface SubscriptionResult {
37
38
  cancelAtPeriodEnd: boolean;
38
39
  externalSubscriptionId: string | null;
39
40
  externalCustomerId: string | null;
41
+ paymentProvider: string | null;
40
42
  createdAt: string;
41
43
  }
42
44
 
@@ -144,6 +146,7 @@ export const GET = withRateLimitTier(async (
144
146
  s."cancelAtPeriodEnd",
145
147
  s."externalSubscriptionId",
146
148
  s."externalCustomerId",
149
+ s."paymentProvider",
147
150
  s."createdAt"
148
151
  FROM "subscriptions" s
149
152
  LEFT JOIN "plans" p ON s."planId" = p.id
@@ -242,6 +245,9 @@ export const GET = withRateLimitTier(async (
242
245
  cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
243
246
  externalSubscriptionId: subscription.externalSubscriptionId,
244
247
  externalCustomerId: subscription.externalCustomerId,
248
+ paymentProvider: subscription.paymentProvider,
249
+ providerName: getBillingGateway().getProviderName(),
250
+ providerDashboardUrl: getBillingGateway().getSubscriptionDashboardUrl(subscription.externalSubscriptionId),
245
251
  createdAt: subscription.createdAt
246
252
  } : null,
247
253
  billingHistory: billingEventsResult.map((event) => ({
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Cancel Subscription Endpoint
3
3
  *
4
- * Allows users to cancel their subscription directly without using Stripe Portal.
4
+ * Allows users to cancel their subscription directly without going through the billing portal.
5
5
  * Supports both soft cancel (at period end) and hard cancel (immediate).
6
6
  *
7
7
  * P1-4: Cancel subscription directo
@@ -11,11 +11,7 @@ import { NextRequest, NextResponse } from 'next/server'
11
11
  import { z } from 'zod'
12
12
  import { authenticateRequest, createAuthError } from '@nextsparkjs/core/lib/api/auth/dual-auth'
13
13
  import { SubscriptionService, MembershipService } from '@nextsparkjs/core/lib/services'
14
- import {
15
- cancelSubscriptionAtPeriodEnd,
16
- cancelSubscriptionImmediately,
17
- reactivateSubscription
18
- } from '@nextsparkjs/core/lib/billing/gateways/stripe'
14
+ import { getBillingGateway } from '@nextsparkjs/core/lib/billing/gateways/factory'
19
15
  import { queryWithRLS } from '@nextsparkjs/core/lib/db'
20
16
  import { withRateLimitTier } from '@nextsparkjs/core/lib/api/rate-limit'
21
17
 
@@ -55,7 +51,7 @@ export const POST = withRateLimitTier(async (request: NextRequest) => {
55
51
 
56
52
  // 3. Permission check using MembershipService
57
53
  const membership = await MembershipService.get(authResult.user.id, teamId)
58
- const actionResult = membership.canPerformAction('billing.cancel')
54
+ const actionResult = membership.canPerformAction('team.billing.manage')
59
55
 
60
56
  if (!actionResult.allowed) {
61
57
  return NextResponse.json(
@@ -106,12 +102,13 @@ export const POST = withRateLimitTier(async (request: NextRequest) => {
106
102
  )
107
103
  }
108
104
 
109
- // 6. Cancel via Stripe
105
+ // 6. Cancel via billing gateway
110
106
  try {
107
+ const gateway = getBillingGateway()
111
108
  if (immediate) {
112
- await cancelSubscriptionImmediately(subscription.externalSubscriptionId)
109
+ await gateway.cancelSubscriptionImmediately(subscription.externalSubscriptionId)
113
110
  } else {
114
- await cancelSubscriptionAtPeriodEnd(subscription.externalSubscriptionId)
111
+ await gateway.cancelSubscriptionAtPeriodEnd(subscription.externalSubscriptionId)
115
112
  }
116
113
 
117
114
  // 7. Update local DB
@@ -174,7 +171,8 @@ async function handleReactivation(teamId: string) {
174
171
  }
175
172
 
176
173
  try {
177
- await reactivateSubscription(subscription.externalSubscriptionId)
174
+ const gateway = getBillingGateway()
175
+ await gateway.reactivateSubscription(subscription.externalSubscriptionId)
178
176
 
179
177
  // Update local DB
180
178
  await queryWithRLS(
@@ -2,7 +2,7 @@
2
2
  * Change Plan Endpoint
3
3
  *
4
4
  * Allows users to upgrade or downgrade their subscription plan.
5
- * Handles proration via Stripe automatically.
5
+ * Handles proration via the payment provider automatically.
6
6
  *
7
7
  * P1-3: Plan Change con Proration
8
8
  */
@@ -49,7 +49,7 @@ export const POST = withRateLimitTier(async (request: NextRequest) => {
49
49
 
50
50
  // 3. Permission check using MembershipService
51
51
  const membership = await MembershipService.get(authResult.user.id, teamId)
52
- const actionResult = membership.canPerformAction('billing.change-plan')
52
+ const actionResult = membership.canPerformAction('team.billing.manage')
53
53
 
54
54
  if (!actionResult.allowed) {
55
55
  return NextResponse.json(
@@ -82,7 +82,7 @@ export const POST = withRateLimitTier(async (request: NextRequest) => {
82
82
  const { planSlug, billingInterval } = parseResult.data
83
83
 
84
84
  // 5. Execute plan change
85
- const result = await SubscriptionService.changePlan(teamId, planSlug, billingInterval)
85
+ const result = await SubscriptionService.changePlan(teamId, planSlug, billingInterval, authResult.user.id)
86
86
 
87
87
  if (!result.success) {
88
88
  return NextResponse.json({ success: false, error: result.error }, { status: 400 })
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { NextRequest, NextResponse } from 'next/server'
13
13
  import { authenticateRequest, createAuthError } from '@nextsparkjs/core/lib/api/auth/dual-auth'
14
- import { MembershipService } from '@nextsparkjs/core/lib/services'
14
+ import { SubscriptionService } from '@nextsparkjs/core/lib/services'
15
15
  import { z } from 'zod'
16
16
  import { withRateLimitTier } from '@nextsparkjs/core/lib/api/rate-limit'
17
17
 
@@ -69,11 +69,12 @@ export const POST = withRateLimitTier(async (request: NextRequest) => {
69
69
  )
70
70
  }
71
71
 
72
- // 4. Check action permission using MembershipService
73
- // Note: MembershipService.get() does NOT throw for non-members
74
- // It returns TeamMembership with role: null
75
- const membership = await MembershipService.get(authResult.user.id, teamId)
76
- const result = membership.canPerformAction(action)
72
+ // 4. Check action using SubscriptionService (RBAC + features + quotas)
73
+ const result = await SubscriptionService.canPerformAction(
74
+ authResult.user.id,
75
+ teamId,
76
+ action
77
+ )
77
78
 
78
79
  return NextResponse.json({
79
80
  success: true,
@@ -1,17 +1,15 @@
1
1
  /**
2
- * Stripe Checkout Endpoint
2
+ * Payment Checkout Endpoint
3
3
  *
4
- * Creates a Stripe Checkout session for subscription upgrade.
5
- * Redirects user to Stripe-hosted checkout page.
4
+ * Creates a checkout session for subscription upgrade.
5
+ * Redirects user to the payment provider's hosted checkout page.
6
6
  *
7
7
  * Security: Requires team owner/admin permission (team.billing.manage)
8
- *
9
- * P2: Stripe Integration
10
8
  */
11
9
 
12
10
  import { NextRequest, NextResponse } from 'next/server'
13
11
  import { authenticateRequest, createAuthError } from '@nextsparkjs/core/lib/api/auth/dual-auth'
14
- import { createCheckoutSession } from '@nextsparkjs/core/lib/billing/gateways/stripe'
12
+ import { getBillingGateway } from '@nextsparkjs/core/lib/billing/gateways/factory'
15
13
  import { SubscriptionService, MembershipService } from '@nextsparkjs/core/lib/services'
16
14
  import { z } from 'zod'
17
15
  import { withRateLimitTier } from '@nextsparkjs/core/lib/api/rate-limit'
@@ -71,7 +69,7 @@ export const POST = withRateLimitTier(async (request: NextRequest) => {
71
69
 
72
70
  // 4. Permission check using MembershipService
73
71
  const membership = await MembershipService.get(authResult.user.id, teamId)
74
- const actionResult = membership.canPerformAction('billing.checkout')
72
+ const actionResult = membership.canPerformAction('team.billing.manage')
75
73
 
76
74
  if (!actionResult.allowed) {
77
75
  return NextResponse.json(
@@ -85,17 +83,45 @@ export const POST = withRateLimitTier(async (request: NextRequest) => {
85
83
  )
86
84
  }
87
85
 
88
- // 5. Check existing subscription for customer ID
86
+ // 5. Check existing subscription
89
87
  try {
90
- const subscription = await SubscriptionService.getActive(teamId)
88
+ const subscription = await SubscriptionService.getActive(teamId, authResult.user.id)
89
+
90
+ // If already subscribed with an active provider subscription,
91
+ // use changePlan (immediate proration) instead of creating a new checkout.
92
+ // This prevents double-billing by updating the existing subscription in Stripe.
93
+ if (subscription?.externalSubscriptionId) {
94
+ const result = await SubscriptionService.changePlan(
95
+ teamId,
96
+ planSlug,
97
+ billingPeriod,
98
+ authResult.user.id
99
+ )
100
+
101
+ if (!result.success) {
102
+ return NextResponse.json(
103
+ { success: false, error: result.error },
104
+ { status: 400 }
105
+ )
106
+ }
107
+
108
+ // No redirect needed — plan changed immediately via API
109
+ return NextResponse.json({
110
+ success: true,
111
+ data: {
112
+ changed: true,
113
+ subscription: result.subscription,
114
+ warnings: result.downgradeWarnings,
115
+ }
116
+ })
117
+ }
91
118
 
92
- // Build URLs
119
+ // No existing provider subscription — create a new checkout session
93
120
  const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:5173'
94
121
  const successUrl = `${appUrl}/dashboard/settings/billing?success=true`
95
122
  const cancelUrl = `${appUrl}/dashboard/settings/billing?canceled=true`
96
123
 
97
- // Create Stripe Checkout session
98
- const session = await createCheckoutSession({
124
+ const session = await getBillingGateway().createCheckoutSession({
99
125
  teamId,
100
126
  planSlug,
101
127
  billingPeriod,
@@ -113,11 +139,11 @@ export const POST = withRateLimitTier(async (request: NextRequest) => {
113
139
  }
114
140
  })
115
141
  } catch (error) {
116
- console.error('[checkout] Error creating checkout session:', error)
142
+ console.error('[checkout] Error:', error)
117
143
  return NextResponse.json(
118
144
  {
119
145
  success: false,
120
- error: error instanceof Error ? error.message : 'Failed to create checkout session'
146
+ error: error instanceof Error ? error.message : 'Failed to process checkout'
121
147
  },
122
148
  { status: 500 }
123
149
  )
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Stripe Customer Portal Endpoint
2
+ * Billing Management Portal Endpoint
3
3
  *
4
- * Creates a Stripe Customer Portal session for self-service billing management.
4
+ * Creates a billing portal session for self-service billing management.
5
5
  * Users can update payment methods, view invoices, and cancel subscriptions.
6
6
  *
7
7
  * P6: Customer Portal
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { NextRequest, NextResponse } from 'next/server'
11
11
  import { authenticateRequest, createAuthError } from '@nextsparkjs/core/lib/api/auth/dual-auth'
12
- import { createPortalSession } from '@nextsparkjs/core/lib/billing/gateways/stripe'
12
+ import { getBillingGateway } from '@nextsparkjs/core/lib/billing/gateways/factory'
13
13
  import { SubscriptionService, MembershipService } from '@nextsparkjs/core/lib/services'
14
14
  import { withRateLimitTier } from '@nextsparkjs/core/lib/api/rate-limit'
15
15
 
@@ -38,7 +38,7 @@ export const POST = withRateLimitTier(async (request: NextRequest) => {
38
38
 
39
39
  // 3. Permission check using MembershipService
40
40
  const membership = await MembershipService.get(authResult.user.id, teamId)
41
- const actionResult = membership.canPerformAction('billing.portal')
41
+ const actionResult = membership.canPerformAction('team.billing.manage')
42
42
 
43
43
  if (!actionResult.allowed) {
44
44
  return NextResponse.json(
@@ -52,7 +52,7 @@ export const POST = withRateLimitTier(async (request: NextRequest) => {
52
52
  )
53
53
  }
54
54
 
55
- // 4. Get subscription with Stripe customer ID
55
+ // 4. Get subscription with payment provider customer ID
56
56
  try {
57
57
  const subscription = await SubscriptionService.getActive(teamId)
58
58
 
@@ -69,7 +69,7 @@ export const POST = withRateLimitTier(async (request: NextRequest) => {
69
69
  const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:5173'
70
70
  const returnUrl = `${appUrl}/dashboard/settings/billing`
71
71
 
72
- const session = await createPortalSession({
72
+ const session = await getBillingGateway().createPortalSession({
73
73
  customerId: subscription.externalCustomerId,
74
74
  returnUrl
75
75
  })
@@ -58,7 +58,7 @@ export default defineApiEndpoint({
58
58
  {
59
59
  id: 'open-portal',
60
60
  title: 'Open Customer Portal',
61
- description: 'Get URL for Stripe customer portal',
61
+ description: 'Get URL for billing management portal',
62
62
  method: 'GET',
63
63
  tags: ['read', 'portal']
64
64
  },
@@ -20,8 +20,11 @@ import { query, queryOne } from '@nextsparkjs/core/lib/db'
20
20
 
21
21
  // Polar webhook verification - import from gateway
22
22
  import { getBillingGateway } from '@nextsparkjs/core/lib/billing/gateways/factory'
23
+ import { withRateLimitTier } from '@nextsparkjs/core/lib/api/rate-limit'
24
+ import type { PolarWebhookExtensions } from '@nextsparkjs/core/lib/billing/polar-webhook'
25
+ import { polarWebhookExtensions } from '@/lib/billing/polar-webhook-extensions'
23
26
 
24
- export async function POST(request: NextRequest) {
27
+ async function handlePolarWebhook(request: NextRequest) {
25
28
  // 1. Get raw body and ALL headers (Polar needs full headers for verification)
26
29
  const payload = await request.text()
27
30
  const headers: Record<string, string> = {}
@@ -83,7 +86,7 @@ export async function POST(request: NextRequest) {
83
86
  break
84
87
 
85
88
  case 'order.paid':
86
- await handleOrderPaid(event.data, eventId)
89
+ await handleOrderPaid(event.data, eventId, polarWebhookExtensions)
87
90
  break
88
91
 
89
92
  default:
@@ -97,6 +100,15 @@ export async function POST(request: NextRequest) {
97
100
  }
98
101
  }
99
102
 
103
+ /**
104
+ * Rate limiting: 500 requests/hour per IP (tier: webhook).
105
+ * Polar signature verification is the primary security layer;
106
+ * rate limiting protects against extreme flood attacks.
107
+ * NOTE: Rate limiter only reads headers — raw body is NOT consumed here,
108
+ * so request.text() inside the handler still works correctly.
109
+ */
110
+ export const POST = withRateLimitTier(handlePolarWebhook, 'webhook')
111
+
100
112
  // ===========================================
101
113
  // POLAR EVENT HANDLERS
102
114
  // ===========================================
@@ -259,9 +271,15 @@ async function handleSubscriptionCanceled(data: Record<string, unknown>, eventId
259
271
 
260
272
  /**
261
273
  * Handle order.paid
262
- * Payment was completed for an order (Polar's equivalent of invoice.paid)
274
+ * Payment was completed for an order (Polar's equivalent of invoice.paid).
275
+ * - With subscriptionId: recurring subscription payment → mark active, log billing event
276
+ * - Without subscriptionId: one-time purchase → delegate to extensions.onOneTimePaymentCompleted
263
277
  */
264
- async function handleOrderPaid(data: Record<string, unknown>, eventId: string) {
278
+ async function handleOrderPaid(
279
+ data: Record<string, unknown>,
280
+ eventId: string,
281
+ extensions?: PolarWebhookExtensions
282
+ ) {
265
283
  const subscriptionId = data.subscriptionId as string | undefined
266
284
  const amount = data.amount as number | undefined
267
285
  const currency = data.currency as string | undefined
@@ -269,7 +287,7 @@ async function handleOrderPaid(data: Record<string, unknown>, eventId: string) {
269
287
  console.log(`[polar-webhook] Order paid: ${eventId}`)
270
288
 
271
289
  if (subscriptionId) {
272
- // Mark subscription as active
290
+ // Recurring subscription payment — mark active and log
273
291
  await query(
274
292
  `UPDATE subscriptions
275
293
  SET status = 'active',
@@ -278,7 +296,6 @@ async function handleOrderPaid(data: Record<string, unknown>, eventId: string) {
278
296
  [subscriptionId]
279
297
  )
280
298
 
281
- // Log billing event for audit trail (recurring payments)
282
299
  const sub = await queryOne<{ id: string }>(
283
300
  `SELECT id FROM subscriptions WHERE "externalSubscriptionId" = $1 LIMIT 1`,
284
301
  [subscriptionId]
@@ -297,6 +314,66 @@ async function handleOrderPaid(data: Record<string, unknown>, eventId: string) {
297
314
  ]
298
315
  )
299
316
  }
317
+ } else {
318
+ // One-time purchase — delegate to project-level extension.
319
+ // Write idempotency record FIRST so Polar retries are deduplicated by the
320
+ // outer billing_events check, mirroring the subscriptionId branch above.
321
+ const metadata = (data.metadata as Record<string, string>) ?? {}
322
+ const teamId = metadata.teamId ?? ''
323
+ const userId = metadata.userId ?? ''
324
+
325
+ const subForIdempotency = teamId
326
+ ? await queryOne<{ id: string }>(
327
+ `SELECT id FROM subscriptions WHERE "teamId" = $1 LIMIT 1`,
328
+ [teamId]
329
+ )
330
+ : null
331
+
332
+ if (subForIdempotency) {
333
+ // Check for existing billing event with this polarEventId (idempotency guard).
334
+ // We use an explicit SELECT instead of ON CONFLICT because billing_events
335
+ // has no unique constraint on metadata->>'polarEventId'.
336
+ const existingEvent = await queryOne(
337
+ `SELECT id FROM "billing_events" WHERE "subscriptionId" = $1 AND metadata->>'polarEventId' = $2`,
338
+ [subForIdempotency.id, eventId]
339
+ )
340
+ if (existingEvent) {
341
+ console.log(`[polar-webhook] One-time order ${eventId} already processed (idempotency), skipping`)
342
+ return
343
+ }
344
+
345
+ await query(
346
+ `INSERT INTO "billing_events" ("subscriptionId", type, status, amount, currency, metadata)
347
+ VALUES ($1, $2, $3, $4, $5, $6)`,
348
+ [
349
+ subForIdempotency.id,
350
+ 'payment',
351
+ 'succeeded',
352
+ amount ?? 0,
353
+ currency ?? 'usd',
354
+ JSON.stringify({ polarEventId: eventId }),
355
+ ]
356
+ )
357
+ } else {
358
+ console.warn(`[polar-webhook] One-time order ${eventId}: no subscription found for teamId "${teamId}", idempotency record skipped`)
359
+ }
360
+
361
+ if (extensions?.onOneTimePaymentCompleted) {
362
+ await extensions.onOneTimePaymentCompleted(
363
+ {
364
+ id: (data.id as string) ?? eventId,
365
+ amount: amount ?? 0,
366
+ currency: currency ?? 'usd',
367
+ metadata,
368
+ customerId: data.customerId as string | undefined,
369
+ externalCustomerId: data.externalCustomerId as string | undefined,
370
+ productId: data.productId as string | undefined,
371
+ },
372
+ { teamId, userId }
373
+ )
374
+ } else {
375
+ console.log(`[polar-webhook] One-time order ${eventId} — no extension handler configured`)
376
+ }
300
377
  }
301
378
  }
302
379