Adjusted Per seat billing and added example to the sample schema

This commit is contained in:
giancarlo
2024-04-22 22:48:02 +08:00
parent b96d4cf855
commit 70da6ef1fa
19 changed files with 2190 additions and 2066 deletions

View File

@@ -661,6 +661,75 @@ NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION=true
6. **NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS** - to disable team accounts
7. **NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION** - to enable/disable the ability to create a team account
#### Personal Accounts vs Team Accounts
Personal accounts are accounts that are owned by a single user. Team accounts are accounts that are owned by multiple users.
This allows you to:
1. Server B2C customers (personal accounts)
2. Serve B2B customers (team accounts)
3. Allow both (for example, like GitHub)
In the vast majority of cases, you will want to enable billing for personal OR team accounts. I have not seen many cases where billing both was required.
To do so, please set the following variables accordingly.
For enabling personal accounts billing only:
```bash
NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING=true
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=false
```
You may also want to fully disable team accounts:
```bash
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS=false
```
To enable team accounts billing only:
```bash
NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING=false
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=true
```
To enable both, leave them both as `true`.
Please remember that for ensuring DB consistency, you need to also set them at DB level by adjusting the table `config`:
```sql
create table if not exists public.config(
enable_team_accounts boolean default true not null,
enable_account_billing boolean default true not null,
enable_team_account_billing boolean default true not null,
billing_provider public.billing_provider default 'stripe' not null
);
```
To enable personal account billing:
```sql
alter table public.config
set enable_account_billing to true;
```
To enable team account billing:
```sql
alter table public.config
set enable_team_account_billing to true;
```
To disable team accounts:
```sql
alter table public.config
set enable_team_accounts to false;
```
To leave them both enabled, just leave them as they are.
### Cloudflare Turnstile protection
To use Cloudflare's Turnstile Captcha, you need to set the following environment variables:
@@ -1089,7 +1158,7 @@ export default createBillingSchema({
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe',
name: 'Addon 2',
cost: 0,
type: 'per-seat',
type: 'per_seat',
tiers: [
{
upTo: 3,

View File

@@ -50,7 +50,7 @@ async function PersonalAccountBillingPage() {
/>
<PageBody>
<div className={'flex flex-col space-y-8'}>
<div className={'flex flex-col space-y-4'}>
<If condition={!data}>
<PersonalAccountCheckoutForm customerId={customerId} />

View File

@@ -242,7 +242,11 @@ export class TeamBillingService {
}> = [];
for (const lineItem of lineItems) {
if (lineItem.type === 'per-seat') {
// check if the line item is a per seat type
const isPerSeat = lineItem.type === 'per_seat';
if (isPerSeat) {
// get the current number of members in the account
const quantity = await this.getCurrentMembersCount(accountId);
const item = {
@@ -254,6 +258,7 @@ export class TeamBillingService {
}
}
// set initial quantity for the line items
return variantQuantities;
}

View File

@@ -80,7 +80,7 @@ async function TeamAccountBillingPage({ params }: Params) {
<PageBody>
<div
className={cn(`flex w-full flex-col space-y-6`, {
className={cn(`flex w-full flex-col space-y-4`, {
'mx-auto max-w-2xl': data,
})}
>

View File

@@ -54,18 +54,24 @@ async function ReturnCheckoutSessionPage({ searchParams }: SessionPageProps) {
/>
</div>
<div
className={
'fixed left-0 top-0 w-full bg-background/30 backdrop-blur-sm' +
' !m-0 h-full'
}
/>
<BlurryBackdrop />
</>
);
}
export default withI18n(ReturnCheckoutSessionPage);
function BlurryBackdrop() {
return (
<div
className={
'fixed left-0 top-0 w-full bg-background/30 backdrop-blur-sm' +
' !m-0 h-full'
}
/>
);
}
async function loadCheckoutSession(sessionId: string) {
const client = getSupabaseServerComponentClient();
const { error } = await requireUser(client);

View File

@@ -26,7 +26,6 @@ export default createBillingSchema({
{
name: 'Starter Monthly',
id: 'starter-monthly',
trialDays: 7,
paymentType: 'recurring',
interval: 'month',
lineItems: [
@@ -36,6 +35,26 @@ export default createBillingSchema({
cost: 9.99,
type: 'flat',
},
{
id: 'price_1P8N0zI1i3VnbZTqtUPc1Zvr',
name: 'Addon 3',
cost: 0,
type: 'per_seat',
tiers: [
{
upTo: 1,
cost: 0,
},
{
upTo: 5,
cost: 4,
},
{
upTo: 'unlimited',
cost: 3,
},
],
},
],
},
{

File diff suppressed because it is too large Load Diff

View File

@@ -1378,6 +1378,7 @@ on conflict (
-- Upsert subscription items
with item_data as (
select
(line_item ->> 'id')::varchar as line_item_id,
(line_item ->> 'product_id')::varchar as prod_id,
(line_item ->> 'variant_id')::varchar as var_id,
(line_item ->> 'type')::public.subscription_item_type as type,
@@ -1388,6 +1389,7 @@ on conflict (
from
jsonb_array_elements(line_items) as line_item)
insert into public.subscription_items(
id,
subscription_id,
product_id,
variant_id,
@@ -1397,6 +1399,7 @@ on conflict (
interval,
interval_count)
select
line_item_id,
target_subscription_id,
prod_id,
var_id,
@@ -1436,6 +1439,7 @@ grant execute on function public.upsert_subscription(uuid, varchar,
* -------------------------------------------------------
*/
create table if not exists public.subscription_items(
id varchar(255) not null primary key,
subscription_id text references public.subscriptions(id) on
delete cascade not null,
product_id varchar(255) not null,

View File

@@ -1,7 +1,13 @@
import { z } from 'zod';
enum LineItemType {
Flat = 'flat',
PerSeat = 'per_seat',
Metered = 'metered',
}
const BillingIntervalSchema = z.enum(['month', 'year']);
const LineItemTypeSchema = z.enum(['flat', 'per-seat', 'metered']);
const LineItemTypeSchema = z.nativeEnum(LineItemType);
export const BillingProviderSchema = z.enum([
'stripe',
@@ -83,8 +89,10 @@ export const PlanSchema = z
lineItems: z.array(LineItemSchema).refine(
(schema) => {
const types = schema.map((item) => item.type);
const perSeat = types.filter((type) => type === 'per-seat').length;
const flat = types.filter((type) => type === 'flat').length;
const perSeat = types.filter(
(type) => type === LineItemType.PerSeat,
).length;
const flat = types.filter((type) => type === LineItemType.Flat).length;
return perSeat <= 1 && flat <= 1;
},
@@ -135,7 +143,7 @@ export const PlanSchema = z
(data) => {
if (data.paymentType === 'one-time') {
const nonFlatLineItems = data.lineItems.filter(
(item) => item.type !== 'flat',
(item) => item.type !== LineItemType.Flat,
);
return nonFlatLineItems.length === 0;
@@ -314,7 +322,7 @@ export function getPrimaryLineItem(
}
const flatLineItem = plan.lineItems.find(
(item) => item.type === 'flat',
(item) => item.type === LineItemType.Flat,
);
if (flatLineItem) {

View File

@@ -20,10 +20,14 @@ export function EmbeddedCheckout(
);
return (
<CheckoutComponent
onClose={props.onClose}
checkoutToken={props.checkoutToken}
/>
<>
<CheckoutComponent
onClose={props.onClose}
checkoutToken={props.checkoutToken}
/>
<BlurryBackdrop />
</>
);
}
@@ -98,3 +102,14 @@ function buildLazyComponent<
return memo(LazyComponent);
}
function BlurryBackdrop() {
return (
<div
className={
'bg-background/30 fixed left-0 top-0 w-full backdrop-blur-sm' +
' !m-0 h-full'
}
/>
);
}

View File

@@ -61,7 +61,7 @@ export function LineItemDetails(
);
const FlatFee = () => (
<div key={item.id} className={'flex flex-col'}>
<div className={'flex flex-col'}>
<div className={className}>
<span className={'flex items-center space-x-1'}>
<span className={'flex items-center space-x-1.5'}>
@@ -115,8 +115,8 @@ export function LineItemDetails(
);
const PerSeat = () => (
<div className={'flex flex-col'}>
<div key={item.id} className={className}>
<div key={index} className={'flex flex-col'}>
<div className={className}>
<span className={'flex items-center space-x-1.5'}>
<PlusSquare className={'w-4'} />
@@ -141,7 +141,7 @@ export function LineItemDetails(
);
const Metered = () => (
<div key={item.id} className={'flex flex-col'}>
<div key={index} className={'flex flex-col'}>
<div className={className}>
<span className={'flex items-center space-x-1'}>
<span className={'flex items-center space-x-1.5'}>
@@ -179,13 +179,13 @@ export function LineItemDetails(
switch (item.type) {
case 'flat':
return <FlatFee />;
return <FlatFee key={item.id} />;
case 'per-seat':
return <PerSeat />;
case 'per_seat':
return <PerSeat key={item.id} />;
case 'metered': {
return <Metered />;
return <Metered key={item.id} />;
}
}
})}

View File

@@ -232,7 +232,7 @@ function PricingItem(
`animate-in slide-in-from-left-4 fade-in text-sm capitalize`,
)}
>
<If condition={props.primaryLineItem.type === 'per-seat'}>
<If condition={props.primaryLineItem.type === 'per_seat'}>
<Trans i18nKey={'billing:perTeamMember'} />
</If>

View File

@@ -60,7 +60,7 @@ export class BillingEventHandlerService {
// Handle the subscription deleted event
// here we delete the subscription from the database
logger.info(ctx, 'Processing subscription deleted event');
logger.info(ctx, 'Processing subscription deleted event...');
const { error } = await client
.from('subscriptions')

View File

@@ -127,16 +127,4 @@ export class BillingGatewayService {
return strategy.updateSubscription(payload);
}
/**
* Retrieves a plan by the specified plan ID.
* @param planId
*/
async getPlanById(planId: string) {
const strategy = await BillingGatewayFactoryService.GetProviderStrategy(
this.provider,
);
return strategy.getPlanById(planId);
}
}

View File

@@ -261,12 +261,12 @@ export class StripeWebhookHandlerService
}
private handleSubscriptionDeletedEvent(
subscription: Stripe.CustomerSubscriptionDeletedEvent,
event: Stripe.CustomerSubscriptionDeletedEvent,
onSubscriptionDeletedCallback: (subscriptionId: string) => Promise<unknown>,
) {
// Here we don't need to do anything, so we just return the callback
return onSubscriptionDeletedCallback(subscription.id);
return onSubscriptionDeletedCallback(event.data.object.id);
}
private buildSubscriptionPayload<
@@ -298,6 +298,7 @@ export class StripeWebhookHandlerService
id: item.id,
quantity,
subscription_id: params.id,
subscription_item_id: item.id,
product_id: item.price?.product as string,
variant_id: variantId,
price_amount: item.price?.unit_amount,

View File

@@ -192,7 +192,7 @@ function useGetColumns(
permissions={permissions}
member={row.original}
currentUserId={params.currentUserId}
accountId={params.currentAccountId}
currentTeamAccountId={params.currentAccountId}
currentRoleHierarchy={params.currentRoleHierarchy}
/>
),
@@ -206,12 +206,13 @@ function ActionsDropdown({
permissions,
member,
currentUserId,
currentTeamAccountId,
currentRoleHierarchy,
}: {
permissions: Permissions;
member: Members[0];
currentUserId: string;
accountId: string;
currentTeamAccountId: string;
currentRoleHierarchy: number;
}) {
const [isRemoving, setIsRemoving] = useState(false);
@@ -275,7 +276,7 @@ function ActionsDropdown({
<RemoveMemberDialog
isOpen
setIsOpen={setIsRemoving}
accountId={member.id}
teamAccountId={currentTeamAccountId}
userId={member.user_id}
/>
</If>
@@ -286,7 +287,7 @@ function ActionsDropdown({
setIsOpen={setIsUpdatingRole}
userId={member.user_id}
userRole={member.role}
teamAccountId={member.account_id}
teamAccountId={currentTeamAccountId}
userRoleHierarchy={currentRoleHierarchy}
/>
</If>

View File

@@ -19,9 +19,9 @@ import { removeMemberFromAccountAction } from '../../server/actions/team-members
export const RemoveMemberDialog: React.FC<{
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
accountId: string;
teamAccountId: string;
userId: string;
}> = ({ isOpen, setIsOpen, accountId, userId }) => {
}> = ({ isOpen, setIsOpen, teamAccountId, userId }) => {
return (
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
<AlertDialogContent>
@@ -37,7 +37,7 @@ export const RemoveMemberDialog: React.FC<{
<RemoveMemberForm
setIsOpen={setIsOpen}
accountId={accountId}
accountId={teamAccountId}
userId={userId}
/>
</AlertDialogContent>

View File

@@ -28,7 +28,7 @@ export class AccountPerSeatBillingService {
id,
subscription_items !inner (
quantity,
id: variant_id,
id,
type
)
`,

File diff suppressed because it is too large Load Diff