mirror of
https://github.com/twentyhq/twenty.git
synced 2026-06-12 09:57:03 -04:00
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:
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const BILLING_MARGIN_MULTIPLIER = 1.2;
|
||||
@@ -0,0 +1 @@
|
||||
export const COMPANY_MATCH_COST_DOLLARS = 0.1;
|
||||
@@ -0,0 +1 @@
|
||||
export const MICRO_CREDITS_PER_DOLLAR = 1_000_000;
|
||||
@@ -0,0 +1 @@
|
||||
export const PERSON_MATCH_COST_DOLLARS = 0.28;
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user