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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <module>` | List integrations of any module |
Expand Down
92 changes: 76 additions & 16 deletions docs/payments.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
# `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
`fetch` replaced and checks the verb and path it sends, plus the request
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

Expand Down Expand Up @@ -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
Expand All @@ -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 <usr_…>] [--reference <ref>] [--status <s>]` | list the orders, newest first | `GET /orders` |
| `norbix payments order <id>` | show one order: lines, total, status, settled / refunded cents, checkout attempts | `GET /orders/{Id}` |
| `norbix payments order create --buyer <usr_…> --reference <ref> --key <key> --line <sku:qty:cents[:title]>... [--meta k=v]... [--currency EUR] [--price-version v1] [--file <json>]` | create an order for one buyer | `POST /orders` |
| `norbix payments order checkout <id> --integration <id> --return-url <url> [--method <m>] [--country <cc>] [--provider <code>]` | 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 <id>` 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 <id>` | show one trigger | `GET /triggers/{id}` |
| `norbix payments trigger save --file <json> [--id <id>] [--event <name>]... [--integration <id>]... [--order <n>] [--[no-]break-on-error]` | create or update a trigger | `POST /triggers` |
| `norbix payments trigger save --file <json> [--when <type>] [--id <id>] [--event <name>]... [--integration <id>]... [--order <n>] [--[no-]break-on-error]` | create or update a trigger | `POST /triggers` |
| `norbix payments trigger enable <id>` | turn it on | `PATCH /triggers/{triggerId}/enable` |
| `norbix payments trigger disable <id> [--yes]` | turn it off | `PATCH /triggers/{triggerId}/disable` |
| `norbix payments trigger delete <id> [--yes]` | delete it | `DELETE /triggers/{triggerId}` |

The `--file` holds the trigger — the same shape `payments trigger <id> --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
Expand All @@ -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.<key>`, …) 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.
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 @@ -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"
Expand Down
27 changes: 27 additions & 0 deletions src/commands/payments/order.ts
Original file line number Diff line number Diff line change
@@ -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<unknown> {
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
}
}
50 changes: 50 additions & 0 deletions src/commands/payments/order/checkout.ts
Original file line number Diff line number Diff line change
@@ -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 <id>\` — 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<unknown> {
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
}
}
63 changes: 63 additions & 0 deletions src/commands/payments/order/create.ts
Original file line number Diff line number Diff line change
@@ -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<unknown> {
const {flags} = await this.parse(PaymentsOrderCreate)
const client = this.client(flags)

const fromFile = flags.file ? await readJsonObject(flags.file, 'file') : {}
let lines: ReturnType<typeof parseOrderLine>[] | undefined
let metadata: Record<string, string> | undefined
try {
lines = flags.line?.map(parseOrderLine)
metadata = parseMetadata(flags.meta)
} catch (error) {
this.error((error as Error).message, {exit: 2})
}

const body: Record<string, unknown> = {...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<string, string> | undefined), ...metadata}

const res = await client.api.payments.createPaymentOrder(body as Parameters<typeof client.api.payments.createPaymentOrder>[0])

this.print(res)
return res
}
}
40 changes: 40 additions & 0 deletions src/commands/payments/orders.ts
Original file line number Diff line number Diff line change
@@ -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<unknown> {
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
}
}
Loading
Loading