@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,43 @@
1
+ // --- ASA GENERATED START ---
2
+ // Database utilities — pagination, filtering, error handling.
3
+ // --- ASA GENERATED END ---
4
+
5
+ // --- USER CODE START ---
6
+
7
+ export interface PaginationParams {
8
+ page?: number;
9
+ pageSize?: number;
10
+ }
11
+
12
+ export interface PaginatedResult<T> {
13
+ data: T[];
14
+ total: number;
15
+ page: number;
16
+ pageSize: number;
17
+ totalPages: number;
18
+ }
19
+
20
+ export function paginate<T>(
21
+ data: T[],
22
+ total: number,
23
+ params: PaginationParams = {},
24
+ ): PaginatedResult<T> {
25
+ const page = params.page ?? 1;
26
+ const pageSize = params.pageSize ?? 20;
27
+ return {
28
+ data,
29
+ total,
30
+ page,
31
+ pageSize,
32
+ totalPages: Math.ceil(total / pageSize),
33
+ };
34
+ }
35
+
36
+ export function handleDbError(error: unknown): never {
37
+ if (error instanceof Error) {
38
+ throw new Error(`Database error: ${error.message}`);
39
+ }
40
+ throw new Error('Unknown database error');
41
+ }
42
+
43
+ // --- USER CODE END ---
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "payments-basic",
3
+ "version": "1.0.0",
4
+ "description": "Basic Stripe billing for SaaS apps — subscriptions, webhooks, plan limits",
5
+ "min_cli_version": "2.0.0",
6
+ "stack": "asa-native-v1",
7
+ "dependencies": ["db-basic", "auth-basic"],
8
+ "package_dependencies": {
9
+ "stripe": "^17.0.0"
10
+ },
11
+ "package_dev_dependencies": {},
12
+ "slices": [
13
+ { "domain": "billing", "name": "subscribe", "type": "route", "has_ui": true, "has_repository": true },
14
+ { "domain": "billing", "name": "cancel", "type": "route", "has_ui": true, "has_repository": true },
15
+ { "domain": "billing", "name": "webhook", "type": "webhook", "has_ui": false, "has_repository": true },
16
+ { "domain": "billing", "name": "check-limits", "type": "route", "has_ui": true, "has_repository": true }
17
+ ],
18
+ "shared": [
19
+ { "src": "shared/stripe-client.ts", "dest": "shared/billing/stripe-client.ts", "overwrite": false },
20
+ { "src": "shared/plans.ts", "dest": "shared/billing/plans.ts", "overwrite": false },
21
+ { "src": "shared/hooks.ts", "dest": "shared/billing/hooks.ts", "overwrite": false },
22
+ { "src": "shared/types.ts", "dest": "shared/billing/types.ts", "overwrite": false },
23
+ { "src": "shared/webhook-handler.ts", "dest": "shared/billing/webhook-handler.ts", "overwrite": false },
24
+ { "src": "shared/entitlements.ts", "dest": "shared/billing/entitlements.ts", "overwrite": false },
25
+ { "src": "shared/webhook-processor.ts", "dest": "shared/billing/webhook-processor.ts", "overwrite": false },
26
+ { "src": "shared/reconciliation.ts", "dest": "shared/billing/reconciliation.ts", "overwrite": false },
27
+ { "src": "shared/guards.ts", "dest": "shared/entitlements/guards.ts", "overwrite": false },
28
+ { "src": "shared/resolver.ts", "dest": "shared/entitlements/resolver.ts", "overwrite": false },
29
+ { "src": "shared/entitlement-types.ts", "dest": "shared/entitlements/types.ts", "overwrite": false },
30
+ { "src": "shared/entitlement-hooks.ts", "dest": "shared/entitlements/hooks.ts", "overwrite": false }
31
+ ],
32
+ "migrations": [
33
+ { "src": "migrations/002_create_subscriptions.sql", "dest": "shared/db/migrations/002_create_subscriptions.sql" },
34
+ { "src": "migrations/003_create_entitlements.sql", "dest": "shared/db/migrations/003_create_entitlements.sql" },
35
+ { "src": "migrations/003b_create_webhook_events.sql", "dest": "shared/db/migrations/003b_create_webhook_events.sql" }
36
+ ],
37
+ "env_vars": [
38
+ "STRIPE_SECRET_KEY",
39
+ "STRIPE_WEBHOOK_SECRET",
40
+ "NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY",
41
+ "STRIPE_PRO_PRICE_ID"
42
+ ],
43
+ "post_install_notes": [
44
+ "Create a Stripe account (test mode) at https://stripe.com",
45
+ "Create Products + Prices in Stripe Dashboard",
46
+ "Set up webhook endpoint in Stripe Dashboard → Developers → Webhooks",
47
+ "Copy keys to .env.local",
48
+ "Run migration: Apply shared/db/migrations/002_create_subscriptions.sql in Supabase SQL Editor",
49
+ "Run migration: Apply shared/db/migrations/003_create_entitlements.sql in Supabase SQL Editor",
50
+ "Seed plan_features table with your plan configuration",
51
+ "Use requireEntitlement() and requireLimit() guards in your route handlers",
52
+ "Run: npm install"
53
+ ]
54
+ }
@@ -0,0 +1,44 @@
1
+ -- ASA Module: payments-basic
2
+ -- Migration: 002_create_subscriptions
3
+ -- Creates subscriptions table for Stripe billing
4
+
5
+ -- Subscription state machine: active, past_due, trialing, incomplete, canceled, unpaid
6
+ -- IMPORTANT: past_due = "still has access" (Stripe retrying payment)
7
+ create table if not exists subscriptions (
8
+ id uuid primary key default gen_random_uuid(),
9
+ user_id uuid references profiles(id) on delete cascade not null,
10
+ stripe_customer_id text,
11
+ stripe_subscription_id text,
12
+ plan text not null default 'free',
13
+ status text not null default 'active'
14
+ check (status in ('active', 'past_due', 'trialing', 'incomplete', 'canceled', 'unpaid')),
15
+ current_period_end timestamptz,
16
+ cancel_at_period_end boolean not null default false,
17
+ created_at timestamptz default now(),
18
+ updated_at timestamptz default now()
19
+ );
20
+
21
+ -- RLS policies (correct WITH CHECK for all operations)
22
+ alter table subscriptions enable row level security;
23
+
24
+ create policy "Users can view own subscription"
25
+ on subscriptions for select
26
+ using (auth.uid() = user_id);
27
+
28
+ -- No direct insert/update from client — managed by webhook handler via admin client
29
+
30
+ -- Webhook event idempotency table
31
+ create table if not exists webhook_events (
32
+ event_id text primary key,
33
+ event_type text not null,
34
+ processed_at timestamptz default now()
35
+ );
36
+
37
+ -- No RLS on webhook_events — accessed only via admin client (server-side)
38
+ alter table webhook_events enable row level security;
39
+ -- No policies = deny all client access (correct — admin client bypasses RLS)
40
+
41
+ -- Indexes
42
+ create index if not exists idx_subscriptions_user_id on subscriptions(user_id);
43
+ create index if not exists idx_subscriptions_stripe_customer on subscriptions(stripe_customer_id);
44
+ create index if not exists idx_webhook_events_type on webhook_events(event_type);
@@ -0,0 +1,54 @@
1
+ -- ASA Module: payments-basic (entitlement architecture)
2
+ -- Migration: 003_create_entitlements
3
+ -- Creates deterministic entitlement tables for feature/limit checks.
4
+ --
5
+ -- Usage:
6
+ -- 1. Apply this migration in Supabase SQL Editor
7
+ -- 2. Seed plan_features with your plan configuration
8
+ -- 3. Use requireEntitlement() and requireLimit() guards in route handlers
9
+
10
+ -- Plan → feature/limit mapping (seed data, admin-editable)
11
+ create table if not exists plan_features (
12
+ id uuid primary key default gen_random_uuid(),
13
+ plan_key text not null,
14
+ feature_key text not null,
15
+ feature_type text not null check (feature_type in ('boolean', 'number')),
16
+ value text not null,
17
+ unique(plan_key, feature_key)
18
+ );
19
+
20
+ -- Materialized entitlements per user (source of truth for app)
21
+ create table if not exists entitlements (
22
+ id uuid primary key default gen_random_uuid(),
23
+ user_id uuid not null references auth.users(id) on delete cascade,
24
+ feature_key text not null,
25
+ feature_type text not null check (feature_type in ('boolean', 'number')),
26
+ value text not null,
27
+ source text not null default 'plan' check (source in ('plan', 'override')),
28
+ created_at timestamptz not null default now(),
29
+ updated_at timestamptz not null default now(),
30
+ unique(user_id, feature_key)
31
+ );
32
+
33
+ -- RLS: users can only read their own entitlements
34
+ alter table entitlements enable row level security;
35
+
36
+ create policy "Users can read own entitlements"
37
+ on entitlements for select
38
+ using (auth.uid() = user_id);
39
+
40
+ -- No direct insert/update from client — managed by resolver via admin client
41
+
42
+ -- RLS: plan_features readable by all authenticated users (public pricing info)
43
+ alter table plan_features enable row level security;
44
+
45
+ create policy "Anyone can read plan features"
46
+ on plan_features for select
47
+ using (true);
48
+
49
+ -- No direct insert/update from client — managed by admin
50
+
51
+ -- Indexes
52
+ create index if not exists idx_entitlements_user_id on entitlements(user_id);
53
+ create index if not exists idx_entitlements_feature on entitlements(user_id, feature_key);
54
+ create index if not exists idx_plan_features_plan on plan_features(plan_key);
@@ -0,0 +1,28 @@
1
+ -- ASA Foundation: webhook_events table for Postgres inbox pattern
2
+ -- Stripe webhook events are stored here with status tracking.
3
+ -- A scheduled processor picks 'pending' events and processes them asynchronously.
4
+
5
+ CREATE TABLE IF NOT EXISTS public.webhook_events (
6
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
7
+ stripe_event_id TEXT NOT NULL UNIQUE,
8
+ type TEXT NOT NULL,
9
+ payload JSONB NOT NULL DEFAULT '{}',
10
+ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'processed', 'failed')),
11
+ attempt_count INTEGER NOT NULL DEFAULT 0,
12
+ locked_at TIMESTAMPTZ,
13
+ last_error TEXT,
14
+ received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
15
+ processed_at TIMESTAMPTZ,
16
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
17
+ );
18
+
19
+ -- Index for processor: find pending events efficiently
20
+ CREATE INDEX IF NOT EXISTS idx_webhook_events_status ON public.webhook_events (status) WHERE status = 'pending';
21
+
22
+ -- Index for deduplication lookup
23
+ CREATE INDEX IF NOT EXISTS idx_webhook_events_stripe_id ON public.webhook_events (stripe_event_id);
24
+
25
+ -- RLS: webhook_events should only be accessible by service_role (backend)
26
+ ALTER TABLE public.webhook_events ENABLE ROW LEVEL SECURITY;
27
+
28
+ -- No RLS policies = only service_role/admin can access (which is correct for webhooks)
@@ -0,0 +1,50 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import { createBrowserClient } from '@/shared/db/supabase-client';
5
+
6
+ // --- ASA GENERATED START ---
7
+ // Entitlement hooks for client components.
8
+ // --- ASA GENERATED END ---
9
+
10
+ // --- USER CODE START ---
11
+
12
+ /**
13
+ * Get all entitlements for current user.
14
+ */
15
+ export function useEntitlements(): Map<string, string> {
16
+ const [entitlements, setEntitlements] = useState<Map<string, string>>(
17
+ new Map(),
18
+ );
19
+
20
+ useEffect(() => {
21
+ const supabase = createBrowserClient();
22
+ supabase.auth.getUser().then(({ data: { user } }) => {
23
+ if (!user) return;
24
+ supabase
25
+ .from('entitlements')
26
+ .select('feature_key, value')
27
+ .eq('user_id', user.id)
28
+ .then(({ data }) => {
29
+ const map = new Map<string, string>();
30
+ (data ?? []).forEach(
31
+ (row: { feature_key: string; value: string }) =>
32
+ map.set(row.feature_key, row.value),
33
+ );
34
+ setEntitlements(map);
35
+ });
36
+ });
37
+ }, []);
38
+
39
+ return entitlements;
40
+ }
41
+
42
+ /**
43
+ * Check if current user has a boolean feature.
44
+ */
45
+ export function useHasFeature(featureKey: string): boolean {
46
+ const entitlements = useEntitlements();
47
+ return entitlements.get(featureKey) === 'true';
48
+ }
49
+
50
+ // --- USER CODE END ---
@@ -0,0 +1,29 @@
1
+ // --- ASA GENERATED START ---
2
+ // Entitlement types for deterministic feature/limit architecture.
3
+ // --- ASA GENERATED END ---
4
+
5
+ // --- USER CODE START ---
6
+
7
+ export type EntitlementSource = 'plan' | 'override';
8
+ export type FeatureType = 'boolean' | 'number';
9
+
10
+ export interface EntitlementRow {
11
+ id: string;
12
+ user_id: string;
13
+ feature_key: string;
14
+ feature_type: FeatureType;
15
+ value: string;
16
+ source: EntitlementSource;
17
+ created_at: string;
18
+ updated_at: string;
19
+ }
20
+
21
+ export interface PlanFeatureRow {
22
+ id: string;
23
+ plan_key: string;
24
+ feature_key: string;
25
+ feature_type: FeatureType;
26
+ value: string;
27
+ }
28
+
29
+ // --- USER CODE END ---
@@ -0,0 +1,78 @@
1
+ import { createAdminClient } from '@/shared/db/supabase-client';
2
+ import { PLANS } from '@/shared/billing/plans';
3
+
4
+ // --- ASA GENERATED START ---
5
+ // Entitlement engine — single source of truth for plan-based access control.
6
+ // Entitlements are separated from raw Stripe subscription status.
7
+ // Always fetch current state — never trust cached/stale data for access decisions.
8
+ // --- ASA GENERATED END ---
9
+
10
+ // --- USER CODE START ---
11
+
12
+ export interface EntitlementResult {
13
+ plan: string;
14
+ status: string;
15
+ entitlements: Record<string, boolean | number>;
16
+ can_access: boolean;
17
+ }
18
+
19
+ /**
20
+ * Check what the user is entitled to based on their current subscription.
21
+ * Optionally check a specific feature.
22
+ *
23
+ * Dunning-safe: users in 'past_due' status retain read access
24
+ * but lose write/create capabilities.
25
+ */
26
+ export async function checkEntitlement(
27
+ userId: string,
28
+ feature?: string,
29
+ ): Promise<EntitlementResult> {
30
+ const supabase = createAdminClient();
31
+
32
+ // Always fetch current state from DB
33
+ const { data: subscription } = await supabase
34
+ .from('subscriptions')
35
+ .select('plan, status')
36
+ .eq('user_id', userId)
37
+ .single();
38
+
39
+ const plan = subscription?.plan ?? 'free';
40
+ const status = subscription?.status ?? 'active';
41
+
42
+ // Find plan definition
43
+ const planDef = PLANS.find((p) => p.id === plan);
44
+ const entitlements: Record<string, boolean | number> = {};
45
+
46
+ if (planDef?.limits) {
47
+ for (const [key, value] of Object.entries(planDef.limits)) {
48
+ entitlements[key] = value;
49
+ }
50
+ }
51
+
52
+ // Dunning-safe access: past_due users keep read access but lose create/write
53
+ const isDunning = status === 'past_due';
54
+ let canAccess = status === 'active' || status === 'trialing' || isDunning;
55
+
56
+ if (feature && planDef?.limits) {
57
+ const limit = planDef.limits[feature];
58
+ if (limit === undefined || limit === false || limit === 0) {
59
+ canAccess = false;
60
+ }
61
+ // During dunning, block write-type features
62
+ if (isDunning && feature.startsWith('create_') || isDunning && feature.startsWith('write_')) {
63
+ canAccess = false;
64
+ }
65
+ }
66
+
67
+ return { plan, status, entitlements, can_access: canAccess };
68
+ }
69
+
70
+ /**
71
+ * Quick guard: can user access a specific feature?
72
+ */
73
+ export async function canAccess(userId: string, feature: string): Promise<boolean> {
74
+ const result = await checkEntitlement(userId, feature);
75
+ return result.can_access;
76
+ }
77
+
78
+ // --- USER CODE END ---
@@ -0,0 +1,110 @@
1
+ import { createServerClient } from '@/shared/db/supabase-client';
2
+
3
+ // --- ASA GENERATED START ---
4
+ // Entitlement guard API for deterministic feature/limit checks.
5
+ // Use these guards in route handlers instead of inline plan checks.
6
+ //
7
+ // Example:
8
+ // await requireEntitlement(userId, "csv_export");
9
+ // await requireLimit(userId, "max_invoices_per_month", currentCount);
10
+ // --- ASA GENERATED END ---
11
+
12
+ // --- USER CODE START ---
13
+
14
+ /**
15
+ * Check if user has a boolean entitlement.
16
+ * Throws 403 Response if not entitled.
17
+ */
18
+ export async function requireEntitlement(
19
+ userId: string,
20
+ featureKey: string,
21
+ ): Promise<void> {
22
+ const entitled = await hasEntitlement(userId, featureKey);
23
+ if (!entitled) {
24
+ throw new Response(
25
+ JSON.stringify({
26
+ error: 'FORBIDDEN',
27
+ message: `Entitlement required: ${featureKey}`,
28
+ }),
29
+ { status: 403, headers: { 'Content-Type': 'application/json' } },
30
+ );
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Check if user is within a numeric limit.
36
+ * Throws 403 Response if currentCount >= limit.
37
+ */
38
+ export async function requireLimit(
39
+ userId: string,
40
+ limitKey: string,
41
+ currentCount: number,
42
+ ): Promise<void> {
43
+ const limit = await getLimit(userId, limitKey);
44
+ if (currentCount >= limit) {
45
+ throw new Response(
46
+ JSON.stringify({
47
+ error: 'LIMIT_EXCEEDED',
48
+ message: `Limit exceeded: ${limitKey} (${currentCount}/${limit})`,
49
+ }),
50
+ { status: 403, headers: { 'Content-Type': 'application/json' } },
51
+ );
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Check entitlement without throwing (for conditional UI or soft checks).
57
+ */
58
+ export async function hasEntitlement(
59
+ userId: string,
60
+ featureKey: string,
61
+ ): Promise<boolean> {
62
+ const supabase = createServerClient();
63
+ const { data } = await supabase
64
+ .from('entitlements')
65
+ .select('value')
66
+ .eq('user_id', userId)
67
+ .eq('feature_key', featureKey)
68
+ .single();
69
+
70
+ return data?.value === 'true';
71
+ }
72
+
73
+ /**
74
+ * Get numeric limit value for a user. Returns 0 if not found.
75
+ */
76
+ export async function getLimit(
77
+ userId: string,
78
+ limitKey: string,
79
+ ): Promise<number> {
80
+ const supabase = createServerClient();
81
+ const { data } = await supabase
82
+ .from('entitlements')
83
+ .select('value')
84
+ .eq('user_id', userId)
85
+ .eq('feature_key', limitKey)
86
+ .single();
87
+
88
+ return data ? parseInt(data.value, 10) : 0;
89
+ }
90
+
91
+ /**
92
+ * Get all entitlements for a user (for UI display or bulk checks).
93
+ */
94
+ export async function getEntitlements(
95
+ userId: string,
96
+ ): Promise<Map<string, string>> {
97
+ const supabase = createServerClient();
98
+ const { data } = await supabase
99
+ .from('entitlements')
100
+ .select('feature_key, value')
101
+ .eq('user_id', userId);
102
+
103
+ const map = new Map<string, string>();
104
+ (data ?? []).forEach((row: { feature_key: string; value: string }) =>
105
+ map.set(row.feature_key, row.value),
106
+ );
107
+ return map;
108
+ }
109
+
110
+ // --- USER CODE END ---
@@ -0,0 +1,45 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import { createBrowserClient } from '@/shared/db/supabase-client';
5
+ import type { Subscription, PlanLimitCheck } from './types';
6
+ import { getPlan } from './plans';
7
+
8
+ // --- ASA GENERATED START ---
9
+ // Billing hooks for client components.
10
+ // --- ASA GENERATED END ---
11
+
12
+ // --- USER CODE START ---
13
+
14
+ export function useSubscription() {
15
+ const [subscription, setSubscription] = useState<Subscription | null>(null);
16
+ const [loading, setLoading] = useState(true);
17
+
18
+ useEffect(() => {
19
+ const supabase = createBrowserClient();
20
+ supabase
21
+ .from('subscriptions')
22
+ .select('*')
23
+ .single()
24
+ .then(({ data }) => {
25
+ setSubscription(data as Subscription | null);
26
+ setLoading(false);
27
+ });
28
+ }, []);
29
+
30
+ const plan = subscription ? getPlan(subscription.plan) : getPlan('free');
31
+
32
+ return { subscription, plan, loading };
33
+ }
34
+
35
+ export function usePlanLimits() {
36
+ const { plan, loading } = useSubscription();
37
+
38
+ return {
39
+ limits: plan.limits,
40
+ planName: plan.name,
41
+ loading,
42
+ };
43
+ }
44
+
45
+ // --- USER CODE END ---
@@ -0,0 +1,54 @@
1
+ // --- ASA GENERATED START ---
2
+ // Plan definitions for payments-basic module.
3
+ // --- ASA GENERATED END ---
4
+
5
+ // --- USER CODE START ---
6
+
7
+ export type PlanId = 'free' | 'pro';
8
+
9
+ export interface PlanDefinition {
10
+ id: PlanId;
11
+ name: string;
12
+ price: number;
13
+ stripePriceId: string | null;
14
+ limits: {
15
+ monthlyItems: number;
16
+ };
17
+ features: string[];
18
+ }
19
+
20
+ export const PLANS: Record<PlanId, PlanDefinition> = {
21
+ free: {
22
+ id: 'free',
23
+ name: 'Free',
24
+ price: 0,
25
+ stripePriceId: null,
26
+ limits: {
27
+ monthlyItems: 5,
28
+ },
29
+ features: [
30
+ 'Up to 5 items per month',
31
+ 'Basic features',
32
+ ],
33
+ },
34
+ pro: {
35
+ id: 'pro',
36
+ name: 'Pro',
37
+ price: 29,
38
+ stripePriceId: process.env.STRIPE_PRO_PRICE_ID || '',
39
+ limits: {
40
+ monthlyItems: Infinity,
41
+ },
42
+ features: [
43
+ 'Unlimited items',
44
+ 'Priority support',
45
+ 'Advanced features',
46
+ ],
47
+ },
48
+ };
49
+
50
+ export function getPlan(planId: string): PlanDefinition {
51
+ return PLANS[planId as PlanId] ?? PLANS.free;
52
+ }
53
+
54
+ // --- USER CODE END ---
@@ -0,0 +1,85 @@
1
+ import { createAdminClient } from '@/shared/db/supabase-client';
2
+ import { stripe } from '@/shared/billing/stripe-client';
3
+
4
+ // --- ASA GENERATED START ---
5
+ // Reconciliation job template — identifies and repairs drift between Stripe and local DB.
6
+ // Run periodically (e.g., daily via cron) to catch missed webhooks or state inconsistencies.
7
+ // --- ASA GENERATED END ---
8
+
9
+ // --- USER CODE START ---
10
+
11
+ export interface ReconciliationResult {
12
+ checked: number;
13
+ drifted: number;
14
+ repaired: number;
15
+ errors: string[];
16
+ }
17
+
18
+ /**
19
+ * Reconcile local subscription state with Stripe.
20
+ * Fetches all active Stripe subscriptions and compares with local DB.
21
+ * Repairs any drift found.
22
+ */
23
+ export async function reconcileSubscriptions(): Promise<ReconciliationResult> {
24
+ const supabase = createAdminClient();
25
+ const result: ReconciliationResult = { checked: 0, drifted: 0, repaired: 0, errors: [] };
26
+
27
+ try {
28
+ // Get all local subscriptions with Stripe customer IDs
29
+ const { data: localSubs } = await supabase
30
+ .from('subscriptions')
31
+ .select('user_id, stripe_customer_id, stripe_subscription_id, plan, status')
32
+ .not('stripe_customer_id', 'is', null);
33
+
34
+ if (!localSubs) return result;
35
+
36
+ for (const local of localSubs) {
37
+ result.checked++;
38
+
39
+ try {
40
+ if (!local.stripe_subscription_id) continue;
41
+
42
+ // Fetch current state from Stripe (source of truth)
43
+ const stripeSub = await stripe.subscriptions.retrieve(local.stripe_subscription_id);
44
+
45
+ // Compare status
46
+ const stripeStatus = mapStripeStatus(stripeSub.status);
47
+ if (local.status !== stripeStatus) {
48
+ result.drifted++;
49
+
50
+ await supabase
51
+ .from('subscriptions')
52
+ .update({ status: stripeStatus })
53
+ .eq('user_id', local.user_id);
54
+
55
+ result.repaired++;
56
+ console.log(
57
+ `[reconciliation] Repaired drift for user ${local.user_id}: ` +
58
+ `${local.status} → ${stripeStatus}`,
59
+ );
60
+ }
61
+ } catch (err) {
62
+ result.errors.push(
63
+ `User ${local.user_id}: ${err instanceof Error ? err.message : String(err)}`,
64
+ );
65
+ }
66
+ }
67
+ } catch (err) {
68
+ result.errors.push(`Fatal: ${err instanceof Error ? err.message : String(err)}`);
69
+ }
70
+
71
+ return result;
72
+ }
73
+
74
+ function mapStripeStatus(stripeStatus: string): string {
75
+ switch (stripeStatus) {
76
+ case 'active': return 'active';
77
+ case 'trialing': return 'trialing';
78
+ case 'past_due': return 'past_due';
79
+ case 'canceled': return 'canceled';
80
+ case 'unpaid': return 'past_due';
81
+ default: return stripeStatus;
82
+ }
83
+ }
84
+
85
+ // --- USER CODE END ---