@nextsparkjs/core 0.1.0-beta.126 → 0.1.0-beta.128

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 (103) hide show
  1. package/dist/components/billing/ManageBillingButton.d.ts +3 -3
  2. package/dist/components/dashboard/block-editor/config-panel.d.ts.map +1 -1
  3. package/dist/components/dashboard/block-editor/config-panel.js +11 -1
  4. package/dist/components/dashboard/block-editor/entity-fields-sidebar.d.ts.map +1 -1
  5. package/dist/components/dashboard/block-editor/entity-fields-sidebar.js +11 -1
  6. package/dist/components/entities/EntityFieldRenderer.d.ts.map +1 -1
  7. package/dist/components/entities/EntityFieldRenderer.js +3 -1
  8. package/dist/components/ui/simple-relation-select.d.ts +5 -1
  9. package/dist/components/ui/simple-relation-select.d.ts.map +1 -1
  10. package/dist/components/ui/simple-relation-select.js +10 -3
  11. package/dist/lib/api/rate-limit.d.ts.map +1 -1
  12. package/dist/lib/api/rate-limit.js +9 -6
  13. package/dist/lib/billing/config-types.d.ts +2 -5
  14. package/dist/lib/billing/config-types.d.ts.map +1 -1
  15. package/dist/lib/billing/gateways/factory.d.ts +13 -2
  16. package/dist/lib/billing/gateways/factory.d.ts.map +1 -1
  17. package/dist/lib/billing/gateways/factory.js +13 -6
  18. package/dist/lib/billing/gateways/interface.d.ts +19 -1
  19. package/dist/lib/billing/gateways/interface.d.ts.map +1 -1
  20. package/dist/lib/billing/gateways/polar.d.ts +8 -1
  21. package/dist/lib/billing/gateways/polar.d.ts.map +1 -1
  22. package/dist/lib/billing/gateways/polar.js +25 -0
  23. package/dist/lib/billing/gateways/stripe.d.ts +8 -26
  24. package/dist/lib/billing/gateways/stripe.d.ts.map +1 -1
  25. package/dist/lib/billing/gateways/stripe.js +41 -44
  26. package/dist/lib/billing/gateways/types.d.ts +11 -0
  27. package/dist/lib/billing/gateways/types.d.ts.map +1 -1
  28. package/dist/lib/billing/jobs.d.ts +1 -1
  29. package/dist/lib/billing/polar-webhook.d.ts +38 -0
  30. package/dist/lib/billing/polar-webhook.d.ts.map +1 -0
  31. package/dist/lib/billing/polar-webhook.js +0 -0
  32. package/dist/lib/billing/schema.d.ts +1 -2
  33. package/dist/lib/billing/schema.d.ts.map +1 -1
  34. package/dist/lib/billing/schema.js +1 -1
  35. package/dist/lib/billing/stripe-webhook.d.ts +48 -0
  36. package/dist/lib/billing/stripe-webhook.d.ts.map +1 -0
  37. package/dist/lib/billing/stripe-webhook.js +316 -0
  38. package/dist/lib/billing/types.d.ts +6 -2
  39. package/dist/lib/billing/types.d.ts.map +1 -1
  40. package/dist/lib/entities/types.d.ts +11 -0
  41. package/dist/lib/entities/types.d.ts.map +1 -1
  42. package/dist/lib/rate-limit-redis.d.ts +2 -2
  43. package/dist/lib/rate-limit-redis.d.ts.map +1 -1
  44. package/dist/lib/rate-limit-redis.js +22 -4
  45. package/dist/lib/selectors/core-selectors.d.ts +2 -2
  46. package/dist/lib/selectors/domains/superadmin.selectors.d.ts +2 -2
  47. package/dist/lib/selectors/domains/superadmin.selectors.js +2 -2
  48. package/dist/lib/selectors/selectors.d.ts +4 -4
  49. package/dist/lib/services/invoice.service.d.ts +3 -3
  50. package/dist/lib/services/invoice.service.js +2 -2
  51. package/dist/lib/services/membership.service.d.ts.map +1 -1
  52. package/dist/lib/services/membership.service.js +29 -0
  53. package/dist/lib/services/plan.service.d.ts +0 -3
  54. package/dist/lib/services/plan.service.d.ts.map +1 -1
  55. package/dist/lib/services/plan.service.js +3 -9
  56. package/dist/lib/services/subscription.service.d.ts +5 -5
  57. package/dist/lib/services/subscription.service.d.ts.map +1 -1
  58. package/dist/lib/services/subscription.service.js +54 -41
  59. package/dist/migrations/001_better_auth_and_functions.sql +5 -11
  60. package/dist/migrations/008_team_members_table.sql +27 -23
  61. package/dist/styles/classes.json +1 -1
  62. package/dist/templates/app/api/auth/[...all]/route.ts +35 -0
  63. package/dist/templates/app/api/health/route.ts +43 -23
  64. package/dist/templates/app/api/internal/user-metadata/route.ts +10 -0
  65. package/dist/templates/app/api/superadmin/subscriptions/route.ts +5 -0
  66. package/dist/templates/app/api/superadmin/teams/[teamId]/route.ts +6 -0
  67. package/dist/templates/app/api/v1/billing/cancel/route.ts +8 -10
  68. package/dist/templates/app/api/v1/billing/change-plan/route.ts +2 -2
  69. package/dist/templates/app/api/v1/billing/checkout/route.ts +6 -8
  70. package/dist/templates/app/api/v1/billing/portal/route.ts +5 -5
  71. package/dist/templates/app/api/v1/billing/presets.ts +1 -1
  72. package/dist/templates/app/api/v1/billing/webhooks/polar/route.ts +83 -6
  73. package/dist/templates/app/api/v1/billing/webhooks/stripe/route.ts +18 -421
  74. package/dist/templates/app/layout.tsx +14 -5
  75. package/dist/templates/app/superadmin/subscriptions/page.tsx +16 -14
  76. package/dist/templates/app/superadmin/teams/[teamId]/page.tsx +18 -15
  77. package/dist/templates/contents/themes/starter/tests/cypress/src/features/SuperadminPOM.ts +2 -2
  78. package/dist/templates/lib/billing/polar-webhook-extensions.ts +23 -0
  79. package/dist/templates/lib/billing/stripe-webhook-extensions.ts +23 -0
  80. package/migrations/001_better_auth_and_functions.sql +5 -11
  81. package/migrations/008_team_members_table.sql +27 -23
  82. package/package.json +10 -2
  83. package/scripts/build/registry/generators/billing-registry.mjs +1 -2
  84. package/scripts/build/registry/generators/entity-registry.mjs +2 -1
  85. package/templates/app/api/auth/[...all]/route.ts +35 -0
  86. package/templates/app/api/health/route.ts +43 -23
  87. package/templates/app/api/internal/user-metadata/route.ts +10 -0
  88. package/templates/app/api/superadmin/subscriptions/route.ts +5 -0
  89. package/templates/app/api/superadmin/teams/[teamId]/route.ts +6 -0
  90. package/templates/app/api/v1/billing/cancel/route.ts +8 -10
  91. package/templates/app/api/v1/billing/change-plan/route.ts +2 -2
  92. package/templates/app/api/v1/billing/checkout/route.ts +6 -8
  93. package/templates/app/api/v1/billing/portal/route.ts +5 -5
  94. package/templates/app/api/v1/billing/presets.ts +1 -1
  95. package/templates/app/api/v1/billing/webhooks/polar/route.ts +83 -6
  96. package/templates/app/api/v1/billing/webhooks/stripe/route.ts +18 -421
  97. package/templates/app/layout.tsx +14 -5
  98. package/templates/app/superadmin/subscriptions/page.tsx +16 -14
  99. package/templates/app/superadmin/teams/[teamId]/page.tsx +18 -15
  100. package/templates/contents/themes/starter/tests/cypress/src/features/SuperadminPOM.ts +2 -2
  101. package/templates/lib/billing/polar-webhook-extensions.ts +23 -0
  102. package/templates/lib/billing/stripe-webhook-extensions.ts +23 -0
  103. package/tests/jest/__mocks__/@nextsparkjs/registries/billing-registry.ts +7 -8
@@ -41,17 +41,11 @@ BEGIN
41
41
  END;
42
42
  $$;
43
43
 
44
- -- Alias sin schema si lo usás en policies legadas
45
- CREATE OR REPLACE FUNCTION get_auth_user_id()
46
- RETURNS TEXT
47
- LANGUAGE plpgsql
48
- SECURITY DEFINER
49
- SET search_path = public
50
- AS $$
51
- BEGIN
52
- RETURN public.get_auth_user_id();
53
- END;
54
- $$;
44
+ -- NOTE: Previously there was an alias function `get_auth_user_id()` (without schema)
45
+ -- that called `public.get_auth_user_id()`. This was removed because PostgreSQL's
46
+ -- `CREATE OR REPLACE` with `SET search_path = public` would overwrite the real
47
+ -- function with the alias, causing infinite recursion. All RLS policies should
48
+ -- use `public.get_auth_user_id()` with the explicit schema qualifier.
55
49
 
56
50
  -- Utilidad: updatedAt (si no existe ya en otra migration)
57
51
  CREATE OR REPLACE FUNCTION public.set_updated_at()
@@ -43,6 +43,24 @@ CREATE TRIGGER team_members_set_updated_at
43
43
  BEFORE UPDATE ON public."team_members"
44
44
  FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
45
45
 
46
+ -- ============================================
47
+ -- HELPER FUNCTION (bypasses RLS via SECURITY DEFINER)
48
+ -- ============================================
49
+
50
+ -- Returns team IDs for a given user, bypassing RLS to prevent self-referencing
51
+ -- recursion. Without this, team_members RLS policies query team_members itself,
52
+ -- causing infinite recursion when any other table (subscriptions, billing_events,
53
+ -- usage) references team_members in its own RLS policy chain.
54
+ CREATE OR REPLACE FUNCTION public.get_user_team_ids(p_user_id TEXT)
55
+ RETURNS SETOF TEXT
56
+ LANGUAGE sql
57
+ STABLE
58
+ SECURITY DEFINER
59
+ SET search_path = public
60
+ AS $$
61
+ SELECT "teamId" FROM public."team_members" WHERE "userId" = p_user_id;
62
+ $$;
63
+
46
64
  -- ============================================
47
65
  -- TEAM MEMBERS ROW LEVEL SECURITY (RLS)
48
66
  -- ============================================
@@ -50,23 +68,21 @@ FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
50
68
  ALTER TABLE public."team_members" ENABLE ROW LEVEL SECURITY;
51
69
 
52
70
  -- Team members: visible to members of the same team
71
+ -- Uses get_user_team_ids() (SECURITY DEFINER) to avoid self-referencing recursion
53
72
  CREATE POLICY "team_members_select_policy" ON public."team_members"
54
73
  FOR SELECT TO authenticated
55
74
  USING (
56
- "teamId" IN (
57
- SELECT "teamId" FROM public."team_members"
58
- WHERE "userId" = public.get_auth_user_id()
59
- )
75
+ "teamId" IN (SELECT public.get_user_team_ids(public.get_auth_user_id()))
60
76
  );
61
77
 
62
78
  -- Team members: owners and admins can add members
63
79
  CREATE POLICY "team_members_insert_policy" ON public."team_members"
64
80
  FOR INSERT TO authenticated
65
81
  WITH CHECK (
66
- "teamId" IN (
67
- SELECT "teamId" FROM public."team_members"
68
- WHERE "userId" = public.get_auth_user_id()
69
- AND role IN ('owner', 'admin')
82
+ "teamId" IN (SELECT public.get_user_team_ids(public.get_auth_user_id()))
83
+ AND EXISTS (
84
+ SELECT 1 FROM public.get_user_team_ids(public.get_auth_user_id()) AS t
85
+ WHERE t = "team_members"."teamId"
70
86
  )
71
87
  );
72
88
 
@@ -74,29 +90,17 @@ CREATE POLICY "team_members_insert_policy" ON public."team_members"
74
90
  CREATE POLICY "team_members_update_policy" ON public."team_members"
75
91
  FOR UPDATE TO authenticated
76
92
  USING (
77
- "teamId" IN (
78
- SELECT "teamId" FROM public."team_members"
79
- WHERE "userId" = public.get_auth_user_id()
80
- AND role IN ('owner', 'admin')
81
- )
93
+ "teamId" IN (SELECT public.get_user_team_ids(public.get_auth_user_id()))
82
94
  )
83
95
  WITH CHECK (
84
- "teamId" IN (
85
- SELECT "teamId" FROM public."team_members"
86
- WHERE "userId" = public.get_auth_user_id()
87
- AND role IN ('owner', 'admin')
88
- )
96
+ "teamId" IN (SELECT public.get_user_team_ids(public.get_auth_user_id()))
89
97
  );
90
98
 
91
99
  -- Team members: owners and admins can remove members (except themselves)
92
100
  CREATE POLICY "team_members_delete_policy" ON public."team_members"
93
101
  FOR DELETE TO authenticated
94
102
  USING (
95
- "teamId" IN (
96
- SELECT "teamId" FROM public."team_members"
97
- WHERE "userId" = public.get_auth_user_id()
98
- AND role IN ('owner', 'admin')
99
- )
103
+ "teamId" IN (SELECT public.get_user_team_ids(public.get_auth_user_id()))
100
104
  -- Cannot remove yourself
101
105
  AND "userId" != public.get_auth_user_id()
102
106
  );
@@ -1,5 +1,5 @@
1
1
  {
2
- "generated": "2026-03-17T22:09:43.971Z",
2
+ "generated": "2026-03-21T22:28:39.842Z",
3
3
  "totalClasses": 1074,
4
4
  "classes": [
5
5
  "!text-2xl",
@@ -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
 
@@ -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
  */
@@ -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 })
@@ -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'
@@ -94,8 +92,8 @@ export const POST = withRateLimitTier(async (request: NextRequest) => {
94
92
  const successUrl = `${appUrl}/dashboard/settings/billing?success=true`
95
93
  const cancelUrl = `${appUrl}/dashboard/settings/billing?canceled=true`
96
94
 
97
- // Create Stripe Checkout session
98
- const session = await createCheckoutSession({
95
+ // Create checkout session via billing gateway
96
+ const session = await getBillingGateway().createCheckoutSession({
99
97
  teamId,
100
98
  planSlug,
101
99
  billingPeriod,
@@ -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
 
@@ -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