diff --git a/README.md b/README.md index 27808e5bd..63656b41e 100644 --- a/README.md +++ b/README.md @@ -453,6 +453,398 @@ pnpm dev If necessary, repeat the process above. +## Billing + +The billing package is used to manage subscriptions, one-off payments, and more. + +The billing package is abstracted from the billing gateway package, which is used to manage the payment gateway (e.g., Stripe, Lemon Squeezy, etc.). + +To set up the billing package, you need to set the following environment variables: + +```bash +NEXT_PUBLIC_BILLING_PROVIDER=stripe # or lemon-squeezy +``` + +Makerkit supports both one-off payments and subscriptions. You have the choice to use one or both. What Makerkit cannot assume with certainty is the billing mode you want to use. By default, we assume you want to use subscriptions, as this is the most common billing mode for SaaS applications. + +This means that - by default - Makerkit will be looking for a subscription plan when visiting the billing section of the personal or team account. This means we fetch data from the tables `subscriptions` and `subscription_items`. + +If you want to use one-off payments, you need to set the billing mode to `one-time`: + +```bash +BILLING_MODE=one-time +``` + +By doing so, Makerkit will be looking for one-off payments when visiting the billing section of the personal or team account. This means we fetch data from the tables `orders` and `order_items`. + +### But - I want to use both + +Perfect - you can, but you need to customize the pages to display the correct data. + +--- + +Depending on the service you use, you will need to set the environment variables accordingly. By default - the billing package uses Stripe. Alternatively, you can use Lemon Squeezy. In the future, we will also add Paddle. + +### Stripe + +For Stripe, you'll need to set the following environment variables: + +```bash +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= +``` + +To run the Stripe CLI - which allows you to listen to Stripe events straight to your own localhost - you can use the following command: + +```bash +pnpm run stripe:listen +``` + +**The first time you set it up, you are required to sign in**. This is a one-time process. Once you sign in, you can use the CLI to listen to Stripe events. + +Please sign in and re-run the command. Now, you can listen to Stripe events. + +### Lemon Squeezy + +For Lemon Squeezy, you'll need to set the following environment variables: + +```bash +LEMON_SQUEEZY_SECRET_KEY= +LEMON_SQUEEZY_SIGNING_SECRET= +``` + +I am aware you know this, but never add these variables to the `.env` file. Instead, add them to the environment variables of your CI/CD system. + +To test locally, you can add them to the `.env.local` file. This file is not committed to Git, therefore it is safe to store sensitive information in it. + +### Billing Schema + +The billing schema replicates your billing provider's schema, so that: + +1. we can display the data in the UI (pricing table, billing section, etc.) +2. create the correct checkout session +3. make some features work correctly - such as per-seat billing + +The billing schema is common to all billing providers. Some billing providers have some differences in what you can or cannot do. In these cases, the schema will try to validate and enforce the rules - but it's up to you to make sure the data is correct. + +The schema is based on three main entities: + +1. **Products**: The main product you are selling (e.g., "Pro Plan", "Starter Plan", etc.) +2. **Plans**: The pricing plan for the product (e.g., "Monthly", "Yearly", etc.) +3. **Line Items**: The line items for the plan (e.g., "flat subscription", "metered usage", "per seat", etc.) + +#### Setting the Billing Provider + +The billing provider is already set as `process.env.NEXT_PUBLIC_BILLING_PROVIDER` and defaults to `stripe`. + +For clarity - this is set in the `apps/web/config/billing.config.ts` file: + +```tsx +export default createBillingSchema({ + // also update config.billing_provider in the DB to match the selected + provider, + // products configuration + products: [] +}); +``` + +We will now add the products to the configuration. + +#### Products + +Products are the main product you are selling. They are defined by the following fields: + +```tsx +export default createBillingSchema({ + provider, + products: [ + { + id: 'starter', + name: 'Starter', + description: 'The perfect plan to get started', + currency: 'USD', + badge: `Value`, + plans: [], + } + ] +}); +``` + +Let's break down the fields: + +1. **id**: The unique identifier for the product. **This is chosen by you, it doesn't need to be the same one as the one in the provider**. +2. **name**: The name of the product +3. **description**: The description of the product +4. **currency**: The currency of the product +5. **badge**: A badge to display on the product (e.g., "Value", "Popular", etc.) + +The majority of these fields are going to populate the pricing table in the UI. + +#### Plans + +Plans are the pricing plans for the product. They are defined by the following fields: + +```tsx +export default createBillingSchema({ + provider, + products: [ + { + id: 'starter', + name: 'Starter', + description: 'The perfect plan to get started', + currency: 'USD', + badge: `Value`, + plans: [ + { + name: 'Starter Monthly', + id: 'starter-monthly', + trialDays: 7, + paymentType: 'recurring', + interval: 'month', + lineItems: [], + } + ], + } + ] +}); +``` + +Let's break down the fields: +- **name**: The name of the plan +- **id**: The unique identifier for the plan. **This is chosen by you, it doesn't need to be the same one as the one in the provider**. +- **trialDays**: The number of days for the trial period +- **paymentType**: The payment type (e.g., `recurring`, `one-time`) +- **interval**: The interval of the payment (e.g., `month`, `year`) +- **lineItems**: The line items for the plan + +Now, we will be looking at the line items. The line items are the items that make up the plan, and can be of different types: +1. **Flat Subscription**: A flat subscription (e.g., $10/month) - specified as `flat` +2. **Metered Billing**: Metered billing (e.g., $0.10 per 1,000 requests) - specified as `metered` +3. **Per-Seat Billing**: Per-seat billing (e.g., $10 per seat) - specified as `per-seat` + +You can add one or more line items to the plan when using Stripe. When using Lemon Squeezy, you can only add one line item - but you can decorate it with the necessary metadata to achieve a similar result. + +#### Flat Subscriptions + +Flat subscriptions are defined by the following fields: + +```tsx + +export default createBillingSchema({ + provider, + products: [ + { + id: 'starter', + name: 'Starter', + description: 'The perfect plan to get started', + currency: 'USD', + badge: `Value`, + plans: [ + { + name: 'Starter Monthly', + id: 'starter-monthly', + trialDays: 7, + paymentType: 'recurring', + interval: 'month', + lineItems: [ + { + id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe', + name: 'Addon 2', + cost: 9.99, + type: 'flat', + }, + ], + } + ], + } + ] +}); +``` + +Let's break down the fields: +- **id**: The unique identifier for the line item. **This must match the price ID in the billing provider**. The schema will validate this, but please remember to set it correctly. +- **name**: The name of the line item +- **cost**: The cost of the line item +- **type**: The type of the line item (e.g., `flat`, `metered`, `per-seat`). In this case, it's `flat`. + +The cost is set for UI purposes. **The billing provider will handle the actual billing** - therefore, **please make sure the cost is correctly set in the billing provider**. + +#### Metered Billing + +Metered billing is defined by the following fields: + +```tsx + +export default createBillingSchema({ + provider, + products: [ + { + id: 'starter', + name: 'Starter', + description: 'The perfect plan to get started', + currency: 'USD', + badge: `Value`, + plans: [ + { + name: 'Starter Monthly', + id: 'starter-monthly', + trialDays: 7, + paymentType: 'recurring', + interval: 'month', + lineItems: [ + { + id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe', + name: 'Addon 2', + cost: 0, + type: 'metered', + unit: 'GBs', + tiers: [ + { + upTo: 10, + cost: 0.1, + }, + { + upTo: 100, + cost: 0.05, + }, + { + upTo: 'unlimited', + cost: 0.01, + } + ] + }, + ], + } + ], + } + ] +}); +``` + +Let's break down the fields: +- **id**: The unique identifier for the line item. **This must match the price ID in the billing provider**. The schema will validate this, but please remember to set it correctly. +- **name**: The name of the line item +- **cost**: The cost of the line item. This can be set to `0` as the cost is calculated based on the tiers. +- **type**: The type of the line item (e.g., `flat`, `metered`, `per-seat`). In this case, it's `metered`. +- **unit**: The unit of the line item (e.g., `GBs`, `requests`, etc.). You can use a translation key here. +- **tiers**: The tiers of the line item. Each tier is defined by the following fields: + - **upTo**: The upper limit of the tier. If the usage is below this limit, the cost is calculated based on this tier. + - **cost**: The cost of the tier. This is the cost per unit. + +The tiers data is used exclusively for UI purposes. **The billing provider will handle the actual billing** - therefore, **please make sure the tiers are correctly set in the billing provider**. + +#### Per-Seat Billing + +Per-seat billing is defined by the following fields: + +```tsx +export default createBillingSchema({ + provider, + products: [ + { + id: 'starter', + name: 'Starter', + description: 'The perfect plan to get started', + currency: 'USD', + badge: `Value`, + plans: [ + { + name: 'Starter Monthly', + id: 'starter-monthly', + trialDays: 7, + paymentType: 'recurring', + interval: 'month', + lineItems: [ + { + id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe', + name: 'Addon 2', + cost: 0, + type: 'per-seat', + tiers: [ + { + upTo: 3, + cost: 0, + }, + { + upTo: 5, + cost: 7.99, + }, + { + upTo: 'unlimited', + cost: 5.99, + } + ] + }, + ], + } + ], + } + ] +}); +``` + +Let's break down the fields: +- **id**: The unique identifier for the line item. **This must match the price ID in the billing provider**. The schema will validate this, but please remember to set it correctly. +- **name**: The name of the line item +- **cost**: The cost of the line item. This can be set to `0` as the cost is calculated based on the tiers. +- **type**: The type of the line item (e.g., `flat`, `metered`, `per-seat`). In this case, it's `per-seat`. +- **tiers**: The tiers of the line item. Each tier is defined by the following fields: + - **upTo**: The upper limit of the tier. If the usage is below this limit, the cost is calculated based on this tier. + - **cost**: The cost of the tier. This is the cost per unit. + +If you set the first tier to `0`, it basically means that the first `n` seats are free. This is a common practice in per-seat billing. + +Please remember that the cost is set for UI purposes. **The billing provider will handle the actual billing** - therefore, **please make sure the cost is correctly set in the billing provider**. + +#### One-Off Payments + +One-off payments are defined by the following fields: + +```tsx +export default createBillingSchema({ + provider, + products: [ + { + id: 'starter', + name: 'Starter', + description: 'The perfect plan to get started', + currency: 'USD', + badge: `Value`, + plans: [ + { + name: 'Starter Monthly', + id: 'starter-monthly', + paymentType: 'one-time', + lineItems: [ + { + id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe', + name: 'Addon 2', + cost: 9.99, + type: 'flat', + }, + ], + } + ], + } + ] +}); +``` + +Let's break down the fields: +- **name**: The name of the plan +- **id**: The unique identifier for the line item. **This must match the price ID in the billing provider**. The schema will validate this, but please remember to set it correctly. +- **paymentType**: The payment type (e.g., `recurring`, `one-time`). In this case, it's `one-time`. +- **lineItems**: The line items for the plan + - **id**: The unique identifier for the line item. **This must match the price ID in the billing provider**. The schema will validate this, but please remember to set it correctly. + - **name**: The name of the line item + - **cost**: The cost of the line item + - **type**: The type of the line item (e.g., `flat`). It can only be `flat` for one-off payments. + +### Adding more Products, Plans, and Line Items + +Simply add more products, plans, and line items to the arrays. The UI **should** be able to handle it in most traditional cases. If you have a more complex billing schema, you may need to adjust the UI accordingly. + ## Deploying to Vercel Deploying to Vercel is straightforward. You can deploy the application using the Vercel CLI or the Vercel dashboard. diff --git a/apps/web/app/(dashboard)/home/(user)/billing/_lib/server/personal-account-billing-page.loader.ts b/apps/web/app/(dashboard)/home/(user)/billing/_lib/server/personal-account-billing-page.loader.ts index d8d7187b8..82958a77a 100644 --- a/apps/web/app/(dashboard)/home/(user)/billing/_lib/server/personal-account-billing-page.loader.ts +++ b/apps/web/app/(dashboard)/home/(user)/billing/_lib/server/personal-account-billing-page.loader.ts @@ -2,24 +2,108 @@ import 'server-only'; import { cache } from 'react'; +import { SupabaseClient } from '@supabase/supabase-js'; + +import { z } from 'zod'; + import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client'; +import { Database } from '~/lib/database.types'; + +/** + * The variable BILLING_MODE represents the billing mode for a service. It can + * have either the value 'subscription' or 'one-time'. If not provided, the default + * value is 'subscription'. The value can be overridden by the environment variable + * BILLING_MODE. + * + * If the value is 'subscription', we fetch the subscription data for the user. + * If the value is 'one-time', we fetch the orders data for the user. + * if none of these suits your needs, please override the below function. + */ +const BILLING_MODE = z + .enum(['subscription', 'one-time']) + .default('subscription') + .parse(process.env.BILLING_MODE); + +/** + * Load the personal account billing page data for the given user. + * @param userId + * @returns The subscription data or the orders data and the billing customer ID. + * This function is cached per-request. + */ export const loadPersonalAccountBillingPageData = cache((userId: string) => { const client = getSupabaseServerComponentClient(); - const subscription = client + const data = + BILLING_MODE === 'subscription' + ? getSubscriptionData(client, userId) + : getOrdersData(client, userId); + + const customerId = getBillingCustomerId(client, userId); + + return Promise.all([data, customerId]); +}); + +/** + * Get the subscription data for the given user. + * @param client + * @param userId + */ +function getSubscriptionData(client: SupabaseClient, userId: string) { + return client .from('subscriptions') .select('*, items: subscription_items !inner (*)') .eq('account_id', userId) .maybeSingle() - .then(({ data }) => data); + .then((response) => { + if (response.error) { + throw response.error; + } - const customer = client + return response.data; + }); +} + +/** + * Get the orders data for the given user. + * @param client + * @param userId + */ +function getOrdersData(client: SupabaseClient, userId: string) { + return client + .from('orders') + .select('*, items: order_items !inner (*)') + .eq('account_id', userId) + .maybeSingle() + .then((response) => { + if (response.error) { + throw response.error; + } + + return response.data; + }); +} + +/** + * Get the billing customer ID for the given user. + * If the user does not have a billing customer ID, it will return null. + * @param client + * @param userId + */ +function getBillingCustomerId( + client: SupabaseClient, + userId: string, +) { + return client .from('billing_customers') .select('customer_id') .eq('account_id', userId) .maybeSingle() - .then(({ data }) => data?.customer_id); + .then((response) => { + if (response.error) { + throw response.error; + } - return Promise.all([subscription, customer]); -}); + return response.data?.customer_id; + }); +} diff --git a/apps/web/app/(dashboard)/home/(user)/billing/_lib/server/user-billing.service.ts b/apps/web/app/(dashboard)/home/(user)/billing/_lib/server/user-billing.service.ts index de3d640e0..9f04525dd 100644 --- a/apps/web/app/(dashboard)/home/(user)/billing/_lib/server/user-billing.service.ts +++ b/apps/web/app/(dashboard)/home/(user)/billing/_lib/server/user-billing.service.ts @@ -10,12 +10,13 @@ import { getLogger } from '@kit/shared/logger'; import { requireUser } from '@kit/supabase/require-user'; import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client'; -import { PersonalAccountCheckoutSchema } from '~/(dashboard)/home/(user)/billing/_lib/schema/personal-account-checkout.schema'; import appConfig from '~/config/app.config'; import billingConfig from '~/config/billing.config'; import pathsConfig from '~/config/paths.config'; import { Database } from '~/lib/database.types'; +import { PersonalAccountCheckoutSchema } from '../schema/personal-account-checkout.schema'; + export class UserBillingService { private readonly namespace = 'billing.personal-account'; diff --git a/apps/web/app/(dashboard)/home/(user)/billing/page.tsx b/apps/web/app/(dashboard)/home/(user)/billing/page.tsx index abe4b6c09..afc898b82 100644 --- a/apps/web/app/(dashboard)/home/(user)/billing/page.tsx +++ b/apps/web/app/(dashboard)/home/(user)/billing/page.tsx @@ -2,6 +2,7 @@ import { redirect } from 'next/navigation'; import { BillingPortalCard, + CurrentLifetimeOrderCard, CurrentSubscriptionCard, } from '@kit/billing-gateway/components'; import { requireUser } from '@kit/supabase/require-user'; @@ -37,7 +38,7 @@ async function PersonalAccountBillingPage() { redirect(auth.redirectTo); } - const [subscription, customerId] = await loadPersonalAccountBillingPageData( + const [data, customerId] = await loadPersonalAccountBillingPageData( auth.data.id, ); @@ -50,7 +51,7 @@ async function PersonalAccountBillingPage() {
- + @@ -58,15 +59,26 @@ async function PersonalAccountBillingPage() { - + {(subscription) => (
- + + + + + + + + + diff --git a/apps/web/app/(dashboard)/home/[account]/_lib/server/team-account-billing-page.loader.ts b/apps/web/app/(dashboard)/home/[account]/_lib/server/team-account-billing-page.loader.ts index 98b1b9319..8955dfe07 100644 --- a/apps/web/app/(dashboard)/home/[account]/_lib/server/team-account-billing-page.loader.ts +++ b/apps/web/app/(dashboard)/home/[account]/_lib/server/team-account-billing-page.loader.ts @@ -2,25 +2,105 @@ import 'server-only'; import { cache } from 'react'; +import { SupabaseClient } from '@supabase/supabase-js'; + +import { z } from 'zod'; + import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client'; +import { Database } from '~/lib/database.types'; + +/** + * The variable BILLING_MODE represents the billing mode for a service. It can + * have either the value 'subscription' or 'one-time'. If not provided, the default + * value is 'subscription'. The value can be overridden by the environment variable + * BILLING_MODE. + * + * If the value is 'subscription', we fetch the subscription data for the user. + * If the value is 'one-time', we fetch the orders data for the user. + * if none of these suits your needs, please override the below function. + */ +const BILLING_MODE = z + .enum(['subscription', 'one-time']) + .default('subscription') + .parse(process.env.BILLING_MODE); + export const loadTeamAccountBillingPage = cache((accountId: string) => { const client = getSupabaseServerComponentClient(); - // TODO: improve these queries to only load the necessary data - const subscription = client + const data = + BILLING_MODE === 'subscription' + ? getSubscriptionData(client, accountId) + : getOrdersData(client, accountId); + + const customerId = getBillingCustomerId(client, accountId); + + return Promise.all([data, customerId]); +}); + +/** + * Get the subscription data for the given user. + * @param client + * @param accountId + */ +function getSubscriptionData( + client: SupabaseClient, + accountId: string, +) { + return client .from('subscriptions') .select('*, items: subscription_items !inner (*)') .eq('account_id', accountId) .maybeSingle() - .then(({ data }) => data); + .then((response) => { + if (response.error) { + throw response.error; + } - const customerId = client + return response.data; + }); +} + +/** + * Get the orders data for the given user. + * @param client + * @param accountId + */ +function getOrdersData(client: SupabaseClient, accountId: string) { + return client + .from('orders') + .select('*, items: order_items !inner (*)') + .eq('account_id', accountId) + .maybeSingle() + .then((response) => { + if (response.error) { + throw response.error; + } + + return response.data; + }); +} + +/** + * Get the billing customer ID for the given user. + * If the user does not have a billing customer ID, it will return null. + * @param client + * @param accountId + */ +function getBillingCustomerId( + client: SupabaseClient, + accountId: string, +) { + return client .from('billing_customers') .select('customer_id') .eq('account_id', accountId) .maybeSingle() - .then(({ data }) => data?.customer_id); + .then((response) => { + if (response.error) { + throw response.error; + } - return Promise.all([subscription, customerId]); -}); + return response.data?.customer_id; + }); +} diff --git a/apps/web/app/(dashboard)/home/_lib/load-user-workspace.ts b/apps/web/app/(dashboard)/home/_lib/load-user-workspace.ts index ecc7d6f07..447ebd508 100644 --- a/apps/web/app/(dashboard)/home/_lib/load-user-workspace.ts +++ b/apps/web/app/(dashboard)/home/_lib/load-user-workspace.ts @@ -7,6 +7,8 @@ import { getSupabaseServerComponentClient } from '@kit/supabase/server-component import featureFlagsConfig from '~/config/feature-flags.config'; import { Database } from '~/lib/database.types'; +const shouldLoadAccounts = featureFlagsConfig.enableTeamAccounts; + /** * @name loadUserWorkspace * @description @@ -15,17 +17,16 @@ import { Database } from '~/lib/database.types'; */ export const loadUserWorkspace = cache(async () => { const client = getSupabaseServerComponentClient(); - const loadAccounts = featureFlagsConfig.enableTeamAccounts; - const accountsPromise = loadAccounts - ? loadUserAccounts(client) - : Promise.resolve([]); + const accountsPromise = shouldLoadAccounts + ? () => loadUserAccounts(client) + : () => Promise.resolve([]); const workspacePromise = loadUserAccountWorkspace(client); const userPromise = client.auth.getUser(); const [accounts, workspace, userResult] = await Promise.all([ - accountsPromise, + accountsPromise(), workspacePromise, userPromise, ]);