diff --git a/apps/platform/trpc/routers/orgRouter/setup/billingRouter.ts b/apps/platform/trpc/routers/orgRouter/setup/billingRouter.ts index 2ee25da7..c84ebc34 100644 --- a/apps/platform/trpc/routers/orgRouter/setup/billingRouter.ts +++ b/apps/platform/trpc/routers/orgRouter/setup/billingRouter.ts @@ -30,34 +30,22 @@ export const billingRouter = router({ and(eq(orgMembers.orgId, orgId), eq(orgMembers.status, 'active')) ); + const dates = orgBillingQuery + ? await billingTrpcClient.stripe.subscriptions.getSubscriptionDates.query( + { + orgId + } + ) + : null; + return { totalUsers: activeOrgMembersCount[0]?.count, currentPlan: orgPlan, - currentPeriod: orgPeriod - }; - }), - getOrgStripePortalLink: eeProcedure - .unstable_concat(orgAdminProcedure) - .query(async ({ ctx }) => { - const { org } = ctx; - const orgId = org.id; - - const orgPortalLink = - await billingTrpcClient.stripe.links.getPortalLink.query({ - orgId: orgId - }); - - if (!orgPortalLink.link) { - throw new TRPCError({ - code: 'FORBIDDEN', - message: 'Org not subscribed to a plan' - }); - } - return { - portalLink: orgPortalLink.link + currentPeriod: orgPeriod, + dates }; }), - getOrgSubscriptionPaymentLink: eeProcedure + createCheckoutSession: eeProcedure .unstable_concat(orgAdminProcedure) .input( z.object({ @@ -76,6 +64,7 @@ export const billingRouter = router({ id: true } }); + if (orgSubscriptionQuery?.id) { throw new TRPCError({ code: 'FORBIDDEN', @@ -93,24 +82,38 @@ export const billingRouter = router({ const activeOrgMembersCount = Number( activeOrgMembersCountResponse[0]?.count ?? '0' ); - const orgSubLink = - await billingTrpcClient.stripe.links.createSubscriptionPaymentLink.mutate( - { - orgId: orgId, - plan: plan, - period: period, - totalOrgUsers: activeOrgMembersCount - } - ); + const checkoutSession = + await billingTrpcClient.stripe.links.createCheckoutSession.mutate({ + orgId: orgId, + plan: plan, + period: period, + totalOrgUsers: activeOrgMembersCount + }); + + return { + checkoutSessionId: checkoutSession.id, + checkoutSessionClientSecret: checkoutSession.clientSecret + }; + }), + getOrgStripePortalLink: eeProcedure + .unstable_concat(orgAdminProcedure) + .mutation(async ({ ctx }) => { + const { org } = ctx; + const orgId = org.id; + + const orgPortalLink = + await billingTrpcClient.stripe.links.getPortalLink.query({ + orgId: orgId + }); - if (!orgSubLink.link) { + if (!orgPortalLink.link) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Org not subscribed to a plan' }); } return { - subLink: orgSubLink.link + portalLink: orgPortalLink.link }; }), isPro: eeProcedure.query(async ({ ctx }) => { diff --git a/apps/platform/trpc/routers/userRouter/securityRouter.ts b/apps/platform/trpc/routers/userRouter/securityRouter.ts index 4f16e514..ee0cdd29 100644 --- a/apps/platform/trpc/routers/userRouter/securityRouter.ts +++ b/apps/platform/trpc/routers/userRouter/securityRouter.ts @@ -1230,6 +1230,7 @@ export const securityRouter = router({ await Promise.allSettled( orgIdsArray.map(async (orgId) => { + // Update org user count await refreshOrgShortcodeCache(orgId); }) ); @@ -1242,6 +1243,16 @@ export const securityRouter = router({ status: 'removed' }) .where(inArray(orgMembers.id, orgMemberIdsArray)); + + if (!ctx.selfHosted) { + await Promise.allSettled( + orgIdsArray.map(async (orgId) => { + await billingTrpcClient.stripe.subscriptions.updateOrgUserCount.mutate( + { orgId } + ); + }) + ); + } } // delete orgs @@ -1384,7 +1395,7 @@ export const securityRouter = router({ // Delete Billing - if (env.EE_LICENSE_KEY) { + if (!ctx.selfHosted) { await Promise.all( orgIdsArray.map(async (orgId) => { await billingTrpcClient.stripe.subscriptions.cancelOrgSubscription.mutate( diff --git a/apps/web/package.json b/apps/web/package.json index 091d553b..4a3ce691 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -40,6 +40,8 @@ "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.2", "@simplewebauthn/browser": "^10.0.0", + "@stripe/react-stripe-js": "^2.8.0", + "@stripe/stripe-js": "^4.3.0", "@t3-oss/env-core": "^0.11.0", "@tailwindcss/typography": "^0.5.14", "@tanstack/react-query": "^5.52.1", diff --git a/apps/web/src/app/[orgShortcode]/settings/org/setup/billing/_components/plans-table.tsx b/apps/web/src/app/[orgShortcode]/settings/org/setup/billing/_components/plans-table.tsx index efdbcfa8..fe9de2de 100644 --- a/apps/web/src/app/[orgShortcode]/settings/org/setup/billing/_components/plans-table.tsx +++ b/apps/web/src/app/[orgShortcode]/settings/org/setup/billing/_components/plans-table.tsx @@ -1,12 +1,5 @@ 'use client'; -import { - AlertDialog, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle -} from '@/src/components/shadcn-ui/alert-dialog'; + import { Card, CardContent, @@ -15,14 +8,29 @@ import { CardHeader, CardTitle } from '@/src/components/shadcn-ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from '@/src/components/shadcn-ui/dialog'; +import { + EmbeddedCheckoutProvider, + EmbeddedCheckout +} from '@stripe/react-stripe-js'; +import { + loadStripe, + type StripeEmbeddedCheckoutOptions +} from '@stripe/stripe-js'; import { Tabs, TabsList, TabsTrigger } from '@/src/components/shadcn-ui/tabs'; import { Button } from '@/src/components/shadcn-ui/button'; -import { Check, SpinnerGap } from '@phosphor-icons/react'; import { useOrgShortcode } from '@/src/hooks/use-params'; -import { useEffect, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; +import { Check } from '@phosphor-icons/react'; import { platform } from '@/src/lib/trpc'; import { cn } from '@/src/lib/utils'; -import { ms } from '@u22n/utils/ms'; +import { env } from '@/src/env'; type PricingSwitchProps = { onSwitch: (value: string) => void; @@ -294,104 +302,60 @@ type StripeModalProps = { }; function StripeModal({ open, isYearly, plan, setOpen }: StripeModalProps) { + if (!env.NEXT_PUBLIC_BILLING_STRIPE_PUBLISHABLE_KEY) { + throw new Error( + 'Stripe publishable key not set, cannot render Stripe modal' + ); + } const orgShortcode = useOrgShortcode(); const utils = platform.useUtils(); + const stripePromise = useRef( + loadStripe(env.NEXT_PUBLIC_BILLING_STRIPE_PUBLISHABLE_KEY) + ); - const { - data: paymentLink, - isLoading: paymentLinkLoading, - error: paymentLinkError - } = platform.org.setup.billing.getOrgSubscriptionPaymentLink.useQuery( - { + const fetchClientSecret = useCallback( + () => + utils.org.setup.billing.createCheckoutSession + .fetch({ + orgShortcode, + plan, + period: isYearly ? 'yearly' : 'monthly' + }) + .then((res) => res.checkoutSessionClientSecret), + [ + isYearly, orgShortcode, plan, - period: isYearly ? 'yearly' : 'monthly' - }, - { - enabled: open - } + utils.org.setup.billing.createCheckoutSession + ] ); + const onComplete = useCallback(() => { + setOpen(false); + setTimeout(() => void utils.org.setup.billing.invalidate(), 1000); + }, [setOpen, utils.org.setup.billing]); - const { data: overview } = - platform.org.setup.billing.getOrgBillingOverview.useQuery( - { orgShortcode }, - { - enabled: open && paymentLink && !paymentLinkLoading, - refetchOnWindowFocus: true, - refetchInterval: ms('15 seconds') - } - ); - - // Open payment link once payment link is generated - useEffect(() => { - if (!open || paymentLinkLoading || !paymentLink) return; - window.open(paymentLink.subLink, '_blank'); - }, [open, paymentLink, paymentLinkLoading]); - - // handle payment info update - useEffect(() => { - if (overview?.currentPlan === 'pro') { - void utils.org.setup.billing.getOrgBillingOverview.invalidate({ - orgShortcode - }); - setOpen(false); - } - }, [ - orgShortcode, - overview, - setOpen, - utils.org.setup.billing.getOrgBillingOverview - ]); + const options = { + fetchClientSecret, + onComplete + } satisfies StripeEmbeddedCheckoutOptions; return ( - - - - Upgrade to Pro - - {paymentLinkLoading ? ( - - - Generating Payment Link - - ) : paymentLink ? ( - 'Waiting for Payment (This may take a few seconds)' - ) : ( - {paymentLinkError?.message} - )} - - -
- - We are waiting for your payment to be processed. It may take a few - seconds for the payment to reflect in app. - - {paymentLink && ( - - If a new tab was not opened,{' '} - - open it manually. - - - )} - - {`If your payment hasn't been detected correctly, please try refreshing - the page.`} - - If the issue persists, please contact support. -
- - - - -
-
+ + + + Stripe Checkout + Checkout with Stripe + + {open && ( + + + + )} + + ); } diff --git a/apps/web/src/app/[orgShortcode]/settings/org/setup/billing/page.tsx b/apps/web/src/app/[orgShortcode]/settings/org/setup/billing/page.tsx index fb124279..20ba5460 100644 --- a/apps/web/src/app/[orgShortcode]/settings/org/setup/billing/page.tsx +++ b/apps/web/src/app/[orgShortcode]/settings/org/setup/billing/page.tsx @@ -8,8 +8,6 @@ import { useOrgShortcode } from '@/src/hooks/use-params'; import { useEffect, useState } from 'react'; import CalEmbed from '@calcom/embed-react'; import { platform } from '@/src/lib/trpc'; -import { cn } from '@/src/lib/utils'; -import Link from 'next/link'; export default function Page() { const orgShortcode = useOrgShortcode(); @@ -18,13 +16,8 @@ export default function Page() { orgShortcode }); - const { data: portalLink } = - platform.org.setup.billing.getOrgStripePortalLink.useQuery( - { orgShortcode }, - { - enabled: data?.currentPlan === 'pro' - } - ); + const { mutateAsync: createPortalLink, isPending: isLoadingPortalLink } = + platform.org.setup.billing.getOrgStripePortalLink.useMutation(); const [showPlan, setShowPlans] = useState(false); @@ -78,18 +71,47 @@ export default function Page() { )} {showPlan && } {data.currentPlan === 'pro' && ( - + + +
+ {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':