@vibecodiq/cli 0.5.0 → 0.5.1
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/foundation/admin_basic/manifest.json +37 -0
- package/dist/foundation/admin_basic/migrations/004_user_roles.sql +117 -0
- package/dist/foundation/admin_basic/migrations/005_audit_log.sql +34 -0
- package/dist/foundation/admin_basic/migrations/006_impersonation_sessions.sql +22 -0
- package/dist/foundation/admin_basic/shared/audit.ts +97 -0
- package/dist/foundation/admin_basic/shared/guards.ts +58 -0
- package/dist/foundation/admin_basic/shared/impersonation.ts +165 -0
- package/dist/foundation/admin_basic/shared/permissions.ts +27 -0
- package/dist/foundation/admin_basic/shared/roles.ts +151 -0
- package/dist/foundation/admin_basic/slices/audit_log/handler.ts +34 -0
- package/dist/foundation/admin_basic/slices/audit_log/slice.contract.json +19 -0
- package/dist/foundation/admin_basic/slices/dashboard/handler.ts +51 -0
- package/dist/foundation/admin_basic/slices/dashboard/slice.contract.json +13 -0
- package/dist/foundation/admin_basic/slices/impersonation/handler.ts +61 -0
- package/dist/foundation/admin_basic/slices/impersonation/slice.contract.json +21 -0
- package/dist/foundation/admin_basic/slices/roles/handler.ts +90 -0
- package/dist/foundation/admin_basic/slices/roles/slice.contract.json +21 -0
- package/dist/foundation/admin_basic/slices/users/handler.ts +48 -0
- package/dist/foundation/admin_basic/slices/users/slice.contract.json +15 -0
- package/dist/foundation/auth_basic/manifest.json +32 -0
- package/dist/foundation/auth_basic/migrations/001_create_profiles.sql +36 -0
- package/dist/foundation/auth_basic/shared/guards.ts +89 -0
- package/dist/foundation/auth_basic/shared/hooks.ts +63 -0
- package/dist/foundation/auth_basic/shared/middleware.ts +46 -0
- package/dist/foundation/auth_basic/shared/server-user.ts +61 -0
- package/dist/foundation/auth_basic/shared/session.ts +38 -0
- package/dist/foundation/auth_basic/shared/types.ts +29 -0
- package/dist/foundation/auth_basic/slices/login/handler.ts +50 -0
- package/dist/foundation/auth_basic/slices/login/repository.ts +23 -0
- package/dist/foundation/auth_basic/slices/login/schemas.ts +22 -0
- package/dist/foundation/auth_basic/slices/login/slice.contract.json +19 -0
- package/dist/foundation/auth_basic/slices/login/ui/AuthLogin.tsx +107 -0
- package/dist/foundation/auth_basic/slices/login/ui/hook.ts +44 -0
- package/dist/foundation/auth_basic/slices/logout/handler.ts +19 -0
- package/dist/foundation/auth_basic/slices/logout/slice.contract.json +16 -0
- package/dist/foundation/auth_basic/slices/register/handler.ts +61 -0
- package/dist/foundation/auth_basic/slices/register/repository.ts +25 -0
- package/dist/foundation/auth_basic/slices/register/schemas.ts +29 -0
- package/dist/foundation/auth_basic/slices/register/slice.contract.json +21 -0
- package/dist/foundation/auth_basic/slices/register/ui/AuthRegister.tsx +118 -0
- package/dist/foundation/auth_basic/slices/register/ui/hook.ts +48 -0
- package/dist/foundation/auth_basic/slices/reset_password/handler.ts +47 -0
- package/dist/foundation/auth_basic/slices/reset_password/schemas.ts +21 -0
- package/dist/foundation/auth_basic/slices/reset_password/slice.contract.json +18 -0
- package/dist/foundation/auth_basic/slices/reset_password/ui/AuthResetPassword.tsx +79 -0
- package/dist/foundation/auth_basic/slices/reset_password/ui/hook.ts +48 -0
- package/dist/foundation/db_basic/manifest.json +33 -0
- package/dist/foundation/db_basic/shared/seed.ts +27 -0
- package/dist/foundation/db_basic/shared/supabase-client.ts +70 -0
- package/dist/foundation/db_basic/shared/types.ts +20 -0
- package/dist/foundation/db_basic/shared/utils.ts +43 -0
- package/dist/foundation/payments_basic/manifest.json +54 -0
- package/dist/foundation/payments_basic/migrations/002_create_subscriptions.sql +44 -0
- package/dist/foundation/payments_basic/migrations/003_create_entitlements.sql +54 -0
- package/dist/foundation/payments_basic/migrations/003b_create_webhook_events.sql +28 -0
- package/dist/foundation/payments_basic/shared/entitlement-hooks.ts +50 -0
- package/dist/foundation/payments_basic/shared/entitlement-types.ts +29 -0
- package/dist/foundation/payments_basic/shared/entitlements.ts +78 -0
- package/dist/foundation/payments_basic/shared/guards.ts +110 -0
- package/dist/foundation/payments_basic/shared/hooks.ts +45 -0
- package/dist/foundation/payments_basic/shared/plans.ts +54 -0
- package/dist/foundation/payments_basic/shared/reconciliation.ts +85 -0
- package/dist/foundation/payments_basic/shared/resolver.ts +61 -0
- package/dist/foundation/payments_basic/shared/stripe-client.ts +15 -0
- package/dist/foundation/payments_basic/shared/types.ts +84 -0
- package/dist/foundation/payments_basic/shared/webhook-handler.ts +198 -0
- package/dist/foundation/payments_basic/shared/webhook-processor.ts +174 -0
- package/dist/foundation/payments_basic/slices/cancel/handler.ts +55 -0
- package/dist/foundation/payments_basic/slices/cancel/slice.contract.json +17 -0
- package/dist/foundation/payments_basic/slices/cancel/ui/hook.ts +45 -0
- package/dist/foundation/payments_basic/slices/check_limits/handler.ts +33 -0
- package/dist/foundation/payments_basic/slices/check_limits/slice.contract.json +17 -0
- package/dist/foundation/payments_basic/slices/subscribe/handler.ts +79 -0
- package/dist/foundation/payments_basic/slices/subscribe/repository.ts +32 -0
- package/dist/foundation/payments_basic/slices/subscribe/schemas.ts +21 -0
- package/dist/foundation/payments_basic/slices/subscribe/slice.contract.json +20 -0
- package/dist/foundation/payments_basic/slices/subscribe/ui/BillingSubscribe.tsx +93 -0
- package/dist/foundation/payments_basic/slices/subscribe/ui/hook.ts +44 -0
- package/dist/foundation/payments_basic/slices/webhook/handler.ts +67 -0
- package/dist/foundation/payments_basic/slices/webhook/slice.contract.json +19 -0
- package/dist/index.js +19 -18
- package/package.json +11 -2
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createAdminClient } from '@/shared/db/supabase-client';
|
|
2
|
+
|
|
3
|
+
// --- ASA GENERATED START ---
|
|
4
|
+
// Entitlement resolver — syncs entitlements from plan_features when subscription changes.
|
|
5
|
+
// Called from Stripe webhook handler on plan change events.
|
|
6
|
+
// --- ASA GENERATED END ---
|
|
7
|
+
|
|
8
|
+
// --- USER CODE START ---
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Sync entitlements for user based on their current plan.
|
|
12
|
+
*
|
|
13
|
+
* Flow:
|
|
14
|
+
* 1. Read plan_features for planKey
|
|
15
|
+
* 2. Delete old plan-sourced entitlements (keep overrides)
|
|
16
|
+
* 3. Insert new entitlements from plan
|
|
17
|
+
*
|
|
18
|
+
* Called from:
|
|
19
|
+
* - Stripe webhook handler (plan change)
|
|
20
|
+
* - User registration (set free plan defaults)
|
|
21
|
+
*/
|
|
22
|
+
export async function syncEntitlements(
|
|
23
|
+
userId: string,
|
|
24
|
+
planKey: string,
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
const supabase = createAdminClient();
|
|
27
|
+
|
|
28
|
+
// 1. Get plan features
|
|
29
|
+
const { data: planFeatures } = await supabase
|
|
30
|
+
.from('plan_features')
|
|
31
|
+
.select('*')
|
|
32
|
+
.eq('plan_key', planKey);
|
|
33
|
+
|
|
34
|
+
if (!planFeatures || planFeatures.length === 0) {
|
|
35
|
+
console.warn(`[ASA Entitlements] No plan_features found for plan: ${planKey}`);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 2. Delete old plan-sourced entitlements (keep overrides)
|
|
40
|
+
await supabase
|
|
41
|
+
.from('entitlements')
|
|
42
|
+
.delete()
|
|
43
|
+
.eq('user_id', userId)
|
|
44
|
+
.eq('source', 'plan');
|
|
45
|
+
|
|
46
|
+
// 3. Insert new entitlements from plan
|
|
47
|
+
const rows = planFeatures.map((pf: { feature_key: string; feature_type: string; value: string }) => ({
|
|
48
|
+
user_id: userId,
|
|
49
|
+
feature_key: pf.feature_key,
|
|
50
|
+
feature_type: pf.feature_type,
|
|
51
|
+
value: pf.value,
|
|
52
|
+
source: 'plan',
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
const { error } = await supabase.from('entitlements').insert(rows);
|
|
56
|
+
if (error) {
|
|
57
|
+
console.error(`[ASA Entitlements] Failed to sync entitlements for user ${userId}:`, error);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --- USER CODE END ---
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import Stripe from 'stripe';
|
|
2
|
+
|
|
3
|
+
// --- ASA GENERATED START ---
|
|
4
|
+
// Stripe client for server-side operations.
|
|
5
|
+
// NEVER import in browser-capable files.
|
|
6
|
+
// --- ASA GENERATED END ---
|
|
7
|
+
|
|
8
|
+
// --- USER CODE START ---
|
|
9
|
+
|
|
10
|
+
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
|
11
|
+
apiVersion: '2024-12-18.acacia',
|
|
12
|
+
typescript: true,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// --- USER CODE END ---
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// --- ASA GENERATED START ---
|
|
2
|
+
// Billing types for payments-basic module.
|
|
3
|
+
// --- ASA GENERATED END ---
|
|
4
|
+
|
|
5
|
+
// --- USER CODE START ---
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Subscription State Machine (research-validated)
|
|
9
|
+
*
|
|
10
|
+
* States and their access implications:
|
|
11
|
+
* - active: ✅ Full access
|
|
12
|
+
* - past_due: ✅ Still has access (Stripe retrying payment)
|
|
13
|
+
* - trialing: ✅ Full access (trial period)
|
|
14
|
+
* - incomplete: ⚠️ Limited access (initial payment pending)
|
|
15
|
+
* - canceled: ❌ Access revoked
|
|
16
|
+
* - unpaid: ❌ Access revoked (all retries failed)
|
|
17
|
+
*
|
|
18
|
+
* IMPORTANT: past_due = "still has access". Most apps incorrectly
|
|
19
|
+
* check only for 'active' and lock out paying users during dunning.
|
|
20
|
+
*/
|
|
21
|
+
export type SubscriptionStatus =
|
|
22
|
+
| 'active'
|
|
23
|
+
| 'past_due'
|
|
24
|
+
| 'trialing'
|
|
25
|
+
| 'incomplete'
|
|
26
|
+
| 'canceled'
|
|
27
|
+
| 'unpaid';
|
|
28
|
+
|
|
29
|
+
export const ACCESS_GRANTED_STATUSES: SubscriptionStatus[] = [
|
|
30
|
+
'active',
|
|
31
|
+
'past_due',
|
|
32
|
+
'trialing',
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
export const ACCESS_REVOKED_STATUSES: SubscriptionStatus[] = [
|
|
36
|
+
'canceled',
|
|
37
|
+
'unpaid',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
export function hasActiveAccess(status: SubscriptionStatus): boolean {
|
|
41
|
+
return ACCESS_GRANTED_STATUSES.includes(status);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface Subscription {
|
|
45
|
+
id: string;
|
|
46
|
+
user_id: string;
|
|
47
|
+
stripe_customer_id: string | null;
|
|
48
|
+
stripe_subscription_id: string | null;
|
|
49
|
+
plan: string;
|
|
50
|
+
status: SubscriptionStatus;
|
|
51
|
+
current_period_end: string | null;
|
|
52
|
+
cancel_at_period_end: boolean;
|
|
53
|
+
created_at: string;
|
|
54
|
+
updated_at: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface PlanLimitCheck {
|
|
58
|
+
allowed: boolean;
|
|
59
|
+
currentUsage: number;
|
|
60
|
+
limit: number;
|
|
61
|
+
plan: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Stripe webhook event types handled by payments-basic.
|
|
66
|
+
*/
|
|
67
|
+
export type HandledStripeEvent =
|
|
68
|
+
| 'checkout.session.completed'
|
|
69
|
+
| 'customer.subscription.created'
|
|
70
|
+
| 'customer.subscription.updated'
|
|
71
|
+
| 'customer.subscription.deleted'
|
|
72
|
+
| 'invoice.paid'
|
|
73
|
+
| 'invoice.payment_failed';
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Processed webhook event record for idempotency.
|
|
77
|
+
*/
|
|
78
|
+
export interface WebhookEventRecord {
|
|
79
|
+
event_id: string;
|
|
80
|
+
event_type: string;
|
|
81
|
+
processed_at: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// --- USER CODE END ---
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import Stripe from 'stripe';
|
|
2
|
+
import { stripe } from './stripe-client';
|
|
3
|
+
import { createAdminClient } from '@/shared/db/supabase-client';
|
|
4
|
+
import type { HandledStripeEvent, SubscriptionStatus } from './types';
|
|
5
|
+
import { syncEntitlements } from '@/shared/entitlements/resolver';
|
|
6
|
+
|
|
7
|
+
// --- ASA GENERATED START ---
|
|
8
|
+
// Idempotent Stripe webhook handler for payments-basic module.
|
|
9
|
+
//
|
|
10
|
+
// Research-validated patterns:
|
|
11
|
+
// 1. Raw body for signature verification (await request.text())
|
|
12
|
+
// 2. Idempotent processing (event ID dedup via webhook_events table)
|
|
13
|
+
// 3. Eager state sync (always fetch current state from Stripe API, don't trust payload)
|
|
14
|
+
// 4. Return 200 for unrecognized events (prevent Stripe retry loops)
|
|
15
|
+
// 5. Correct dunning: past_due = still has access
|
|
16
|
+
// --- ASA GENERATED END ---
|
|
17
|
+
|
|
18
|
+
// --- USER CODE START ---
|
|
19
|
+
|
|
20
|
+
const HANDLED_EVENTS: HandledStripeEvent[] = [
|
|
21
|
+
'checkout.session.completed',
|
|
22
|
+
'customer.subscription.created',
|
|
23
|
+
'customer.subscription.updated',
|
|
24
|
+
'customer.subscription.deleted',
|
|
25
|
+
'invoice.paid',
|
|
26
|
+
'invoice.payment_failed',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Process incoming Stripe webhook event.
|
|
31
|
+
*
|
|
32
|
+
* IMPORTANT implementation notes:
|
|
33
|
+
* - Uses raw body (string) for signature verification
|
|
34
|
+
* - Checks idempotency before processing (event ID in webhook_events table)
|
|
35
|
+
* - Always fetches current state from Stripe API (never trusts webhook payload alone)
|
|
36
|
+
* - Returns 200 for all events (including unhandled) to prevent Stripe retries
|
|
37
|
+
*/
|
|
38
|
+
export async function handleWebhook(
|
|
39
|
+
rawBody: string,
|
|
40
|
+
signature: string,
|
|
41
|
+
): Promise<{ status: number; message: string }> {
|
|
42
|
+
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
|
|
43
|
+
|
|
44
|
+
// 1. Verify signature using RAW body (not parsed JSON)
|
|
45
|
+
let event: Stripe.Event;
|
|
46
|
+
try {
|
|
47
|
+
event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
50
|
+
return { status: 400, message: `Webhook signature verification failed: ${message}` };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 2. Idempotency check — skip if already processed
|
|
54
|
+
const supabase = createAdminClient();
|
|
55
|
+
const { data: existing } = await supabase
|
|
56
|
+
.from('webhook_events')
|
|
57
|
+
.select('event_id')
|
|
58
|
+
.eq('event_id', event.id)
|
|
59
|
+
.single();
|
|
60
|
+
|
|
61
|
+
if (existing) {
|
|
62
|
+
return { status: 200, message: `Event ${event.id} already processed` };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 3. Record event as processing (before handling)
|
|
66
|
+
await supabase.from('webhook_events').insert({
|
|
67
|
+
event_id: event.id,
|
|
68
|
+
event_type: event.type,
|
|
69
|
+
processed_at: new Date().toISOString(),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// 4. Handle known events (fetch current state from Stripe, don't trust payload)
|
|
73
|
+
if (HANDLED_EVENTS.includes(event.type as HandledStripeEvent)) {
|
|
74
|
+
try {
|
|
75
|
+
await processEvent(event, supabase);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
console.error(`[ASA Webhook] Error processing ${event.type}:`, err);
|
|
78
|
+
// Still return 200 — retrying won't help if our logic failed
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 5. Return 200 for ALL events (including unrecognized)
|
|
83
|
+
// Returning 400 for unrecognized events causes Stripe retry loops
|
|
84
|
+
return { status: 200, message: `Event ${event.id} processed` };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Process a specific Stripe event by fetching current state from Stripe API.
|
|
89
|
+
*
|
|
90
|
+
* PATTERN: Webhooks are SIGNALS, not source of truth.
|
|
91
|
+
* Always fetch the latest canonical state from Stripe before updating DB.
|
|
92
|
+
*/
|
|
93
|
+
async function processEvent(event: Stripe.Event, supabase: any): Promise<void> {
|
|
94
|
+
switch (event.type) {
|
|
95
|
+
case 'checkout.session.completed': {
|
|
96
|
+
const session = event.data.object as Stripe.Checkout.Session;
|
|
97
|
+
if (session.subscription && session.customer) {
|
|
98
|
+
// Fetch current subscription state from Stripe (not from webhook payload)
|
|
99
|
+
const subscription = await stripe.subscriptions.retrieve(
|
|
100
|
+
session.subscription as string,
|
|
101
|
+
);
|
|
102
|
+
await upsertSubscription(supabase, subscription, session.customer as string);
|
|
103
|
+
}
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
case 'customer.subscription.created':
|
|
108
|
+
case 'customer.subscription.updated':
|
|
109
|
+
case 'customer.subscription.deleted': {
|
|
110
|
+
const subEvent = event.data.object as Stripe.Subscription;
|
|
111
|
+
// Fetch current state from Stripe API (eager sync)
|
|
112
|
+
const subscription = await stripe.subscriptions.retrieve(subEvent.id);
|
|
113
|
+
await upsertSubscription(
|
|
114
|
+
supabase,
|
|
115
|
+
subscription,
|
|
116
|
+
subscription.customer as string,
|
|
117
|
+
);
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
case 'invoice.paid':
|
|
122
|
+
case 'invoice.payment_failed': {
|
|
123
|
+
const invoice = event.data.object as Stripe.Invoice;
|
|
124
|
+
if (invoice.subscription) {
|
|
125
|
+
const subscription = await stripe.subscriptions.retrieve(
|
|
126
|
+
invoice.subscription as string,
|
|
127
|
+
);
|
|
128
|
+
await upsertSubscription(
|
|
129
|
+
supabase,
|
|
130
|
+
subscription,
|
|
131
|
+
subscription.customer as string,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Upsert subscription state in database.
|
|
141
|
+
*
|
|
142
|
+
* Maps Stripe subscription status to our state machine:
|
|
143
|
+
* - active, past_due, trialing → access granted
|
|
144
|
+
* - canceled, unpaid, incomplete_expired → access revoked
|
|
145
|
+
*/
|
|
146
|
+
async function upsertSubscription(
|
|
147
|
+
supabase: any,
|
|
148
|
+
subscription: Stripe.Subscription,
|
|
149
|
+
stripeCustomerId: string,
|
|
150
|
+
): Promise<void> {
|
|
151
|
+
// Find user by stripe_customer_id
|
|
152
|
+
const { data: existingSub } = await supabase
|
|
153
|
+
.from('subscriptions')
|
|
154
|
+
.select('id, user_id')
|
|
155
|
+
.eq('stripe_customer_id', stripeCustomerId)
|
|
156
|
+
.single();
|
|
157
|
+
|
|
158
|
+
const status = mapStripeStatus(subscription.status);
|
|
159
|
+
const plan = subscription.status === 'canceled' ? 'free' : 'pro';
|
|
160
|
+
|
|
161
|
+
const subscriptionData = {
|
|
162
|
+
stripe_customer_id: stripeCustomerId,
|
|
163
|
+
stripe_subscription_id: subscription.id,
|
|
164
|
+
plan,
|
|
165
|
+
status,
|
|
166
|
+
current_period_end: subscription.current_period_end
|
|
167
|
+
? new Date(subscription.current_period_end * 1000).toISOString()
|
|
168
|
+
: null,
|
|
169
|
+
cancel_at_period_end: subscription.cancel_at_period_end ?? false,
|
|
170
|
+
updated_at: new Date().toISOString(),
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
if (existingSub) {
|
|
174
|
+
await supabase
|
|
175
|
+
.from('subscriptions')
|
|
176
|
+
.update(subscriptionData)
|
|
177
|
+
.eq('id', existingSub.id);
|
|
178
|
+
|
|
179
|
+
// Sync entitlements based on new plan
|
|
180
|
+
await syncEntitlements(existingSub.user_id, plan);
|
|
181
|
+
}
|
|
182
|
+
// If no existing subscription found, the checkout handler should have created it
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function mapStripeStatus(stripeStatus: string): SubscriptionStatus {
|
|
186
|
+
const statusMap: Record<string, SubscriptionStatus> = {
|
|
187
|
+
active: 'active',
|
|
188
|
+
past_due: 'past_due',
|
|
189
|
+
trialing: 'trialing',
|
|
190
|
+
incomplete: 'incomplete',
|
|
191
|
+
canceled: 'canceled',
|
|
192
|
+
unpaid: 'unpaid',
|
|
193
|
+
incomplete_expired: 'canceled',
|
|
194
|
+
};
|
|
195
|
+
return statusMap[stripeStatus] ?? 'canceled';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// --- USER CODE END ---
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { createAdminClient } from '@/shared/db/supabase-client';
|
|
2
|
+
import { stripe } from '@/shared/billing/stripe-client';
|
|
3
|
+
import type Stripe from 'stripe';
|
|
4
|
+
|
|
5
|
+
// --- ASA GENERATED START ---
|
|
6
|
+
// Webhook event processor — processes pending events from webhook_events table.
|
|
7
|
+
// Called by scheduled processor (Supabase Cron or Vercel Cron).
|
|
8
|
+
// Implements: idempotency, locking, retry tracking, always-fetch-current-state.
|
|
9
|
+
// --- ASA GENERATED END ---
|
|
10
|
+
|
|
11
|
+
// --- USER CODE START ---
|
|
12
|
+
|
|
13
|
+
const BATCH_SIZE = 10;
|
|
14
|
+
const LOCK_TIMEOUT_MINUTES = 5;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Process pending webhook events from the inbox table.
|
|
18
|
+
* Call this from a cron job / scheduled function.
|
|
19
|
+
*/
|
|
20
|
+
export async function processWebhookEvents(): Promise<{ processed: number; failed: number }> {
|
|
21
|
+
const supabase = createAdminClient();
|
|
22
|
+
let processed = 0;
|
|
23
|
+
let failed = 0;
|
|
24
|
+
|
|
25
|
+
// Lock and fetch pending events (skip already locked)
|
|
26
|
+
const cutoff = new Date(Date.now() - LOCK_TIMEOUT_MINUTES * 60 * 1000).toISOString();
|
|
27
|
+
|
|
28
|
+
const { data: events } = await supabase
|
|
29
|
+
.from('webhook_events')
|
|
30
|
+
.select('*')
|
|
31
|
+
.eq('status', 'pending')
|
|
32
|
+
.or(`locked_at.is.null,locked_at.lt.${cutoff}`)
|
|
33
|
+
.order('received_at', { ascending: true })
|
|
34
|
+
.limit(BATCH_SIZE);
|
|
35
|
+
|
|
36
|
+
if (!events || events.length === 0) return { processed, failed };
|
|
37
|
+
|
|
38
|
+
for (const event of events) {
|
|
39
|
+
// Lock the event
|
|
40
|
+
await supabase
|
|
41
|
+
.from('webhook_events')
|
|
42
|
+
.update({ locked_at: new Date().toISOString(), status: 'processing' })
|
|
43
|
+
.eq('id', event.id)
|
|
44
|
+
.eq('status', 'pending');
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
await handleStripeEvent(event.type, event.payload);
|
|
48
|
+
|
|
49
|
+
// Mark as processed
|
|
50
|
+
await supabase
|
|
51
|
+
.from('webhook_events')
|
|
52
|
+
.update({
|
|
53
|
+
status: 'processed',
|
|
54
|
+
processed_at: new Date().toISOString(),
|
|
55
|
+
attempt_count: event.attempt_count + 1,
|
|
56
|
+
locked_at: null,
|
|
57
|
+
})
|
|
58
|
+
.eq('id', event.id);
|
|
59
|
+
|
|
60
|
+
processed++;
|
|
61
|
+
} catch (err) {
|
|
62
|
+
// Mark as failed with error
|
|
63
|
+
await supabase
|
|
64
|
+
.from('webhook_events')
|
|
65
|
+
.update({
|
|
66
|
+
status: event.attempt_count >= 2 ? 'failed' : 'pending',
|
|
67
|
+
last_error: err instanceof Error ? err.message : String(err),
|
|
68
|
+
attempt_count: event.attempt_count + 1,
|
|
69
|
+
locked_at: null,
|
|
70
|
+
})
|
|
71
|
+
.eq('id', event.id);
|
|
72
|
+
|
|
73
|
+
failed++;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { processed, failed };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Handle a single Stripe event.
|
|
82
|
+
* Always fetches current state from Stripe — never trusts webhook payload alone.
|
|
83
|
+
*/
|
|
84
|
+
async function handleStripeEvent(type: string, payload: Record<string, unknown>): Promise<void> {
|
|
85
|
+
const supabase = createAdminClient();
|
|
86
|
+
const object = payload.object as Record<string, unknown>;
|
|
87
|
+
|
|
88
|
+
switch (type) {
|
|
89
|
+
case 'customer.subscription.created':
|
|
90
|
+
case 'customer.subscription.updated': {
|
|
91
|
+
// Always fetch current subscription from Stripe
|
|
92
|
+
const sub = await stripe.subscriptions.retrieve(object.id as string);
|
|
93
|
+
await syncSubscription(supabase, sub);
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
case 'customer.subscription.deleted': {
|
|
98
|
+
const sub = payload.object as unknown as Stripe.Subscription;
|
|
99
|
+
const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer?.id;
|
|
100
|
+
|
|
101
|
+
await supabase
|
|
102
|
+
.from('subscriptions')
|
|
103
|
+
.update({ plan: 'free', status: 'canceled', stripe_subscription_id: null })
|
|
104
|
+
.eq('stripe_customer_id', customerId);
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
case 'invoice.payment_failed': {
|
|
109
|
+
const invoice = payload.object as unknown as Stripe.Invoice;
|
|
110
|
+
const customerId = typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
|
|
111
|
+
|
|
112
|
+
await supabase
|
|
113
|
+
.from('subscriptions')
|
|
114
|
+
.update({ status: 'past_due' })
|
|
115
|
+
.eq('stripe_customer_id', customerId);
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
case 'invoice.paid': {
|
|
120
|
+
const invoice = payload.object as unknown as Stripe.Invoice;
|
|
121
|
+
const customerId = typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
|
|
122
|
+
|
|
123
|
+
// Re-activate if was past_due
|
|
124
|
+
await supabase
|
|
125
|
+
.from('subscriptions')
|
|
126
|
+
.update({ status: 'active' })
|
|
127
|
+
.eq('stripe_customer_id', customerId)
|
|
128
|
+
.eq('status', 'past_due');
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
default:
|
|
133
|
+
// Unhandled event type — log but don't fail
|
|
134
|
+
console.log(`[webhook-processor] Unhandled event type: ${type}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Sync subscription state from Stripe to local DB.
|
|
140
|
+
* Implements "always fetch current state" pattern.
|
|
141
|
+
*/
|
|
142
|
+
async function syncSubscription(
|
|
143
|
+
supabase: ReturnType<typeof createAdminClient>,
|
|
144
|
+
sub: Stripe.Subscription,
|
|
145
|
+
): Promise<void> {
|
|
146
|
+
const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer.id;
|
|
147
|
+
const priceId = sub.items.data[0]?.price?.id;
|
|
148
|
+
|
|
149
|
+
// Map Stripe status to our billing state machine
|
|
150
|
+
let status: string;
|
|
151
|
+
switch (sub.status) {
|
|
152
|
+
case 'active': status = 'active'; break;
|
|
153
|
+
case 'trialing': status = 'trialing'; break;
|
|
154
|
+
case 'past_due': status = 'past_due'; break;
|
|
155
|
+
case 'canceled': status = 'canceled'; break;
|
|
156
|
+
case 'unpaid': status = 'past_due'; break;
|
|
157
|
+
default: status = sub.status;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Determine plan from price ID
|
|
161
|
+
const { PLANS } = await import('@/shared/billing/plans');
|
|
162
|
+
const plan = PLANS.find((p: { stripe_price_id?: string }) => p.stripe_price_id === priceId)?.id ?? 'pro';
|
|
163
|
+
|
|
164
|
+
await supabase
|
|
165
|
+
.from('subscriptions')
|
|
166
|
+
.update({
|
|
167
|
+
plan,
|
|
168
|
+
status,
|
|
169
|
+
stripe_subscription_id: sub.id,
|
|
170
|
+
})
|
|
171
|
+
.eq('stripe_customer_id', customerId);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// --- USER CODE END ---
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { requireAuth } from '@/shared/auth/guards';
|
|
3
|
+
import { stripe } from '@/shared/billing/stripe-client';
|
|
4
|
+
import { createAdminClient } from '@/shared/db/supabase-client';
|
|
5
|
+
|
|
6
|
+
// --- ASA GENERATED START ---
|
|
7
|
+
// Route handler for billing/cancel slice.
|
|
8
|
+
// Cancels subscription at period end (not immediately).
|
|
9
|
+
// Security: requires authenticated user, validates subscription ownership.
|
|
10
|
+
// --- ASA GENERATED END ---
|
|
11
|
+
|
|
12
|
+
// --- USER CODE START ---
|
|
13
|
+
|
|
14
|
+
export async function POST(request: NextRequest): Promise<NextResponse> {
|
|
15
|
+
try {
|
|
16
|
+
const { user, response } = await requireAuth(request);
|
|
17
|
+
if (response) return response;
|
|
18
|
+
|
|
19
|
+
const supabase = createAdminClient();
|
|
20
|
+
|
|
21
|
+
const { data: subscription } = await supabase
|
|
22
|
+
.from('subscriptions')
|
|
23
|
+
.select('stripe_subscription_id, status')
|
|
24
|
+
.eq('user_id', user!.id)
|
|
25
|
+
.single();
|
|
26
|
+
|
|
27
|
+
if (!subscription?.stripe_subscription_id) {
|
|
28
|
+
return NextResponse.json(
|
|
29
|
+
{ success: false, error: 'No active subscription found' },
|
|
30
|
+
{ status: 404 },
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Cancel at period end — user keeps access until billing period ends
|
|
35
|
+
await stripe.subscriptions.update(subscription.stripe_subscription_id, {
|
|
36
|
+
cancel_at_period_end: true,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Update local status
|
|
40
|
+
await supabase
|
|
41
|
+
.from('subscriptions')
|
|
42
|
+
.update({ status: 'canceling' })
|
|
43
|
+
.eq('user_id', user!.id);
|
|
44
|
+
|
|
45
|
+
return NextResponse.json({ success: true, error: null });
|
|
46
|
+
} catch (err) {
|
|
47
|
+
console.error('[billing/cancel] Error:', err);
|
|
48
|
+
return NextResponse.json(
|
|
49
|
+
{ success: false, error: 'Failed to cancel subscription' },
|
|
50
|
+
{ status: 500 },
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// --- USER CODE END ---
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"domain": "billing",
|
|
3
|
+
"slice": "cancel",
|
|
4
|
+
"type": "route",
|
|
5
|
+
"has_ui": true,
|
|
6
|
+
"has_repository": true,
|
|
7
|
+
"description": "Cancel active subscription via Stripe. Cancels at period end (not immediately).",
|
|
8
|
+
"input": {},
|
|
9
|
+
"output": {
|
|
10
|
+
"success": "boolean",
|
|
11
|
+
"error": "string | null"
|
|
12
|
+
},
|
|
13
|
+
"side_effects": [
|
|
14
|
+
"Sets Stripe subscription to cancel_at_period_end",
|
|
15
|
+
"Updates local subscription status"
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
|
|
3
|
+
// --- ASA GENERATED START ---
|
|
4
|
+
// React hook for billing/cancel slice.
|
|
5
|
+
// --- ASA GENERATED END ---
|
|
6
|
+
|
|
7
|
+
// --- USER CODE START ---
|
|
8
|
+
|
|
9
|
+
export function useCancel() {
|
|
10
|
+
const [loading, setLoading] = useState(false);
|
|
11
|
+
const [error, setError] = useState<string | null>(null);
|
|
12
|
+
const [success, setSuccess] = useState(false);
|
|
13
|
+
|
|
14
|
+
async function cancel(): Promise<boolean> {
|
|
15
|
+
setLoading(true);
|
|
16
|
+
setError(null);
|
|
17
|
+
setSuccess(false);
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetch('/api/billing/cancel', {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
headers: { 'Content-Type': 'application/json' },
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const data = await res.json();
|
|
26
|
+
|
|
27
|
+
if (!data.success) {
|
|
28
|
+
setError(data.error);
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
setSuccess(true);
|
|
33
|
+
return true;
|
|
34
|
+
} catch {
|
|
35
|
+
setError('Failed to cancel subscription. Please try again.');
|
|
36
|
+
return false;
|
|
37
|
+
} finally {
|
|
38
|
+
setLoading(false);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { cancel, loading, error, success };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// --- USER CODE END ---
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { requireAuth } from '@/shared/auth/guards';
|
|
3
|
+
import { checkEntitlement } from '@/shared/billing/entitlements';
|
|
4
|
+
|
|
5
|
+
// --- ASA GENERATED START ---
|
|
6
|
+
// Route handler for billing/check-limits slice.
|
|
7
|
+
// Returns current plan entitlements and whether user can access a specific feature.
|
|
8
|
+
// Security: requires authenticated user.
|
|
9
|
+
// --- ASA GENERATED END ---
|
|
10
|
+
|
|
11
|
+
// --- USER CODE START ---
|
|
12
|
+
|
|
13
|
+
export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
14
|
+
try {
|
|
15
|
+
const { user, response } = await requireAuth(request);
|
|
16
|
+
if (response) return response;
|
|
17
|
+
|
|
18
|
+
const url = new URL(request.url);
|
|
19
|
+
const feature = url.searchParams.get('feature');
|
|
20
|
+
|
|
21
|
+
const result = await checkEntitlement(user!.id, feature ?? undefined);
|
|
22
|
+
|
|
23
|
+
return NextResponse.json(result);
|
|
24
|
+
} catch (err) {
|
|
25
|
+
console.error('[billing/check-limits] Error:', err);
|
|
26
|
+
return NextResponse.json(
|
|
27
|
+
{ plan: 'free', entitlements: {}, can_access: false },
|
|
28
|
+
{ status: 500 },
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// --- USER CODE END ---
|