From 78c704e54dbc2981031c033840084a9ba351612b Mon Sep 17 00:00:00 2001
From: giancarlo
Date: Sun, 24 Mar 2024 20:54:12 +0800
Subject: [PATCH] Implement new billing-gateway and update related services
Created a new package named billing-gateway which implements interfaces for different billing providers and provides a centralized service for payments. This will potentially help to maintain cleaner code by reducing direct dependencies on specific payment providers in the core application code. Additionally, made adjustments in existing services, like Stripe, to comply with this change. The relevant interfaces and types have been exported and imported accordingly.
---
README.md | 105 ++++++++-
apps/web/.env.development | 2 +-
.../personal-account-checkout-form.tsx | 63 +++++
.../(dashboard)/home/(user)/billing/page.tsx | 10 +-
.../home/(user)/billing/server-actions.ts | 123 ++++++++++
apps/web/app/join/{[code] => }/page.tsx | 30 ++-
apps/web/config/paths.config.ts | 30 ++-
apps/web/next.config.mjs | 2 +-
apps/web/package.json | 1 +
components.json | 17 ++
packages/billing-gateway/README.md | 3 +
packages/billing-gateway/package.json | 48 ++++
.../src/billing-gateway-factory.service.ts | 28 +++
.../src/billing-gateway-service.ts | 93 ++++++++
.../src/components/current-plan-card.tsx | 1 +
.../src/components/embedded-checkout.tsx | 39 +++
.../billing-gateway/src/components/index.ts | 3 +
.../src/components/plan-picker.tsx | 222 ++++++++++++++++++
.../src/gateway-provider-factory.ts | 33 +++
packages/billing-gateway/src/index.ts | 2 +
packages/billing-gateway/tsconfig.json | 8 +
packages/billing/package.json | 5 +-
packages/billing/src/create-billing-schema.ts | 20 +-
packages/billing/src/index.ts | 2 +
.../cancel-subscription-params.schema.ts | 6 +
.../create-biling-portal-session.schema.ts | 6 +
.../schema/create-billing-checkout.schema.ts | 13 +
packages/billing/src/schema/index.ts | 4 +
.../retrieve-checkout-session.schema.ts | 5 +
.../billing-strategy-provider.service.ts | 32 +++
packages/stripe/package.json | 9 +-
packages/stripe/src/components/index.ts | 1 +
.../components/stripe-embedded-checkout.tsx | 86 +++++++
packages/stripe/src/create-checkout.ts | 50 ++--
.../create-stripe-billing-portal-session.ts | 8 +-
packages/stripe/src/index.ts | 2 +-
packages/stripe/src/stripe.service.ts | 54 +++--
.../src/clients/server-actions.client.ts | 14 +-
packages/ui/README.md | 18 +-
packages/ui/package.json | 3 +
packages/ui/src/shadcn/radio-group.tsx | 22 +-
packages/ui/src/shadcn/separator.tsx | 31 +++
pnpm-lock.yaml | 97 ++++++++
supabase/migrations/20221215192558_schema.sql | 8 +-
44 files changed, 1251 insertions(+), 108 deletions(-)
create mode 100644 apps/web/app/(dashboard)/home/(user)/billing/components/personal-account-checkout-form.tsx
create mode 100644 apps/web/app/(dashboard)/home/(user)/billing/server-actions.ts
rename apps/web/app/join/{[code] => }/page.tsx (82%)
create mode 100644 components.json
create mode 100644 packages/billing-gateway/README.md
create mode 100644 packages/billing-gateway/package.json
create mode 100644 packages/billing-gateway/src/billing-gateway-factory.service.ts
create mode 100644 packages/billing-gateway/src/billing-gateway-service.ts
create mode 100644 packages/billing-gateway/src/components/current-plan-card.tsx
create mode 100644 packages/billing-gateway/src/components/embedded-checkout.tsx
create mode 100644 packages/billing-gateway/src/components/index.ts
create mode 100644 packages/billing-gateway/src/components/plan-picker.tsx
create mode 100644 packages/billing-gateway/src/gateway-provider-factory.ts
create mode 100644 packages/billing-gateway/src/index.ts
create mode 100644 packages/billing-gateway/tsconfig.json
create mode 100644 packages/billing/src/index.ts
create mode 100644 packages/billing/src/schema/cancel-subscription-params.schema.ts
create mode 100644 packages/billing/src/schema/create-biling-portal-session.schema.ts
create mode 100644 packages/billing/src/schema/create-billing-checkout.schema.ts
create mode 100644 packages/billing/src/schema/index.ts
create mode 100644 packages/billing/src/schema/retrieve-checkout-session.schema.ts
create mode 100644 packages/billing/src/services/billing-strategy-provider.service.ts
create mode 100644 packages/stripe/src/components/index.ts
create mode 100644 packages/stripe/src/components/stripe-embedded-checkout.tsx
create mode 100644 packages/ui/src/shadcn/separator.tsx
diff --git a/README.md b/README.md
index c472757ad..9db7b4458 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,44 @@
# Makerkit - Supabase SaaS Starter Kit - Turbo Edition
+This is a Starter Kit for building SaaS applications using Supabase, Next.js, and Tailwind CSS.
+
+This version uses Turborepo to manage multiple packages in a single repository.
+
+## Features
+
+- **Authentication**: Sign up, sign in, sign out, forgot password, update profile, and more.
+- **Billing**: Subscription management, payment methods, invoices, and more.
+- **Personal Account**: Manage your account, profile picture, and more.
+- **Team Accounts**: Invite members, manage roles, and more.
+- **Admin Dashboard**: Manage users, subscriptions, and more.
+- **Pluggable**: Easily add new features and packages to your SaaS application.
+
+The most notable difference between this version and the original version is the use of Turborepo to manage multiple packages in a single repository.
+
+Thanks to Turborepo, we can manage and isolate different parts of the application in separate packages. This makes it easier to manage and scale the application as it grows.
+
+Additionally, we can extend the codebase without it impacting your web application.
+
+Let's get started!
+
+## Quick Start
+
+### 0. Prerequisites
+
+- Node.js 18.x or later
+- Docker
+- Pnpm
+- Supabase account (optional for local development)
+- Payment Gateway account (Stripe/Lemon Squeezy)
+- Email Service account (optional for local development)
+
+#### 0.1. Install Pnpm
+
+```bash
+# Install pnpm
+npm i -g pnpm
+```
+
### 1. Setup dependencies
```bash
@@ -12,4 +51,68 @@ pnpm i
```bash
# Start the development server
pnpm dev
-```
\ No newline at end of file
+```
+
+## Architecture
+
+This project uses Turborepo to manage multiple packages in a single repository.
+
+### Apps
+
+The core web application can be found in the `apps/web` package.
+
+Here is where we add the skeleton of the application, including the routing, layout, and global styles.
+
+The main application defines the following:
+1. **Configuration**: Environment variables, feature flags, paths, and more. The configuration gets passed down to other packages.
+2. **Routing**: The main routing of the application. Since this is file-based routing, we define the routes here.
+3. **Local components**: Shared components that are used across the application but not necessarily shared with other apps/packages.
+4. **Global styles**: Global styles that are used across the application.
+
+### Packages
+
+Below are the reusable packages that can be shared across multiple applications (or packages).
+
+- **`@kit/ui`**: Shared UI components and styles (using Shadcn UI)
+- **`@kit/shared`**: Shared code and utilities
+- **`@kit/supabase`**: Supabase package that defines the schema and logic for managing Supabase
+- **`@kit/i18n`**: Internationalization package that defines utilities for managing translations
+- **`@kit/billing`**: Billing package that defines the schema and logic for managing subscriptions
+- **`@kit/billing-gateway`**: Billing gateway package that defines the schema and logic for managing payment gateways
+- **`@kit/stripe`**: Stripe package that defines the schema and logic for managing Stripe. This is used by the `@kit/billing-gateway` package and abstracts the Stripe API.
+- **`@kit/lemon-squeezy`**: Lemon Squeezy package that defines the schema and logic for managing Lemon Squeezy. This is used by the `@kit/billing-gateway` package and abstracts the Lemon Squeezy API.
+- **`@kit/emails`**: Here we define the email templates using the `react.email` package.
+- **`@kit/mailers`**: Mailer package that abstracts the email service provider (e.g., Resend, Cloudflare, SendGrid, Mailgun, etc.)
+
+And features that can be added to the application:
+- **`@kit/auth`**: Authentication package (using Supabase)
+- **`@kit/accounts`**: Package that defines components and logic for managing personal accounts
+- **`@kit/team-accounts`**: Package that defines components and logic for managing team
+- **`@kit/admin`**: Admin package that defines the schema and logic for managing users, subscriptions, and more.
+
+### Application Configuration
+
+The configuration is defined in the `apps/web/config` folder. Here you can find the following configuration files:
+- **`app.config.ts`**: Application configuration (e.g., name, description, etc.)
+- **`auth.config.ts`**: Authentication configuration
+- **`billing.config.ts`**: Billing configuration
+- **`feature-flags.config.ts`**: Feature flags configuration
+- **`paths.config.ts`**: Paths configuration (e.g., routes, API paths, etc.)
+- **`personal-account-sidebar.config.ts`**: Personal account sidebar configuration (e.g., links, icons, etc.)
+- **`organization-account-sidebar.config.ts`**: Team account sidebar configuration (e.g., links, icons, etc.)
+
+## Installing a Shadcn UI component
+
+To install a Shadcn UI component, you can use the following command:
+
+```bash
+npx shadcn-ui@latest add --path=packages/ui/shadcn
+```
+
+For example, to install the `Button` component, you can use the following command:
+
+```bash
+npx shadcn-ui@latest add button --path=packages/rsc/ui/shadcn
+```
+
+We pass the `--path` flag to specify the path where the component should be installed.
\ No newline at end of file
diff --git a/apps/web/.env.development b/apps/web/.env.development
index 087d17390..44a227b8b 100644
--- a/apps/web/.env.development
+++ b/apps/web/.env.development
@@ -33,4 +33,4 @@ NEXT_PUBLIC_LOCALES_PATH=apps/web/public/locales
SIGN_IN_PATH=/auth/sign-in
SIGN_UP_PATH=/auth/sign-up
ORGANIZATION_ACCOUNTS_PATH=/home
-INVITATION_PAGE_PATH=/invite
+INVITATION_PAGE_PATH=/join
\ No newline at end of file
diff --git a/apps/web/app/(dashboard)/home/(user)/billing/components/personal-account-checkout-form.tsx b/apps/web/app/(dashboard)/home/(user)/billing/components/personal-account-checkout-form.tsx
new file mode 100644
index 000000000..9508394bd
--- /dev/null
+++ b/apps/web/app/(dashboard)/home/(user)/billing/components/personal-account-checkout-form.tsx
@@ -0,0 +1,63 @@
+'use client';
+
+import { useState, useTransition } from 'react';
+
+import { EmbeddedCheckout, PlanPicker } from '@kit/billing-gateway/components';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@kit/ui/card';
+
+import billingConfig from '~/config/billing.config';
+
+import { createPersonalAccountCheckoutSession } from '../server-actions';
+
+export function PersonalAccountCheckoutForm() {
+ const [pending, startTransition] = useTransition();
+ const [checkoutToken, setCheckoutToken] = useState(null);
+
+ // If the checkout token is set, render the embedded checkout component
+ if (checkoutToken) {
+ return (
+
+ );
+ }
+
+ // Otherwise, render the plan picker component
+ return (
+
+
+
+ Manage your Plan
+
+
+ You can change your plan at any time.
+
+
+
+
+ {
+ startTransition(async () => {
+ const { checkoutToken } =
+ await createPersonalAccountCheckoutSession({
+ planId,
+ });
+
+ setCheckoutToken(checkoutToken);
+ });
+ }}
+ />
+
+
+
+ );
+}
diff --git a/apps/web/app/(dashboard)/home/(user)/billing/page.tsx b/apps/web/app/(dashboard)/home/(user)/billing/page.tsx
index 9a4864673..c51007310 100644
--- a/apps/web/app/(dashboard)/home/(user)/billing/page.tsx
+++ b/apps/web/app/(dashboard)/home/(user)/billing/page.tsx
@@ -1,8 +1,10 @@
-import { withI18n } from '~/lib/i18n/with-i18n';
-
import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
+import { withI18n } from '~/lib/i18n/with-i18n';
+
+import { PersonalAccountCheckoutForm } from './components/personal-account-checkout-form';
+
function PersonalAccountBillingPage() {
return (
<>
@@ -11,7 +13,9 @@ function PersonalAccountBillingPage() {
description={}
/>
-
+
+
+
>
);
}
diff --git a/apps/web/app/(dashboard)/home/(user)/billing/server-actions.ts b/apps/web/app/(dashboard)/home/(user)/billing/server-actions.ts
new file mode 100644
index 000000000..9fd2afcc1
--- /dev/null
+++ b/apps/web/app/(dashboard)/home/(user)/billing/server-actions.ts
@@ -0,0 +1,123 @@
+'use server';
+
+import { URL } from 'next/dist/compiled/@edge-runtime/primitives';
+import { headers } from 'next/headers';
+import { redirect } from 'next/navigation';
+
+import { z } from 'zod';
+
+import { getProductPlanPairFromId } from '@kit/billing';
+import { getGatewayProvider } from '@kit/billing-gateway';
+import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
+
+import billingConfig from '~/config/billing.config';
+import pathsConfig from '~/config/paths.config';
+
+/**
+ * Creates a checkout session for a personal account.
+ *
+ * @param {object} params - The parameters for creating the checkout session.
+ * @param {string} params.planId - The ID of the plan to be associated with the account.
+ */
+export async function createPersonalAccountCheckoutSession(params: {
+ planId: string;
+}) {
+ const client = getSupabaseServerActionClient();
+ const { data, error } = await client.auth.getUser();
+
+ if (error ?? !data.user) {
+ throw new Error('Authentication required');
+ }
+
+ const planId = z.string().min(1).parse(params.planId);
+ const service = await getGatewayProvider(client);
+ const productPlanPairFromId = getProductPlanPairFromId(billingConfig, planId);
+
+ if (!productPlanPairFromId) {
+ throw new Error('Product not found');
+ }
+
+ // in the case of personal accounts
+ // the account ID is the same as the user ID
+ const accountId = data.user.id;
+
+ // the return URL for the checkout session
+ const returnUrl = getCheckoutSessionReturnUrl();
+
+ // find the customer ID for the account if it exists
+ // (eg. if the account has been billed before)
+ const customerId = await getCustomerIdFromAccountId(accountId);
+
+ // retrieve the product and plan from the billing configuration
+ const { product, plan } = productPlanPairFromId;
+
+ // call the payment gateway to create the checkout session
+ const { checkoutToken } = await service.createCheckoutSession({
+ paymentType: product.paymentType,
+ returnUrl,
+ accountId,
+ planId,
+ trialPeriodDays: plan.trialPeriodDays,
+ customerEmail: data.user.email,
+ customerId,
+ });
+
+ // return the checkout token to the client
+ // so we can call the payment gateway to complete the checkout
+ return {
+ checkoutToken,
+ };
+}
+
+export async function createBillingPortalSession() {
+ const client = getSupabaseServerActionClient();
+ const { data, error } = await client.auth.getUser();
+
+ if (error ?? !data.user) {
+ throw new Error('Authentication required');
+ }
+
+ const service = await getGatewayProvider(client);
+
+ const accountId = data.user.id;
+ const customerId = await getCustomerIdFromAccountId(accountId);
+ const returnUrl = getBillingPortalReturnUrl();
+
+ const { url } = await service.createBillingPortalSession({
+ customerId,
+ returnUrl,
+ });
+
+ return redirect(url);
+}
+
+function getCheckoutSessionReturnUrl() {
+ const origin = headers().get('origin')!;
+
+ return new URL(
+ pathsConfig.app.personalAccountBillingReturn,
+ origin,
+ ).toString();
+}
+
+function getBillingPortalReturnUrl() {
+ const origin = headers().get('origin')!;
+
+ return new URL(pathsConfig.app.accountBilling, origin).toString();
+}
+
+async function getCustomerIdFromAccountId(accountId: string) {
+ const client = getSupabaseServerActionClient();
+
+ const { data, error } = await client
+ .from('billing_customers')
+ .select('customer_id')
+ .eq('account_id', accountId)
+ .maybeSingle();
+
+ if (error) {
+ throw error;
+ }
+
+ return data?.customer_id;
+}
diff --git a/apps/web/app/join/[code]/page.tsx b/apps/web/app/join/page.tsx
similarity index 82%
rename from apps/web/app/join/[code]/page.tsx
rename to apps/web/app/join/page.tsx
index 1ba879012..f3c9c364b 100644
--- a/apps/web/app/join/[code]/page.tsx
+++ b/apps/web/app/join/page.tsx
@@ -3,19 +3,20 @@ import { notFound } from 'next/navigation';
import type { SupabaseClient } from '@supabase/supabase-js';
-import ExistingUserInviteForm from '~/join/_components/ExistingUserInviteForm';
-import NewUserInviteForm from '~/join/_components/NewUserInviteForm';
-import { withI18n } from '~/lib/i18n/with-i18n';
-
import { Logger } from '@kit/shared/logger';
+import { Database } from '@kit/supabase/database';
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
import { Heading } from '@kit/ui/heading';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
+import ExistingUserInviteForm from '~/join/_components/ExistingUserInviteForm';
+import NewUserInviteForm from '~/join/_components/NewUserInviteForm';
+import { withI18n } from '~/lib/i18n/with-i18n';
+
interface Context {
- params: {
- code: string;
+ searchParams: {
+ invite_token: string;
};
}
@@ -23,9 +24,9 @@ export const metadata = {
title: `Join Organization`,
};
-async function InvitePage({ params }: Context) {
- const code = params.code;
- const data = await loadInviteData(code);
+async function JoinTeamAccountPage({ searchParams }: Context) {
+ const token = searchParams.invite_token;
+ const data = await getInviteDataFromInviteToken(token);
if (!data.membership) {
notFound();
@@ -62,16 +63,19 @@ async function InvitePage({ params }: Context) {
- }>
- {(session) => }
+ }
+ >
+ {(session) => }
>
);
}
-export default withI18n(InvitePage);
+export default withI18n(JoinTeamAccountPage);
-async function loadInviteData(code: string) {
+async function getInviteDataFromInviteToken(code: string) {
const client = getSupabaseServerComponentClient();
// we use an admin client to be able to read the pending membership
diff --git a/apps/web/config/paths.config.ts b/apps/web/config/paths.config.ts
index b578a5153..d35e6f6f4 100644
--- a/apps/web/config/paths.config.ts
+++ b/apps/web/config/paths.config.ts
@@ -2,21 +2,23 @@ import { z } from 'zod';
const PathsSchema = z.object({
auth: z.object({
- signIn: z.string(),
- signUp: z.string(),
- verifyMfa: z.string(),
- callback: z.string(),
- passwordReset: z.string(),
- passwordUpdate: z.string(),
+ signIn: z.string().min(1),
+ signUp: z.string().min(1),
+ verifyMfa: z.string().min(1),
+ callback: z.string().min(1),
+ passwordReset: z.string().min(1),
+ passwordUpdate: z.string().min(1),
}),
app: z.object({
- home: z.string(),
- personalAccountSettings: z.string(),
- personalAccountBilling: z.string(),
- accountHome: z.string(),
- accountSettings: z.string(),
- accountBilling: z.string(),
- accountMembers: z.string(),
+ home: z.string().min(1),
+ personalAccountSettings: z.string().min(1),
+ personalAccountBilling: z.string().min(1),
+ personalAccountBillingReturn: z.string().min(1),
+ accountHome: z.string().min(1),
+ accountSettings: z.string().min(1),
+ accountBilling: z.string().min(1),
+ accountMembers: z.string().min(1),
+ accountBillingReturn: z.string().min(1),
}),
});
@@ -33,10 +35,12 @@ const pathsConfig = PathsSchema.parse({
home: '/home',
personalAccountSettings: '/home/account',
personalAccountBilling: '/home/billing',
+ personalAccountBillingReturn: '/home/billing/return',
accountHome: '/home/[account]',
accountSettings: `/home/[account]/settings`,
accountBilling: `/home/[account]/billing`,
accountMembers: `/home/[account]/members`,
+ accountBillingReturn: `/home/[account]/billing/return`,
},
});
diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs
index 2b702c989..96574a5a4 100644
--- a/apps/web/next.config.mjs
+++ b/apps/web/next.config.mjs
@@ -12,7 +12,7 @@ const config = {
'@kit/i18n',
'@kit/mailers',
'@kit/billing',
- '@kit/stripe',
+ '@kit/billing-gateway'
],
pageExtensions: ['ts', 'tsx', 'mdx'],
experimental: {
diff --git a/apps/web/package.json b/apps/web/package.json
index ad0deef8a..6504560ab 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -23,6 +23,7 @@
"@kit/team-accounts": "^0.1.0",
"@kit/supabase": "^0.1.0",
"@kit/billing": "^0.1.0",
+ "@kit/billing-gateway": "^0.1.0",
"@kit/mailers": "^0.1.0",
"@hookform/resolvers": "^3.3.4",
"@next/mdx": "^14.1.0",
diff --git a/components.json b/components.json
new file mode 100644
index 000000000..b282fe339
--- /dev/null
+++ b/components.json
@@ -0,0 +1,17 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "tailwind.config.js",
+ "css": "app/globals.css",
+ "baseColor": "slate",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@kit/ui",
+ "utils": "@kit/ui/utils"
+ }
+}
\ No newline at end of file
diff --git a/packages/billing-gateway/README.md b/packages/billing-gateway/README.md
new file mode 100644
index 000000000..0cd1e8af1
--- /dev/null
+++ b/packages/billing-gateway/README.md
@@ -0,0 +1,3 @@
+# Billing - @kit/billing-gateway
+
+This package is responsible for handling all billing related operations. It is a gateway to the billing service.
\ No newline at end of file
diff --git a/packages/billing-gateway/package.json b/packages/billing-gateway/package.json
new file mode 100644
index 000000000..67a0ff70b
--- /dev/null
+++ b/packages/billing-gateway/package.json
@@ -0,0 +1,48 @@
+{
+ "name": "@kit/billing-gateway",
+ "private": true,
+ "version": "0.1.0",
+ "scripts": {
+ "clean": "git clean -xdf .turbo node_modules",
+ "format": "prettier --check \"**/*.{ts,tsx}\"",
+ "lint": "eslint .",
+ "typecheck": "tsc --noEmit"
+ },
+ "prettier": "@kit/prettier-config",
+ "exports": {
+ ".": "./src/index.ts",
+ "./components": "./src/components/index.ts"
+ },
+ "peerDependencies": {
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "zod": "^3.22.4"
+ },
+ "dependencies": {
+ "@kit/ui": "0.1.0",
+ "@kit/stripe": "0.1.0",
+ "@kit/billing": "0.1.0",
+ "@kit/supabase": "^0.1.0",
+ "lucide-react": "^0.361.0"
+ },
+ "devDependencies": {
+ "@kit/prettier-config": "0.1.0",
+ "@kit/eslint-config": "0.2.0",
+ "@kit/tailwind-config": "0.1.0",
+ "@kit/tsconfig": "0.1.0"
+ },
+ "eslintConfig": {
+ "root": true,
+ "extends": [
+ "@kit/eslint-config/base",
+ "@kit/eslint-config/react"
+ ]
+ },
+ "typesVersions": {
+ "*": {
+ "*": [
+ "src/*"
+ ]
+ }
+ }
+}
diff --git a/packages/billing-gateway/src/billing-gateway-factory.service.ts b/packages/billing-gateway/src/billing-gateway-factory.service.ts
new file mode 100644
index 000000000..3979f5571
--- /dev/null
+++ b/packages/billing-gateway/src/billing-gateway-factory.service.ts
@@ -0,0 +1,28 @@
+import { z } from 'zod';
+
+import { BillingProvider, BillingStrategyProviderService } from '@kit/billing';
+
+export class BillingGatewayFactoryService {
+ static async GetProviderStrategy(
+ provider: z.infer,
+ ): Promise {
+ switch (provider) {
+ case 'stripe': {
+ const { StripeBillingStrategyService } = await import('@kit/stripe');
+
+ return new StripeBillingStrategyService();
+ }
+
+ case 'paddle': {
+ throw new Error('Paddle is not supported yet');
+ }
+
+ case 'lemon-squeezy': {
+ throw new Error('Lemon Squeezy is not supported yet');
+ }
+
+ default:
+ throw new Error(`Unsupported billing provider: ${provider as string}`);
+ }
+ }
+}
diff --git a/packages/billing-gateway/src/billing-gateway-service.ts b/packages/billing-gateway/src/billing-gateway-service.ts
new file mode 100644
index 000000000..b0331e06a
--- /dev/null
+++ b/packages/billing-gateway/src/billing-gateway-service.ts
@@ -0,0 +1,93 @@
+import { z } from 'zod';
+
+import { BillingProvider } from '@kit/billing';
+import {
+ CancelSubscriptionParamsSchema,
+ CreateBillingCheckoutSchema,
+ CreateBillingPortalSessionSchema,
+ RetrieveCheckoutSessionSchema,
+} from '@kit/billing/schema';
+
+import { BillingGatewayFactoryService } from './billing-gateway-factory.service';
+
+/**
+ * @description The billing gateway service to interact with the billing provider of choice (e.g. Stripe)
+ * @class BillingGatewayService
+ * @param {BillingProvider} provider - The billing provider to use
+ * @example
+ *
+ * const provider = 'stripe';
+ * const billingGatewayService = new BillingGatewayService(provider);
+ */
+export class BillingGatewayService {
+ constructor(private readonly provider: z.infer) {}
+
+ /**
+ * Creates a checkout session for billing.
+ *
+ * @param {CreateBillingCheckoutSchema} params - The parameters for creating the checkout session.
+ *
+ */
+ async createCheckoutSession(
+ params: z.infer,
+ ) {
+ const strategy = await BillingGatewayFactoryService.GetProviderStrategy(
+ this.provider,
+ );
+
+ const payload = CreateBillingCheckoutSchema.parse(params);
+
+ return strategy.createCheckoutSession(payload);
+ }
+
+ /**
+ * Retrieves the checkout session from the specified provider.
+ *
+ * @param {RetrieveCheckoutSessionSchema} params - The parameters to retrieve the checkout session.
+ */
+ async retrieveCheckoutSession(
+ params: z.infer,
+ ) {
+ const strategy = await BillingGatewayFactoryService.GetProviderStrategy(
+ this.provider,
+ );
+
+ const payload = RetrieveCheckoutSessionSchema.parse(params);
+
+ return strategy.retrieveCheckoutSession(payload);
+ }
+
+ /**
+ * Creates a billing portal session for the specified parameters.
+ *
+ * @param {CreateBillingPortalSessionSchema} params - The parameters to create the billing portal session.
+ */
+ async createBillingPortalSession(
+ params: z.infer,
+ ) {
+ const strategy = await BillingGatewayFactoryService.GetProviderStrategy(
+ this.provider,
+ );
+
+ const payload = CreateBillingPortalSessionSchema.parse(params);
+
+ return strategy.createBillingPortalSession(payload);
+ }
+
+ /**
+ * Cancels a subscription.
+ *
+ * @param {CancelSubscriptionParamsSchema} params - The parameters for cancelling the subscription.
+ */
+ async cancelSubscription(
+ params: z.infer,
+ ) {
+ const strategy = await BillingGatewayFactoryService.GetProviderStrategy(
+ this.provider,
+ );
+
+ const payload = CancelSubscriptionParamsSchema.parse(params);
+
+ return strategy.cancelSubscription(payload);
+ }
+}
diff --git a/packages/billing-gateway/src/components/current-plan-card.tsx b/packages/billing-gateway/src/components/current-plan-card.tsx
new file mode 100644
index 000000000..fa5a3987e
--- /dev/null
+++ b/packages/billing-gateway/src/components/current-plan-card.tsx
@@ -0,0 +1 @@
+export function CurrentPlanCard(props: React.PropsWithChildren<{}>) {}
diff --git a/packages/billing-gateway/src/components/embedded-checkout.tsx b/packages/billing-gateway/src/components/embedded-checkout.tsx
new file mode 100644
index 000000000..afe209f14
--- /dev/null
+++ b/packages/billing-gateway/src/components/embedded-checkout.tsx
@@ -0,0 +1,39 @@
+import { lazy } from 'react';
+
+import { Database } from '@kit/supabase/database';
+
+type BillingProvider = Database['public']['Enums']['billing_provider'];
+
+export function EmbeddedCheckout(
+ props: React.PropsWithChildren<{
+ checkoutToken: string;
+ provider: BillingProvider;
+ }>,
+) {
+ const CheckoutComponent = loadCheckoutComponent(props.provider);
+
+ return ;
+}
+
+function loadCheckoutComponent(provider: BillingProvider) {
+ switch (provider) {
+ case 'stripe': {
+ return lazy(() => {
+ return import('@kit/stripe/components').then((c) => ({
+ default: c.StripeCheckout,
+ }));
+ });
+ }
+
+ case 'lemon-squeezy': {
+ throw new Error('Lemon Squeezy is not yet supported');
+ }
+
+ case 'paddle': {
+ throw new Error('Paddle is not yet supported');
+ }
+
+ default:
+ throw new Error(`Unsupported provider: ${provider as string}`);
+ }
+}
diff --git a/packages/billing-gateway/src/components/index.ts b/packages/billing-gateway/src/components/index.ts
new file mode 100644
index 000000000..67c25f1bf
--- /dev/null
+++ b/packages/billing-gateway/src/components/index.ts
@@ -0,0 +1,3 @@
+export * from './plan-picker';
+export * from './current-plan-card';
+export * from './embedded-checkout';
diff --git a/packages/billing-gateway/src/components/plan-picker.tsx b/packages/billing-gateway/src/components/plan-picker.tsx
new file mode 100644
index 000000000..463ffb895
--- /dev/null
+++ b/packages/billing-gateway/src/components/plan-picker.tsx
@@ -0,0 +1,222 @@
+'use client';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { ArrowRightIcon } from 'lucide-react';
+import { useForm } from 'react-hook-form';
+import { z } from 'zod';
+
+import { BillingSchema } from '@kit/billing';
+import { Button } from '@kit/ui/button';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@kit/ui/form';
+import { Label } from '@kit/ui/label';
+import {
+ RadioGroup,
+ RadioGroupItem,
+ RadioGroupItemLabel,
+} from '@kit/ui/radio-group';
+import { Trans } from '@kit/ui/trans';
+
+export function PlanPicker(
+ props: React.PropsWithChildren<{
+ config: z.infer;
+ onSubmit: (data: { planId: string }) => void;
+ pending?: boolean;
+ }>,
+) {
+ const intervals = props.config.products.reduce((acc, item) => {
+ return Array.from(
+ new Set([...acc, ...item.plans.map((plan) => plan.interval)]),
+ );
+ }, []);
+
+ const form = useForm({
+ resolver: zodResolver(
+ z
+ .object({
+ planId: z.string(),
+ interval: z.string(),
+ })
+ .refine(
+ (data) => {
+ const planFound = props.config.products
+ .flatMap((item) => item.plans)
+ .some((plan) => plan.id === data.planId);
+
+ if (!planFound) {
+ return false;
+ }
+
+ return intervals.includes(data.interval);
+ },
+ { message: 'Invalid plan', path: ['planId'] },
+ ),
+ ),
+ defaultValues: {
+ interval: intervals[0],
+ planId: '',
+ },
+ });
+
+ const selectedInterval = form.watch('interval');
+
+ return (
+
+
+ );
+}
+
+function Price(props: React.PropsWithChildren) {
+ return (
+
+ {props.children}
+
+ );
+}
+
+function formatCurrency(currencyCode: string, value: string) {
+ return new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: currencyCode,
+ }).format(value);
+}
diff --git a/packages/billing-gateway/src/gateway-provider-factory.ts b/packages/billing-gateway/src/gateway-provider-factory.ts
new file mode 100644
index 000000000..bb67f06fd
--- /dev/null
+++ b/packages/billing-gateway/src/gateway-provider-factory.ts
@@ -0,0 +1,33 @@
+import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
+
+import { BillingGatewayService } from './billing-gateway-service';
+
+/**
+ * @description This function retrieves the billing provider from the database and returns a
+ * new instance of the `BillingGatewayService` class. This class is used to interact with the server actions
+ * defined in the host application.
+ * @param {ReturnType} client - The Supabase server action client.
+ *
+ */
+export async function getGatewayProvider(
+ client: ReturnType,
+) {
+ const provider = await getBillingProvider(client);
+
+ return new BillingGatewayService(provider);
+}
+
+async function getBillingProvider(
+ client: ReturnType,
+) {
+ const { data, error } = await client
+ .from('config')
+ .select('billing_provider')
+ .single();
+
+ if (error ?? !data.billing_provider) {
+ throw error;
+ }
+
+ return data.billing_provider;
+}
diff --git a/packages/billing-gateway/src/index.ts b/packages/billing-gateway/src/index.ts
new file mode 100644
index 000000000..23c3dba8c
--- /dev/null
+++ b/packages/billing-gateway/src/index.ts
@@ -0,0 +1,2 @@
+export * from './billing-gateway-service';
+export * from './gateway-provider-factory';
diff --git a/packages/billing-gateway/tsconfig.json b/packages/billing-gateway/tsconfig.json
new file mode 100644
index 000000000..c4697e934
--- /dev/null
+++ b/packages/billing-gateway/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "@kit/tsconfig/base.json",
+ "compilerOptions": {
+ "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
+ },
+ "include": ["*.ts", "src"],
+ "exclude": ["node_modules"]
+}
diff --git a/packages/billing/package.json b/packages/billing/package.json
index 1881af686..d81b9670b 100644
--- a/packages/billing/package.json
+++ b/packages/billing/package.json
@@ -10,8 +10,9 @@
},
"prettier": "@kit/prettier-config",
"exports": {
- ".": "./src/create-billing-schema.ts",
- "./components/*": "./src/components/*"
+ ".": "./src/index.ts",
+ "./components/*": "./src/components/*",
+ "./schema": "./src/schema/index.ts"
},
"peerDependencies": {
"react": "^18.2.0",
diff --git a/packages/billing/src/create-billing-schema.ts b/packages/billing/src/create-billing-schema.ts
index 00e426f6d..79a9c09be 100644
--- a/packages/billing/src/create-billing-schema.ts
+++ b/packages/billing/src/create-billing-schema.ts
@@ -2,7 +2,8 @@ import { z } from 'zod';
const Interval = z.enum(['month', 'year']);
const PaymentType = z.enum(['recurring', 'one-time']);
-const BillingProvider = z.enum(['stripe']);
+
+export const BillingProvider = z.enum(['stripe', 'paddle', 'lemon-squeezy']);
const PlanSchema = z.object({
id: z.string().min(1),
@@ -72,7 +73,6 @@ export function createBillingSchema(config: z.infer) {
* Returns an array of billing plans based on the provided configuration.
*
* @param {Object} config - The configuration object containing product and plan information.
- * @return {Array} - An array of billing plans.
*/
export function getBillingPlans(config: z.infer) {
return config.products.flatMap((product) => product.plans);
@@ -82,7 +82,6 @@ export function getBillingPlans(config: z.infer) {
* Retrieves the intervals of all plans specified in the given configuration.
*
* @param {Object} config - The billing configuration containing products and plans.
- * @returns {Array} - An array of intervals.
*/
export function getPlanIntervals(config: z.infer) {
return Array.from(
@@ -93,3 +92,18 @@ export function getPlanIntervals(config: z.infer) {
),
);
}
+
+export function getProductPlanPairFromId(
+ config: z.infer,
+ planId: string,
+) {
+ for (const product of config.products) {
+ const plan = product.plans.find((plan) => plan.id === planId);
+
+ if (plan) {
+ return { product, plan };
+ }
+ }
+
+ return undefined;
+}
diff --git a/packages/billing/src/index.ts b/packages/billing/src/index.ts
new file mode 100644
index 000000000..af37a5b62
--- /dev/null
+++ b/packages/billing/src/index.ts
@@ -0,0 +1,2 @@
+export * from './create-billing-schema';
+export * from './services/billing-strategy-provider.service';
diff --git a/packages/billing/src/schema/cancel-subscription-params.schema.ts b/packages/billing/src/schema/cancel-subscription-params.schema.ts
new file mode 100644
index 000000000..b0e6ef48d
--- /dev/null
+++ b/packages/billing/src/schema/cancel-subscription-params.schema.ts
@@ -0,0 +1,6 @@
+import { z } from 'zod';
+
+export const CancelSubscriptionParamsSchema = z.object({
+ subscriptionId: z.string(),
+ invoiceNow: z.boolean().optional(),
+});
diff --git a/packages/billing/src/schema/create-biling-portal-session.schema.ts b/packages/billing/src/schema/create-biling-portal-session.schema.ts
new file mode 100644
index 000000000..75affe124
--- /dev/null
+++ b/packages/billing/src/schema/create-biling-portal-session.schema.ts
@@ -0,0 +1,6 @@
+import { z } from 'zod';
+
+export const CreateBillingPortalSessionSchema = z.object({
+ returnUrl: z.string().url(),
+ customerId: z.string().min(1),
+});
diff --git a/packages/billing/src/schema/create-billing-checkout.schema.ts b/packages/billing/src/schema/create-billing-checkout.schema.ts
new file mode 100644
index 000000000..0f87c2e98
--- /dev/null
+++ b/packages/billing/src/schema/create-billing-checkout.schema.ts
@@ -0,0 +1,13 @@
+import { z } from 'zod';
+
+export const CreateBillingCheckoutSchema = z.object({
+ returnUrl: z.string().url(),
+ accountId: z.string(),
+ planId: z.string(),
+ paymentType: z.enum(['recurring', 'one-time']),
+
+ trialPeriodDays: z.number().optional(),
+
+ customerId: z.string().optional(),
+ customerEmail: z.string().optional(),
+});
diff --git a/packages/billing/src/schema/index.ts b/packages/billing/src/schema/index.ts
new file mode 100644
index 000000000..aa78ecdcb
--- /dev/null
+++ b/packages/billing/src/schema/index.ts
@@ -0,0 +1,4 @@
+export * from './create-billing-checkout.schema';
+export * from './create-biling-portal-session.schema';
+export * from './retrieve-checkout-session.schema';
+export * from './cancel-subscription-params.schema';
diff --git a/packages/billing/src/schema/retrieve-checkout-session.schema.ts b/packages/billing/src/schema/retrieve-checkout-session.schema.ts
new file mode 100644
index 000000000..4be18b3cf
--- /dev/null
+++ b/packages/billing/src/schema/retrieve-checkout-session.schema.ts
@@ -0,0 +1,5 @@
+import { z } from 'zod';
+
+export const RetrieveCheckoutSessionSchema = z.object({
+ sessionId: z.string(),
+});
diff --git a/packages/billing/src/services/billing-strategy-provider.service.ts b/packages/billing/src/services/billing-strategy-provider.service.ts
new file mode 100644
index 000000000..63f500509
--- /dev/null
+++ b/packages/billing/src/services/billing-strategy-provider.service.ts
@@ -0,0 +1,32 @@
+import { z } from 'zod';
+
+import {
+ CancelSubscriptionParamsSchema,
+ CreateBillingCheckoutSchema,
+ CreateBillingPortalSessionSchema,
+ RetrieveCheckoutSessionSchema,
+} from '../schema';
+
+export abstract class BillingStrategyProviderService {
+ abstract createBillingPortalSession(
+ params: z.infer,
+ ): Promise<{
+ url: string;
+ }>;
+
+ abstract retrieveCheckoutSession(
+ params: z.infer,
+ ): Promise;
+
+ abstract createCheckoutSession(
+ params: z.infer,
+ ): Promise<{
+ checkoutToken: string;
+ }>;
+
+ abstract cancelSubscription(
+ params: z.infer,
+ ): Promise<{
+ success: boolean;
+ }>;
+}
diff --git a/packages/stripe/package.json b/packages/stripe/package.json
index 57bbf818a..17525a92a 100644
--- a/packages/stripe/package.json
+++ b/packages/stripe/package.json
@@ -11,10 +11,15 @@
},
"prettier": "@kit/prettier-config",
"exports": {
- ".": "./src/index.ts"
+ ".": "./src/index.ts",
+ "./components": "./src/components/index.ts"
},
"dependencies": {
- "stripe": "^14.21.0"
+ "stripe": "^14.21.0",
+ "@stripe/react-stripe-js": "^2.6.2",
+ "@stripe/stripe-js": "^3.0.10",
+ "@kit/billing": "0.1.0",
+ "@kit/ui": "0.1.0"
},
"devDependencies": {
"@kit/prettier-config": "0.1.0",
diff --git a/packages/stripe/src/components/index.ts b/packages/stripe/src/components/index.ts
new file mode 100644
index 000000000..57a128617
--- /dev/null
+++ b/packages/stripe/src/components/index.ts
@@ -0,0 +1 @@
+export * from './stripe-embedded-checkout';
diff --git a/packages/stripe/src/components/stripe-embedded-checkout.tsx b/packages/stripe/src/components/stripe-embedded-checkout.tsx
new file mode 100644
index 000000000..763b02902
--- /dev/null
+++ b/packages/stripe/src/components/stripe-embedded-checkout.tsx
@@ -0,0 +1,86 @@
+'use client';
+
+import { useState } from 'react';
+
+import {
+ EmbeddedCheckout,
+ EmbeddedCheckoutProvider,
+} from '@stripe/react-stripe-js';
+import { loadStripe } from '@stripe/stripe-js';
+
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from '@kit/ui/dialog';
+import { cn } from '@kit/ui/utils';
+
+const STRIPE_PUBLISHABLE_KEY = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
+
+if (!STRIPE_PUBLISHABLE_KEY) {
+ throw new Error(
+ 'Missing NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY environment variable. Did you forget to add it to your .env file?',
+ );
+}
+
+const stripePromise = loadStripe(STRIPE_PUBLISHABLE_KEY);
+
+export function StripeCheckout({
+ checkoutToken,
+ onClose,
+}: React.PropsWithChildren<{
+ checkoutToken: string;
+ onClose?: () => void;
+}>) {
+ return (
+
+
+
+
+
+ );
+}
+
+function EmbeddedCheckoutPopup({
+ onClose,
+ children,
+}: React.PropsWithChildren<{
+ onClose?: () => void;
+}>) {
+ const [open, setOpen] = useState(true);
+
+ const className = cn({
+ [`bg-white p-4 max-h-[98vh] overflow-y-auto shadow-transparent border border-gray-200 dark:border-dark-700`]:
+ true,
+ });
+
+ return (
+
+ );
+}
diff --git a/packages/stripe/src/create-checkout.ts b/packages/stripe/src/create-checkout.ts
index 2f215c525..f387325a1 100644
--- a/packages/stripe/src/create-checkout.ts
+++ b/packages/stripe/src/create-checkout.ts
@@ -1,14 +1,7 @@
import type { Stripe } from 'stripe';
+import { z } from 'zod';
-export interface CreateStripeCheckoutParams {
- returnUrl: string;
- organizationUid: string;
- priceId: string;
- customerId?: string;
- trialPeriodDays?: number | undefined;
- customerEmail?: string;
- embedded: boolean;
-}
+import { CreateBillingCheckoutSchema } from '@kit/billing/schema';
/**
* @name createStripeCheckout
@@ -16,43 +9,43 @@ export interface CreateStripeCheckoutParams {
* containing the session, which you can use to redirect the user to the
* checkout page
*/
-export default async function createStripeCheckout(
+export async function createStripeCheckout(
stripe: Stripe,
- params: CreateStripeCheckoutParams,
+ params: z.infer,
) {
// in MakerKit, a subscription belongs to an organization,
// rather than to a user
// if you wish to change it, use the current user ID instead
- const clientReferenceId = params.organizationUid;
+ const clientReferenceId = params.accountId;
// we pass an optional customer ID, so we do not duplicate the Stripe
// customers if an organization subscribes multiple times
const customer = params.customerId ?? undefined;
- // if it's a one-time payment
- // you should change this to "payment"
// docs: https://stripe.com/docs/billing/subscriptions/build-subscription
- const mode: Stripe.Checkout.SessionCreateParams.Mode = 'subscription';
+ const mode: Stripe.Checkout.SessionCreateParams.Mode =
+ params.paymentType === 'recurring' ? 'subscription' : 'payment';
+ // TODO: support multiple line items and per-seat pricing
const lineItem: Stripe.Checkout.SessionCreateParams.LineItem = {
quantity: 1,
- price: params.priceId,
+ price: params.planId,
};
const subscriptionData: Stripe.Checkout.SessionCreateParams.SubscriptionData =
{
trial_period_days: params.trialPeriodDays,
metadata: {
- organizationUid: params.organizationUid,
+ accountId: params.accountId,
},
};
const urls = getUrls({
- embedded: params.embedded,
returnUrl: params.returnUrl,
});
- const uiMode = params.embedded ? 'embedded' : 'hosted';
+ // we use the embedded mode, so the user does not leave the page
+ const uiMode = 'embedded';
const customerData = customer
? {
@@ -66,24 +59,17 @@ export default async function createStripeCheckout(
mode,
ui_mode: uiMode,
line_items: [lineItem],
- client_reference_id: clientReferenceId.toString(),
+ client_reference_id: clientReferenceId,
subscription_data: subscriptionData,
...customerData,
...urls,
});
}
-function getUrls(params: { returnUrl: string; embedded?: boolean }) {
- const successUrl = `${params.returnUrl}?success=true`;
- const cancelUrl = `${params.returnUrl}?cancel=true`;
- const returnUrl = `${params.returnUrl}/return?session_id={CHECKOUT_SESSION_ID}`;
+function getUrls(params: { returnUrl: string }) {
+ const returnUrl = `${params.returnUrl}?session_id={CHECKOUT_SESSION_ID}`;
- return params.embedded
- ? {
- return_url: returnUrl,
- }
- : {
- success_url: successUrl,
- cancel_url: cancelUrl,
- };
+ return {
+ return_url: returnUrl,
+ };
}
diff --git a/packages/stripe/src/create-stripe-billing-portal-session.ts b/packages/stripe/src/create-stripe-billing-portal-session.ts
index 3e00cfb0d..b25f464ae 100644
--- a/packages/stripe/src/create-stripe-billing-portal-session.ts
+++ b/packages/stripe/src/create-stripe-billing-portal-session.ts
@@ -1,9 +1,7 @@
import type { Stripe } from 'stripe';
+import { z } from 'zod';
-export interface CreateBillingPortalSessionParams {
- customerId: string;
- returnUrl: string;
-}
+import { CreateBillingPortalSessionSchema } from '@kit/billing/schema';
/**
* @name createStripeBillingPortalSession
@@ -11,7 +9,7 @@ export interface CreateBillingPortalSessionParams {
*/
export async function createStripeBillingPortalSession(
stripe: Stripe,
- params: CreateBillingPortalSessionParams,
+ params: z.infer,
) {
return stripe.billingPortal.sessions.create({
customer: params.customerId,
diff --git a/packages/stripe/src/index.ts b/packages/stripe/src/index.ts
index e71f04376..754ff8968 100644
--- a/packages/stripe/src/index.ts
+++ b/packages/stripe/src/index.ts
@@ -1 +1 @@
-export { stripe } from './stripe.service';
+export { StripeBillingStrategyService } from './stripe.service';
diff --git a/packages/stripe/src/stripe.service.ts b/packages/stripe/src/stripe.service.ts
index da22037c9..e3918c3a7 100644
--- a/packages/stripe/src/stripe.service.ts
+++ b/packages/stripe/src/stripe.service.ts
@@ -1,37 +1,59 @@
import 'server-only';
import type { Stripe } from 'stripe';
+import { z } from 'zod';
-import createStripeCheckout, {
- CreateStripeCheckoutParams,
-} from './create-checkout';
+import { BillingStrategyProviderService } from '@kit/billing';
import {
- CreateBillingPortalSessionParams,
- createStripeBillingPortalSession,
-} from './create-stripe-billing-portal-session';
+ CancelSubscriptionParamsSchema,
+ CreateBillingCheckoutSchema,
+ CreateBillingPortalSessionSchema,
+ RetrieveCheckoutSessionSchema,
+} from '@kit/billing/schema';
+
+import { createStripeCheckout } from './create-checkout';
+import { createStripeBillingPortalSession } from './create-stripe-billing-portal-session';
import { createStripeClient } from './stripe-sdk';
-class StripeService {
- constructor(private readonly stripeProvider: () => Promise) {}
-
- async createCheckout(params: CreateStripeCheckoutParams) {
+export class StripeBillingStrategyService
+ implements BillingStrategyProviderService
+{
+ async createCheckoutSession(
+ params: z.infer,
+ ) {
const stripe = await this.stripeProvider();
return createStripeCheckout(stripe, params);
}
- async createBillingPortalSession(params: CreateBillingPortalSessionParams) {
+ async createBillingPortalSession(
+ params: z.infer,
+ ) {
const stripe = await this.stripeProvider();
return createStripeBillingPortalSession(stripe, params);
}
- async cancelSubscription(subscriptionId: string) {
+ async cancelSubscription(
+ params: z.infer,
+ ) {
const stripe = await this.stripeProvider();
- return stripe.subscriptions.cancel(subscriptionId, {
- invoice_now: true,
+ await stripe.subscriptions.cancel(params.subscriptionId, {
+ invoice_now: params.invoiceNow ?? true,
});
+
+ return { success: true };
+ }
+
+ async retrieveCheckoutSession(
+ params: z.infer,
+ ) {
+ const stripe = await this.stripeProvider();
+
+ return await stripe.subscriptions.retrieve(params.sessionId);
+ }
+
+ private async stripeProvider(): Promise {
+ return createStripeClient();
}
}
-
-export const stripe = new StripeService(createStripeClient);
diff --git a/packages/supabase/src/clients/server-actions.client.ts b/packages/supabase/src/clients/server-actions.client.ts
index 8b67eedd7..21604e021 100644
--- a/packages/supabase/src/clients/server-actions.client.ts
+++ b/packages/supabase/src/clients/server-actions.client.ts
@@ -6,19 +6,15 @@ import 'server-only';
import { Database } from '../database.types';
import { getSupabaseClientKeys } from '../get-supabase-client-keys';
-const createServerSupabaseClient = () => {
+const createServerSupabaseClient = () => {
const keys = getSupabaseClientKeys();
- return createServerClient(keys.url, keys.anonKey, {
+ return createServerClient(keys.url, keys.anonKey, {
cookies: getCookiesStrategy(),
});
};
-export const getSupabaseServerActionClient = <
- GenericSchema = Database,
->(params?: {
- admin: false;
-}) => {
+export const getSupabaseServerActionClient = (params?: { admin: false }) => {
const keys = getSupabaseClientKeys();
const admin = params?.admin ?? false;
@@ -35,7 +31,7 @@ export const getSupabaseServerActionClient = <
throw new Error('Supabase Service Role Key not provided');
}
- return createServerClient(keys.url, serviceRoleKey, {
+ return createServerClient(keys.url, serviceRoleKey, {
auth: {
persistSession: false,
},
@@ -43,7 +39,7 @@ export const getSupabaseServerActionClient = <
});
}
- return createServerSupabaseClient();
+ return createServerSupabaseClient();
};
function getCookiesStrategy() {
diff --git a/packages/ui/README.md b/packages/ui/README.md
index 9f77b8083..deeb6840c 100644
--- a/packages/ui/README.md
+++ b/packages/ui/README.md
@@ -5,4 +5,20 @@ This package is responsible for managing the UI components and styles across the
This package define two sets of components:
- `shadn-ui`: A set of UI components that can be used across the app using shadn UI
-- `makerkit`: Components specific to MakerKit
\ No newline at end of file
+- `makerkit`: Components specific to MakerKit
+
+## Installing a Shadcn UI component
+
+To install a Shadcn UI component, you can use the following command in the root of the repository:
+
+```bash
+npx shadcn-ui@latest add --path=packages/ui/src/shadcn
+```
+
+For example, to install the `Button` component, you can use the following command:
+
+```bash
+npx shadcn-ui@latest add button --path=packages/ui/src/shadcn
+```
+
+We pass the `--path` flag to specify the path where the component should be installed.
\ No newline at end of file
diff --git a/packages/ui/package.json b/packages/ui/package.json
index a09494975..79028bebd 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -25,6 +25,7 @@
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-navigation-menu": "^1.1.4",
+ "@radix-ui/react-separator": "^1.0.3",
"class-variance-authority": "^0.7.0",
"react-top-loading-bar": "2.3.1",
"clsx": "^2.1.0",
@@ -91,6 +92,8 @@
"./heading": "./src/shadcn/heading.tsx",
"./alert": "./src/shadcn/alert.tsx",
"./badge": "./src/shadcn/badge.tsx",
+ "./radio-group": "./src/shadcn/radio-group.tsx",
+ "./separator": "./src/shadcn/separator.tsx",
"./utils": "./src/utils/index.ts",
"./if": "./src/makerkit/if.tsx",
"./trans": "./src/makerkit/trans.tsx",
diff --git a/packages/ui/src/shadcn/radio-group.tsx b/packages/ui/src/shadcn/radio-group.tsx
index eb7150009..367c9ba29 100644
--- a/packages/ui/src/shadcn/radio-group.tsx
+++ b/packages/ui/src/shadcn/radio-group.tsx
@@ -42,4 +42,24 @@ const RadioGroupItem = React.forwardRef<
});
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
-export { RadioGroup, RadioGroupItem };
+const RadioGroupItemLabel = (
+ props: React.PropsWithChildren<{
+ className?: string;
+ }>,
+) => {
+ return (
+
+ );
+};
+RadioGroupItemLabel.displayName = 'RadioGroupItemLabel';
+
+export { RadioGroup, RadioGroupItem, RadioGroupItemLabel };
diff --git a/packages/ui/src/shadcn/separator.tsx b/packages/ui/src/shadcn/separator.tsx
new file mode 100644
index 000000000..fa3a32749
--- /dev/null
+++ b/packages/ui/src/shadcn/separator.tsx
@@ -0,0 +1,31 @@
+"use client"
+
+import * as React from "react"
+import * as SeparatorPrimitive from "@radix-ui/react-separator"
+
+import { cn } from "@kit/ui/utils"
+
+const Separator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(
+ (
+ { className, orientation = "horizontal", decorative = true, ...props },
+ ref
+ ) => (
+
+ )
+)
+Separator.displayName = SeparatorPrimitive.Root.displayName
+
+export { Separator }
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1c167abe2..07c61365c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -47,6 +47,9 @@ importers:
'@kit/billing':
specifier: ^0.1.0
version: link:../../packages/billing
+ '@kit/billing-gateway':
+ specifier: ^0.1.0
+ version: link:../../packages/billing-gateway
'@kit/emails':
specifier: ^0.1.0
version: link:../../packages/emails
@@ -215,6 +218,46 @@ importers:
specifier: 0.1.0
version: link:../../tooling/typescript
+ packages/billing-gateway:
+ dependencies:
+ '@kit/billing':
+ specifier: 0.1.0
+ version: link:../billing
+ '@kit/stripe':
+ specifier: 0.1.0
+ version: link:../stripe
+ '@kit/supabase':
+ specifier: ^0.1.0
+ version: link:../supabase
+ '@kit/ui':
+ specifier: 0.1.0
+ version: link:../ui
+ lucide-react:
+ specifier: ^0.361.0
+ version: 0.361.0(react@18.2.0)
+ react:
+ specifier: ^18.2.0
+ version: 18.2.0
+ react-dom:
+ specifier: ^18.2.0
+ version: 18.2.0(react@18.2.0)
+ zod:
+ specifier: ^3.22.4
+ version: 3.22.4
+ devDependencies:
+ '@kit/eslint-config':
+ specifier: 0.2.0
+ version: link:../../tooling/eslint
+ '@kit/prettier-config':
+ specifier: 0.1.0
+ version: link:../../tooling/prettier
+ '@kit/tailwind-config':
+ specifier: 0.1.0
+ version: link:../../tooling/tailwind
+ '@kit/tsconfig':
+ specifier: 0.1.0
+ version: link:../../tooling/typescript
+
packages/emails:
dependencies:
'@react-email/components':
@@ -434,6 +477,18 @@ importers:
packages/stripe:
dependencies:
+ '@kit/billing':
+ specifier: 0.1.0
+ version: link:../billing
+ '@kit/ui':
+ specifier: 0.1.0
+ version: link:../ui
+ '@stripe/react-stripe-js':
+ specifier: ^2.6.2
+ version: 2.6.2(@stripe/stripe-js@3.0.10)(react-dom@18.2.0)(react@18.2.0)
+ '@stripe/stripe-js':
+ specifier: ^3.0.10
+ version: 3.0.10
stripe:
specifier: ^14.21.0
version: 14.21.0
@@ -517,6 +572,9 @@ importers:
'@radix-ui/react-select':
specifier: ^2.0.0
version: 2.0.0(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0)(react@18.2.0)
+ '@radix-ui/react-separator':
+ specifier: ^1.0.3
+ version: 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-slot':
specifier: ^1.0.2
version: 1.0.2(@types/react@18.2.67)(react@18.2.0)
@@ -3141,6 +3199,27 @@ packages:
react-remove-scroll: 2.5.5(@types/react@18.2.67)(react@18.2.0)
dev: false
+ /@radix-ui/react-separator@1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0)(react@18.2.0):
+ resolution: {integrity: sha512-itYmTy/kokS21aiV5+Z56MZB54KrhPgn6eHDKkFeOLR34HMN2s8PaN47qZZAGnvupcjxHaFZnW4pQEh0BvvVuw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0
+ react-dom: ^16.8 || ^17.0 || ^18.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+ dependencies:
+ '@babel/runtime': 7.24.1
+ '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0)(react@18.2.0)
+ '@types/react': 18.2.67
+ '@types/react-dom': 18.2.22
+ react: 18.2.0
+ react-dom: 18.2.0(react@18.2.0)
+ dev: false
+
/@radix-ui/react-slot@1.0.0(react@18.2.0):
resolution: {integrity: sha512-3mrKauI/tWXo1Ll+gN5dHcxDPdm/Df1ufcDLCecn+pnCIVcdWE7CujXo8QaXOWRJyZyQWWbpB8eFwHzWXlv5mQ==}
peerDependencies:
@@ -3741,6 +3820,24 @@ packages:
resolution: {integrity: sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==}
dev: false
+ /@stripe/react-stripe-js@2.6.2(@stripe/stripe-js@3.0.10)(react-dom@18.2.0)(react@18.2.0):
+ resolution: {integrity: sha512-FSjNg4v7BiCfojvx25PQ8DugOa09cGk1t816R/DLI/lT+1bgRAYpMvoPirLT4ZQ3ev/0VDtPdWNaabPsLDTOMA==}
+ peerDependencies:
+ '@stripe/stripe-js': ^1.44.1 || ^2.0.0 || ^3.0.0
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
+ dependencies:
+ '@stripe/stripe-js': 3.0.10
+ prop-types: 15.8.1
+ react: 18.2.0
+ react-dom: 18.2.0(react@18.2.0)
+ dev: false
+
+ /@stripe/stripe-js@3.0.10:
+ resolution: {integrity: sha512-CFRNha+aPXR8GrqJss2TbK1j4aSGZXQY8gx0hvaYiSp+dU7EK/Zs5uwFTSAgV+t8H4+jcZ/iBGajAvoMYOwy+A==}
+ engines: {node: '>=12.16'}
+ dev: false
+
/@supabase/functions-js@2.1.5:
resolution: {integrity: sha512-BNzC5XhCzzCaggJ8s53DP+WeHHGT/NfTsx2wUSSGKR2/ikLFQTBCDzMvGz/PxYMqRko/LwncQtKXGOYp1PkPaw==}
dependencies:
diff --git a/supabase/migrations/20221215192558_schema.sql b/supabase/migrations/20221215192558_schema.sql
index b9c4509da..b5c8b13f4 100644
--- a/supabase/migrations/20221215192558_schema.sql
+++ b/supabase/migrations/20221215192558_schema.sql
@@ -141,10 +141,10 @@ create type public.billing_provider as ENUM('stripe', 'lemon-squeezy', 'paddle')
*/
create table if not exists
public.config (
- enable_organization_accounts boolean default true,
- enable_account_billing boolean default true,
- enable_organization_billing boolean default true,
- billing_provider public.billing_provider default 'stripe'
+ enable_organization_accounts boolean default true not null,
+ enable_account_billing boolean default true not null,
+ enable_organization_billing boolean default true not null,
+ billing_provider public.billing_provider default 'stripe' not null
);
comment on table public.config is 'Configuration for the Supabase MakerKit.';