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
31 changes: 30 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.20261001204803
polymorfa/0.1.0-dev.0 sdk/0.1.0-dev.20261001215108
```

The SDK component reports the version built into the installed SDK. Dev
Expand Down Expand Up @@ -596,6 +596,35 @@ polymorfa message send list --session session_123 --to +15551234567 \
- `list` takes repeatable `--row` values `[<section>/]<id>=<title>[|<description>]`.
Rows with the same section are grouped in order.

### Brazil payment orders (beta)

Teams enrolled in the beta can send Brazil orders from Official API Numbers
that Meta has made eligible for payments in Brazil. Polymorfa relays the order
and payment reports; it does not collect, hold or confirm funds.

```sh
polymorfa message send order --session session_123 --to +5511987654321 \
--order order.json --idempotency-key order-1522
polymorfa message send order-status --session session_123 --to +5511987654321 \
--reference-id order-1522 --body 'Payment received' \
--order-status processing --payment-status captured
```

- `order --order` reads the `orderDetails` JSON object from a file or `-`:
`referenceId`, `type`, `body`, `currency: "BRL"`, `totalAmount` in centavos
with `offset: 100`, at least one of `paymentSettings.pixDynamicCode`,
`paymentLink` or `boleto`, and an optional itemized `order`. The API checks
totals, the Pix key and the dynamic Pix code, and names the failing field.
- `order-status` needs `--order-status`, `--payment-status`, or both. Send a
payment status only after your payment provider confirms it.
- Both commands send through the Official API connection and make one
attempt. After an uncertain failure, rerun with the same `--idempotency-key`
to read the recorded outcome instead of sending again.
- WhatsApp refuses invalid status changes with `order_status_transition_invalid`
and refused cancellations with `order_cancellation_failed`. Teams outside the
beta receive `feature_unavailable`. Never resend an order after an uncertain
result; use a new `referenceId` for every order.

The API has no sticker send kind, so the CLI has no `message send sticker`.

### Edit and delete
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.20261001204803",
"@polymorfa/sdk": "0.1.0-dev.20261001215108",
"jq-wasm": "3.0.0-jq-1.8.2",
"zod": "4.2.0"
},
Expand Down
129 changes: 129 additions & 0 deletions src/commands/message/send/order-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import {Flags} from '@oclif/core'
import {
PolymorfaConfigurationError,
type OrderPaymentStatus,
type OrderStatus,
type OrderStatusMessageContent,
type SendOrderStatusMessageRequest,
} from '@polymorfa/sdk'

import {BaseCommand} from '../../../base-command.js'
import {
paymentOrderContext,
paymentOrderFlags,
paymentOrderRequestOptions,
} from '../../../input/payment-order.js'
import {sendMessage} from '../../../services/messages.js'

const ORDER_STATUSES = [
'pending',
'processing',
'partially_shipped',
'shipped',
'completed',
'canceled',
] as const satisfies readonly OrderStatus[]
const PAYMENT_STATUSES = [
'pending',
'captured',
'failed',
] as const satisfies readonly OrderPaymentStatus[]

function nonBlank(value: string | undefined, flag: string): string | undefined {
if (value !== undefined && value.trim() === '') {
throw new PolymorfaConfigurationError(`--${flag} must not be empty.`, flag)
}
return value
}

export default class MessageSendOrderStatus extends BaseCommand {
static override description =
'Update a Brazil order sent with "message send order" (beta). Send a payment status only after your payment provider confirms it; captured shows the order as paid. Makes one attempt.'

static override examples = [
'<%= config.bin %> message send order-status --session session_123 --to +5511987654321 --reference-id order-1522 --body "Payment received" --order-status processing --payment-status captured',
]

static override flags = {
...paymentOrderFlags,
body: Flags.string({description: 'Message text.', required: true}),
footer: Flags.string({description: 'Footer text.'}),
'order-description': Flags.string({
description: 'Order status detail, such as a cancellation reason.',
dependsOn: ['order-status'],
}),
'order-status': Flags.string({
description: 'New order status.',
options: [...ORDER_STATUSES],
}),
'payment-status': Flags.string({
description: 'Payment status confirmed by your payment provider.',
options: [...PAYMENT_STATUSES],
}),
'payment-timestamp': Flags.integer({
description: 'Payment time in Unix seconds.',
min: 1,
dependsOn: ['payment-status'],
}),
'reference-id': Flags.string({
description: 'The referenceId of the order.',
required: true,
}),
}

async run(): Promise<void> {
const {flags} = await this.parse(MessageSendOrderStatus)
const base = {
referenceId: nonBlank(flags['reference-id'], 'reference-id')!,
body: nonBlank(flags.body, 'body')!,
...(flags.footer === undefined
? {}
: {footer: nonBlank(flags.footer, 'footer')!}),
}
const description = nonBlank(
flags['order-description'],
'order-description',
)
const order =
flags['order-status'] === undefined
? undefined
: {
status: flags['order-status'] as OrderStatus,
...(description === undefined ? {} : {description}),
}
const payment =
flags['payment-status'] === undefined
? undefined
: {
status: flags['payment-status'] as OrderPaymentStatus,
...(flags['payment-timestamp'] === undefined
? {}
: {timestamp: flags['payment-timestamp']}),
}
let content: OrderStatusMessageContent
if (order !== undefined)
content = {...base, order, ...(payment === undefined ? {} : {payment})}
else if (payment !== undefined) content = {...base, payment}
else {
throw new PolymorfaConfigurationError(
'Provide --order-status, --payment-status, or both.',
'order-status',
)
}
const request: SendOrderStatusMessageRequest = {
...paymentOrderContext(flags),
content: {orderStatus: content},
}
const result = await sendMessage(
await this.messagingClient(flags),
flags.session,
request,
paymentOrderRequestOptions(flags['idempotency-key']),
)
this.writeValue(
result,
`Sent order status ${result.data.id} to ${flags.to}.`,
flags,
)
}
}
82 changes: 82 additions & 0 deletions src/commands/message/send/order.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import {Flags} from '@oclif/core'
import {
PolymorfaConfigurationError,
type OrderDetailsMessageContent,
type SendOrderDetailsMessageRequest,
} from '@polymorfa/sdk'

import {BaseCommand} from '../../../base-command.js'
import {readJsonInput} from '../../../api/request.js'
import {
paymentOrderContext,
paymentOrderFlags,
paymentOrderRequestOptions,
} from '../../../input/payment-order.js'
import {sendMessage} from '../../../services/messages.js'

const REQUIRED_KEYS = [
'referenceId',
'type',
'body',
'currency',
'totalAmount',
'paymentSettings',
] as const

export default class MessageSendOrder extends BaseCommand {
static override description =
'Send a Brazil order with Pix, payment link or boleto instructions (beta; Official API Numbers only). Polymorfa relays the order and does not process the payment. Makes one attempt.'

static override examples = [
'<%= config.bin %> message send order --session session_123 --to +5511987654321 --order order.json',
'cat order.json | <%= config.bin %> message send order --session session_123 --to +5511987654321 --order - --idempotency-key order-1522',
]

static override flags = {
...paymentOrderFlags,
order: Flags.string({
description:
'Read the orderDetails JSON object (referenceId, type, body, currency, totalAmount, paymentSettings, optional order) from a file or stdin (-).',
required: true,
}),
}

async run(): Promise<void> {
const {flags} = await this.parse(MessageSendOrder)
const parsed = await readJsonInput(flags.order)
if (
parsed === null ||
typeof parsed !== 'object' ||
Array.isArray(parsed)
) {
throw new PolymorfaConfigurationError(
'Order --order must contain a JSON object.',
'order',
)
}
const missing = REQUIRED_KEYS.filter((key) => !(key in parsed))
if (missing.length > 0) {
throw new PolymorfaConfigurationError(
`Order --order must be the orderDetails object itself; missing ${missing.join(', ')}.`,
'order',
)
}
// The API validates the full order (BRL, totals, Pix key and code) and
// names the failing field; the CLI does not duplicate those rules.
const request: SendOrderDetailsMessageRequest = {
...paymentOrderContext(flags),
content: {orderDetails: parsed as OrderDetailsMessageContent},
}
const result = await sendMessage(
await this.messagingClient(flags),
flags.session,
request,
paymentOrderRequestOptions(flags['idempotency-key']),
)
this.writeValue(
result,
`Sent order ${result.data.id} to ${flags.to}.`,
flags,
)
}
}
45 changes: 45 additions & 0 deletions src/input/payment-order.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import {Flags} from '@oclif/core'
import type {MessageSendContext, RequestOptions} from '@polymorfa/sdk'

import {conversationReference} from '../services/conversation.js'
import {idempotencyKey} from './idempotency.js'

/**
* Flags for Brazil payment order sends. Orders go only through an Official API
* connection, address one person, and are never replies, mentions or forwards,
* so those send options are not offered.
*/
export const paymentOrderFlags = {
'idempotency-key': Flags.string({
description:
'Idempotency-Key header. Generated per invocation when omitted; reuse an explicit key to check a previous attempt (24-hour retention).',
}),
session: Flags.string({
description: 'Messaging session ID of an Official API Number.',
required: true,
}),
to: Flags.string({
description: 'Conversation ID, E.164 phone number, or BSUID of the buyer.',
required: true,
}),
}

export function paymentOrderContext(flags: {
readonly to: string
}): MessageSendContext {
return {
conversation: conversationReference(flags.to),
transport: 'official_api',
}
}

/**
* One network attempt: a lost response may still mean WhatsApp delivered the
* order, so it is never resent automatically. A manual retry with the same
* key returns the recorded outcome instead of sending again.
*/
export function paymentOrderRequestOptions(
key: string | undefined,
): RequestOptions {
return {idempotencyKey: idempotencyKey(key), maxNetworkRetries: 0}
}
Loading