@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
|
@@ -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 {
|
|
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
|
-
|
|
20
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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 {
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
*
|
|
2
|
+
* Payment Checkout Endpoint
|
|
3
3
|
*
|
|
4
|
-
* Creates a
|
|
5
|
-
* Redirects user to
|
|
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 {
|
|
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.
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
2
|
+
* Billing Management Portal Endpoint
|
|
3
3
|
*
|
|
4
|
-
* Creates a
|
|
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 {
|
|
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.
|
|
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
|
|
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
|
})
|
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
//
|
|
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
|
|