feat(billing): make logic function executions free for exempt apps (#23255)

## 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
This commit is contained in:
martmull
2026-07-24 18:23:17 +02:00
committed by GitHub
parent d6c186a71e
commit 45f92b6763
4 changed files with 56 additions and 5 deletions
@@ -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',
];
@@ -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);
});
});
@@ -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,
);
@@ -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,