50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/public/stripe.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
25 |
--------------------------------------------------------------------------------
/utils/supabase/server.ts:
--------------------------------------------------------------------------------
1 | import { createServerClient, type CookieOptions } from '@supabase/ssr';
2 | import { cookies } from 'next/headers';
3 | import { Database } from '@/types_db';
4 |
5 | // Define a function to create a Supabase client for server-side operations
6 | // The function takes a cookie store created with next/headers cookies as an argument
7 | export const createClient = () => {
8 | const cookieStore = cookies();
9 |
10 | return createServerClient(
11 | // Pass Supabase URL and anonymous key from the environment to the client
12 | process.env.NEXT_PUBLIC_SUPABASE_URL!,
13 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
14 |
15 | // Define a cookies object with methods for interacting with the cookie store and pass it to the client
16 | {
17 | cookies: {
18 | // The get method is used to retrieve a cookie by its name
19 | get(name: string) {
20 | return cookieStore.get(name)?.value;
21 | },
22 | // The set method is used to set a cookie with a given name, value, and options
23 | set(name: string, value: string, options: CookieOptions) {
24 | try {
25 | cookieStore.set({ name, value, ...options });
26 | } catch (error) {
27 | // If the set method is called from a Server Component, an error may occur
28 | // This can be ignored if there is middleware refreshing user sessions
29 | }
30 | },
31 | // The remove method is used to delete a cookie by its name
32 | remove(name: string, options: CookieOptions) {
33 | try {
34 | cookieStore.set({ name, value: '', ...options });
35 | } catch (error) {
36 | // If the remove method is called from a Server Component, an error may occur
37 | // This can be ignored if there is middleware refreshing user sessions
38 | }
39 | }
40 | }
41 | }
42 | );
43 | };
44 |
--------------------------------------------------------------------------------
/components/ui/AccountForms/NameForm.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Button from '@/components/ui/Button';
4 | import Card from '@/components/ui/Card';
5 | import { updateName } from '@/utils/auth-helpers/server';
6 | import { handleRequest } from '@/utils/auth-helpers/client';
7 | import { useRouter } from 'next/navigation';
8 | import { useState } from 'react';
9 |
10 | export default function NameForm({ userName }: { userName: string }) {
11 | const router = useRouter();
12 | const [isSubmitting, setIsSubmitting] = useState(false);
13 |
14 | const handleSubmit = async (e: React.FormEvent) => {
15 | setIsSubmitting(true);
16 | // Check if the new name is the same as the old name
17 | if (e.currentTarget.fullName.value === userName) {
18 | e.preventDefault();
19 | setIsSubmitting(false);
20 | return;
21 | }
22 | handleRequest(e, updateName, router);
23 | setIsSubmitting(false);
24 | };
25 |
26 | return (
27 |
32 |
111 | );
112 | }
113 |
--------------------------------------------------------------------------------
/components/ui/Footer/Footer.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 |
3 | import Logo from '@/components/icons/Logo';
4 | import GitHub from '@/components/icons/GitHub';
5 |
6 | export default function Footer() {
7 | return (
8 |
111 | );
112 | }
113 |
--------------------------------------------------------------------------------
/components/ui/Toasts/use-toast.ts:
--------------------------------------------------------------------------------
1 | // Inspired by react-hot-toast library
2 | import * as React from 'react';
3 |
4 | import type {
5 | ToastActionElement,
6 | ToastProps
7 | } from '@/components/ui/Toasts/toast';
8 |
9 | const TOAST_LIMIT = 1;
10 | const TOAST_REMOVE_DELAY = 1000000;
11 |
12 | type ToasterToast = ToastProps & {
13 | id: string;
14 | title?: React.ReactNode;
15 | description?: React.ReactNode;
16 | action?: ToastActionElement;
17 | };
18 |
19 | const actionTypes = {
20 | ADD_TOAST: 'ADD_TOAST',
21 | UPDATE_TOAST: 'UPDATE_TOAST',
22 | DISMISS_TOAST: 'DISMISS_TOAST',
23 | REMOVE_TOAST: 'REMOVE_TOAST'
24 | } as const;
25 |
26 | let count = 0;
27 |
28 | function genId() {
29 | count = (count + 1) % Number.MAX_SAFE_INTEGER;
30 | return count.toString();
31 | }
32 |
33 | type ActionType = typeof actionTypes;
34 |
35 | type Action =
36 | | {
37 | type: ActionType['ADD_TOAST'];
38 | toast: ToasterToast;
39 | }
40 | | {
41 | type: ActionType['UPDATE_TOAST'];
42 | toast: Partial;
43 | }
44 | | {
45 | type: ActionType['DISMISS_TOAST'];
46 | toastId?: ToasterToast['id'];
47 | }
48 | | {
49 | type: ActionType['REMOVE_TOAST'];
50 | toastId?: ToasterToast['id'];
51 | };
52 |
53 | interface State {
54 | toasts: ToasterToast[];
55 | }
56 |
57 | const toastTimeouts = new Map>();
58 |
59 | const addToRemoveQueue = (toastId: string) => {
60 | if (toastTimeouts.has(toastId)) {
61 | return;
62 | }
63 |
64 | const timeout = setTimeout(() => {
65 | toastTimeouts.delete(toastId);
66 | dispatch({
67 | type: 'REMOVE_TOAST',
68 | toastId: toastId
69 | });
70 | }, TOAST_REMOVE_DELAY);
71 |
72 | toastTimeouts.set(toastId, timeout);
73 | };
74 |
75 | export const reducer = (state: State, action: Action): State => {
76 | switch (action.type) {
77 | case 'ADD_TOAST':
78 | return {
79 | ...state,
80 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT)
81 | };
82 |
83 | case 'UPDATE_TOAST':
84 | return {
85 | ...state,
86 | toasts: state.toasts.map((t) =>
87 | t.id === action.toast.id ? { ...t, ...action.toast } : t
88 | )
89 | };
90 |
91 | case 'DISMISS_TOAST': {
92 | const { toastId } = action;
93 |
94 | // ! Side effects ! - This could be extracted into a dismissToast() action,
95 | // but I'll keep it here for simplicity
96 | if (toastId) {
97 | addToRemoveQueue(toastId);
98 | } else {
99 | state.toasts.forEach((toast) => {
100 | addToRemoveQueue(toast.id);
101 | });
102 | }
103 |
104 | return {
105 | ...state,
106 | toasts: state.toasts.map((t) =>
107 | t.id === toastId || toastId === undefined
108 | ? {
109 | ...t,
110 | open: false
111 | }
112 | : t
113 | )
114 | };
115 | }
116 | case 'REMOVE_TOAST':
117 | if (action.toastId === undefined) {
118 | return {
119 | ...state,
120 | toasts: []
121 | };
122 | }
123 | return {
124 | ...state,
125 | toasts: state.toasts.filter((t) => t.id !== action.toastId)
126 | };
127 | }
128 | };
129 |
130 | const listeners: Array<(state: State) => void> = [];
131 |
132 | let memoryState: State = { toasts: [] };
133 |
134 | function dispatch(action: Action) {
135 | memoryState = reducer(memoryState, action);
136 | listeners.forEach((listener) => {
137 | listener(memoryState);
138 | });
139 | }
140 |
141 | type Toast = Omit;
142 |
143 | function toast({ ...props }: Toast) {
144 | const id = genId();
145 |
146 | const update = (props: ToasterToast) =>
147 | dispatch({
148 | type: 'UPDATE_TOAST',
149 | toast: { ...props, id }
150 | });
151 | const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
152 |
153 | dispatch({
154 | type: 'ADD_TOAST',
155 | toast: {
156 | ...props,
157 | id,
158 | open: true,
159 | onOpenChange: (open) => {
160 | if (!open) dismiss();
161 | }
162 | }
163 | });
164 |
165 | return {
166 | id: id,
167 | dismiss,
168 | update
169 | };
170 | }
171 |
172 | function useToast() {
173 | const [state, setState] = React.useState(memoryState);
174 |
175 | React.useEffect(() => {
176 | listeners.push(setState);
177 | return () => {
178 | const index = listeners.indexOf(setState);
179 | if (index > -1) {
180 | listeners.splice(index, 1);
181 | }
182 | };
183 | }, [state]);
184 |
185 | return {
186 | ...state,
187 | toast,
188 | dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId })
189 | };
190 | }
191 |
192 | export { useToast, toast };
193 |
--------------------------------------------------------------------------------
/utils/stripe/server.ts:
--------------------------------------------------------------------------------
1 | 'use server';
2 |
3 | import Stripe from 'stripe';
4 | import { stripe } from '@/utils/stripe/config';
5 | import { createClient } from '@/utils/supabase/server';
6 | import { createOrRetrieveCustomer } from '@/utils/supabase/admin';
7 | import {
8 | getURL,
9 | getErrorRedirect,
10 | calculateTrialEndUnixTimestamp
11 | } from '@/utils/helpers';
12 | import { Tables } from '@/types_db';
13 |
14 | type Price = Tables<'prices'>;
15 |
16 | type CheckoutResponse = {
17 | errorRedirect?: string;
18 | sessionId?: string;
19 | };
20 |
21 | export async function checkoutWithStripe(
22 | price: Price,
23 | redirectPath: string = '/account'
24 | ): Promise {
25 | try {
26 | // Get the user from Supabase auth
27 | const supabase = createClient();
28 | const {
29 | error,
30 | data: { user }
31 | } = await supabase.auth.getUser();
32 |
33 | if (error || !user) {
34 | console.error(error);
35 | throw new Error('Could not get user session.');
36 | }
37 |
38 | // Retrieve or create the customer in Stripe
39 | let customer: string;
40 | try {
41 | customer = await createOrRetrieveCustomer({
42 | uuid: user?.id || '',
43 | email: user?.email || ''
44 | });
45 | } catch (err) {
46 | console.error(err);
47 | throw new Error('Unable to access customer record.');
48 | }
49 |
50 | let params: Stripe.Checkout.SessionCreateParams = {
51 | allow_promotion_codes: true,
52 | billing_address_collection: 'required',
53 | customer,
54 | customer_update: {
55 | address: 'auto'
56 | },
57 | line_items: [
58 | {
59 | price: price.id,
60 | quantity: 1
61 | }
62 | ],
63 | cancel_url: getURL(),
64 | success_url: getURL(redirectPath)
65 | };
66 |
67 | console.log(
68 | 'Trial end:',
69 | calculateTrialEndUnixTimestamp(price.trial_period_days)
70 | );
71 | if (price.type === 'recurring') {
72 | params = {
73 | ...params,
74 | mode: 'subscription',
75 | subscription_data: {
76 | trial_end: calculateTrialEndUnixTimestamp(price.trial_period_days)
77 | }
78 | };
79 | } else if (price.type === 'one_time') {
80 | params = {
81 | ...params,
82 | mode: 'payment'
83 | };
84 | }
85 |
86 | // Create a checkout session in Stripe
87 | let session;
88 | try {
89 | session = await stripe.checkout.sessions.create(params);
90 | } catch (err) {
91 | console.error(err);
92 | throw new Error('Unable to create checkout session.');
93 | }
94 |
95 | // Instead of returning a Response, just return the data or error.
96 | if (session) {
97 | return { sessionId: session.id };
98 | } else {
99 | throw new Error('Unable to create checkout session.');
100 | }
101 | } catch (error) {
102 | if (error instanceof Error) {
103 | return {
104 | errorRedirect: getErrorRedirect(
105 | redirectPath,
106 | error.message,
107 | 'Please try again later or contact a system administrator.'
108 | )
109 | };
110 | } else {
111 | return {
112 | errorRedirect: getErrorRedirect(
113 | redirectPath,
114 | 'An unknown error occurred.',
115 | 'Please try again later or contact a system administrator.'
116 | )
117 | };
118 | }
119 | }
120 | }
121 |
122 | export async function createStripePortal(currentPath: string) {
123 | try {
124 | const supabase = createClient();
125 | const {
126 | error,
127 | data: { user }
128 | } = await supabase.auth.getUser();
129 |
130 | if (!user) {
131 | if (error) {
132 | console.error(error);
133 | }
134 | throw new Error('Could not get user session.');
135 | }
136 |
137 | let customer;
138 | try {
139 | customer = await createOrRetrieveCustomer({
140 | uuid: user.id || '',
141 | email: user.email || ''
142 | });
143 | } catch (err) {
144 | console.error(err);
145 | throw new Error('Unable to access customer record.');
146 | }
147 |
148 | if (!customer) {
149 | throw new Error('Could not get customer.');
150 | }
151 |
152 | try {
153 | const { url } = await stripe.billingPortal.sessions.create({
154 | customer,
155 | return_url: getURL('/account')
156 | });
157 | if (!url) {
158 | throw new Error('Could not create billing portal');
159 | }
160 | return url;
161 | } catch (err) {
162 | console.error(err);
163 | throw new Error('Could not create billing portal');
164 | }
165 | } catch (error) {
166 | if (error instanceof Error) {
167 | console.error(error);
168 | return getErrorRedirect(
169 | currentPath,
170 | error.message,
171 | 'Please try again later or contact a system administrator.'
172 | );
173 | } else {
174 | return getErrorRedirect(
175 | currentPath,
176 | 'An unknown error occurred.',
177 | 'Please try again later or contact a system administrator.'
178 | );
179 | }
180 | }
181 | }
182 |
--------------------------------------------------------------------------------
/public/supabase.svg:
--------------------------------------------------------------------------------
1 |
14 |
--------------------------------------------------------------------------------
/components/ui/Toasts/toast.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as ToastPrimitives from '@radix-ui/react-toast';
3 | import { cva, type VariantProps } from 'class-variance-authority';
4 | import { X } from 'lucide-react';
5 |
6 | import { cn } from '@/utils/cn';
7 |
8 | const ToastProvider = ToastPrimitives.Provider;
9 |
10 | const ToastViewport = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ));
23 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
24 |
25 | const toastVariants = cva(
26 | 'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border border-zinc-200 p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full dark:border-zinc-800',
27 | {
28 | variants: {
29 | variant: {
30 | default:
31 | 'border bg-white text-zinc-950 dark:bg-zinc-950 dark:text-zinc-50',
32 | destructive:
33 | 'destructive group border-red-500 bg-red-500 text-zinc-50 dark:border-red-900 dark:bg-red-900 dark:text-zinc-50'
34 | }
35 | },
36 | defaultVariants: {
37 | variant: 'default'
38 | }
39 | }
40 | );
41 |
42 | const Toast = React.forwardRef<
43 | React.ElementRef,
44 | React.ComponentPropsWithoutRef &
45 | VariantProps
46 | >(({ className, variant, ...props }, ref) => {
47 | return (
48 |
53 | );
54 | });
55 | Toast.displayName = ToastPrimitives.Root.displayName;
56 |
57 | const ToastAction = React.forwardRef<
58 | React.ElementRef,
59 | React.ComponentPropsWithoutRef
60 | >(({ className, ...props }, ref) => (
61 |
69 | ));
70 | ToastAction.displayName = ToastPrimitives.Action.displayName;
71 |
72 | const ToastClose = React.forwardRef<
73 | React.ElementRef,
74 | React.ComponentPropsWithoutRef
75 | >(({ className, ...props }, ref) => (
76 |
85 |
86 |
87 | ));
88 | ToastClose.displayName = ToastPrimitives.Close.displayName;
89 |
90 | const ToastTitle = React.forwardRef<
91 | React.ElementRef,
92 | React.ComponentPropsWithoutRef
93 | >(({ className, ...props }, ref) => (
94 |
99 | ));
100 | ToastTitle.displayName = ToastPrimitives.Title.displayName;
101 |
102 | const ToastDescription = React.forwardRef<
103 | React.ElementRef,
104 | React.ComponentPropsWithoutRef
105 | >(({ className, ...props }, ref) => (
106 |
111 | ));
112 | ToastDescription.displayName = ToastPrimitives.Description.displayName;
113 |
114 | type ToastProps = React.ComponentPropsWithoutRef;
115 |
116 | type ToastActionElement = React.ReactElement;
117 |
118 | export {
119 | type ToastProps,
120 | type ToastActionElement,
121 | ToastProvider,
122 | ToastViewport,
123 | Toast,
124 | ToastTitle,
125 | ToastDescription,
126 | ToastClose,
127 | ToastAction
128 | };
129 |
--------------------------------------------------------------------------------
/supabase/config.toml:
--------------------------------------------------------------------------------
1 | # A string used to distinguish different Supabase projects on the same host. Defaults to the
2 | # working directory name when running `supabase init`.
3 | project_id = "nextjs-subscription-payments"
4 |
5 | [api]
6 | enabled = true
7 | # Port to use for the API URL.
8 | port = 54321
9 | # Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
10 | # endpoints. public and storage are always included.
11 | schemas = ["public", "storage", "graphql_public"]
12 | # Extra schemas to add to the search_path of every request. public is always included.
13 | extra_search_path = ["public", "extensions"]
14 | # The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
15 | # for accidental or malicious requests.
16 | max_rows = 1000
17 |
18 | [db]
19 | # Port to use for the local database URL.
20 | port = 54322
21 | # Port used by db diff command to initialize the shadow database.
22 | shadow_port = 54320
23 | # The database major version to use. This has to be the same as your remote database's. Run `SHOW
24 | # server_version;` on the remote database to check.
25 | major_version = 15
26 |
27 | [db.pooler]
28 | enabled = false
29 | # Port to use for the local connection pooler.
30 | port = 54329
31 | # Specifies when a server connection can be reused by other clients.
32 | # Configure one of the supported pooler modes: `transaction`, `session`.
33 | pool_mode = "transaction"
34 | # How many server connections to allow per user/database pair.
35 | default_pool_size = 20
36 | # Maximum number of client connections allowed.
37 | max_client_conn = 100
38 |
39 | [realtime]
40 | enabled = true
41 | # Bind realtime via either IPv4 or IPv6. (default: IPv6)
42 | # ip_version = "IPv6"
43 | # The maximum length in bytes of HTTP request headers. (default: 4096)
44 | # max_header_length = 4096
45 |
46 | [studio]
47 | enabled = true
48 | # Port to use for Supabase Studio.
49 | port = 54323
50 | # External URL of the API server that frontend connects to.
51 | api_url = "http://127.0.0.1"
52 |
53 | # Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
54 | # are monitored, and you can view the emails that would have been sent from the web interface.
55 | [inbucket]
56 | enabled = true
57 | # Port to use for the email testing server web interface.
58 | port = 54324
59 | # Uncomment to expose additional ports for testing user applications that send emails.
60 | # smtp_port = 54325
61 | # pop3_port = 54326
62 |
63 | [storage]
64 | enabled = true
65 | # The maximum file size allowed (e.g. "5MB", "500KB").
66 | file_size_limit = "50MiB"
67 |
68 | [auth]
69 | enabled = true
70 | # The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
71 | # in emails.
72 | site_url = "http://localhost:3000"
73 | # A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
74 | additional_redirect_urls = ["https://127.0.0.1:3000"]
75 | # How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
76 | jwt_expiry = 3600
77 | # If disabled, the refresh token will never expire.
78 | enable_refresh_token_rotation = true
79 | # Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
80 | # Requires enable_refresh_token_rotation = true.
81 | refresh_token_reuse_interval = 10
82 | # Allow/disallow new user signups to your project.
83 | enable_signup = true
84 | # Allow/disallow testing manual linking of accounts
85 | enable_manual_linking = false
86 |
87 | [auth.email]
88 | # Allow/disallow new user signups via email to your project.
89 | enable_signup = true
90 | # If enabled, a user will be required to confirm any email change on both the old, and new email
91 | # addresses. If disabled, only the new email is required to confirm.
92 | double_confirm_changes = true
93 | # If enabled, users need to confirm their email address before signing in.
94 | enable_confirmations = false
95 |
96 | # Uncomment to customize email template
97 | # [auth.email.template.invite]
98 | # subject = "You have been invited"
99 | # content_path = "./supabase/templates/invite.html"
100 |
101 | [auth.sms]
102 | # Allow/disallow new user signups via SMS to your project.
103 | enable_signup = true
104 | # If enabled, users need to confirm their phone number before signing in.
105 | enable_confirmations = false
106 | # Template for sending OTP to users
107 | template = "Your code is {{ .Code }} ."
108 |
109 | # Use pre-defined map of phone number to OTP for testing.
110 | [auth.sms.test_otp]
111 | # 4152127777 = "123456"
112 |
113 | # This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
114 | [auth.hook.custom_access_token]
115 | # enabled = true
116 | # uri = "pg-functions:////"
117 |
118 |
119 | # Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
120 | [auth.sms.twilio]
121 | enabled = false
122 | account_sid = ""
123 | message_service_sid = ""
124 | # DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
125 | auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
126 |
127 | # Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
128 | # `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
129 | # `twitter`, `slack`, `spotify`, `workos`, `zoom`.
130 | [auth.external.github]
131 | enabled = true
132 | client_id = "env(SUPABASE_AUTH_EXTERNAL_GITHUB_CLIENT_ID)"
133 | # DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
134 | secret = "env(SUPABASE_AUTH_EXTERNAL_GITHUB_SECRET)"
135 | # Overrides the default auth redirectUrl.
136 | redirect_uri = "env(SUPABASE_AUTH_EXTERNAL_GITHUB_REDIRECT_URI)"
137 | # Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
138 | # or any other third-party OIDC providers.
139 | url = ""
140 |
141 | [analytics]
142 | enabled = false
143 | port = 54327
144 | vector_port = 54328
145 | # Configure one of the supported backends: `postgres`, `bigquery`.
146 | backend = "postgres"
147 |
148 | # Experimental features may be deprecated any time
149 | [experimental]
150 | # Configures Postgres storage engine to use OrioleDB (S3)
151 | orioledb_version = ""
152 | # Configures S3 bucket URL, eg. .s3-.amazonaws.com
153 | s3_host = "env(S3_HOST)"
154 | # Configures S3 bucket region, eg. us-east-1
155 | s3_region = "env(S3_REGION)"
156 | # Configures AWS_ACCESS_KEY_ID for S3 bucket
157 | s3_access_key = "env(S3_ACCESS_KEY)"
158 | # Configures AWS_SECRET_ACCESS_KEY for S3 bucket
159 | s3_secret_key = "env(S3_SECRET_KEY)"
160 |
--------------------------------------------------------------------------------
/public/architecture_diagram.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/schema.sql:
--------------------------------------------------------------------------------
1 | /**
2 | * USERS
3 | * Note: This table contains user data. Users should only be able to view and update their own data.
4 | */
5 | create table users (
6 | -- UUID from auth.users
7 | id uuid references auth.users not null primary key,
8 | full_name text,
9 | avatar_url text,
10 | -- The customer's billing address, stored in JSON format.
11 | billing_address jsonb,
12 | -- Stores your customer's payment instruments.
13 | payment_method jsonb
14 | );
15 | alter table users enable row level security;
16 | create policy "Can view own user data." on users for select using (auth.uid() = id);
17 | create policy "Can update own user data." on users for update using (auth.uid() = id);
18 |
19 | /**
20 | * This trigger automatically creates a user entry when a new user signs up via Supabase Auth.
21 | */
22 | create function public.handle_new_user()
23 | returns trigger as $$
24 | begin
25 | insert into public.users (id, full_name, avatar_url)
26 | values (new.id, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url');
27 | return new;
28 | end;
29 | $$ language plpgsql security definer;
30 | create trigger on_auth_user_created
31 | after insert on auth.users
32 | for each row execute procedure public.handle_new_user();
33 |
34 | /**
35 | * CUSTOMERS
36 | * Note: this is a private table that contains a mapping of user IDs to Stripe customer IDs.
37 | */
38 | create table customers (
39 | -- UUID from auth.users
40 | id uuid references auth.users not null primary key,
41 | -- The user's customer ID in Stripe. User must not be able to update this.
42 | stripe_customer_id text
43 | );
44 | alter table customers enable row level security;
45 | -- No policies as this is a private table that the user must not have access to.
46 |
47 | /**
48 | * PRODUCTS
49 | * Note: products are created and managed in Stripe and synced to our DB via Stripe webhooks.
50 | */
51 | create table products (
52 | -- Product ID from Stripe, e.g. prod_1234.
53 | id text primary key,
54 | -- Whether the product is currently available for purchase.
55 | active boolean,
56 | -- 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.
57 | name text,
58 | -- 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.
59 | description text,
60 | -- A URL of the product image in Stripe, meant to be displayable to the customer.
61 | image text,
62 | -- Set of key-value pairs, used to store additional information about the object in a structured format.
63 | metadata jsonb
64 | );
65 | alter table products enable row level security;
66 | create policy "Allow public read-only access." on products for select using (true);
67 |
68 | /**
69 | * PRICES
70 | * Note: prices are created and managed in Stripe and synced to our DB via Stripe webhooks.
71 | */
72 | create type pricing_type as enum ('one_time', 'recurring');
73 | create type pricing_plan_interval as enum ('day', 'week', 'month', 'year');
74 | create table prices (
75 | -- Price ID from Stripe, e.g. price_1234.
76 | id text primary key,
77 | -- The ID of the prduct that this price belongs to.
78 | product_id text references products,
79 | -- Whether the price can be used for new purchases.
80 | active boolean,
81 | -- A brief description of the price.
82 | description text,
83 | -- 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).
84 | unit_amount bigint,
85 | -- Three-letter ISO currency code, in lowercase.
86 | currency text check (char_length(currency) = 3),
87 | -- One of `one_time` or `recurring` depending on whether the price is for a one-time purchase or a recurring (subscription) purchase.
88 | type pricing_type,
89 | -- The frequency at which a subscription is billed. One of `day`, `week`, `month` or `year`.
90 | interval pricing_plan_interval,
91 | -- The number of intervals (specified in the `interval` attribute) between subscription billings. For example, `interval=month` and `interval_count=3` bills every 3 months.
92 | interval_count integer,
93 | -- 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).
94 | trial_period_days integer,
95 | -- Set of key-value pairs, used to store additional information about the object in a structured format.
96 | metadata jsonb
97 | );
98 | alter table prices enable row level security;
99 | create policy "Allow public read-only access." on prices for select using (true);
100 |
101 | /**
102 | * SUBSCRIPTIONS
103 | * Note: subscriptions are created and managed in Stripe and synced to our DB via Stripe webhooks.
104 | */
105 | create type subscription_status as enum ('trialing', 'active', 'canceled', 'incomplete', 'incomplete_expired', 'past_due', 'unpaid', 'paused');
106 | create table subscriptions (
107 | -- Subscription ID from Stripe, e.g. sub_1234.
108 | id text primary key,
109 | user_id uuid references auth.users not null,
110 | -- The status of the subscription object, one of subscription_status type above.
111 | status subscription_status,
112 | -- Set of key-value pairs, used to store additional information about the object in a structured format.
113 | metadata jsonb,
114 | -- ID of the price that created this subscription.
115 | price_id text references prices,
116 | -- Quantity multiplied by the unit amount of the price creates the amount of the subscription. Can be used to charge multiple seats.
117 | quantity integer,
118 | -- If true the subscription has been canceled by the user and will be deleted at the end of the billing period.
119 | cancel_at_period_end boolean,
120 | -- Time at which the subscription was created.
121 | created timestamp with time zone default timezone('utc'::text, now()) not null,
122 | -- Start of the current period that the subscription has been invoiced for.
123 | current_period_start timestamp with time zone default timezone('utc'::text, now()) not null,
124 | -- End of the current period that the subscription has been invoiced for. At the end of this period, a new invoice will be created.
125 | current_period_end timestamp with time zone default timezone('utc'::text, now()) not null,
126 | -- If the subscription has ended, the timestamp of the date the subscription ended.
127 | ended_at timestamp with time zone default timezone('utc'::text, now()),
128 | -- A date in the future at which the subscription will automatically get canceled.
129 | cancel_at timestamp with time zone default timezone('utc'::text, now()),
130 | -- 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.
131 | canceled_at timestamp with time zone default timezone('utc'::text, now()),
132 | -- If the subscription has a trial, the beginning of that trial.
133 | trial_start timestamp with time zone default timezone('utc'::text, now()),
134 | -- If the subscription has a trial, the end of that trial.
135 | trial_end timestamp with time zone default timezone('utc'::text, now())
136 | );
137 | alter table subscriptions enable row level security;
138 | create policy "Can only view own subs data." on subscriptions for select using (auth.uid() = user_id);
139 |
140 | /**
141 | * REALTIME SUBSCRIPTIONS
142 | * Only allow realtime listening on public tables.
143 | */
144 | drop publication if exists supabase_realtime;
145 | create publication supabase_realtime for table products, prices;
--------------------------------------------------------------------------------
/supabase/migrations/20230530034630_init.sql:
--------------------------------------------------------------------------------
1 | /**
2 | * USERS
3 | * Note: This table contains user data. Users should only be able to view and update their own data.
4 | */
5 | create table users (
6 | -- UUID from auth.users
7 | id uuid references auth.users not null primary key,
8 | full_name text,
9 | avatar_url text,
10 | -- The customer's billing address, stored in JSON format.
11 | billing_address jsonb,
12 | -- Stores your customer's payment instruments.
13 | payment_method jsonb
14 | );
15 | alter table users enable row level security;
16 | create policy "Can view own user data." on users for select using (auth.uid() = id);
17 | create policy "Can update own user data." on users for update using (auth.uid() = id);
18 |
19 | /**
20 | * This trigger automatically creates a user entry when a new user signs up via Supabase Auth.
21 | */
22 | create function public.handle_new_user()
23 | returns trigger as $$
24 | begin
25 | insert into public.users (id, full_name, avatar_url)
26 | values (new.id, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url');
27 | return new;
28 | end;
29 | $$ language plpgsql security definer;
30 | create trigger on_auth_user_created
31 | after insert on auth.users
32 | for each row execute procedure public.handle_new_user();
33 |
34 | /**
35 | * CUSTOMERS
36 | * Note: this is a private table that contains a mapping of user IDs to Stripe customer IDs.
37 | */
38 | create table customers (
39 | -- UUID from auth.users
40 | id uuid references auth.users not null primary key,
41 | -- The user's customer ID in Stripe. User must not be able to update this.
42 | stripe_customer_id text
43 | );
44 | alter table customers enable row level security;
45 | -- No policies as this is a private table that the user must not have access to.
46 |
47 | /**
48 | * PRODUCTS
49 | * Note: products are created and managed in Stripe and synced to our DB via Stripe webhooks.
50 | */
51 | create table products (
52 | -- Product ID from Stripe, e.g. prod_1234.
53 | id text primary key,
54 | -- Whether the product is currently available for purchase.
55 | active boolean,
56 | -- 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.
57 | name text,
58 | -- 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.
59 | description text,
60 | -- A URL of the product image in Stripe, meant to be displayable to the customer.
61 | image text,
62 | -- Set of key-value pairs, used to store additional information about the object in a structured format.
63 | metadata jsonb
64 | );
65 | alter table products enable row level security;
66 | create policy "Allow public read-only access." on products for select using (true);
67 |
68 | /**
69 | * PRICES
70 | * Note: prices are created and managed in Stripe and synced to our DB via Stripe webhooks.
71 | */
72 | create type pricing_type as enum ('one_time', 'recurring');
73 | create type pricing_plan_interval as enum ('day', 'week', 'month', 'year');
74 | create table prices (
75 | -- Price ID from Stripe, e.g. price_1234.
76 | id text primary key,
77 | -- The ID of the prduct that this price belongs to.
78 | product_id text references products,
79 | -- Whether the price can be used for new purchases.
80 | active boolean,
81 | -- A brief description of the price.
82 | description text,
83 | -- 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).
84 | unit_amount bigint,
85 | -- Three-letter ISO currency code, in lowercase.
86 | currency text check (char_length(currency) = 3),
87 | -- One of `one_time` or `recurring` depending on whether the price is for a one-time purchase or a recurring (subscription) purchase.
88 | type pricing_type,
89 | -- The frequency at which a subscription is billed. One of `day`, `week`, `month` or `year`.
90 | interval pricing_plan_interval,
91 | -- The number of intervals (specified in the `interval` attribute) between subscription billings. For example, `interval=month` and `interval_count=3` bills every 3 months.
92 | interval_count integer,
93 | -- 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).
94 | trial_period_days integer,
95 | -- Set of key-value pairs, used to store additional information about the object in a structured format.
96 | metadata jsonb
97 | );
98 | alter table prices enable row level security;
99 | create policy "Allow public read-only access." on prices for select using (true);
100 |
101 | /**
102 | * SUBSCRIPTIONS
103 | * Note: subscriptions are created and managed in Stripe and synced to our DB via Stripe webhooks.
104 | */
105 | create type subscription_status as enum ('trialing', 'active', 'canceled', 'incomplete', 'incomplete_expired', 'past_due', 'unpaid', 'paused');
106 | create table subscriptions (
107 | -- Subscription ID from Stripe, e.g. sub_1234.
108 | id text primary key,
109 | user_id uuid references auth.users not null,
110 | -- The status of the subscription object, one of subscription_status type above.
111 | status subscription_status,
112 | -- Set of key-value pairs, used to store additional information about the object in a structured format.
113 | metadata jsonb,
114 | -- ID of the price that created this subscription.
115 | price_id text references prices,
116 | -- Quantity multiplied by the unit amount of the price creates the amount of the subscription. Can be used to charge multiple seats.
117 | quantity integer,
118 | -- If true the subscription has been canceled by the user and will be deleted at the end of the billing period.
119 | cancel_at_period_end boolean,
120 | -- Time at which the subscription was created.
121 | created timestamp with time zone default timezone('utc'::text, now()) not null,
122 | -- Start of the current period that the subscription has been invoiced for.
123 | current_period_start timestamp with time zone default timezone('utc'::text, now()) not null,
124 | -- End of the current period that the subscription has been invoiced for. At the end of this period, a new invoice will be created.
125 | current_period_end timestamp with time zone default timezone('utc'::text, now()) not null,
126 | -- If the subscription has ended, the timestamp of the date the subscription ended.
127 | ended_at timestamp with time zone default timezone('utc'::text, now()),
128 | -- A date in the future at which the subscription will automatically get canceled.
129 | cancel_at timestamp with time zone default timezone('utc'::text, now()),
130 | -- 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.
131 | canceled_at timestamp with time zone default timezone('utc'::text, now()),
132 | -- If the subscription has a trial, the beginning of that trial.
133 | trial_start timestamp with time zone default timezone('utc'::text, now()),
134 | -- If the subscription has a trial, the end of that trial.
135 | trial_end timestamp with time zone default timezone('utc'::text, now())
136 | );
137 | alter table subscriptions enable row level security;
138 | create policy "Can only view own subs data." on subscriptions for select using (auth.uid() = user_id);
139 |
140 | /**
141 | * REALTIME SUBSCRIPTIONS
142 | * Only allow realtime listening on public tables.
143 | */
144 | drop publication if exists supabase_realtime;
145 | create publication supabase_realtime for table products, prices;
--------------------------------------------------------------------------------
/components/ui/Pricing/Pricing.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Button from '@/components/ui/Button';
4 | import LogoCloud from '@/components/ui/LogoCloud';
5 | import type { Tables } from '@/types_db';
6 | import { getStripe } from '@/utils/stripe/client';
7 | import { checkoutWithStripe } from '@/utils/stripe/server';
8 | import { getErrorRedirect } from '@/utils/helpers';
9 | import { User } from '@supabase/supabase-js';
10 | import cn from 'classnames';
11 | import { useRouter, usePathname } from 'next/navigation';
12 | import { useState } from 'react';
13 |
14 | type Subscription = Tables<'subscriptions'>;
15 | type Product = Tables<'products'>;
16 | type Price = Tables<'prices'>;
17 | interface ProductWithPrices extends Product {
18 | prices: Price[];
19 | }
20 | interface PriceWithProduct extends Price {
21 | products: Product | null;
22 | }
23 | interface SubscriptionWithProduct extends Subscription {
24 | prices: PriceWithProduct | null;
25 | }
26 |
27 | interface Props {
28 | user: User | null | undefined;
29 | products: ProductWithPrices[];
30 | subscription: SubscriptionWithProduct | null;
31 | }
32 |
33 | type BillingInterval = 'lifetime' | 'year' | 'month';
34 |
35 | export default function Pricing({ user, products, subscription }: Props) {
36 | const intervals = Array.from(
37 | new Set(
38 | products.flatMap((product) =>
39 | product?.prices?.map((price) => price?.interval)
40 | )
41 | )
42 | );
43 | const router = useRouter();
44 | const [billingInterval, setBillingInterval] =
45 | useState('month');
46 | const [priceIdLoading, setPriceIdLoading] = useState();
47 | const currentPath = usePathname();
48 |
49 | const handleStripeCheckout = async (price: Price) => {
50 | setPriceIdLoading(price.id);
51 |
52 | if (!user) {
53 | setPriceIdLoading(undefined);
54 | return router.push('/signin/signup');
55 | }
56 |
57 | const { errorRedirect, sessionId } = await checkoutWithStripe(
58 | price,
59 | currentPath
60 | );
61 |
62 | if (errorRedirect) {
63 | setPriceIdLoading(undefined);
64 | return router.push(errorRedirect);
65 | }
66 |
67 | if (!sessionId) {
68 | setPriceIdLoading(undefined);
69 | return router.push(
70 | getErrorRedirect(
71 | currentPath,
72 | 'An unknown error occurred.',
73 | 'Please try again later or contact a system administrator.'
74 | )
75 | );
76 | }
77 |
78 | const stripe = await getStripe();
79 | stripe?.redirectToCheckout({ sessionId });
80 |
81 | setPriceIdLoading(undefined);
82 | };
83 |
84 | if (!products.length) {
85 | return (
86 |
87 |
201 |
202 | );
203 | }
204 | }
205 |
--------------------------------------------------------------------------------
/utils/auth-helpers/server.ts:
--------------------------------------------------------------------------------
1 | 'use server';
2 |
3 | import { createClient } from '@/utils/supabase/server';
4 | import { cookies } from 'next/headers';
5 | import { redirect } from 'next/navigation';
6 | import { getURL, getErrorRedirect, getStatusRedirect } from 'utils/helpers';
7 | import { getAuthTypes } from 'utils/auth-helpers/settings';
8 |
9 | function isValidEmail(email: string) {
10 | var regex = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6}$/;
11 | return regex.test(email);
12 | }
13 |
14 | export async function redirectToPath(path: string) {
15 | return redirect(path);
16 | }
17 |
18 | export async function SignOut(formData: FormData) {
19 | const pathName = String(formData.get('pathName')).trim();
20 |
21 | const supabase = createClient();
22 | const { error } = await supabase.auth.signOut();
23 |
24 | if (error) {
25 | return getErrorRedirect(
26 | pathName,
27 | 'Hmm... Something went wrong.',
28 | 'You could not be signed out.'
29 | );
30 | }
31 |
32 | return '/signin';
33 | }
34 |
35 | export async function signInWithEmail(formData: FormData) {
36 | const cookieStore = cookies();
37 | const callbackURL = getURL('/auth/callback');
38 |
39 | const email = String(formData.get('email')).trim();
40 | let redirectPath: string;
41 |
42 | if (!isValidEmail(email)) {
43 | redirectPath = getErrorRedirect(
44 | '/signin/email_signin',
45 | 'Invalid email address.',
46 | 'Please try again.'
47 | );
48 | }
49 |
50 | const supabase = createClient();
51 | let options = {
52 | emailRedirectTo: callbackURL,
53 | shouldCreateUser: true
54 | };
55 |
56 | // If allowPassword is false, do not create a new user
57 | const { allowPassword } = getAuthTypes();
58 | if (allowPassword) options.shouldCreateUser = false;
59 | const { data, error } = await supabase.auth.signInWithOtp({
60 | email,
61 | options: options
62 | });
63 |
64 | if (error) {
65 | redirectPath = getErrorRedirect(
66 | '/signin/email_signin',
67 | 'You could not be signed in.',
68 | error.message
69 | );
70 | } else if (data) {
71 | cookieStore.set('preferredSignInView', 'email_signin', { path: '/' });
72 | redirectPath = getStatusRedirect(
73 | '/signin/email_signin',
74 | 'Success!',
75 | 'Please check your email for a magic link. You may now close this tab.',
76 | true
77 | );
78 | } else {
79 | redirectPath = getErrorRedirect(
80 | '/signin/email_signin',
81 | 'Hmm... Something went wrong.',
82 | 'You could not be signed in.'
83 | );
84 | }
85 |
86 | return redirectPath;
87 | }
88 |
89 | export async function requestPasswordUpdate(formData: FormData) {
90 | const callbackURL = getURL('/auth/reset_password');
91 |
92 | // Get form data
93 | const email = String(formData.get('email')).trim();
94 | let redirectPath: string;
95 |
96 | if (!isValidEmail(email)) {
97 | redirectPath = getErrorRedirect(
98 | '/signin/forgot_password',
99 | 'Invalid email address.',
100 | 'Please try again.'
101 | );
102 | }
103 |
104 | const supabase = createClient();
105 |
106 | const { data, error } = await supabase.auth.resetPasswordForEmail(email, {
107 | redirectTo: callbackURL
108 | });
109 |
110 | if (error) {
111 | redirectPath = getErrorRedirect(
112 | '/signin/forgot_password',
113 | error.message,
114 | 'Please try again.'
115 | );
116 | } else if (data) {
117 | redirectPath = getStatusRedirect(
118 | '/signin/forgot_password',
119 | 'Success!',
120 | 'Please check your email for a password reset link. You may now close this tab.',
121 | true
122 | );
123 | } else {
124 | redirectPath = getErrorRedirect(
125 | '/signin/forgot_password',
126 | 'Hmm... Something went wrong.',
127 | 'Password reset email could not be sent.'
128 | );
129 | }
130 |
131 | return redirectPath;
132 | }
133 |
134 | export async function signInWithPassword(formData: FormData) {
135 | const cookieStore = cookies();
136 | const email = String(formData.get('email')).trim();
137 | const password = String(formData.get('password')).trim();
138 | let redirectPath: string;
139 |
140 | const supabase = createClient();
141 | const { error, data } = await supabase.auth.signInWithPassword({
142 | email,
143 | password
144 | });
145 |
146 | if (error) {
147 | redirectPath = getErrorRedirect(
148 | '/signin/password_signin',
149 | 'Sign in failed.',
150 | error.message
151 | );
152 | } else if (data.user) {
153 | cookieStore.set('preferredSignInView', 'password_signin', { path: '/' });
154 | redirectPath = getStatusRedirect('/', 'Success!', 'You are now signed in.');
155 | } else {
156 | redirectPath = getErrorRedirect(
157 | '/signin/password_signin',
158 | 'Hmm... Something went wrong.',
159 | 'You could not be signed in.'
160 | );
161 | }
162 |
163 | return redirectPath;
164 | }
165 |
166 | export async function signUp(formData: FormData) {
167 | const callbackURL = getURL('/auth/callback');
168 |
169 | const email = String(formData.get('email')).trim();
170 | const password = String(formData.get('password')).trim();
171 | let redirectPath: string;
172 |
173 | if (!isValidEmail(email)) {
174 | redirectPath = getErrorRedirect(
175 | '/signin/signup',
176 | 'Invalid email address.',
177 | 'Please try again.'
178 | );
179 | }
180 |
181 | const supabase = createClient();
182 | const { error, data } = await supabase.auth.signUp({
183 | email,
184 | password,
185 | options: {
186 | emailRedirectTo: callbackURL
187 | }
188 | });
189 |
190 | if (error) {
191 | redirectPath = getErrorRedirect(
192 | '/signin/signup',
193 | 'Sign up failed.',
194 | error.message
195 | );
196 | } else if (data.session) {
197 | redirectPath = getStatusRedirect('/', 'Success!', 'You are now signed in.');
198 | } else if (
199 | data.user &&
200 | data.user.identities &&
201 | data.user.identities.length == 0
202 | ) {
203 | redirectPath = getErrorRedirect(
204 | '/signin/signup',
205 | 'Sign up failed.',
206 | 'There is already an account associated with this email address. Try resetting your password.'
207 | );
208 | } else if (data.user) {
209 | redirectPath = getStatusRedirect(
210 | '/',
211 | 'Success!',
212 | 'Please check your email for a confirmation link. You may now close this tab.'
213 | );
214 | } else {
215 | redirectPath = getErrorRedirect(
216 | '/signin/signup',
217 | 'Hmm... Something went wrong.',
218 | 'You could not be signed up.'
219 | );
220 | }
221 |
222 | return redirectPath;
223 | }
224 |
225 | export async function updatePassword(formData: FormData) {
226 | const password = String(formData.get('password')).trim();
227 | const passwordConfirm = String(formData.get('passwordConfirm')).trim();
228 | let redirectPath: string;
229 |
230 | // Check that the password and confirmation match
231 | if (password !== passwordConfirm) {
232 | redirectPath = getErrorRedirect(
233 | '/signin/update_password',
234 | 'Your password could not be updated.',
235 | 'Passwords do not match.'
236 | );
237 | }
238 |
239 | const supabase = createClient();
240 | const { error, data } = await supabase.auth.updateUser({
241 | password
242 | });
243 |
244 | if (error) {
245 | redirectPath = getErrorRedirect(
246 | '/signin/update_password',
247 | 'Your password could not be updated.',
248 | error.message
249 | );
250 | } else if (data.user) {
251 | redirectPath = getStatusRedirect(
252 | '/',
253 | 'Success!',
254 | 'Your password has been updated.'
255 | );
256 | } else {
257 | redirectPath = getErrorRedirect(
258 | '/signin/update_password',
259 | 'Hmm... Something went wrong.',
260 | 'Your password could not be updated.'
261 | );
262 | }
263 |
264 | return redirectPath;
265 | }
266 |
267 | export async function updateEmail(formData: FormData) {
268 | // Get form data
269 | const newEmail = String(formData.get('newEmail')).trim();
270 |
271 | // Check that the email is valid
272 | if (!isValidEmail(newEmail)) {
273 | return getErrorRedirect(
274 | '/account',
275 | 'Your email could not be updated.',
276 | 'Invalid email address.'
277 | );
278 | }
279 |
280 | const supabase = createClient();
281 |
282 | const callbackUrl = getURL(
283 | getStatusRedirect('/account', 'Success!', `Your email has been updated.`)
284 | );
285 |
286 | const { error } = await supabase.auth.updateUser(
287 | { email: newEmail },
288 | {
289 | emailRedirectTo: callbackUrl
290 | }
291 | );
292 |
293 | if (error) {
294 | return getErrorRedirect(
295 | '/account',
296 | 'Your email could not be updated.',
297 | error.message
298 | );
299 | } else {
300 | return getStatusRedirect(
301 | '/account',
302 | 'Confirmation emails sent.',
303 | `You will need to confirm the update by clicking the links sent to both the old and new email addresses.`
304 | );
305 | }
306 | }
307 |
308 | export async function updateName(formData: FormData) {
309 | // Get form data
310 | const fullName = String(formData.get('fullName')).trim();
311 |
312 | const supabase = createClient();
313 | const { error, data } = await supabase.auth.updateUser({
314 | data: { full_name: fullName }
315 | });
316 |
317 | if (error) {
318 | return getErrorRedirect(
319 | '/account',
320 | 'Your name could not be updated.',
321 | error.message
322 | );
323 | } else if (data.user) {
324 | return getStatusRedirect(
325 | '/account',
326 | 'Success!',
327 | 'Your name has been updated.'
328 | );
329 | } else {
330 | return getErrorRedirect(
331 | '/account',
332 | 'Hmm... Something went wrong.',
333 | 'Your name could not be updated.'
334 | );
335 | }
336 | }
337 |
--------------------------------------------------------------------------------
/utils/supabase/admin.ts:
--------------------------------------------------------------------------------
1 | import { toDateTime } from '@/utils/helpers';
2 | import { stripe } from '@/utils/stripe/config';
3 | import { createClient } from '@supabase/supabase-js';
4 | import Stripe from 'stripe';
5 | import type { Database, Tables, TablesInsert } from 'types_db';
6 |
7 | type Product = Tables<'products'>;
8 | type Price = Tables<'prices'>;
9 |
10 | // Change to control trial period length
11 | const TRIAL_PERIOD_DAYS = 0;
12 |
13 | // Note: supabaseAdmin uses the SERVICE_ROLE_KEY which you must only use in a secure server-side context
14 | // as it has admin privileges and overwrites RLS policies!
15 | const supabaseAdmin = createClient(
16 | process.env.NEXT_PUBLIC_SUPABASE_URL || '',
17 | process.env.SUPABASE_SERVICE_ROLE_KEY || ''
18 | );
19 |
20 | const upsertProductRecord = async (product: Stripe.Product) => {
21 | const productData: Product = {
22 | id: product.id,
23 | active: product.active,
24 | name: product.name,
25 | description: product.description ?? null,
26 | image: product.images?.[0] ?? null,
27 | metadata: product.metadata
28 | };
29 |
30 | const { error: upsertError } = await supabaseAdmin
31 | .from('products')
32 | .upsert([productData]);
33 | if (upsertError)
34 | throw new Error(`Product insert/update failed: ${upsertError.message}`);
35 | console.log(`Product inserted/updated: ${product.id}`);
36 | };
37 |
38 | const upsertPriceRecord = async (
39 | price: Stripe.Price,
40 | retryCount = 0,
41 | maxRetries = 3
42 | ) => {
43 | const priceData: Price = {
44 | id: price.id,
45 | product_id: typeof price.product === 'string' ? price.product : '',
46 | active: price.active,
47 | currency: price.currency,
48 | type: price.type,
49 | unit_amount: price.unit_amount ?? null,
50 | interval: price.recurring?.interval ?? null,
51 | interval_count: price.recurring?.interval_count ?? null,
52 | trial_period_days: price.recurring?.trial_period_days ?? TRIAL_PERIOD_DAYS
53 | };
54 |
55 | const { error: upsertError } = await supabaseAdmin
56 | .from('prices')
57 | .upsert([priceData]);
58 |
59 | if (upsertError?.message.includes('foreign key constraint')) {
60 | if (retryCount < maxRetries) {
61 | console.log(`Retry attempt ${retryCount + 1} for price ID: ${price.id}`);
62 | await new Promise((resolve) => setTimeout(resolve, 2000));
63 | await upsertPriceRecord(price, retryCount + 1, maxRetries);
64 | } else {
65 | throw new Error(
66 | `Price insert/update failed after ${maxRetries} retries: ${upsertError.message}`
67 | );
68 | }
69 | } else if (upsertError) {
70 | throw new Error(`Price insert/update failed: ${upsertError.message}`);
71 | } else {
72 | console.log(`Price inserted/updated: ${price.id}`);
73 | }
74 | };
75 |
76 | const deleteProductRecord = async (product: Stripe.Product) => {
77 | const { error: deletionError } = await supabaseAdmin
78 | .from('products')
79 | .delete()
80 | .eq('id', product.id);
81 | if (deletionError)
82 | throw new Error(`Product deletion failed: ${deletionError.message}`);
83 | console.log(`Product deleted: ${product.id}`);
84 | };
85 |
86 | const deletePriceRecord = async (price: Stripe.Price) => {
87 | const { error: deletionError } = await supabaseAdmin
88 | .from('prices')
89 | .delete()
90 | .eq('id', price.id);
91 | if (deletionError) throw new Error(`Price deletion failed: ${deletionError.message}`);
92 | console.log(`Price deleted: ${price.id}`);
93 | };
94 |
95 | const upsertCustomerToSupabase = async (uuid: string, customerId: string) => {
96 | const { error: upsertError } = await supabaseAdmin
97 | .from('customers')
98 | .upsert([{ id: uuid, stripe_customer_id: customerId }]);
99 |
100 | if (upsertError)
101 | throw new Error(`Supabase customer record creation failed: ${upsertError.message}`);
102 |
103 | return customerId;
104 | };
105 |
106 | const createCustomerInStripe = async (uuid: string, email: string) => {
107 | const customerData = { metadata: { supabaseUUID: uuid }, email: email };
108 | const newCustomer = await stripe.customers.create(customerData);
109 | if (!newCustomer) throw new Error('Stripe customer creation failed.');
110 |
111 | return newCustomer.id;
112 | };
113 |
114 | const createOrRetrieveCustomer = async ({
115 | email,
116 | uuid
117 | }: {
118 | email: string;
119 | uuid: string;
120 | }) => {
121 | // Check if the customer already exists in Supabase
122 | const { data: existingSupabaseCustomer, error: queryError } =
123 | await supabaseAdmin
124 | .from('customers')
125 | .select('*')
126 | .eq('id', uuid)
127 | .maybeSingle();
128 |
129 | if (queryError) {
130 | throw new Error(`Supabase customer lookup failed: ${queryError.message}`);
131 | }
132 |
133 | // Retrieve the Stripe customer ID using the Supabase customer ID, with email fallback
134 | let stripeCustomerId: string | undefined;
135 | if (existingSupabaseCustomer?.stripe_customer_id) {
136 | const existingStripeCustomer = await stripe.customers.retrieve(
137 | existingSupabaseCustomer.stripe_customer_id
138 | );
139 | stripeCustomerId = existingStripeCustomer.id;
140 | } else {
141 | // If Stripe ID is missing from Supabase, try to retrieve Stripe customer ID by email
142 | const stripeCustomers = await stripe.customers.list({ email: email });
143 | stripeCustomerId =
144 | stripeCustomers.data.length > 0 ? stripeCustomers.data[0].id : undefined;
145 | }
146 |
147 | // If still no stripeCustomerId, create a new customer in Stripe
148 | const stripeIdToInsert = stripeCustomerId
149 | ? stripeCustomerId
150 | : await createCustomerInStripe(uuid, email);
151 | if (!stripeIdToInsert) throw new Error('Stripe customer creation failed.');
152 |
153 | if (existingSupabaseCustomer && stripeCustomerId) {
154 | // If Supabase has a record but doesn't match Stripe, update Supabase record
155 | if (existingSupabaseCustomer.stripe_customer_id !== stripeCustomerId) {
156 | const { error: updateError } = await supabaseAdmin
157 | .from('customers')
158 | .update({ stripe_customer_id: stripeCustomerId })
159 | .eq('id', uuid);
160 |
161 | if (updateError)
162 | throw new Error(
163 | `Supabase customer record update failed: ${updateError.message}`
164 | );
165 | console.warn(
166 | `Supabase customer record mismatched Stripe ID. Supabase record updated.`
167 | );
168 | }
169 | // If Supabase has a record and matches Stripe, return Stripe customer ID
170 | return stripeCustomerId;
171 | } else {
172 | console.warn(
173 | `Supabase customer record was missing. A new record was created.`
174 | );
175 |
176 | // If Supabase has no record, create a new record and return Stripe customer ID
177 | const upsertedStripeCustomer = await upsertCustomerToSupabase(
178 | uuid,
179 | stripeIdToInsert
180 | );
181 | if (!upsertedStripeCustomer)
182 | throw new Error('Supabase customer record creation failed.');
183 |
184 | return upsertedStripeCustomer;
185 | }
186 | };
187 |
188 | /**
189 | * Copies the billing details from the payment method to the customer object.
190 | */
191 | const copyBillingDetailsToCustomer = async (
192 | uuid: string,
193 | payment_method: Stripe.PaymentMethod
194 | ) => {
195 | //Todo: check this assertion
196 | const customer = payment_method.customer as string;
197 | const { name, phone, address } = payment_method.billing_details;
198 | if (!name || !phone || !address) return;
199 | //@ts-ignore
200 | await stripe.customers.update(customer, { name, phone, address });
201 | const { error: updateError } = await supabaseAdmin
202 | .from('users')
203 | .update({
204 | billing_address: { ...address },
205 | payment_method: { ...payment_method[payment_method.type] }
206 | })
207 | .eq('id', uuid);
208 | if (updateError) throw new Error(`Customer update failed: ${updateError.message}`);
209 | };
210 |
211 | const manageSubscriptionStatusChange = async (
212 | subscriptionId: string,
213 | customerId: string,
214 | createAction = false
215 | ) => {
216 | // Get customer's UUID from mapping table.
217 | const { data: customerData, error: noCustomerError } = await supabaseAdmin
218 | .from('customers')
219 | .select('id')
220 | .eq('stripe_customer_id', customerId)
221 | .single();
222 |
223 | if (noCustomerError)
224 | throw new Error(`Customer lookup failed: ${noCustomerError.message}`);
225 |
226 | const { id: uuid } = customerData!;
227 |
228 | const subscription = await stripe.subscriptions.retrieve(subscriptionId, {
229 | expand: ['default_payment_method']
230 | });
231 | // Upsert the latest status of the subscription object.
232 | const subscriptionData: TablesInsert<'subscriptions'> = {
233 | id: subscription.id,
234 | user_id: uuid,
235 | metadata: subscription.metadata,
236 | status: subscription.status,
237 | price_id: subscription.items.data[0].price.id,
238 | //TODO check quantity on subscription
239 | // @ts-ignore
240 | quantity: subscription.quantity,
241 | cancel_at_period_end: subscription.cancel_at_period_end,
242 | cancel_at: subscription.cancel_at
243 | ? toDateTime(subscription.cancel_at).toISOString()
244 | : null,
245 | canceled_at: subscription.canceled_at
246 | ? toDateTime(subscription.canceled_at).toISOString()
247 | : null,
248 | current_period_start: toDateTime(
249 | subscription.current_period_start
250 | ).toISOString(),
251 | current_period_end: toDateTime(
252 | subscription.current_period_end
253 | ).toISOString(),
254 | created: toDateTime(subscription.created).toISOString(),
255 | ended_at: subscription.ended_at
256 | ? toDateTime(subscription.ended_at).toISOString()
257 | : null,
258 | trial_start: subscription.trial_start
259 | ? toDateTime(subscription.trial_start).toISOString()
260 | : null,
261 | trial_end: subscription.trial_end
262 | ? toDateTime(subscription.trial_end).toISOString()
263 | : null
264 | };
265 |
266 | const { error: upsertError } = await supabaseAdmin
267 | .from('subscriptions')
268 | .upsert([subscriptionData]);
269 | if (upsertError)
270 | throw new Error(`Subscription insert/update failed: ${upsertError.message}`);
271 | console.log(
272 | `Inserted/updated subscription [${subscription.id}] for user [${uuid}]`
273 | );
274 |
275 | // For a new subscription copy the billing details to the customer object.
276 | // NOTE: This is a costly operation and should happen at the very end.
277 | if (createAction && subscription.default_payment_method && uuid)
278 | //@ts-ignore
279 | await copyBillingDetailsToCustomer(
280 | uuid,
281 | subscription.default_payment_method as Stripe.PaymentMethod
282 | );
283 | };
284 |
285 | export {
286 | upsertProductRecord,
287 | upsertPriceRecord,
288 | deleteProductRecord,
289 | deletePriceRecord,
290 | createOrRetrieveCustomer,
291 | manageSubscriptionStatusChange
292 | };
293 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Next.js Subscription Payments Starter
2 |
3 |
4 | > [!WARNING]
5 | > This repo has been sunset and replaced by a new template: https://github.com/nextjs/saas-starter
6 |
7 | ## Features
8 |
9 | - Secure user management and authentication with [Supabase](https://supabase.io/docs/guides/auth)
10 | - Powerful data access & management tooling on top of PostgreSQL with [Supabase](https://supabase.io/docs/guides/database)
11 | - Integration with [Stripe Checkout](https://stripe.com/docs/payments/checkout) and the [Stripe customer portal](https://stripe.com/docs/billing/subscriptions/customer-portal)
12 | - Automatic syncing of pricing plans and subscription statuses via [Stripe webhooks](https://stripe.com/docs/webhooks)
13 |
14 | ## Demo
15 |
16 | - https://subscription-payments.vercel.app/
17 |
18 | [](https://subscription-payments.vercel.app/)
19 |
20 | ## Architecture
21 |
22 | 
23 |
24 | ## Step-by-step setup
25 |
26 | When deploying this template, the sequence of steps is important. Follow the steps below in order to get up and running.
27 |
28 | ### Initiate Deployment
29 |
30 | #### Vercel Deploy Button
31 |
32 | [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fnextjs-subscription-payments&env=NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,STRIPE_SECRET_KEY&envDescription=Enter%20your%20Stripe%20API%20keys.&envLink=https%3A%2F%2Fdashboard.stripe.com%2Fapikeys&project-name=nextjs-subscription-payments&repository-name=nextjs-subscription-payments&integration-ids=oac_VqOgBHqhEoFTPzGkPd7L0iH6&external-id=https%3A%2F%2Fgithub.com%2Fvercel%2Fnextjs-subscription-payments%2Ftree%2Fmain)
33 |
34 | The Vercel Deployment will create a new repository with this template on your GitHub account and guide you through a new Supabase project creation. The [Supabase Vercel Deploy Integration](https://vercel.com/integrations/supabase) will set up the necessary Supabase environment variables and run the [SQL migrations](./supabase/migrations/20230530034630_init.sql) to set up the Database schema on your account. You can inspect the created tables in your project's [Table editor](https://app.supabase.com/project/_/editor).
35 |
36 | Should the automatic setup fail, please [create a Supabase account](https://app.supabase.com/projects), and a new project if needed. In your project, navigate to the [SQL editor](https://app.supabase.com/project/_/sql) and select the "Stripe Subscriptions" starter template from the Quick start section.
37 |
38 | ### Configure Auth
39 |
40 | Follow [this guide](https://supabase.com/docs/guides/auth/social-login/auth-github) to set up an OAuth app with GitHub and configure Supabase to use it as an auth provider.
41 |
42 | In your Supabase project, navigate to [auth > URL configuration](https://app.supabase.com/project/_/auth/url-configuration) and set your main production URL (e.g. https://your-deployment-url.vercel.app) as the site url.
43 |
44 | Next, in your Vercel deployment settings, add a new **Production** environment variable called `NEXT_PUBLIC_SITE_URL` and set it to the same URL. Make sure to deselect preview and development environments to make sure that preview branches and local development work correctly.
45 |
46 | #### [Optional] - Set up redirect wildcards for deploy previews (not needed if you installed via the Deploy Button)
47 |
48 | If you've deployed this template via the "Deploy to Vercel" button above, you can skip this step. The Supabase Vercel Integration will have set redirect wildcards for you. You can check this by going to your Supabase [auth settings](https://app.supabase.com/project/_/auth/url-configuration) and you should see a list of redirects under "Redirect URLs".
49 |
50 | Otherwise, for auth redirects (email confirmations, magic links, OAuth providers) to work correctly in deploy previews, navigate to the [auth settings](https://app.supabase.com/project/_/auth/url-configuration) and add the following wildcard URL to "Redirect URLs": `https://*-username.vercel.app/**`. You can read more about redirect wildcard patterns in the [docs](https://supabase.com/docs/guides/auth#redirect-urls-and-wildcards).
51 |
52 | If you've deployed this template via the "Deploy to Vercel" button above, you can skip this step. The Supabase Vercel Integration will have run database migrations for you. You can check this by going to [the Table Editor for your Supabase project](https://supabase.com/dashboard/project/_/editor), and confirming there are tables with seed data.
53 |
54 | Otherwise, navigate to the [SQL Editor](https://supabase.com/dashboard/project/_/sql/new), paste the contents of [the Supabase `schema.sql` file](./schema.sql), and click RUN to initialize the database.
55 |
56 | #### [Maybe Optional] - Set up Supabase environment variables (not needed if you installed via the Deploy Button)
57 |
58 | If you've deployed this template via the "Deploy to Vercel" button above, you can skip this step. The Supabase Vercel Integration will have set your environment variables for you. You can check this by going to your Vercel project settings, and clicking on 'Environment variables', there will be a list of environment variables with the Supabase icon displayed next to them.
59 |
60 | Otherwise navigate to the [API settings](https://app.supabase.com/project/_/settings/api) and paste them into the Vercel deployment interface. Copy project API keys and paste into the `NEXT_PUBLIC_SUPABASE_ANON_KEY` and `SUPABASE_SERVICE_ROLE_KEY` fields, and copy the project URL and paste to Vercel as `NEXT_PUBLIC_SUPABASE_URL`.
61 |
62 | Congrats, this completes the Supabase setup, almost there!
63 |
64 | ### Configure Stripe
65 |
66 | Next, we'll need to configure [Stripe](https://stripe.com/) to handle test payments. If you don't already have a Stripe account, create one now.
67 |
68 | For the following steps, make sure you have the ["Test Mode" toggle](https://stripe.com/docs/testing) switched on.
69 |
70 | #### Create a Webhook
71 |
72 | We need to create a webhook in the `Developers` section of Stripe. Pictured in the architecture diagram above, this webhook is the piece that connects Stripe to your Vercel Serverless Functions.
73 |
74 | 1. Click the "Add Endpoint" button on the [test Endpoints page](https://dashboard.stripe.com/test/webhooks).
75 | 1. Enter your production deployment URL followed by `/api/webhooks` for the endpoint URL. (e.g. `https://your-deployment-url.vercel.app/api/webhooks`)
76 | 1. Click `Select events` under the `Select events to listen to` heading.
77 | 1. Click `Select all events` in the `Select events to send` section.
78 | 1. Copy `Signing secret` as we'll need that in the next step (e.g `whsec_xxx`) (/!\ be careful not to copy the webook id we_xxxx).
79 | 1. In addition to the `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` and the `STRIPE_SECRET_KEY` we've set earlier during deployment, we need to add the webhook secret as `STRIPE_WEBHOOK_SECRET` env var.
80 |
81 | #### Redeploy with new env vars
82 |
83 | For the newly set environment variables to take effect and everything to work together correctly, we need to redeploy our app in Vercel. In your Vercel Dashboard, navigate to deployments, click the overflow menu button and select "Redeploy" (do NOT enable the "Use existing Build Cache" option). Once Vercel has rebuilt and redeployed your app, you're ready to set up your products and prices.
84 |
85 | #### Create product and pricing information
86 |
87 | Your application's webhook listens for product updates on Stripe and automatically propagates them to your Supabase database. So with your webhook listener running, you can now create your product and pricing information in the [Stripe Dashboard](https://dashboard.stripe.com/test/products).
88 |
89 | Stripe Checkout currently supports pricing that bills a predefined amount at a specific interval. More complex plans (e.g., different pricing tiers or seats) are not yet supported.
90 |
91 | For example, you can create business models with different pricing tiers, e.g.:
92 |
93 | - Product 1: Hobby
94 | - Price 1: 10 USD per month
95 | - Price 2: 100 USD per year
96 | - Product 2: Freelancer
97 | - Price 1: 20 USD per month
98 | - Price 2: 200 USD per year
99 |
100 | Optionally, to speed up the setup, we have added a [fixtures file](fixtures/stripe-fixtures.json) to bootstrap test product and pricing data in your Stripe account. The [Stripe CLI](https://stripe.com/docs/stripe-cli#install) `fixtures` command executes a series of API requests defined in this JSON file. Simply run `stripe fixtures fixtures/stripe-fixtures.json`.
101 |
102 | **Important:** Make sure that you've configured your Stripe webhook correctly and redeployed with all needed environment variables.
103 |
104 | #### Configure the Stripe customer portal
105 |
106 | 1. Set your custom branding in the [settings](https://dashboard.stripe.com/settings/branding)
107 | 1. Configure the Customer Portal [settings](https://dashboard.stripe.com/test/settings/billing/portal)
108 | 1. Toggle on "Allow customers to update their payment methods"
109 | 1. Toggle on "Allow customers to update subscriptions"
110 | 1. Toggle on "Allow customers to cancel subscriptions"
111 | 1. Add the products and prices that you want
112 | 1. Set up the required business information and links
113 |
114 | ### That's it
115 |
116 | I know, that was quite a lot to get through, but it's worth it. You're now ready to earn recurring revenue from your customers. 🥳
117 |
118 | ## Develop locally
119 |
120 | If you haven't already done so, clone your Github repository to your local machine.
121 |
122 | ### Install dependencies
123 |
124 | Ensure you have [pnpm](https://pnpm.io/installation) installed and run:
125 |
126 | ```bash
127 | pnpm install
128 | ```
129 |
130 | Next, use the [Vercel CLI](https://vercel.com/docs/cli) to link your project:
131 |
132 | ```bash
133 | pnpm dlx vercel login
134 | pnpm dlx vercel link
135 | ```
136 |
137 | `pnpm dlx` runs a package from the registry, without installing it as a dependency. Alternatively, you can install these packages globally, and drop the `pnpm dlx` part.
138 |
139 | If you don't intend to use a local Supabase instance for development and testing, you can use the Vercel CLI to download the development env vars:
140 |
141 | ```bash
142 | pnpm dlx vercel env pull .env.local
143 | ```
144 |
145 | Running this command will create a new `.env.local` file in your project folder. For security purposes, you will need to set the `SUPABASE_SERVICE_ROLE_KEY` manually from your [Supabase dashboard](https://app.supabase.io/) (`Settings > API`). If you are not using a local Supabase instance, you should also change the `--local` flag to `--linked' or '--project-id ' in the `supabase:generate-types` script in `package.json`.(see -> [https://supabase.com/docs/reference/cli/supabase-gen-types-typescript])
146 |
147 | ### Local development with Supabase
148 |
149 | It's highly recommended to use a local Supabase instance for development and testing. We have provided a set of custom commands for this in `package.json`.
150 |
151 | First, you will need to install [Docker](https://www.docker.com/get-started/). You should also copy or rename:
152 |
153 | - `.env.local.example` -> `.env.local`
154 | - `.env.example` -> `.env`
155 |
156 | Next, run the following command to start a local Supabase instance and run the migrations to set up the database schema:
157 |
158 | ```bash
159 | pnpm supabase:start
160 | ```
161 |
162 | The terminal output will provide you with URLs to access the different services within the Supabase stack. The Supabase Studio is where you can make changes to your local database instance.
163 |
164 | Copy the value for the `service_role_key` and paste it as the value for the `SUPABASE_SERVICE_ROLE_KEY` in your `.env.local` file.
165 |
166 | You can print out these URLs at any time with the following command:
167 |
168 | ```bash
169 | pnpm supabase:status
170 | ```
171 |
172 | To link your local Supabase instance to your project, run the following command, navigate to the Supabase project you created above, and enter your database password.
173 |
174 | ```bash
175 | pnpm supabase:link
176 | ```
177 |
178 | If you need to reset your database password, head over to [your database settings](https://supabase.com/dashboard/project/_/settings/database) and click "Reset database password", and this time copy it across to a password manager! 😄
179 |
180 | 🚧 Warning: This links our Local Development instance to the project we are using for `production`. Currently, it only has test records, but once it has customer data, we recommend using [Branching](https://supabase.com/docs/guides/platform/branching) or manually creating a separate `preview` or `staging` environment, to ensure your customer's data is not used locally, and schema changes/migrations can be thoroughly tested before shipping to `production`.
181 |
182 | Once you've linked your project, you can pull down any schema changes you made in your remote database with:
183 |
184 | ```bash
185 | pnpm supabase:pull
186 | ```
187 |
188 | You can seed your local database with any data you added in your remote database with:
189 |
190 | ```bash
191 | pnpm supabase:generate-seed
192 | pnpm supabase:reset
193 | ```
194 |
195 | 🚧 Warning: this is seeding data from the `production` database. Currently, this only contains test data, but we recommend using [Branching](https://supabase.com/docs/guides/platform/branching) or manually setting up a `preview` or `staging` environment once this contains real customer data.
196 |
197 | You can make changes to the database schema in your local Supabase Studio and run the following command to generate TypeScript types to match your schema:
198 |
199 | ```bash
200 | pnpm supabase:generate-types
201 | ```
202 |
203 | You can also automatically generate a migration file with all the changes you've made to your local database schema with the following command:
204 |
205 | ```bash
206 | pnpm supabase:generate-migration
207 | ```
208 |
209 | And push those changes to your remote database with:
210 |
211 | ```bash
212 | pnpm supabase:push
213 | ```
214 |
215 | Remember to test your changes thoroughly in your `local` and `staging` or `preview` environments before deploying them to `production`!
216 |
217 | ### Use the Stripe CLI to test webhooks
218 |
219 | Use the [Stripe CLI](https://stripe.com/docs/stripe-cli) to [login to your Stripe account](https://stripe.com/docs/stripe-cli#login-account):
220 |
221 | ```bash
222 | pnpm stripe:login
223 | ```
224 |
225 | This will print a URL to navigate to in your browser and provide access to your Stripe account.
226 |
227 | Next, start local webhook forwarding:
228 |
229 | ```bash
230 | pnpm stripe:listen
231 | ```
232 |
233 | Running this Stripe command will print a webhook secret (such as, `whsec_***`) to the console. Set `STRIPE_WEBHOOK_SECRET` to this value in your `.env.local` file. If you haven't already, you should also set `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` and `STRIPE_SECRET_KEY` in your `.env.local` file using the **test mode**(!) keys from your Stripe dashboard.
234 |
235 | ### Run the Next.js client
236 |
237 | In a separate terminal, run the following command to start the development server:
238 |
239 | ```bash
240 | pnpm dev
241 | ```
242 |
243 | Note that webhook forwarding and the development server must be running concurrently in two separate terminals for the application to work correctly.
244 |
245 | Finally, navigate to [http://localhost:3000](http://localhost:3000) in your browser to see the application rendered.
246 |
247 | ## Going live
248 |
249 | ### Archive testing products
250 |
251 | Archive all test mode Stripe products before going live. Before creating your live mode products, make sure to follow the steps below to set up your live mode env vars and webhooks.
252 |
253 | ### Configure production environment variables
254 |
255 | To run the project in live mode and process payments with Stripe, switch Stripe from "test mode" to "production mode." Your Stripe API keys will be different in production mode, and you will have to create a separate production mode webhook. Copy these values and paste them into Vercel, replacing the test mode values.
256 |
257 | ### Redeploy
258 |
259 | Afterward, you will need to rebuild your production deployment for the changes to take effect. Within your project Dashboard, navigate to the "Deployments" tab, select the most recent deployment, click the overflow menu button (next to the "Visit" button) and select "Redeploy" (do NOT enable the "Use existing Build Cache" option).
260 |
261 | To verify you are running in production mode, test checking out with the [Stripe test card](https://stripe.com/docs/testing). The test card should not work.
262 |
--------------------------------------------------------------------------------