Skip to content

Commit a5c7bc7

Browse files
dstaleyaelioxmauricioabreujescalanalexcarpenter
authored
feat(*): Add support for per-seat costs (#8629)
Co-authored-by: Keiran Flanigan <keiran@clerk.dev> Co-authored-by: Mauricio Antunes <mauricio.abreua@gmail.com> Co-authored-by: Jeff Escalante <jescalan@users.noreply.github.com> Co-authored-by: Alex Carpenter <alex.carpenter@clerk.dev>
1 parent c5d2694 commit a5c7bc7

48 files changed

Lines changed: 2401 additions & 213 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/many-views-check.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@clerk/localizations': minor
3+
'@clerk/clerk-js': minor
4+
'@clerk/shared': minor
5+
'@clerk/react': minor
6+
'@clerk/testing': minor
7+
'@clerk/ui': minor
8+
---
9+
10+
Add support for Clerk Billing plans with per-seat costs.
11+
12+
- New invite-to-checkout flow when inviting members while on a plan that uses per-seat costs.
13+
- New localization values to support UI additions.
14+
- Support for the `orgId` and `minSeats` parameters to `getPlans()`.
15+
- Support for the `seatsQuantity` and `priceId` parameters to checkout creation.
16+
- New `totals` field on payments.
17+
- New `availablePrices` field on plans.
18+
- New `nextPayment` field on subscription items.
19+
- New `discounts` field on checkouts.
20+
- Additional fields on `nextPayment` for more granularity.

integration/templates/vue-vite/src/router.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ const routes = [
3636
path: '/custom-pages/organization-profile',
3737
component: () => import('./views/custom-pages/OrganizationProfile.vue'),
3838
},
39+
{
40+
name: 'OrganizationProfile',
41+
path: '/organization-profile',
42+
component: () => import('./views/OrganizationProfile.vue'),
43+
},
3944
{
4045
name: 'PricingTable',
4146
path: '/pricing-table',
@@ -119,7 +124,14 @@ const router = createRouter({
119124

120125
router.beforeEach(async (to, _, next) => {
121126
const { isSignedIn, isLoaded } = useAuth();
122-
const authenticatedPages = ['Profile', 'Admin', 'CustomUserProfile', 'CustomOrganizationProfile', 'UserAvatar'];
127+
const authenticatedPages = [
128+
'Profile',
129+
'Admin',
130+
'CustomUserProfile',
131+
'CustomOrganizationProfile',
132+
'OrganizationProfile',
133+
'UserAvatar',
134+
];
123135

124136
if (!isLoaded.value) {
125137
await waitForClerkJsLoaded(isLoaded);
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<script setup lang="ts">
2+
import { OrganizationProfile } from '@clerk/vue';
3+
</script>
4+
5+
<template>
6+
<OrganizationProfile />
7+
</template>
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
import type { FakeOrganization, FakeUser } from '../testUtils';
4+
import { createTestUtils, testAgainstRunningApps } from '../testUtils';
5+
6+
const INVITEE_EMAIL = 'one+clerk_test@clerk.dev';
7+
8+
test.describe.configure({ mode: 'serial' });
9+
10+
testAgainstRunningApps({})('per-seat pricing @billing', ({ app }) => {
11+
let fakeAdmin: FakeUser;
12+
let fakeOrganization: FakeOrganization;
13+
14+
test.beforeAll(async () => {
15+
const u = createTestUtils({ app });
16+
17+
fakeAdmin = u.services.users.createFakeUser();
18+
const admin = await u.services.users.createBapiUser(fakeAdmin);
19+
fakeOrganization = await u.services.users.createFakeOrganization(admin.id);
20+
});
21+
22+
test.afterAll(async () => {
23+
await fakeOrganization.delete();
24+
await fakeAdmin.deleteIfExists();
25+
await app.teardown();
26+
});
27+
28+
test('invites a member after purchasing additional seats', async ({ page, context }) => {
29+
const u = createTestUtils({ app, page, context });
30+
const fillInviteForm = async () => {
31+
const emailInput = u.po.page.getByTestId('tag-input');
32+
await emailInput.fill(INVITEE_EMAIL);
33+
await emailInput.press('Enter');
34+
35+
await u.po.page.getByRole('button', { name: /select role/i }).click();
36+
await u.po.page.getByRole('option', { name: /^member$/i }).click();
37+
};
38+
39+
await u.po.signIn.goTo();
40+
await u.po.signIn.waitForMounted();
41+
await u.po.signIn.signInWithEmailAndInstantPassword({
42+
email: fakeAdmin.email,
43+
password: fakeAdmin.password,
44+
});
45+
await u.po.expect.toBeSignedIn();
46+
47+
await u.po.organizationProfile.goTo();
48+
await u.po.organizationProfile.switchToBillingTab();
49+
await expect(u.po.page.getByRole('heading', { name: 'Billing' })).toBeVisible();
50+
51+
await u.po.page.getByText(/^Switch plans$/).click();
52+
await u.po.pricingTable.waitForMounted();
53+
await u.po.pricingTable.startCheckout({ planSlug: 'bronze' });
54+
await u.po.checkout.waitForMounted();
55+
await u.po.checkout.fillTestCard();
56+
await u.po.checkout.clickPayOrSubscribe();
57+
await expect(u.po.checkout.root.getByText('Payment was successful!')).toBeVisible({
58+
timeout: 15_000,
59+
});
60+
await u.po.checkout.confirmAndContinue();
61+
62+
await u.po.page.getByText(/^Members$/).click();
63+
await expect(u.po.page.getByRole('heading', { name: 'Members' })).toBeVisible();
64+
65+
await u.po.page.getByRole('button', { name: 'Invite' }).click();
66+
await fillInviteForm();
67+
68+
const purchaseSeatsButton = u.po.page.getByRole('button', { name: 'Purchase additional seats' });
69+
await expect(purchaseSeatsButton).toBeVisible();
70+
await purchaseSeatsButton.click();
71+
72+
await u.po.checkout.waitForMounted();
73+
await u.po.checkout.clickPayOrSubscribe();
74+
await expect(u.po.checkout.root.getByText('Payment was successful!')).toBeVisible({
75+
timeout: 15_000,
76+
});
77+
await u.po.checkout.confirmAndContinue();
78+
await u.po.checkout.root.waitFor({ state: 'hidden', timeout: 15_000 });
79+
80+
await u.po.organizationProfile.goTo();
81+
await u.po.page.getByText(/^Members$/).click();
82+
await expect(u.po.page.getByRole('heading', { name: 'Members' })).toBeVisible();
83+
await u.po.page.getByRole('button', { name: 'Invite' }).click();
84+
await fillInviteForm();
85+
86+
const sendInvitationsButton = u.po.page.getByRole('button', { name: 'Send invitations' });
87+
await expect(sendInvitationsButton).toBeEnabled({ timeout: 15_000 });
88+
await sendInvitationsButton.click({ timeout: 15_000 });
89+
90+
await expect(u.po.page.getByText('Invitations successfully sent')).toBeVisible({
91+
timeout: 15_000,
92+
});
93+
await u.po.page.getByRole('button', { name: 'Finish' }).click();
94+
95+
await u.po.page.getByRole('tab', { name: /Invitations/i }).click();
96+
await expect(u.po.page.getByText(INVITEE_EMAIL)).toBeVisible({
97+
timeout: 15_000,
98+
});
99+
});
100+
});

integration/tests/pricing-table.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -355,8 +355,7 @@ testAgainstRunningApps({})('pricing table @billing', ({ app }) => {
355355
await expect(u.po.checkout.root.getByText('Free trial')).toBeHidden();
356356

357357
await expect(matchLineItem(u.po.checkout.root, 'Total Due after')).toBeHidden();
358-
await expect(matchLineItem(u.po.checkout.root, 'Subtotal', '$999.00')).toBeVisible();
359-
await expect(matchLineItem(u.po.checkout.root, 'Total Due Today', '$999.00')).toBeVisible();
358+
await expect(matchLineItem(u.po.checkout.root, 'Total due today', '$999.00')).toBeVisible();
360359
expect(await countLineItems(u.po.checkout.root)).toBe(3);
361360

362361
await u.po.checkout.root.getByRole('button', { name: /^pay\s\$/i }).waitFor({ state: 'visible' });

packages/clerk-js/bundlewatch.config.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
"files": [
33
{ "path": "./dist/clerk.js", "maxSize": "549KB" },
44
{ "path": "./dist/clerk.browser.js", "maxSize": "71KB" },
5-
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "112KB" },
5+
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "114KB" },
66
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "316KB" },
7-
{ "path": "./dist/clerk.native.js", "maxSize": "70KB" },
7+
{ "path": "./dist/clerk.native.js", "maxSize": "72KB" },
88
{ "path": "./dist/vendors*.js", "maxSize": "7KB" },
99
{ "path": "./dist/coinbase*.js", "maxSize": "36KB" },
1010
{ "path": "./dist/base-account-sdk*.js", "maxSize": "203KB" },

packages/clerk-js/src/core/modules/billing/namespace.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,13 @@ export class Billing implements BillingNamespace {
3636
}
3737

3838
getPlans = async (params?: GetPlansParams): Promise<ClerkPaginatedResponse<BillingPlanResource>> => {
39-
const { for: forParam, ...safeParams } = params || {};
40-
const searchParams = { ...safeParams, payer_type: forParam === 'organization' ? 'org' : 'user' };
39+
const { for: forParam, orgId, minSeats, ...safeParams } = params || {};
40+
const searchParams = {
41+
...safeParams,
42+
payer_type: forParam === 'organization' ? 'org' : 'user',
43+
org_id: orgId,
44+
min_seats: minSeats,
45+
};
4146
return await BaseResource._fetch({
4247
path: `${Billing.#pathRoot}/plans`,
4348
method: 'GET',

packages/clerk-js/src/core/modules/checkout/instance.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,16 @@ type CheckoutKey = string & { readonly __tag: 'CheckoutKey' };
99
/**
1010
* Generate cache key for checkout instance
1111
*/
12-
function cacheKey(options: { userId: string; orgId?: string; planId: string; planPeriod: string }): CheckoutKey {
13-
const { userId, orgId, planId, planPeriod } = options;
14-
return `${userId}-${orgId || 'user'}-${planId}-${planPeriod}` as CheckoutKey;
12+
function cacheKey(options: {
13+
userId: string;
14+
orgId?: string;
15+
planId: string;
16+
planPeriod: string;
17+
seatsQuantity?: number;
18+
priceId?: string;
19+
}): CheckoutKey {
20+
const { userId, orgId, planId, planPeriod, seatsQuantity, priceId } = options;
21+
return `${userId}-${orgId || 'user'}-${planId}-${planPeriod}-${seatsQuantity}-${priceId}` as CheckoutKey;
1522
}
1623

1724
/**
@@ -26,7 +33,7 @@ const CheckoutSignalCache = new Map<
2633
* Create a checkout instance with the given options
2734
*/
2835
function createCheckoutInstance(clerk: Clerk, options: __experimental_CheckoutOptions): CheckoutSignalValue {
29-
const { for: forOrganization, planId, planPeriod } = options;
36+
const { for: forOrganization, planId, planPeriod, seatsQuantity, priceId } = options;
3037

3138
if (clerk.user === null) {
3239
throw new Error('Clerk: User is not authenticated');
@@ -43,6 +50,8 @@ function createCheckoutInstance(clerk: Clerk, options: __experimental_CheckoutOp
4350
orgId: forOrganization === 'organization' ? clerk.organization?.id : undefined,
4451
planId,
4552
planPeriod,
53+
seatsQuantity,
54+
priceId,
4655
});
4756

4857
const checkoutInstance = CheckoutSignalCache.get(checkoutKey);
@@ -56,6 +65,8 @@ function createCheckoutInstance(clerk: Clerk, options: __experimental_CheckoutOp
5665
...(forOrganization === 'organization' ? { orgId: clerk.organization?.id } : {}),
5766
planId,
5867
planPeriod,
68+
seatsQuantity,
69+
priceId,
5970
});
6071

6172
CheckoutSignalCache.set(checkoutKey, { resource: checkout, signals });

packages/clerk-js/src/core/resources/BillingCheckout.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ import { retry } from '@clerk/shared/retry';
44
import type {
55
BillingCheckoutJSON,
66
BillingCheckoutResource,
7-
BillingCheckoutTotals,
87
BillingPayerResource,
98
BillingPaymentMethodResource,
109
BillingSubscriptionPlanPeriod,
10+
BillingTotals,
1111
CheckoutFlowFinalizeParams,
1212
CheckoutFlowResource,
1313
CheckoutFlowResourceNonStrict,
@@ -34,7 +34,7 @@ export class BillingCheckout extends BaseResource implements BillingCheckoutReso
3434
planPeriod!: BillingSubscriptionPlanPeriod;
3535
planPeriodStart!: number | undefined;
3636
status!: 'needs_confirmation' | 'completed';
37-
totals!: BillingCheckoutTotals;
37+
totals!: BillingTotals;
3838
isImmediatePlanChange!: boolean;
3939
freeTrialEndsAt?: Date;
4040
payer!: BillingPayerResource;

packages/clerk-js/src/core/resources/BillingPayment.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ import type {
55
BillingPaymentMethodResource,
66
BillingPaymentResource,
77
BillingPaymentStatus,
8+
BillingPaymentTotals,
89
BillingSubscriptionItemResource,
910
} from '@clerk/shared/types';
1011

11-
import { billingMoneyAmountFromJSON } from '../../utils';
12+
import { billingMoneyAmountFromJSON, billingPaymentTotalsFromJSON } from '../../utils';
1213
import { unixEpochToDate } from '../../utils/date';
1314
import { BaseResource, BillingPaymentMethod, BillingSubscriptionItem } from './internal';
1415

@@ -22,6 +23,7 @@ export class BillingPayment extends BaseResource implements BillingPaymentResour
2223
subscriptionItem!: BillingSubscriptionItemResource;
2324
chargeType!: BillingPaymentChargeType;
2425
status!: BillingPaymentStatus;
26+
totals: BillingPaymentTotals | null = null;
2527

2628
constructor(data: BillingPaymentJSON) {
2729
super();
@@ -42,6 +44,7 @@ export class BillingPayment extends BaseResource implements BillingPaymentResour
4244
this.subscriptionItem = new BillingSubscriptionItem(data.subscription_item);
4345
this.chargeType = data.charge_type;
4446
this.status = data.status;
47+
this.totals = data.totals ? billingPaymentTotalsFromJSON(data.totals) : null;
4548
return this;
4649
}
4750
}

0 commit comments

Comments
 (0)