@vibecodiq/cli 0.5.0 → 0.6.0

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 (82) hide show
  1. package/dist/foundation/admin_basic/manifest.json +37 -0
  2. package/dist/foundation/admin_basic/migrations/004_user_roles.sql +117 -0
  3. package/dist/foundation/admin_basic/migrations/005_audit_log.sql +34 -0
  4. package/dist/foundation/admin_basic/migrations/006_impersonation_sessions.sql +22 -0
  5. package/dist/foundation/admin_basic/shared/audit.ts +97 -0
  6. package/dist/foundation/admin_basic/shared/guards.ts +58 -0
  7. package/dist/foundation/admin_basic/shared/impersonation.ts +165 -0
  8. package/dist/foundation/admin_basic/shared/permissions.ts +27 -0
  9. package/dist/foundation/admin_basic/shared/roles.ts +151 -0
  10. package/dist/foundation/admin_basic/slices/audit_log/handler.ts +34 -0
  11. package/dist/foundation/admin_basic/slices/audit_log/slice.contract.json +19 -0
  12. package/dist/foundation/admin_basic/slices/dashboard/handler.ts +51 -0
  13. package/dist/foundation/admin_basic/slices/dashboard/slice.contract.json +13 -0
  14. package/dist/foundation/admin_basic/slices/impersonation/handler.ts +61 -0
  15. package/dist/foundation/admin_basic/slices/impersonation/slice.contract.json +21 -0
  16. package/dist/foundation/admin_basic/slices/roles/handler.ts +90 -0
  17. package/dist/foundation/admin_basic/slices/roles/slice.contract.json +21 -0
  18. package/dist/foundation/admin_basic/slices/users/handler.ts +48 -0
  19. package/dist/foundation/admin_basic/slices/users/slice.contract.json +15 -0
  20. package/dist/foundation/auth_basic/manifest.json +32 -0
  21. package/dist/foundation/auth_basic/migrations/001_create_profiles.sql +36 -0
  22. package/dist/foundation/auth_basic/shared/guards.ts +89 -0
  23. package/dist/foundation/auth_basic/shared/hooks.ts +63 -0
  24. package/dist/foundation/auth_basic/shared/middleware.ts +46 -0
  25. package/dist/foundation/auth_basic/shared/server-user.ts +61 -0
  26. package/dist/foundation/auth_basic/shared/session.ts +38 -0
  27. package/dist/foundation/auth_basic/shared/types.ts +29 -0
  28. package/dist/foundation/auth_basic/slices/login/handler.ts +50 -0
  29. package/dist/foundation/auth_basic/slices/login/repository.ts +23 -0
  30. package/dist/foundation/auth_basic/slices/login/schemas.ts +22 -0
  31. package/dist/foundation/auth_basic/slices/login/slice.contract.json +19 -0
  32. package/dist/foundation/auth_basic/slices/login/ui/AuthLogin.tsx +107 -0
  33. package/dist/foundation/auth_basic/slices/login/ui/hook.ts +44 -0
  34. package/dist/foundation/auth_basic/slices/logout/handler.ts +19 -0
  35. package/dist/foundation/auth_basic/slices/logout/slice.contract.json +16 -0
  36. package/dist/foundation/auth_basic/slices/register/handler.ts +61 -0
  37. package/dist/foundation/auth_basic/slices/register/repository.ts +25 -0
  38. package/dist/foundation/auth_basic/slices/register/schemas.ts +29 -0
  39. package/dist/foundation/auth_basic/slices/register/slice.contract.json +21 -0
  40. package/dist/foundation/auth_basic/slices/register/ui/AuthRegister.tsx +118 -0
  41. package/dist/foundation/auth_basic/slices/register/ui/hook.ts +48 -0
  42. package/dist/foundation/auth_basic/slices/reset_password/handler.ts +47 -0
  43. package/dist/foundation/auth_basic/slices/reset_password/schemas.ts +21 -0
  44. package/dist/foundation/auth_basic/slices/reset_password/slice.contract.json +18 -0
  45. package/dist/foundation/auth_basic/slices/reset_password/ui/AuthResetPassword.tsx +79 -0
  46. package/dist/foundation/auth_basic/slices/reset_password/ui/hook.ts +48 -0
  47. package/dist/foundation/db_basic/manifest.json +33 -0
  48. package/dist/foundation/db_basic/shared/seed.ts +27 -0
  49. package/dist/foundation/db_basic/shared/supabase-client.ts +70 -0
  50. package/dist/foundation/db_basic/shared/types.ts +20 -0
  51. package/dist/foundation/db_basic/shared/utils.ts +43 -0
  52. package/dist/foundation/payments_basic/manifest.json +54 -0
  53. package/dist/foundation/payments_basic/migrations/002_create_subscriptions.sql +44 -0
  54. package/dist/foundation/payments_basic/migrations/003_create_entitlements.sql +54 -0
  55. package/dist/foundation/payments_basic/migrations/003b_create_webhook_events.sql +28 -0
  56. package/dist/foundation/payments_basic/shared/entitlement-hooks.ts +50 -0
  57. package/dist/foundation/payments_basic/shared/entitlement-types.ts +29 -0
  58. package/dist/foundation/payments_basic/shared/entitlements.ts +78 -0
  59. package/dist/foundation/payments_basic/shared/guards.ts +110 -0
  60. package/dist/foundation/payments_basic/shared/hooks.ts +45 -0
  61. package/dist/foundation/payments_basic/shared/plans.ts +54 -0
  62. package/dist/foundation/payments_basic/shared/reconciliation.ts +85 -0
  63. package/dist/foundation/payments_basic/shared/resolver.ts +61 -0
  64. package/dist/foundation/payments_basic/shared/stripe-client.ts +15 -0
  65. package/dist/foundation/payments_basic/shared/types.ts +84 -0
  66. package/dist/foundation/payments_basic/shared/webhook-handler.ts +198 -0
  67. package/dist/foundation/payments_basic/shared/webhook-processor.ts +174 -0
  68. package/dist/foundation/payments_basic/slices/cancel/handler.ts +55 -0
  69. package/dist/foundation/payments_basic/slices/cancel/slice.contract.json +17 -0
  70. package/dist/foundation/payments_basic/slices/cancel/ui/hook.ts +45 -0
  71. package/dist/foundation/payments_basic/slices/check_limits/handler.ts +33 -0
  72. package/dist/foundation/payments_basic/slices/check_limits/slice.contract.json +17 -0
  73. package/dist/foundation/payments_basic/slices/subscribe/handler.ts +79 -0
  74. package/dist/foundation/payments_basic/slices/subscribe/repository.ts +32 -0
  75. package/dist/foundation/payments_basic/slices/subscribe/schemas.ts +21 -0
  76. package/dist/foundation/payments_basic/slices/subscribe/slice.contract.json +20 -0
  77. package/dist/foundation/payments_basic/slices/subscribe/ui/BillingSubscribe.tsx +93 -0
  78. package/dist/foundation/payments_basic/slices/subscribe/ui/hook.ts +44 -0
  79. package/dist/foundation/payments_basic/slices/webhook/handler.ts +67 -0
  80. package/dist/foundation/payments_basic/slices/webhook/slice.contract.json +19 -0
  81. package/dist/index.js +20 -18
  82. 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 ---