diff --git a/README.md b/README.md index 59e8951..b321bfe 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ norbix scheduler list | `norbix account profile/status/usage/projects/team/regions/billing-portal/me` | Account-level info; `account team` pages and filters, `account me set-phone` saves the phone "Account users" SMS campaigns use — see [docs/account.md](docs/account.md) | | `norbix project ...` | One project's settings — name, look, languages, regions, CORS origins, admin portal, legal documents, AI chat and assistants, see [docs/project.md](docs/project.md) | | `norbix ai ...` | LLM and MCP server integrations, AI service users and their keys, see [docs/ai.md](docs/ai.md) | -| `norbix payments ...` | Payments module: provider integrations (Stripe, Apple In-App, Google In-App, …), the received-webhook log, payment triggers — every Payments endpoint a person calls, see [docs/payments.md](docs/payments.md) | +| `norbix payments ...` | Payments module: provider integrations (Stripe, Apple In-App, Google In-App, Montonio, …), payment orders and checkout, the received-webhook log, payment and order triggers — every Payments endpoint a person calls, see [docs/payments.md](docs/payments.md) | | `norbix code functions/qualifiers/invoke/event-sample/runs` | Cloud functions (AWS Lambda, Azure Functions, Google Cloud Functions): list what a code integration may call, aliases and versions, call one by hand, a sample envelope for a local run, the last runs of a trigger or task — see [docs/code.md](docs/code.md) | | `norbix code marketplace listings/connections/functions/runs` | Marketplace: browse listings, enable a listing as a connection, validate it, sign in to an MCP connection (OAuth) and out again, list an MCP connection's tools, set who may use it (access matrix, a role's package, a custom policy), add functions (db.find, chat.postMessage, an MCP tool, …), call one by hand, the last runs of a function, trigger, task or an MCP connection's AI tool calls (`--details`: input, steps, answer when the project's logs are on) — see [docs/code.md](docs/code.md#norbix-code-marketplace) | | `norbix integrations ` | List integrations of any module | diff --git a/docs/payments.md b/docs/payments.md index 448b077..4de9177 100644 --- a/docs/payments.md +++ b/docs/payments.md @@ -1,10 +1,14 @@ # `norbix payments` -Manage the Payments module from the terminal. Norbix does not take payments: -it **receives the webhooks** your payment provider sends (Stripe, Apple -In-App, Google In-App, …), checks their signature, turns each one into one -event — `payments.webhook.received` — and runs your **payment triggers** on -it: send an email, SMS or push, call a webhook, run a function. +Manage the Payments module from the terminal. Norbix **receives the webhooks** +your payment provider sends (Stripe, Apple In-App, Google In-App, Montonio, …), +checks their signature, turns each one into one event — +`payments.webhook.received` — and runs your **payment triggers** on it: send +an email, SMS or push, call a webhook, run a function. With **payment orders** +your server creates an order at a fixed price, Norbix opens the provider's +checkout for it (Montonio sandbox: Lithuanian bank links, cards, Apple Pay, +Google Pay), and the provider's signed callback moves the order — which fires +the order triggers. Every endpoint a person calls has a command. Every command is covered by `tests/payments-routes.test.ts`, which runs it through the real SDK with @@ -12,7 +16,8 @@ Every endpoint a person calls has a command. Every command is covered by fields of `save`, `test`, `webhook-log` and the toggles. No test contacts a payment provider. -All paths below are under `/{version}/payments`. +All paths below are under `/{version}/payments`. The order commands go to the +Api host, everything else to the Hub. ## Module @@ -54,7 +59,7 @@ signing secret in the integration is wrong. `--provider` is one of `Stripe`, `AppleInApp`, `GoogleInApp` (fully supported), or `PayPal`, `Adyen`, `Paddle`, `LemonSqueezy`, `Mollie`, `Shopify`, `WooCommerce`, `Magento`, `Braintree`, `AuthorizeNet`, -`CheckOutCom`, `Worldpay`. Put the provider-specific fields in `--config` as +`CheckOutCom`, `Worldpay`, `Montonio`. Put the provider-specific fields in `--config` as a JSON object (inline, `@file.json`, or `-` for stdin). For Stripe: ```json @@ -73,24 +78,66 @@ norbix payments integration test --integration int_123 norbix payments integration confirm-delivery int_123 ``` +For Montonio (the checkout provider of payment orders), the keys of a Montonio +**sandbox** store — production stays off until the owner turns it on: + +```json +{"accessKey": "…", "secretKey": "…", "isSandbox": true, "locale": "lt"} +``` + +```bash +norbix payments integration save --provider Montonio --name "Montonio sandbox" --config @montonio.json +``` + +## Orders + +| command | what it does | endpoint | +|---|---|---| +| `norbix payments orders [--buyer ] [--reference ] [--status ]` | list the orders, newest first | `GET /orders` | +| `norbix payments order ` | show one order: lines, total, status, settled / refunded cents, checkout attempts | `GET /orders/{Id}` | +| `norbix payments order create --buyer --reference --key --line ... [--meta k=v]... [--currency EUR] [--price-version v1] [--file ]` | create an order for one buyer | `POST /orders` | +| `norbix payments order checkout --integration --return-url [--method ] [--country ] [--provider ]` | start or reuse the checkout; answers the payment URL | `POST /orders/{Id}/checkout` | + +The price is yours: Norbix adds up the lines (whole cents, EUR) and never +reads a price from the buyer. `--key` is the idempotency key: the same key with +the same body returns the same order, with another body it is refused (409). +Creating an order needs a key whose role has "Payments — full access" (or +Administrator); a buyer can read and check out only their own orders. +`--return-url` must be on one of the project's allowed origins. The return +page proves nothing — read the result with `payments order ` or react to +the order triggers below. + +```bash +norbix payments order create --buyer usr_1szW… --reference ad-1042-promo-1 --key promo-1042-0001 --line star-day:21:30 --meta adId=ad_1042 +norbix payments order checkout ord_2ToI… --integration int_6KfK… --return-url https://shop.example.com/paid +norbix payments order ord_2ToI… +``` + ## Triggers | command | what it does | endpoint | |---|---|---| | `norbix payments triggers` | list the payment triggers | `GET /triggers` | | `norbix payments trigger ` | show one trigger | `GET /triggers/{id}` | -| `norbix payments trigger save --file [--id ] [--event ]... [--integration ]... [--order ] [--[no-]break-on-error]` | create or update a trigger | `POST /triggers` | +| `norbix payments trigger save --file [--when ] [--id ] [--event ]... [--integration ]... [--order ] [--[no-]break-on-error]` | create or update a trigger | `POST /triggers` | | `norbix payments trigger enable ` | turn it on | `PATCH /triggers/{triggerId}/enable` | | `norbix payments trigger disable [--yes]` | turn it off | `PATCH /triggers/{triggerId}/disable` | | `norbix payments trigger delete [--yes]` | delete it | `DELETE /triggers/{triggerId}` | The `--file` holds the trigger — the same shape `payments trigger --json` -shows. `type` (`Payments`) and `when` (`OnWebhookCallReceived`, the only one -that fires) are filled in when the file leaves them out. - -- `events` — the provider's own event names, e.g. `payment_intent.succeeded` - or `charge.refunded`. Empty = every event in the server's list for that - provider. A name the server does not know is refused. +shows. `type` (`Payments`) is filled in, and `when` defaults to +`OnWebhookCallReceived` when the file leaves it out; `--when` overrides it: + +- `OnWebhookCallReceived` — a provider webhook reached Norbix. +- `OnOrderPaid` — a verified callback made a stored order Paid. +- `OnOrderPaymentChanged` — a stored order was paid, refunded, reversed or + expired. (`OnOrderCreated` is refused: nothing fires it.) + +- `events` — for `OnWebhookCallReceived` the provider's own event names, e.g. + `payment_intent.succeeded` or `charge.refunded`; for the order types + `payments.order.paid`, `payments.order.partially_refunded`, + `payments.order.refunded`, `payments.order.reversed`, `payments.order.expired`. + Empty = every event. A name the server does not know is refused. - `integrations` — integration IDs. Empty = every payments integration. - `action` — what to run: `{"type": "WebhookCall"}`, `Email`, `Sms`, `Push`, `Code`, `SseCall` or `Marketplace`, each with its own fields (the portal's @@ -117,9 +164,22 @@ the `payments.webhook.received` event. A `WebhookCall` action sends the `payments.webhook.received` event, with the payment as its data, to the project's webhook destinations that listen to it (`norbix webhooks show`). +An order trigger runs once per change of a stored order, from the order as +stored (never from the callback body). Templates read `@Model.Order.*` +(`Order.Id`, `Order.Status`, `Order.Total`, `Order.MerchantReference`, +`Order.Metadata.`, …) next to `@Model.Payment.*`; a function gets +`data.order` (and `data.payment`); a `WebhookCall` sends +`payments.order.paid` (or the other order event) with the order event as its +data. De-duplicate on `orderEventId` — it is the same on every retry. + +```bash +norbix payments trigger save --file order-paid.json --when OnOrderPaid +norbix payments trigger save --file refunds.json --when OnOrderPaymentChanged --event payments.order.refunded +``` + ## Not offered on purpose - The inbound webhook (`POST /payments/webhooks/{accountId}/…`) — the payment provider calls it, not a person. -- Orders, payment schemas, customers, plans and discounts — the Payments - module is a webhook receiver in this phase; those parts are not live. +- Payment schemas, customers, plans and discounts — not live in this phase. +- Production charging through Montonio — sandbox only until the owner turns it on. diff --git a/package-lock.json b/package-lock.json index 7c1059b..a85af7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@inquirer/prompts": "^8.7.2", - "@norbix.ai/ts": "^4.28.0", + "@norbix.ai/ts": "^4.29.0", "@oclif/core": "^5.0.0", "@oclif/plugin-autocomplete": "^4.0.0", "@oclif/plugin-help": "^7.0.0" @@ -860,9 +860,9 @@ } }, "node_modules/@norbix.ai/ts": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@norbix.ai/ts/-/ts-4.28.0.tgz", - "integrity": "sha512-S6dtlVnMpJM9epn9l2jJeIedYFUUQgZ3xkoAzlJ7ZNWchKTZbDmBsNaNWoF5YFXpeq+joIcrFe0GqwVAusGCvQ==", + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/@norbix.ai/ts/-/ts-4.29.0.tgz", + "integrity": "sha512-3iKNGlLdDjoGhGcdom7Fm6i/8Rna7+oLBaBT4p7dvcYz+iDpvkpzSXpzvDpDkrsfWfWxumwb3nUgMocEmTULVQ==", "license": "MIT", "engines": { "node": ">=18" diff --git a/package.json b/package.json index 8fbbe75..b30969f 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ }, "dependencies": { "@inquirer/prompts": "^8.7.2", - "@norbix.ai/ts": "^4.28.0", + "@norbix.ai/ts": "^4.29.0", "@oclif/core": "^5.0.0", "@oclif/plugin-autocomplete": "^4.0.0", "@oclif/plugin-help": "^7.0.0" diff --git a/src/commands/payments/order.ts b/src/commands/payments/order.ts new file mode 100644 index 0000000..1a8bb76 --- /dev/null +++ b/src/commands/payments/order.ts @@ -0,0 +1,27 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../base.js' + +export default class PaymentsOrder extends BaseCommand { + static description = `Show one payment order + +The order as stored: lines, total in cents, status, settled / refunded cents, +the provider payment id, and every checkout attempt. Read the status from here +after the buyer comes back from the bank — the return URL proves nothing.` + + static examples = ['<%= config.bin %> payments order ord_2ToIiRM03X63TZaJ2dJdv2'] + + static args = { + id: Args.string({required: true, description: 'Order ID (ord_…)'}), + } + + async run(): Promise { + const {args, flags} = await this.parse(PaymentsOrder) + const client = this.client(flags) + + const res = await client.api.payments.getPaymentOrder({id: args.id}) + + this.print(res) + return res + } +} diff --git a/src/commands/payments/order/checkout.ts b/src/commands/payments/order/checkout.ts new file mode 100644 index 0000000..161784f --- /dev/null +++ b/src/commands/payments/order/checkout.ts @@ -0,0 +1,50 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' + +export default class PaymentsOrderCheckout extends BaseCommand { + static description = `Start (or reuse) the checkout of a payment order + +Sends the stored price to the payment provider and answers the payment URL +to send the buyer to. An open checkout that has more than two minutes left is +reused. --integration names the payments integration (Montonio sandbox today); +--return-url must be on one of the project's allowed origins. After the buyer +comes back, read the result with \`payments order \` — the order moves only +when the provider's signed callback arrives.` + + static examples = [ + '<%= config.bin %> payments order checkout ord_2ToIiRM03X63TZaJ2dJdv2 --integration int_6KfKmvjN9KRVVzEWudjFs2 --return-url https://shop.example.com/paid', + '<%= config.bin %> payments order checkout ord_2ToIiRM03X63TZaJ2dJdv2 --integration int_6KfKmvjN9KRVVzEWudjFs2 --return-url https://shop.example.com/paid --method bankLink --country LT', + '<%= config.bin %> payments order checkout ord_2ToIiRM03X63TZaJ2dJdv2 --integration int_6KfKmvjN9KRVVzEWudjFs2 --return-url https://shop.example.com/paid --dry-run', + ] + + static args = { + id: Args.string({required: true, description: 'Order ID (ord_…)'}), + } + + static flags = { + ...BaseCommand.dryRunFlags, + integration: Flags.string({required: true, description: 'Payments integration ID that takes the payment'}), + 'return-url': Flags.string({required: true, description: "Where the buyer lands afterwards (an allowed origin of the project)"}), + method: Flags.string({description: 'Payment method, e.g. bankLink, cardPayments (provider default if left out)'}), + country: Flags.string({description: 'Preferred country of the bank list, e.g. LT'}), + provider: Flags.string({description: 'Preferred bank / provider code inside the method'}), + } + + async run(): Promise { + const {args, flags} = await this.parse(PaymentsOrderCheckout) + const client = this.client(flags) + + const res = await client.api.payments.startPaymentCheckout({ + id: args.id, + integrationId: flags.integration, + returnUrl: flags['return-url'], + method: flags.method, + preferredCountry: flags.country, + preferredProvider: flags.provider, + }) + + this.print(res) + return res + } +} diff --git a/src/commands/payments/order/create.ts b/src/commands/payments/order/create.ts new file mode 100644 index 0000000..ec47c1f --- /dev/null +++ b/src/commands/payments/order/create.ts @@ -0,0 +1,63 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' +import {parseMetadata, parseOrderLine, readJsonObject} from '../../../lib/payments.js' + +export default class PaymentsOrderCreate extends BaseCommand { + static description = `Create a payment order for one buyer + +The price is yours: give each line as --line sku:quantity:unitCents[:title] +(repeat for several); Norbix adds up the total in cents. --key is the +idempotency key (8 characters or more): the same key with the same body +returns the same order, the same key with another body is refused. Needs a +key whose role may create orders (Payments — full access, or Administrator). +--file takes the whole body as JSON instead (inline, @file.json or -), with +the flags on top.` + + static examples = [ + '<%= config.bin %> payments order create --buyer usr_1szWVIyZFBV8HfvC4SfVw --reference ad-1042-promo-1 --key promo-1042-0001 --line star-day:21:30 --meta adId=ad_1042', + '<%= config.bin %> payments order create --file @order.json', + '<%= config.bin %> payments order create --file @order.json --dry-run', + ] + + static flags = { + ...BaseCommand.dryRunFlags, + buyer: Flags.string({description: 'The buyer (a project user id, usr_…)'}), + reference: Flags.string({description: 'Your own reference for the order (unique in the project, no spaces)'}), + key: Flags.string({description: 'Idempotency key, 8 characters or more'}), + currency: Flags.string({description: 'Currency (EUR only for now; default EUR)'}), + 'price-version': Flags.string({description: 'The version of your price list the lines come from (default v1)'}), + line: Flags.string({multiple: true, description: 'Order line sku:quantity:unitCents[:title] (repeat for several)'}), + meta: Flags.string({multiple: true, description: 'Metadata key=value (repeat for several)'}), + file: Flags.string({description: 'The whole request body as JSON, @file or -'}), + } + + async run(): Promise { + const {flags} = await this.parse(PaymentsOrderCreate) + const client = this.client(flags) + + const fromFile = flags.file ? await readJsonObject(flags.file, 'file') : {} + let lines: ReturnType[] | undefined + let metadata: Record | undefined + try { + lines = flags.line?.map(parseOrderLine) + metadata = parseMetadata(flags.meta) + } catch (error) { + this.error((error as Error).message, {exit: 2}) + } + + const body: Record = {...fromFile} + if (flags.buyer) body.buyerUserId = flags.buyer + if (flags.reference) body.merchantReference = flags.reference + if (flags.key) body.idempotencyKey = flags.key + body.currency = flags.currency ?? fromFile.currency ?? 'EUR' + body.priceVersion = flags['price-version'] ?? fromFile.priceVersion ?? 'v1' + if (lines) body.lines = lines + if (metadata) body.metadata = {...(fromFile.metadata as Record | undefined), ...metadata} + + const res = await client.api.payments.createPaymentOrder(body as Parameters[0]) + + this.print(res) + return res + } +} diff --git a/src/commands/payments/orders.ts b/src/commands/payments/orders.ts new file mode 100644 index 0000000..b45ff82 --- /dev/null +++ b/src/commands/payments/orders.ts @@ -0,0 +1,40 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../base.js' + +export default class PaymentsOrders extends BaseCommand { + static description = `List the project's payment orders, newest first + +A payment order is created by the project's server (\`payments order create\`) +and moves when a verified provider callback pays, refunds or reverses it. +Filter by buyer, by your own merchant reference or by status.` + + static examples = [ + '<%= config.bin %> payments orders', + '<%= config.bin %> payments orders --buyer usr_1szWVIyZFBV8HfvC4SfVw --status Paid', + '<%= config.bin %> payments orders --reference ad-1042-promo-1', + ] + + static flags = { + buyer: Flags.string({description: 'Only the orders of this buyer (usr_…)'}), + reference: Flags.string({description: 'Only the order with this merchant reference'}), + status: Flags.string({ + description: 'Only orders in this status', + options: ['Created', 'CheckoutStarted', 'Authorized', 'Paid', 'PartiallyRefunded', 'Refunded', 'Reversed', 'Expired'], + }), + } + + async run(): Promise { + const {flags} = await this.parse(PaymentsOrders) + const client = this.client(flags) + + const res = await client.api.payments.getPaymentOrders({ + buyerUserId: flags.buyer, + merchantReference: flags.reference, + status: flags.status, + }) + + this.print(res) + return res + } +} diff --git a/src/commands/payments/trigger/save.ts b/src/commands/payments/trigger/save.ts index 3f35940..8173301 100644 --- a/src/commands/payments/trigger/save.ts +++ b/src/commands/payments/trigger/save.ts @@ -1,19 +1,25 @@ import {Flags} from '@oclif/core' import {BaseCommand} from '../../../base.js' -import {ON_WEBHOOK_CALL_RECEIVED, PAYMENTS_TRIGGER, readJsonObject} from '../../../lib/payments.js' +import {ON_WEBHOOK_CALL_RECEIVED, PAYMENT_TRIGGER_TYPES, PAYMENTS_TRIGGER, readJsonObject} from '../../../lib/payments.js' import {triggerFromFile} from '../../../lib/triggers.js' export default class PaymentsTriggerSave extends BaseCommand { static description = `Create or update a payment trigger from a JSON file -A payment trigger fires when a provider webhook (Stripe, Apple In-App, Google -In-App, …) reaches Norbix and its event matches. The file holds the trigger: -name, the action ({type, ...}) and optionally "integrations" (integration IDs; -empty = every payments integration) and "events" (provider event names such as -payment_intent.succeeded; empty = every event of the catalog) — the same shape -\`payments trigger --json\` shows. "type" is set to "Payments" and "when" -to "OnWebhookCallReceived" when the file leaves them out. +"when" says what fires it: +- OnWebhookCallReceived (default): a provider webhook (Stripe, Apple In-App, + Google In-App, Montonio, …) reached Norbix; "events" = provider event names + such as payment_intent.succeeded. +- OnOrderPaid: a verified callback made a stored payment order Paid. +- OnOrderPaymentChanged: a stored order was paid, refunded, reversed or + expired; "events" = payments.order.paid, payments.order.partially_refunded, + payments.order.refunded, payments.order.reversed, payments.order.expired. +The file holds the trigger: name, the action ({type, ...}) and optionally +"integrations" (integration IDs; empty = every payments integration) and +"events" (empty = every event) — the same shape \`payments trigger --json\` +shows. "type" is set to "Payments" and "when" to "OnWebhookCallReceived" when +the file leaves them out; --when overrides the file. --event and --integration (repeat for several) replace the file's lists, --id sets triggerId (update instead of create), --order and --break-on-error set the @@ -24,13 +30,16 @@ trigger's place in the queue of the same event.` '<%= config.bin %> payments trigger save --file trigger.json --event payment_intent.succeeded --event charge.refunded', 'cat trigger.json | <%= config.bin %> payments trigger save --file -', '<%= config.bin %> payments trigger save --file trigger.json --id 66b2f0a1c3d4e5f6a7b8c9d0 --dry-run', + '<%= config.bin %> payments trigger save --file order-paid.json --when OnOrderPaid', + '<%= config.bin %> payments trigger save --file refunds.json --when OnOrderPaymentChanged --event payments.order.refunded', ] static flags = { ...BaseCommand.dryRunFlags, file: Flags.string({required: true, description: 'Trigger JSON file (or `-` for stdin)'}), id: Flags.string({description: 'Trigger ID — set it to update instead of create'}), - event: Flags.string({multiple: true, description: 'Provider event name to react to (repeat for several; overrides "events")'}), + when: Flags.string({description: 'What fires the trigger (overrides "when" in the file)', options: PAYMENT_TRIGGER_TYPES}), + event: Flags.string({multiple: true, description: 'Event to react to — a provider event name, or payments.order.* for the order types (repeat for several; overrides "events")'}), integration: Flags.string({ multiple: true, description: 'Payments integration ID to listen to (repeat for several; overrides "integrations")', @@ -56,6 +65,7 @@ trigger's place in the queue of the same event.` const trigger: Record = {type: PAYMENTS_TRIGGER, when: ON_WEBHOOK_CALL_RECEIVED, ...fromFile} if (flags.id) trigger.triggerId = flags.id + if (flags.when) trigger.when = flags.when if (flags.event) trigger.events = flags.event if (flags.integration) trigger.integrations = flags.integration if (flags.order !== undefined) trigger.order = flags.order diff --git a/src/lib/payments.ts b/src/lib/payments.ts index f2a0b04..5da6f12 100644 --- a/src/lib/payments.ts +++ b/src/lib/payments.ts @@ -6,9 +6,26 @@ export {readJsonObject} from './push.js' /** The trigger type the server dispatches a payment trigger body by. */ export const PAYMENTS_TRIGGER = 'Payments' -/** The one payment trigger type that fires (the order types are refused, CM-ERRORS-PAYMENTS-007). */ +/** A provider webhook reached Norbix (filters on the provider's own event names). */ export const ON_WEBHOOK_CALL_RECEIVED = 'OnWebhookCallReceived' +/** + * The payment trigger types that fire. The two order types fire when a verified + * callback changed a stored order (OnOrderPaid: it became Paid; OnOrderPaymentChanged: + * paid, refunded, reversed or expired) and filter on the order events below. + * OnOrderCreated is refused by the server (CM-ERRORS-PAYMENTS-007). + */ +export const PAYMENT_TRIGGER_TYPES = [ON_WEBHOOK_CALL_RECEIVED, 'OnOrderPaid', 'OnOrderPaymentChanged'] + +/** The Norbix order events an order trigger's "events" filter may name. */ +export const PAYMENT_ORDER_EVENTS = [ + 'payments.order.paid', + 'payments.order.partially_refunded', + 'payments.order.refunded', + 'payments.order.reversed', + 'payments.order.expired', +] + /** The provider names the server's integration converter accepts (PaymentGatewayPlatform). */ export const PAYMENT_PROVIDERS = [ 'Stripe', @@ -26,4 +43,31 @@ export const PAYMENT_PROVIDERS = [ 'AuthorizeNet', 'CheckOutCom', 'Worldpay', + 'Montonio', ] + +/** + * One order line from `--line sku:quantity:unitCents[:title]` — e.g. `star-day:21:30`. + * A title with spaces goes in --file instead. + */ +export function parseOrderLine(raw: string): {sku: string; quantity: number; unitAmountMinor: number; title?: string} { + const [sku, quantity, unit, ...title] = raw.split(':') + const q = Number(quantity) + const u = Number(unit) + if (!sku || !Number.isInteger(q) || q < 1 || !Number.isInteger(u) || u < 0) { + throw new Error(`--line "${raw}" must be sku:quantity:unitCents[:title], e.g. star-day:21:30 (quantity 1 or more, cents 0 or more)`) + } + return title.length > 0 ? {sku, quantity: q, unitAmountMinor: u, title: title.join(':')} : {sku, quantity: q, unitAmountMinor: u} +} + +/** `--meta key=value` (repeatable) → a string map. */ +export function parseMetadata(pairs: string[] | undefined): Record | undefined { + if (!pairs || pairs.length === 0) return undefined + const out: Record = {} + for (const pair of pairs) { + const at = pair.indexOf('=') + if (at < 1) throw new Error(`--meta "${pair}" must be key=value`) + out[pair.slice(0, at)] = pair.slice(at + 1) + } + return out +} diff --git a/tests/__snapshots__/schema.test.ts.snap b/tests/__snapshots__/schema.test.ts.snap index 661be97..cc908af 100644 --- a/tests/__snapshots__/schema.test.ts.snap +++ b/tests/__snapshots__/schema.test.ts.snap @@ -2180,6 +2180,59 @@ exports[`norbix schema > is stable (snapshot of ids, flags and markers) 1`] = ` "id": "payments integrations", "supportsDryRun": false, }, + { + "args": [ + "id", + ], + "destructive": false, + "flags": [], + "id": "payments order", + "supportsDryRun": false, + }, + { + "args": [ + "id", + ], + "destructive": false, + "flags": [ + "dry-run:boolean", + "integration:string", + "return-url:string", + "method:string", + "country:string", + "provider:string", + ], + "id": "payments order checkout", + "supportsDryRun": true, + }, + { + "args": [], + "destructive": false, + "flags": [ + "dry-run:boolean", + "buyer:string", + "reference:string", + "key:string", + "currency:string", + "price-version:string", + "line:string", + "meta:string", + "file:string", + ], + "id": "payments order create", + "supportsDryRun": true, + }, + { + "args": [], + "destructive": false, + "flags": [ + "buyer:string", + "reference:string", + "status:option", + ], + "id": "payments orders", + "supportsDryRun": false, + }, { "args": [ "id", @@ -2231,6 +2284,7 @@ exports[`norbix schema > is stable (snapshot of ids, flags and markers) 1`] = ` "dry-run:boolean", "file:string", "id:string", + "when:option", "event:string", "integration:string", "order:string", diff --git a/tests/payments-routes.test.ts b/tests/payments-routes.test.ts index 2336cba..81252db 100644 --- a/tests/payments-routes.test.ts +++ b/tests/payments-routes.test.ts @@ -12,11 +12,13 @@ import {envListAnswer} from './_env.js' * path of the request it sends. `fetch` is replaced, so nothing leaves the * process and no payment provider is contacted. * + * The order commands go to the Api host (same /v3 prefix), the rest to the Hub. * The inbound provider webhook (`POST /payments/webhooks/{acc}/{prj}/{source}/{int}`) * is called by Stripe & co., not by a person, so it has no command. */ const ID = '66b2f0a1' +const ORD = 'ord_2ToIiRM03X63TZaJ2dJdv2' interface Call { method: string @@ -90,6 +92,12 @@ const routes: Array<[() => string[], string, string]> = [ [() => ['trigger', 'enable', ID], 'PATCH', `${P}/triggers/${ID}/enable`], [() => ['trigger', 'disable', ID, '--yes'], 'PATCH', `${P}/triggers/${ID}/disable`], [() => ['trigger', 'delete', ID, '--yes'], 'DELETE', `${P}/triggers/${ID}`], + + // Orders (Api host) + [() => ['orders'], 'GET', `${P}/orders`], + [() => ['order', ORD], 'GET', `${P}/orders/${ORD}`], + [() => ['order', 'create', '--buyer', 'usr_1', '--reference', 'ad-1', '--key', 'key-00000001', '--line', 'star-day:21:30'], 'POST', `${P}/orders`], + [() => ['order', 'checkout', ORD, '--integration', 'int_1', '--return-url', 'https://shop.example.com/paid'], 'POST', `${P}/orders/${ORD}/checkout`], ] describe('every payments command reaches its route', () => { @@ -105,11 +113,11 @@ describe('every payments command reaches its route', () => { } }) -it('covers all 17 payments routes a person calls', () => { - // The manifest (sdks/typegen/coverage/endpoints.hub.json) lists 17 Payments - // entries and @norbix.ai/ts 4.28.0 has 17 payments methods; the 18th route, - // the inbound provider webhook, is called by the provider. - expect(new Set(routes.map(([, verb, path]) => `${verb} ${path}`)).size).toBe(17) +it('covers all 21 payments routes a person calls', () => { + // The manifests (sdks/typegen/coverage/endpoints.{hub,api}.json) list 17 Hub + // and 4 Api Payments entries, the same as @norbix.ai/ts 4.29.0; the inbound + // provider webhook is called by the provider. + expect(new Set(routes.map(([, verb, path]) => `${verb} ${path}`)).size).toBe(21) }) /** Run a command and return the one request it sent. */ @@ -257,3 +265,101 @@ describe('payments trigger enable / disable / delete', () => { }) } }) + +describe('payments trigger save --when', () => { + it('saves an order trigger with the Norbix order events', async () => { + const {body} = await callOf([ + 'trigger', 'save', '--file', triggerFile({events: undefined}), '--when', 'OnOrderPaymentChanged', + '--event', 'payments.order.refunded', + ]) + expect(body?.trigger).toMatchObject({type: 'Payments', when: 'OnOrderPaymentChanged', events: ['payments.order.refunded']}) + }) + + it('keeps the file\'s when unless --when is given', async () => { + const {body} = await callOf(['trigger', 'save', '--file', triggerFile({when: 'OnOrderPaid', events: []})]) + expect(body?.trigger).toMatchObject({when: 'OnOrderPaid'}) + }) + + it('refuses a trigger type the server does not fire', async () => { + const {error} = await runCommand(['payments', 'trigger', 'save', '--file', triggerFile(), '--when', 'OnOrderCreated', ...globalArgs]) + expect(error?.message).toMatch(/Expected --when=OnOrderCreated to be one of/) + expect(calls).toHaveLength(0) + }) +}) + +describe('payments integration save --provider Montonio', () => { + it('sends the Montonio keys from --config', async () => { + const {body} = await callOf([ + 'integration', 'save', '--provider', 'Montonio', '--name', 'Montonio', + '--config', '{"accessKey":"ak_1","secretKey":"sk_1","isSandbox":true}', + ]) + expect(body).toEqual({ + integration: {accessKey: 'ak_1', secretKey: 'sk_1', isSandbox: true, provider: 'Montonio', integrationName: 'Montonio', isEnabled: true}, + }) + }) +}) + +describe('payments order create', () => { + it('turns the flags into the order body, EUR and v1 by default', async () => { + const {body} = await callOf([ + 'order', 'create', '--buyer', 'usr_1', '--reference', 'ad-1042', '--key', 'promo-1042-0001', + '--line', 'star-day:21:30', '--line', 'highlight-day:7:30:Highlight', '--meta', 'adId=ad_1042', + ]) + expect(body).toEqual({ + buyerUserId: 'usr_1', + merchantReference: 'ad-1042', + idempotencyKey: 'promo-1042-0001', + currency: 'EUR', + priceVersion: 'v1', + lines: [ + {sku: 'star-day', quantity: 21, unitAmountMinor: 30}, + {sku: 'highlight-day', quantity: 7, unitAmountMinor: 30, title: 'Highlight'}, + ], + metadata: {adId: 'ad_1042'}, + }) + }) + + it('takes the body from --file and lets the flags override it', async () => { + const file = join(dir, 'order.json') + writeFileSync(file, JSON.stringify({ + buyerUserId: 'usr_file', merchantReference: 'ref-file', idempotencyKey: 'key-file-0001', currency: 'EUR', + priceVersion: '2026-10-01', lines: [{sku: 'star-day', title: 'Star × day', quantity: 21, unitAmountMinor: 30}], + metadata: {adId: 'ad_1'}, + })) + const {body} = await callOf(['order', 'create', '--file', `@${file}`, '--reference', 'ref-flag', '--meta', 'promotion=stars']) + expect(body).toMatchObject({ + buyerUserId: 'usr_file', + merchantReference: 'ref-flag', + priceVersion: '2026-10-01', + metadata: {adId: 'ad_1', promotion: 'stars'}, + }) + }) + + it('refuses a malformed --line before sending anything', async () => { + const {error} = await runCommand(['payments', 'order', 'create', '--line', 'star-day:zero:30', ...globalArgs]) + expect(error?.message).toMatch(/must be sku:quantity:unitCents/) + expect(calls).toHaveLength(0) + }) +}) + +describe('payments orders / order / checkout', () => { + it('orders sends the filters as query parameters', async () => { + const call = await callOf(['orders', '--buyer', 'usr_1', '--reference', 'ad-1', '--status', 'Paid']) + expect(Object.fromEntries(call.query)).toMatchObject({buyerUserId: 'usr_1', merchantReference: 'ad-1', status: 'Paid'}) + }) + + it('checkout sends the integration, the return URL and the options in the body', async () => { + const call = await callOf([ + 'order', 'checkout', ORD, '--integration', 'int_1', '--return-url', 'https://shop.example.com/paid', + '--method', 'bankLink', '--country', 'LT', + ]) + expect(call.path).toBe(`${P}/orders/${ORD}/checkout`) + expect(call.body).toMatchObject({integrationId: 'int_1', returnUrl: 'https://shop.example.com/paid', method: 'bankLink', preferredCountry: 'LT'}) + }) + + it('checkout needs --integration and --return-url', async () => { + const {error} = await runCommand(['payments', 'order', 'checkout', ORD, ...globalArgs]) + expect(error?.message).toMatch(/Missing required flag/) + expect(calls).toHaveLength(0) + }) +})