From 1ec6bd89c89c4a45320d0328d89e31df4c6009ab Mon Sep 17 00:00:00 2001 From: christiankrag Date: Sat, 13 Dec 2025 23:24:22 +0100 Subject: [PATCH] Add Stripe subscription system and n8n chat webhook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Stripe SDK and subscription management - Create checkout, webhook, and portal API routes - Add pricing page with plan cards - Create subscriptions table in Supabase - Update database types for subscriptions - Configure n8n webhook for AI chat responses 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- package-lock.json | 54 ++++++++---- package.json | 1 + src/app/(marketing)/pricing/page.tsx | 119 +++++++++++++++++++++++++++ src/app/api/stripe/checkout/route.ts | 66 +++++++++++++++ src/app/api/stripe/portal/route.ts | 38 +++++++++ src/app/api/stripe/webhook/route.ts | 103 +++++++++++++++++++++++ src/lib/stripe/client.ts | 12 +++ src/lib/stripe/config.ts | 57 +++++++++++++ src/types/database.ts | 41 +++++++++ 9 files changed, 473 insertions(+), 18 deletions(-) create mode 100644 src/app/(marketing)/pricing/page.tsx create mode 100644 src/app/api/stripe/checkout/route.ts create mode 100644 src/app/api/stripe/portal/route.ts create mode 100644 src/app/api/stripe/webhook/route.ts create mode 100644 src/lib/stripe/client.ts create mode 100644 src/lib/stripe/config.ts diff --git a/package-lock.json b/package-lock.json index 973eb2b..2992033 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "react": "19.2.1", "react-dom": "19.2.1", "sonner": "^2.0.7", + "stripe": "^20.0.0", "tailwind-merge": "^3.4.0" }, "devDependencies": { @@ -3925,7 +3926,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3939,7 +3939,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -4250,7 +4249,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -4362,7 +4360,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4372,7 +4369,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4410,7 +4406,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -5058,7 +5053,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5119,7 +5113,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -5153,7 +5146,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -5241,7 +5233,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5313,7 +5304,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5342,7 +5332,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -6343,7 +6332,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6555,7 +6543,6 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6862,6 +6849,21 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -7305,7 +7307,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7325,7 +7326,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7342,7 +7342,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -7361,7 +7360,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -7553,6 +7551,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.0.0.tgz", + "integrity": "sha512-EaZeWpbJOCcDytdjKSwdrL5BxzbDGNueiCfHjHXlPdBQvLqoxl6AAivC35SPzTmVXJb5duXQlXFGS45H0+e6Gg==", + "license": "MIT", + "dependencies": { + "qs": "^6.11.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@types/node": ">=16" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", diff --git a/package.json b/package.json index 6206d29..e368809 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "react": "19.2.1", "react-dom": "19.2.1", "sonner": "^2.0.7", + "stripe": "^20.0.0", "tailwind-merge": "^3.4.0" }, "devDependencies": { diff --git a/src/app/(marketing)/pricing/page.tsx b/src/app/(marketing)/pricing/page.tsx new file mode 100644 index 0000000..c7ff4c5 --- /dev/null +++ b/src/app/(marketing)/pricing/page.tsx @@ -0,0 +1,119 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Check, Loader2 } from 'lucide-react' +import { PLANS, type PlanKey } from '@/lib/stripe/config' + +export default function PricingPage() { + const router = useRouter() + const [loading, setLoading] = useState(null) + + const handleSubscribe = async (plan: PlanKey) => { + if (plan === 'free') { + router.push('/auth/signup') + return + } + + setLoading(plan) + try { + const res = await fetch('/api/stripe/checkout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ plan }), + }) + + const data = await res.json() + + if (data.error === 'Unauthorized') { + router.push('/auth/login?redirect=/pricing') + return + } + + if (data.url) { + window.location.href = data.url + } + } catch (error) { + console.error('Checkout error:', error) + } finally { + setLoading(null) + } + } + + return ( +
+
+
+

+ Simple, transparent pricing +

+

+ Start free, upgrade when you need more. No hidden fees. +

+
+ +
+ {(Object.entries(PLANS) as [PlanKey, typeof PLANS[PlanKey]][]).map(([key, plan]) => ( + + {key === 'pro' && ( + + Most Popular + + )} + + {plan.name} + + + ${plan.price} + + {plan.price > 0 && ( + /month + )} + + + +
    + {plan.features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+
+ + + +
+ ))} +
+ +
+

All plans include SSL encryption and 99.9% uptime SLA.

+

Questions? Contact support@mylder.io

+
+
+
+ ) +} diff --git a/src/app/api/stripe/checkout/route.ts b/src/app/api/stripe/checkout/route.ts new file mode 100644 index 0000000..3a85b16 --- /dev/null +++ b/src/app/api/stripe/checkout/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { stripe, PLANS, type PlanKey } from '@/lib/stripe/config' +import type { Database } from '@/types/database' + +type Subscription = Database['public']['Tables']['subscriptions']['Row'] + +export async function POST(request: Request) { + try { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { plan } = await request.json() as { plan: PlanKey } + const planConfig = PLANS[plan] + + if (!planConfig || !planConfig.priceId) { + return NextResponse.json({ error: 'Invalid plan' }, { status: 400 }) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: subscription } = await (supabase as any) + .from('subscriptions') + .select('stripe_customer_id') + .eq('user_id', user.id) + .single() as { data: Pick | null } + + let customerId = subscription?.stripe_customer_id + + if (!customerId) { + const customer = await stripe.customers.create({ + email: user.email, + metadata: { userId: user.id }, + }) + customerId = customer.id + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (supabase as any) + .from('subscriptions') + .upsert({ + user_id: user.id, + stripe_customer_id: customerId, + plan: 'free', + status: 'active', + }) + } + + const session = await stripe.checkout.sessions.create({ + customer: customerId, + mode: 'subscription', + payment_method_types: ['card'], + line_items: [{ price: planConfig.priceId, quantity: 1 }], + success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/dashboard?checkout=success`, + cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing?checkout=cancelled`, + metadata: { userId: user.id, plan }, + }) + + return NextResponse.json({ url: session.url }) + } catch (error) { + console.error('Checkout error:', error) + return NextResponse.json({ error: 'Failed to create checkout session' }, { status: 500 }) + } +} diff --git a/src/app/api/stripe/portal/route.ts b/src/app/api/stripe/portal/route.ts new file mode 100644 index 0000000..124fb95 --- /dev/null +++ b/src/app/api/stripe/portal/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { stripe } from '@/lib/stripe/config' +import type { Database } from '@/types/database' + +type Subscription = Database['public']['Tables']['subscriptions']['Row'] + +export async function POST() { + try { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: subscription } = await (supabase as any) + .from('subscriptions') + .select('stripe_customer_id') + .eq('user_id', user.id) + .single() as { data: Pick | null } + + if (!subscription?.stripe_customer_id) { + return NextResponse.json({ error: 'No subscription found' }, { status: 404 }) + } + + const session = await stripe.billingPortal.sessions.create({ + customer: subscription.stripe_customer_id, + return_url: `${process.env.NEXT_PUBLIC_SITE_URL}/dashboard/settings`, + }) + + return NextResponse.json({ url: session.url }) + } catch (error) { + console.error('Portal error:', error) + return NextResponse.json({ error: 'Failed to create portal session' }, { status: 500 }) + } +} diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts new file mode 100644 index 0000000..656ec8e --- /dev/null +++ b/src/app/api/stripe/webhook/route.ts @@ -0,0 +1,103 @@ +import { NextResponse } from 'next/server' +import { headers } from 'next/headers' +import { stripe } from '@/lib/stripe/config' +import { createClient } from '@supabase/supabase-js' +import type Stripe from 'stripe' + +const supabaseAdmin = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! +) + +export async function POST(request: Request) { + const body = await request.text() + const headersList = await headers() + const signature = headersList.get('stripe-signature')! + + let event: Stripe.Event + + try { + event = stripe.webhooks.constructEvent( + body, + signature, + process.env.STRIPE_WEBHOOK_SECRET! + ) + } catch (err) { + console.error('Webhook signature verification failed:', err) + return NextResponse.json({ error: 'Invalid signature' }, { status: 400 }) + } + + try { + switch (event.type) { + case 'checkout.session.completed': { + const session = event.data.object as Stripe.Checkout.Session + const userId = session.metadata?.userId + const plan = session.metadata?.plan + + if (userId && session.subscription) { + const subscription = await stripe.subscriptions.retrieve( + session.subscription as string + ) + + await supabaseAdmin + .from('subscriptions') + .upsert({ + user_id: userId, + stripe_customer_id: session.customer as string, + stripe_subscription_id: subscription.id, + plan: plan || 'pro', + status: subscription.status, + current_period_start: new Date((subscription as unknown as { current_period_start: number }).current_period_start * 1000).toISOString(), + current_period_end: new Date((subscription as unknown as { current_period_end: number }).current_period_end * 1000).toISOString(), + cancel_at_period_end: subscription.cancel_at_period_end, + }) + } + break + } + + case 'customer.subscription.updated': + case 'customer.subscription.deleted': { + const subscription = event.data.object as Stripe.Subscription + const customerId = subscription.customer as string + + const { data: existingSub } = await supabaseAdmin + .from('subscriptions') + .select('user_id') + .eq('stripe_customer_id', customerId) + .single() + + if (existingSub) { + const sub = subscription as unknown as { current_period_start: number; current_period_end: number } + await supabaseAdmin + .from('subscriptions') + .update({ + status: subscription.status, + plan: subscription.status === 'canceled' ? 'free' : undefined, + current_period_start: new Date(sub.current_period_start * 1000).toISOString(), + current_period_end: new Date(sub.current_period_end * 1000).toISOString(), + cancel_at_period_end: subscription.cancel_at_period_end, + updated_at: new Date().toISOString(), + }) + .eq('stripe_customer_id', customerId) + } + break + } + + case 'invoice.payment_failed': { + const invoice = event.data.object as Stripe.Invoice + const customerId = invoice.customer as string + + await supabaseAdmin + .from('subscriptions') + .update({ status: 'past_due', updated_at: new Date().toISOString() }) + .eq('stripe_customer_id', customerId) + break + } + } + + return NextResponse.json({ received: true }) + } catch (error) { + console.error('Webhook processing error:', error) + return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 }) + } +} diff --git a/src/lib/stripe/client.ts b/src/lib/stripe/client.ts new file mode 100644 index 0000000..f324699 --- /dev/null +++ b/src/lib/stripe/client.ts @@ -0,0 +1,12 @@ +'use client' + +import { loadStripe, type Stripe } from '@stripe/stripe-js' + +let stripePromise: Promise | null = null + +export function getStripe() { + if (!stripePromise) { + stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!) + } + return stripePromise +} diff --git a/src/lib/stripe/config.ts b/src/lib/stripe/config.ts new file mode 100644 index 0000000..861af0a --- /dev/null +++ b/src/lib/stripe/config.ts @@ -0,0 +1,57 @@ +import Stripe from 'stripe' + +const stripeSecretKey = process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder' + +export const stripe = new Stripe(stripeSecretKey) + +export const PLANS = { + free: { + name: 'Free', + price: 0, + priceId: null, + features: [ + '1 project', + '50 AI messages/month', + 'Basic templates', + 'Community support', + ], + limits: { + projects: 1, + messages: 50, + }, + }, + pro: { + name: 'Pro', + price: 19, + priceId: process.env.STRIPE_PRO_PRICE_ID, + features: [ + 'Unlimited projects', + '2,000 AI messages/month', + 'All templates', + 'Priority support', + 'API access', + ], + limits: { + projects: -1, + messages: 2000, + }, + }, + team: { + name: 'Team', + price: 49, + priceId: process.env.STRIPE_TEAM_PRICE_ID, + features: [ + 'Everything in Pro', + '10,000 AI messages/month', + 'Team collaboration', + 'Admin dashboard', + 'Custom integrations', + ], + limits: { + projects: -1, + messages: 10000, + }, + }, +} as const + +export type PlanKey = keyof typeof PLANS diff --git a/src/types/database.ts b/src/types/database.ts index 7c1d707..672c139 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -233,6 +233,47 @@ export interface Database { updated_at?: string } } + subscriptions: { + Row: { + id: string + user_id: string + stripe_customer_id: string | null + stripe_subscription_id: string | null + plan: 'free' | 'pro' | 'team' + status: 'active' | 'canceled' | 'past_due' | 'incomplete' + current_period_start: string | null + current_period_end: string | null + cancel_at_period_end: boolean + created_at: string + updated_at: string + } + Insert: { + id?: string + user_id: string + stripe_customer_id?: string | null + stripe_subscription_id?: string | null + plan?: 'free' | 'pro' | 'team' + status?: 'active' | 'canceled' | 'past_due' | 'incomplete' + current_period_start?: string | null + current_period_end?: string | null + cancel_at_period_end?: boolean + created_at?: string + updated_at?: string + } + Update: { + id?: string + user_id?: string + stripe_customer_id?: string | null + stripe_subscription_id?: string | null + plan?: 'free' | 'pro' | 'team' + status?: 'active' | 'canceled' | 'past_due' | 'incomplete' + current_period_start?: string | null + current_period_end?: string | null + cancel_at_period_end?: boolean + created_at?: string + updated_at?: string + } + } } Views: { [_ in never]: never