From 52f334a62155984e4aa47407f0e8a09de1856a54 Mon Sep 17 00:00:00 2001 From: Claudio Fuentes Date: Fri, 27 Jun 2025 14:19:55 -0400 Subject: [PATCH 1/4] refactor(onboarding): rename and update onboarding completion fields - Removed the 'completed' field from the Onboarding model and replaced it with 'triggerJobCompleted' to better reflect the onboarding process. - Updated related actions and components to use the new field, ensuring consistency across the application. - Added a new 'onboardingCompleted' field to the Organization model to track overall onboarding status. - Adjusted database migrations to accommodate these changes. --- .../initialize-organization-action.ts | 93 ------------------- apps/app/src/app/(app)/[orgId]/layout.tsx | 2 +- .../setup/actions/create-organization.ts | 2 +- .../app/src/app/api/retool/reset-org/route.ts | 2 +- apps/app/src/components/mobile-menu.tsx | 21 +---- .../tasks/onboarding/onboard-organization.ts | 2 +- .../migration.sql | 5 + .../migration.sql | 15 +++ packages/db/prisma/schema/onboarding.prisma | 2 +- packages/db/prisma/schema/organization.prisma | 1 + 10 files changed, 30 insertions(+), 115 deletions(-) delete mode 100644 apps/app/src/actions/organization/initialize-organization-action.ts create mode 100644 packages/db/prisma/migrations/20250627180331_add_onboaridng_field/migration.sql create mode 100644 packages/db/prisma/migrations/20250627180828_rename_completed_col/migration.sql diff --git a/apps/app/src/actions/organization/initialize-organization-action.ts b/apps/app/src/actions/organization/initialize-organization-action.ts deleted file mode 100644 index de9b0c3055..0000000000 --- a/apps/app/src/actions/organization/initialize-organization-action.ts +++ /dev/null @@ -1,93 +0,0 @@ -'use server'; - -import { createFleetLabelForOrg } from '@/jobs/tasks/device/create-fleet-label-for-org'; -import { auth } from '@/utils/auth'; -import { db } from '@comp/db'; -import { revalidatePath } from 'next/cache'; -import { headers } from 'next/headers'; -import { authActionClient } from '../safe-action'; -import { organizationSchema } from '../schema'; -import { createStripeCustomer } from './lib/create-stripe-customer'; -import { initializeOrganization } from './lib/initialize-organization'; - -export const initializeOrganizationAction = authActionClient - .inputSchema(organizationSchema) - .metadata({ - name: 'initialize-organization', - track: { - event: 'initialize-organization', - channel: 'server', - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { frameworkIds } = parsedInput; - - try { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session?.session.activeOrganizationId) { - throw new Error('User is not part of an organization'); - } - - await db.onboarding.create({ - data: { - organizationId: session.session.activeOrganizationId, - completed: false, - }, - }); - - const organizationId = session.session.activeOrganizationId; - - const stripeCustomerId = await createStripeCustomer({ - name: 'My Organization', - email: session.user.email, - organizationId, - }); - - if (!stripeCustomerId) { - throw new Error('Failed to create Stripe customer'); - } - - await db.organization.update({ - where: { id: organizationId }, - data: { stripeCustomerId }, - }); - - await initializeOrganization({ frameworkIds, organizationId }); - - await auth.api.setActiveOrganization({ - headers: await headers(), - body: { - organizationId, - }, - }); - - const userOrgs = await db.member.findMany({ - where: { - userId: session.user.id, - }, - select: { - organizationId: true, - }, - }); - - for (const org of userOrgs) { - revalidatePath(`/${org.organizationId}`); - } - - await createFleetLabelForOrg.trigger({ - organizationId, - }); - - return { - success: true, - organizationId, - }; - } catch (error) { - console.error('Error during organization creation/update:', error); - - throw new Error('Failed to create or update organization structure'); - } - }); diff --git a/apps/app/src/app/(app)/[orgId]/layout.tsx b/apps/app/src/app/(app)/[orgId]/layout.tsx index a7f9b79695..8ce26fb09e 100644 --- a/apps/app/src/app/(app)/[orgId]/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/layout.tsx @@ -83,7 +83,7 @@ export default async function Layout({ }, }); - const isOnboardingRunning = !!onboarding?.triggerJobId && !onboarding.completed; + const isOnboardingRunning = !!onboarding?.triggerJobId && !onboarding.triggerJobCompleted; const navbarHeight = 53 + 1; // 1 for border const onboardingHeight = 132 + 1; // 1 for border diff --git a/apps/app/src/app/(app)/setup/actions/create-organization.ts b/apps/app/src/app/(app)/setup/actions/create-organization.ts index c8aa27b24a..91a5e6443a 100644 --- a/apps/app/src/app/(app)/setup/actions/create-organization.ts +++ b/apps/app/src/app/(app)/setup/actions/create-organization.ts @@ -68,7 +68,7 @@ export const createOrganization = authActionClientWithoutOrg await db.onboarding.create({ data: { organizationId: orgId, - completed: false, + triggerJobCompleted: false, }, }); diff --git a/apps/app/src/app/api/retool/reset-org/route.ts b/apps/app/src/app/api/retool/reset-org/route.ts index ccc9fa791e..7d038e678e 100644 --- a/apps/app/src/app/api/retool/reset-org/route.ts +++ b/apps/app/src/app/api/retool/reset-org/route.ts @@ -81,7 +81,7 @@ export async function POST(request: NextRequest) { db.onboarding.update({ where: { organizationId }, data: { - completed: false, + triggerJobCompleted: false, }, }), ]); diff --git a/apps/app/src/components/mobile-menu.tsx b/apps/app/src/components/mobile-menu.tsx index b6d3a04e9c..d4908b4193 100644 --- a/apps/app/src/components/mobile-menu.tsx +++ b/apps/app/src/components/mobile-menu.tsx @@ -1,16 +1,15 @@ 'use client'; -import type { Organization as DbOrganization } from '@comp/db/types'; +import type { Organization } from '@comp/db/types'; import { Button } from '@comp/ui/button'; import { Icons } from '@comp/ui/icons'; import { Sheet, SheetContent } from '@comp/ui/sheet'; -import type { Organization as AuthOrganization } from 'better-auth/plugins'; import { useState } from 'react'; import { MainMenu } from './main-menu'; import { OrganizationSwitcher } from './organization-switcher'; interface MobileMenuProps { - organizations: AuthOrganization[]; + organizations: Organization[]; isCollapsed?: boolean; organizationId: string; } @@ -22,19 +21,7 @@ export function MobileMenu({ organizationId, organizations }: MobileMenuProps) { setOpen(false); }; - const adaptedOrganizations: DbOrganization[] = organizations.map((org) => ({ - ...org, - logo: org.logo ?? null, - metadata: org.metadata ? String(org.metadata) : null, - stripeCustomerId: null, - website: null, - fleetDmLabelId: null, - isFleetSetupCompleted: false, - subscriptionType: 'NONE' as const, - stripeSubscriptionData: null, - })); - - const currentOrganization = adaptedOrganizations.find((org) => org.id === organizationId) || null; + const currentOrganization = organizations.find((org) => org.id === organizationId) || null; return ( @@ -54,7 +41,7 @@ export function MobileMenu({ organizationId, organizations }: MobileMenuProps) {
diff --git a/apps/app/src/jobs/tasks/onboarding/onboard-organization.ts b/apps/app/src/jobs/tasks/onboarding/onboard-organization.ts index e393bfcc89..a84bc0442a 100644 --- a/apps/app/src/jobs/tasks/onboarding/onboard-organization.ts +++ b/apps/app/src/jobs/tasks/onboarding/onboard-organization.ts @@ -217,7 +217,7 @@ export const onboardOrganization = task({ where: { organizationId: payload.organizationId, }, - data: { completed: true }, + data: { triggerJobCompleted: true }, }); logger.info(`Created ${extractRisks.object.risks.length} risks`); diff --git a/packages/db/prisma/migrations/20250627180331_add_onboaridng_field/migration.sql b/packages/db/prisma/migrations/20250627180331_add_onboaridng_field/migration.sql new file mode 100644 index 0000000000..642541e829 --- /dev/null +++ b/packages/db/prisma/migrations/20250627180331_add_onboaridng_field/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Organization" ADD COLUMN "onboardingCompleted" BOOLEAN NOT NULL DEFAULT false; + +-- Update existing records to set onboardingCompleted to true +UPDATE "Organization" SET "onboardingCompleted" = true; diff --git a/packages/db/prisma/migrations/20250627180828_rename_completed_col/migration.sql b/packages/db/prisma/migrations/20250627180828_rename_completed_col/migration.sql new file mode 100644 index 0000000000..a989a54089 --- /dev/null +++ b/packages/db/prisma/migrations/20250627180828_rename_completed_col/migration.sql @@ -0,0 +1,15 @@ +/* + Warnings: + + - You are about to drop the column `completed` on the `Onboarding` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Onboarding" +ADD COLUMN "triggerJobCompleted" BOOLEAN NOT NULL DEFAULT false; + +-- Copy existing values from completed to triggerJobCompleted +UPDATE "Onboarding" SET "triggerJobCompleted" = "completed"; + +-- Drop the old column +ALTER TABLE "Onboarding" DROP COLUMN "completed"; diff --git a/packages/db/prisma/schema/onboarding.prisma b/packages/db/prisma/schema/onboarding.prisma index a9987fe6c7..c2386ee79b 100644 --- a/packages/db/prisma/schema/onboarding.prisma +++ b/packages/db/prisma/schema/onboarding.prisma @@ -1,7 +1,6 @@ model Onboarding { organizationId String @id organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - completed Boolean @default(true) policies Boolean @default(false) employees Boolean @default(false) vendors Boolean @default(false) @@ -13,6 +12,7 @@ model Onboarding { companyBookingDetails Json? companyDetails Json? triggerJobId String? + triggerJobCompleted Boolean @default(false) @@index([organizationId]) } diff --git a/packages/db/prisma/schema/organization.prisma b/packages/db/prisma/schema/organization.prisma index 2c25ec7fcd..7f1d75e842 100644 --- a/packages/db/prisma/schema/organization.prisma +++ b/packages/db/prisma/schema/organization.prisma @@ -10,6 +10,7 @@ model Organization { website String? // Subscription tracking + onboardingCompleted Boolean @default(false) subscriptionType SubscriptionType @default(NONE) stripeSubscriptionData Json? From 862e4ed2e21796453be2ab2a63ac913a51780553 Mon Sep 17 00:00:00 2001 From: Claudio Fuentes Date: Fri, 27 Jun 2025 14:20:31 -0400 Subject: [PATCH 2/4] refactor(migration): update SQL migration for onboarding completion field - Modified the SQL migration to use table aliases for improved clarity and consistency. - Renamed the 'completed' column to 'triggerJobCompleted' and adjusted the update statement accordingly. - Ensured the migration accurately reflects the recent changes in the onboarding model. --- .../20250627180828_rename_completed_col/migration.sql | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/db/prisma/migrations/20250627180828_rename_completed_col/migration.sql b/packages/db/prisma/migrations/20250627180828_rename_completed_col/migration.sql index a989a54089..7eab0ac48c 100644 --- a/packages/db/prisma/migrations/20250627180828_rename_completed_col/migration.sql +++ b/packages/db/prisma/migrations/20250627180828_rename_completed_col/migration.sql @@ -5,11 +5,11 @@ */ -- AlterTable -ALTER TABLE "Onboarding" +ALTER TABLE "Onboarding" AS o ADD COLUMN "triggerJobCompleted" BOOLEAN NOT NULL DEFAULT false; -- Copy existing values from completed to triggerJobCompleted -UPDATE "Onboarding" SET "triggerJobCompleted" = "completed"; +UPDATE "Onboarding" AS o SET "triggerJobCompleted" = o."completed"; -- Drop the old column -ALTER TABLE "Onboarding" DROP COLUMN "completed"; +ALTER TABLE "Onboarding" AS o DROP COLUMN "completed"; From 2ac788d193cb04087e145515c1aef4c1626b0850 Mon Sep 17 00:00:00 2001 From: Claudio Fuentes Date: Fri, 27 Jun 2025 14:46:55 -0400 Subject: [PATCH 3/4] refactor(pricing): update pricing card functionality and add money-back guarantee - Modified the PricingCard component to support separate checkout functions for upfront and monthly payments. - Updated pricing display to show annual and monthly prices clearly. - Added a section highlighting the 14-day money-back guarantee for enhanced customer assurance. - Refactored related state management to improve clarity and maintainability. --- .../src/app/(app)/upgrade/[orgId]/page.tsx | 2 +- .../(app)/upgrade/[orgId]/pricing-cards.tsx | 236 +++++++++--------- .../generate-checkout-session.ts | 71 +++--- 3 files changed, 148 insertions(+), 161 deletions(-) diff --git a/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx b/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx index f8a55adb72..3b9a233b9a 100644 --- a/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx +++ b/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx @@ -23,7 +23,7 @@ const pricingFaqData = [ id: 'free-trial', question: 'Is there a free trial available?', answer: - 'Our Starter plan is completely free. For our Done For You plan, we offer competitive pricing without free trials since our services are already affordable.', + "We offer a 14-day money back guarantee on all plans. This gives you the confidence to try our services risk-free, knowing you can get a full refund if you're not completely satisfied within the first 14 days.", }, { id: 'how-it-works', diff --git a/apps/app/src/app/(app)/upgrade/[orgId]/pricing-cards.tsx b/apps/app/src/app/(app)/upgrade/[orgId]/pricing-cards.tsx index 6ddd741ede..b98d580d1f 100644 --- a/apps/app/src/app/(app)/upgrade/[orgId]/pricing-cards.tsx +++ b/apps/app/src/app/(app)/upgrade/[orgId]/pricing-cards.tsx @@ -59,38 +59,36 @@ interface PricingCardsProps { interface PricingCardProps { planType: 'starter' | 'managed'; - onCheckout: () => void; + onCheckoutUpfront: () => void; + onCheckoutMonthly: () => void; title: string; description: string; - price: number; - priceLabel: string; + annualPrice: number; + monthlyPrice: number; subtitle?: string; features: string[]; badge?: string; - footerText?: string; - yearlyPrice?: number; - isYearly?: boolean; - isExecuting?: boolean; - buttonText?: string; + isExecutingUpfront?: boolean; + isExecutingMonthly?: boolean; isCurrentPlan?: boolean; + isLoadingSubscription?: boolean; } const PricingCard = ({ planType, - onCheckout, + onCheckoutUpfront, + onCheckoutMonthly, title, description, - price, - priceLabel, + annualPrice, + monthlyPrice, subtitle, features, badge, - footerText, - yearlyPrice, - isYearly, - isExecuting, - buttonText, + isExecutingUpfront, + isExecutingMonthly, isCurrentPlan, + isLoadingSubscription, }: PricingCardProps) => { const isPopular = planType === 'managed'; @@ -98,10 +96,10 @@ const PricingCard = ({ {isPopular && ( @@ -118,11 +116,9 @@ const PricingCard = ({ {badge && !isPopular && ( {badge} @@ -133,14 +129,12 @@ const PricingCard = ({
- ${price.toLocaleString()} - /{priceLabel} + ${annualPrice.toLocaleString()} + /year
- {isYearly && yearlyPrice && ( -

- Billed as ${yearlyPrice.toLocaleString()} yearly -

- )} +

+ or 12 payments of ${monthlyPrice.toLocaleString()} +

{subtitle && (

{subtitle}

)} @@ -184,30 +178,81 @@ const PricingCard = ({ ); })} -
-

{footerText}

+ + {/* Money Back Guarantee Section */} +
+
+
+ + + +
+
+

+ 14-Day Money Back Guarantee +

+

+ Try risk-free. Full refund if not satisfied. +

+
+
- + ) : ( +
+ + + +
+ )}
); @@ -228,11 +273,9 @@ const managedFeatures = [ 'SOC 2 or ISO 27001 Done For You', '3rd Party Audit Included', 'Compliant in 14 Days or Less', - '14 Day Money Back Guarantee', 'Dedicated Success Team', '24x7x365 Support & SLA', 'Slack Channel with Comp AI', - '12-month minimum term', ]; export function PricingCards({ @@ -242,7 +285,7 @@ export function PricingCards({ subscriptionType, }: PricingCardsProps) { const router = useRouter(); - const [isYearly, setIsYearly] = useState(true); + const [executingButton, setExecutingButton] = useState(null); // Check if user has an active starter subscription const hasStarterSubscription = (() => { @@ -285,9 +328,11 @@ export function PricingCards({ if (data?.checkoutUrl) { router.push(data.checkoutUrl); } + setExecutingButton(null); }, onError: ({ error }) => { toast.error(error.serverError || 'Failed to create checkout session'); + setExecutingButton(null); }, }); @@ -296,30 +341,31 @@ export function PricingCards({ ? `${window.location.protocol}//${window.location.host}` : process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'; - const handleSubscribe = (plan: 'starter' | 'managed') => { + const handleSubscribe = (plan: 'starter' | 'managed', paymentType: 'upfront' | 'monthly') => { // Don't allow subscribing to starter if already on starter if (plan === 'starter' && hasStarterSubscription) { return; } + // Set which button is executing + setExecutingButton(`${plan}-${paymentType}`); + let priceId: string | undefined; let planType: string; - let trialPeriodDays: number | undefined; + const isYearly = paymentType === 'upfront'; if (plan === 'starter') { - // Use starter prices with 14-day trial + // Use starter prices priceId = isYearly ? priceDetails.starterYearlyPrice?.id : priceDetails.starterMonthlyPrice?.id; planType = 'starter'; - trialPeriodDays = 14; } else { // Use managed (Done For You) prices priceId = isYearly ? priceDetails.managedYearlyPrice?.id : priceDetails.managedMonthlyPrice?.id; planType = 'done-for-you'; - trialPeriodDays = undefined; } if (!priceId) { @@ -354,7 +400,6 @@ export function PricingCards({ successUrl: `${baseUrl}/api/stripe/success?organizationId=${organizationId}&planType=${planType}`, cancelUrl: `${baseUrl}/upgrade/${organizationId}`, allowPromotionCodes: true, - trialPeriodDays, metadata: { organizationId, plan, @@ -400,46 +445,18 @@ export function PricingCards({ )} - {/* Pricing Toggle */} -
-
- - -
-
- {/* Main Grid */}
{/* Plan Selection */}
handleSubscribe('starter')} + onCheckoutUpfront={() => handleSubscribe('starter', 'upfront')} + onCheckoutMonthly={() => handleSubscribe('starter', 'monthly')} title="Starter" description="Everything you need to get compliant, fast." - price={isYearly ? starterYearlyPriceMonthly : starterMonthlyPrice} - priceLabel="month" + annualPrice={starterYearlyPriceTotal} + monthlyPrice={starterMonthlyPrice} subtitle="DIY (Do It Yourself) Compliance" features={starterFeatures} badge={ @@ -447,46 +464,29 @@ export function PricingCards({ ? isSubscriptionCanceling ? 'Canceling' : 'Current Plan' - : '14-day trial' - } - yearlyPrice={isYearly ? starterYearlyPriceTotal : undefined} - isYearly={isYearly} - isExecuting={(isExecuting && !hasStarterSubscription) || isLoadingSubscription} - buttonText={ - isLoadingSubscription - ? 'Loading...' - : hasStarterSubscription - ? isSubscriptionCanceling - ? 'Plan Canceling' - : 'Your Current Plan' - : 'Start 14-Day Free Trial' + : 'Self-Serve' } + isExecutingUpfront={executingButton === 'starter-upfront'} + isExecutingMonthly={executingButton === 'starter-monthly'} isCurrentPlan={hasStarterSubscription} + isLoadingSubscription={isLoadingSubscription} /> handleSubscribe('managed')} + onCheckoutUpfront={() => handleSubscribe('managed', 'upfront')} + onCheckoutMonthly={() => handleSubscribe('managed', 'monthly')} title="Done For You" description="For companies up to 25 people." - price={isYearly ? managedYearlyPriceMonthly : managedMonthlyPrice} - priceLabel="month" + annualPrice={managedYearlyPriceTotal} + monthlyPrice={managedMonthlyPrice} subtitle="White-glove compliance service" features={managedFeatures} badge="Popular" - yearlyPrice={isYearly ? managedYearlyPriceTotal : undefined} - isYearly={isYearly} - isExecuting={isExecuting || isLoadingSubscription} - buttonText={ - isLoadingSubscription - ? 'Loading...' - : hasStarterSubscription - ? isSubscriptionCanceling - ? 'Upgrade Instead of Canceling' - : 'Upgrade to Done For You' - : 'Continue' - } + isExecutingUpfront={executingButton === 'managed-upfront'} + isExecutingMonthly={executingButton === 'managed-monthly'} isCurrentPlan={false} + isLoadingSubscription={isLoadingSubscription} />
diff --git a/apps/app/src/app/api/stripe/generate-checkout-session/generate-checkout-session.ts b/apps/app/src/app/api/stripe/generate-checkout-session/generate-checkout-session.ts index a4e1064cfb..c2793f963d 100644 --- a/apps/app/src/app/api/stripe/generate-checkout-session/generate-checkout-session.ts +++ b/apps/app/src/app/api/stripe/generate-checkout-session/generate-checkout-session.ts @@ -4,6 +4,7 @@ import { stripe } from '@/actions/organization/lib/stripe'; import { authWithOrgAccessClient } from '@/actions/safe-action'; import { db } from '@comp/db'; import { client } from '@comp/kv'; +import type { Stripe } from 'stripe'; import { z } from 'zod'; /** @@ -19,16 +20,11 @@ import { z } from 'zod'; const generateCheckoutSessionSchema = z .object({ organizationId: z.string(), - mode: z.enum(['payment', 'setup', 'subscription']).default('subscription'), - // URLs for redirect after checkout - successUrl: z.string().url().optional(), - cancelUrl: z.string().url().optional(), - // Price and quantity for line items - priceId: z.string().optional(), - quantity: z.number().int().positive().optional().default(1), - // Other optional parameters - allowPromotionCodes: z.boolean().optional().default(false), - trialPeriodDays: z.number().int().positive().optional(), + mode: z.enum(['payment', 'subscription']), + priceId: z.string(), + successUrl: z.string().url(), + cancelUrl: z.string().url(), + allowPromotionCodes: z.boolean().optional(), metadata: z.record(z.string()).optional(), }) .refine( @@ -62,9 +58,7 @@ export const generateCheckoutSessionAction = authWithOrgAccessClient cancelUrl, mode, priceId, - quantity = 1, allowPromotionCodes = false, - trialPeriodDays, metadata, } = parsedInput; @@ -115,36 +109,16 @@ export const generateCheckoutSessionAction = authWithOrgAccessClient stripeCustomerId = newCustomer.id; } - // Build line items based on mode - const lineItems = priceId - ? [ - { - price: priceId, - quantity: quantity || 1, - }, - ] - : undefined; - - // Build subscription data if applicable - const subscriptionData = - mode === 'subscription' && trialPeriodDays - ? { - trial_period_days: trialPeriodDays, - } - : undefined; - - // Ensure we have a valid base URL - const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'; - - // ALWAYS create a checkout with a stripeCustomerId. They should enforce this. - const checkout = await stripe.checkout.sessions.create({ - customer: stripeCustomerId as string, - success_url: successUrl || `${appUrl}/api/stripe/success?organizationId=${organizationId}`, - cancel_url: cancelUrl || `${appUrl}/${organizationId}/settings/billing`, + const sessionData: Stripe.Checkout.SessionCreateParams = { + payment_method_types: ['card'], + line_items: [ + { + price: priceId, + quantity: 1, + }, + ], mode, - line_items: lineItems, - allow_promotion_codes: allowPromotionCodes, - subscription_data: subscriptionData, + ...(allowPromotionCodes && { allow_promotion_codes: true }), metadata: { organizationId, userId: user.id, @@ -152,10 +126,23 @@ export const generateCheckoutSessionAction = authWithOrgAccessClient dubCustomerId: user.id, ...metadata, }, + success_url: successUrl, + cancel_url: cancelUrl, customer_update: { address: 'auto', }, - }); + ...(stripeCustomerId && { customer: stripeCustomerId as string }), + ...(mode === 'subscription' && { + subscription_data: { + metadata: { + organizationId, + userId: user.id, + }, + }, + }), + }; + + const checkout = await stripe.checkout.sessions.create(sessionData); return { success: true, From 317555e853a35dfd5f4446a10cb400c719285850 Mon Sep 17 00:00:00 2001 From: Claudio Fuentes Date: Fri, 27 Jun 2025 14:53:01 -0400 Subject: [PATCH 4/4] refactor(billing): enforce minimum term for starter and managed plans - Added a minimum term of 12 months for both starter and managed plans in the billing settings. - Updated logic to calculate if the minimum term has been met, considering free trials. - Adjusted cancellation date calculation to reflect the new minimum term requirements. - Enhanced user messaging to clarify the commitment needed for the starter plan. --- .../(app)/[orgId]/settings/billing/page.tsx | 40 ++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/settings/billing/page.tsx b/apps/app/src/app/(app)/[orgId]/settings/billing/page.tsx index 0c75cd6dfb..6a7f2d53b3 100644 --- a/apps/app/src/app/(app)/[orgId]/settings/billing/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/settings/billing/page.tsx @@ -80,6 +80,7 @@ export default function BillingPage() { 'Community Support', ], trialDays: 14, + minimumTermMonths: 12, }, managed: { displayName: 'Done For You', @@ -98,10 +99,16 @@ export default function BillingPage() { const currentPlanConfig = planConfig[planType]; - // Calculate if minimum term has been met for managed plans + // Calculate if minimum term has been met for both starter and managed plans const hasMetMinimumTerm = () => { + // Free trials can be cancelled anytime + if (isTrialing) { + return true; + } + + // Only enforce minimum term for starter and managed plans if ( - planType !== 'managed' || + (planType !== 'starter' && planType !== 'managed') || !('currentPeriodStart' in subscription) || subscription.currentPeriodStart == null ) { @@ -113,15 +120,25 @@ export default function BillingPage() { const monthsElapsed = (now.getFullYear() - startDate.getFullYear()) * 12 + (now.getMonth() - startDate.getMonth()); - return monthsElapsed >= (planConfig.managed.minimumTermMonths || 12); + const minimumTermMonths = + planType === 'starter' + ? planConfig.starter.minimumTermMonths + : planConfig.managed.minimumTermMonths; + + return monthsElapsed >= (minimumTermMonths || 12); }; const canCancelSubscription = hasMetMinimumTerm(); // Calculate when cancellation will be available const getCancellationAvailableDate = () => { + // Free trials don't have minimum term + if (isTrialing) { + return null; + } + if ( - planType !== 'managed' || + (planType !== 'starter' && planType !== 'managed') || !('currentPeriodStart' in subscription) || subscription.currentPeriodStart == null ) { @@ -130,9 +147,12 @@ export default function BillingPage() { const startDate = new Date(subscription.currentPeriodStart * 1000); const cancellationDate = new Date(startDate); - cancellationDate.setMonth( - cancellationDate.getMonth() + (planConfig.managed.minimumTermMonths || 12), - ); + const minimumTermMonths = + planType === 'starter' + ? planConfig.starter.minimumTermMonths + : planConfig.managed.minimumTermMonths; + + cancellationDate.setMonth(cancellationDate.getMonth() + (minimumTermMonths || 12)); return cancellationDate; }; @@ -349,8 +369,8 @@ export default function BillingPage() {

{planType === 'starter' && (

- Add a payment method now to continue after your 14-day trial. You won't be charged - until the trial ends. + Add a payment method now to continue after your trial. You won't be charged until + the trial ends. Note: This plan requires a 12-month minimum commitment.

)} {planType === 'managed' && ( @@ -469,7 +489,7 @@ export default function BillingPage() {
)} - {planType === 'managed' && + {(planType === 'starter' || planType === 'managed') && !canCancelSubscription && getCancellationAvailableDate() && (