Bill People Data Labs enrichments in Twenty credits (#21481)

Adds per-enrichment billing to the People Data Labs app. Each
**matched** record charges the workspace in Twenty credits via
`chargeCredits` (`twenty-sdk/billing`), following the same pattern as
the exa app.

- Person match: **336,000 micro-credits** ($0.336 — PDL list price $0.28
+ 20% margin)
- Company match: **120,000 micro-credits** ($0.12 — PDL list price $0.10
+ 20% margin)

> **Note:** the 20% margin is a first draft, not final — it's a single
constant (`src/constants/billing-margin-multiplier.ts`) and easy to
adjust once we settle on pricing.

PDL only consumes a credit on a successful match, so `not_found`,
errors, and skipped records are free. The charge is emitted once per PDL
batch call (≤100 records) with `operationType: CODE_EXECUTION`,
`quantity` = match count, and `resourceContext` `pdl/person` /
`pdl/company`, at the moment PDL responds — a match whose record write
later fails is still billed since the PDL cost was already incurred.
Billing failures are non-fatal and never break an enrichment.

Prices and margin live as constants in `src/constants/` for easy
retuning. No SDK bump needed (`twenty-sdk@2.10.1` already ships
`./billing`).
This commit is contained in:
Raphaël Bosi
2026-06-12 15:13:17 +02:00
committed by GitHub
parent fa9aeea408
commit e3cfbbffb5
12 changed files with 216 additions and 3 deletions

View File

@@ -34,6 +34,22 @@ trigger-agnostic core in `src/logic-functions/handlers/`:
Run locally: `yarn twenty dev:function:exec -n enrich-person -p '{"records":[{"id":"<id>"}]}'`.
### Billing
Each **successful match** is billed to the workspace in Twenty credits via
`chargeCredits` (`twenty-sdk/billing`), mirroring PDL's own model — PDL only consumes a
credit on a `200` match, so `not_found`, errors, and skipped records are free:
- Person match: **336,000 micro-credits** ($0.336 — PDL list price $0.28 + 20% margin)
- Company match: **120,000 micro-credits** ($0.12 — PDL list price $0.10 + 20% margin)
The charge is emitted once per PDL batch call (`src/logic-functions/utils/enrich-chunk.ts`)
with `quantity` = number of matches and `resourceContext` `pdl/person` / `pdl/company`,
at the moment PDL returns — a record whose subsequent write fails is still billed, since the
PDL cost was already incurred. Prices live in `src/constants/*-match-cost-dollars.ts` and
the margin in `src/constants/billing-margin-multiplier.ts`. Billing is non-fatal: a failed
charge never fails the enrichment.
### Seeded workflows (post-install)
> **Not currently wired up.** `post-install.function.ts` is a no-op
@@ -55,8 +71,7 @@ The intended seeding (`postInstallCore`) resolves each function's runtime id fro
`universalIdentifier` via the metadata API, publishes the version
(`activateWorkflowVersion`), and is **idempotent** (skips a workflow whose name already exists).
**Deferred to a later PR:** enrichment metering/billing, and auto-enrichment
triggers (on-create event + cron backfill).
**Deferred to a later PR:** auto-enrichment triggers (on-create event + cron backfill).
---

View File

@@ -0,0 +1 @@
export const BILLING_MARGIN_MULTIPLIER = 1.2;

View File

@@ -0,0 +1 @@
export const COMPANY_MATCH_COST_DOLLARS = 0.1;

View File

@@ -0,0 +1 @@
export const MICRO_CREDITS_PER_DOLLAR = 1_000_000;

View File

@@ -0,0 +1 @@
export const PERSON_MATCH_COST_DOLLARS = 0.28;

View File

@@ -1,3 +1,4 @@
import { COMPANY_MATCH_COST_DOLLARS } from 'src/constants/company-match-cost-dollars';
import { buildCompanyMatchedData } from 'src/logic-functions/utils/build-company-matched-data';
import { enrichCompanies } from 'src/logic-functions/utils/enrich-companies';
import { extractCompanyMatchParams } from 'src/logic-functions/utils/extract-company-match-params';
@@ -17,6 +18,7 @@ export const companyEnrichmentAdapter: BatchEnrichmentAdapter<
objectNameSingular: 'Company',
noIdentifierMessage:
'No usable identifier (domain, LinkedIn, or name) to match against PDL.',
costPerMatchDollars: COMPANY_MATCH_COST_DOLLARS,
readRecords: readCompanies,
getNodeId: (node) => node.id,
extractParams: extractCompanyMatchParams,

View File

@@ -1,3 +1,4 @@
import { PERSON_MATCH_COST_DOLLARS } from 'src/constants/person-match-cost-dollars';
import { buildPersonMatchedData } from 'src/logic-functions/utils/build-person-matched-data';
import { enrichPeople } from 'src/logic-functions/utils/enrich-people';
import { extractPersonMatchParams } from 'src/logic-functions/utils/extract-person-match-params';
@@ -17,6 +18,7 @@ export const personEnrichmentAdapter: BatchEnrichmentAdapter<
objectNameSingular: 'Person',
noIdentifierMessage:
'No usable identifier (email, LinkedIn, PDL id, or name paired with a company) to match against PDL.',
costPerMatchDollars: PERSON_MATCH_COST_DOLLARS,
readRecords: readPeople,
getNodeId: (node) => node.id,
extractParams: extractPersonMatchParams,

View File

@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { chargeCredits } from 'twenty-sdk/billing';
import { COMPANY_MATCH_COST_DOLLARS } from 'src/constants/company-match-cost-dollars';
import { PERSON_MATCH_COST_DOLLARS } from 'src/constants/person-match-cost-dollars';
import { chargeMatchedEnrichments } from 'src/logic-functions/utils/charge-matched-enrichments';
vi.mock('twenty-sdk/billing', () => ({
chargeCredits: vi.fn(async () => undefined),
}));
describe('chargeMatchedEnrichments', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('charges person matches at PDL list price plus margin', async () => {
await chargeMatchedEnrichments({
matchedCount: 3,
costPerMatchDollars: PERSON_MATCH_COST_DOLLARS,
resourceContext: 'pdl/person',
});
expect(chargeCredits).toHaveBeenCalledExactlyOnceWith({
creditsUsedMicro: 3 * 336_000,
operationType: 'CODE_EXECUTION',
quantity: 3,
resourceContext: 'pdl/person',
});
});
it('charges company matches at PDL list price plus margin', async () => {
await chargeMatchedEnrichments({
matchedCount: 2,
costPerMatchDollars: COMPANY_MATCH_COST_DOLLARS,
resourceContext: 'pdl/company',
});
expect(chargeCredits).toHaveBeenCalledExactlyOnceWith({
creditsUsedMicro: 2 * 120_000,
operationType: 'CODE_EXECUTION',
quantity: 2,
resourceContext: 'pdl/company',
});
});
it('does not charge when nothing matched', async () => {
await chargeMatchedEnrichments({
matchedCount: 0,
costPerMatchDollars: PERSON_MATCH_COST_DOLLARS,
resourceContext: 'pdl/person',
});
expect(chargeCredits).not.toHaveBeenCalled();
});
});

View File

@@ -1,10 +1,18 @@
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { chargeCredits } from 'twenty-sdk/billing';
import { runBatchEnrichment } from 'src/logic-functions/utils/run-batch-enrichment';
import { type BatchEnrichmentAdapter } from 'src/types/batch-enrichment-adapter';
import { type PdlEnrichResult } from 'src/types/pdl-enrich-result';
vi.mock('twenty-sdk/billing', () => ({
chargeCredits: vi.fn(async () => undefined),
}));
const FAKE_COST_PER_MATCH_DOLLARS = 0.1;
const FAKE_CREDITS_PER_MATCH_MICRO = 120_000;
type FakeNode = {
id: string;
hasIdentifier: boolean;
@@ -90,6 +98,7 @@ const buildHarness = (configs: RecordConfig[]) => {
const adapter: BatchEnrichmentAdapter<FakeNode, FakeData, FakeParams> = {
objectNameSingular: 'Test',
noIdentifierMessage: 'no identifier',
costPerMatchDollars: FAKE_COST_PER_MATCH_DOLLARS,
readRecords,
getNodeId: (node) => node.id,
extractParams: ({ node }) =>
@@ -116,6 +125,10 @@ const isPresent = <TValue>(value: TValue | undefined): value is TValue =>
const records = (...ids: string[]) => ids.map((id) => ({ id }));
describe('runBatchEnrichment', () => {
beforeEach(() => {
vi.mocked(chargeCredits).mockClear();
});
it('reads every id in one call and enriches the set in one batch', async () => {
const harness = buildHarness([{ id: 'a' }, { id: 'b' }]);
@@ -372,6 +385,88 @@ describe('runBatchEnrichment', () => {
expect(result.matched).toBe(150);
});
it('bills only matched outcomes after the PDL call', async () => {
const harness = buildHarness([
{ id: 'a' },
{ id: 'b' },
{ id: 'c', outcome: { outcome: 'not_found', httpStatus: 404 } },
{ id: 'd', outcome: { outcome: 'error', httpStatus: 500, message: 'x' } },
{ id: 'e', hasIdentifier: false },
]);
await runBatchEnrichment({
client: CLIENT,
input: { records: records('a', 'b', 'c', 'd', 'e') },
adapter: harness.adapter,
});
expect(chargeCredits).toHaveBeenCalledExactlyOnceWith({
creditsUsedMicro: 2 * FAKE_CREDITS_PER_MATCH_MICRO,
operationType: 'CODE_EXECUTION',
quantity: 2,
resourceContext: 'pdl/test',
});
});
it('does not bill when no record matches', async () => {
const harness = buildHarness([
{ id: 'a', outcome: { outcome: 'not_found', httpStatus: 404 } },
{ id: 'b', hasIdentifier: false },
]);
await runBatchEnrichment({
client: CLIENT,
input: { records: records('a', 'b') },
adapter: harness.adapter,
});
expect(chargeCredits).not.toHaveBeenCalled();
});
it('still bills a match whose record write fails', async () => {
const harness = buildHarness([{ id: 'a', updateFails: true }]);
await runBatchEnrichment({
client: CLIENT,
input: { records: records('a') },
adapter: harness.adapter,
});
expect(chargeCredits).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
creditsUsedMicro: FAKE_CREDITS_PER_MATCH_MICRO,
quantity: 1,
}),
);
});
it('bills each chunk separately', async () => {
const ids = Array.from({ length: 150 }, (_unused, index) => `r${index}`);
const harness = buildHarness(ids.map((id) => ({ id })));
await runBatchEnrichment({
client: CLIENT,
input: { records: ids.map((id) => ({ id })) },
adapter: harness.adapter,
});
expect(chargeCredits).toHaveBeenCalledTimes(2);
expect(chargeCredits).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
creditsUsedMicro: 100 * FAKE_CREDITS_PER_MATCH_MICRO,
quantity: 100,
}),
);
expect(chargeCredits).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
creditsUsedMicro: 50 * FAKE_CREDITS_PER_MATCH_MICRO,
quantity: 50,
}),
);
});
it('returns an empty summary when there are no records', async () => {
const harness = buildHarness([]);

View File

@@ -0,0 +1,29 @@
import { chargeCredits } from 'twenty-sdk/billing';
import { BILLING_MARGIN_MULTIPLIER } from 'src/constants/billing-margin-multiplier';
import { MICRO_CREDITS_PER_DOLLAR } from 'src/constants/micro-credits-per-dollar';
export const chargeMatchedEnrichments = async ({
matchedCount,
costPerMatchDollars,
resourceContext,
}: {
matchedCount: number;
costPerMatchDollars: number;
resourceContext: string;
}): Promise<void> => {
if (matchedCount === 0) {
return;
}
const creditsPerMatchMicro = Math.round(
costPerMatchDollars * BILLING_MARGIN_MULTIPLIER * MICRO_CREDITS_PER_DOLLAR,
);
await chargeCredits({
creditsUsedMicro: matchedCount * creditsPerMatchMicro,
operationType: 'CODE_EXECUTION',
quantity: matchedCount,
resourceContext,
});
};

View File

@@ -4,6 +4,7 @@ import { buildErrorResult } from 'src/logic-functions/utils/build-error-result';
import { buildMatchedResult } from 'src/logic-functions/utils/build-matched-result';
import { buildNotFoundResult } from 'src/logic-functions/utils/build-not-found-result';
import { buildSkippedResult } from 'src/logic-functions/utils/build-skipped-result';
import { chargeMatchedEnrichments } from 'src/logic-functions/utils/charge-matched-enrichments';
import { INTERNAL_BOOKKEEPING_FIELDS } from 'src/logic-functions/utils/internal-field-names';
import { nowIso } from 'src/logic-functions/utils/now-iso';
import { type BatchEnrichmentAdapter } from 'src/types/batch-enrichment-adapter';
@@ -135,6 +136,14 @@ export const enrichChunk = async <TNode, TData, TParams>({
return;
}
await chargeMatchedEnrichments({
matchedCount: pdlEnrichmentOutcomes.filter(
(enrichmentOutcome) => enrichmentOutcome?.outcome === 'matched',
).length,
costPerMatchDollars: adapter.costPerMatchDollars,
resourceContext: `pdl/${adapter.objectNameSingular.toLowerCase()}`,
});
const notFoundRecordIds: string[] = [];
const matchedRecordsToPersist: {
recordId: string;

View File

@@ -7,6 +7,7 @@ import { type PdlEnrichResult } from 'src/types/pdl-enrich-result';
export type BatchEnrichmentAdapter<TNode, TData, TParams> = {
objectNameSingular: string;
noIdentifierMessage: string;
costPerMatchDollars: number;
readRecords: (args: {
client: CoreApiClient;
recordIds: string[];