diff --git a/api/src/billing/billing-notifications.service.ts b/api/src/billing/billing-notifications.service.ts index 739b66d..b6eefae 100644 --- a/api/src/billing/billing-notifications.service.ts +++ b/api/src/billing/billing-notifications.service.ts @@ -84,6 +84,7 @@ export class BillingNotificationsService { [BillingNotificationType.DAILY_LIMIT_REACHED]: 12, [BillingNotificationType.MONTHLY_LIMIT_REACHED]: 48, [BillingNotificationType.BULK_SMS_LIMIT_REACHED]: 12, + [BillingNotificationType.DEVICE_LIMIT_REACHED]: 48, [BillingNotificationType.DAILY_LIMIT_APPROACHING]: 24, [BillingNotificationType.MONTHLY_LIMIT_APPROACHING]: 48, }[type] diff --git a/api/src/billing/billing.dto.ts b/api/src/billing/billing.dto.ts index ce34868..6e55fad 100644 --- a/api/src/billing/billing.dto.ts +++ b/api/src/billing/billing.dto.ts @@ -32,8 +32,8 @@ export class CheckoutInputDTO { @ApiProperty({ type: String }) discountId?: string - @ApiProperty({ type: Boolean }) - isYearly?: boolean + @ApiProperty({ enum: ['monthly', 'yearly'], required: false }) + billingInterval?: 'monthly' | 'yearly' } export class CheckoutResponseDTO { diff --git a/api/src/billing/billing.service.ts b/api/src/billing/billing.service.ts index 1dde1c4..bfbd074 100644 --- a/api/src/billing/billing.service.ts +++ b/api/src/billing/billing.service.ts @@ -135,6 +135,7 @@ export class BillingService { processedSmsLastMonth, dailyLimit: effectiveLimits.dailyLimit, monthlyLimit: effectiveLimits.monthlyLimit, + deviceLimit: effectiveLimits.deviceLimit, dailyRemaining: effectiveLimits.dailyLimit === -1 ? -1 @@ -213,6 +214,7 @@ export class BillingService { processedSmsLastMonth, dailyLimit: effectiveLimits.dailyLimit, monthlyLimit: effectiveLimits.monthlyLimit, + deviceLimit: effectiveLimits.deviceLimit, dailyRemaining: effectiveLimits.dailyLimit === -1 ? -1 @@ -246,14 +248,23 @@ export class BillingService { payload: any req: any }): Promise { - const isYearly = payload.isYearly + const billingInterval = + payload.billingInterval === 'yearly' ? 'yearly' : 'monthly' const existingCheckoutSession = await this.checkoutSessionModel.findOne({ user: user._id, expiresAt: { $gt: new Date() }, + isCompleted: { $ne: true }, + isAbandoned: { $ne: true }, }) - if (existingCheckoutSession) { + // Only reuse a cached checkout created for the same plan and billing + // interval, otherwise Polar would preselect the wrong product + if ( + existingCheckoutSession && + existingCheckoutSession.planName === payload.planName && + existingCheckoutSession.billingInterval === billingInterval + ) { return { redirectUrl: existingCheckoutSession.checkoutUrl } } @@ -282,12 +293,17 @@ export class BillingService { payload.discountId ?? process.env.POLAR_DEFAULT_DISCOUNT_ID try { + // Polar preselects the first product in the list, so order it by the + // billing interval the user chose + const orderedProductIds = ( + billingInterval === 'yearly' + ? [selectedPlan.polarYearlyProductId, selectedPlan.polarMonthlyProductId] + : [selectedPlan.polarMonthlyProductId, selectedPlan.polarYearlyProductId] + ).filter(Boolean) + const checkoutOptions: any = { // productId: selectedPlan.polarProductId, // deprecated - products: [ - selectedPlan.polarMonthlyProductId, - selectedPlan.polarYearlyProductId, - ], + products: orderedProductIds, successUrl: `${process.env.FRONTEND_URL}/dashboard/account?checkout-success=1&checkout_id={CHECKOUT_ID}`, cancelUrl: `${process.env.FRONTEND_URL}/dashboard/account?checkout-cancel=1&checkout_id={CHECKOUT_ID}`, customerEmail: user.email, @@ -324,6 +340,8 @@ export class BillingService { user: user._id, checkoutSessionId: checkout.id, checkoutUrl: checkout.url, + planName: payload.planName, + billingInterval, expiresAt: new Date(checkout.expiresAt), payload: checkout, }, @@ -402,6 +420,7 @@ export class BillingService { dailyLimit: plan.dailyLimit, monthlyLimit: plan.monthlyLimit, bulkSendLimit: plan.bulkSendLimit, + deviceLimit: plan.deviceLimit ?? -1, } } @@ -409,6 +428,8 @@ export class BillingService { dailyLimit: subscription.customDailyLimit ?? plan.dailyLimit, monthlyLimit: subscription.customMonthlyLimit ?? plan.monthlyLimit, bulkSendLimit: subscription.customBulkSendLimit ?? plan.bulkSendLimit, + deviceLimit: + subscription.customDeviceLimit ?? plan.deviceLimit ?? -1, } } @@ -427,6 +448,24 @@ export class BillingService { return this.getEffectiveLimits(subscription, subscription.plan) } + async notifyDeviceLimitReached( + userId: Types.ObjectId | string, + deviceLimit: number, + activeDeviceCount: number, + ) { + await this.billingNotifications.notifyOnce({ + userId, + type: BillingNotificationType.DEVICE_LIMIT_REACHED, + title: 'Active device limit reached', + message: `Your plan allows up to ${deviceLimit} active device(s) and you have ${activeDeviceCount}. Disable or delete another device, or upgrade your plan to connect more devices.`, + meta: { + deviceLimit, + activeDeviceCount, + }, + sendEmail: true, + }) + } + async switchPlan({ userId, newPlanName, @@ -732,6 +771,7 @@ export class BillingService { dailyLimit: effectiveLimits.dailyLimit, monthlyLimit: effectiveLimits.monthlyLimit, bulkSendLimit: effectiveLimits.bulkSendLimit, + deviceLimit: effectiveLimits.deviceLimit, dailyRemaining: effectiveLimits.dailyLimit === -1 ? -1 diff --git a/api/src/billing/schemas/billing-notification.schema.ts b/api/src/billing/schemas/billing-notification.schema.ts index a55bc7c..a418880 100644 --- a/api/src/billing/schemas/billing-notification.schema.ts +++ b/api/src/billing/schemas/billing-notification.schema.ts @@ -9,6 +9,7 @@ export enum BillingNotificationType { DAILY_LIMIT_REACHED = 'daily_limit_reached', MONTHLY_LIMIT_REACHED = 'monthly_limit_reached', BULK_SMS_LIMIT_REACHED = 'bulk_sms_limit_reached', + DEVICE_LIMIT_REACHED = 'device_limit_reached', DAILY_LIMIT_APPROACHING = 'daily_limit_approaching', MONTHLY_LIMIT_APPROACHING = 'monthly_limit_approaching', } diff --git a/api/src/billing/schemas/checkout-session.schema.ts b/api/src/billing/schemas/checkout-session.schema.ts index bf3acd7..1cd8db1 100644 --- a/api/src/billing/schemas/checkout-session.schema.ts +++ b/api/src/billing/schemas/checkout-session.schema.ts @@ -17,6 +17,12 @@ export class CheckoutSession { @Prop({ type: String, required: true }) checkoutUrl: string + @Prop({ type: String }) + planName?: string + + @Prop({ type: String, enum: ['monthly', 'yearly'] }) + billingInterval?: string + @Prop({ type: Date, required: true }) expiresAt: Date diff --git a/api/src/billing/schemas/plan.schema.ts b/api/src/billing/schemas/plan.schema.ts index e91cbe9..ff18240 100644 --- a/api/src/billing/schemas/plan.schema.ts +++ b/api/src/billing/schemas/plan.schema.ts @@ -17,6 +17,10 @@ export class Plan { @Prop({ required: true }) bulkSendLimit: number + // max number of enabled devices; -1 means unlimited + @Prop({ type: Number, default: -1 }) + deviceLimit?: number + @Prop({ required: true }) monthlyPrice: number // in cents diff --git a/api/src/billing/schemas/subscription.schema.ts b/api/src/billing/schemas/subscription.schema.ts index 35fea74..dd62f11 100644 --- a/api/src/billing/schemas/subscription.schema.ts +++ b/api/src/billing/schemas/subscription.schema.ts @@ -62,6 +62,10 @@ export class Subscription { @Prop({ type: Number }) customBulkSendLimit?: number + + // no default on purpose: absent means "no override", fall back to plan.deviceLimit + @Prop({ type: Number }) + customDeviceLimit?: number } export const SubscriptionSchema = SchemaFactory.createForClass(Subscription) diff --git a/api/src/gateway/gateway.service.ts b/api/src/gateway/gateway.service.ts index a60fc9e..79d4fce 100644 --- a/api/src/gateway/gateway.service.ts +++ b/api/src/gateway/gateway.service.ts @@ -39,6 +39,54 @@ export class GatewayService { private smsQueueService: SmsQueueService, ) {} + // Blocks creating or re-enabling a device when the user's plan device limit + // is reached. Effective limit comes from the subscription override or the + // plan (deviceLimit of -1 or missing means unlimited). Only enabled devices + // count toward the limit. Fails open if the limit lookup itself errors. + private async assertDeviceLimitNotReached( + userId: Types.ObjectId | string, + { excludeDeviceId }: { excludeDeviceId?: Types.ObjectId | string } = {}, + ): Promise { + let deviceLimit: number + try { + const limits = await this.billingService.getUserLimits( + userId?.toString(), + ) + deviceLimit = limits?.deviceLimit ?? -1 + } catch (error) { + console.error('assertDeviceLimitNotReached: failed to load limits', error) + return + } + + if (deviceLimit == null || deviceLimit === -1) { + return + } + + const filter: any = { user: userId, enabled: true } + if (excludeDeviceId) { + filter._id = { $ne: excludeDeviceId } + } + const activeDeviceCount = await this.deviceModel.countDocuments(filter) + + if (activeDeviceCount >= deviceLimit) { + this.billingService + .notifyDeviceLimitReached(userId, deviceLimit, activeDeviceCount) + .catch((error) => { + console.error('failed to send device limit notification', error) + }) + + throw new HttpException( + { + message: `Active device limit reached — your plan allows up to ${deviceLimit} active device(s) and you have ${activeDeviceCount}. Disable or delete another device, or upgrade your plan at https://textbee.dev/pricing`, + hasReachedLimit: true, + deviceLimit, + activeDeviceCount, + }, + HttpStatus.TOO_MANY_REQUESTS, + ) + } + } + async registerDevice( input: RegisterDeviceInputDTO, user: User, @@ -77,11 +125,14 @@ export class GatewayService { } if (device && device.appVersionCode <= 11) { + // re-enable path: updateDevice enforces the device limit on the + // disabled -> enabled transition return await this.updateDevice(device._id.toString(), { ...deviceData, enabled: true, }) } else { + await this.assertDeviceLimitNotReached(user._id) return await this.deviceModel.create(deviceData) } } @@ -113,6 +164,14 @@ export class GatewayService { input.enabled = true; } + // enforce the device limit only on the disabled -> enabled transition so + // routine updates of already-enabled devices are never blocked + if (!device.enabled && input.enabled) { + await this.assertDeviceLimitNotReached(device.user as Types.ObjectId, { + excludeDeviceId: device._id, + }) + } + const now = new Date() const updateData: any = { ...input } diff --git a/web/app/(app)/checkout/[planName]/page.tsx b/web/app/(app)/checkout/[planName]/page.tsx index a4c10ea..da2eae4 100644 --- a/web/app/(app)/checkout/[planName]/page.tsx +++ b/web/app/(app)/checkout/[planName]/page.tsx @@ -16,8 +16,15 @@ export default function CheckoutPage({ params }) { const initiateCheckout = useCallback( async (retries = 2) => { try { + // marketing site and pricing pages link here with ?billingInterval=monthly|yearly + // (legacy ?billing= fallback until the marketing site redeploys) + const searchParams = new URLSearchParams(window.location.search) + const billingInterval = + searchParams.get('billingInterval') ?? searchParams.get('billing') + const response = await httpBrowserClient.post('/billing/checkout', { planName, + billingInterval: billingInterval === 'yearly' ? 'yearly' : 'monthly', }) if (response.data?.redirectUrl) { diff --git a/web/app/(app)/dashboard/(components)/device-list.tsx b/web/app/(app)/dashboard/(components)/device-list.tsx index 5e03a09..b562c76 100644 --- a/web/app/(app)/dashboard/(components)/device-list.tsx +++ b/web/app/(app)/dashboard/(components)/device-list.tsx @@ -12,7 +12,9 @@ import { ExternalLink, Loader2, MoreVertical, + TriangleAlert, } from 'lucide-react' +import Link from 'next/link' import { useToast } from '@/hooks/use-toast' import httpBrowserClient from '@/lib/httpBrowserClient' import { ApiEndpoints } from '@/config/api' @@ -72,6 +74,23 @@ export default function DeviceList() { // select: (res) => res.data, }) + const { data: currentSubscription } = useQuery({ + queryKey: ['currentSubscription'], + queryFn: () => + httpBrowserClient + .get(ApiEndpoints.billing.currentSubscription()) + .then((res) => res.data), + }) + + // -1 (or missing) means unlimited; only enabled devices count toward the limit + const deviceLimit = currentSubscription?.usage?.deviceLimit ?? -1 + const activeDeviceCount = + devices?.data?.filter((device) => device.enabled).length ?? 0 + const isDeviceLimitReached = + deviceLimit !== -1 && !isPending && activeDeviceCount >= deviceLimit + const isApproachingDeviceLimit = + deviceLimit >= 2 && !isPending && activeDeviceCount === deviceLimit - 1 + const { mutate: deleteDevice, isPending: isDeletingDevice, @@ -124,6 +143,47 @@ export default function DeviceList() { + {(isDeviceLimitReached || isApproachingDeviceLimit) && ( +
+
+ +

+ {isDeviceLimitReached ? ( + <> + You've reached your plan's limit of{' '} + + {deviceLimit} active device{deviceLimit === 1 ? '' : 's'} + + . New devices can't be registered or re-enabled. + + ) : ( + <> + You're using{' '} + + {activeDeviceCount} of {deviceLimit} + {' '} + active devices included in your plan. + + )} +

+
+ +
+ )}
{isPending && ( <>