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,