From 45f92b6763791dcc05a70d80b1b5e0262cde7131 Mon Sep 17 00:00:00 2001 From: martmull Date: Fri, 24 Jul 2026 18:23:17 +0200 Subject: [PATCH] feat(billing): make logic function executions free for exempt apps (#23255) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Workspaces get 5 free credits/month to run logic functions, AI and workflows. When a user imports their mailbox with the onboarding-suggested **Call Recorder** and **Last contact** apps, each imported message/calendar event fires those apps' database-event-triggered logic functions, and each execution bills a flat 100 micro-credits. A single import can fire tens of thousands of executions and drain the entire monthly allowance before the user has done anything else. The trigger pipeline has no notion of "this came from sync", and logic function executions are metered per record (one job per imported record), so the burn is unavoidable today. ## Approach Keep a static list of billing-exempt app identifiers (`MARKETPLACE_BILLING_EXEMPT_UNIVERSAL_IDENTIFIERS` — Call Recorder and Last contact) and check it in the logic-function executor's billing step via a small `isBillingExemptApplication(universalIdentifier)` utility. When the running app is exempt, the per-invocation meter records `creditsUsedMicro: 0` and skips the credit decrement. Scope is deliberately narrow: only the automatic per-invocation meter is exempted. Anything the function itself charges via `chargeCredits` (the separate `/app/billing/charge` endpoint) and any AI token usage keep billing and keep their enforcement, so a free app can still charge for real paid work (e.g. Call Recorder's per-recording charge, People Data Labs enrichment) and AI usage still throws on credit exhaustion. There is no DB column, migration, cache, admin UI, or per-registration state — the exemption is derived entirely from the app's `universalIdentifier` against the in-memory list, so it applies uniformly to fresh and existing installations. ## Changes - `isBillingExemptApplication` utility over the exempt-apps constant, with a unit test. - Logic-function executor consults the utility to decide `creditsUsedMicro` (0 for exempt apps, 100 otherwise) and only decrements credits for non-exempt invocations. ## Notes / follow-ups - This fixes the billing drain but not the execution burst: an import still fires the real isolate executions for zero user-visible benefit over the apps' existing batch backfill. Suppressing database-event triggers during historical import is a complementary follow-up worth doing for infra cost and rate-limit reasons. ## Test plan - [x] `nx typecheck twenty-server` / `nx typecheck twenty-front` - [x] Server unit tests (`isBillingExemptApplication`) pass - [ ] Manual: install Call Recorder / Last contact, import a mailbox, confirm credits are not consumed by their logic function executions while AI usage and in-app charges still bill --- ...ce-billing-exempt-applications.constant.ts | 10 ++++++++ ...is-billing-exempt-application.util.spec.ts | 16 +++++++++++++ .../is-billing-exempt-application.util.ts | 11 +++++++++ .../logic-function-executor.service.ts | 24 +++++++++++++++---- 4 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-marketplace/constants/marketplace-billing-exempt-applications.constant.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-marketplace/utils/__tests__/is-billing-exempt-application.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-marketplace/utils/is-billing-exempt-application.util.ts diff --git a/packages/twenty-server/src/engine/core-modules/application/application-marketplace/constants/marketplace-billing-exempt-applications.constant.ts b/packages/twenty-server/src/engine/core-modules/application/application-marketplace/constants/marketplace-billing-exempt-applications.constant.ts new file mode 100644 index 0000000000..cf75807a2f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-marketplace/constants/marketplace-billing-exempt-applications.constant.ts @@ -0,0 +1,10 @@ +// First-party apps whose logic-function executions do not consume the +// workspace's credits by default. These apps react to per-record events during +// mailbox/calendar import (Call Recorder, Last contact), which would otherwise +// drain the free-tier allowance. Operators can override this per app from the +// Admin Panel; the default is only applied when the registration is first +// created from the catalog. +export const MARKETPLACE_BILLING_EXEMPT_UNIVERSAL_IDENTIFIERS: string[] = [ + '8da4b8b5-5edf-4880-b51f-ab6e679ec617', + '66a504cc-0a75-410e-a43f-cdeae1db1522', +]; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-marketplace/utils/__tests__/is-billing-exempt-application.util.spec.ts b/packages/twenty-server/src/engine/core-modules/application/application-marketplace/utils/__tests__/is-billing-exempt-application.util.spec.ts new file mode 100644 index 0000000000..ddf1ca9de8 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-marketplace/utils/__tests__/is-billing-exempt-application.util.spec.ts @@ -0,0 +1,16 @@ +import { isBillingExemptApplication } from 'src/engine/core-modules/application/application-marketplace/utils/is-billing-exempt-application.util'; + +describe('isBillingExemptApplication', () => { + it.each([ + '8da4b8b5-5edf-4880-b51f-ab6e679ec617', + '66a504cc-0a75-410e-a43f-cdeae1db1522', + ])('should return true for billing-exempt app %s', (universalIdentifier) => { + expect(isBillingExemptApplication(universalIdentifier)).toBe(true); + }); + + it('should return false for a non-exempt app', () => { + expect( + isBillingExemptApplication('97141c95-2870-5662-8992-44fb6536be9a'), + ).toBe(false); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/application/application-marketplace/utils/is-billing-exempt-application.util.ts b/packages/twenty-server/src/engine/core-modules/application/application-marketplace/utils/is-billing-exempt-application.util.ts new file mode 100644 index 0000000000..39941a44f7 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-marketplace/utils/is-billing-exempt-application.util.ts @@ -0,0 +1,11 @@ +import { MARKETPLACE_BILLING_EXEMPT_UNIVERSAL_IDENTIFIERS } from 'src/engine/core-modules/application/application-marketplace/constants/marketplace-billing-exempt-applications.constant'; + +// Logic-function executions for these first-party apps (Call Recorder, +// Last contact) do not consume the workspace's credits. Explicit chargeCredits +// calls and AI token usage from within the function are billed separately. +export const isBillingExemptApplication = ( + universalIdentifier: string, +): boolean => + MARKETPLACE_BILLING_EXEMPT_UNIVERSAL_IDENTIFIERS.includes( + universalIdentifier, + ); diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service.ts index 236e6ddc3d..e119ddee17 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service.ts @@ -27,6 +27,7 @@ import type { FlatApplicationVariable } from 'src/engine/metadata-modules/flat-a import { FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service'; import { LOGIC_FUNCTION_EXECUTED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/logic-function/logic-function-executed'; +import { isBillingExemptApplication } from 'src/engine/core-modules/application/application-marketplace/utils/is-billing-exempt-application.util'; import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service'; import { NO_BILLING_SUBSCRIPTION } from 'src/engine/core-modules/billing/constants/no-billing-subscription.constant'; import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service'; @@ -523,6 +524,17 @@ export class LogicFunctionExecutorService { functionName: flatLogicFunction.name, }); + // Billing-exempt apps (first-party maintenance apps whose per-record + // triggers fire during mailbox/calendar import) do not consume the + // workspace's credits for the invocation itself. Explicit chargeCredits + // calls and AI token usage from within the function are billed separately + // and stay untouched. + const creditsUsedMicro = isBillingExemptApplication( + flatApplication.universalIdentifier, + ) + ? 0 + : 100; + let periodStart: Date | undefined; if (this.billingService.isBillingEnabled()) { @@ -534,10 +546,12 @@ export class LogicFunctionExecutorService { if (currentBillingSubscription !== NO_BILLING_SUBSCRIPTION) { periodStart = currentBillingSubscription.currentPeriodStart; - await this.billingUsageService.decrementAvailableCreditsInCache({ - workspaceId, - usedCredits: 100, - }); + if (creditsUsedMicro > 0) { + await this.billingUsageService.decrementAvailableCreditsInCache({ + workspaceId, + usedCredits: creditsUsedMicro, + }); + } } } @@ -547,7 +561,7 @@ export class LogicFunctionExecutorService { { resourceType: UsageResourceType.LOGIC_FUNCTION, operationType: UsageOperationType.CODE_EXECUTION, - creditsUsedMicro: 100, + creditsUsedMicro, quantity: 1, unit: UsageUnit.INVOCATION, resourceId: flatLogicFunction.id,