diff --git a/packages/twenty-apps/internal/people-data-labs/README.md b/packages/twenty-apps/internal/people-data-labs/README.md index 6a8a764172d..7c5917ef00b 100644 --- a/packages/twenty-apps/internal/people-data-labs/README.md +++ b/packages/twenty-apps/internal/people-data-labs/README.md @@ -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":""}]}'`. +### 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). --- diff --git a/packages/twenty-apps/internal/people-data-labs/src/constants/billing-margin-multiplier.ts b/packages/twenty-apps/internal/people-data-labs/src/constants/billing-margin-multiplier.ts new file mode 100644 index 00000000000..2a54c45b84d --- /dev/null +++ b/packages/twenty-apps/internal/people-data-labs/src/constants/billing-margin-multiplier.ts @@ -0,0 +1 @@ +export const BILLING_MARGIN_MULTIPLIER = 1.2; diff --git a/packages/twenty-apps/internal/people-data-labs/src/constants/company-match-cost-dollars.ts b/packages/twenty-apps/internal/people-data-labs/src/constants/company-match-cost-dollars.ts new file mode 100644 index 00000000000..1ef85bd35d3 --- /dev/null +++ b/packages/twenty-apps/internal/people-data-labs/src/constants/company-match-cost-dollars.ts @@ -0,0 +1 @@ +export const COMPANY_MATCH_COST_DOLLARS = 0.1; diff --git a/packages/twenty-apps/internal/people-data-labs/src/constants/micro-credits-per-dollar.ts b/packages/twenty-apps/internal/people-data-labs/src/constants/micro-credits-per-dollar.ts new file mode 100644 index 00000000000..abef9eda707 --- /dev/null +++ b/packages/twenty-apps/internal/people-data-labs/src/constants/micro-credits-per-dollar.ts @@ -0,0 +1 @@ +export const MICRO_CREDITS_PER_DOLLAR = 1_000_000; diff --git a/packages/twenty-apps/internal/people-data-labs/src/constants/person-match-cost-dollars.ts b/packages/twenty-apps/internal/people-data-labs/src/constants/person-match-cost-dollars.ts new file mode 100644 index 00000000000..8c612e15f46 --- /dev/null +++ b/packages/twenty-apps/internal/people-data-labs/src/constants/person-match-cost-dollars.ts @@ -0,0 +1 @@ +export const PERSON_MATCH_COST_DOLLARS = 0.28; diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/company-enrichment-adapter.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/company-enrichment-adapter.ts index c4ce29d9dc3..38d4a0fb141 100644 --- a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/company-enrichment-adapter.ts +++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/company-enrichment-adapter.ts @@ -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, diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/person-enrichment-adapter.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/person-enrichment-adapter.ts index 3d345e0509b..e304a57d825 100644 --- a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/person-enrichment-adapter.ts +++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/person-enrichment-adapter.ts @@ -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, diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/charge-matched-enrichments.spec.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/charge-matched-enrichments.spec.ts new file mode 100644 index 00000000000..e10d798b3f3 --- /dev/null +++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/charge-matched-enrichments.spec.ts @@ -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(); + }); +}); diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/run-batch-enrichment.spec.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/run-batch-enrichment.spec.ts index 24dbdf600ff..1404daf5433 100644 --- a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/run-batch-enrichment.spec.ts +++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/run-batch-enrichment.spec.ts @@ -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 = { objectNameSingular: 'Test', noIdentifierMessage: 'no identifier', + costPerMatchDollars: FAKE_COST_PER_MATCH_DOLLARS, readRecords, getNodeId: (node) => node.id, extractParams: ({ node }) => @@ -116,6 +125,10 @@ const isPresent = (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([]); diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/charge-matched-enrichments.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/charge-matched-enrichments.ts new file mode 100644 index 00000000000..8d603ecaeb0 --- /dev/null +++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/charge-matched-enrichments.ts @@ -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 => { + 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, + }); +}; diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/enrich-chunk.ts b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/enrich-chunk.ts index 36b7df3377e..a2561c3e39f 100644 --- a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/enrich-chunk.ts +++ b/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/enrich-chunk.ts @@ -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 ({ 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; diff --git a/packages/twenty-apps/internal/people-data-labs/src/types/batch-enrichment-adapter.ts b/packages/twenty-apps/internal/people-data-labs/src/types/batch-enrichment-adapter.ts index 7d96bcb6dc2..c92b56d2733 100644 --- a/packages/twenty-apps/internal/people-data-labs/src/types/batch-enrichment-adapter.ts +++ b/packages/twenty-apps/internal/people-data-labs/src/types/batch-enrichment-adapter.ts @@ -7,6 +7,7 @@ import { type PdlEnrichResult } from 'src/types/pdl-enrich-result'; export type BatchEnrichmentAdapter = { objectNameSingular: string; noIdentifierMessage: string; + costPerMatchDollars: number; readRecords: (args: { client: CoreApiClient; recordIds: string[];