vibefast-cli 1.3.4 → 1.3.5

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 (44) hide show
  1. package/README.md +1 -1
  2. package/dist/commands/add.d.ts.map +1 -1
  3. package/dist/commands/add.js +35 -14
  4. package/dist/commands/add.js.map +1 -1
  5. package/dist/commands/init.d.ts.map +1 -1
  6. package/dist/commands/init.js +0 -5
  7. package/dist/commands/init.js.map +1 -1
  8. package/docs/commands.md +2 -2
  9. package/docs/quickstart.md +1 -1
  10. package/package.json +1 -1
  11. package/recipes/lemonsqueezy-supabase/apps/web/app/billing/page.tsx +48 -0
  12. package/recipes/lemonsqueezy-supabase/apps/web/app/pricing/page.tsx +19 -0
  13. package/recipes/lemonsqueezy-supabase/apps/web/components/payments/BillingSection.tsx +224 -0
  14. package/recipes/lemonsqueezy-supabase/apps/web/components/payments/PricingCard.tsx +87 -0
  15. package/recipes/lemonsqueezy-supabase/apps/web/components/payments/PricingSection.tsx +100 -0
  16. package/recipes/lemonsqueezy-supabase/apps/web/components/payments/index.ts +2 -0
  17. package/recipes/lemonsqueezy-supabase/apps/web/lib/plans.ts +84 -0
  18. package/recipes/lemonsqueezy-supabase/packages/backend/supabase/functions/payments/config.ts +92 -0
  19. package/recipes/lemonsqueezy-supabase/packages/backend/supabase/functions/payments/index.ts +138 -0
  20. package/recipes/lemonsqueezy-supabase/packages/backend/supabase/functions/payments/providers/lemonsqueezy.ts +165 -0
  21. package/recipes/lemonsqueezy-supabase/packages/backend/supabase/functions/payments/providers/stripe.ts +179 -0
  22. package/recipes/lemonsqueezy-supabase/packages/backend/supabase/functions/payments/types.ts +57 -0
  23. package/recipes/lemonsqueezy-supabase/packages/backend/supabase/functions/payments-webhook/index.ts +339 -0
  24. package/recipes/lemonsqueezy-supabase/packages/backend/supabase/migrations/web_payments.sql +85 -0
  25. package/recipes/lemonsqueezy-supabase/recipe.json +129 -0
  26. package/recipes/lemonsqueezy-supabase-web@latest.zip +0 -0
  27. package/recipes/lemonsqueezy-web@latest.zip +0 -0
  28. package/recipes/stripe-supabase/apps/web/app/billing/page.tsx +48 -0
  29. package/recipes/stripe-supabase/apps/web/app/pricing/page.tsx +19 -0
  30. package/recipes/stripe-supabase/apps/web/components/payments/BillingSection.tsx +224 -0
  31. package/recipes/stripe-supabase/apps/web/components/payments/PricingCard.tsx +87 -0
  32. package/recipes/stripe-supabase/apps/web/components/payments/PricingSection.tsx +100 -0
  33. package/recipes/stripe-supabase/apps/web/components/payments/index.ts +2 -0
  34. package/recipes/stripe-supabase/apps/web/lib/plans.ts +84 -0
  35. package/recipes/stripe-supabase/packages/backend/supabase/functions/payments/config.ts +92 -0
  36. package/recipes/stripe-supabase/packages/backend/supabase/functions/payments/index.ts +138 -0
  37. package/recipes/stripe-supabase/packages/backend/supabase/functions/payments/providers/lemonsqueezy.ts +165 -0
  38. package/recipes/stripe-supabase/packages/backend/supabase/functions/payments/providers/stripe.ts +179 -0
  39. package/recipes/stripe-supabase/packages/backend/supabase/functions/payments/types.ts +57 -0
  40. package/recipes/stripe-supabase/packages/backend/supabase/functions/payments-webhook/index.ts +339 -0
  41. package/recipes/stripe-supabase/packages/backend/supabase/migrations/web_payments.sql +85 -0
  42. package/recipes/stripe-supabase/recipe.json +123 -0
  43. package/recipes/stripe-supabase-web@latest.zip +0 -0
  44. package/recipes/stripe-web@latest.zip +0 -0
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Supabase Payments Edge Function
3
+ *
4
+ * Unified entry point for authenticated payment actions:
5
+ * - checkout
6
+ * - portal
7
+ * - subscription
8
+ * - cancel
9
+ * - payments
10
+ */
11
+
12
+ import 'jsr:@supabase/functions-js/edge-runtime.d.ts';
13
+ import { createClient } from 'npm:@supabase/supabase-js@2';
14
+ import { getPaymentProvider } from './config.ts';
15
+ import { stripeProvider } from './providers/stripe.ts';
16
+ import { lemonsqueezyProvider } from './providers/lemonsqueezy.ts';
17
+ import type { PaymentProviderApi } from './types.ts';
18
+
19
+ const corsHeaders = {
20
+ 'Access-Control-Allow-Origin': '*',
21
+ 'Access-Control-Allow-Headers':
22
+ 'authorization, x-client-info, apikey, content-type',
23
+ 'Access-Control-Allow-Methods': 'POST, OPTIONS',
24
+ };
25
+
26
+ const jsonResponse = (body: unknown, status = 200) =>
27
+ new Response(JSON.stringify(body), {
28
+ status,
29
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
30
+ });
31
+
32
+ const getEnv = (key: string, fallback?: string) => {
33
+ const value = Deno.env.get(key) ?? fallback;
34
+ if (!value) {
35
+ throw new Error(`Missing environment variable: ${key}`);
36
+ }
37
+ return value;
38
+ };
39
+
40
+ Deno.serve(async (req: Request) => {
41
+ if (req.method === 'OPTIONS') {
42
+ return new Response('ok', { headers: corsHeaders });
43
+ }
44
+
45
+ if (req.method !== 'POST') {
46
+ return jsonResponse({ ok: false, message: 'Method not allowed' }, 405);
47
+ }
48
+
49
+ try {
50
+ const authHeader = req.headers.get('Authorization');
51
+ if (!authHeader) {
52
+ return jsonResponse({ ok: false, message: 'Missing authorization header' }, 401);
53
+ }
54
+
55
+ const token = authHeader.replace('Bearer ', '');
56
+ const supabaseUrl = getEnv('SUPABASE_URL');
57
+ const supabaseAnonKey =
58
+ Deno.env.get('SUPABASE_ANON_KEY') ??
59
+ Deno.env.get('SUPABASE_PUBLISHABLE_KEY');
60
+ const supabaseSecretKey =
61
+ Deno.env.get('SUPABASE_SECRET_KEY') ??
62
+ Deno.env.get('SUPABASE_SERVICE_ROLE_KEY');
63
+
64
+ if (!supabaseAnonKey || !supabaseSecretKey) {
65
+ throw new Error('Supabase keys are not configured');
66
+ }
67
+
68
+ const supabase = createClient(supabaseUrl, supabaseAnonKey, {
69
+ global: { headers: { Authorization: `Bearer ${token}` } },
70
+ });
71
+ const supabaseAdmin = createClient(supabaseUrl, supabaseSecretKey);
72
+
73
+ const {
74
+ data: { user },
75
+ error: authError,
76
+ } = await supabase.auth.getUser();
77
+
78
+ if (authError || !user) {
79
+ return jsonResponse({ ok: false, message: 'Invalid or expired token' }, 401);
80
+ }
81
+
82
+ const body = await req.json();
83
+ const action = body?.action as string | undefined;
84
+
85
+ if (!action) {
86
+ return jsonResponse({ ok: false, message: 'Missing action' }, 400);
87
+ }
88
+
89
+ const provider = getPaymentProvider();
90
+ const api: PaymentProviderApi =
91
+ provider === 'stripe'
92
+ ? stripeProvider({ supabaseAdmin, user })
93
+ : lemonsqueezyProvider({ supabaseAdmin, user });
94
+
95
+ switch (action) {
96
+ case 'checkout': {
97
+ const priceId = body?.priceId;
98
+ if (!priceId) {
99
+ return jsonResponse({ ok: false, message: 'priceId is required' }, 400);
100
+ }
101
+ const result = await api.createCheckout({
102
+ priceId,
103
+ mode: body?.mode,
104
+ successUrl: body?.successUrl,
105
+ cancelUrl: body?.cancelUrl,
106
+ });
107
+ return jsonResponse({ ok: true, ...result });
108
+ }
109
+ case 'portal': {
110
+ const result = await api.createPortalSession({
111
+ returnUrl: body?.returnUrl,
112
+ });
113
+ return jsonResponse({ ok: true, ...result });
114
+ }
115
+ case 'subscription': {
116
+ const subscription = await api.getSubscription();
117
+ return jsonResponse({ ok: true, subscription });
118
+ }
119
+ case 'cancel': {
120
+ const subscriptionId = body?.subscriptionId;
121
+ if (!subscriptionId) {
122
+ return jsonResponse({ ok: false, message: 'subscriptionId is required' }, 400);
123
+ }
124
+ await api.cancelSubscription({ subscriptionId });
125
+ return jsonResponse({ ok: true });
126
+ }
127
+ case 'payments': {
128
+ const payments = await api.getPayments();
129
+ return jsonResponse({ ok: true, payments });
130
+ }
131
+ default:
132
+ return jsonResponse({ ok: false, message: 'Unknown action' }, 400);
133
+ }
134
+ } catch (err) {
135
+ const message = err instanceof Error ? err.message : 'Unexpected error';
136
+ return jsonResponse({ ok: false, message }, 500);
137
+ }
138
+ });
@@ -0,0 +1,165 @@
1
+ import {
2
+ createCheckout as lsCreateCheckout,
3
+ cancelSubscription as lsCancelSubscription,
4
+ lemonSqueezySetup,
5
+ } from 'npm:@lemonsqueezy/lemonsqueezy.js';
6
+ import { createClient } from 'npm:@supabase/supabase-js@2';
7
+ import { getCheckoutUrls, getPlanByPriceId } from '../config.ts';
8
+ import type { PaymentProviderApi, Subscription } from '../types.ts';
9
+
10
+ type SupabaseClient = ReturnType<typeof createClient>;
11
+
12
+ type ProviderContext = {
13
+ supabaseAdmin: SupabaseClient;
14
+ user: {
15
+ id: string;
16
+ email?: string | null;
17
+ user_metadata?: Record<string, unknown>;
18
+ };
19
+ };
20
+
21
+ const initLemonSqueezy = () => {
22
+ const apiKey = Deno.env.get('LEMONSQUEEZY_API_KEY');
23
+ if (!apiKey) {
24
+ throw new Error('LEMONSQUEEZY_API_KEY is not configured');
25
+ }
26
+ lemonSqueezySetup({ apiKey });
27
+ };
28
+
29
+ const getCustomerName = (user: ProviderContext['user']) => {
30
+ const metadata = user.user_metadata ?? {};
31
+ return (
32
+ (metadata['full_name'] as string | undefined) ||
33
+ (metadata['name'] as string | undefined) ||
34
+ undefined
35
+ );
36
+ };
37
+
38
+ const mapSubscription = (row: any): Subscription | null => {
39
+ if (!row) return null;
40
+ const activeStatuses = ['active', 'trialing', 'on_trial'];
41
+ if (!activeStatuses.includes(row.status)) {
42
+ return null;
43
+ }
44
+
45
+ const plan = getPlanByPriceId(row.price_id ?? '');
46
+ const toUnix = (value: string | null) =>
47
+ value ? Math.floor(new Date(value).getTime() / 1000) : undefined;
48
+
49
+ return {
50
+ id: row.provider_subscription_id,
51
+ provider: 'lemonsqueezy',
52
+ status: row.status === 'on_trial' ? 'trialing' : row.status,
53
+ planId: plan?.id ?? '',
54
+ priceId: row.price_id ?? undefined,
55
+ currentPeriodEnd: toUnix(row.current_period_end),
56
+ cancelAt: toUnix(row.cancel_at),
57
+ cancelAtPeriodEnd: row.cancel_at_period_end ?? undefined,
58
+ };
59
+ };
60
+
61
+ export const lemonsqueezyProvider = ({
62
+ supabaseAdmin,
63
+ user,
64
+ }: ProviderContext): PaymentProviderApi => {
65
+ return {
66
+ async createCheckout(args) {
67
+ initLemonSqueezy();
68
+
69
+ const storeId = Deno.env.get('LEMONSQUEEZY_STORE_ID');
70
+ if (!storeId) {
71
+ throw new Error('LEMONSQUEEZY_STORE_ID is not configured');
72
+ }
73
+
74
+ const urls = getCheckoutUrls(args.successUrl, args.cancelUrl);
75
+
76
+ const { data, error } = await lsCreateCheckout(storeId, args.priceId, {
77
+ checkoutData: {
78
+ email: user.email ?? undefined,
79
+ name: getCustomerName(user),
80
+ custom: {
81
+ user_id: user.id,
82
+ },
83
+ },
84
+ productOptions: {
85
+ redirectUrl: urls.successUrl,
86
+ },
87
+ });
88
+
89
+ if (error) {
90
+ throw new Error(`LemonSqueezy checkout error: ${error.message}`);
91
+ }
92
+
93
+ const url = data?.data?.attributes?.url;
94
+ if (!url) {
95
+ throw new Error('No checkout URL returned from LemonSqueezy');
96
+ }
97
+
98
+ return { url };
99
+ },
100
+
101
+ async createPortalSession(args) {
102
+ const { data, error } = await supabaseAdmin
103
+ .from('subscriptions')
104
+ .select('*')
105
+ .eq('user_id', user.id)
106
+ .eq('provider', 'lemonsqueezy')
107
+ .order('created_at', { ascending: false })
108
+ .limit(1)
109
+ .maybeSingle();
110
+
111
+ if (error) {
112
+ throw new Error(`Failed to read subscription: ${error.message}`);
113
+ }
114
+
115
+ const portalUrl = data?.metadata?.customerPortalUrl as
116
+ | string
117
+ | undefined;
118
+
119
+ return {
120
+ url: portalUrl ?? args.returnUrl ?? `${Deno.env.get('SITE_URL')}/billing`,
121
+ };
122
+ },
123
+
124
+ async getSubscription() {
125
+ const { data, error } = await supabaseAdmin
126
+ .from('subscriptions')
127
+ .select('*')
128
+ .eq('user_id', user.id)
129
+ .eq('provider', 'lemonsqueezy')
130
+ .order('created_at', { ascending: false })
131
+ .limit(1)
132
+ .maybeSingle();
133
+
134
+ if (error) {
135
+ throw new Error(`Failed to read subscription: ${error.message}`);
136
+ }
137
+
138
+ return mapSubscription(data);
139
+ },
140
+
141
+ async cancelSubscription({ subscriptionId }) {
142
+ initLemonSqueezy();
143
+ const { error } = await lsCancelSubscription(subscriptionId);
144
+ if (error) {
145
+ throw new Error(`Failed to cancel subscription: ${error.message}`);
146
+ }
147
+ return true;
148
+ },
149
+
150
+ async getPayments() {
151
+ const { data, error } = await supabaseAdmin
152
+ .from('orders')
153
+ .select('*')
154
+ .eq('user_id', user.id)
155
+ .eq('provider', 'lemonsqueezy')
156
+ .order('created_at', { ascending: false });
157
+
158
+ if (error) {
159
+ throw new Error(`Failed to read payments: ${error.message}`);
160
+ }
161
+
162
+ return data ?? [];
163
+ },
164
+ };
165
+ };
@@ -0,0 +1,179 @@
1
+ import Stripe from 'npm:stripe@14.25.0';
2
+ import { createClient } from 'npm:@supabase/supabase-js@2';
3
+ import { getCheckoutUrls, getPlanByPriceId } from '../config.ts';
4
+ import type { PaymentProviderApi, Subscription } from '../types.ts';
5
+
6
+ type SupabaseClient = ReturnType<typeof createClient>;
7
+
8
+ type ProviderContext = {
9
+ supabaseAdmin: SupabaseClient;
10
+ user: {
11
+ id: string;
12
+ email?: string | null;
13
+ user_metadata?: Record<string, unknown>;
14
+ };
15
+ };
16
+
17
+ const getStripeClient = () => {
18
+ const secretKey = Deno.env.get('STRIPE_SECRET_KEY');
19
+ if (!secretKey) {
20
+ throw new Error('STRIPE_SECRET_KEY is not configured');
21
+ }
22
+ return new Stripe(secretKey, {
23
+ apiVersion: '2023-10-16',
24
+ });
25
+ };
26
+
27
+ const getCustomerName = (user: ProviderContext['user']) => {
28
+ const metadata = user.user_metadata ?? {};
29
+ const name =
30
+ (metadata['full_name'] as string | undefined) ||
31
+ (metadata['name'] as string | undefined) ||
32
+ undefined;
33
+ return name;
34
+ };
35
+
36
+ const getOrCreateCustomer = async (
37
+ stripe: Stripe,
38
+ supabaseAdmin: SupabaseClient,
39
+ user: ProviderContext['user'],
40
+ ) => {
41
+ const { data: existing, error } = await supabaseAdmin
42
+ .from('payment_customers')
43
+ .select('provider_customer_id')
44
+ .eq('user_id', user.id)
45
+ .eq('provider', 'stripe')
46
+ .maybeSingle();
47
+
48
+ if (error) {
49
+ throw new Error(`Failed to query customer mapping: ${error.message}`);
50
+ }
51
+
52
+ if (existing?.provider_customer_id) {
53
+ return existing.provider_customer_id;
54
+ }
55
+
56
+ const customer = await stripe.customers.create({
57
+ email: user.email ?? undefined,
58
+ name: getCustomerName(user),
59
+ metadata: { user_id: user.id },
60
+ });
61
+
62
+ const { error: insertError } = await supabaseAdmin
63
+ .from('payment_customers')
64
+ .insert({
65
+ user_id: user.id,
66
+ provider: 'stripe',
67
+ provider_customer_id: customer.id,
68
+ });
69
+
70
+ if (insertError) {
71
+ throw new Error(`Failed to store customer mapping: ${insertError.message}`);
72
+ }
73
+
74
+ return customer.id;
75
+ };
76
+
77
+ const mapSubscription = (row: any): Subscription | null => {
78
+ if (!row) return null;
79
+ const activeStatuses = ['active', 'trialing'];
80
+ if (!activeStatuses.includes(row.status)) {
81
+ return null;
82
+ }
83
+
84
+ const plan = getPlanByPriceId(row.price_id ?? '');
85
+ const toUnix = (value: string | null) =>
86
+ value ? Math.floor(new Date(value).getTime() / 1000) : undefined;
87
+
88
+ return {
89
+ id: row.provider_subscription_id,
90
+ provider: 'stripe',
91
+ status: row.status,
92
+ planId: plan?.id ?? '',
93
+ priceId: row.price_id ?? undefined,
94
+ currentPeriodEnd: toUnix(row.current_period_end),
95
+ cancelAt: toUnix(row.cancel_at),
96
+ cancelAtPeriodEnd: row.cancel_at_period_end ?? undefined,
97
+ };
98
+ };
99
+
100
+ export const stripeProvider = ({
101
+ supabaseAdmin,
102
+ user,
103
+ }: ProviderContext): PaymentProviderApi => {
104
+ return {
105
+ async createCheckout(args) {
106
+ const stripe = getStripeClient();
107
+ const customerId = await getOrCreateCustomer(stripe, supabaseAdmin, user);
108
+ const urls = getCheckoutUrls(args.successUrl, args.cancelUrl);
109
+
110
+ const session = await stripe.checkout.sessions.create({
111
+ mode: args.mode ?? 'subscription',
112
+ customer: customerId,
113
+ line_items: [{ price: args.priceId, quantity: 1 }],
114
+ success_url: urls.successUrl,
115
+ cancel_url: urls.cancelUrl,
116
+ subscription_data: { metadata: { user_id: user.id } },
117
+ metadata: { user_id: user.id },
118
+ });
119
+
120
+ if (!session.url) {
121
+ throw new Error('No checkout URL returned from Stripe');
122
+ }
123
+
124
+ return { url: session.url, sessionId: session.id };
125
+ },
126
+
127
+ async createPortalSession(args) {
128
+ const stripe = getStripeClient();
129
+ const customerId = await getOrCreateCustomer(stripe, supabaseAdmin, user);
130
+
131
+ const result = await stripe.billingPortal.sessions.create({
132
+ customer: customerId,
133
+ return_url: args.returnUrl ?? `${Deno.env.get('SITE_URL')}/billing`,
134
+ });
135
+
136
+ return { url: result.url };
137
+ },
138
+
139
+ async getSubscription() {
140
+ const { data, error } = await supabaseAdmin
141
+ .from('subscriptions')
142
+ .select('*')
143
+ .eq('user_id', user.id)
144
+ .eq('provider', 'stripe')
145
+ .order('created_at', { ascending: false })
146
+ .limit(1)
147
+ .maybeSingle();
148
+
149
+ if (error) {
150
+ throw new Error(`Failed to read subscription: ${error.message}`);
151
+ }
152
+
153
+ return mapSubscription(data);
154
+ },
155
+
156
+ async cancelSubscription({ subscriptionId }) {
157
+ const stripe = getStripeClient();
158
+ await stripe.subscriptions.update(subscriptionId, {
159
+ cancel_at_period_end: true,
160
+ });
161
+ return true;
162
+ },
163
+
164
+ async getPayments() {
165
+ const { data, error } = await supabaseAdmin
166
+ .from('orders')
167
+ .select('*')
168
+ .eq('user_id', user.id)
169
+ .eq('provider', 'stripe')
170
+ .order('created_at', { ascending: false });
171
+
172
+ if (error) {
173
+ throw new Error(`Failed to read payments: ${error.message}`);
174
+ }
175
+
176
+ return data ?? [];
177
+ },
178
+ };
179
+ };
@@ -0,0 +1,57 @@
1
+ export type PaymentProvider = 'stripe' | 'lemonsqueezy';
2
+
3
+ export type SubscriptionStatus =
4
+ | 'active'
5
+ | 'trialing'
6
+ | 'past_due'
7
+ | 'canceled'
8
+ | 'expired'
9
+ | 'paused'
10
+ | 'unpaid';
11
+
12
+ export type BillingInterval = 'month' | 'year' | 'one_time';
13
+
14
+ export interface Plan {
15
+ id: string;
16
+ name: string;
17
+ description?: string;
18
+ price: string;
19
+ priceId: string;
20
+ interval: BillingInterval;
21
+ features: string[];
22
+ popular?: boolean;
23
+ }
24
+
25
+ export interface Subscription {
26
+ id: string;
27
+ provider: PaymentProvider;
28
+ status: SubscriptionStatus | string;
29
+ planId: string;
30
+ priceId?: string;
31
+ currentPeriodEnd?: number;
32
+ cancelAt?: number;
33
+ cancelAtPeriodEnd?: boolean;
34
+ createdAt?: number;
35
+ }
36
+
37
+ export interface CheckoutResult {
38
+ url: string;
39
+ sessionId?: string;
40
+ }
41
+
42
+ export interface PortalResult {
43
+ url: string;
44
+ }
45
+
46
+ export interface PaymentProviderApi {
47
+ createCheckout: (args: {
48
+ priceId: string;
49
+ mode?: 'subscription' | 'payment';
50
+ successUrl?: string;
51
+ cancelUrl?: string;
52
+ }) => Promise<CheckoutResult>;
53
+ createPortalSession: (args: { returnUrl?: string }) => Promise<PortalResult>;
54
+ getSubscription: () => Promise<Subscription | null>;
55
+ cancelSubscription: (args: { subscriptionId: string }) => Promise<boolean>;
56
+ getPayments: () => Promise<any[]>;
57
+ }