Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
79 changes: 79 additions & 0 deletions src/commands/campaign/conversions/record.ts
Original file line number Diff line number Diff line change
@@ -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 %> <campaignId> --project-id <projectId> --recipient <recipientId> --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<void> {
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)
}
}
37 changes: 37 additions & 0 deletions src/commands/campaign/conversions/report.ts
Original file line number Diff line number Diff line change
@@ -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 %> <campaignId> --project-id <projectId>',
]

static override flags = {
'project-id': Flags.string({
description: 'Project that owns the campaign.',
required: true,
}),
}

async run(): Promise<void> {
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)
}
}
84 changes: 84 additions & 0 deletions src/services/campaign-conversions.ts
Original file line number Diff line number Diff line change
@@ -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<CampaignResult<CampaignConversion>> {
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<CampaignResult<CampaignConversionReport>> {
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<T>(data: T, metadata: ResponseMetadata): CampaignResult<T> {
return {
data,
metadata: {
attempts: metadata.attempts,
status: metadata.status,
...(metadata.apiVersion === undefined
? {}
: {apiVersion: metadata.apiVersion}),
...(metadata.requestId === undefined
? {}
: {requestId: metadata.requestId}),
},
}
}
Loading