Merge pull request #232 from vernu/dev

fix billing and checkout issues
This commit is contained in:
vernu
2026-06-10 18:46:44 +03:00
committed by GitHub
10 changed files with 190 additions and 8 deletions

View File

@@ -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]

View File

@@ -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 {

View File

@@ -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

View File

@@ -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',
}

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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 }

View File

@@ -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) {

View File

@@ -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 && (
<>