diff --git a/README.md b/README.md index b188310..57a80cf 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ prerelease in `package.json`. The version output includes both runtime components: ```text -polymorfa/0.1.0-dev.0 sdk/0.1.0-dev.20261001124422 +polymorfa/0.1.0-dev.0 sdk/0.1.0-dev.20261001202146 ``` The SDK component reports the version built into the installed SDK. Dev @@ -1673,6 +1673,33 @@ List returns `{data, page}`. Pass `page.nextCursor` back as `--cursor` while with `--status queued|sending|sent|delivered|read|failed|skipped`. A recipient suppressed by an opt-out appears as `skipped` with `lastError` `opted_out`. +#### Campaign conversions (beta) + +Campaign conversion reporting is a beta. The team must be enrolled, otherwise +both commands exit with the API's `403`. They use a team API key and the +campaign's project ID: + +```shell +polymorfa campaign conversions record campaign_123 \ + --project-id project_123 \ + --recipient recipient_123 \ + --event-id order-1001 \ + --event-type purchase \ + --occurred-at 2026-10-01T10:00:00Z \ + --amount-minor 1999 --currency USD + +polymorfa campaign conversions report campaign_123 --project-id project_123 +``` + +Name the recipient by its ID from `campaign recipients list`. A conversion is +attributed when `--occurred-at` is within 7 days after that recipient was sent. +Otherwise the outcome is `outside_window` or `not_sent`. A recipient on the +opt-out list is recorded as `opted_out`, with no link and no value. Running +`record` again with the same `--event-id` and details prints `Already +recorded`; changed details are refused. `--amount-minor` is in the currency's +minor unit and needs `--currency`. Human output never prints the event ID. +The report lists reported value per currency and never converts between them. + The source exposes no campaign update, delete, validate, archive, duplicate, event, history, or replay route on the Messaging surface. The CLI does not substitute similarly named Platform campaign methods for these gaps. To follow diff --git a/package-lock.json b/package-lock.json index 2c47e2e..3bf359e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "0.1.0-dev.20261001124422", + "@polymorfa/sdk": "0.1.0-dev.20261001202146", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, @@ -997,9 +997,9 @@ } }, "node_modules/@polymorfa/sdk": { - "version": "0.1.0-dev.20261001124422", - "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20261001124422.tgz", - "integrity": "sha512-nZbd0jpq7OhQseH3gLXwI+UoMQJCaxQYERJmyxEmvGJgLm+xHqaFFXA8uOf87Epq0uyn221lmDiK6R5/fsa0eA==", + "version": "0.1.0-dev.20261001202146", + "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20261001202146.tgz", + "integrity": "sha512-ACifZOk4L9+fWU+MwRV6iu2SGCgmXN7vL6DUREab/GkJkmKD9yGQLEmOqaueq0ZyS8Kr0A1ojYGz6G2tBH0RiA==", "engines": { "node": ">=20" } diff --git a/package.json b/package.json index af0b4ee..2992c8e 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "0.1.0-dev.20261001124422", + "@polymorfa/sdk": "0.1.0-dev.20261001202146", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, diff --git a/src/commands/campaign/conversions/record.ts b/src/commands/campaign/conversions/record.ts new file mode 100644 index 0000000..de6e6e2 --- /dev/null +++ b/src/commands/campaign/conversions/record.ts @@ -0,0 +1,79 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import { + recordCampaignConversion, + renderCampaignConversion, +} from '../../../services/campaign-conversions.js' + +export default class CampaignConversionsRecord extends BaseCommand { + static override args = { + campaign: Args.string({description: 'Campaign ID.', required: true}), + } + + static override description = `Report a conversion for one campaign recipient (beta; requires team enrollment). + +It is attributed to the campaign when --occurred-at is within 7 days after the recipient was sent. Sending the same --event-id with the same details again returns the original conversion; changed details are refused. Amounts are integer minor units of the currency and are never converted.` + + static override examples = [ + '<%= config.bin %> <%= command.id %> --project-id --recipient --event-id order-1001 --event-type purchase --occurred-at 2026-10-01T10:00:00Z --amount-minor 1999 --currency USD', + ] + + static override flags = { + 'amount-minor': Flags.integer({ + dependsOn: ['currency'], + description: + 'Reported value in the currency minor unit, for example 1999 for USD 19.99.', + min: 0, + }), + currency: Flags.string({ + dependsOn: ['amount-minor'], + description: 'ISO 4217 currency code of --amount-minor, for example USD.', + }), + 'event-id': Flags.string({ + description: 'Your unique ID for this conversion, such as an order ID.', + required: true, + }), + 'event-type': Flags.string({ + description: 'Kind of conversion, such as purchase or sign_up.', + required: true, + }), + 'occurred-at': Flags.string({ + description: + 'When it happened, as an ISO 8601 date-time with a time zone offset.', + required: true, + }), + 'project-id': Flags.string({ + description: 'Project that owns the campaign.', + required: true, + }), + recipient: Flags.string({ + description: 'Campaign recipient ID, from campaign recipients list.', + required: true, + }), + } + + async run(): Promise { + const {args, flags} = await this.parse(CampaignConversionsRecord) + const result = await recordCampaignConversion( + await this.platformClient(flags), + args.campaign, + { + eventId: flags['event-id'], + eventType: flags['event-type'], + occurredAt: flags['occurred-at'], + projectId: flags['project-id'], + recipientId: flags.recipient, + ...(flags['amount-minor'] === undefined || flags.currency === undefined + ? {} + : { + value: { + amountMinor: flags['amount-minor'], + currency: flags.currency, + }, + }), + }, + ) + this.writeValue(result, renderCampaignConversion(result.data), flags) + } +} diff --git a/src/commands/campaign/conversions/report.ts b/src/commands/campaign/conversions/report.ts new file mode 100644 index 0000000..4eacbca --- /dev/null +++ b/src/commands/campaign/conversions/report.ts @@ -0,0 +1,37 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import { + renderCampaignConversions, + viewCampaignConversions, +} from '../../../services/campaign-conversions.js' + +export default class CampaignConversionsReport extends BaseCommand { + static override args = { + campaign: Args.string({description: 'Campaign ID.', required: true}), + } + + static override description = + "View a campaign's reported conversions by attribution outcome, with reported value per currency (beta; requires team enrollment). Currencies are never combined or converted." + + static override examples = [ + '<%= config.bin %> <%= command.id %> --project-id ', + ] + + static override flags = { + 'project-id': Flags.string({ + description: 'Project that owns the campaign.', + required: true, + }), + } + + async run(): Promise { + const {args, flags} = await this.parse(CampaignConversionsReport) + const result = await viewCampaignConversions( + await this.platformClient(flags), + args.campaign, + flags['project-id'], + ) + this.writeValue(result, renderCampaignConversions(result.data), flags) + } +} diff --git a/src/services/campaign-conversions.ts b/src/services/campaign-conversions.ts new file mode 100644 index 0000000..b4c7e26 --- /dev/null +++ b/src/services/campaign-conversions.ts @@ -0,0 +1,84 @@ +import type { + CampaignConversion, + CampaignConversionReport, + ClientInstance, + RecordCampaignConversionRequest, + ResponseMetadata, +} from '@polymorfa/sdk' + +import type {CampaignResult} from './campaigns.js' + +/** Report a conversion for one campaign recipient (beta). */ +export async function recordCampaignConversion( + client: ClientInstance<'organization'>, + campaignId: string, + body: RecordCampaignConversionRequest, +): Promise> { + const response = await client.campaigns.recordConversion(campaignId, body) + return result(response.data.data, response.metadata) +} + +/** A campaign's conversion counts and per-currency values (beta). */ +export async function viewCampaignConversions( + client: ClientInstance<'organization'>, + campaignId: string, + projectId: string, +): Promise> { + const response = await client.campaigns.conversions(campaignId, {projectId}) + return result(response.data.data, response.metadata) +} + +/** + * The caller's event ID is not echoed: it can be an order number and the + * API stores only its hash. + */ +export function renderCampaignConversion( + conversion: CampaignConversion, +): string { + return [ + `${conversion.replayed ? 'Already recorded' : 'Recorded'} conversion ${conversion.id}`, + `Campaign\t${conversion.campaignId}`, + `Outcome\t${conversion.attribution.outcome}`, + `Window\t${conversion.attribution.windowDays} days after the recipient was sent`, + ...(conversion.value === null + ? [] + : [ + `Value\t${conversion.value.amountMinor} ${conversion.value.currency} (minor units, as reported)`, + ]), + ].join('\n') +} + +export function renderCampaignConversions( + report: CampaignConversionReport, +): string { + return [ + `Campaign\t${report.campaignId}`, + `Window\t${report.model.windowDays} days after the recipient was sent`, + `Attributed\t${report.conversions.attributed}`, + `Outside window\t${report.conversions.outsideWindow}`, + `Not sent\t${report.conversions.notSent}`, + `Opted out\t${report.conversions.optedOut}`, + `Converted recipients\t${report.convertedRecipients} of ${report.sentCount} sent`, + `Conversion rate\t${report.conversionRate}`, + ...report.values.map( + (value) => + `Value ${value.currency}\t${value.attributedAmountMinor} attributed, ${value.unattributedAmountMinor} not attributed (minor units, as reported)`, + ), + ].join('\n') +} + +function result(data: T, metadata: ResponseMetadata): CampaignResult { + return { + data, + metadata: { + attempts: metadata.attempts, + status: metadata.status, + ...(metadata.apiVersion === undefined + ? {} + : {apiVersion: metadata.apiVersion}), + ...(metadata.requestId === undefined + ? {} + : {requestId: metadata.requestId}), + }, + } +} diff --git a/test/commands/campaign-conversions.test.ts b/test/commands/campaign-conversions.test.ts new file mode 100644 index 0000000..1628e21 --- /dev/null +++ b/test/commands/campaign-conversions.test.ts @@ -0,0 +1,187 @@ +import {runCommand} from '@oclif/test' +import {afterEach, describe, expect, test} from 'vitest' + +import { + startHttpServer, + withEnvironment, + type TestHttpServer, +} from '../helpers/http-server.js' + +const PROJECT = '0199a000-0000-7000-8000-000000000002' +const CAMPAIGN = '0199a000-0000-7000-8000-000000000003' +const RECIPIENT = '0199a000-0000-7000-8000-000000000004' + +describe('Campaign conversion commands (beta)', () => { + let server: TestHttpServer | undefined + + afterEach(async () => { + await server?.close() + server = undefined + }) + + test('records a conversion without echoing the event ID', async () => { + server = await startHttpServer({body: {data: conversion()}}) + + const result = await apiKeyEnvironment(() => + runCommand( + `campaign conversions record ${CAMPAIGN} --project-id ${PROJECT} --recipient ${RECIPIENT} --event-id ORDER-SECRET-1 --event-type purchase --occurred-at 2026-10-01T10:00:00Z --amount-minor 1999 --currency USD`, + ), + ) + + expect(result.error).toBeUndefined() + expect(server.requests[0]?.method).toBe('POST') + expect(server.requests[0]?.url).toBe( + `/platform/campaigns/${CAMPAIGN}/conversions`, + ) + expect(JSON.parse(server.requests[0]!.body)).toEqual({ + eventId: 'ORDER-SECRET-1', + eventType: 'purchase', + occurredAt: '2026-10-01T10:00:00Z', + projectId: PROJECT, + recipientId: RECIPIENT, + value: {amountMinor: 1999, currency: 'USD'}, + }) + expect(result.stdout).toContain('Outcome\tattributed') + expect(result.stdout).toContain('1999 USD (minor units, as reported)') + expect(result.stdout).not.toContain('ORDER-SECRET-1') + }) + + test('sends no value without both amount and currency', async () => { + server = await startHttpServer({body: {data: conversion()}}) + + const result = await apiKeyEnvironment(() => + runCommand( + `campaign conversions record ${CAMPAIGN} --project-id ${PROJECT} --recipient ${RECIPIENT} --event-id e1 --event-type sign_up --occurred-at 2026-10-01T10:00:00Z --amount-minor 5`, + ), + ) + + expect(result.error).toBeDefined() + expect(result.stderr).toContain('--currency') + expect(server.requests).toHaveLength(0) + }) + + test('reports a replayed conversion as already recorded', async () => { + server = await startHttpServer({ + body: {data: {...conversion(), replayed: true}}, + }) + + const result = await apiKeyEnvironment(() => + runCommand( + `campaign conversions record ${CAMPAIGN} --project-id ${PROJECT} --recipient ${RECIPIENT} --event-id e1 --event-type purchase --occurred-at 2026-10-01T10:00:00Z`, + ), + ) + + expect(result.error).toBeUndefined() + expect(result.stdout).toContain('Already recorded conversion') + }) + + test('shows the report per currency', async () => { + server = await startHttpServer({body: {data: report()}}) + + const result = await apiKeyEnvironment(() => + runCommand( + `campaign conversions report ${CAMPAIGN} --project-id ${PROJECT}`, + ), + ) + + expect(result.error).toBeUndefined() + expect(server.requests[0]?.url).toBe( + `/platform/campaigns/${CAMPAIGN}/conversions?projectId=${PROJECT}`, + ) + expect(result.stdout).toContain('Attributed\t5') + expect(result.stdout).toContain('Converted recipients\t2 of 4 sent') + expect(result.stdout).toContain('Value BRL\t5000 attributed') + expect(result.stdout).toContain( + 'Value USD\t1250 attributed, 700 not attributed', + ) + }) + + test('surfaces a beta refusal and exits with an error', async () => { + server = await startHttpServer({ + body: { + error: 'This beta is not available to this team', + code: 'feature_unavailable', + }, + status: 403, + }) + + const result = await apiKeyEnvironment(() => + runCommand( + `campaign conversions report ${CAMPAIGN} --project-id ${PROJECT}`, + ), + ) + + expect(result.error).toBeDefined() + }) + + async function apiKeyEnvironment(run: () => Promise): Promise { + if (server === undefined) throw new Error('Test server was not started.') + return withEnvironment( + { + POLYMORFA_API_KEY: + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + }, + run, + ) + } +}) + +function conversion() { + return { + attribution: { + outcome: 'attributed', + touchAt: '2026-09-30T10:00:00.000Z', + windowDays: 7, + }, + campaignId: CAMPAIGN, + eventType: 'purchase', + evidence: 'customer_reported', + id: '0199a000-0000-7000-8000-000000000009', + occurredAt: '2026-10-01T10:00:00.000Z', + recipientId: RECIPIENT, + recordedAt: '2026-10-01T10:00:01.000Z', + replayed: false, + value: {amountMinor: 1999, currency: 'USD'}, + } +} + +function report() { + return { + campaignId: CAMPAIGN, + conversionRate: 0.5, + conversions: { + attributed: 5, + notSent: 1, + optedOut: 0, + outsideWindow: 0, + total: 6, + }, + convertedRecipients: 2, + model: { + correlation: 'explicit_recipient', + touch: 'recipient_sent', + windowDays: 7, + }, + sentCount: 4, + values: [ + { + attributedAmountMinor: '5000', + attributedConversions: 1, + currency: 'BRL', + evidence: 'customer_reported', + unattributedAmountMinor: '0', + unattributedConversions: 0, + }, + { + attributedAmountMinor: '1250', + attributedConversions: 2, + currency: 'USD', + evidence: 'customer_reported', + unattributedAmountMinor: '700', + unattributedConversions: 1, + }, + ], + } +} diff --git a/test/release/check-prerelease.test.ts b/test/release/check-prerelease.test.ts index 60f98fe..f942b61 100644 --- a/test/release/check-prerelease.test.ts +++ b/test/release/check-prerelease.test.ts @@ -56,7 +56,7 @@ describe('npm prerelease guard', () => { test('accepts the reviewed manifest and lockfile', () => { const result = runWith() expect(result.status).toBe(0) - expect(result.stdout).toContain('@polymorfa/sdk@0.1.0-dev.20261001124422') + expect(result.stdout).toContain('@polymorfa/sdk@0.1.0-dev.20261001202146') }) test('rejects the default latest tag', () => {