-
+
+
{
+ const { portalLink } = await createPortalLink({
+ orgShortcode
+ });
+ window.open(portalLink, '_blank');
+ }}>
Manage Your Subscription
-
-
+
+
+
+ {data.dates?.start_date ? (
+
+ Subscription started on
+
+ {new Date(
+ data.dates.start_date * 1000
+ ).toLocaleDateString()}
+
+
+ ) : null}
+
+
+ {data.dates?.cancel_at_period_end ? (
+ Pending cancelation on
+ ) : (
+ Subscription renews on
+ )}
+
+ {data.dates?.current_period_end
+ ? new Date(
+ data.dates?.current_period_end * 1000
+ ).toLocaleDateString()
+ : 'End of Current billing cycle'}
+
+
+
+
)}
{data.currentPlan === 'pro' && (
diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts
index 0f003d1d..17cbb310 100644
--- a/apps/web/src/env.ts
+++ b/apps/web/src/env.ts
@@ -27,7 +27,8 @@ if (!IS_BROWSER) {
'EE_ENABLED',
'POSTHOG_KEY',
'POSTHOG_ENABLED',
- 'APP_VERSION'
+ 'APP_VERSION',
+ 'BILLING_STRIPE_PUBLISHABLE_KEY'
];
PUBLIC_ENV_LIST.forEach((key) => {
@@ -48,6 +49,7 @@ export const env = createEnv({
NEXT_PUBLIC_REALTIME_PORT: z.coerce.number().optional(),
NEXT_PUBLIC_TURNSTILE_SITE_KEY: z.string().optional(),
NEXT_PUBLIC_EE_ENABLED: z.enum(['true', 'false']),
+ NEXT_PUBLIC_BILLING_STRIPE_PUBLISHABLE_KEY: z.string().optional(),
NEXT_PUBLIC_APP_VERSION: z.string().default('development'),
NEXT_PUBLIC_POSTHOG_ENABLED: z.enum(['true', 'false']).default('false'),
NEXT_PUBLIC_POSTHOG_KEY: IS_POSTHOG_ENABLED
diff --git a/ee/apps/billing/app.ts b/ee/apps/billing/app.ts
index baf87608..123564d0 100644
--- a/ee/apps/billing/app.ts
+++ b/ee/apps/billing/app.ts
@@ -5,7 +5,8 @@ import {
setupHealthReporting,
setupHonoListener,
setupRuntime,
- setupTrpcHandler
+ setupTrpcHandler,
+ setupRouteLogger
} from '@u22n/hono';
import { stripeWebhookMiddleware } from './middlewares';
import { validateLicense } from './validateLicenseKey';
@@ -19,6 +20,7 @@ import { env } from './env';
await validateLicense();
const app = createHonoApp();
+setupRouteLogger(app, env.NODE_ENV === 'development');
setupCors(app, { origin: env.WEBAPP_URL });
setupHealthReporting(app, { service: 'Billing' });
setupErrorHandlers(app);
diff --git a/ee/apps/billing/routes/stripe.ts b/ee/apps/billing/routes/stripe.ts
index 09fa0fb3..5abec085 100644
--- a/ee/apps/billing/routes/stripe.ts
+++ b/ee/apps/billing/routes/stripe.ts
@@ -1,9 +1,9 @@
import { orgBilling } from '@u22n/database/schema';
import { createHonoApp } from '@u22n/hono';
import { eq } from '@u22n/database/orm';
+import { stripeData } from '../stripe';
import { db } from '@u22n/database';
import type { Ctx } from '../ctx';
-import type Stripe from 'stripe';
export const stripeApi = createHonoApp();
@@ -12,69 +12,105 @@ stripeApi.post('/webhooks', async (c) => {
if (!stripeEvent) {
return c.json({ error: 'Missing stripe event' }, 400);
}
- if (stripeEvent.type === 'customer.subscription.updated') {
- await handleCustomerSubscriptionUpdated(stripeEvent);
- } else {
- console.info('Unhandled stripe event', {
- event: stripeEvent.type
- });
+ switch (stripeEvent.type) {
+ case 'customer.subscription.created':
+ case 'customer.subscription.updated':
+ case 'customer.subscription.deleted':
+ {
+ const { orgId } = validateMetadata(stripeEvent.data.object.metadata);
+ const priceId = stripeEvent.data.object.items.data[0]?.price.id;
+ if (!priceId) {
+ console.info('No price id found', {
+ event: stripeEvent.type,
+ data: stripeEvent.data.object
+ });
+ return c.json(null, 200);
+ }
+ const price = resolvePriceItem(priceId);
+ await createOrUpdateBillingRecords({
+ orgId,
+ price,
+ active: stripeEvent.data.object.status === 'active',
+ stripeCustomerId: stripeEvent.data.object.customer as string,
+ stripeSubscriptionId: stripeEvent.data.object.id
+ });
+ }
+ break;
+
+ default:
+ console.info('Unhandled stripe event', {
+ event: stripeEvent.type
+ });
+ break;
}
+
return c.json(null, 200);
});
-const handleCustomerSubscriptionUpdated = async (stripeEvent: Stripe.Event) => {
- const data = stripeEvent.data.object as Stripe.Subscription;
- const orgsId = Number(data.metadata.orgId);
- const subId = data.id;
- const customerId = data.customer as string;
- const status = data.status;
- const plan = data.metadata.plan as 'starter' | 'pro';
- const period = data.metadata.period as 'monthly' | 'yearly';
-
- if (status !== 'active') {
- console.error('❌', 'Subscription not active - manual check', {
- status,
- subId
- });
- return;
+export const resolvePriceItem = (id: string) => {
+ switch (id) {
+ case stripeData.plans.pro.monthly:
+ return ['pro', 'monthly'] as const;
+ case stripeData.plans.pro.yearly:
+ return ['pro', 'yearly'] as const;
+ default:
+ throw new Error(`Unknown plan ${id}`);
}
+};
- if (!orgsId || !subId || !customerId || !plan || !period) {
- console.error('❌', 'Missing data', {
- orgsId,
- subId,
- customerId,
- plan,
- period
- });
- return;
+export const validateMetadata = (
+ metadata: Record
+) => {
+ const { orgId, totalUsers } = metadata;
+ if (!orgId || isNaN(Number(orgId))) {
+ throw new Error('Invalid orgId');
}
+ if (!totalUsers || isNaN(Number(totalUsers))) {
+ throw new Error('Invalid totalUsers');
+ }
+ return { orgId: Number(orgId), totalUsers: Number(totalUsers) };
+};
+
+type BillingRecordParams = {
+ orgId: number;
+ price: ReturnType;
+ stripeCustomerId: string;
+ stripeSubscriptionId: string;
+ active: boolean;
+};
- const existingOrgBilling = await db.query.orgBilling.findFirst({
- where: eq(orgBilling.orgId, orgsId),
- columns: {
- id: true
- }
+export const createOrUpdateBillingRecords = async ({
+ active,
+ stripeCustomerId,
+ orgId,
+ price,
+ stripeSubscriptionId
+}: BillingRecordParams) => {
+ const existingRecord = await db.query.orgBilling.findFirst({
+ where: eq(orgBilling.orgId, orgId),
+ columns: { id: true }
});
- if (existingOrgBilling) {
+ // If the subscription is canceled, we need to delete the orgBilling record
+ if (!active) {
+ await db.delete(orgBilling).where(eq(orgBilling.orgId, orgId));
+ return;
+ }
+
+ const [plan, period] = price;
+ const values = {
+ orgId,
+ period,
+ stripeCustomerId,
+ stripeSubscriptionId,
+ plan
+ } as const;
+ if (existingRecord) {
await db
.update(orgBilling)
- .set({
- orgId: orgsId,
- stripeCustomerId: customerId,
- stripeSubscriptionId: subId,
- plan: plan,
- period: period
- })
- .where(eq(orgBilling.id, existingOrgBilling.id));
+ .set(values)
+ .where(eq(orgBilling.id, existingRecord.id));
} else {
- await db.insert(orgBilling).values({
- orgId: orgsId,
- stripeCustomerId: customerId,
- stripeSubscriptionId: subId,
- plan: plan,
- period: period
- });
+ await db.insert(orgBilling).values(values);
}
};
diff --git a/ee/apps/billing/scripts/sync-stripe-db.ts b/ee/apps/billing/scripts/sync-stripe-db.ts
new file mode 100644
index 00000000..6f70f493
--- /dev/null
+++ b/ee/apps/billing/scripts/sync-stripe-db.ts
@@ -0,0 +1,114 @@
+import {
+ createOrUpdateBillingRecords,
+ resolvePriceItem,
+ validateMetadata
+} from '../routes/stripe';
+import { orgBilling, orgMembers } from '@u22n/database/schema';
+import { and, eq, inArray, sql } from '@u22n/database/orm';
+import { stripeSdk } from '../stripe';
+import { db } from '@u22n/database';
+
+const confirm = process.argv.includes('--confirm');
+
+const subscriptions = await stripeSdk.subscriptions.list();
+
+const dbOrgIds = (
+ await db.query.orgBilling.findMany({
+ columns: { orgId: true }
+ })
+).map(({ orgId }) => orgId);
+
+const listOfUpdatedOrgs: number[] = [];
+
+if (!confirm) {
+ console.info('Doing a dry run, not updating any data');
+}
+
+for (const subscription of subscriptions.data) {
+ console.info('Processing subscription', subscription.id);
+
+ const { orgId, totalUsers } = validateMetadata(subscription.metadata);
+ if (listOfUpdatedOrgs.includes(orgId)) {
+ console.info('Duplicate subscription, skipping', {
+ id: subscription.id,
+ orgId
+ });
+ continue;
+ }
+ listOfUpdatedOrgs.push(orgId);
+
+ const activeOrgMembersCount = await db
+ .select({ count: sql`count(*)` })
+ .from(orgMembers)
+ .where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.status, 'active')));
+
+ const totalOrgUsers = Number(activeOrgMembersCount[0]?.count ?? '1');
+
+ if (totalOrgUsers !== totalUsers) {
+ console.info(
+ 'Total users mismatch between stripe and database, will update stripe',
+ {
+ subscriptionId: subscription.id,
+ orgId,
+ totalOrgUsers,
+ totalUsers
+ }
+ );
+ }
+
+ if (confirm) {
+ // Update the subscription with the new metadata, removing the old metadata
+ await stripeSdk.subscriptions.update(subscription.id, {
+ description: `Total users: ${totalOrgUsers}`,
+ items: [
+ {
+ id: subscription.items.data[0]?.id,
+ quantity: totalOrgUsers
+ }
+ ],
+ proration_behavior: 'always_invoice',
+ metadata: {
+ orgId,
+ totalUsers: totalOrgUsers
+ }
+ });
+ }
+
+ const priceId = subscription.items.data[0]?.price.id;
+ if (!priceId) {
+ console.info('No price id found', {
+ subscription
+ });
+ continue;
+ }
+ const price = resolvePriceItem(priceId);
+
+ if (confirm) {
+ await createOrUpdateBillingRecords({
+ orgId,
+ price,
+ stripeCustomerId: subscription.customer as string,
+ stripeSubscriptionId: subscription.id,
+ active: subscription.status === 'active'
+ });
+ }
+}
+
+const orphanDbEntries = dbOrgIds.filter(
+ (id) => !listOfUpdatedOrgs.includes(id)
+);
+
+if (orphanDbEntries.length > 0) {
+ console.info(
+ `Found ${orphanDbEntries.length} database entries which don't have a subscription attached to them`,
+ orphanDbEntries
+ );
+
+ console.info('Deleting orphan database entries');
+
+ if (confirm) {
+ await db
+ .delete(orgBilling)
+ .where(inArray(orgBilling.orgId, orphanDbEntries));
+ }
+}
diff --git a/ee/apps/billing/trpc/routers/stripeLinksRouter.ts b/ee/apps/billing/trpc/routers/stripeLinksRouter.ts
index 6998400e..f25fc6ec 100644
--- a/ee/apps/billing/trpc/routers/stripeLinksRouter.ts
+++ b/ee/apps/billing/trpc/routers/stripeLinksRouter.ts
@@ -1,11 +1,12 @@
import { stripePlans, stripeBillingPeriods, stripeSdk } from '../../stripe';
import { router, protectedProcedure } from '../trpc';
import { orgBilling } from '@u22n/database/schema';
+import { TRPCError } from '@trpc/server';
import { eq } from '@u22n/database/orm';
import { z } from 'zod';
export const stripeLinksRouter = router({
- createSubscriptionPaymentLink: protectedProcedure
+ createCheckoutSession: protectedProcedure
.input(
z.object({
orgId: z.number().min(1),
@@ -17,35 +18,35 @@ export const stripeLinksRouter = router({
.mutation(async ({ ctx, input }) => {
const { stripe } = ctx;
const { orgId, totalOrgUsers } = input;
-
const planPriceId = stripe.plans[input.plan][input.period];
const subscriptionDescription = `Total users: ${totalOrgUsers}`;
- const subscribeToPlan = await stripeSdk.paymentLinks.create({
- metadata: {
- orgId
- },
- line_items: [
- {
- price: planPriceId,
- quantity: totalOrgUsers
- }
- ],
+ const checkoutSession = await stripeSdk.checkout.sessions.create({
+ ui_mode: 'embedded',
+ metadata: { orgId },
+ line_items: [{ price: planPriceId, quantity: totalOrgUsers }],
subscription_data: {
description: subscriptionDescription,
metadata: {
orgId,
- product: 'subscription',
- plan: input.plan,
- period: input.period,
totalUsers: input.totalOrgUsers
}
},
- allow_promotion_codes: true
+ allow_promotion_codes: true,
+ mode: 'subscription',
+ redirect_on_completion: 'never'
});
+ if (!checkoutSession.client_secret) {
+ throw new TRPCError({
+ code: 'INTERNAL_SERVER_ERROR',
+ message: 'Failed to create checkout session'
+ });
+ }
+
return {
- link: subscribeToPlan.url
+ id: checkoutSession.id,
+ clientSecret: checkoutSession.client_secret
};
}),
getPortalLink: protectedProcedure
@@ -69,7 +70,7 @@ export const stripeLinksRouter = router({
throw new Error('No stripe customer id');
const portalLink = await stripeSdk.billingPortal.sessions.create({
- customer: orgBillingQuery?.stripeCustomerId
+ customer: orgBillingQuery.stripeCustomerId
});
return {
diff --git a/ee/apps/billing/trpc/routers/subscriptionsRouter.ts b/ee/apps/billing/trpc/routers/subscriptionsRouter.ts
index e2cb7a4f..848b117d 100644
--- a/ee/apps/billing/trpc/routers/subscriptionsRouter.ts
+++ b/ee/apps/billing/trpc/routers/subscriptionsRouter.ts
@@ -2,9 +2,50 @@ import { orgBilling, orgMembers } from '@u22n/database/schema';
import { router, protectedProcedure } from '../trpc';
import { and, eq, sql } from '@u22n/database/orm';
import { stripeSdk } from '../../stripe';
+import { TRPCError } from '@trpc/server';
import { z } from 'zod';
export const subscriptionsRouter = router({
+ getSubscriptionDates: protectedProcedure
+ .input(
+ z.object({
+ orgId: z.number().min(1)
+ })
+ )
+ .query(async ({ ctx, input }) => {
+ const { db } = ctx;
+ const { orgId } = input;
+
+ const orgSubscriptionQuery = await db.query.orgBilling.findFirst({
+ where: eq(orgBilling.orgId, orgId),
+ columns: {
+ id: true,
+ orgId: true,
+ stripeSubscriptionId: true,
+ stripeCustomerId: true,
+ plan: true,
+ period: true
+ }
+ });
+
+ if (!orgSubscriptionQuery?.stripeSubscriptionId) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Org is not subscribed to a plan'
+ });
+ }
+
+ const { start_date, cancel_at_period_end, current_period_end } =
+ await stripeSdk.subscriptions.retrieve(
+ orgSubscriptionQuery.stripeSubscriptionId
+ );
+
+ return {
+ start_date,
+ cancel_at_period_end,
+ current_period_end
+ };
+ }),
updateOrgUserCount: protectedProcedure
.input(
z.object({
@@ -48,7 +89,8 @@ export const subscriptionsRouter = router({
if (
stripeGetSubscriptionResult &&
- stripeGetSubscriptionResult.items?.data
+ stripeGetSubscriptionResult.items?.data &&
+ stripeGetSubscriptionResult.status === 'active'
) {
await stripeSdk.subscriptions.update(
orgSubscriptionQuery.stripeSubscriptionId,
@@ -63,10 +105,6 @@ export const subscriptionsRouter = router({
proration_behavior: 'always_invoice',
metadata: {
orgId,
- product: 'subscription',
- plan: stripeGetSubscriptionResult.metadata.plan ?? 'starter',
- period:
- stripeGetSubscriptionResult.metadata.period ?? 'monthly',
totalUsers: totalOrgUsers
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 01e6002a..f34ca164 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -366,6 +366,12 @@ importers:
'@simplewebauthn/browser':
specifier: ^10.0.0
version: 10.0.0
+ '@stripe/react-stripe-js':
+ specifier: ^2.8.0
+ version: 2.8.0(@stripe/stripe-js@4.3.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@stripe/stripe-js':
+ specifier: ^4.3.0
+ version: 4.3.0
'@t3-oss/env-core':
specifier: ^0.11.0
version: 0.11.0(typescript@5.5.4)(zod@3.23.8)
@@ -3378,6 +3384,17 @@ packages:
resolution: {integrity: sha512-4pP0EV3iTsexDx+8PPGAKCQpd/6hsQBaQhqWzU4hqKPHN5epPsxKbvUTIiYIHTxaKt6/kEaqPBpu/ufvfbrRzw==}
engines: {node: '>=16.0.0'}
+ '@stripe/react-stripe-js@2.8.0':
+ resolution: {integrity: sha512-Vf1gNEuBxA9EtxiLghm2ZWmgbADNMJw4HW6eolUu0DON/6mZvWZgk0KHolN0sozNJwYp0i/8hBsDBcBUWcvnbw==}
+ peerDependencies:
+ '@stripe/stripe-js': ^1.44.1 || ^2.0.0 || ^3.0.0 || ^4.0.0
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
+
+ '@stripe/stripe-js@4.3.0':
+ resolution: {integrity: sha512-bf8MxzzgD3dybtyIJUQSDMqxjEkJfsmj9IdRqDv609Zw08R41O7eoIy0f8KY41u8MbaFOYsn+XGJZtg1xwR2wQ==}
+ engines: {node: '>=12.16'}
+
'@swc/counter@0.1.3':
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
@@ -9528,6 +9545,15 @@ snapshots:
'@smithy/types': 3.3.0
tslib: 2.7.0
+ '@stripe/react-stripe-js@2.8.0(@stripe/stripe-js@4.3.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@stripe/stripe-js': 4.3.0
+ prop-types: 15.8.1
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ '@stripe/stripe-js@4.3.0': {}
+
'@swc/counter@0.1.3': {}
'@swc/helpers@0.5.5':