diff --git a/.env.example b/.env.example index 7097841..5c33859 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,9 @@ GOOGLE_SECRET="YOUR_GOOGLE_SECRET" GITHUB_CLIENT_ID="YOUR_GITHUB_CLIENT_ID" GITHUB_SECRET="YOUR_GITHUB_SECRET" + +STRIPE_SECRET_KEY="YOUR_STRIPE_SECRET_KEY" +STRIPE_WEBHOOK_SIGNING_SECRET="YOUR_STRIPE_WEBHOOK_SIGNING_SECRET" +POSTHOG_CLIENT_KEY="YOUR_POSTHOG_CLIENT_KEY" +POSTMARK_SERVER_TOKEN="YOUR_POSTMARK_SERVER_TOKEN" +POSTMARK_FROM_EMAIL="YOUR_POSTMARK_FROM_EMAIL" diff --git a/flutter/README.md b/flutter/README.md index 672ab78..f065c6f 100644 --- a/flutter/README.md +++ b/flutter/README.md @@ -23,7 +23,14 @@ cd flutter flutter run -d chrome --dart-define-from-file=env.json ``` +## Pricing + +You would normally put your pricing page on your landing page. Below are links to try out how the pricing works in the app. + +- [Hobby Plan ($10 / month)](https://flutter.devtodollars.com/payments?price=price_1Pdy8yFttF99a1NCLpDa83xf) +- [Freelancer Plan ($20 / month)](https://flutter.devtodollars.com/payments?price=price_1Pdy8zFttF99a1NCGQJc5ZTZ) + ## Stack - State Management ([riverpod](https://pub.dev/packages/riverpod)) -- Routing ([go\_router](https://pub.dev/packages/go\_router)) +- Routing ([go_router](https://pub.dev/packages/go_router)) diff --git a/flutter/env.json b/flutter/env.json index d981e86..0c29bd7 100644 --- a/flutter/env.json +++ b/flutter/env.json @@ -1,4 +1,4 @@ { - "SUPABASE_URL": "https://crnytzptlghehxsarjxm.supabase.co", - "SUPABASE_ANON_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNybnl0enB0bGdoZWh4c2FyanhtIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDkyMjQxNjgsImV4cCI6MjAyNDgwMDE2OH0.UW1dHRt4hGF6uCdPXimxv0Ggwq5uJ1WoQuCZ1_ixmCU" + "SUPABASE_URL": "https://ibyxomczmyaptsjgzvzi.supabase.co", + "SUPABASE_ANON_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImlieXhvbWN6bXlhcHRzamd6dnppIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MjEzMDk0OTUsImV4cCI6MjAzNjg4NTQ5NX0.DOhoJJ3V7KJFjglNftgDDQwMNrB3oiWtf21BfRcB2Cc" } diff --git a/flutter/lib/components/email_form.dart b/flutter/lib/components/email_form.dart index ccf8be7..db1c947 100644 --- a/flutter/lib/components/email_form.dart +++ b/flutter/lib/components/email_form.dart @@ -78,7 +78,7 @@ class _EmailFormState extends ConsumerState { builder: (_) => AlertDialog( title: const Text("Check your Email!"), content: const Text( - "We sent an email from pleasereply@devtodollars.com to verify your email"), + "We sent an email from hi@devtodollars.com to verify your email"), actions: [ TextButton( onPressed: context.pop, child: const Text("Ok Matt.")) diff --git a/flutter/lib/models/app_user.dart b/flutter/lib/models/app_user.dart deleted file mode 100644 index 3c0cd31..0000000 --- a/flutter/lib/models/app_user.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:supabase_flutter/supabase_flutter.dart'; - -class AppUser { - Session session; - AuthChangeEvent? authEvent; - List activeProducts; - String? stripeCustomerId; - - AppUser({ - required this.session, - this.authEvent, - this.activeProducts = const [], - this.stripeCustomerId, - }); -} diff --git a/flutter/lib/models/stripe.dart b/flutter/lib/models/stripe.dart new file mode 100644 index 0000000..547ae17 --- /dev/null +++ b/flutter/lib/models/stripe.dart @@ -0,0 +1,186 @@ +class SubscriptionWithPrice { + final String? cancelAt; + final bool? cancelAtPeriodEnd; + final String? canceledAt; + final String created; + final String currentPeriodEnd; + final String currentPeriodStart; + final String? endedAt; + final String id; + final Map? metadata; + final String? priceId; + final int? quantity; + final String? status; + final String? trialEnd; + final String? trialStart; + final String userId; + final PriceWithProduct? prices; + + SubscriptionWithPrice({ + this.cancelAt, + this.cancelAtPeriodEnd, + this.canceledAt, + required this.created, + required this.currentPeriodEnd, + required this.currentPeriodStart, + this.endedAt, + required this.id, + this.metadata, + this.priceId, + this.quantity, + this.status, + this.trialEnd, + this.trialStart, + required this.userId, + this.prices, + }); + + factory SubscriptionWithPrice.fromJson(Map json) { + return SubscriptionWithPrice( + cancelAt: json['cancel_at'], + cancelAtPeriodEnd: json['cancel_at_period_end'], + canceledAt: json['canceled_at'], + created: json['created'], + currentPeriodEnd: json['current_period_end'], + currentPeriodStart: json['current_period_start'], + endedAt: json['ended_at'], + id: json['id'], + metadata: json['metadata'], + priceId: json['price_id'], + quantity: json['quantity'], + status: json['status'], + trialEnd: json['trial_end'], + trialStart: json['trial_start'], + userId: json['user_id'], + prices: json['prices'] != null + ? PriceWithProduct.fromJson(json['prices']) + : null, + ); + } + + Map toJson() { + return { + 'cancel_at': cancelAt, + 'cancel_at_period_end': cancelAtPeriodEnd, + 'canceled_at': canceledAt, + 'created': created, + 'current_period_end': currentPeriodEnd, + 'current_period_start': currentPeriodStart, + 'ended_at': endedAt, + 'id': id, + 'metadata': metadata, + 'price_id': priceId, + 'quantity': quantity, + 'status': status, + 'trial_end': trialEnd, + 'trial_start': trialStart, + 'user_id': userId, + 'prices': prices?.toJson(), + }; + } +} + +class PriceWithProduct { + final bool? active; + final String? currency; + final String? description; + final String id; + final String? interval; + final int? intervalCount; + final Map? metadata; + final String? productId; + final int? trialPeriodDays; + final String? type; + final int? unitAmount; + final Product? products; + + PriceWithProduct({ + this.active, + this.currency, + this.description, + required this.id, + this.interval, + this.intervalCount, + this.metadata, + this.productId, + this.trialPeriodDays, + this.type, + this.unitAmount, + this.products, + }); + + factory PriceWithProduct.fromJson(Map json) { + return PriceWithProduct( + active: json['active'], + currency: json['currency'], + description: json['description'], + id: json['id'], + interval: json['interval'], + intervalCount: json['interval_count'], + metadata: json['metadata'], + productId: json['product_id'], + trialPeriodDays: json['trial_period_days'], + type: json['type'], + unitAmount: json['unit_amount'], + products: + json['products'] != null ? Product.fromJson(json['products']) : null, + ); + } + + Map toJson() { + return { + 'active': active, + 'currency': currency, + 'description': description, + 'id': id, + 'interval': interval, + 'interval_count': intervalCount, + 'metadata': metadata, + 'product_id': productId, + 'trial_period_days': trialPeriodDays, + 'type': type, + 'unit_amount': unitAmount, + 'products': products?.toJson(), + }; + } +} + +class Product { + final bool? active; + final String? description; + final String id; + final String? image; + final Map? metadata; + final String? name; + + Product({ + this.active, + this.description, + required this.id, + this.image, + this.metadata, + this.name, + }); + + factory Product.fromJson(Map json) { + return Product( + active: json['active'], + description: json['description'], + id: json['id'], + image: json['image'], + metadata: json['metadata'], + name: json['name'], + ); + } + + Map toJson() { + return { + 'active': active, + 'description': description, + 'id': id, + 'image': image, + 'metadata': metadata, + 'name': name, + }; + } +} diff --git a/flutter/lib/models/user_metadata.dart b/flutter/lib/models/user_metadata.dart new file mode 100644 index 0000000..680f34a --- /dev/null +++ b/flutter/lib/models/user_metadata.dart @@ -0,0 +1,43 @@ +import 'package:devtodollars/models/stripe.dart'; + +class UserMetadata { + final String? avatarUrl; + final Map? billingAddress; + final String? fullName; + final String id; + final Map? paymentMethod; + SubscriptionWithPrice? subscription; + + UserMetadata({ + this.avatarUrl, + this.billingAddress, + this.fullName, + required this.id, + this.paymentMethod, + this.subscription, + }); + + factory UserMetadata.fromJson(Map json) { + return UserMetadata( + avatarUrl: json['avatar_url'], + billingAddress: json['billing_address'], + fullName: json['full_name'], + id: json['id'], + paymentMethod: json['payment_method'], + subscription: json['subscription'] != null + ? SubscriptionWithPrice.fromJson(json['subscription']) + : null, + ); + } + + Map toJson() { + return { + 'avatar_url': avatarUrl, + 'billing_address': billingAddress, + 'full_name': fullName, + 'id': id, + 'payment_method': paymentMethod, + 'subscription': subscription?.toJson(), + }; + } +} diff --git a/flutter/lib/screens/auth_screen.dart b/flutter/lib/screens/auth_screen.dart index d80fd1f..b91850d 100644 --- a/flutter/lib/screens/auth_screen.dart +++ b/flutter/lib/screens/auth_screen.dart @@ -22,7 +22,7 @@ class _AuthScreenState extends State { builder: (_) => AlertDialog( title: const Text("Check your Email!"), content: const Text( - "We sent an email from pleasereply@devtodollars.com to verify your email"), + "We sent an email from hi@devtodollars.com to verify your email"), actions: [ TextButton(onPressed: context.pop, child: const Text("Ok Matt.")) ], diff --git a/flutter/lib/screens/home_screen.dart b/flutter/lib/screens/home_screen.dart index afc6d63..88ec23d 100644 --- a/flutter/lib/screens/home_screen.dart +++ b/flutter/lib/screens/home_screen.dart @@ -1,7 +1,9 @@ +import 'package:devtodollars/services/metadata_notifier.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:devtodollars/services/auth_notifier.dart'; +import 'package:url_launcher/url_launcher.dart'; class HomeScreen extends ConsumerStatefulWidget { const HomeScreen({super.key, required this.title}); @@ -13,17 +15,12 @@ class HomeScreen extends ConsumerStatefulWidget { } class _HomeScreenState extends ConsumerState { - int _counter = 0; - - void _incrementCounter() { - setState(() { - _counter++; - }); - } - @override Widget build(BuildContext context) { final authNotif = ref.watch(authProvider.notifier); + final metaAsync = ref.watch(metadataProvider); + final pricingUrl = Uri.parse( + "https://github.com/devtodollars/mvp-boilerplate/blob/main/flutter/README.md"); return Scaffold( appBar: AppBar( backgroundColor: Theme.of(context).colorScheme.inversePrimary, @@ -40,21 +37,24 @@ class _HomeScreenState extends ConsumerState { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Text( - 'You have pushed the button this many times:', + metaAsync.when( + data: (metadata) { + final subscription = metadata?.subscription; + return (Text(subscription != null + ? "You are currently on the ${subscription.prices?.products?.name} plan." + : "You are not currently subscribed to any plan.")); + }, + loading: () => const CircularProgressIndicator(), + error: (_, __) => const Text("Failed to load subscription plan"), ), - Text( - '$_counter', - style: Theme.of(context).textTheme.headlineMedium, + const SizedBox(height: 8), + TextButton( + onPressed: () => launchUrl(pricingUrl), + child: const Text("See Pricing"), ), ], ), ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: const Icon(Icons.add), - ), ); } } diff --git a/flutter/lib/services/auth_notifier.dart b/flutter/lib/services/auth_notifier.dart index 026e0da..38aefb2 100644 --- a/flutter/lib/services/auth_notifier.dart +++ b/flutter/lib/services/auth_notifier.dart @@ -4,7 +4,6 @@ import 'package:flutter/foundation.dart'; import 'package:posthog_flutter/posthog_flutter.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:supabase_flutter/supabase_flutter.dart' as supa; -import 'package:devtodollars/models/app_user.dart'; // ignore: depend_on_referenced_packages import 'package:path/path.dart' as p; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -13,25 +12,24 @@ part 'auth_notifier.g.dart'; @Riverpod(keepAlive: true) class Auth extends _$Auth { - final StreamController authStateController = + final StreamController authStateController = StreamController.broadcast(); bool cancelled = false; Auth(); @override - Stream build() { + Stream build() { final streamSub = client.auth.onAuthStateChange.listen((authState) async { - final appUser = await refreshUser(authState); + final session = authState.session; + authStateController.add(session); // capture posthog events for analytics - if (appUser != null) { - await Posthog() - .identify(userId: appUser.session.user.id, userProperties: { - "email": appUser.session.user.email ?? "", - "active_products": appUser.activeProducts, - "stripe_customer_id": appUser.stripeCustomerId ?? "", - }); + if (session != null) { + await Posthog().identify( + userId: session.user.id, + userProperties: {"email": session.user.email ?? ""}, + ); } else { await Posthog().reset(); } @@ -47,28 +45,6 @@ class Auth extends _$Auth { supa.SupabaseClient get client => supa.Supabase.instance.client; supa.Session? get currentSession => client.auth.currentSession; - Future refreshUser(supa.AuthState state) async { - final session = state.session; - if (session == null) { - authStateController.add(null); - return null; - } - - final metadata = await client - .from("stripe") - .select() - .eq("user_id", session.user.id) - .maybeSingle(); - final user = AppUser( - session: session, - authEvent: state.event, - activeProducts: List.from(metadata?["active_products"] ?? []), - stripeCustomerId: metadata?["stripe_customer_id"], - ); - authStateController.add(user); - return user; - } - Future signInWithPassword(String email, String password) async { await client.auth.signInWithPassword(password: password, email: email); } diff --git a/flutter/lib/services/auth_notifier.g.dart b/flutter/lib/services/auth_notifier.g.dart index 430274e..c01dbe5 100644 --- a/flutter/lib/services/auth_notifier.g.dart +++ b/flutter/lib/services/auth_notifier.g.dart @@ -6,11 +6,11 @@ part of 'auth_notifier.dart'; // RiverpodGenerator // ************************************************************************** -String _$authHash() => r'2b64916ea86e7033381c692acae85ca734580866'; +String _$authHash() => r'380cdf0e9cb795d8efc7749bb092ffaa8add2170'; /// See also [Auth]. @ProviderFor(Auth) -final authProvider = StreamNotifierProvider.internal( +final authProvider = StreamNotifierProvider.internal( Auth.new, name: r'authProvider', debugGetCreateSourceHash: @@ -19,6 +19,6 @@ final authProvider = StreamNotifierProvider.internal( allTransitiveDependencies: null, ); -typedef _$Auth = StreamNotifier; +typedef _$Auth = StreamNotifier; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/flutter/lib/services/metadata_notifier.dart b/flutter/lib/services/metadata_notifier.dart new file mode 100644 index 0000000..2f786d6 --- /dev/null +++ b/flutter/lib/services/metadata_notifier.dart @@ -0,0 +1,36 @@ +import 'package:devtodollars/models/stripe.dart'; +import 'package:devtodollars/models/user_metadata.dart'; +import 'package:devtodollars/services/auth_notifier.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +part 'metadata_notifier.g.dart'; + +@riverpod +class Metadata extends _$Metadata { + SupabaseClient get client => Supabase.instance.client; + + @override + Future build() async { + final session = ref.watch(authProvider).value; + if (session == null) return null; + + final (metadata, subscription) = + await (getUserDetails(), getSubscription()).wait; + metadata.subscription = subscription; + return metadata; + } + + Future getUserDetails() async { + final res = await client.from('users').select('*').single(); + return UserMetadata.fromJson(res); + } + + Future getSubscription() async { + final res = await client + .from('subscriptions') + .select('*, prices(*, products(*))') + .inFilter('status', ['trialing', 'active']).maybeSingle(); + return (res == null) ? null : SubscriptionWithPrice.fromJson(res); + } +} diff --git a/flutter/lib/services/metadata_notifier.g.dart b/flutter/lib/services/metadata_notifier.g.dart new file mode 100644 index 0000000..690c258 --- /dev/null +++ b/flutter/lib/services/metadata_notifier.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'metadata_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$metadataHash() => r'a05e6979d5e0fa8712bffdb0e387e293d3baa29e'; + +/// See also [Metadata]. +@ProviderFor(Metadata) +final metadataProvider = + AutoDisposeAsyncNotifierProvider.internal( + Metadata.new, + name: r'metadataProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$metadataHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$Metadata = AutoDisposeAsyncNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/flutter/lib/services/router_notifier.g.dart b/flutter/lib/services/router_notifier.g.dart index 80096c1..c32ee3b 100644 --- a/flutter/lib/services/router_notifier.g.dart +++ b/flutter/lib/services/router_notifier.g.dart @@ -6,7 +6,7 @@ part of 'router_notifier.dart'; // RiverpodGenerator // ************************************************************************** -String _$routerHash() => r'a274451f3ef44165ae4e01188b8459c71d2e724f'; +String _$routerHash() => r'dc9cd41bfae46ac7a7e6e0ce63360dd290feaa42'; /// See also [router]. @ProviderFor(router) diff --git a/nextjs/.env.example b/nextjs/.env.example index b22a7ee..f80f93d 100644 --- a/nextjs/.env.example +++ b/nextjs/.env.example @@ -1,8 +1,8 @@ # example .env file using devtodollars backend NEXT_PUBLIC_SITE_URL="http://localhost:3000" -NEXT_PUBLIC_SUPABASE_URL="https://crnytzptlghehxsarjxm.supabase.co" -NEXT_PUBLIC_SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNybnl0enB0bGdoZWh4c2FyanhtIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDkyMjQxNjgsImV4cCI6MjAyNDgwMDE2OH0.UW1dHRt4hGF6uCdPXimxv0Ggwq5uJ1WoQuCZ1_ixmCU" - -NEXT_PUBLIC_TEST_SUBSCRIPTION_PRICE="price_1PUbF5FttF99a1NCuHGLaTGS" +NEXT_PUBLIC_SUPABASE_URL="https://ibyxomczmyaptsjgzvzi.supabase.co" +NEXT_PUBLIC_SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImlieXhvbWN6bXlhcHRzamd6dnppIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MjEzMDk0OTUsImV4cCI6MjAzNjg4NTQ5NX0.DOhoJJ3V7KJFjglNftgDDQwMNrB3oiWtf21BfRcB2Cc" +NEXT_PUBLIC_POSTHOG_HOST="https://app.posthog.com" +NEXT_PUBLIC_POSTHOG_KEY="phc_brFjanMrA2KXPYyvFjLdwJC9VJvl4VG7mG9yKqvgCCY" diff --git a/nextjs/.env.local.example b/nextjs/.env.local.example index f21c04e..bd13909 100644 --- a/nextjs/.env.local.example +++ b/nextjs/.env.local.example @@ -4,9 +4,6 @@ NEXT_PUBLIC_SITE_URL="http://localhost:3000" NEXT_PUBLIC_SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" NEXT_PUBLIC_SUPABASE_URL="http://127.0.0.1:54321" -# stripe env variables -NEXT_PUBLIC_STRIPE_PREMIUM_SUPPORT_PRICE_ID="price_1P3MEnFttF99a1NCjNcCLLvA" - # posthog env variables NEXT_PUBLIC_POSTHOG_HOST="https://app.posthog.com" NEXT_PUBLIC_POSTHOG_KEY="phc_brFjanMrA2KXPYyvFjLdwJC9VJvl4VG7mG9yKqvgCCY" diff --git a/nextjs/app/account/page.tsx b/nextjs/app/account/page.tsx index 54dcabe..eb94f8a 100644 --- a/nextjs/app/account/page.tsx +++ b/nextjs/app/account/page.tsx @@ -1,17 +1,18 @@ import AccountPage from '@/components/misc/AccountPage'; import { createClient } from '@/utils/supabase/server'; import { redirect } from 'next/navigation'; +import { getSubscription, getUser } from '@/utils/supabase/queries'; export default async function Account() { const supabase = createClient(); - - const { - data: { user } - } = await supabase.auth.getUser(); + const [user, subscription] = await Promise.all([ + getUser(supabase), + getSubscription(supabase) + ]); if (!user) { return redirect('/auth/signin'); } - return ; + return ; } diff --git a/nextjs/app/auth/page.tsx b/nextjs/app/auth/page.tsx deleted file mode 100644 index 35365ac..0000000 --- a/nextjs/app/auth/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from 'next/navigation'; - -export default function SignIn() { - return redirect(`/auth/signin`); -} diff --git a/nextjs/app/page.tsx b/nextjs/app/page.tsx index 7764f5f..8bf7ec9 100644 --- a/nextjs/app/page.tsx +++ b/nextjs/app/page.tsx @@ -15,7 +15,7 @@ import { Team } from '@/components/landing/Team'; import { Testimonials } from '@/components/landing/Testimonials'; import { createClient } from '@/utils/supabase/server'; -export default async function PricingPage() { +export default async function LandingPage() { const supabase = createClient(); const { diff --git a/nextjs/components/landing/Pricing.tsx b/nextjs/components/landing/Pricing.tsx index b885021..986d3a0 100644 --- a/nextjs/components/landing/Pricing.tsx +++ b/nextjs/components/landing/Pricing.tsx @@ -44,19 +44,20 @@ const pricingList: PricingProps[] = [ benefitList: [ '1 Team member', '2 GB Storage', - 'Upto 4 pages', + 'Up to 4 pages', 'Community support', 'lorem ipsum dolor' - ] + ], + redirectURL: '/account' }, { - id: process.env.NEXT_PUBLIC_TEST_SUBSCRIPTION_PRICE, - title: 'Premium', + id: 'price_1Pdy8yFttF99a1NCLpDa83xf', + title: 'Hobby', popular: 1, - price: 5, + price: 10, description: 'Lorem ipsum dolor sit, amet ipsum consectetur adipisicing elit.', - buttonText: 'Start Free Trial', + buttonText: 'Subscribe Now', benefitList: [ '4 Team member', '4 GB Storage', @@ -66,12 +67,13 @@ const pricingList: PricingProps[] = [ ] }, { - title: 'Enterprise', + id: 'price_1Pdy8zFttF99a1NCGQJc5ZTZ', + title: 'Freelancer', popular: 0, - price: 40, + price: 20, description: 'Lorem ipsum dolor sit, amet ipsum consectetur adipisicing elit.', - buttonText: 'Contact US', + buttonText: 'Subscribe Now', benefitList: [ '10 Team member', '8 GB Storage', diff --git a/nextjs/components/misc/AccountPage.tsx b/nextjs/components/misc/AccountPage.tsx index 8c3ee7a..a209f3b 100644 --- a/nextjs/components/misc/AccountPage.tsx +++ b/nextjs/components/misc/AccountPage.tsx @@ -18,8 +18,15 @@ import { getURL } from '@/utils/helpers'; import { useToast } from '@/components/ui/use-toast'; import { useRouter } from 'next/navigation'; import { createApiClient } from '@/utils/supabase/api'; +import { SubscriptionWithPriceAndProduct } from '@/utils/types'; -export default function AccountPage({ user }: { user: User }) { +export default function AccountPage({ + user, + subscription +}: { + user: User; + subscription: SubscriptionWithPriceAndProduct; +}) { const supabase = createClient(); const { toast } = useToast(); const router = useRouter(); @@ -61,8 +68,10 @@ export default function AccountPage({ user }: { user: User }) { toast({ title: 'Signed out successfully!' }); - return router.replace('/'); + router.push('/'); + router.refresh(); }; + console.log(subscription); return (
@@ -97,12 +106,16 @@ export default function AccountPage({ user }: { user: User }) { - Billing Portal - Manage your billing on Stripe + Your Plan + + {subscription + ? `You are currently on the ${subscription?.prices?.products?.name} plan.` + : 'You are not currently subscribed to any plan.'} + - + diff --git a/nextjs/next.config.js b/nextjs/next.config.js new file mode 100644 index 0000000..8172b4f --- /dev/null +++ b/nextjs/next.config.js @@ -0,0 +1,10 @@ +module.exports = { + rewrites: async () => { + return [ + { + source: '/auth', + destination: '/auth/signin' + } + ]; + } +}; diff --git a/nextjs/package.json b/nextjs/package.json index dfbe049..27a5c83 100644 --- a/nextjs/package.json +++ b/nextjs/package.json @@ -9,7 +9,7 @@ "lint": "next lint", "prettier-fix": "prettier --write .", "stripe:login": "stripe login", - "stripe:listen": "stripe listen --forward-to=localhost:3000/api/webhooks", + "stripe:listen": "stripe listen --forward-to=http://127.0.0.1:54321/functions/v1/stripe_webhook", "stripe:fixtures": "stripe fixtures fixtures/stripe-fixtures.json", "supabase:start": "npx supabase start", "supabase:stop": "npx supabase stop", @@ -17,7 +17,7 @@ "supabase:restart": "npm run supabase:stop && npm run supabase:start", "supabase:reset": "npx supabase reset", "supabase:link": "npx supabase link", - "supabase:generate-types": "npx supabase gen types typescript --local --schema public > types_db.ts", + "supabase:generate-types": "npx supabase gen types typescript --local --schema public > types_db.ts && cp types_db.ts ../supabase/functions/types_db.ts", "supabase:generate-migration": "npx supabase db diff | npx supabase migration new", "supabase:generate-seed": "npx supabase db dump --data-only -f supabase/seed.sql", "supabase:push": "npx supabase push", diff --git a/nextjs/types_db.ts b/nextjs/types_db.ts index bb7db82..4617ac7 100644 --- a/nextjs/types_db.ts +++ b/nextjs/types_db.ts @@ -9,32 +9,201 @@ export type Json = export type Database = { public: { Tables: { - stripe: { + customers: { Row: { - active_products: string[] - created_at: string + id: string stripe_customer_id: string | null - updated_at: string - user_id: string } Insert: { - active_products?: string[] - created_at?: string + id: string stripe_customer_id?: string | null - updated_at?: string - user_id: string } Update: { - active_products?: string[] - created_at?: string + id?: string stripe_customer_id?: string | null - updated_at?: string + } + Relationships: [ + { + foreignKeyName: "customers_id_fkey" + columns: ["id"] + isOneToOne: true + referencedRelation: "users" + referencedColumns: ["id"] + }, + ] + } + prices: { + Row: { + active: boolean | null + currency: string | null + description: string | null + id: string + interval: Database["public"]["Enums"]["pricing_plan_interval"] | null + interval_count: number | null + metadata: Json | null + product_id: string | null + trial_period_days: number | null + type: Database["public"]["Enums"]["pricing_type"] | null + unit_amount: number | null + } + Insert: { + active?: boolean | null + currency?: string | null + description?: string | null + id: string + interval?: Database["public"]["Enums"]["pricing_plan_interval"] | null + interval_count?: number | null + metadata?: Json | null + product_id?: string | null + trial_period_days?: number | null + type?: Database["public"]["Enums"]["pricing_type"] | null + unit_amount?: number | null + } + Update: { + active?: boolean | null + currency?: string | null + description?: string | null + id?: string + interval?: Database["public"]["Enums"]["pricing_plan_interval"] | null + interval_count?: number | null + metadata?: Json | null + product_id?: string | null + trial_period_days?: number | null + type?: Database["public"]["Enums"]["pricing_type"] | null + unit_amount?: number | null + } + Relationships: [ + { + foreignKeyName: "prices_product_id_fkey" + columns: ["product_id"] + isOneToOne: false + referencedRelation: "products" + referencedColumns: ["id"] + }, + ] + } + products: { + Row: { + active: boolean | null + description: string | null + id: string + image: string | null + metadata: Json | null + name: string | null + } + Insert: { + active?: boolean | null + description?: string | null + id: string + image?: string | null + metadata?: Json | null + name?: string | null + } + Update: { + active?: boolean | null + description?: string | null + id?: string + image?: string | null + metadata?: Json | null + name?: string | null + } + Relationships: [] + } + subscriptions: { + Row: { + cancel_at: string | null + cancel_at_period_end: boolean | null + canceled_at: string | null + created: string + current_period_end: string + current_period_start: string + ended_at: string | null + id: string + metadata: Json | null + price_id: string | null + quantity: number | null + status: Database["public"]["Enums"]["subscription_status"] | null + trial_end: string | null + trial_start: string | null + user_id: string + } + Insert: { + cancel_at?: string | null + cancel_at_period_end?: boolean | null + canceled_at?: string | null + created?: string + current_period_end?: string + current_period_start?: string + ended_at?: string | null + id: string + metadata?: Json | null + price_id?: string | null + quantity?: number | null + status?: Database["public"]["Enums"]["subscription_status"] | null + trial_end?: string | null + trial_start?: string | null + user_id: string + } + Update: { + cancel_at?: string | null + cancel_at_period_end?: boolean | null + canceled_at?: string | null + created?: string + current_period_end?: string + current_period_start?: string + ended_at?: string | null + id?: string + metadata?: Json | null + price_id?: string | null + quantity?: number | null + status?: Database["public"]["Enums"]["subscription_status"] | null + trial_end?: string | null + trial_start?: string | null user_id?: string } Relationships: [ { - foreignKeyName: "stripe_user_id_fkey" + foreignKeyName: "subscriptions_price_id_fkey" + columns: ["price_id"] + isOneToOne: false + referencedRelation: "prices" + referencedColumns: ["id"] + }, + { + foreignKeyName: "subscriptions_user_id_fkey" columns: ["user_id"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] + }, + ] + } + users: { + Row: { + avatar_url: string | null + billing_address: Json | null + full_name: string | null + id: string + payment_method: Json | null + } + Insert: { + avatar_url?: string | null + billing_address?: Json | null + full_name?: string | null + id: string + payment_method?: Json | null + } + Update: { + avatar_url?: string | null + billing_address?: Json | null + full_name?: string | null + id?: string + payment_method?: Json | null + } + Relationships: [ + { + foreignKeyName: "users_id_fkey" + columns: ["id"] isOneToOne: true referencedRelation: "users" referencedColumns: ["id"] @@ -49,7 +218,17 @@ export type Database = { [_ in never]: never } Enums: { - [_ in never]: never + pricing_plan_interval: "day" | "week" | "month" | "year" + pricing_type: "one_time" | "recurring" + subscription_status: + | "trialing" + | "active" + | "canceled" + | "incomplete" + | "incomplete_expired" + | "past_due" + | "unpaid" + | "paused" } CompositeTypes: { [_ in never]: never @@ -138,3 +317,4 @@ export type Enums< : PublicEnumNameOrOptions extends keyof PublicSchema["Enums"] ? PublicSchema["Enums"][PublicEnumNameOrOptions] : never + diff --git a/nextjs/utils/supabase/queries.ts b/nextjs/utils/supabase/queries.ts new file mode 100644 index 0000000..d65e3e7 --- /dev/null +++ b/nextjs/utils/supabase/queries.ts @@ -0,0 +1,39 @@ +import { SupabaseClient } from '@supabase/supabase-js'; +import { cache } from 'react'; + +export const getUser = cache(async (supabase: SupabaseClient) => { + const { + data: { user } + } = await supabase.auth.getUser(); + return user; +}); + +export const getSubscription = cache(async (supabase: SupabaseClient) => { + const { data: subscription } = await supabase + .from('subscriptions') + .select('*, prices(*, products(*))') + .in('status', ['trialing', 'active']) + .maybeSingle(); + + return subscription; +}); + +export const getProducts = cache(async (supabase: SupabaseClient) => { + const { data: products } = await supabase + .from('products') + .select('*, prices(*)') + .eq('active', true) + .eq('prices.active', true) + .order('metadata->index') + .order('unit_amount', { referencedTable: 'prices' }); + + return products; +}); + +export const getUserDetails = cache(async (supabase: SupabaseClient) => { + const { data: userDetails } = await supabase + .from('users') + .select('*') + .single(); + return userDetails; +}); diff --git a/nextjs/utils/types.ts b/nextjs/utils/types.ts index 70ec58e..a19f10e 100644 --- a/nextjs/utils/types.ts +++ b/nextjs/utils/types.ts @@ -1,3 +1,5 @@ +import { Tables } from '@/types_db'; + export enum AuthState { Signin = 'signin', ForgotPassword = 'forgot_password', @@ -14,3 +16,15 @@ export type StateInfo = { hasPasswordField: boolean; hasOAuth: boolean; }; + +type Subscription = Tables<'subscriptions'>; +type Price = Tables<'prices'>; +type Product = Tables<'products'>; + +export type SubscriptionWithPriceAndProduct = Subscription & { + prices: + | (Price & { + products: Product | null; + }) + | null; +}; diff --git a/supabase/.env.example b/supabase/.env.example deleted file mode 100644 index afabcf0..0000000 --- a/supabase/.env.example +++ /dev/null @@ -1,5 +0,0 @@ -STRIPE_SECRET_KEY="YOUR_STRIPE_SECRET_KEY" -STRIPE_WEBHOOK_SIGNING_SECRET="YOUR_STRIPE_WEBHOOK_SIGNING_SECRET" -POSTHOG_CLIENT_KEY="YOUR_POSTHOG_CLIENT_KEY" -POSTMARK_SERVER_TOKEN="YOUR_POSTMARK_SERVER_TOKEN" -POSTMARK_FROM_EMAIL="YOUR_POSTMARK_FROM_EMAIL" diff --git a/supabase/functions/_shared/posthog.ts b/supabase/functions/_shared/posthog.ts index 18a4860..2e9cb76 100644 --- a/supabase/functions/_shared/posthog.ts +++ b/supabase/functions/_shared/posthog.ts @@ -1,4 +1,4 @@ -import { PostHog } from "npm:posthog-node@3.2.0"; +import { PostHog } from "posthog"; export const posthog = new PostHog(Deno.env.get("POSTHOG_CLIENT_KEY") || "", { flushAt: 1, diff --git a/supabase/functions/_shared/request.ts b/supabase/functions/_shared/request.ts index 1bee5bb..a9b14c8 100644 --- a/supabase/functions/_shared/request.ts +++ b/supabase/functions/_shared/request.ts @@ -1,4 +1,4 @@ -import { User } from "https://esm.sh/@supabase/supabase-js@2.39.7"; +import { User } from "supabase"; import { supabase } from "./supabase.ts"; async function getUserFromRequest(req: Request) { @@ -34,13 +34,10 @@ function corsResponse(res: Response): Response { } export function resFromError(e: { message?: string }): Response { - return new Response( - JSON.stringify({ message: e?.message }), - { - status: 400, - headers: { "Content-Type": "application/json", ...corsHeaders }, - }, - ); + return new Response(JSON.stringify({ message: e?.message }), { + status: 400, + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); } export function clientRequestHandlerWithUser( diff --git a/supabase/functions/_shared/stripe.ts b/supabase/functions/_shared/stripe.ts index ab10d07..da6c32d 100644 --- a/supabase/functions/_shared/stripe.ts +++ b/supabase/functions/_shared/stripe.ts @@ -1,9 +1,18 @@ -import Stripe from "https://esm.sh/stripe@10.12.0?target=deno"; -export const stripe = Stripe(Deno.env.get("STRIPE_SECRET_KEY")!, { +import Stripe from "stripe"; +import { Tables } from "../types_db.ts"; +import { createOrRetrieveCustomer } from "./supabase.ts"; +import { calculateTrialEndUnixTimestamp } from "./utils.ts"; +import { User } from "supabase"; + +export const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!, { httpClient: Stripe.createFetchHttpClient(), - apiVersion: "2022-08-01", + // https://github.com/stripe/stripe-node#configuration + // https://stripe.com/docs/api/versioning + apiVersion: "2024-06-20", }); +type Price = Tables<"prices">; + const cryptoProvider = Stripe.createSubtleCryptoProvider(); export const processWebhookRequest = async (req: Request) => { @@ -17,3 +26,108 @@ export const processWebhookRequest = async (req: Request) => { cryptoProvider, ); }; + +export async function checkoutWithStripe( + price: Price, + user: User, + returnUrl: string, +): Promise { + // Retrieve or create the customer in Stripe + let customer: string; + try { + customer = await createOrRetrieveCustomer({ + uuid: user?.id || "", + email: user?.email || "", + }); + } catch (err) { + console.error(err); + throw new Error("Unable to access customer record."); + } + + let params: Stripe.Checkout.SessionCreateParams = { + allow_promotion_codes: true, + billing_address_collection: "required", + customer, + customer_update: { + address: "auto", + }, + line_items: [ + { + price: price.id, + quantity: 1, + }, + ], + cancel_url: returnUrl || undefined, + success_url: returnUrl || undefined, + }; + + console.log( + "Trial end:", + calculateTrialEndUnixTimestamp(price.trial_period_days), + ); + if (price.type === "recurring") { + params = { + ...params, + mode: "subscription", + subscription_data: { + trial_end: calculateTrialEndUnixTimestamp(price.trial_period_days), + }, + }; + } else if (price.type === "one_time") { + params = { + ...params, + mode: "payment", + }; + } + + // Create a checkout session in Stripe + let session; + try { + session = await stripe.checkout.sessions.create(params); + } catch (err) { + console.error(err); + throw new Error("Unable to create checkout session."); + } + + // Instead of returning a Response, just return the url or error. + const url = session.url; + if (url) { + return url; + } else { + throw new Error("Unable to create checkout session."); + } +} + +export async function createStripePortal( + user: User, + returnUrl: string, +): Promise { + let customer; + try { + customer = await createOrRetrieveCustomer({ + uuid: user.id || "", + email: user.email || "", + }); + } catch (err) { + console.error(err); + throw new Error("Unable to access customer record."); + } + + if (!customer) { + throw new Error("Could not get customer."); + } + + try { + const { url } = await stripe.billingPortal.sessions.create({ + customer, + return_url: returnUrl, + }); + if (!url) { + throw new Error("Could not create billing portal"); + } + return url; + } catch (err) { + console.error(err); + throw new Error("Could not create billing portal"); + } +} diff --git a/supabase/functions/_shared/supabase.ts b/supabase/functions/_shared/supabase.ts index dc1dbef..2e713b3 100644 --- a/supabase/functions/_shared/supabase.ts +++ b/supabase/functions/_shared/supabase.ts @@ -1,7 +1,10 @@ -// TODO: fix the import_map.json to also work with types -import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.7"; +import { createClient } from "supabase"; +import { Database, Tables, TablesInsert } from "../types_db.ts"; +import Stripe from "stripe"; +import { stripe } from "./stripe.ts"; +import { toDateTime } from "./utils.ts"; -export const supabase = createClient( +export const supabase = createClient( Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!, { @@ -10,3 +13,297 @@ export const supabase = createClient( }, }, ); + +// Code below adapted from: https://github.com/vercel/nextjs-subscription-payments/blob/main/utils/supabase/admin.ts +// Change to control trial period length +const TRIAL_PERIOD_DAYS = 0; + +const upsertProductRecord = async (product: Stripe.Product) => { + const productData: Tables<"products"> = { + id: product.id, + active: product.active, + name: product.name, + description: product.description ?? null, + image: product.images?.[0] ?? null, + metadata: product.metadata, + }; + + const { error: upsertError } = await supabase + .from("products") + .upsert([productData]); + if (upsertError) { + throw new Error(`Product insert/update failed: ${upsertError.message}`); + } + console.log(`Product inserted/updated: ${product.id}`); +}; + +const upsertPriceRecord = async ( + price: Stripe.Price, + retryCount = 0, + maxRetries = 3, +) => { + const priceData: TablesInsert<"prices"> = { + id: price.id, + product_id: typeof price.product === "string" ? price.product : "", + active: price.active, + currency: price.currency, + type: price.type, + unit_amount: price.unit_amount ?? null, + interval: price.recurring?.interval ?? null, + interval_count: price.recurring?.interval_count ?? null, + trial_period_days: price.recurring?.trial_period_days ?? TRIAL_PERIOD_DAYS, + metadata: price.metadata, + }; + + const { error: upsertError } = await supabase + .from("prices") + .upsert([priceData]); + + if (upsertError?.message.includes("foreign key constraint")) { + if (retryCount < maxRetries) { + console.log(`Retry attempt ${retryCount + 1} for price ID: ${price.id}`); + await new Promise((resolve) => setTimeout(resolve, 2000)); + await upsertPriceRecord(price, retryCount + 1, maxRetries); + } else { + throw new Error( + `Price insert/update failed after ${maxRetries} retries: ${upsertError.message}`, + ); + } + } else if (upsertError) { + throw new Error(`Price insert/update failed: ${upsertError.message}`); + } else { + console.log(`Price inserted/updated: ${price.id}`); + } +}; + +const deleteProductRecord = async (product: Stripe.Product) => { + const { error: deletionError } = await supabase + .from("products") + .delete() + .eq("id", product.id); + if (deletionError) { + throw new Error(`Product deletion failed: ${deletionError.message}`); + } + console.log(`Product deleted: ${product.id}`); +}; + +const deletePriceRecord = async (price: Stripe.Price) => { + const { error: deletionError } = await supabase + .from("prices") + .delete() + .eq("id", price.id); + if (deletionError) { + throw new Error(`Price deletion failed: ${deletionError.message}`); + } + console.log(`Price deleted: ${price.id}`); +}; + +const upsertCustomerToSupabase = async (uuid: string, customerId: string) => { + const { error: upsertError } = await supabase + .from("customers") + .upsert([{ id: uuid, stripe_customer_id: customerId }]); + + if (upsertError) { + throw new Error( + `Supabase customer record creation failed: ${upsertError.message}`, + ); + } + + return customerId; +}; + +const createCustomerInStripe = async (uuid: string, email: string) => { + const customerData = { metadata: { supabaseUUID: uuid }, email: email }; + const newCustomer = await stripe.customers.create(customerData); + if (!newCustomer) throw new Error("Stripe customer creation failed."); + + return newCustomer.id; +}; + +const createOrRetrieveCustomer = async ({ + email, + uuid, +}: { + email: string; + uuid: string; +}) => { + // Check if the customer already exists in Supabase + const { data: existingSupabaseCustomer, error: queryError } = await supabase + .from("customers") + .select("*") + .eq("id", uuid) + .maybeSingle(); + + if (queryError) { + throw new Error(`Supabase customer lookup failed: ${queryError.message}`); + } + + // Retrieve the Stripe customer ID using the Supabase customer ID, with email fallback + let stripeCustomerId: string | undefined; + if (existingSupabaseCustomer?.stripe_customer_id) { + const existingStripeCustomer = await stripe.customers.retrieve( + existingSupabaseCustomer.stripe_customer_id, + ); + stripeCustomerId = existingStripeCustomer.id; + } else { + // If Stripe ID is missing from Supabase, try to retrieve Stripe customer ID by email + const stripeCustomers = await stripe.customers.list({ email: email }); + stripeCustomerId = stripeCustomers.data.length > 0 + ? stripeCustomers.data[0].id + : undefined; + } + + // If still no stripeCustomerId, create a new customer in Stripe + const stripeIdToInsert = stripeCustomerId + ? stripeCustomerId + : await createCustomerInStripe(uuid, email); + if (!stripeIdToInsert) throw new Error("Stripe customer creation failed."); + + if (existingSupabaseCustomer && stripeCustomerId) { + // If Supabase has a record but doesn't match Stripe, update Supabase record + if (existingSupabaseCustomer.stripe_customer_id !== stripeCustomerId) { + const { error: updateError } = await supabase + .from("customers") + .update({ stripe_customer_id: stripeCustomerId }) + .eq("id", uuid); + + if (updateError) { + throw new Error( + `Supabase customer record update failed: ${updateError.message}`, + ); + } + console.warn( + `Supabase customer record mismatched Stripe ID. Supabase record updated.`, + ); + } + // If Supabase has a record and matches Stripe, return Stripe customer ID + return stripeCustomerId; + } else { + console.warn( + `Supabase customer record was missing. A new record was created.`, + ); + + // If Supabase has no record, create a new record and return Stripe customer ID + const upsertedStripeCustomer = await upsertCustomerToSupabase( + uuid, + stripeIdToInsert, + ); + if (!upsertedStripeCustomer) { + throw new Error("Supabase customer record creation failed."); + } + + return upsertedStripeCustomer; + } +}; + +/** + * Copies the billing details from the payment method to the customer object. + */ +const copyBillingDetailsToCustomer = async ( + uuid: string, + payment_method: Stripe.PaymentMethod, +) => { + //Todo: check this assertion + const customer = payment_method.customer as string; + const { name, phone, address } = payment_method.billing_details; + if (!name || !phone || !address) return; + //@ts-ignore: typescript error for stripe type + await stripe.customers.update(customer, { name, phone, address }); + const { error: updateError } = await supabase + .from("users") + .update({ + billing_address: { ...address }, + payment_method: { ...payment_method[payment_method.type] }, + }) + .eq("id", uuid); + if (updateError) { + throw new Error(`Customer update failed: ${updateError.message}`); + } +}; + +const manageSubscriptionStatusChange = async ( + subscriptionId: string, + customerId: string, + createAction = false, +) => { + // Get customer's UUID from mapping table. + const { data: customerData, error: noCustomerError } = await supabase + .from("customers") + .select("id") + .eq("stripe_customer_id", customerId) + .single(); + + if (noCustomerError) { + throw new Error(`Customer lookup failed: ${noCustomerError.message}`); + } + + const { id: uuid } = customerData!; + + const subscription = await stripe.subscriptions.retrieve(subscriptionId, { + expand: ["default_payment_method"], + }); + // Upsert the latest status of the subscription object. + const subscriptionData: TablesInsert<"subscriptions"> = { + id: subscription.id, + user_id: uuid, + metadata: subscription.metadata, + status: subscription.status, + price_id: subscription.items.data[0].price.id, + //TODO check quantity on subscription + // @ts-ignore: ignore quantity doesnt exist + quantity: subscription.quantity, + cancel_at_period_end: subscription.cancel_at_period_end, + cancel_at: subscription.cancel_at + ? toDateTime(subscription.cancel_at).toISOString() + : null, + canceled_at: subscription.canceled_at + ? toDateTime(subscription.canceled_at).toISOString() + : null, + current_period_start: toDateTime( + subscription.current_period_start, + ).toISOString(), + current_period_end: toDateTime( + subscription.current_period_end, + ).toISOString(), + created: toDateTime(subscription.created).toISOString(), + ended_at: subscription.ended_at + ? toDateTime(subscription.ended_at).toISOString() + : null, + trial_start: subscription.trial_start + ? toDateTime(subscription.trial_start).toISOString() + : null, + trial_end: subscription.trial_end + ? toDateTime(subscription.trial_end).toISOString() + : null, + }; + + const { error: upsertError } = await supabase + .from("subscriptions") + .upsert([subscriptionData]); + if (upsertError) { + throw new Error( + `Subscription insert/update failed: ${upsertError.message}`, + ); + } + console.log( + `Inserted/updated subscription [${subscription.id}] for user [${uuid}]`, + ); + + // For a new subscription copy the billing details to the customer object. + // NOTE: This is a costly operation and should happen at the very end. + if (createAction && subscription.default_payment_method && uuid) { + await copyBillingDetailsToCustomer( + uuid, + subscription.default_payment_method as Stripe.PaymentMethod, + ); + } +}; + +export { + createOrRetrieveCustomer, + deletePriceRecord, + deleteProductRecord, + manageSubscriptionStatusChange, + upsertPriceRecord, + upsertProductRecord, +}; diff --git a/supabase/functions/_shared/utils.ts b/supabase/functions/_shared/utils.ts new file mode 100644 index 0000000..c5b496f --- /dev/null +++ b/supabase/functions/_shared/utils.ts @@ -0,0 +1,24 @@ +export const toDateTime = (secs: number) => { + const t = new Date(+0); // Unix epoch start. + t.setSeconds(secs); + return t; +}; + +export const calculateTrialEndUnixTimestamp = ( + trialPeriodDays: number | null | undefined, +) => { + // Check if trialPeriodDays is null, undefined, or less than 2 days + if ( + trialPeriodDays === null || + trialPeriodDays === undefined || + trialPeriodDays < 2 + ) { + return undefined; + } + + const currentDate = new Date(); // Current date and time + const trialEnd = new Date( + currentDate.getTime() + (trialPeriodDays + 1) * 24 * 60 * 60 * 1000, + ); // Add trial days + return Math.floor(trialEnd.getTime() / 1000); // Convert to Unix timestamp in seconds +}; diff --git a/supabase/functions/deno.json b/supabase/functions/deno.json index 498f7f1..a1b11c4 100644 --- a/supabase/functions/deno.json +++ b/supabase/functions/deno.json @@ -1,6 +1,7 @@ { "imports": { "supabase": "https://esm.sh/@supabase/supabase-js@2.39.7", - "stripe": "https://esm.sh/stripe@10.12.0?target=deno" + "stripe": "https://esm.sh/stripe@16.2.0?target=deno&no-check", + "posthog": "npm:posthog-node@3.2.0" } } diff --git a/supabase/functions/deno.lock b/supabase/functions/deno.lock new file mode 100644 index 0000000..c63379d --- /dev/null +++ b/supabase/functions/deno.lock @@ -0,0 +1,223 @@ +{ + "version": "3", + "packages": { + "specifiers": { + "npm:posthog-node@3.2.0": "npm:posthog-node@3.2.0" + }, + "npm": { + "asynckit@0.4.0": { + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dependencies": {} + }, + "axios@1.6.7": { + "integrity": "sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==", + "dependencies": { + "follow-redirects": "follow-redirects@1.15.5", + "form-data": "form-data@4.0.0", + "proxy-from-env": "proxy-from-env@1.1.0" + } + }, + "combined-stream@1.0.8": { + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "delayed-stream@1.0.0" + } + }, + "delayed-stream@1.0.0": { + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dependencies": {} + }, + "follow-redirects@1.15.5": { + "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", + "dependencies": {} + }, + "form-data@4.0.0": { + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "asynckit@0.4.0", + "combined-stream": "combined-stream@1.0.8", + "mime-types": "mime-types@2.1.35" + } + }, + "mime-db@1.52.0": { + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dependencies": {} + }, + "mime-types@2.1.35": { + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "mime-db@1.52.0" + } + }, + "posthog-node@3.2.0": { + "integrity": "sha512-R/kNgZuJNt/vZ0ghEFzSZw5V0VjdhyBcXkDQN4fahbJy491u+FhBqghl1JIi8AHAoOxTdG0eDTedPvHp5usGmQ==", + "dependencies": { + "axios": "axios@1.6.7", + "rusha": "rusha@0.8.14" + } + }, + "proxy-from-env@1.1.0": { + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dependencies": {} + }, + "rusha@0.8.14": { + "integrity": "sha512-cLgakCUf6PedEu15t8kbsjnwIFFR2D4RfL+W3iWFJ4iac7z4B0ZI8fxy4R3J956kAI68HclCFGL8MPoUVC3qVA==", + "dependencies": {} + } + } + }, + "remote": { + "https://deno.land/std@0.177.1/_util/asserts.ts": "178dfc49a464aee693a7e285567b3d0b555dc805ff490505a8aae34f9cfb1462", + "https://deno.land/std@0.177.1/_util/os.ts": "d932f56d41e4f6a6093d56044e29ce637f8dcc43c5a90af43504a889cf1775e3", + "https://deno.land/std@0.177.1/async/abortable.ts": "73acfb3ed7261ce0d930dbe89e43db8d34e017b063cf0eaa7d215477bf53442e", + "https://deno.land/std@0.177.1/async/deadline.ts": "c5facb0b404eede83e38bd2717ea8ab34faa2ffb20ef87fd261fcba32ba307aa", + "https://deno.land/std@0.177.1/async/debounce.ts": "adab11d04ca38d699444ac8a9d9856b4155e8dda2afd07ce78276c01ea5a4332", + "https://deno.land/std@0.177.1/async/deferred.ts": "42790112f36a75a57db4a96d33974a936deb7b04d25c6084a9fa8a49f135def8", + "https://deno.land/std@0.177.1/async/delay.ts": "73aa04cec034c84fc748c7be49bb15cac3dd43a57174bfdb7a4aec22c248f0dd", + "https://deno.land/std@0.177.1/async/mod.ts": "f04344fa21738e5ad6bea37a6bfffd57c617c2d372bb9f9dcfd118a1b622e576", + "https://deno.land/std@0.177.1/async/mux_async_iterator.ts": "70c7f2ee4e9466161350473ad61cac0b9f115cff4c552eaa7ef9d50c4cbb4cc9", + "https://deno.land/std@0.177.1/async/pool.ts": "fd082bd4aaf26445909889435a5c74334c017847842ec035739b4ae637ae8260", + "https://deno.land/std@0.177.1/async/retry.ts": "5efa3ba450ac0c07a40a82e2df296287b5013755d232049efd7ea2244f15b20f", + "https://deno.land/std@0.177.1/async/tee.ts": "47e42d35f622650b02234d43803d0383a89eb4387e1b83b5a40106d18ae36757", + "https://deno.land/std@0.177.1/bytes/index_of_needle.ts": "65c939607df609374c4415598fa4dad04a2f14c4d98cd15775216f0aaf597f24", + "https://deno.land/std@0.177.1/crypto/timing_safe_equal.ts": "8d69ab611c67fe51b6127d97fcfb4d8e7d0e1b6b4f3e0cc4ab86744c3691f965", + "https://deno.land/std@0.177.1/encoding/base64.ts": "7de04c2f8aeeb41453b09b186480be90f2ff357613b988e99fabb91d2eeceba1", + "https://deno.land/std@0.177.1/encoding/base64url.ts": "3f1178f6446834457b16bfde8b559c1cd3481727fe384d3385e4a9995dc2d851", + "https://deno.land/std@0.177.1/flags/mod.ts": "d1cdefa18472ef69858a17df5cf7c98445ed27ac10e1460183081303b0ebc270", + "https://deno.land/std@0.177.1/node/_core.ts": "9a58c0ef98ee77e9b8fcc405511d1b37a003a705eb6a9b6e95f75434d8009adc", + "https://deno.land/std@0.177.1/node/_events.mjs": "d4ba4e629abe3db9f1b14659fd5c282b7da8b2b95eaf13238eee4ebb142a2448", + "https://deno.land/std@0.177.1/node/_next_tick.ts": "9a3cf107d59b019a355d3cf32275b4c6157282e4b68ea85b46a799cb1d379305", + "https://deno.land/std@0.177.1/node/_process/exiting.ts": "6e336180aaabd1192bf99ffeb0d14b689116a3dec1dfb34a2afbacd6766e98ab", + "https://deno.land/std@0.177.1/node/_process/process.ts": "c96bb1f6253824c372f4866ee006dcefda02b7050d46759736e403f862d91051", + "https://deno.land/std@0.177.1/node/_process/stdio.mjs": "cf17727eac8da3a665851df700b5aca6a12bacc3ebbf33e63e4b919f80ba44a6", + "https://deno.land/std@0.177.1/node/_process/streams.mjs": "408777fba99580567f3ee82ee584ca79012cc550f8dacb8c5ec633b58cd0c1ca", + "https://deno.land/std@0.177.1/node/_stream.mjs": "d6e2c86c1158ac65b4c2ca4fa019d7e84374ff12e21e2175345fe68c0823efe3", + "https://deno.land/std@0.177.1/node/_utils.ts": "7fd55872a0cf9275e3c080a60e2fa6d45b8de9e956ebcde9053e72a344185884", + "https://deno.land/std@0.177.1/node/buffer.ts": "85617be2063eccaf177dbb84c7580d1e32023724ed14bd9df4e453b152a26167", + "https://deno.land/std@0.177.1/node/events.ts": "d2de352d509de11a375e2cb397d6b98f5fed4e562fc1d41be33214903a38e6b0", + "https://deno.land/std@0.177.1/node/internal/buffer.mjs": "e92303a3cc6d9aaabcd270a937ad9319825d9ba08cb332650944df4562029b27", + "https://deno.land/std@0.177.1/node/internal/crypto/_keys.ts": "8f3c3b5a141aa0331a53c205e9338655f1b3b307a08085fd6ff6dda6f7c4190b", + "https://deno.land/std@0.177.1/node/internal/crypto/constants.ts": "544d605703053218499b08214f2e25cf4310651d535b7ab995891c4b7a217693", + "https://deno.land/std@0.177.1/node/internal/error_codes.ts": "8495e33f448a484518d76fa3d41d34fc20fe03c14b30130ad8e936b0035d4b8b", + "https://deno.land/std@0.177.1/node/internal/errors.ts": "1c699b8a3cb93174f697a348c004b1c6d576b66688eac8a48ebb78e65c720aae", + "https://deno.land/std@0.177.1/node/internal/fixed_queue.ts": "62bb119afa5b5ae8fc0c7048b50502347bec82e2588017d0b250c4671d6eff8f", + "https://deno.land/std@0.177.1/node/internal/hide_stack_frames.ts": "9dd1bad0a6e62a1042ce3a51eb1b1ecee2f246907bff44835f86e8f021de679a", + "https://deno.land/std@0.177.1/node/internal/net.ts": "5538d31b595ac63d4b3e90393168bc65ace2f332c3317cffa2fd780070b2d86c", + "https://deno.land/std@0.177.1/node/internal/normalize_encoding.mjs": "fd1d9df61c44d7196432f6e8244621468715131d18cc79cd299fc78ac549f707", + "https://deno.land/std@0.177.1/node/internal/options.ts": "888f267c3fe8f18dc7b2f2fbdbe7e4a0fd3302ff3e99f5d6645601e924f3e3fb", + "https://deno.land/std@0.177.1/node/internal/primordials.mjs": "a72d86b5aa55d3d50b8e916b6a59b7cc0dc5a31da8937114b4a113ad5aa08c74", + "https://deno.land/std@0.177.1/node/internal/process/per_thread.mjs": "10142bbb13978c2f8f79778ad90f3a67a8ea6d8d2970f3dfc6bf2c6fff0162a2", + "https://deno.land/std@0.177.1/node/internal/readline/callbacks.mjs": "bdb129b140c3b21b5e08cdc3d8e43517ad818ac03f75197338d665cca1cbaed3", + "https://deno.land/std@0.177.1/node/internal/readline/utils.mjs": "c3dbf3a97c01ed14052cca3848f09e2fc24818c1822ceed57c33b9f0840f3b87", + "https://deno.land/std@0.177.1/node/internal/streams/destroy.mjs": "b665fc71178919a34ddeac8389d162a81b4bc693ff7dc2557fa41b3a91011967", + "https://deno.land/std@0.177.1/node/internal/streams/end-of-stream.mjs": "a4fb1c2e32d58dff440d4e716e2c4daaa403b3095304a028bb428575cfeed716", + "https://deno.land/std@0.177.1/node/internal/streams/utils.mjs": "f2fe2e6bdc506da24c758970890cc2a21642045b129dee618bd3827c60dd9e33", + "https://deno.land/std@0.177.1/node/internal/util.mjs": "f7fe2e1ca5e66f550ad0856b9f5ee4d666f0c071fe212ea7fc7f37cfa81f97a5", + "https://deno.land/std@0.177.1/node/internal/util/inspect.mjs": "11d7c9cab514b8e485acc3978c74b837263ff9c08ae4537fa18ad56bae633259", + "https://deno.land/std@0.177.1/node/internal/util/types.ts": "0e587b44ec5e017cf228589fc5ce9983b75beece6c39409c34170cfad49d6417", + "https://deno.land/std@0.177.1/node/internal/validators.mjs": "e02f2b02dd072a5d623970292588d541204dc82207b4c58985d933a5f4b382e6", + "https://deno.land/std@0.177.1/node/internal_binding/_libuv_winerror.ts": "30c9569603d4b97a1f1a034d88a3f74800d5ea1f12fcc3d225c9899d4e1a518b", + "https://deno.land/std@0.177.1/node/internal_binding/_listen.ts": "c6038be47116f7755c01fd98340a0d1e8e66ef874710ab59ed3f5607d50d7a25", + "https://deno.land/std@0.177.1/node/internal_binding/_node.ts": "cb2389b0eab121df99853eb6a5e3a684e4537e065fb8bf2cca0cbf219ce4e32e", + "https://deno.land/std@0.177.1/node/internal_binding/_timingSafeEqual.ts": "7d9732464d3c669ff07713868ce5d25bc974a06112edbfb5f017fc3c70c0853e", + "https://deno.land/std@0.177.1/node/internal_binding/_utils.ts": "7c58a2fbb031a204dee9583ba211cf9c67922112fe77e7f0b3226112469e9fe1", + "https://deno.land/std@0.177.1/node/internal_binding/_winerror.ts": "3e8cfdfe22e89f13d2b28529bab35155e6b1730c0221ec5a6fc7077dc037be13", + "https://deno.land/std@0.177.1/node/internal_binding/ares.ts": "bdd34c679265a6c115a8cfdde000656837a0a0dcdb0e4c258e622e136e9c31b8", + "https://deno.land/std@0.177.1/node/internal_binding/async_wrap.ts": "0dc5ae64eea2c9e57ab17887ef1573922245167ffe38e3685c28d636f487f1b7", + "https://deno.land/std@0.177.1/node/internal_binding/buffer.ts": "31729e0537921d6c730ad0afea44a7e8a0a1044d070ade8368226cb6f7390c8b", + "https://deno.land/std@0.177.1/node/internal_binding/cares_wrap.ts": "9b7247772167f8ed56acd0244a232d9d50e8d7c9cfc379f77f3d54cecc2f32ab", + "https://deno.land/std@0.177.1/node/internal_binding/config.ts": "37d293009d1718205bf28e878e54a9f1ca24c1c320cee2416c20dc054104c6ea", + "https://deno.land/std@0.177.1/node/internal_binding/connection_wrap.ts": "7dd089ea46de38e4992d0f43a09b586e4cf04878fb06863c1cb8cb2ece7da521", + "https://deno.land/std@0.177.1/node/internal_binding/constants.ts": "21ff9d1ee71d0a2086541083a7711842fc6ae25e264dbf45c73815aadce06f4c", + "https://deno.land/std@0.177.1/node/internal_binding/contextify.ts": "37d293009d1718205bf28e878e54a9f1ca24c1c320cee2416c20dc054104c6ea", + "https://deno.land/std@0.177.1/node/internal_binding/credentials.ts": "37d293009d1718205bf28e878e54a9f1ca24c1c320cee2416c20dc054104c6ea", + "https://deno.land/std@0.177.1/node/internal_binding/crypto.ts": "29e8f94f283a2e7d4229d3551369c6a40c2af9737fad948cb9be56bef6c468cd", + "https://deno.land/std@0.177.1/node/internal_binding/errors.ts": "37d293009d1718205bf28e878e54a9f1ca24c1c320cee2416c20dc054104c6ea", + "https://deno.land/std@0.177.1/node/internal_binding/fs.ts": "37d293009d1718205bf28e878e54a9f1ca24c1c320cee2416c20dc054104c6ea", + "https://deno.land/std@0.177.1/node/internal_binding/fs_dir.ts": "37d293009d1718205bf28e878e54a9f1ca24c1c320cee2416c20dc054104c6ea", + "https://deno.land/std@0.177.1/node/internal_binding/fs_event_wrap.ts": "37d293009d1718205bf28e878e54a9f1ca24c1c320cee2416c20dc054104c6ea", + "https://deno.land/std@0.177.1/node/internal_binding/handle_wrap.ts": "adf0b8063da2c54f26edd5e8ec50296a4d38e42716a70a229f14654b17a071d9", + "https://deno.land/std@0.177.1/node/internal_binding/heap_utils.ts": "37d293009d1718205bf28e878e54a9f1ca24c1c320cee2416c20dc054104c6ea", + "https://deno.land/std@0.177.1/node/internal_binding/http_parser.ts": "37d293009d1718205bf28e878e54a9f1ca24c1c320cee2416c20dc054104c6ea", + "https://deno.land/std@0.177.1/node/internal_binding/icu.ts": "37d293009d1718205bf28e878e54a9f1ca24c1c320cee2416c20dc054104c6ea", + "https://deno.land/std@0.177.1/node/internal_binding/inspector.ts": "37d293009d1718205bf28e878e54a9f1ca24c1c320cee2416c20dc054104c6ea", + "https://deno.land/std@0.177.1/node/internal_binding/js_stream.ts": "37d293009d1718205bf28e878e54a9f1ca24c1c320cee2416c20dc054104c6ea", + "https://deno.land/std@0.177.1/node/internal_binding/messaging.ts": "37d293009d1718205bf28e878e54a9f1ca24c1c320cee2416c20dc054104c6ea", + "https://deno.land/std@0.177.1/node/internal_binding/mod.ts": "9fc65f7af1d35e2d3557539a558ea9ad7a9954eefafe614ad82d94bddfe25845", + "https://deno.land/std@0.177.1/node/internal_binding/module_wrap.ts": "37d293009d1718205bf28e878e54a9f1ca24c1c320cee2416c20dc054104c6ea", + "https://deno.land/std@0.177.1/node/internal_binding/native_module.ts": "37d293009d1718205bf28e878e54a9f1ca24c1c320cee2416c20dc054104c6ea", + "https://deno.land/std@0.177.1/node/internal_binding/natives.ts": "37d293009d1718205bf28e878e54a9f1ca24c1c320cee2416c20dc054104c6ea", + "https://deno.land/std@0.177.1/node/internal_binding/node_file.ts": "21edbbc95653e45514aff252b6cae7bf127a4338cbc5f090557d258aa205d8a5", + "https://deno.land/std@0.177.1/node/internal_binding/node_options.ts": "0b5cb0bf4379a39278d7b7bb6bb2c2751baf428fe437abe5ed3e8441fae1f18b", + "https://deno.land/std@0.177.1/node/internal_binding/options.ts": "37d293009d1718205bf28e878e54a9f1ca24c1c320cee2416c20dc054104c6ea", + "https://deno.land/std@0.177.1/node/internal_binding/os.ts": "37d293009d1718205bf28e878e54a9f1ca24c1c320cee2416c20dc054104c6ea", + "https://deno.land/std@0.177.1/node/internal_binding/performance.ts": "37d293009d1718205bf28e878e54a9f1ca24c1c320cee2416c20dc054104c6ea", + "https://deno.land/std@0.177.1/node/internal_binding/pipe_wrap.ts": "30e3a63954313f9d5bbc2ac02c7f9be4b1204c493e47f6e1b9c7366994e6ea6d", + "https://deno.land/std@0.177.1/node/internal_binding/process_methods.ts": "37d293009d1718205bf28e878e54a9f1ca24c1c320cee2416c20dc054104c6ea", + "https://deno.land/std@0.177.1/node/internal_binding/report.ts": "37d293009d1718205bf28e878e54a9f1ca24c1c320cee2416c20dc054104c6ea", + "https://deno.land/std@0.177.1/node/internal_binding/serdes.ts": "37d293009d1718205bf28e878e54a9f1ca24c1c320cee2416c20dc054104c6ea", + "https://deno.land/std@0.177.1/node/internal_binding/signal_wrap.ts": "37d293009d1718205bf28e878e54a9f1ca24c1c320cee2416c20dc054104c6ea", + "https://deno.land/std@0.177.1/node/internal_binding/spawn_sync.ts": "37d293009d1718205bf28e878e54a9f1ca24c1c320cee2416c20dc054104c6ea", + "https://deno.land/std@0.177.1/node/internal_binding/stream_wrap.ts": "452bff74d1db280a0cd78c75a95bb6d163e849e06e9638c4af405d40296bd050", + "https://deno.land/std@0.177.1/node/internal_binding/string_decoder.ts": "54c3c1cbd5a9254881be58bf22637965dc69535483014dab60487e299cb95445", + "https://deno.land/std@0.177.1/node/internal_binding/symbols.ts": "4dee2f3a400d711fd57fa3430b8de1fdb011e08e260b81fef5b81cc06ed77129", + "https://deno.land/std@0.177.1/node/internal_binding/task_queue.ts": "37d293009d1718205bf28e878e54a9f1ca24c1c320cee2416c20dc054104c6ea", + "https://deno.land/std@0.177.1/node/internal_binding/tcp_wrap.ts": "d298d855e862fc9a5c94e13ad982fde99f6d8a56620a4772681b7226f5a15c91", + "https://deno.land/std@0.177.1/node/internal_binding/timers.ts": "37d293009d1718205bf28e878e54a9f1ca24c1c320cee2416c20dc054104c6ea", + "https://deno.land/std@0.177.1/node/internal_binding/tls_wrap.ts": "37d293009d1718205bf28e878e54a9f1ca24c1c320cee2416c20dc054104c6ea", + "https://deno.land/std@0.177.1/node/internal_binding/trace_events.ts": "37d293009d1718205bf28e878e54a9f1ca24c1c320cee2416c20dc054104c6ea", + "https://deno.land/std@0.177.1/node/internal_binding/tty_wrap.ts": "37d293009d1718205bf28e878e54a9f1ca24c1c320cee2416c20dc054104c6ea", + "https://deno.land/std@0.177.1/node/internal_binding/types.ts": "2187595a58d2cf0134f4db6cc2a12bf777f452f52b15b6c3aed73fa072aa5fc3", + "https://deno.land/std@0.177.1/node/internal_binding/udp_wrap.ts": "b77d7024aef1282b9fe6e1f6c8064ab8a7b9ecbae0bc08a36f2b30dcbb1d2752", + "https://deno.land/std@0.177.1/node/internal_binding/url.ts": "37d293009d1718205bf28e878e54a9f1ca24c1c320cee2416c20dc054104c6ea", + "https://deno.land/std@0.177.1/node/internal_binding/util.ts": "808ff3b92740284184ab824adfc420e75398c88c8bccf5111f0c24ac18c48f10", + "https://deno.land/std@0.177.1/node/internal_binding/uv.ts": "eb0048e30af4db407fb3f95563e30d70efd6187051c033713b0a5b768593a3a3", + "https://deno.land/std@0.177.1/node/internal_binding/v8.ts": "37d293009d1718205bf28e878e54a9f1ca24c1c320cee2416c20dc054104c6ea", + "https://deno.land/std@0.177.1/node/internal_binding/worker.ts": "37d293009d1718205bf28e878e54a9f1ca24c1c320cee2416c20dc054104c6ea", + "https://deno.land/std@0.177.1/node/internal_binding/zlib.ts": "37d293009d1718205bf28e878e54a9f1ca24c1c320cee2416c20dc054104c6ea", + "https://deno.land/std@0.177.1/node/process.ts": "6608012d6d51a17a7346f36079c574b9b9f81f1b5c35436489ad089f39757466", + "https://deno.land/std@0.177.1/node/stream.ts": "09e348302af40dcc7dc58aa5e40fdff868d11d8d6b0cfb85cbb9c75b9fe450c7", + "https://deno.land/std@0.177.1/node/string_decoder.ts": "1a17e3572037c512cc5fc4b29076613e90f225474362d18da908cb7e5ccb7e88", + "https://deno.land/std@0.177.1/path/_constants.ts": "e49961f6f4f48039c0dfed3c3f93e963ca3d92791c9d478ac5b43183413136e0", + "https://deno.land/std@0.177.1/path/_interface.ts": "6471159dfbbc357e03882c2266d21ef9afdb1e4aa771b0545e90db58a0ba314b", + "https://deno.land/std@0.177.1/path/_util.ts": "d7abb1e0dea065f427b89156e28cdeb32b045870acdf865833ba808a73b576d0", + "https://deno.land/std@0.177.1/path/common.ts": "ee7505ab01fd22de3963b64e46cff31f40de34f9f8de1fff6a1bd2fe79380000", + "https://deno.land/std@0.177.1/path/glob.ts": "d479e0a695621c94d3fd7fe7abd4f9499caf32a8de13f25073451c6ef420a4e1", + "https://deno.land/std@0.177.1/path/mod.ts": "4b83694ac500d7d31b0cdafc927080a53dc0c3027eb2895790fb155082b0d232", + "https://deno.land/std@0.177.1/path/posix.ts": "8b7c67ac338714b30c816079303d0285dd24af6b284f7ad63da5b27372a2c94d", + "https://deno.land/std@0.177.1/path/separator.ts": "0fb679739d0d1d7bf45b68dacfb4ec7563597a902edbaf3c59b50d5bcadd93b1", + "https://deno.land/std@0.177.1/path/win32.ts": "d186344e5583bcbf8b18af416d13d82b35a317116e6460a5a3953508c3de5bba", + "https://deno.land/std@0.177.1/streams/write_all.ts": "3b2e1ce44913f966348ce353d02fa5369e94115181037cd8b602510853ec3033", + "https://esm.sh/stripe@16.2.0?target=deno": "f7a45ad6684da7f21ac167fe81fe42acd569a23d58238e750b0e9d67ef7ee9a5", + "https://esm.sh/stripe@16.2.0?target=deno&no-check": "f7a45ad6684da7f21ac167fe81fe42acd569a23d58238e750b0e9d67ef7ee9a5", + "https://esm.sh/v135/call-bind@1.0.7/deno/callBound.js": "2dbb8c2563fa744d767648811f3f355fc1fddf1e9e0126243d2b98db0b6932c9", + "https://esm.sh/v135/define-data-property@1.1.4/deno/define-data-property.mjs": "f91322ad18ff12e8a2a86393d2b790d4fd0bc32b359d5253cfb9127cb95f7eec", + "https://esm.sh/v135/es-define-property@1.0.0/deno/es-define-property.mjs": "5806a7114f3cba6ddaa2366d5fb04f26f152651ae10ba4bdb459f84e8ab95377", + "https://esm.sh/v135/es-errors@1.3.0/deno/es-errors.mjs": "50f74e9daf25615214ef9f3e0105daa2fe1dddbf1e8a404f4ae643bb61cd6553", + "https://esm.sh/v135/es-errors@1.3.0/deno/eval.js": "475476913529e6c1488f277d4f711faf81b6d228c7ec89ffa32e63a1abeca652", + "https://esm.sh/v135/es-errors@1.3.0/deno/range.js": "5a0ecb96022ab0af01349b365a8c21318b561be6a45480321685b3da960efef2", + "https://esm.sh/v135/es-errors@1.3.0/deno/ref.js": "b58b680ba267a275fa296e94fd5ab81be2d65ca454aa9d3e0286b057e79ea302", + "https://esm.sh/v135/es-errors@1.3.0/deno/syntax.js": "24676d9f770b38f17c9597171696d5da9b80547014ef0f9c0f4848b8e825e325", + "https://esm.sh/v135/es-errors@1.3.0/deno/type.js": "d295c1c627d5ca6f36d66d38198b62a82f721ab25037e7b48703b6a15ef83191", + "https://esm.sh/v135/es-errors@1.3.0/deno/uri.js": "06282291bf3411c7b52f8ac6ef27850c26ce6541b12e42fdd504c83035c70263", + "https://esm.sh/v135/function-bind@1.1.2/deno/function-bind.mjs": "de1d5f13e68841ee057c872941391a14bdced91579f4da4ea2f8f9ccce617bc3", + "https://esm.sh/v135/get-intrinsic@1.2.2/deno/get-intrinsic.mjs": "e7d41adf72d22f24995fd7c0585a2276b2e03ceba6357927107f663a96a9009a", + "https://esm.sh/v135/get-intrinsic@1.2.4/deno/get-intrinsic.mjs": "e5dfec78243ac00412dc7ac17c06a6102c6c616911e5bfb34e0facf122cc09ed", + "https://esm.sh/v135/gopd@1.0.1/deno/gopd.mjs": "5438fbbfae584b32cd00618fd40cc7879ea782569544fdfab0e91438e4139dc7", + "https://esm.sh/v135/has-property-descriptors@1.0.2/deno/has-property-descriptors.mjs": "924ed810a21eb48b529e99b5dff58668c624fe2326a4fa7a5abe8c19bb0636d1", + "https://esm.sh/v135/has-proto@1.0.1/deno/has-proto.mjs": "1f7413341b9935520f658d9fb241eb671a13ed73378863ee118eb52c74867eed", + "https://esm.sh/v135/has-symbols@1.0.3/deno/has-symbols.mjs": "99c4f81d6d8d8897df5a5fd4291e01c10de00831c292ab5d96cdf0af63d3fbf2", + "https://esm.sh/v135/hasown@2.0.0/deno/hasown.mjs": "4256b8bb56ec2c82a6b9b8d5fda31d78ebd11c42b8f88251343b4c2b039918ef", + "https://esm.sh/v135/hasown@2.0.1/deno/hasown.mjs": "eb0c1f08f2d411b56b911f66e2ec10ae267b7977e3a55b643c0fddabba5e1714", + "https://esm.sh/v135/object-inspect@1.13.1/deno/object-inspect.mjs": "85b5f7e23306ed450ff4fce601ea097793e65ef79a45115a25d16b32b0173a0d", + "https://esm.sh/v135/qs@6.12.2/deno/qs.mjs": "50e1117d065e1875e1735191d2e87462309f47c2ed3a8b2b5c31ce5a7326e541", + "https://esm.sh/v135/set-function-length@1.2.1/deno/set-function-length.mjs": "eccddcd4de55fc251f429bd9581d61f89bfbca77a1c00040af91bdec53e867e4", + "https://esm.sh/v135/side-channel@1.0.6/deno/side-channel.mjs": "af7f2ac6ac8ad07834a3a355c68557c6c83269111698f2366a1b4211309a2237", + "https://esm.sh/v135/stripe@16.2.0/deno/stripe.mjs": "134eacc5307325b3fa0d6bc0ac34a556e8d7fe163ce625bcaa82eac363db6d24" + }, + "workspace": { + "dependencies": [ + "npm:posthog-node@3.2.0" + ] + } +} diff --git a/supabase/functions/get_stripe_url/index.ts b/supabase/functions/get_stripe_url/index.ts index 6e69a62..d44cfad 100644 --- a/supabase/functions/get_stripe_url/index.ts +++ b/supabase/functions/get_stripe_url/index.ts @@ -1,65 +1,31 @@ import { clientRequestHandlerWithUser } from "../_shared/request.ts"; -import { supabase } from "../_shared/supabase.ts"; -import { stripe } from "../_shared/stripe.ts"; +import { createOrRetrieveCustomer, supabase } from "../_shared/supabase.ts"; +import { checkoutWithStripe, createStripePortal } from "../_shared/stripe.ts"; import { posthog } from "../_shared/posthog.ts"; clientRequestHandlerWithUser(async (req, user) => { - const { price, return_url } = await req.json(); - // get stripe information from stripe table! - const { data } = await supabase - .from("stripe") - .select() - .eq("user_id", user.id) - .maybeSingle(); - let stripeCustomerId = data?.stripe_customer_id; - if (!stripeCustomerId) { - // create stripe customer if doesn't exist - const customer = await stripe.customers.create({ - phone: user.phone, - email: user.email, - metadata: { - uid: user.id, - }, - }); - stripeCustomerId = customer.id; - supabase.from("stripe").upsert({ - user_id: user.id, - stripe_customer_id: customer.id, - }); - } + const { price: priceId, return_url } = await req.json(); + const stripeCustomerId = createOrRetrieveCustomer({ + uuid: user.id, + email: user.email || "", + }); // get price based on product let redirect_url: string | undefined; let event: string; - // check if user paid for product - const priceObj = price ? await stripe.prices.retrieve(price) : null; - if (priceObj === null) { - // open billing portal if product/subscription has been purchased - // or if price is null + // if price exists open checkout, otherwise portal + const { data: price } = await supabase + .from("prices") + .select() + .eq("id", priceId) + .maybeSingle(); + if (!price) { event = "user opens billing portal"; - const session = await stripe.billingPortal.sessions.create({ - customer: stripeCustomerId, - return_url: return_url || undefined, - }); - redirect_url = session?.url; + redirect_url = await createStripePortal(user, return_url); } else { - // open checkout session to purchase product event = "user starts checkout"; - const session = await stripe.checkout.sessions.create({ - customer: stripeCustomerId, - mode: priceObj.type == "recurring" ? "subscription" : "payment", - line_items: [ - { - price, - quantity: 1, - }, - ], - allow_promotion_codes: true, - success_url: return_url || undefined, - cancel_url: return_url || undefined, - }); - redirect_url = session?.url; + redirect_url = await checkoutWithStripe(price, user, return_url); } posthog.capture({ @@ -67,7 +33,6 @@ clientRequestHandlerWithUser(async (req, user) => { event, properties: { price, - product: priceObj?.product, $set: { stripe_customer_id: stripeCustomerId, }, diff --git a/supabase/functions/stripe_webhook/index.ts b/supabase/functions/stripe_webhook/index.ts index cf323d4..b26583e 100644 --- a/supabase/functions/stripe_webhook/index.ts +++ b/supabase/functions/stripe_webhook/index.ts @@ -1,116 +1,91 @@ -import Stripe from "https://esm.sh/@supabase/supabase-js@2.39.7"; -import { processWebhookRequest } from "../_shared/stripe.ts"; -import { supabase } from "../_shared/supabase.ts"; -import { stripe } from "../_shared/stripe.ts"; -import { posthog } from "../_shared/posthog.ts"; -import { sendEmail } from "../_shared/postmark.ts"; +import { processWebhookRequest, stripe } from "../_shared/stripe.ts"; +import Stripe from "stripe"; +import { + deletePriceRecord, + deleteProductRecord, + manageSubscriptionStatusChange, + upsertPriceRecord, + upsertProductRecord, +} from "../_shared/supabase.ts"; +const relevantEvents = new Set([ + "product.created", + "product.updated", + "product.deleted", + "price.created", + "price.updated", + "price.deleted", + "checkout.session.completed", + "customer.subscription.created", + "customer.subscription.updated", + "customer.subscription.deleted", +]); Deno.serve(async (req) => { - let receivedEvent; + let event: Stripe.Event; try { - receivedEvent = await processWebhookRequest(req); - } catch (e) { - return new Response(e.message, { status: 400 }); + event = await processWebhookRequest(req); + console.log(`🔔 Webhook received: ${event.type}`); + } catch (err) { + console.log(`❌ Error message: ${err.message}`); + return new Response(`Webhook Error: ${err.message}`, { status: 400 }); } - console.log(`🔔 Received event: ${receivedEvent.type}`); - const object = receivedEvent.data.object; - switch (receivedEvent.type) { - case "customer.subscription.deleted": - await onSubscriptionUpdated(object, true); - break; - case "customer.subscription.updated": - await onSubscriptionUpdated(object); - break; - case "customer.subscription.created": - await onSubscriptionUpdated(object); - break; - case "checkout.session.completed": - await onCheckoutComplete(object); - break; - } - return new Response(JSON.stringify({ ok: true }), { status: 200 }); -}); - -async function onSubscriptionUpdated( - subscription: Stripe.Subscription, - deleted = false, -) { - const prods = await getActiveProducts(subscription.customer); - const subscriptionItems = subscription.items.data; - const validStatuses = ["incomplete", "trialing", "active"]; - for (const item of subscriptionItems) { - const prod = item.plan.product; - - if (deleted || !validStatuses.includes(subscription.status)) { - // removes product from purchased products - const i = prods.indexOf(prod); - if (i !== -1) prods.splice(i, 1); - } else if (!prods.includes(prod)) { - prods.push(prod); + if (relevantEvents.has(event.type)) { + try { + switch (event.type) { + case "product.created": + case "product.updated": + await upsertProductRecord(event.data.object as Stripe.Product); + break; + case "price.created": + case "price.updated": + await upsertPriceRecord(event.data.object as Stripe.Price); + break; + case "price.deleted": + await deletePriceRecord(event.data.object as Stripe.Price); + break; + case "product.deleted": + await deleteProductRecord(event.data.object as Stripe.Product); + break; + case "customer.subscription.created": + case "customer.subscription.updated": + case "customer.subscription.deleted": { + const subscription = event.data.object as Stripe.Subscription; + await manageSubscriptionStatusChange( + subscription.id, + subscription.customer as string, + event.type === "customer.subscription.created", + ); + break; + } + case "checkout.session.completed": { + const checkoutSession = event.data.object as Stripe.Checkout.Session; + if (checkoutSession.mode === "subscription") { + const subscriptionId = checkoutSession.subscription; + await manageSubscriptionStatusChange( + subscriptionId as string, + checkoutSession.customer as string, + true, + ); + } + break; + } + default: + throw new Error("Unhandled relevant event!"); + } + } catch (error) { + console.log(error); + return new Response( + "Webhook handler failed. View your Next.js function logs.", + { + status: 400, + }, + ); } + } else { + return new Response(`Unsupported event type: ${event.type}`, { + status: 400, + }); } - // updates purchased_products - await supabase.from("stripe").update({ - active_products: prods, - }).eq("stripe_customer_id", subscription.customer); -} - -async function onCheckoutComplete(session: Stripe.Session) { - const prods = await getActiveProducts(session.customer); - const { data: lineItems } = await stripe.checkout.sessions.listLineItems( - session.id, - ); - - for (const item of lineItems) { - const prod = item.price.product; - // skip if product is subscription or already purchased - if (item.mode === "subscription" || prods.includes(prod)) continue; - prods.push(prod); - } - const { data: row } = await supabase.from("stripe").update({ - active_products: prods, - }).eq("stripe_customer_id", session.customer).select().maybeSingle(); - - // Sends email based on purchase - const checkoutProducts = lineItems.map((i: Stripe.LineItem) => - i.price.product - ); - await sendPurchaseEmail(checkoutProducts, session.customer_details.email); - - // posthog capture - if (!row) return; - posthog.capture({ - distinctId: row.user_id, - event: "user completes checkout", - properties: { - prods, - $set: { - "stripe_customer_id": row.stripe_customer_id, - }, - }, - }); -} - -async function getActiveProducts(customer: string): Promise { - const { data } = await supabase.from("stripe").select( - "active_products", - ).eq( - "stripe_customer_id", - customer, - ).single(); - const purchasedProds: string[] = data?.active_products || []; - return purchasedProds; -} - -async function sendPurchaseEmail(products: string[], to: string) { - // TODO: update this function based on your emailing needs - const product = products[0]; - let template = ""; - if (product === "prod_PfRVCVqv8fBrxN") { - template = "paid-docs-support"; - } - if (template) { - await sendEmail({ to, template }); - } -} + return new Response(JSON.stringify({ received: true })); +}); diff --git a/supabase/functions/types_db.ts b/supabase/functions/types_db.ts new file mode 100644 index 0000000..4617ac7 --- /dev/null +++ b/supabase/functions/types_db.ts @@ -0,0 +1,320 @@ +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[] + +export type Database = { + public: { + Tables: { + customers: { + Row: { + id: string + stripe_customer_id: string | null + } + Insert: { + id: string + stripe_customer_id?: string | null + } + Update: { + id?: string + stripe_customer_id?: string | null + } + Relationships: [ + { + foreignKeyName: "customers_id_fkey" + columns: ["id"] + isOneToOne: true + referencedRelation: "users" + referencedColumns: ["id"] + }, + ] + } + prices: { + Row: { + active: boolean | null + currency: string | null + description: string | null + id: string + interval: Database["public"]["Enums"]["pricing_plan_interval"] | null + interval_count: number | null + metadata: Json | null + product_id: string | null + trial_period_days: number | null + type: Database["public"]["Enums"]["pricing_type"] | null + unit_amount: number | null + } + Insert: { + active?: boolean | null + currency?: string | null + description?: string | null + id: string + interval?: Database["public"]["Enums"]["pricing_plan_interval"] | null + interval_count?: number | null + metadata?: Json | null + product_id?: string | null + trial_period_days?: number | null + type?: Database["public"]["Enums"]["pricing_type"] | null + unit_amount?: number | null + } + Update: { + active?: boolean | null + currency?: string | null + description?: string | null + id?: string + interval?: Database["public"]["Enums"]["pricing_plan_interval"] | null + interval_count?: number | null + metadata?: Json | null + product_id?: string | null + trial_period_days?: number | null + type?: Database["public"]["Enums"]["pricing_type"] | null + unit_amount?: number | null + } + Relationships: [ + { + foreignKeyName: "prices_product_id_fkey" + columns: ["product_id"] + isOneToOne: false + referencedRelation: "products" + referencedColumns: ["id"] + }, + ] + } + products: { + Row: { + active: boolean | null + description: string | null + id: string + image: string | null + metadata: Json | null + name: string | null + } + Insert: { + active?: boolean | null + description?: string | null + id: string + image?: string | null + metadata?: Json | null + name?: string | null + } + Update: { + active?: boolean | null + description?: string | null + id?: string + image?: string | null + metadata?: Json | null + name?: string | null + } + Relationships: [] + } + subscriptions: { + Row: { + cancel_at: string | null + cancel_at_period_end: boolean | null + canceled_at: string | null + created: string + current_period_end: string + current_period_start: string + ended_at: string | null + id: string + metadata: Json | null + price_id: string | null + quantity: number | null + status: Database["public"]["Enums"]["subscription_status"] | null + trial_end: string | null + trial_start: string | null + user_id: string + } + Insert: { + cancel_at?: string | null + cancel_at_period_end?: boolean | null + canceled_at?: string | null + created?: string + current_period_end?: string + current_period_start?: string + ended_at?: string | null + id: string + metadata?: Json | null + price_id?: string | null + quantity?: number | null + status?: Database["public"]["Enums"]["subscription_status"] | null + trial_end?: string | null + trial_start?: string | null + user_id: string + } + Update: { + cancel_at?: string | null + cancel_at_period_end?: boolean | null + canceled_at?: string | null + created?: string + current_period_end?: string + current_period_start?: string + ended_at?: string | null + id?: string + metadata?: Json | null + price_id?: string | null + quantity?: number | null + status?: Database["public"]["Enums"]["subscription_status"] | null + trial_end?: string | null + trial_start?: string | null + user_id?: string + } + Relationships: [ + { + foreignKeyName: "subscriptions_price_id_fkey" + columns: ["price_id"] + isOneToOne: false + referencedRelation: "prices" + referencedColumns: ["id"] + }, + { + foreignKeyName: "subscriptions_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] + }, + ] + } + users: { + Row: { + avatar_url: string | null + billing_address: Json | null + full_name: string | null + id: string + payment_method: Json | null + } + Insert: { + avatar_url?: string | null + billing_address?: Json | null + full_name?: string | null + id: string + payment_method?: Json | null + } + Update: { + avatar_url?: string | null + billing_address?: Json | null + full_name?: string | null + id?: string + payment_method?: Json | null + } + Relationships: [ + { + foreignKeyName: "users_id_fkey" + columns: ["id"] + isOneToOne: true + referencedRelation: "users" + referencedColumns: ["id"] + }, + ] + } + } + Views: { + [_ in never]: never + } + Functions: { + [_ in never]: never + } + Enums: { + pricing_plan_interval: "day" | "week" | "month" | "year" + pricing_type: "one_time" | "recurring" + subscription_status: + | "trialing" + | "active" + | "canceled" + | "incomplete" + | "incomplete_expired" + | "past_due" + | "unpaid" + | "paused" + } + CompositeTypes: { + [_ in never]: never + } + } +} + +type PublicSchema = Database[Extract] + +export type Tables< + PublicTableNameOrOptions extends + | keyof (PublicSchema["Tables"] & PublicSchema["Views"]) + | { schema: keyof Database }, + TableName extends PublicTableNameOrOptions extends { schema: keyof Database } + ? keyof (Database[PublicTableNameOrOptions["schema"]]["Tables"] & + Database[PublicTableNameOrOptions["schema"]]["Views"]) + : never = never, +> = PublicTableNameOrOptions extends { schema: keyof Database } + ? (Database[PublicTableNameOrOptions["schema"]]["Tables"] & + Database[PublicTableNameOrOptions["schema"]]["Views"])[TableName] extends { + Row: infer R + } + ? R + : never + : PublicTableNameOrOptions extends keyof (PublicSchema["Tables"] & + PublicSchema["Views"]) + ? (PublicSchema["Tables"] & + PublicSchema["Views"])[PublicTableNameOrOptions] extends { + Row: infer R + } + ? R + : never + : never + +export type TablesInsert< + PublicTableNameOrOptions extends + | keyof PublicSchema["Tables"] + | { schema: keyof Database }, + TableName extends PublicTableNameOrOptions extends { schema: keyof Database } + ? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = PublicTableNameOrOptions extends { schema: keyof Database } + ? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Insert: infer I + } + ? I + : never + : PublicTableNameOrOptions extends keyof PublicSchema["Tables"] + ? PublicSchema["Tables"][PublicTableNameOrOptions] extends { + Insert: infer I + } + ? I + : never + : never + +export type TablesUpdate< + PublicTableNameOrOptions extends + | keyof PublicSchema["Tables"] + | { schema: keyof Database }, + TableName extends PublicTableNameOrOptions extends { schema: keyof Database } + ? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = PublicTableNameOrOptions extends { schema: keyof Database } + ? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Update: infer U + } + ? U + : never + : PublicTableNameOrOptions extends keyof PublicSchema["Tables"] + ? PublicSchema["Tables"][PublicTableNameOrOptions] extends { + Update: infer U + } + ? U + : never + : never + +export type Enums< + PublicEnumNameOrOptions extends + | keyof PublicSchema["Enums"] + | { schema: keyof Database }, + EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database } + ? keyof Database[PublicEnumNameOrOptions["schema"]]["Enums"] + : never = never, +> = PublicEnumNameOrOptions extends { schema: keyof Database } + ? Database[PublicEnumNameOrOptions["schema"]]["Enums"][EnumName] + : PublicEnumNameOrOptions extends keyof PublicSchema["Enums"] + ? PublicSchema["Enums"][PublicEnumNameOrOptions] + : never + diff --git a/supabase/migrations/20240229165933_init_db.sql b/supabase/migrations/20240229165933_init_db.sql deleted file mode 100644 index 0f6966b..0000000 --- a/supabase/migrations/20240229165933_init_db.sql +++ /dev/null @@ -1,81 +0,0 @@ -create extension if not exists "moddatetime" with schema "extensions"; - - -create table "public"."stripe" ( - "user_id" uuid not null, - "updated_at" timestamp with time zone not null default now(), - "stripe_customer_id" text, - "created_at" timestamp with time zone not null default now(), - "one_time_payment_products" text[] not null default '{}'::text[], - "active_subscription_product" text, - "active_subscription_status" text -); - - -alter table "public"."stripe" enable row level security; - -CREATE UNIQUE INDEX customers_stripe_customer_id_key ON public.stripe USING btree (stripe_customer_id); - -CREATE UNIQUE INDEX user_metadata_pkey ON public.stripe USING btree (user_id); - -alter table "public"."stripe" add constraint "user_metadata_pkey" PRIMARY KEY using index "user_metadata_pkey"; - -alter table "public"."stripe" add constraint "customers_stripe_customer_id_key" UNIQUE using index "customers_stripe_customer_id_key"; - -alter table "public"."stripe" add constraint "stripe_user_id_fkey" FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE not valid; - -alter table "public"."stripe" validate constraint "stripe_user_id_fkey"; - -grant delete on table "public"."stripe" to "anon"; - -grant insert on table "public"."stripe" to "anon"; - -grant references on table "public"."stripe" to "anon"; - -grant select on table "public"."stripe" to "anon"; - -grant trigger on table "public"."stripe" to "anon"; - -grant truncate on table "public"."stripe" to "anon"; - -grant update on table "public"."stripe" to "anon"; - -grant delete on table "public"."stripe" to "authenticated"; - -grant insert on table "public"."stripe" to "authenticated"; - -grant references on table "public"."stripe" to "authenticated"; - -grant select on table "public"."stripe" to "authenticated"; - -grant trigger on table "public"."stripe" to "authenticated"; - -grant truncate on table "public"."stripe" to "authenticated"; - -grant update on table "public"."stripe" to "authenticated"; - -grant delete on table "public"."stripe" to "service_role"; - -grant insert on table "public"."stripe" to "service_role"; - -grant references on table "public"."stripe" to "service_role"; - -grant select on table "public"."stripe" to "service_role"; - -grant trigger on table "public"."stripe" to "service_role"; - -grant truncate on table "public"."stripe" to "service_role"; - -grant update on table "public"."stripe" to "service_role"; - -create policy "authenticated users can only see their data" -on "public"."stripe" -as permissive -for select -to authenticated -using ((auth.uid() = user_id)); - - -CREATE TRIGGER handle_updated_at BEFORE UPDATE ON public.stripe FOR EACH ROW EXECUTE FUNCTION moddatetime('updated_at'); - - diff --git a/supabase/migrations/20240229205654_remote_schema.sql b/supabase/migrations/20240229205654_remote_schema.sql deleted file mode 100644 index a0ec3ce..0000000 --- a/supabase/migrations/20240229205654_remote_schema.sql +++ /dev/null @@ -1,9 +0,0 @@ -alter table "public"."stripe" drop column "active_subscription_product"; - -alter table "public"."stripe" drop column "active_subscription_status"; - -alter table "public"."stripe" drop column "one_time_payment_products"; - -alter table "public"."stripe" add column "active_products" text[] not null default '{}'::text[]; - - diff --git a/supabase/migrations/20240717231009_init.sql b/supabase/migrations/20240717231009_init.sql new file mode 100644 index 0000000..659300d --- /dev/null +++ b/supabase/migrations/20240717231009_init.sql @@ -0,0 +1,145 @@ +/** +* USERS +* Note: This table contains user data. Users should only be able to view and update their own data. +*/ +create table users ( + -- UUID from auth.users + id uuid references auth.users not null primary key, + full_name text, + avatar_url text, + -- The customer's billing address, stored in JSON format. + billing_address jsonb, + -- Stores your customer's payment instruments. + payment_method jsonb +); +alter table users enable row level security; +create policy "Can view own user data." on users for select using (auth.uid() = id); +create policy "Can update own user data." on users for update using (auth.uid() = id); + +/** +* This trigger automatically creates a user entry when a new user signs up via Supabase Auth. +*/ +create function public.handle_new_user() +returns trigger as $$ +begin + insert into public.users (id, full_name, avatar_url) + values (new.id, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url'); + return new; +end; +$$ language plpgsql security definer; +create trigger on_auth_user_created + after insert on auth.users + for each row execute procedure public.handle_new_user(); + +/** +* CUSTOMERS +* Note: this is a private table that contains a mapping of user IDs to Stripe customer IDs. +*/ +create table customers ( + -- UUID from auth.users + id uuid references auth.users not null primary key, + -- The user's customer ID in Stripe. User must not be able to update this. + stripe_customer_id text +); +alter table customers enable row level security; +-- No policies as this is a private table that the user must not have access to. + +/** +* PRODUCTS +* Note: products are created and managed in Stripe and synced to our DB via Stripe webhooks. +*/ +create table products ( + -- Product ID from Stripe, e.g. prod_1234. + id text primary key, + -- Whether the product is currently available for purchase. + active boolean, + -- The product's name, meant to be displayable to the customer. Whenever this product is sold via a subscription, name will show up on associated invoice line item descriptions. + name text, + -- The product's description, meant to be displayable to the customer. Use this field to optionally store a long form explanation of the product being sold for your own rendering purposes. + description text, + -- A URL of the product image in Stripe, meant to be displayable to the customer. + image text, + -- Set of key-value pairs, used to store additional information about the object in a structured format. + metadata jsonb +); +alter table products enable row level security; +create policy "Allow public read-only access." on products for select using (true); + +/** +* PRICES +* Note: prices are created and managed in Stripe and synced to our DB via Stripe webhooks. +*/ +create type pricing_type as enum ('one_time', 'recurring'); +create type pricing_plan_interval as enum ('day', 'week', 'month', 'year'); +create table prices ( + -- Price ID from Stripe, e.g. price_1234. + id text primary key, + -- The ID of the prduct that this price belongs to. + product_id text references products, + -- Whether the price can be used for new purchases. + active boolean, + -- A brief description of the price. + description text, + -- The unit amount as a positive integer in the smallest currency unit (e.g., 100 cents for US$1.00 or 100 for ¥100, a zero-decimal currency). + unit_amount bigint, + -- Three-letter ISO currency code, in lowercase. + currency text check (char_length(currency) = 3), + -- One of `one_time` or `recurring` depending on whether the price is for a one-time purchase or a recurring (subscription) purchase. + type pricing_type, + -- The frequency at which a subscription is billed. One of `day`, `week`, `month` or `year`. + interval pricing_plan_interval, + -- The number of intervals (specified in the `interval` attribute) between subscription billings. For example, `interval=month` and `interval_count=3` bills every 3 months. + interval_count integer, + -- Default number of trial days when subscribing a customer to this price using [`trial_from_plan=true`](https://stripe.com/docs/api#create_subscription-trial_from_plan). + trial_period_days integer, + -- Set of key-value pairs, used to store additional information about the object in a structured format. + metadata jsonb +); +alter table prices enable row level security; +create policy "Allow public read-only access." on prices for select using (true); + +/** +* SUBSCRIPTIONS +* Note: subscriptions are created and managed in Stripe and synced to our DB via Stripe webhooks. +*/ +create type subscription_status as enum ('trialing', 'active', 'canceled', 'incomplete', 'incomplete_expired', 'past_due', 'unpaid', 'paused'); +create table subscriptions ( + -- Subscription ID from Stripe, e.g. sub_1234. + id text primary key, + user_id uuid references auth.users not null, + -- The status of the subscription object, one of subscription_status type above. + status subscription_status, + -- Set of key-value pairs, used to store additional information about the object in a structured format. + metadata jsonb, + -- ID of the price that created this subscription. + price_id text references prices, + -- Quantity multiplied by the unit amount of the price creates the amount of the subscription. Can be used to charge multiple seats. + quantity integer, + -- If true the subscription has been canceled by the user and will be deleted at the end of the billing period. + cancel_at_period_end boolean, + -- Time at which the subscription was created. + created timestamp with time zone default timezone('utc'::text, now()) not null, + -- Start of the current period that the subscription has been invoiced for. + current_period_start timestamp with time zone default timezone('utc'::text, now()) not null, + -- End of the current period that the subscription has been invoiced for. At the end of this period, a new invoice will be created. + current_period_end timestamp with time zone default timezone('utc'::text, now()) not null, + -- If the subscription has ended, the timestamp of the date the subscription ended. + ended_at timestamp with time zone default timezone('utc'::text, now()), + -- A date in the future at which the subscription will automatically get canceled. + cancel_at timestamp with time zone default timezone('utc'::text, now()), + -- If the subscription has been canceled, the date of that cancellation. If the subscription was canceled with `cancel_at_period_end`, `canceled_at` will still reflect the date of the initial cancellation request, not the end of the subscription period when the subscription is automatically moved to a canceled state. + canceled_at timestamp with time zone default timezone('utc'::text, now()), + -- If the subscription has a trial, the beginning of that trial. + trial_start timestamp with time zone default timezone('utc'::text, now()), + -- If the subscription has a trial, the end of that trial. + trial_end timestamp with time zone default timezone('utc'::text, now()) +); +alter table subscriptions enable row level security; +create policy "Can only view own subs data." on subscriptions for select using (auth.uid() = user_id); + +/** + * REALTIME SUBSCRIPTIONS + * Only allow realtime listening on public tables. + */ +drop publication if exists supabase_realtime; +create publication supabase_realtime for table products, prices;