mirror of
https://github.com/vernu/textbee.git
synced 2026-06-11 01:09:42 -04:00
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<CheckoutResponseDTO> {
|
||||
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
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<void> {
|
||||
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 }
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(isDeviceLimitReached || isApproachingDeviceLimit) && (
|
||||
<div
|
||||
className={`mb-4 flex flex-col gap-2 rounded-lg border px-3 py-2 sm:flex-row sm:items-center sm:justify-between ${
|
||||
isDeviceLimitReached
|
||||
? 'border-red-200 bg-red-50 dark:border-red-900/50 dark:bg-red-950/20'
|
||||
: 'border-amber-300 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/40'
|
||||
}`}
|
||||
>
|
||||
<div className='flex items-start gap-2'>
|
||||
<TriangleAlert
|
||||
className={`mt-0.5 h-4 w-4 shrink-0 ${
|
||||
isDeviceLimitReached
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-amber-600 dark:text-amber-400'
|
||||
}`}
|
||||
/>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{isDeviceLimitReached ? (
|
||||
<>
|
||||
You've reached your plan's limit of{' '}
|
||||
<span className='font-medium text-foreground'>
|
||||
{deviceLimit} active device{deviceLimit === 1 ? '' : 's'}
|
||||
</span>
|
||||
. New devices can't be registered or re-enabled.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
You're using{' '}
|
||||
<span className='font-medium text-foreground'>
|
||||
{activeDeviceCount} of {deviceLimit}
|
||||
</span>{' '}
|
||||
active devices included in your plan.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant='outline' size='sm' asChild className='shrink-0'>
|
||||
<Link href='/pricing'>Upgrade plan</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className='space-y-2'>
|
||||
{isPending && (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user