diff --git a/packages/twenty-front/src/modules/ai/components/AIChatCreditsExhaustedMessage.tsx b/packages/twenty-front/src/modules/ai/components/AIChatCreditsExhaustedMessage.tsx index c97e3b9846..b9ec08efb7 100644 --- a/packages/twenty-front/src/modules/ai/components/AIChatCreditsExhaustedMessage.tsx +++ b/packages/twenty-front/src/modules/ai/components/AIChatCreditsExhaustedMessage.tsx @@ -52,7 +52,6 @@ export const AIChatCreditsExhaustedMessage = () => { const result = await endTrialPeriod(); setIsProcessing(false); - // If no payment method, redirect to billing portal to add one if (!result.success) { openBillingPortal(); } diff --git a/packages/twenty-front/src/modules/ai/components/ToolStepRenderer.tsx b/packages/twenty-front/src/modules/ai/components/ToolStepRenderer.tsx index c1d2b50300..084d9f608f 100644 --- a/packages/twenty-front/src/modules/ai/components/ToolStepRenderer.tsx +++ b/packages/twenty-front/src/modules/ai/components/ToolStepRenderer.tsx @@ -142,7 +142,6 @@ export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => { const hasError = isDefined(errorText); const isExpandable = isDefined(output) || hasError; - // Special handling for code_interpreter tool if (toolName === 'code_interpreter') { const codeInput = toolInput as { code?: string } | undefined; const codeOutput = output as { diff --git a/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-invoice.service.ts b/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-invoice.service.ts index 48b753d879..f6c1272c17 100644 --- a/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-invoice.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-invoice.service.ts @@ -2,11 +2,12 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { isDefined } from 'twenty-shared/utils'; -import { Repository } from 'typeorm'; +import { type Repository } from 'typeorm'; import type Stripe from 'stripe'; import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity'; +import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; const SUBSCRIPTION_CYCLE_BILLING_REASON = 'subscription_cycle'; @@ -16,12 +17,18 @@ export class BillingWebhookInvoiceService { constructor( @InjectRepository(BillingSubscriptionItemEntity) private readonly billingSubscriptionItemRepository: Repository, + private readonly billingSubscriptionService: BillingSubscriptionService, ) {} async processStripeEvent(data: Stripe.InvoiceFinalizedEvent.Data) { - const { billing_reason: billingReason, subscription } = data.object; + const { + billing_reason: billingReason, + subscription, + customer, + } = data.object; const stripeSubscriptionId = subscription as string | undefined; + const stripeCustomerId = customer as string | undefined; if ( isDefined(stripeSubscriptionId) && @@ -31,6 +38,12 @@ export class BillingWebhookInvoiceService { { stripeSubscriptionId }, { hasReachedCurrentPeriodCap: false }, ); + + if (isDefined(stripeCustomerId)) { + await this.billingSubscriptionService.createBillingAlertForCustomer( + stripeCustomerId, + ); + } } } } diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/__test__/billing-subscription.service.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/services/__test__/billing-subscription.service.spec.ts index e41328ca34..29668b6dd5 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/__test__/billing-subscription.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/__test__/billing-subscription.service.spec.ts @@ -20,6 +20,7 @@ import { BillingSubscriptionService } from 'src/engine/core-modules/billing/serv import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service'; import { StripeSubscriptionItemService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service'; import { StripeSubscriptionScheduleService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service'; +import { StripeBillingAlertService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-alert.service'; import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service'; import { SubscriptionUpdateType } from 'src/engine/core-modules/billing/types/billing-subscription-update.type'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; @@ -164,6 +165,12 @@ describe('BillingSubscriptionService', () => { provide: getRepositoryToken(BillingCustomerEntity), useValue: repoMock(), }, + { + provide: StripeBillingAlertService, + useValue: { + createUsageThresholdAlertForCustomerMeter: jest.fn(), + }, + }, BillingPriceService, ], }).compile(); diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts index 590b35428e..135121e4b0 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts @@ -39,6 +39,7 @@ import { BillingPlanService } from 'src/engine/core-modules/billing/services/bil import { BillingPriceService } from 'src/engine/core-modules/billing/services/billing-price.service'; import { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service'; import { BillingSubscriptionPhaseService } from 'src/engine/core-modules/billing/services/billing-subscription-phase.service'; +import { StripeBillingAlertService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-alert.service'; import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service'; import { StripeSubscriptionScheduleService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service'; import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service'; @@ -81,6 +82,7 @@ export class BillingSubscriptionService { private readonly billingSubscriptionPhaseService: BillingSubscriptionPhaseService, @InjectRepository(BillingCustomerEntity) private readonly billingCustomerRepository: Repository, + private readonly stripeBillingAlertService: StripeBillingAlertService, ) {} async getBillingSubscriptions(workspaceId: string) { @@ -221,10 +223,33 @@ export class BillingSubscriptionService { { workspaceId }, ); - await this.updateSubscription(billingSubscription.id, { + const subscriptionUpdate = { type: SubscriptionUpdateType.METERED_PRICE, newMeteredPriceId: meteredPriceId, - }); + } as const; + + const isScheduledForPeriodEnd = + await this.shouldUpdateAtSubscriptionPeriodEnd( + billingSubscription, + subscriptionUpdate, + ); + + await this.updateSubscription(billingSubscription.id, subscriptionUpdate); + + if (!isScheduledForPeriodEnd) { + await this.billingSubscriptionItemRepository.update( + { stripeSubscriptionId: billingSubscription.stripeSubscriptionId }, + { hasReachedCurrentPeriodCap: false }, + ); + + const newTierCap = + await this.getWorkflowTierCapFromPriceId(meteredPriceId); + + await this.stripeBillingAlertService.createUsageThresholdAlertForCustomerMeter( + billingSubscription.stripeCustomerId, + newTierCap, + ); + } } async cancelSwitchMeteredPrice(workspace: WorkspaceEntity): Promise { @@ -274,6 +299,10 @@ export class BillingSubscriptionService { { hasReachedCurrentPeriodCap: false }, ); + await this.createBillingAlertForCustomer( + billingSubscription.stripeCustomerId, + ); + return { status: getSubscriptionStatus(updatedSubscription.status), hasPaymentMethod: true, @@ -513,6 +542,82 @@ export class BillingSubscriptionService { ); } + async getWorkflowTierCapForSubscription( + subscriptionId: string, + ): Promise { + const subscription = await this.billingSubscriptionRepository.findOneOrFail( + { + where: { id: subscriptionId }, + relations: [ + 'billingSubscriptionItems', + 'billingSubscriptionItems.billingProduct', + 'billingSubscriptionItems.billingProduct.billingPrices', + ], + }, + ); + + const workflowItem = subscription.billingSubscriptionItems.find( + (item) => + item.billingProduct.metadata.productKey === + BillingProductKey.WORKFLOW_NODE_EXECUTION, + ); + + if (!isDefined(workflowItem)) { + throw new BillingException( + 'Workflow subscription item not found', + BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND, + ); + } + + const matchingPrice = workflowItem.billingProduct.billingPrices.find( + (price) => price.stripePriceId === workflowItem.stripePriceId, + ); + + if (!isDefined(matchingPrice)) { + throw new BillingException( + `Cannot find price for product ${workflowItem.stripeProductId}`, + BillingExceptionCode.BILLING_PRICE_NOT_FOUND, + ); + } + + billingValidator.assertIsMeteredTiersSchemaOrThrow(matchingPrice.tiers); + + return matchingPrice.tiers[0].up_to; + } + + async getWorkflowTierCapFromPriceId(meteredPriceId: string): Promise { + const price = await this.billingPriceRepository.findOneOrFail({ + where: { stripePriceId: meteredPriceId }, + }); + + billingValidator.assertIsMeteredTiersSchemaOrThrow(price.tiers); + + return price.tiers[0].up_to; + } + + async createBillingAlertForCustomer(stripeCustomerId: string): Promise { + const subscription = await this.getCurrentBillingSubscription({ + stripeCustomerId, + }); + + if (!isDefined(subscription)) { + this.logger.warn( + `Cannot create billing alert: subscription not found for stripeCustomerId ${stripeCustomerId}`, + ); + + return; + } + + const tierCap = await this.getWorkflowTierCapForSubscription( + subscription.id, + ); + + await this.stripeBillingAlertService.createUsageThresholdAlertForCustomerMeter( + stripeCustomerId, + tierCap, + ); + } + private async runSubscriptionUpdate({ stripeSubscriptionId, licensedStripeItemId, diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-billing-alert.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-billing-alert.service.ts index 38358e2939..91922e2eca 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-billing-alert.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-billing-alert.service.ts @@ -7,6 +7,7 @@ import type Stripe from 'stripe'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service'; import { StripeBillingMeterService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter.service'; +import { StripeBillingMeterEventService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter-event.service'; import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names'; @Injectable() @@ -18,6 +19,7 @@ export class StripeBillingAlertService { private readonly twentyConfigService: TwentyConfigService, private readonly stripeSDKService: StripeSDKService, private readonly stripeBillingMeterService: StripeBillingMeterService, + private readonly stripeBillingMeterEventService: StripeBillingMeterEventService, ) { if (!this.twentyConfigService.get('IS_BILLING_ENABLED')) { return; @@ -29,7 +31,7 @@ export class StripeBillingAlertService { async createUsageThresholdAlertForCustomerMeter( customerId: string, - gte: number, + tierCap: number, ): Promise { const meter = (await this.stripeBillingMeterService.getAllMeters()).find( (meter) => { @@ -39,11 +41,21 @@ export class StripeBillingAlertService { assertIsDefinedOrThrow(meter); + await this.archiveAlertsForCustomer(customerId, meter.id); + + const cumulativeUsage = + await this.stripeBillingMeterEventService.getTotalCumulativeUsage( + meter.id, + customerId, + ); + + const dynamicThreshold = cumulativeUsage + tierCap; + await this.stripe.billing.alerts.create({ alert_type: 'usage_threshold', - title: `Trial usage cap for customer ${customerId}`, + title: `Usage cap for customer ${customerId}`, usage_threshold: { - gte, + gte: dynamicThreshold, meter: meter.id, recurrence: 'one_time', filters: [ @@ -54,5 +66,32 @@ export class StripeBillingAlertService { ], }, }); + + this.logger.log( + `Created billing alert for customer ${customerId}: threshold=${dynamicThreshold} (cumulative=${cumulativeUsage} + tierCap=${tierCap})`, + ); + } + + private async archiveAlertsForCustomer( + customerId: string, + meterId: string, + ): Promise { + const alerts = await this.stripe.billing.alerts.list({ + meter: meterId, + }); + + const customerAlerts = alerts.data.filter( + (alert) => + alert.status === 'active' && + alert.usage_threshold?.filters?.some( + (filter) => + filter.type === 'customer' && filter.customer === customerId, + ), + ); + + for (const alert of customerAlerts) { + await this.stripe.billing.alerts.archive(alert.id); + this.logger.log(`Archived alert ${alert.id} for customer ${customerId}`); + } } } diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-billing-meter-event.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-billing-meter-event.service.ts index a6be24ebc7..53b6a8c438 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-billing-meter-event.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-billing-meter-event.service.ts @@ -79,4 +79,19 @@ export class StripeBillingMeterEventService { return acc + eventSummary.aggregated_value; }, 0); } + + async getTotalCumulativeUsage( + stripeMeterId: string, + stripeCustomerId: string, + ): Promise { + const startTime = new Date(0); + const endTime = new Date(); + + return this.sumMeterEvents( + stripeMeterId, + stripeCustomerId, + startTime, + endTime, + ); + } }