diff --git a/README.md b/README.md index 4f34f34..50dea38 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.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 @@ -596,6 +596,35 @@ polymorfa message send list --session session_123 --to +15551234567 \ - `list` takes repeatable `--row` values `[
/]=[|<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 diff --git a/package-lock.json b/package-lock.json index b6e3486..550a858 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.20261001204803", + "@polymorfa/sdk": "0.1.0-dev.20261001215108", "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.20261001204803", - "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20261001204803.tgz", - "integrity": "sha512-HrIwEvctKgAt8thGdDZnWzurPGdCjGRgaTYqwCLGrR5gAcF9T44cPnn2NW7dfd+aD0rhYfv0XYjkZ1/IbiYUKw==", + "version": "0.1.0-dev.20261001215108", + "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20261001215108.tgz", + "integrity": "sha512-Q0gmgGxUqC+HP3nzipqEZN6oD9QaH/z2LsmrGxDqmniMeBwDLLRAnxbYc5P/+3+QIOr/9FhQ8+GI7tkJ6s9Q+w==", "engines": { "node": ">=20" } diff --git a/package.json b/package.json index 4271ab2..37ce93d 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.20261001204803", + "@polymorfa/sdk": "0.1.0-dev.20261001215108", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, diff --git a/src/commands/message/send/order-status.ts b/src/commands/message/send/order-status.ts new file mode 100644 index 0000000..6473eae --- /dev/null +++ b/src/commands/message/send/order-status.ts @@ -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, + ) + } +} diff --git a/src/commands/message/send/order.ts b/src/commands/message/send/order.ts new file mode 100644 index 0000000..7fc2aaa --- /dev/null +++ b/src/commands/message/send/order.ts @@ -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, + ) + } +} diff --git a/src/input/payment-order.ts b/src/input/payment-order.ts new file mode 100644 index 0000000..e912134 --- /dev/null +++ b/src/input/payment-order.ts @@ -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} +} diff --git a/test/commands/message-sends.test.ts b/test/commands/message-sends.test.ts index bf09f49..e474ea9 100644 --- a/test/commands/message-sends.test.ts +++ b/test/commands/message-sends.test.ts @@ -249,6 +249,221 @@ describe('typed message send commands', () => { expect(server.requests).toHaveLength(0) }) + test('sends a Brazil order from a JSON file unchanged', async () => { + const order = { + referenceId: 'order-1522', + type: 'digital-goods', + body: 'Your order', + currency: 'BRL', + totalAmount: {value: 5000, offset: 100}, + paymentSettings: {paymentLink: {uri: 'https://pay.example.com/c/1522'}}, + } + const path = join(directory, 'order.json') + await writeFile(path, JSON.stringify(order)) + server = await messageServer() + + const result = await send([ + 'message', + 'send', + 'order', + '--session', + 'session_1', + '--to', + '+5511987654321', + '--order', + path, + '--idempotency-key', + 'order-1522', + '--json', + ]) + + expect(result.error).toBeUndefined() + expect(sentBody()).toEqual({ + conversation: {phoneNumber: '+5511987654321'}, + transport: 'official_api', + content: {orderDetails: order}, + }) + expect(server.requests[0]?.headers['idempotency-key']).toBe('order-1522') + }) + + test('rejects an order file that is not a JSON object', async () => { + const path = join(directory, 'order.json') + await writeFile(path, '[]') + server = await messageServer() + + const result = await send([ + 'message', + 'send', + 'order', + '--session', + 'session_1', + '--to', + '+5511987654321', + '--order', + path, + '--json', + ]) + + expect(exitCode(result.error)).toBe(2) + expect(server.requests).toHaveLength(0) + }) + + test('sends an order status update from flags', async () => { + server = await messageServer() + + const result = await send([ + 'message', + 'send', + 'order-status', + '--session', + 'session_1', + '--to', + '+5511987654321', + '--reference-id', + 'order-1522', + '--body', + 'Paid', + '--order-status', + 'processing', + '--payment-status', + 'captured', + '--payment-timestamp', + '1790000000', + '--json', + ]) + + expect(result.error).toBeUndefined() + expect(sentBody()).toEqual({ + conversation: {phoneNumber: '+5511987654321'}, + transport: 'official_api', + content: { + orderStatus: { + referenceId: 'order-1522', + body: 'Paid', + order: {status: 'processing'}, + payment: {status: 'captured', timestamp: 1790000000}, + }, + }, + }) + }) + + test('sends a payment-only status and refuses dependent flags without their status', async () => { + server = await messageServer() + const base = [ + 'message', + 'send', + 'order-status', + '--session', + 'session_1', + '--to', + '+5511987654321', + '--reference-id', + 'order-1522', + '--body', + 'Paid', + ] + + const paid = await send([...base, '--payment-status', 'captured', '--json']) + expect(paid.error).toBeUndefined() + expect(sentBody()).toMatchObject({ + content: {orderStatus: {payment: {status: 'captured'}}}, + }) + expect(JSON.stringify(sentBody())).not.toContain('"order"') + + for (const extra of [ + ['--order-description', 'late', '--payment-status', 'captured'], + ['--payment-timestamp', '1790000000', '--order-status', 'processing'], + ['--order-status', 'processing', '--footer', ' '], + ]) { + const refused = await send([...base, ...extra, '--json']) + expect(exitCode(refused.error)).toBe(2) + } + expect(server.requests).toHaveLength(1) + }) + + test('refuses a wrapped order file and makes one attempt on an uncertain failure', async () => { + const wrapped = join(directory, 'wrapped.json') + await writeFile(wrapped, JSON.stringify({orderDetails: {referenceId: 'r'}})) + server = await startHttpServer({ + status: 503, + body: { + success: false, + error: { + type: 'api_error', + code: 'service_unavailable', + message: 'down', + param: null, + request_id: 'r', + }, + data: null, + }, + }) + + const refused = await send([ + 'message', + 'send', + 'order', + '--session', + 'session_1', + '--to', + '+5511987654321', + '--order', + wrapped, + '--json', + ]) + expect(exitCode(refused.error)).toBe(2) + expect(server.requests).toHaveLength(0) + + const order = join(directory, 'order.json') + await writeFile( + order, + JSON.stringify({ + referenceId: 'r', + type: 'digital-goods', + body: 'b', + currency: 'BRL', + totalAmount: {value: 1, offset: 100}, + paymentSettings: {boleto: {digitableLine: '1'}}, + }), + ) + const failed = await send([ + 'message', + 'send', + 'order', + '--session', + 'session_1', + '--to', + '+5511987654321', + '--order', + order, + '--json', + ]) + expect(failed.error).toBeDefined() + expect(server.requests).toHaveLength(1) + }) + + test('requires an order or payment status before sending', async () => { + server = await messageServer() + + const result = await send([ + 'message', + 'send', + 'order-status', + '--session', + 'session_1', + '--to', + '+5511987654321', + '--reference-id', + 'order-1522', + '--body', + 'Nothing', + '--json', + ]) + + expect(exitCode(result.error)).toBe(2) + expect(server.requests).toHaveLength(0) + }) + test('sends a location with signed coordinates', async () => { server = await messageServer() diff --git a/test/release/check-prerelease.test.ts b/test/release/check-prerelease.test.ts index 5b091a3..89d1b60 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.20261001204803') + expect(result.stdout).toContain('@polymorfa/sdk@0.1.0-dev.20261001215108') }) test('rejects the default latest tag', () => {