diff --git a/README.md b/README.md index 6f91b83..7ec0c18 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,12 @@ npm install -g agentaos | `agenta pay checkout -a 50` | Create a checkout session | | `agenta pay get ` | Get checkout details | | `agenta pay list` | List your checkouts | +| `agenta subscriptions list` | List subscriptions | +| `agenta subscriptions cancel ` | Cancel a subscription (at period end; `--now` for immediate) | +| `agenta customers list` | List customers | +| `agenta invoices list` | List invoices | +| `agenta invoices receipt ` | Download the receipt PDF | +| `agenta invoices send-receipt ` | Re-send the receipt email | **Agent Sub-accounts** — autonomous wallets with guardrails @@ -228,7 +234,7 @@ Connect any AI assistant to AgentaOS. Claude, Cursor, Windsurf — they sign tra - **API Key** + **API Secret** — for wallet tools. Generated when you create a sub-account. - **Gateway Key** — for payment tools. Generated at [app.agentaos.ai](https://app.agentaos.ai) → API Keys. -### Tools (21 total) +### Tools (25 total) **Payments** @@ -237,6 +243,10 @@ Connect any AI assistant to AgentaOS. Claude, Cursor, Windsurf — they sign tra | `agenta_pay_create_checkout` | Create a checkout session | | `agenta_pay_get_checkout` | Get checkout status | | `agenta_pay_list_checkouts` | List checkouts | +| `agenta_pay_list_subscriptions` | List subscriptions | +| `agenta_pay_cancel_subscription` | Cancel a subscription (at period end or immediately) | +| `agenta_pay_list_customers` | List customers who have paid you | +| `agenta_pay_send_receipt` | Re-send a paid invoice's receipt email to the buyer | **Wallet** diff --git a/packages/chains/CHANGELOG.md b/packages/chains/CHANGELOG.md index 7c974b3..f71655b 100644 --- a/packages/chains/CHANGELOG.md +++ b/packages/chains/CHANGELOG.md @@ -1,14 +1,26 @@ # @agentaos/chains +## 2.0.0 + +### Patch Changes + +- Updated dependencies []: + - @agentaos/core@2.0.0 + ## 1.2.0 ### Minor Changes -- [#26](https://github.com/AgentaOS/agentaos/pull/26) [`7affbd4`](https://github.com/AgentaOS/agentaos/commit/7affbd4509e606a08c0910e59f3325ecbe491257) Thanks [@PancheI](https://github.com/PancheI)! - Add EIP-7702 (Type 4) transaction support — authorization list handling for account delegation transactions. +- [#26](https://github.com/AgentaOS/agentaos/pull/26) + [`7affbd4`](https://github.com/AgentaOS/agentaos/commit/7affbd4509e606a08c0910e59f3325ecbe491257) + Thanks [@PancheI](https://github.com/PancheI)! - Add EIP-7702 (Type 4) + transaction support — authorization list handling for account delegation + transactions. ### Patch Changes -- Updated dependencies [[`7affbd4`](https://github.com/AgentaOS/agentaos/commit/7affbd4509e606a08c0910e59f3325ecbe491257)]: +- Updated dependencies + [[`7affbd4`](https://github.com/AgentaOS/agentaos/commit/7affbd4509e606a08c0910e59f3325ecbe491257)]: - @agentaos/core@1.2.0 ## 1.1.1 @@ -29,20 +41,27 @@ ### Major Changes -- [#16](https://github.com/AgentaOS/agentaos/pull/16) [`beb6eea`](https://github.com/AgentaOS/agentaos/commit/beb6eeaa1d0a0cfa8df5d42b511b305913e0ec1c) Thanks [@PancheI](https://github.com/PancheI)! - Device-code CLI login, MCP payment tools, CLI restructure +- [#16](https://github.com/AgentaOS/agentaos/pull/16) + [`beb6eea`](https://github.com/AgentaOS/agentaos/commit/beb6eeaa1d0a0cfa8df5d42b511b305913e0ec1c) + Thanks [@PancheI](https://github.com/PancheI)! - Device-code CLI login, MCP + payment tools, CLI restructure - `agenta login` opens browser for authentication + wallet activation - `agenta pay checkout/get/list` — create and manage payment checkouts - - `agenta sub` namespace — all sub-account commands (init, send, balance, policies, etc.) + - `agenta sub` namespace — all sub-account commands (init, send, balance, + policies, etc.) - `agenta status` — full account overview with `--json` mode for AI agents - - Non-interactive `agenta sub init --create/--import` with flag-based interface + - Non-interactive `agenta sub init --create/--import` with flag-based + interface - Auto-refresh JWT on expiry or scope upgrade - - MCP: 3 new payment tools (create_checkout, get_checkout, list_checkouts) — 21 total + - MCP: 3 new payment tools (create_checkout, get_checkout, list_checkouts) — + 21 total - All commands support `--json` for machine-readable output ### Patch Changes -- Updated dependencies [[`beb6eea`](https://github.com/AgentaOS/agentaos/commit/beb6eeaa1d0a0cfa8df5d42b511b305913e0ec1c)]: +- Updated dependencies + [[`beb6eea`](https://github.com/AgentaOS/agentaos/commit/beb6eeaa1d0a0cfa8df5d42b511b305913e0ec1c)]: - @agentaos/core@1.0.0 ## 0.2.1 @@ -56,7 +75,9 @@ ### Minor Changes -- [`f3a9ebd`](https://github.com/AgentaOS/agentaos/commit/f3a9ebde8594353a8cb416e99c481abc2af71959) Thanks [@PancheI](https://github.com/PancheI)! - Initial public release — threshold ECDSA signing for autonomous agents +- [`f3a9ebd`](https://github.com/AgentaOS/agentaos/commit/f3a9ebde8594353a8cb416e99c481abc2af71959) + Thanks [@PancheI](https://github.com/PancheI)! - Initial public release — + threshold ECDSA signing for autonomous agents - 2-of-3 CGGMP24 threshold signing via Rust WASM (full key never exists) - Three signing paths: Signer+Server, User+Server, Signer+User @@ -68,5 +89,6 @@ ### Patch Changes -- Updated dependencies [[`f3a9ebd`](https://github.com/AgentaOS/agentaos/commit/f3a9ebde8594353a8cb416e99c481abc2af71959)]: +- Updated dependencies + [[`f3a9ebd`](https://github.com/AgentaOS/agentaos/commit/f3a9ebde8594353a8cb416e99c481abc2af71959)]: - @agentaos/core@0.2.0 diff --git a/packages/chains/package.json b/packages/chains/package.json index dccb1a5..c027aa3 100644 --- a/packages/chains/package.json +++ b/packages/chains/package.json @@ -1,6 +1,6 @@ { "name": "@agentaos/chains", - "version": "1.2.0", + "version": "2.0.0", "description": "Ethereum chain adapter for AgentaOS threshold wallet", "license": "Apache-2.0", "author": "AgentaOS ", @@ -29,7 +29,7 @@ "access": "public" }, "scripts": { - "build": "tsc", + "build": "tsc -p tsconfig.build.json", "lint": "biome check src/", "test": "vitest run", "typecheck": "tsc --noEmit", @@ -38,6 +38,5 @@ "dependencies": { "@agentaos/core": "workspace:*", "viem": "^2.21.0" - }, - "devDependencies": {} + } } diff --git a/packages/chains/tsconfig.build.json b/packages/chains/tsconfig.build.json new file mode 100644 index 0000000..45ab7a4 --- /dev/null +++ b/packages/chains/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"] +} diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index f58069b..252c3c9 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,10 +1,16 @@ # @agentaos/core +## 2.0.0 + ## 1.2.0 ### Minor Changes -- [#26](https://github.com/AgentaOS/agentaos/pull/26) [`7affbd4`](https://github.com/AgentaOS/agentaos/commit/7affbd4509e606a08c0910e59f3325ecbe491257) Thanks [@PancheI](https://github.com/PancheI)! - Add EIP-7702 (Type 4) transaction support — authorization list handling for account delegation transactions. +- [#26](https://github.com/AgentaOS/agentaos/pull/26) + [`7affbd4`](https://github.com/AgentaOS/agentaos/commit/7affbd4509e606a08c0910e59f3325ecbe491257) + Thanks [@PancheI](https://github.com/PancheI)! - Add EIP-7702 (Type 4) + transaction support — authorization list handling for account delegation + transactions. ## 1.1.1 @@ -14,15 +20,21 @@ ### Major Changes -- [#16](https://github.com/AgentaOS/agentaos/pull/16) [`beb6eea`](https://github.com/AgentaOS/agentaos/commit/beb6eeaa1d0a0cfa8df5d42b511b305913e0ec1c) Thanks [@PancheI](https://github.com/PancheI)! - Device-code CLI login, MCP payment tools, CLI restructure +- [#16](https://github.com/AgentaOS/agentaos/pull/16) + [`beb6eea`](https://github.com/AgentaOS/agentaos/commit/beb6eeaa1d0a0cfa8df5d42b511b305913e0ec1c) + Thanks [@PancheI](https://github.com/PancheI)! - Device-code CLI login, MCP + payment tools, CLI restructure - `agenta login` opens browser for authentication + wallet activation - `agenta pay checkout/get/list` — create and manage payment checkouts - - `agenta sub` namespace — all sub-account commands (init, send, balance, policies, etc.) + - `agenta sub` namespace — all sub-account commands (init, send, balance, + policies, etc.) - `agenta status` — full account overview with `--json` mode for AI agents - - Non-interactive `agenta sub init --create/--import` with flag-based interface + - Non-interactive `agenta sub init --create/--import` with flag-based + interface - Auto-refresh JWT on expiry or scope upgrade - - MCP: 3 new payment tools (create_checkout, get_checkout, list_checkouts) — 21 total + - MCP: 3 new payment tools (create_checkout, get_checkout, list_checkouts) — + 21 total - All commands support `--json` for machine-readable output ## 0.2.1 @@ -31,7 +43,9 @@ ### Minor Changes -- [`f3a9ebd`](https://github.com/AgentaOS/agentaos/commit/f3a9ebde8594353a8cb416e99c481abc2af71959) Thanks [@PancheI](https://github.com/PancheI)! - Initial public release — threshold ECDSA signing for autonomous agents +- [`f3a9ebd`](https://github.com/AgentaOS/agentaos/commit/f3a9ebde8594353a8cb416e99c481abc2af71959) + Thanks [@PancheI](https://github.com/PancheI)! - Initial public release — + threshold ECDSA signing for autonomous agents - 2-of-3 CGGMP24 threshold signing via Rust WASM (full key never exists) - Three signing paths: Signer+Server, User+Server, Signer+User diff --git a/packages/core/package.json b/packages/core/package.json index 3f5aed4..d846799 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@agentaos/core", - "version": "1.2.0", + "version": "2.0.0", "description": "Type definitions and interfaces for AgentaOS threshold wallet", "license": "Apache-2.0", "author": "AgentaOS ", diff --git a/packages/mpc-wasm/CHANGELOG.md b/packages/mpc-wasm/CHANGELOG.md index a989040..9597e4d 100644 --- a/packages/mpc-wasm/CHANGELOG.md +++ b/packages/mpc-wasm/CHANGELOG.md @@ -1,5 +1,7 @@ # @agentaos/crypto +## 2.0.0 + ## 1.2.0 ## 1.1.1 @@ -10,28 +12,38 @@ ### Major Changes -- [#16](https://github.com/AgentaOS/agentaos/pull/16) [`beb6eea`](https://github.com/AgentaOS/agentaos/commit/beb6eeaa1d0a0cfa8df5d42b511b305913e0ec1c) Thanks [@PancheI](https://github.com/PancheI)! - Device-code CLI login, MCP payment tools, CLI restructure +- [#16](https://github.com/AgentaOS/agentaos/pull/16) + [`beb6eea`](https://github.com/AgentaOS/agentaos/commit/beb6eeaa1d0a0cfa8df5d42b511b305913e0ec1c) + Thanks [@PancheI](https://github.com/PancheI)! - Device-code CLI login, MCP + payment tools, CLI restructure - `agenta login` opens browser for authentication + wallet activation - `agenta pay checkout/get/list` — create and manage payment checkouts - - `agenta sub` namespace — all sub-account commands (init, send, balance, policies, etc.) + - `agenta sub` namespace — all sub-account commands (init, send, balance, + policies, etc.) - `agenta status` — full account overview with `--json` mode for AI agents - - Non-interactive `agenta sub init --create/--import` with flag-based interface + - Non-interactive `agenta sub init --create/--import` with flag-based + interface - Auto-refresh JWT on expiry or scope upgrade - - MCP: 3 new payment tools (create_checkout, get_checkout, list_checkouts) — 21 total + - MCP: 3 new payment tools (create_checkout, get_checkout, list_checkouts) — + 21 total - All commands support `--json` for machine-readable output ## 0.2.1 ### Patch Changes -- [`38f76db`](https://github.com/AgentaOS/agentaos/commit/38f76db35c018a97ccebeb87b51be1c4e09c2db4) Thanks [@PancheI](https://github.com/PancheI)! - Fix WASM binaries missing from npm tarball +- [`38f76db`](https://github.com/AgentaOS/agentaos/commit/38f76db35c018a97ccebeb87b51be1c4e09c2db4) + Thanks [@PancheI](https://github.com/PancheI)! - Fix WASM binaries missing + from npm tarball ## 0.2.0 ### Minor Changes -- [`f3a9ebd`](https://github.com/AgentaOS/agentaos/commit/f3a9ebde8594353a8cb416e99c481abc2af71959) Thanks [@PancheI](https://github.com/PancheI)! - Initial public release — threshold ECDSA signing for autonomous agents +- [`f3a9ebd`](https://github.com/AgentaOS/agentaos/commit/f3a9ebde8594353a8cb416e99c481abc2af71959) + Thanks [@PancheI](https://github.com/PancheI)! - Initial public release — + threshold ECDSA signing for autonomous agents - 2-of-3 CGGMP24 threshold signing via Rust WASM (full key never exists) - Three signing paths: Signer+Server, User+Server, Signer+User diff --git a/packages/mpc-wasm/package.json b/packages/mpc-wasm/package.json index ab93f15..b6a22e9 100644 --- a/packages/mpc-wasm/package.json +++ b/packages/mpc-wasm/package.json @@ -1,6 +1,6 @@ { "name": "@agentaos/crypto", - "version": "1.2.0", + "version": "2.0.0", "description": "CGGMP24 threshold ECDSA WASM module for AgentaOS", "license": "Apache-2.0", "author": "AgentaOS ", diff --git a/packages/pay/CHANGELOG.md b/packages/pay/CHANGELOG.md index 48860d5..3f3a9f4 100644 --- a/packages/pay/CHANGELOG.md +++ b/packages/pay/CHANGELOG.md @@ -1,13 +1,58 @@ # @agentaos/pay +## 2.0.0 + +### Major Changes + +- [#32](https://github.com/AgentaOS/agentaos/pull/32) + [`a161935`](https://github.com/AgentaOS/agentaos/commit/a16193567d0a9223f5463394149d1d07bea93b83) + Thanks [@PancheI](https://github.com/PancheI)! - 2.0.0 — target the new + server-owned gateway API. + + This release targets the new AgentaOS API generation and requires it. Seller + mode is now DERIVED AND OWNED BY THE SERVER — never a client input: the SDK + sends no seller mode and reads the resolved value off responses for display. + + **BREAKING (for raw-HTTP integrations — existing SDK callers upgrade + cleanly).** `POST /payment-links` no longer accepts `acceptsWallet` / + `acceptsSepa` (or a client-supplied seller mode) — it returns a 400. Seller + mode is derived from the account (Merchant of Record once the business is + verified, otherwise on-chain to the wallet on file). The published SDK never + sent those fields, so SDK/CLI callers are unaffected on the wire; the major + bump reflects that 2.x targets the new API. + + **New surface** + + - Subscription payment links: `type: 'one_time' | 'subscription'` + + `billingInterval` on `paymentLinks.create` (subscriptions require a + verified/MoR account). + - `dueDate` on `checkouts.create` for invoice-authored sessions. + - Response fields: `sellerMode`, `type`, `billingInterval` on `PaymentLink`; + `sellerMode`, `invoiceId`, `invoiceNumber` on `Checkout`. + + **Management / read surface** (mirrors the dashboard — subscriptions are + created by buyers on the hosted checkout, never by the SDK): + + - `subscriptions.list()` and `subscriptions.cancel(id, { atPeriodEnd })` + (defaults to cancel-at-period-end; no refund). + - `customers.list()`. + - `invoices.getReceipt(id)` (receipt PDF) and `invoices.sendReceipt(id)`. + - CLI parity: `agenta subscriptions list|cancel`, `agenta customers list`. + ## 1.0.1 ### Patch Changes -- [#16](https://github.com/AgentaOS/agentaos/pull/16) [`beb6eea`](https://github.com/AgentaOS/agentaos/commit/beb6eeaa1d0a0cfa8df5d42b511b305913e0ec1c) Thanks [@PancheI](https://github.com/PancheI)! - Add supportedNetworks to CreateCheckoutParams, dual auth (JWT + API key) support +- [#16](https://github.com/AgentaOS/agentaos/pull/16) + [`beb6eea`](https://github.com/AgentaOS/agentaos/commit/beb6eeaa1d0a0cfa8df5d42b511b305913e0ec1c) + Thanks [@PancheI](https://github.com/PancheI)! - Add supportedNetworks to + CreateCheckoutParams, dual auth (JWT + API key) support ## 1.0.0 ### Major Changes -- [#9](https://github.com/AgentaOS/agentaos/pull/9) [`cb90955`](https://github.com/AgentaOS/agentaos/commit/cb90955c60192b6bd3ed7c1f70563f3c6baafcb9) Thanks [@PancheI](https://github.com/PancheI)! - Accept regulated stablecoin payments programmatically +- [#9](https://github.com/AgentaOS/agentaos/pull/9) + [`cb90955`](https://github.com/AgentaOS/agentaos/commit/cb90955c60192b6bd3ed7c1f70563f3c6baafcb9) + Thanks [@PancheI](https://github.com/PancheI)! - Accept regulated stablecoin + payments programmatically diff --git a/packages/pay/README.md b/packages/pay/README.md index 153fb74..06e7eaf 100644 --- a/packages/pay/README.md +++ b/packages/pay/README.md @@ -67,6 +67,7 @@ const checkout = await agentaos.checkouts.create({ webhookUrl: 'https://shop.com/webhooks', // Server notification on payment (HTTPS) expiresIn: 1800, // Seconds until expiry (300-86400, default 1800) taxRateId: 'uuid', // Pre-created tax rate UUID + dueDate: '2026-09-30', // Invoice due date (YYYY-MM-DD), presentation only // --- Optional: pre-populate buyer info --- buyerEmail: 'john@example.com', @@ -90,8 +91,11 @@ const checkout = await agentaos.checkouts.create({ | `checkoutUrl` | `string` | URL to send your human customer to | | `x402Url` | `string` | x402 protocol URL for AI agent payments | | `status` | `'open' \| 'completed' \| 'expired' \| 'cancelled'` | Current status | +| `sellerMode` | `'mor' \| 'crypto'` | How this session settles | | `amountOverride` | `number \| null` | Amount for this session | | `currency` | `string` | Settlement currency | +| `invoiceId` | `string \| null` | Issued invoice UUID (null until an invoice exists) | +| `invoiceNumber` | `string \| null` | Human-readable invoice number | | `expiresAt` | `string` | ISO 8601 expiration time | | `createdAt` | `string` | ISO 8601 creation time | @@ -124,13 +128,15 @@ await agentaos.checkouts.cancel('mZrESFyR7RC9RPsJfZCVkg'); Reusable payment templates. Share the `checkoutUrl` — each visitor gets a new session. +> **Seller mode is derived from your account — it is not a parameter.** Once your business is verified you accept card + bank via Merchant of Record; otherwise payments settle on-chain to the wallet on file. You never pass it; the server resolves it and returns it as `sellerMode` on the response (a `checkouts.create` with `linkId` inherits the link's mode). + ### `paymentLinks.create(params)` ```typescript const link = await agentaos.paymentLinks.create({ amount: 29.99, currency: 'EUR', - description: 'Monthly subscription', + description: 'Pro plan', successUrl: 'https://shop.com/success', cancelUrl: 'https://shop.com/cancel', webhookUrl: 'https://shop.com/webhooks', @@ -147,6 +153,18 @@ console.log(link.checkoutUrl); // → https://app.agentaos.ai/pay/7rr6S9ml4BMp829wV5WeAA ``` +**Recurring (subscription) link** — set `type: 'subscription'` and a `billingInterval` (requires a verified account, since subscriptions bill card/bank via Merchant of Record): + +```typescript +const subscription = await agentaos.paymentLinks.create({ + amount: 29.99, + currency: 'EUR', + description: 'Pro plan — monthly', + type: 'subscription', + billingInterval: 'month', // 'month' | 'year' — REQUIRED for subscriptions +}); +``` + **Response:** | Field | Type | Description | @@ -156,6 +174,9 @@ console.log(link.checkoutUrl); | `amount` | `number` | Payment amount | | `currency` | `string` | Settlement currency | | `status` | `'active' \| 'cancelled'` | Link status | +| `sellerMode` | `'mor' \| 'crypto'` | How this link settles | +| `type` | `'one_time' \| 'subscription'` | Link type | +| `billingInterval` | `'month' \| 'year' \| null` | Cadence for subscriptions; null for one-time | | `paymentCount` | `number` | Times this link has been paid | | `createdAt` | `string` | ISO 8601 | @@ -179,6 +200,90 @@ await agentaos.paymentLinks.cancel('uuid'); --- +## Subscriptions + +Read + manage subscriptions. Subscriptions are **created by buyers** on the hosted checkout (paying a payment link with `type: 'subscription'`) — this resource is the merchant-side management surface (list, cancel), mirroring the dashboard. There is no `create` here by design. + +### `subscriptions.list(params?)` + +Paginated — returns `{ items, total, hasMore }` (`total` is the full count, `hasMore` tells you whether another page remains). + +```typescript +const page = await agentaos.subscriptions.list({ + limit: 20, // 1-100, default 20 + offset: 0, +}); +console.log(page.total, page.hasMore); +``` + +**Each item in `page.items`:** + +| Field | Type | Description | +|-------|------|-------------| +| `id` | `string` | Subscription UUID | +| `customerEmail` | `string \| null` | Subscriber email | +| `customerName` | `string \| null` | Subscriber name | +| `planName` | `string \| null` | The plan (subscription payment link) name or description | +| `billingInterval` | `'month' \| 'year' \| null` | Billing cadence | +| `status` | `'incomplete' \| 'incomplete_expired' \| 'trialing' \| 'active' \| 'past_due' \| 'canceled' \| 'unpaid' \| 'paused'` | Current status | +| `unitAmountMinor` | `number` | Per-cycle amount in integer minor units (e.g. `1999` = €19.99) | +| `currency` | `string` | Settlement currency | +| `currentPeriodEnd` | `string \| null` | ISO 8601 end of the current paid period; null before the first cycle books | +| `stripeSubscriptionId` | `string \| null` | Underlying Stripe subscription ID | + +### `subscriptions.cancel(id, params?)` + +Defaults to cancel-at-period-end — the subscriber keeps the current paid period, no refund. Pass `{ atPeriodEnd: false }` to cancel immediately. Idempotent on an already-canceled subscription. + +```typescript +// Cancel at period end (default) — subscriber keeps access until currentPeriodEnd +await agentaos.subscriptions.cancel('uuid'); + +// Cancel immediately — access revoked now, no refund +await agentaos.subscriptions.cancel('uuid', { atPeriodEnd: false }); +``` + +**Response:** + +| Field | Type | Description | +|-------|------|-------------| +| `status` | `SubscriptionStatus` | Status after the cancellation | +| `currentPeriodEnd` | `string \| null` | ISO 8601 end of the current paid period | +| `cancelAtPeriodEnd` | `boolean` | Whether the subscription is scheduled to cancel at period end | +| `effectiveCancelDate` | `string \| null` | ISO 8601 date the cancellation takes effect | + +--- + +## Customers + +Read the customers who have paid you (mirrors the dashboard Customers list). + +### `customers.list(params?)` + +Paginated — returns `{ items, total, hasMore }` (`total` is the full count, `hasMore` tells you whether another page remains). + +```typescript +const page = await agentaos.customers.list({ + limit: 20, // 1-100, default 20 + offset: 0, +}); +console.log(page.total, page.hasMore); +``` + +**Each item in `page.items`:** + +| Field | Type | Description | +|-------|------|-------------| +| `id` | `string` | Customer UUID | +| `email` | `string` | Customer email | +| `name` | `string \| null` | Customer name | +| `country` | `string \| null` | ISO 3166-1 alpha-2 country code | +| `vatNumber` | `string \| null` | VAT number on file | +| `stripeCustomerId` | `string \| null` | Underlying Stripe customer ID | +| `createdAt` | `string` | ISO 8601 | + +--- + ## Transactions Unified ledger of all confirmed inbound (received) and outbound (sent) payments. @@ -285,6 +390,24 @@ const csv = await agentaos.invoices.exportCsv({ fs.writeFileSync('invoices.csv', csv); ``` +### `invoices.getReceipt(id)` + +Download the receipt PDF for a paid invoice. Falls back to the invoice PDF for invoices issued before receipts existed. + +```typescript +const receipt = await agentaos.invoices.getReceipt('uuid'); +fs.writeFileSync('receipt.pdf', receipt); +``` + +### `invoices.sendReceipt(id)` + +Re-send the receipt email to the buyer on file. Paid invoices only. + +```typescript +const result = await agentaos.invoices.sendReceipt('uuid'); +console.log(result.sentTo); // buyer email the receipt was sent to +``` + --- ## Webhooks diff --git a/packages/pay/package.json b/packages/pay/package.json index 02ef4e2..4df9149 100644 --- a/packages/pay/package.json +++ b/packages/pay/package.json @@ -1,6 +1,6 @@ { "name": "@agentaos/pay", - "version": "1.0.1", + "version": "2.0.0", "description": "AgentaOS Payment SDK — accept regulated stablecoin payments programmatically", "license": "Apache-2.0", "author": "AgentaOS ", @@ -29,7 +29,7 @@ "@types/node": "^20.0.0" }, "scripts": { - "build": "tsc", + "build": "tsc -p tsconfig.build.json", "lint": "biome check src/", "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit", diff --git a/packages/pay/src/client.test.ts b/packages/pay/src/client.test.ts new file mode 100644 index 0000000..7df9f46 --- /dev/null +++ b/packages/pay/src/client.test.ts @@ -0,0 +1,122 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { AgentaOS } from './client.js'; +import { CheckoutsResource } from './resources/checkouts.js'; +import { InvoicesResource } from './resources/invoices.js'; +import { PaymentLinksResource } from './resources/payment-links.js'; +import { TransactionsResource } from './resources/transactions.js'; +import { WebhooksResource } from './resources/webhooks.js'; + +interface FetchCall { + url: string; + headers: Record; +} + +/** Capture the url + headers of the first request so auth mode / baseUrl are provable. */ +function captureCall(): FetchCall[] { + const calls: FetchCall[] = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string, init?: RequestInit) => { + calls.push({ url: String(url), headers: (init?.headers ?? {}) as Record }); + return new Response(JSON.stringify({ id: 'x' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }), + ); + return calls; +} + +afterEach(() => vi.unstubAllGlobals()); + +describe('auth mode detection', () => { + it('sends x-api-key for an sk_test_ key', async () => { + const calls = captureCall(); + await new AgentaOS('sk_test_abc123').invoices.retrieve('i1'); + expect(calls[0]?.headers['x-api-key']).toBe('sk_test_abc123'); + expect(calls[0]?.headers.authorization).toBeUndefined(); + }); + + it('sends x-api-key for an sk_live_ key', async () => { + const calls = captureCall(); + await new AgentaOS('sk_live_prod999').invoices.retrieve('i1'); + expect(calls[0]?.headers['x-api-key']).toBe('sk_live_prod999'); + expect(calls[0]?.headers.authorization).toBeUndefined(); + }); + + it('sends authorization Bearer for a 3-part JWT', async () => { + const calls = captureCall(); + const jwt = 'header.payload.signature'; + await new AgentaOS(jwt).invoices.retrieve('i1'); + expect(calls[0]?.headers.authorization).toBe(`Bearer ${jwt}`); + expect(calls[0]?.headers['x-api-key']).toBeUndefined(); + }); +}); + +describe('invalid key format', () => { + it('throws for a key that is neither sk_ nor a JWT', () => { + expect(() => new AgentaOS('nonsense')).toThrow('Invalid API key format'); + }); + + it('throws for a 2-part dotted token (not a valid JWT)', () => { + expect(() => new AgentaOS('header.payload')).toThrow('Invalid API key format'); + }); + + it('throws for a 4-part dotted token (not a valid JWT)', () => { + expect(() => new AgentaOS('a.b.c.d')).toThrow('Invalid API key format'); + }); +}); + +describe('browser guard', () => { + it('throws when both window and document exist', () => { + vi.stubGlobal('window', {}); + vi.stubGlobal('document', {}); + expect(() => new AgentaOS('sk_test_abc')).toThrow('server-side SDK'); + }); + + it('does not trip when only window exists (needs both)', () => { + vi.stubGlobal('window', {}); + expect(() => new AgentaOS('sk_test_abc')).not.toThrow(); + }); +}); + +describe('baseUrl', () => { + it('defaults to https://api.agentaos.ai', async () => { + const calls = captureCall(); + await new AgentaOS('sk_test_abc').invoices.retrieve('i1'); + expect(new URL(calls[0]?.url ?? '').origin).toBe('https://api.agentaos.ai'); + }); + + it('honors a baseUrl override', async () => { + const calls = captureCall(); + await new AgentaOS('sk_test_abc', { baseUrl: 'https://staging.example.com' }).invoices.retrieve( + 'i1', + ); + expect(new URL(calls[0]?.url ?? '').origin).toBe('https://staging.example.com'); + }); +}); + +describe('resource wiring', () => { + const client = new AgentaOS('sk_test_abc'); + + it('wires the checkouts resource', () => { + expect(client.checkouts).toBeInstanceOf(CheckoutsResource); + }); + + it('wires the paymentLinks resource', () => { + expect(client.paymentLinks).toBeInstanceOf(PaymentLinksResource); + }); + + it('wires the transactions resource', () => { + expect(client.transactions).toBeInstanceOf(TransactionsResource); + }); + + it('wires the invoices resource', () => { + expect(client.invoices).toBeInstanceOf(InvoicesResource); + }); + + it('wires the webhooks resource', () => { + expect(client.webhooks).toBeInstanceOf(WebhooksResource); + }); +}); diff --git a/packages/pay/src/client.ts b/packages/pay/src/client.ts index 2f336f6..de759c6 100644 --- a/packages/pay/src/client.ts +++ b/packages/pay/src/client.ts @@ -1,6 +1,8 @@ import { CheckoutsResource } from './resources/checkouts.js'; +import { CustomersResource } from './resources/customers.js'; import { InvoicesResource } from './resources/invoices.js'; import { PaymentLinksResource } from './resources/payment-links.js'; +import { SubscriptionsResource } from './resources/subscriptions.js'; import { TransactionsResource } from './resources/transactions.js'; import { WebhooksResource } from './resources/webhooks.js'; import type { AgentaOSOptions } from './types.js'; @@ -14,6 +16,8 @@ export class AgentaOS { readonly paymentLinks: PaymentLinksResource; readonly transactions: TransactionsResource; readonly invoices: InvoicesResource; + readonly subscriptions: SubscriptionsResource; + readonly customers: CustomersResource; readonly webhooks: WebhooksResource; constructor(apiKey: string, options?: AgentaOSOptions) { @@ -50,6 +54,8 @@ export class AgentaOS { this.paymentLinks = new PaymentLinksResource(baseUrl, apiKey, resourceOptions); this.transactions = new TransactionsResource(baseUrl, apiKey, resourceOptions); this.invoices = new InvoicesResource(baseUrl, apiKey, resourceOptions); + this.subscriptions = new SubscriptionsResource(baseUrl, apiKey, resourceOptions); + this.customers = new CustomersResource(baseUrl, apiKey, resourceOptions); this.webhooks = new WebhooksResource(); } } diff --git a/packages/pay/src/index.ts b/packages/pay/src/index.ts index a3a103c..7be31db 100644 --- a/packages/pay/src/index.ts +++ b/packages/pay/src/index.ts @@ -13,6 +13,11 @@ export type { ListTransactionParams, Invoice, ListInvoiceParams, + Subscription, + SubscriptionStatus, + CancelSubscriptionParams, + CancelSubscriptionResult, + Customer, WebhookEvent, CheckoutCompletedData, SendCompletedData, diff --git a/packages/pay/src/resources/checkouts.ts b/packages/pay/src/resources/checkouts.ts index d256054..d108817 100644 --- a/packages/pay/src/resources/checkouts.ts +++ b/packages/pay/src/resources/checkouts.ts @@ -10,6 +10,9 @@ const BASE_PATH = '/api/v1/gateway/sessions'; export class CheckoutsResource extends BaseResource { async create(params: CreateCheckoutParams): Promise { + // Seller mode is NOT a parameter — a link-based session inherits its link's + // mode and a link-less session uses the mode the server derives from the + // merchant's account. The response carries the resolved `sellerMode`. return this.post(BASE_PATH, params); } diff --git a/packages/pay/src/resources/create-request-body.test.ts b/packages/pay/src/resources/create-request-body.test.ts new file mode 100644 index 0000000..b9e8538 --- /dev/null +++ b/packages/pay/src/resources/create-request-body.test.ts @@ -0,0 +1,73 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { AgentaOS } from '../client.js'; + +/** + * Stub global fetch and capture the JSON body the SDK actually puts on the wire. + * Asserting the serialized request (not an internal mock) proves what the server + * would receive. + */ +function captureRequestBodies(): Array> { + const bodies: Array> = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (_url: string, init?: { body?: string }) => { + bodies.push(init?.body ? (JSON.parse(init.body) as Record) : {}); + return new Response(JSON.stringify({ id: 'test', seller_mode: 'crypto' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }), + ); + return bodies; +} + +/** + * Seller mode is a property of the merchant's ACCOUNT (Merchant of Record once + * verified, otherwise on-chain to the wallet on file) and is derived server-side. + * It is not a create parameter, so the SDK must never put it on the wire — sending + * it would let a caller override the account's real mode. + */ +describe('create requests never carry sellerMode', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('paymentLinks.create omits sellerMode', async () => { + const bodies = captureRequestBodies(); + await new AgentaOS('sk_test_x').paymentLinks.create({ amount: 29.99 }); + expect(bodies[0]).not.toHaveProperty('sellerMode'); + }); + + it('checkouts.create (link-less) omits sellerMode', async () => { + const bodies = captureRequestBodies(); + await new AgentaOS('sk_test_x').checkouts.create({ amount: 10 }); + expect(bodies[0]).not.toHaveProperty('sellerMode'); + }); + + it('checkouts.create with a linkId omits sellerMode (inherits the link)', async () => { + const bodies = captureRequestBodies(); + await new AgentaOS('sk_test_x').checkouts.create({ linkId: 'abc123' }); + expect(bodies[0]).not.toHaveProperty('sellerMode'); + }); +}); + +/** The additive new fields ARE forwarded unchanged (camelCase, as the DTO expects). */ +describe('create forwards the new additive fields', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('paymentLinks.create forwards subscription fields', async () => { + const bodies = captureRequestBodies(); + await new AgentaOS('sk_test_x').paymentLinks.create({ + amount: 29.99, + type: 'subscription', + billingInterval: 'month', + }); + expect(bodies[0]?.type).toBe('subscription'); + expect(bodies[0]?.billingInterval).toBe('month'); + }); + + it('checkouts.create forwards dueDate', async () => { + const bodies = captureRequestBodies(); + await new AgentaOS('sk_test_x').checkouts.create({ amount: 10, dueDate: '2026-09-30' }); + expect(bodies[0]?.dueDate).toBe('2026-09-30'); + }); +}); diff --git a/packages/pay/src/resources/customers.ts b/packages/pay/src/resources/customers.ts new file mode 100644 index 0000000..3f7d243 --- /dev/null +++ b/packages/pay/src/resources/customers.ts @@ -0,0 +1,15 @@ +import type { Customer, ListParams, PaginatedList } from '../types.js'; +import { BaseResource } from './base.js'; + +const BASE_PATH = '/api/v1/gateway/customers'; + +/** Read the customers who have paid you (mirrors the dashboard Customers list). */ +export class CustomersResource extends BaseResource { + /** List customers in the current environment (test/live from the API key), paginated. */ + async list(params?: ListParams): Promise> { + return this.get>( + BASE_PATH, + params as Record, + ); + } +} diff --git a/packages/pay/src/resources/invoices.ts b/packages/pay/src/resources/invoices.ts index 9cc872a..e51d613 100644 --- a/packages/pay/src/resources/invoices.ts +++ b/packages/pay/src/resources/invoices.ts @@ -16,7 +16,7 @@ export class InvoicesResource extends BaseResource { } async void(id: string): Promise<{ success: boolean }> { - return this.del<{ success: boolean }>(`${BASE_PATH}/${id}`); + return this.post<{ success: boolean }>(`${BASE_PATH}/${id}/void`); } async downloadPdf(id: string): Promise { @@ -37,4 +37,17 @@ export class InvoicesResource extends BaseResource { params as Record, ); } + + /** + * Download the receipt PDF for a paid invoice. Falls back to the invoice PDF + * for invoices issued before receipts existed. + */ + async getReceipt(id: string): Promise { + return this.getRaw(`${BASE_PATH}/${id}/receipt`); + } + + /** Re-send the receipt email to the buyer on file. Paid invoices only. */ + async sendReceipt(id: string): Promise<{ ok: true; sentTo: string }> { + return this.post<{ ok: true; sentTo: string }>(`${BASE_PATH}/${id}/send-receipt`); + } } diff --git a/packages/pay/src/resources/payment-links.ts b/packages/pay/src/resources/payment-links.ts index 902bc82..b21519b 100644 --- a/packages/pay/src/resources/payment-links.ts +++ b/packages/pay/src/resources/payment-links.ts @@ -5,6 +5,9 @@ const BASE_PATH = '/api/v1/gateway/payment-links'; export class PaymentLinksResource extends BaseResource { async create(params: CreatePaymentLinkParams): Promise { + // Seller mode (Merchant of Record vs on-chain crypto) is NOT a parameter — + // the server derives it from the merchant's account. The response carries + // the resolved `sellerMode` for rendering. return this.post(BASE_PATH, params); } diff --git a/packages/pay/src/resources/resources.test.ts b/packages/pay/src/resources/resources.test.ts new file mode 100644 index 0000000..e077bda --- /dev/null +++ b/packages/pay/src/resources/resources.test.ts @@ -0,0 +1,284 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { AgentaOS } from '../client.js'; + +interface FetchCall { + url: string; + method?: string; + body?: string; +} + +/** Capture method + url + body of each call and return a scripted response body. */ +function stubRoutes(responseBody: unknown = { ok: true }): FetchCall[] { + const calls: FetchCall[] = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string, init?: RequestInit) => { + calls.push({ + url: String(url), + method: init?.method, + body: init?.body as string | undefined, + }); + const isText = typeof responseBody === 'string'; + return new Response(isText ? (responseBody as string) : JSON.stringify(responseBody), { + status: 200, + headers: isText ? {} : { 'content-type': 'application/json' }, + }); + }), + ); + return calls; +} + +function client(): AgentaOS { + return new AgentaOS('sk_test_key', { baseUrl: 'https://api.example.com' }); +} + +function pathOf(call: FetchCall | undefined): string { + return new URL(call?.url ?? '').pathname; +} + +afterEach(() => vi.unstubAllGlobals()); + +describe('checkouts', () => { + it('create → POST /api/v1/gateway/sessions', async () => { + const calls = stubRoutes(); + await client().checkouts.create({ amount: 10 }); + expect(calls[0]?.method).toBe('POST'); + expect(pathOf(calls[0])).toBe('/api/v1/gateway/sessions'); + }); + + it('list → GET /api/v1/gateway/sessions with query params', async () => { + const calls = stubRoutes({ items: [], total: 0, has_more: false }); + await client().checkouts.list({ status: 'open', limit: 5, offset: 10 }); + const url = new URL(calls[0]?.url ?? ''); + expect(calls[0]?.method).toBe('GET'); + expect(url.pathname).toBe('/api/v1/gateway/sessions'); + expect(url.searchParams.get('status')).toBe('open'); + expect(url.searchParams.get('limit')).toBe('5'); + expect(url.searchParams.get('offset')).toBe('10'); + }); + + it('retrieve → GET /api/v1/gateway/sessions/:id', async () => { + const calls = stubRoutes(); + await client().checkouts.retrieve('sess_123'); + expect(calls[0]?.method).toBe('GET'); + expect(pathOf(calls[0])).toBe('/api/v1/gateway/sessions/sess_123'); + }); + + it('cancel → POST /api/v1/gateway/sessions/:id/cancel', async () => { + const calls = stubRoutes(); + await client().checkouts.cancel('sess_123'); + expect(calls[0]?.method).toBe('POST'); + expect(pathOf(calls[0])).toBe('/api/v1/gateway/sessions/sess_123/cancel'); + }); + + it('camelizes the create response', async () => { + stubRoutes({ id: 'sess_1', seller_mode: 'mor', payment_link_id: 'pl_1' }); + const checkout = await client().checkouts.create({ amount: 10 }); + expect(checkout).toMatchObject({ sellerMode: 'mor', paymentLinkId: 'pl_1' }); + }); +}); + +describe('paymentLinks', () => { + it('create → POST /api/v1/gateway/payment-links', async () => { + const calls = stubRoutes(); + await client().paymentLinks.create({ amount: 29.99 }); + expect(calls[0]?.method).toBe('POST'); + expect(pathOf(calls[0])).toBe('/api/v1/gateway/payment-links'); + }); + + it('list → GET /api/v1/gateway/payment-links with pagination params', async () => { + const calls = stubRoutes({ items: [], total: 0, has_more: false }); + await client().paymentLinks.list({ limit: 25, offset: 50 }); + const url = new URL(calls[0]?.url ?? ''); + expect(calls[0]?.method).toBe('GET'); + expect(url.pathname).toBe('/api/v1/gateway/payment-links'); + expect(url.searchParams.get('limit')).toBe('25'); + expect(url.searchParams.get('offset')).toBe('50'); + }); + + it('retrieve → GET /api/v1/gateway/payment-links/:id', async () => { + const calls = stubRoutes(); + await client().paymentLinks.retrieve('pl_9'); + expect(calls[0]?.method).toBe('GET'); + expect(pathOf(calls[0])).toBe('/api/v1/gateway/payment-links/pl_9'); + }); + + it('cancel → DELETE /api/v1/gateway/payment-links/:id', async () => { + const calls = stubRoutes(); + await client().paymentLinks.cancel('pl_9'); + expect(calls[0]?.method).toBe('DELETE'); + expect(pathOf(calls[0])).toBe('/api/v1/gateway/payment-links/pl_9'); + }); +}); + +describe('transactions', () => { + it('list → GET /api/v1/gateway/all-transactions with filter params', async () => { + const calls = stubRoutes({ items: [], total: 0, has_more: false }); + await client().transactions.list({ + direction: 'inbound', + from: '2026-01-01', + to: '2026-02-01', + limit: 20, + }); + const url = new URL(calls[0]?.url ?? ''); + expect(calls[0]?.method).toBe('GET'); + expect(url.pathname).toBe('/api/v1/gateway/all-transactions'); + expect(url.searchParams.get('direction')).toBe('inbound'); + expect(url.searchParams.get('from')).toBe('2026-01-01'); + expect(url.searchParams.get('to')).toBe('2026-02-01'); + expect(url.searchParams.get('limit')).toBe('20'); + }); + + it('camelizes list items in the response', async () => { + stubRoutes({ + items: [{ id: 't1', payer_address: '0xabc', settlement_token: 'EURC' }], + total: 1, + has_more: false, + }); + const page = await client().transactions.list(); + expect(page.hasMore).toBe(false); + expect(page.items[0]).toMatchObject({ payerAddress: '0xabc', settlementToken: 'EURC' }); + }); +}); + +describe('invoices', () => { + it('list → GET /api/v1/gateway/invoices with status filter', async () => { + const calls = stubRoutes({ items: [], total: 0, has_more: false }); + await client().invoices.list({ status: 'issued', from: '2026-01-01', to: '2026-02-01' }); + const url = new URL(calls[0]?.url ?? ''); + expect(calls[0]?.method).toBe('GET'); + expect(url.pathname).toBe('/api/v1/gateway/invoices'); + expect(url.searchParams.get('status')).toBe('issued'); + }); + + it('retrieve → GET /api/v1/gateway/invoices/:id', async () => { + const calls = stubRoutes(); + await client().invoices.retrieve('inv_5'); + expect(calls[0]?.method).toBe('GET'); + expect(pathOf(calls[0])).toBe('/api/v1/gateway/invoices/inv_5'); + }); + + it('void → POST /api/v1/gateway/invoices/:id/void', async () => { + const calls = stubRoutes(); + await client().invoices.void('inv_5'); + expect(calls[0]?.method).toBe('POST'); + expect(pathOf(calls[0])).toBe('/api/v1/gateway/invoices/inv_5/void'); + }); + + it('downloadPdf → GET /api/v1/gateway/invoices/:id/pdf returning a Buffer', async () => { + const calls = stubRoutes('%PDF-1.7 bytes'); + const buffer = await client().invoices.downloadPdf('inv_5'); + expect(calls[0]?.method).toBe('GET'); + expect(pathOf(calls[0])).toBe('/api/v1/gateway/invoices/inv_5/pdf'); + expect(Buffer.isBuffer(buffer)).toBe(true); + expect(buffer.toString('utf-8')).toBe('%PDF-1.7 bytes'); + }); + + it('downloadStatement → GET /api/v1/gateway/invoices/statement with from/to', async () => { + const calls = stubRoutes('statement-bytes'); + await client().invoices.downloadStatement({ from: '2026-01-01', to: '2026-03-01' }); + const url = new URL(calls[0]?.url ?? ''); + expect(calls[0]?.method).toBe('GET'); + expect(url.pathname).toBe('/api/v1/gateway/invoices/statement'); + expect(url.searchParams.get('from')).toBe('2026-01-01'); + expect(url.searchParams.get('to')).toBe('2026-03-01'); + }); + + it('exportCsv → GET /api/v1/gateway/invoices/export returning raw text', async () => { + const calls = stubRoutes('number,amount\nINV-1,100'); + const csv = await client().invoices.exportCsv({ status: 'issued' }); + const url = new URL(calls[0]?.url ?? ''); + expect(calls[0]?.method).toBe('GET'); + expect(url.pathname).toBe('/api/v1/gateway/invoices/export'); + expect(url.searchParams.get('status')).toBe('issued'); + expect(csv).toBe('number,amount\nINV-1,100'); + }); + + it('getReceipt → GET /api/v1/gateway/invoices/:id/receipt returning a Buffer', async () => { + const calls = stubRoutes('%PDF-receipt-bytes'); + const buffer = await client().invoices.getReceipt('inv_5'); + expect(calls[0]?.method).toBe('GET'); + expect(pathOf(calls[0])).toBe('/api/v1/gateway/invoices/inv_5/receipt'); + expect(Buffer.isBuffer(buffer)).toBe(true); + expect(buffer.toString('utf-8')).toBe('%PDF-receipt-bytes'); + }); + + it('sendReceipt → POST /api/v1/gateway/invoices/:id/send-receipt (camelized)', async () => { + const calls = stubRoutes({ ok: true, sent_to: 'buyer@example.com' }); + const res = await client().invoices.sendReceipt('inv_5'); + expect(calls[0]?.method).toBe('POST'); + expect(pathOf(calls[0])).toBe('/api/v1/gateway/invoices/inv_5/send-receipt'); + expect(res).toMatchObject({ ok: true, sentTo: 'buyer@example.com' }); + }); +}); + +describe('subscriptions', () => { + it('list → GET /api/v1/gateway/subscriptions with pagination params, returning a camelized envelope', async () => { + const calls = stubRoutes({ + items: [ + { + id: 'sub_1', + customer_email: 'a@b.com', + unit_amount_minor: 1999, + current_period_end: '2026-09-01T00:00:00Z', + stripe_subscription_id: 'sub_x', + }, + ], + total: 1, + has_more: false, + }); + const page = await client().subscriptions.list({ limit: 5, offset: 10 }); + const url = new URL(calls[0]?.url ?? ''); + expect(calls[0]?.method).toBe('GET'); + expect(url.pathname).toBe('/api/v1/gateway/subscriptions'); + expect(url.searchParams.get('limit')).toBe('5'); + expect(url.searchParams.get('offset')).toBe('10'); + expect(page.total).toBe(1); + expect(page.hasMore).toBe(false); + expect(page.items[0]).toMatchObject({ + customerEmail: 'a@b.com', + unitAmountMinor: 1999, + currentPeriodEnd: '2026-09-01T00:00:00Z', + stripeSubscriptionId: 'sub_x', + }); + }); + + it('cancel → POST /api/v1/gateway/subscriptions/:id/cancel with atPeriodEnd:true by default', async () => { + const calls = stubRoutes({ status: 'active', cancel_at_period_end: true }); + const res = await client().subscriptions.cancel('sub_1'); + expect(calls[0]?.method).toBe('POST'); + expect(pathOf(calls[0])).toBe('/api/v1/gateway/subscriptions/sub_1/cancel'); + expect(JSON.parse(calls[0]?.body ?? '{}')).toEqual({ atPeriodEnd: true }); + expect(res).toMatchObject({ cancelAtPeriodEnd: true }); + }); + + it('cancel({ atPeriodEnd: false }) → sends the immediate-cancel body', async () => { + const calls = stubRoutes({ status: 'canceled', cancel_at_period_end: false }); + await client().subscriptions.cancel('sub_1', { atPeriodEnd: false }); + expect(JSON.parse(calls[0]?.body ?? '{}')).toEqual({ atPeriodEnd: false }); + }); +}); + +describe('customers', () => { + it('list → GET /api/v1/gateway/customers with pagination params, returning a camelized envelope', async () => { + const calls = stubRoutes({ + items: [{ id: 'cus_1', email: 'a@b.com', vat_number: 'DE123', stripe_customer_id: 'cus_x' }], + total: 1, + has_more: false, + }); + const page = await client().customers.list({ limit: 5, offset: 10 }); + const url = new URL(calls[0]?.url ?? ''); + expect(calls[0]?.method).toBe('GET'); + expect(url.pathname).toBe('/api/v1/gateway/customers'); + expect(url.searchParams.get('limit')).toBe('5'); + expect(url.searchParams.get('offset')).toBe('10'); + expect(page.total).toBe(1); + expect(page.hasMore).toBe(false); + expect(page.items[0]).toMatchObject({ + email: 'a@b.com', + vatNumber: 'DE123', + stripeCustomerId: 'cus_x', + }); + }); +}); diff --git a/packages/pay/src/resources/subscriptions.ts b/packages/pay/src/resources/subscriptions.ts new file mode 100644 index 0000000..f8e9a5d --- /dev/null +++ b/packages/pay/src/resources/subscriptions.ts @@ -0,0 +1,37 @@ +import type { + CancelSubscriptionParams, + CancelSubscriptionResult, + ListParams, + PaginatedList, + Subscription, +} from '../types.js'; +import { BaseResource } from './base.js'; + +const BASE_PATH = '/api/v1/gateway/subscriptions'; + +/** + * Read + manage subscriptions. Subscriptions are CREATED by buyers on the + * hosted checkout (paying a `type: 'subscription'` payment link) — this + * resource is the merchant-side management surface (list, cancel), mirroring + * the dashboard. There is no `create` here by design. + */ +export class SubscriptionsResource extends BaseResource { + /** List subscriptions in the current environment (test/live from the API key), paginated. */ + async list(params?: ListParams): Promise> { + return this.get>( + BASE_PATH, + params as Record, + ); + } + + /** + * Cancel a subscription. Defaults to cancel-at-period-end (the subscriber + * keeps the current paid period, no refund); pass `{ atPeriodEnd: false }` + * to cancel immediately. Idempotent on an already-canceled subscription. + */ + async cancel(id: string, params?: CancelSubscriptionParams): Promise { + return this.post(`${BASE_PATH}/${id}/cancel`, { + atPeriodEnd: params?.atPeriodEnd ?? true, + }); + } +} diff --git a/packages/pay/src/resources/webhooks.test.ts b/packages/pay/src/resources/webhooks.test.ts new file mode 100644 index 0000000..f7895bd --- /dev/null +++ b/packages/pay/src/resources/webhooks.test.ts @@ -0,0 +1,129 @@ +// @vitest-environment node +import { createHmac } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { WebhookVerificationError } from '../errors.js'; +import { WebhooksResource } from './webhooks.js'; + +const SECRET = 'whsec_test_secret'; + +function nowSec(): number { + return Math.floor(Date.now() / 1000); +} + +/** Produce a Stripe-style `t=,v1=` header over `${ts}.${payload}`. */ +function sign(payload: string, secret: string, timestamp: number): string { + const hmac = createHmac('sha256', secret).update(`${timestamp}.${payload}`).digest('hex'); + return `t=${timestamp},v1=${hmac}`; +} + +const VALID_PAYLOAD = JSON.stringify({ + type: 'checkout.session.completed', + data: { session_id: 's_1', link_id: 'l_1', payer_type: 'agent' }, +}); + +describe('webhooks.verify — valid signatures', () => { + it('verifies a correct signature and returns the camelized event', () => { + const ts = nowSec(); + const signature = sign(VALID_PAYLOAD, SECRET, ts); + const event = new WebhooksResource().verify(VALID_PAYLOAD, signature, SECRET); + expect(event.type).toBe('checkout.session.completed'); + expect(event.data).toMatchObject({ sessionId: 's_1', linkId: 'l_1', payerType: 'agent' }); + }); + + it('accepts a Buffer payload', () => { + const ts = nowSec(); + const signature = sign(VALID_PAYLOAD, SECRET, ts); + const event = new WebhooksResource().verify( + Buffer.from(VALID_PAYLOAD, 'utf-8'), + signature, + SECRET, + ); + expect(event.type).toBe('checkout.session.completed'); + }); + + it('verifies a timestamp still inside the tolerance window', () => { + const ts = nowSec() - 200; + const signature = sign(VALID_PAYLOAD, SECRET, ts); + expect(() => + new WebhooksResource().verify(VALID_PAYLOAD, signature, SECRET, 300), + ).not.toThrow(); + }); +}); + +describe('webhooks.verify — rejects bad signatures', () => { + it('throws when the payload is tampered after signing', () => { + const ts = nowSec(); + const signature = sign(VALID_PAYLOAD, SECRET, ts); + const tampered = VALID_PAYLOAD.replace('s_1', 's_2'); + expect(() => new WebhooksResource().verify(tampered, signature, SECRET)).toThrow( + WebhookVerificationError, + ); + }); + + it('throws when the v1 digest is altered (same length)', () => { + const ts = nowSec(); + const hmac = createHmac('sha256', SECRET).update(`${ts}.${VALID_PAYLOAD}`).digest('hex'); + const flipped = (hmac[0] === '0' ? '1' : '0') + hmac.slice(1); + const signature = `t=${ts},v1=${flipped}`; + expect(() => new WebhooksResource().verify(VALID_PAYLOAD, signature, SECRET)).toThrow( + WebhookVerificationError, + ); + }); + + it('throws when the v1 digest has the wrong length', () => { + const ts = nowSec(); + const signature = `t=${ts},v1=deadbeef`; + expect(() => new WebhooksResource().verify(VALID_PAYLOAD, signature, SECRET)).toThrow( + WebhookVerificationError, + ); + }); + + it('throws when verified with the wrong secret', () => { + const ts = nowSec(); + const signature = sign(VALID_PAYLOAD, SECRET, ts); + expect(() => new WebhooksResource().verify(VALID_PAYLOAD, signature, 'whsec_wrong')).toThrow( + WebhookVerificationError, + ); + }); +}); + +describe('webhooks.verify — format and freshness', () => { + it('throws on a malformed signature header (missing v1)', () => { + const ts = nowSec(); + expect(() => new WebhooksResource().verify(VALID_PAYLOAD, `t=${ts}`, SECRET)).toThrow( + /Invalid webhook signature format/, + ); + }); + + it('throws on a malformed signature header (missing t)', () => { + const hmac = createHmac('sha256', SECRET).update(`x.${VALID_PAYLOAD}`).digest('hex'); + expect(() => new WebhooksResource().verify(VALID_PAYLOAD, `v1=${hmac}`, SECRET)).toThrow( + /Invalid webhook signature format/, + ); + }); + + it('throws when the timestamp is older than the tolerance', () => { + const ts = nowSec() - 400; + const signature = sign(VALID_PAYLOAD, SECRET, ts); + expect(() => new WebhooksResource().verify(VALID_PAYLOAD, signature, SECRET, 300)).toThrow( + /expired/, + ); + }); + + it('throws when the timestamp is in the future', () => { + const ts = nowSec() + 400; + const signature = sign(VALID_PAYLOAD, SECRET, ts); + expect(() => new WebhooksResource().verify(VALID_PAYLOAD, signature, SECRET, 300)).toThrow( + /expired/, + ); + }); + + it('throws when a correctly signed payload is not valid JSON', () => { + const ts = nowSec(); + const notJson = 'this is not json'; + const signature = sign(notJson, SECRET, ts); + expect(() => new WebhooksResource().verify(notJson, signature, SECRET)).toThrow( + /not valid JSON/, + ); + }); +}); diff --git a/packages/pay/src/types.ts b/packages/pay/src/types.ts index 545a435..c840cf9 100644 --- a/packages/pay/src/types.ts +++ b/packages/pay/src/types.ts @@ -83,6 +83,10 @@ export interface CreatePaymentLinkParams { /** UUID of pre-created tax rate */ taxRateId?: string; checkoutFields?: CheckoutField[]; + /** 'one_time' (default) or 'subscription' for recurring billing. */ + type?: 'one_time' | 'subscription'; + /** Billing cadence — REQUIRED when type is 'subscription', omit otherwise. */ + billingInterval?: 'month' | 'year'; } export interface PaymentLink { @@ -92,6 +96,12 @@ export interface PaymentLink { currency: string; description: string | null; status: 'active' | 'cancelled'; + /** Settlement mode: 'mor' (card + bank) or 'crypto' (on-chain to your wallet). */ + sellerMode: 'mor' | 'crypto'; + /** 'one_time' or 'subscription'. */ + type: 'one_time' | 'subscription'; + /** Set only for subscription links; null for one-time links. */ + billingInterval: 'month' | 'year' | null; checkoutUrl: string; metadata: Record; checkoutFields: CheckoutField[]; @@ -142,6 +152,8 @@ export interface CreateCheckoutParams { expiresIn?: number; /** CAIP-2 network IDs (e.g. ['eip155:8453']). Defaults to Base mainnet. */ supportedNetworks?: string[]; + /** Invoice due date (YYYY-MM-DD). Presentation only — stamped on the issued invoice. */ + dueDate?: string; } export interface ListCheckoutParams extends ListParams { @@ -156,11 +168,16 @@ export interface Checkout { checkoutUrl: string; x402Url: string; status: 'open' | 'completed' | 'expired' | 'cancelled'; + /** Settlement mode: 'mor' (card + bank) or 'crypto' (on-chain to your wallet). */ + sellerMode: 'mor' | 'crypto'; amountOverride: number | null; currency: string; metadata: Record; successUrl: string | null; cancelUrl: string | null; + /** Set once an invoice has been issued for this session; null until then. */ + invoiceId: string | null; + invoiceNumber: string | null; expiresAt: string; createdAt: string; updatedAt: string; @@ -244,6 +261,69 @@ export interface Invoice { createdAt: string; } +// --------------------------------------------------------------------------- +// Subscriptions +// --------------------------------------------------------------------------- + +/** Raw Stripe subscription status, mirrored onto the local record by the poll. */ +export type SubscriptionStatus = + | 'incomplete' + | 'incomplete_expired' + | 'trialing' + | 'active' + | 'past_due' + | 'canceled' + | 'unpaid' + | 'paused'; + +export interface Subscription { + id: string; + customerEmail: string | null; + customerName: string | null; + /** The plan (subscription payment-link) name or description. */ + planName: string | null; + billingInterval: 'month' | 'year' | null; + status: SubscriptionStatus; + /** Per-cycle amount in integer minor units (e.g. 1999 = €19.99). */ + unitAmountMinor: number; + currency: string; + /** ISO 8601 end of the current paid period; null before the first cycle books. */ + currentPeriodEnd: string | null; + stripeSubscriptionId: string | null; +} + +export interface CancelSubscriptionParams { + /** + * Cancel at the end of the current paid period (default true) — the + * subscriber keeps what they paid for, no refund. Pass false to cancel + * immediately. + */ + atPeriodEnd?: boolean; +} + +export interface CancelSubscriptionResult { + status: SubscriptionStatus; + currentPeriodEnd: string | null; + cancelAtPeriodEnd: boolean; + /** ISO 8601 date the cancellation takes effect. */ + effectiveCancelDate: string | null; +} + +// --------------------------------------------------------------------------- +// Customers +// --------------------------------------------------------------------------- + +export interface Customer { + id: string; + email: string; + name: string | null; + /** ISO 3166-1 alpha-2 country code. */ + country: string | null; + vatNumber: string | null; + stripeCustomerId: string | null; + createdAt: string; +} + // --------------------------------------------------------------------------- // Webhook Events // --------------------------------------------------------------------------- diff --git a/packages/pay/src/utils/fetch.test.ts b/packages/pay/src/utils/fetch.test.ts new file mode 100644 index 0000000..3a26331 --- /dev/null +++ b/packages/pay/src/utils/fetch.test.ts @@ -0,0 +1,388 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + AgentaOSError, + ApiError, + AuthenticationError, + IdempotencyError, + NotFoundError, + PermissionError, + RateLimitError, + TimeoutError, + ValidationError, +} from '../errors.js'; +import { request, requestRaw, requestText } from './fetch.js'; + +interface FetchCall { + url: string; + method?: string; + headers: Record; + body?: string; +} + +/** + * Stub global fetch with a scripted sequence of responses (or thrown errors), + * one per attempt. Records the exact url/method/headers/body the SDK put on the + * wire so assertions target the real request, never the mock's own behavior. + */ +function scriptFetch(steps: Array<() => Response | Promise>): FetchCall[] { + const calls: FetchCall[] = []; + let i = 0; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string, init?: RequestInit) => { + calls.push({ + url: String(url), + method: init?.method, + headers: (init?.headers ?? {}) as Record, + body: init?.body as string | undefined, + }); + const step = steps[Math.min(i, steps.length - 1)]; + i += 1; + if (!step) throw new Error('scriptFetch called with no scripted step'); + return step(); + }), + ); + return calls; +} + +function jsonResponse( + body: unknown, + init?: { status?: number; headers?: Record }, +): Response { + return new Response(JSON.stringify(body), { + status: init?.status ?? 200, + headers: { 'content-type': 'application/json', ...(init?.headers ?? {}) }, + }); +} + +const ok = () => jsonResponse({ ok: true }); + +interface Options { + baseUrl: string; + apiKey: string; + method: 'GET' | 'POST' | 'DELETE'; + path: string; + query?: Record; + body?: unknown; + timeout: number; + maxRetries: number; + idempotencyKey?: string; + authMode?: 'api-key' | 'jwt'; +} + +function baseOptions(overrides: Partial = {}): Options { + return { + baseUrl: 'https://api.example.com', + apiKey: 'sk_test_key', + method: 'GET', + path: '/api/v1/gateway/invoices', + timeout: 30_000, + maxRetries: 0, + authMode: 'api-key', + ...overrides, + }; +} + +/** + * Drive a request to completion under fake timers so retry sleeps resolve + * instantly. The outcome handler is attached BEFORE timers advance, so a + * rejection surfaced mid-advance is never an unhandled rejection. + */ +async function runUnderFakeTimers(start: () => Promise): Promise { + vi.useFakeTimers(); + try { + const settled = start().then( + (value) => ({ ok: true as const, value }), + (error: unknown) => ({ ok: false as const, error }), + ); + await vi.runAllTimersAsync(); + const outcome = await settled; + if (outcome.ok) return outcome.value; + throw outcome.error; + } finally { + vi.useRealTimers(); + } +} + +afterEach(() => vi.unstubAllGlobals()); + +describe('headers', () => { + it('sends x-api-key when authMode is api-key', async () => { + const calls = scriptFetch([ok]); + await request(baseOptions({ authMode: 'api-key', apiKey: 'sk_test_abc' })); + expect(calls[0]?.headers['x-api-key']).toBe('sk_test_abc'); + expect(calls[0]?.headers.authorization).toBeUndefined(); + }); + + it('sends authorization Bearer when authMode is jwt', async () => { + const calls = scriptFetch([ok]); + await request(baseOptions({ authMode: 'jwt', apiKey: 'a.b.c' })); + expect(calls[0]?.headers.authorization).toBe('Bearer a.b.c'); + expect(calls[0]?.headers['x-api-key']).toBeUndefined(); + }); + + it('always sends accept application/json', async () => { + const calls = scriptFetch([ok]); + await request(baseOptions()); + expect(calls[0]?.headers.accept).toBe('application/json'); + }); + + it('sets content-type application/json on POST with a body', async () => { + const calls = scriptFetch([ok]); + await request(baseOptions({ method: 'POST', body: { amount: 10 } })); + expect(calls[0]?.headers['content-type']).toBe('application/json'); + }); + + it('omits content-type on GET', async () => { + const calls = scriptFetch([ok]); + await request(baseOptions({ method: 'GET' })); + expect(calls[0]?.headers['content-type']).toBeUndefined(); + }); + + it('omits content-type on POST without a body', async () => { + const calls = scriptFetch([ok]); + await request(baseOptions({ method: 'POST' })); + expect(calls[0]?.headers['content-type']).toBeUndefined(); + }); +}); + +describe('idempotency key', () => { + const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + + it('generates a UUID idempotency-key on POST when none is passed', async () => { + const calls = scriptFetch([ok]); + await request(baseOptions({ method: 'POST', body: { amount: 10 } })); + expect(calls[0]?.headers['idempotency-key']).toMatch(UUID); + }); + + it('uses the passed idempotency-key on POST', async () => { + const calls = scriptFetch([ok]); + await request( + baseOptions({ method: 'POST', body: { amount: 10 }, idempotencyKey: 'idem-123' }), + ); + expect(calls[0]?.headers['idempotency-key']).toBe('idem-123'); + }); + + it('sets an idempotency-key on POST even without a body', async () => { + const calls = scriptFetch([ok]); + await request(baseOptions({ method: 'POST' })); + expect(calls[0]?.headers['idempotency-key']).toMatch(UUID); + }); + + it('does not set an idempotency-key on GET', async () => { + const calls = scriptFetch([ok]); + await request(baseOptions({ method: 'GET' })); + expect(calls[0]?.headers['idempotency-key']).toBeUndefined(); + }); +}); + +describe('request body', () => { + it('sends the body as-is in camelCase (never snake-cased)', async () => { + const calls = scriptFetch([ok]); + await request( + baseOptions({ method: 'POST', body: { billingInterval: 'month', taxRateId: 't1' } }), + ); + expect(JSON.parse(calls[0]?.body ?? '{}')).toEqual({ + billingInterval: 'month', + taxRateId: 't1', + }); + }); + + it('sends no body on GET', async () => { + const calls = scriptFetch([ok]); + await request(baseOptions({ method: 'GET' })); + expect(calls[0]?.body).toBeUndefined(); + }); +}); + +describe('query string', () => { + it('appends defined query params and skips undefined ones', async () => { + const calls = scriptFetch([ok]); + await request(baseOptions({ query: { status: 'open', limit: 5, missing: undefined } })); + const url = new URL(calls[0]?.url ?? ''); + expect(url.searchParams.get('status')).toBe('open'); + expect(url.searchParams.get('limit')).toBe('5'); + expect(url.searchParams.has('missing')).toBe(false); + }); +}); + +describe('response transform', () => { + it('camelizes the response body', async () => { + scriptFetch([() => jsonResponse({ seller_mode: 'crypto', created_at: '2026-01-01' })]); + const result = await request<{ sellerMode: string; createdAt: string }>(baseOptions()); + expect(result).toEqual({ sellerMode: 'crypto', createdAt: '2026-01-01' }); + }); +}); + +describe('retries that eventually succeed', () => { + it('retries a 429 with retry-after <= 60 then succeeds', async () => { + const calls = scriptFetch([ + () => jsonResponse({ error: 'slow down' }, { status: 429, headers: { 'retry-after': '1' } }), + () => jsonResponse({ ok: true }), + ]); + const result = await runUnderFakeTimers(() => + request<{ ok: boolean }>(baseOptions({ maxRetries: 2 })), + ); + expect(result).toEqual({ ok: true }); + expect(calls).toHaveLength(2); + }); + + it('retries a 5xx with exponential backoff then succeeds', async () => { + const calls = scriptFetch([ + () => jsonResponse({ error: 'boom' }, { status: 503 }), + () => jsonResponse({ ok: true }), + ]); + const result = await runUnderFakeTimers(() => + request<{ ok: boolean }>(baseOptions({ maxRetries: 2 })), + ); + expect(result).toEqual({ ok: true }); + expect(calls).toHaveLength(2); + }); + + it('retries a network error then succeeds', async () => { + const calls = scriptFetch([ + () => { + throw new Error('ECONNRESET'); + }, + () => jsonResponse({ ok: true }), + ]); + const result = await runUnderFakeTimers(() => + request<{ ok: boolean }>(baseOptions({ maxRetries: 2 })), + ); + expect(result).toEqual({ ok: true }); + expect(calls).toHaveLength(2); + }); +}); + +describe('retries exhausted', () => { + it('throws ApiError after exhausting retries on repeated 5xx', async () => { + scriptFetch([() => jsonResponse({ message: 'still down' }, { status: 500 })]); + await expect( + runUnderFakeTimers(() => request(baseOptions({ maxRetries: 1 }))), + ).rejects.toBeInstanceOf(ApiError); + }); + + it('throws a network AgentaOSError after exhausting retries on repeated network errors', async () => { + scriptFetch([ + () => { + throw new Error('ECONNRESET'); + }, + ]); + await expect( + runUnderFakeTimers(() => request(baseOptions({ maxRetries: 1 }))), + ).rejects.toMatchObject({ code: 'network_error', status: 0 }); + }); +}); + +describe('error mapping by status', () => { + it('maps 400 to ValidationError carrying errors[]', async () => { + scriptFetch([ + () => + jsonResponse( + { message: 'bad input', errors: [{ field: 'amount', message: 'required' }] }, + { status: 400 }, + ), + ]); + const error = await request(baseOptions()).catch((e) => e); + expect(error).toBeInstanceOf(ValidationError); + expect((error as ValidationError).errors).toEqual([{ field: 'amount', message: 'required' }]); + }); + + it('maps 401 to AuthenticationError', async () => { + scriptFetch([() => jsonResponse({ message: 'nope' }, { status: 401 })]); + await expect(request(baseOptions())).rejects.toBeInstanceOf(AuthenticationError); + }); + + it('maps 403 to PermissionError', async () => { + scriptFetch([() => jsonResponse({ message: 'forbidden' }, { status: 403 })]); + await expect(request(baseOptions())).rejects.toBeInstanceOf(PermissionError); + }); + + it('maps 404 to NotFoundError', async () => { + scriptFetch([() => jsonResponse({ message: 'missing' }, { status: 404 })]); + await expect(request(baseOptions())).rejects.toBeInstanceOf(NotFoundError); + }); + + it('maps 409 to IdempotencyError', async () => { + scriptFetch([() => jsonResponse({ message: 'dup' }, { status: 409 })]); + await expect(request(baseOptions())).rejects.toBeInstanceOf(IdempotencyError); + }); + + it('maps 429 (no more retries) to RateLimitError with retryAfter in ms', async () => { + scriptFetch([ + () => + jsonResponse({ message: 'slow down' }, { status: 429, headers: { 'retry-after': '30' } }), + ]); + const error = await request(baseOptions({ maxRetries: 0 })).catch((e) => e); + expect(error).toBeInstanceOf(RateLimitError); + expect((error as RateLimitError).retryAfter).toBe(30_000); + }); + + it('defaults RateLimitError retryAfter to 60000ms when no retry-after header', async () => { + scriptFetch([() => jsonResponse({ message: 'slow down' }, { status: 429 })]); + const error = await request(baseOptions({ maxRetries: 0 })).catch((e) => e); + expect((error as RateLimitError).retryAfter).toBe(60_000); + }); + + it('maps 5xx to ApiError when retries are disabled', async () => { + scriptFetch([() => jsonResponse({ message: 'server' }, { status: 502 })]); + const error = await request(baseOptions({ maxRetries: 0 })).catch((e) => e); + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).status).toBe(502); + }); + + it('maps an unmapped 4xx to a generic AgentaOSError', async () => { + scriptFetch([() => jsonResponse({ message: 'teapot' }, { status: 418 })]); + const error = await request(baseOptions({ maxRetries: 0 })).catch((e) => e); + expect(error).toBeInstanceOf(AgentaOSError); + expect((error as AgentaOSError).code).toBe('unknown_error'); + expect((error as AgentaOSError).status).toBe(418); + }); + + it('maps an AbortError to TimeoutError', async () => { + scriptFetch([ + () => { + throw new DOMException('The operation was aborted', 'AbortError'); + }, + ]); + await expect(request(baseOptions({ maxRetries: 2 }))).rejects.toBeInstanceOf(TimeoutError); + }); + + it('propagates x-request-id onto the mapped error', async () => { + scriptFetch([ + () => + jsonResponse( + { message: 'missing' }, + { status: 404, headers: { 'x-request-id': 'req_42' } }, + ), + ]); + const error = await request(baseOptions()).catch((e) => e); + expect((error as NotFoundError).requestId).toBe('req_42'); + }); +}); + +describe('requestRaw', () => { + it('GETs the path and returns the body as a Buffer', async () => { + const calls = scriptFetch([() => new Response('PDFBYTES', { status: 200 })]); + const buffer = await requestRaw(baseOptions({ path: '/api/v1/gateway/invoices/i1/pdf' })); + expect(calls[0]?.method).toBe('GET'); + expect(new URL(calls[0]?.url ?? '').pathname).toBe('/api/v1/gateway/invoices/i1/pdf'); + expect(Buffer.isBuffer(buffer)).toBe(true); + expect(buffer.toString('utf-8')).toBe('PDFBYTES'); + }); + + it('maps error responses through handleErrorResponse', async () => { + scriptFetch([() => jsonResponse({ message: 'missing' }, { status: 404 })]); + await expect(requestRaw(baseOptions({ path: '/x/pdf' }))).rejects.toBeInstanceOf(NotFoundError); + }); +}); + +describe('requestText', () => { + it('GETs the path and returns the raw text body', async () => { + const calls = scriptFetch([() => new Response('a,b,c\n1,2,3', { status: 200 })]); + const text = await requestText(baseOptions({ path: '/api/v1/gateway/invoices/export' })); + expect(calls[0]?.method).toBe('GET'); + expect(text).toBe('a,b,c\n1,2,3'); + }); +}); diff --git a/packages/pay/src/utils/fetch.ts b/packages/pay/src/utils/fetch.ts index 0506be6..90b5958 100644 --- a/packages/pay/src/utils/fetch.ts +++ b/packages/pay/src/utils/fetch.ts @@ -9,7 +9,7 @@ import { TimeoutError, ValidationError, } from '../errors.js'; -import { camelToSnake, snakeToCamel } from './transform.js'; +import { snakeToCamel } from './transform.js'; interface RequestOptions { baseUrl: string; diff --git a/packages/pay/src/utils/transform.test.ts b/packages/pay/src/utils/transform.test.ts new file mode 100644 index 0000000..8f33c65 --- /dev/null +++ b/packages/pay/src/utils/transform.test.ts @@ -0,0 +1,92 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { camelToSnake, snakeToCamel } from './transform.js'; + +describe('snakeToCamel', () => { + it('camelizes flat snake_case keys', () => { + expect(snakeToCamel({ seller_mode: 'crypto', payment_count: 3 })).toEqual({ + sellerMode: 'crypto', + paymentCount: 3, + }); + }); + + it('camelizes keys of nested objects', () => { + const input = { outer_key: { inner_key: { deep_key: 1 } } }; + expect(snakeToCamel(input)).toEqual({ outerKey: { innerKey: { deepKey: 1 } } }); + }); + + it('camelizes keys inside arrays of objects', () => { + const input = { line_items: [{ unit_price: 100 }, { unit_price: 200 }] }; + expect(snakeToCamel(input)).toEqual({ lineItems: [{ unitPrice: 100 }, { unitPrice: 200 }] }); + }); + + it('passes a Date instance through untouched (same reference)', () => { + const date = new Date('2026-08-05T00:00:00.000Z'); + const result = snakeToCamel({ created_at: date }) as { createdAt: Date }; + expect(result.createdAt).toBe(date); + }); + + it('returns null unchanged', () => { + expect(snakeToCamel(null)).toBeNull(); + }); + + it('returns primitives unchanged', () => { + expect(snakeToCamel('a_string')).toBe('a_string'); + expect(snakeToCamel(42)).toBe(42); + expect(snakeToCamel(true)).toBe(true); + }); + + it('leaves already-camel keys untouched', () => { + expect(snakeToCamel({ alreadyCamel: 1 })).toEqual({ alreadyCamel: 1 }); + }); + + it('does not mutate the input object', () => { + const input = { seller_mode: 'crypto' }; + snakeToCamel(input); + expect(input).toEqual({ seller_mode: 'crypto' }); + }); +}); + +describe('camelToSnake', () => { + it('snake-cases flat camelCase keys', () => { + expect(camelToSnake({ sellerMode: 'crypto', paymentCount: 3 })).toEqual({ + seller_mode: 'crypto', + payment_count: 3, + }); + }); + + it('snake-cases keys of nested objects', () => { + const input = { outerKey: { innerKey: { deepKey: 1 } } }; + expect(camelToSnake(input)).toEqual({ outer_key: { inner_key: { deep_key: 1 } } }); + }); + + it('snake-cases keys inside arrays of objects', () => { + const input = { lineItems: [{ unitPrice: 100 }, { unitPrice: 200 }] }; + expect(camelToSnake(input)).toEqual({ + line_items: [{ unit_price: 100 }, { unit_price: 200 }], + }); + }); + + it('passes a Date instance through untouched (same reference)', () => { + const date = new Date('2026-08-05T00:00:00.000Z'); + const result = camelToSnake({ createdAt: date }) as { created_at: Date }; + expect(result.created_at).toBe(date); + }); + + it('returns null and primitives unchanged', () => { + expect(camelToSnake(null)).toBeNull(); + expect(camelToSnake('billingInterval')).toBe('billingInterval'); + expect(camelToSnake(7)).toBe(7); + }); +}); + +describe('round-trip', () => { + it('snake -> camel -> snake reproduces the original', () => { + const input = { + session_id: 's_1', + nested_obj: { inner_key: 1 }, + arr: [{ item_key: 2 }], + }; + expect(camelToSnake(snakeToCamel(input))).toEqual(input); + }); +}); diff --git a/packages/pay/tsconfig.build.json b/packages/pay/tsconfig.build.json new file mode 100644 index 0000000..45ab7a4 --- /dev/null +++ b/packages/pay/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"] +} diff --git a/packages/schemes/CHANGELOG.md b/packages/schemes/CHANGELOG.md index 594ec7e..7ac1c07 100644 --- a/packages/schemes/CHANGELOG.md +++ b/packages/schemes/CHANGELOG.md @@ -1,10 +1,19 @@ # @agentaos/engine +## 2.0.0 + +### Patch Changes + +- Updated dependencies []: + - @agentaos/core@2.0.0 + - @agentaos/crypto@2.0.0 + ## 1.2.0 ### Patch Changes -- Updated dependencies [[`7affbd4`](https://github.com/AgentaOS/agentaos/commit/7affbd4509e606a08c0910e59f3325ecbe491257)]: +- Updated dependencies + [[`7affbd4`](https://github.com/AgentaOS/agentaos/commit/7affbd4509e606a08c0910e59f3325ecbe491257)]: - @agentaos/core@1.2.0 - @agentaos/crypto@1.2.0 @@ -28,20 +37,27 @@ ### Major Changes -- [#16](https://github.com/AgentaOS/agentaos/pull/16) [`beb6eea`](https://github.com/AgentaOS/agentaos/commit/beb6eeaa1d0a0cfa8df5d42b511b305913e0ec1c) Thanks [@PancheI](https://github.com/PancheI)! - Device-code CLI login, MCP payment tools, CLI restructure +- [#16](https://github.com/AgentaOS/agentaos/pull/16) + [`beb6eea`](https://github.com/AgentaOS/agentaos/commit/beb6eeaa1d0a0cfa8df5d42b511b305913e0ec1c) + Thanks [@PancheI](https://github.com/PancheI)! - Device-code CLI login, MCP + payment tools, CLI restructure - `agenta login` opens browser for authentication + wallet activation - `agenta pay checkout/get/list` — create and manage payment checkouts - - `agenta sub` namespace — all sub-account commands (init, send, balance, policies, etc.) + - `agenta sub` namespace — all sub-account commands (init, send, balance, + policies, etc.) - `agenta status` — full account overview with `--json` mode for AI agents - - Non-interactive `agenta sub init --create/--import` with flag-based interface + - Non-interactive `agenta sub init --create/--import` with flag-based + interface - Auto-refresh JWT on expiry or scope upgrade - - MCP: 3 new payment tools (create_checkout, get_checkout, list_checkouts) — 21 total + - MCP: 3 new payment tools (create_checkout, get_checkout, list_checkouts) — + 21 total - All commands support `--json` for machine-readable output ### Patch Changes -- Updated dependencies [[`beb6eea`](https://github.com/AgentaOS/agentaos/commit/beb6eeaa1d0a0cfa8df5d42b511b305913e0ec1c)]: +- Updated dependencies + [[`beb6eea`](https://github.com/AgentaOS/agentaos/commit/beb6eeaa1d0a0cfa8df5d42b511b305913e0ec1c)]: - @agentaos/core@1.0.0 - @agentaos/crypto@1.0.0 @@ -49,7 +65,8 @@ ### Patch Changes -- Updated dependencies [[`38f76db`](https://github.com/AgentaOS/agentaos/commit/38f76db35c018a97ccebeb87b51be1c4e09c2db4)]: +- Updated dependencies + [[`38f76db`](https://github.com/AgentaOS/agentaos/commit/38f76db35c018a97ccebeb87b51be1c4e09c2db4)]: - @agentaos/crypto@0.2.1 - @agentaos/core@0.2.1 @@ -57,7 +74,9 @@ ### Minor Changes -- [`f3a9ebd`](https://github.com/AgentaOS/agentaos/commit/f3a9ebde8594353a8cb416e99c481abc2af71959) Thanks [@PancheI](https://github.com/PancheI)! - Initial public release — threshold ECDSA signing for autonomous agents +- [`f3a9ebd`](https://github.com/AgentaOS/agentaos/commit/f3a9ebde8594353a8cb416e99c481abc2af71959) + Thanks [@PancheI](https://github.com/PancheI)! - Initial public release — + threshold ECDSA signing for autonomous agents - 2-of-3 CGGMP24 threshold signing via Rust WASM (full key never exists) - Three signing paths: Signer+Server, User+Server, Signer+User @@ -69,6 +88,7 @@ ### Patch Changes -- Updated dependencies [[`f3a9ebd`](https://github.com/AgentaOS/agentaos/commit/f3a9ebde8594353a8cb416e99c481abc2af71959)]: +- Updated dependencies + [[`f3a9ebd`](https://github.com/AgentaOS/agentaos/commit/f3a9ebde8594353a8cb416e99c481abc2af71959)]: - @agentaos/core@0.2.0 - @agentaos/crypto@0.2.0 diff --git a/packages/schemes/package.json b/packages/schemes/package.json index f1a225f..83b010a 100644 --- a/packages/schemes/package.json +++ b/packages/schemes/package.json @@ -1,6 +1,6 @@ { "name": "@agentaos/engine", - "version": "1.2.0", + "version": "2.0.0", "description": "CGGMP24 threshold ECDSA signing scheme (WASM)", "license": "Apache-2.0", "author": "AgentaOS ", @@ -29,7 +29,7 @@ "access": "public" }, "scripts": { - "build": "tsc", + "build": "tsc -p tsconfig.build.json", "lint": "biome check src/", "test": "vitest run --passWithNoTests --exclude='**/*.integration.test.*'", "test:integration": "vitest run --testPathPattern=integration", @@ -37,10 +37,9 @@ "clean": "rm -rf dist" }, "dependencies": { - "@noble/curves": "^2.0.1", "@agentaos/core": "workspace:*", "@agentaos/crypto": "workspace:*", + "@noble/curves": "^2.0.1", "viem": "^2.21.0" - }, - "devDependencies": {} + } } diff --git a/packages/schemes/tsconfig.build.json b/packages/schemes/tsconfig.build.json new file mode 100644 index 0000000..45ab7a4 --- /dev/null +++ b/packages/schemes/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"] +} diff --git a/packages/signer/CHANGELOG.md b/packages/signer/CHANGELOG.md index 61c100d..8a5705d 100644 --- a/packages/signer/CHANGELOG.md +++ b/packages/signer/CHANGELOG.md @@ -1,10 +1,19 @@ # @agentaos/sdk +## 2.0.0 + +### Patch Changes + +- Updated dependencies []: + - @agentaos/core@2.0.0 + - @agentaos/engine@2.0.0 + ## 1.2.0 ### Patch Changes -- Updated dependencies [[`7affbd4`](https://github.com/AgentaOS/agentaos/commit/7affbd4509e606a08c0910e59f3325ecbe491257)]: +- Updated dependencies + [[`7affbd4`](https://github.com/AgentaOS/agentaos/commit/7affbd4509e606a08c0910e59f3325ecbe491257)]: - @agentaos/core@1.2.0 - @agentaos/engine@1.2.0 @@ -28,20 +37,27 @@ ### Major Changes -- [#16](https://github.com/AgentaOS/agentaos/pull/16) [`beb6eea`](https://github.com/AgentaOS/agentaos/commit/beb6eeaa1d0a0cfa8df5d42b511b305913e0ec1c) Thanks [@PancheI](https://github.com/PancheI)! - Device-code CLI login, MCP payment tools, CLI restructure +- [#16](https://github.com/AgentaOS/agentaos/pull/16) + [`beb6eea`](https://github.com/AgentaOS/agentaos/commit/beb6eeaa1d0a0cfa8df5d42b511b305913e0ec1c) + Thanks [@PancheI](https://github.com/PancheI)! - Device-code CLI login, MCP + payment tools, CLI restructure - `agenta login` opens browser for authentication + wallet activation - `agenta pay checkout/get/list` — create and manage payment checkouts - - `agenta sub` namespace — all sub-account commands (init, send, balance, policies, etc.) + - `agenta sub` namespace — all sub-account commands (init, send, balance, + policies, etc.) - `agenta status` — full account overview with `--json` mode for AI agents - - Non-interactive `agenta sub init --create/--import` with flag-based interface + - Non-interactive `agenta sub init --create/--import` with flag-based + interface - Auto-refresh JWT on expiry or scope upgrade - - MCP: 3 new payment tools (create_checkout, get_checkout, list_checkouts) — 21 total + - MCP: 3 new payment tools (create_checkout, get_checkout, list_checkouts) — + 21 total - All commands support `--json` for machine-readable output ### Patch Changes -- Updated dependencies [[`beb6eea`](https://github.com/AgentaOS/agentaos/commit/beb6eeaa1d0a0cfa8df5d42b511b305913e0ec1c)]: +- Updated dependencies + [[`beb6eea`](https://github.com/AgentaOS/agentaos/commit/beb6eeaa1d0a0cfa8df5d42b511b305913e0ec1c)]: - @agentaos/core@1.0.0 - @agentaos/engine@1.0.0 @@ -57,7 +73,9 @@ ### Minor Changes -- [`f3a9ebd`](https://github.com/AgentaOS/agentaos/commit/f3a9ebde8594353a8cb416e99c481abc2af71959) Thanks [@PancheI](https://github.com/PancheI)! - Initial public release — threshold ECDSA signing for autonomous agents +- [`f3a9ebd`](https://github.com/AgentaOS/agentaos/commit/f3a9ebde8594353a8cb416e99c481abc2af71959) + Thanks [@PancheI](https://github.com/PancheI)! - Initial public release — + threshold ECDSA signing for autonomous agents - 2-of-3 CGGMP24 threshold signing via Rust WASM (full key never exists) - Three signing paths: Signer+Server, User+Server, Signer+User @@ -69,6 +87,7 @@ ### Patch Changes -- Updated dependencies [[`f3a9ebd`](https://github.com/AgentaOS/agentaos/commit/f3a9ebde8594353a8cb416e99c481abc2af71959)]: +- Updated dependencies + [[`f3a9ebd`](https://github.com/AgentaOS/agentaos/commit/f3a9ebde8594353a8cb416e99c481abc2af71959)]: - @agentaos/core@0.2.0 - @agentaos/engine@0.2.0 diff --git a/packages/signer/package.json b/packages/signer/package.json index 7204e6e..0a6b8ff 100644 --- a/packages/signer/package.json +++ b/packages/signer/package.json @@ -1,6 +1,6 @@ { "name": "@agentaos/sdk", - "version": "1.2.0", + "version": "2.0.0", "description": "SDK for threshold signing — the key never exists", "license": "Apache-2.0", "author": "AgentaOS ", @@ -29,7 +29,7 @@ "access": "public" }, "scripts": { - "build": "tsc", + "build": "tsc -p tsconfig.build.json", "lint": "biome check src/", "test": "vitest run", "typecheck": "tsc --noEmit", diff --git a/packages/signer/tsconfig.build.json b/packages/signer/tsconfig.build.json new file mode 100644 index 0000000..45ab7a4 --- /dev/null +++ b/packages/signer/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"] +} diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index c8b646f..898c59f 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -1,10 +1,41 @@ # agentaos +## 2.0.0 + +### Major Changes + +- [#32](https://github.com/AgentaOS/agentaos/pull/32) + [`fc672b8`](https://github.com/AgentaOS/agentaos/commit/fc672b802997174b6e79ceed7f4cc77de5dc3e27) + Thanks [@PancheI](https://github.com/PancheI)! - 2.0.0 — unified AgentaOS 2.0. + Add subscription/customer/invoice management to the CLI and MCP server, + mirroring the @agentaos/pay 2.0 management surface + + - `agenta subscriptions list` / `agenta subscriptions cancel ` — list and + cancel subscriptions (cancel at period end by default, `--now` for + immediate) + - `agenta customers list` — list customers who have paid you + - `agenta invoices list` / `agenta invoices receipt ` / + `agenta invoices send-receipt ` — list invoices, download a receipt PDF, + re-send the receipt email + - New MCP tools: `agenta_pay_list_subscriptions`, + `agenta_pay_cancel_subscription`, `agenta_pay_list_customers`, + `agenta_pay_send_receipt` + +### Patch Changes + +- Updated dependencies + [[`a161935`](https://github.com/AgentaOS/agentaos/commit/a16193567d0a9223f5463394149d1d07bea93b83)]: + - @agentaos/pay@2.0.0 + - @agentaos/core@2.0.0 + - @agentaos/engine@2.0.0 + - @agentaos/sdk@2.0.0 + ## 1.2.0 ### Patch Changes -- Updated dependencies [[`7affbd4`](https://github.com/AgentaOS/agentaos/commit/7affbd4509e606a08c0910e59f3325ecbe491257)]: +- Updated dependencies + [[`7affbd4`](https://github.com/AgentaOS/agentaos/commit/7affbd4509e606a08c0910e59f3325ecbe491257)]: - @agentaos/core@1.2.0 - @agentaos/engine@1.2.0 - @agentaos/sdk@1.2.0 @@ -13,10 +44,15 @@ ### Patch Changes -- [#22](https://github.com/AgentaOS/agentaos/pull/22) [`1c3b97c`](https://github.com/AgentaOS/agentaos/commit/1c3b97cab3c6a8c6d2d14f8c78585f62be560959) Thanks [@PancheI](https://github.com/PancheI)! - x402 fetch falls back to local signer config +- [#22](https://github.com/AgentaOS/agentaos/pull/22) + [`1c3b97c`](https://github.com/AgentaOS/agentaos/commit/1c3b97cab3c6a8c6d2d14f8c78585f62be560959) + Thanks [@PancheI](https://github.com/PancheI)! - x402 fetch falls back to + local signer config - - `agenta sub x402 fetch` no longer requires `AGENTA_API_SECRET` env var when the secret is already saved locally from `agenta sub create` - - `SignerManager` checks env vars first (MCP/CI), then falls back to `~/.agenta/signers/` config (CLI) + - `agenta sub x402 fetch` no longer requires `AGENTA_API_SECRET` env var when + the secret is already saved locally from `agenta sub create` + - `SignerManager` checks env vars first (MCP/CI), then falls back to + `~/.agenta/signers/` config (CLI) - Updated dependencies []: - @agentaos/core@1.1.1 @@ -27,7 +63,10 @@ ### Minor Changes -- [#19](https://github.com/AgentaOS/agentaos/pull/19) [`62ad7d8`](https://github.com/AgentaOS/agentaos/commit/62ad7d8e46c760527cd740d31d85a652e4606473) Thanks [@PancheI](https://github.com/PancheI)! - Split init into create/import, add x402 CLI commands, add switch +- [#19](https://github.com/AgentaOS/agentaos/pull/19) + [`62ad7d8`](https://github.com/AgentaOS/agentaos/commit/62ad7d8e46c760527cd740d31d85a652e4606473) + Thanks [@PancheI](https://github.com/PancheI)! - Split init into + create/import, add x402 CLI commands, add switch - `agenta sub create` / `agenta sub import` replace `agenta sub init` - `agenta sub switch` to change active sub-account @@ -46,20 +85,28 @@ ### Major Changes -- [#16](https://github.com/AgentaOS/agentaos/pull/16) [`beb6eea`](https://github.com/AgentaOS/agentaos/commit/beb6eeaa1d0a0cfa8df5d42b511b305913e0ec1c) Thanks [@PancheI](https://github.com/PancheI)! - Device-code CLI login, MCP payment tools, CLI restructure +- [#16](https://github.com/AgentaOS/agentaos/pull/16) + [`beb6eea`](https://github.com/AgentaOS/agentaos/commit/beb6eeaa1d0a0cfa8df5d42b511b305913e0ec1c) + Thanks [@PancheI](https://github.com/PancheI)! - Device-code CLI login, MCP + payment tools, CLI restructure - `agenta login` opens browser for authentication + wallet activation - `agenta pay checkout/get/list` — create and manage payment checkouts - - `agenta sub` namespace — all sub-account commands (init, send, balance, policies, etc.) + - `agenta sub` namespace — all sub-account commands (init, send, balance, + policies, etc.) - `agenta status` — full account overview with `--json` mode for AI agents - - Non-interactive `agenta sub init --create/--import` with flag-based interface + - Non-interactive `agenta sub init --create/--import` with flag-based + interface - Auto-refresh JWT on expiry or scope upgrade - - MCP: 3 new payment tools (create_checkout, get_checkout, list_checkouts) — 21 total + - MCP: 3 new payment tools (create_checkout, get_checkout, list_checkouts) — + 21 total - All commands support `--json` for machine-readable output ### Patch Changes -- Updated dependencies [[`beb6eea`](https://github.com/AgentaOS/agentaos/commit/beb6eeaa1d0a0cfa8df5d42b511b305913e0ec1c), [`beb6eea`](https://github.com/AgentaOS/agentaos/commit/beb6eeaa1d0a0cfa8df5d42b511b305913e0ec1c)]: +- Updated dependencies + [[`beb6eea`](https://github.com/AgentaOS/agentaos/commit/beb6eeaa1d0a0cfa8df5d42b511b305913e0ec1c), + [`beb6eea`](https://github.com/AgentaOS/agentaos/commit/beb6eeaa1d0a0cfa8df5d42b511b305913e0ec1c)]: - @agentaos/core@1.0.0 - @agentaos/engine@1.0.0 - @agentaos/sdk@1.0.0 @@ -78,7 +125,9 @@ ### Minor Changes -- [`f3a9ebd`](https://github.com/AgentaOS/agentaos/commit/f3a9ebde8594353a8cb416e99c481abc2af71959) Thanks [@PancheI](https://github.com/PancheI)! - Initial public release — threshold ECDSA signing for autonomous agents +- [`f3a9ebd`](https://github.com/AgentaOS/agentaos/commit/f3a9ebde8594353a8cb416e99c481abc2af71959) + Thanks [@PancheI](https://github.com/PancheI)! - Initial public release — + threshold ECDSA signing for autonomous agents - 2-of-3 CGGMP24 threshold signing via Rust WASM (full key never exists) - Three signing paths: Signer+Server, User+Server, Signer+User @@ -90,7 +139,8 @@ ### Patch Changes -- Updated dependencies [[`f3a9ebd`](https://github.com/AgentaOS/agentaos/commit/f3a9ebde8594353a8cb416e99c481abc2af71959)]: +- Updated dependencies + [[`f3a9ebd`](https://github.com/AgentaOS/agentaos/commit/f3a9ebde8594353a8cb416e99c481abc2af71959)]: - @agentaos/core@0.2.0 - @agentaos/engine@0.2.0 - @agentaos/sdk@0.2.0 diff --git a/packages/wallet/README.md b/packages/wallet/README.md index 48969d1..d8fdaa8 100644 --- a/packages/wallet/README.md +++ b/packages/wallet/README.md @@ -56,6 +56,12 @@ agenta --help | `agenta pay checkout -a ` | Create a checkout session | | `agenta pay get ` | Get checkout details | | `agenta pay list` | List checkouts | +| `agenta subscriptions list` | List subscriptions | +| `agenta subscriptions cancel ` | Cancel a subscription (at period end; `--now` for immediate) | +| `agenta customers list` | List customers | +| `agenta invoices list` | List invoices | +| `agenta invoices receipt ` | Download the receipt PDF | +| `agenta invoices send-receipt ` | Re-send the receipt email | ### Agent Sub-accounts @@ -128,7 +134,7 @@ OS Keychain (service: agenta): When invoked with no arguments, runs as an MCP server over stdio. This lets AI agents interact with the wallet through the [Model Context Protocol](https://modelcontextprotocol.io/). -### 18 Tools +### 25 Tools | Tool | Description | |------|-------------| @@ -150,6 +156,13 @@ When invoked with no arguments, runs as an MCP server over stdio. This lets AI a | `agenta_x402_check` | Check if a URL requires x402 payment | | `agenta_x402_discover` | Discover x402-protected endpoints | | `agenta_x402_fetch` | Fetch a 402-protected resource with auto-payment | +| `agenta_pay_create_checkout` | Create a checkout session | +| `agenta_pay_get_checkout` | Get checkout session details | +| `agenta_pay_list_checkouts` | List checkout sessions | +| `agenta_pay_list_subscriptions` | List subscriptions | +| `agenta_pay_cancel_subscription` | Cancel a subscription (at period end or immediately) | +| `agenta_pay_list_customers` | List customers who have paid you | +| `agenta_pay_send_receipt` | Re-send a paid invoice's receipt email to the buyer | ### Claude Desktop diff --git a/packages/wallet/package.json b/packages/wallet/package.json index 61cd35e..b85b540 100644 --- a/packages/wallet/package.json +++ b/packages/wallet/package.json @@ -1,6 +1,6 @@ { "name": "agentaos", - "version": "1.2.0", + "version": "2.0.0", "description": "AgentaOS — CLI + MCP server for threshold signing. The key never exists.", "license": "Apache-2.0", "author": "AgentaOS ", @@ -25,7 +25,7 @@ "access": "public" }, "scripts": { - "build": "tsc", + "build": "tsc -p tsconfig.build.json", "dev": "tsx src/index.ts", "lint": "biome check src/", "test": "vitest run --passWithNoTests", diff --git a/packages/wallet/src/__tests__/mcp-server.test.ts b/packages/wallet/src/__tests__/mcp-server.test.ts index 276434b..cf05ac8 100644 --- a/packages/wallet/src/__tests__/mcp-server.test.ts +++ b/packages/wallet/src/__tests__/mcp-server.test.ts @@ -31,6 +31,10 @@ const EXPECTED_TOOLS = [ 'agenta_pay_create_checkout', 'agenta_pay_get_checkout', 'agenta_pay_list_checkouts', + 'agenta_pay_list_subscriptions', + 'agenta_pay_cancel_subscription', + 'agenta_pay_list_customers', + 'agenta_pay_send_receipt', ]; describe('AgentaOS Terminal MCP Server', () => { diff --git a/packages/wallet/src/__tests__/x402-client.test.ts b/packages/wallet/src/__tests__/x402-client.test.ts index 179883a..5e11e40 100644 --- a/packages/wallet/src/__tests__/x402-client.test.ts +++ b/packages/wallet/src/__tests__/x402-client.test.ts @@ -1,24 +1,99 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { checkX402, discoverX402 } from '../lib/x402-client.js'; +// `checkX402` / `discoverX402` call the global `fetch`. We mock it here so these +// are deterministic unit tests. (The old version hit https://httpbin.org live and +// was flaky — 15s timeouts and a different assertion failing on each run.) + +function response( + status: number, + init?: { headers?: Record; body?: string }, +): Response { + return new Response(init?.body ?? '', { status, headers: init?.headers }); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + describe('x402-client', () => { - it('checkX402 returns requires402=false for a normal URL', async () => { - // httpbin returns 200 — no 402 - const result = await checkX402('https://httpbin.org/get'); + it('checkX402 returns requires402=false for a non-402 URL', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => response(200)), + ); + const result = await checkX402('https://example.com/free'); expect(result.requires402).toBe(false); - expect(result.url).toBe('https://httpbin.org/get'); - }, 20_000); + expect(result.url).toBe('https://example.com/free'); + }); it('checkX402 returns requires402=true for a 402 URL', async () => { - // httpbin can return any status code - const result = await checkX402('https://httpbin.org/status/402'); + vi.stubGlobal( + 'fetch', + vi.fn(async () => response(402)), + ); + const result = await checkX402('https://example.com/paid'); expect(result.requires402).toBe(true); - }, 20_000); - - it('discoverX402 returns empty for a non-402 domain', async () => { - const result = await discoverX402('https://httpbin.org'); - // httpbin doesn't serve .well-known/x402, and most paths return 200 - expect(result.domain).toBe('https://httpbin.org'); - expect(Array.isArray(result.endpoints)).toBe(true); - }, 30_000); + }); + + it('checkX402 parses payment requirements from a JSON 402 body', async () => { + const paymentRequired = { + x402Version: 1, + accepts: [{ scheme: 'exact', network: 'eip155:84532', amount: '1000000', asset: '0xUSDC' }], + }; + vi.stubGlobal( + 'fetch', + vi.fn(async () => + response(402, { + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(paymentRequired), + }), + ), + ); + const result = await checkX402('https://example.com/paid'); + expect(result.requires402).toBe(true); + expect(result.paymentRequired?.accepts[0]?.amount).toBe('1000000'); + }); + + it('discoverX402 returns empty when nothing requires payment', async () => { + // .well-known/x402 → 404 (no manifest); every probe → 200 (no 402) + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string | URL) => + String(url).includes('/.well-known/x402') ? response(404) : response(200), + ), + ); + const result = await discoverX402('example.com'); + expect(result.domain).toBe('example.com'); + expect(result.endpoints).toEqual([]); + }); + + it('discoverX402 reads the .well-known/x402 manifest when present', async () => { + const manifest = { + endpoints: [ + { + path: '/api/data', + method: 'GET', + scheme: 'exact', + network: 'eip155:84532', + amount: '1000000', + asset: '0xUSDC', + }, + ], + }; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string | URL) => + String(url).includes('/.well-known/x402') + ? response(200, { + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(manifest), + }) + : response(200), + ), + ); + const result = await discoverX402('https://example.com'); + expect(result.endpoints).toHaveLength(1); + expect(result.endpoints[0]?.path).toBe('/api/data'); + }); }); diff --git a/packages/wallet/src/cli/commands/customers.command.ts b/packages/wallet/src/cli/commands/customers.command.ts new file mode 100644 index 0000000..9e9e63f --- /dev/null +++ b/packages/wallet/src/cli/commands/customers.command.ts @@ -0,0 +1,59 @@ +import chalk from 'chalk'; +import { Command } from 'commander'; +import ora from 'ora'; +import { isJsonMode, outputError } from '../output.js'; +import { dim } from '../theme.js'; +import { requirePayClient } from './pay.command.js'; + +// --------------------------------------------------------------------------- +// agenta customers — read the customers who have paid you (list) +// --------------------------------------------------------------------------- + +export const customersCommand = new Command('customers').description('Customer management (list)'); + +customersCommand + .command('list') + .description('List customers') + .option('--limit ', 'Results per page (default 10)', '10') + .option('--json', 'Output as JSON') + .action(async (opts: { limit: string }) => { + const client = await requirePayClient(); + if (!client) return; + + const json = isJsonMode(); + const spinner = json ? null : ora({ text: 'Fetching customers...', indent: 2 }).start(); + + try { + const data = await client.customers.list({ limit: Number.parseInt(opts.limit, 10) || 10 }); + spinner?.stop(); + + if (json) { + console.log( + JSON.stringify({ total: data.total, hasMore: data.hasMore, items: data.items }), + ); + return; + } + if (!data.items.length) { + console.log(dim('\n No customers found.\n')); + return; + } + console.log(`\n ${chalk.bold(`Customers (${data.total} total)`)}\n`); + for (const c of data.items) { + const name = c.name ?? '—'; + const country = c.country ?? ''; + console.log( + ` ${(c.email ?? '—').padEnd(30)} ${name.padEnd(24)} ${country.padEnd(4)} ${dim(c.id)}`, + ); + } + if (data.hasMore) console.log(dim(`\n ${data.items.length} of ${data.total} shown.`)); + console.log(''); + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + if (spinner) { + spinner.fail(msg); + } else { + outputError(msg); + } + process.exitCode = 1; + } + }); diff --git a/packages/wallet/src/cli/commands/invoices.command.ts b/packages/wallet/src/cli/commands/invoices.command.ts new file mode 100644 index 0000000..5001b90 --- /dev/null +++ b/packages/wallet/src/cli/commands/invoices.command.ts @@ -0,0 +1,127 @@ +import { writeFileSync } from 'node:fs'; +import chalk from 'chalk'; +import { Command } from 'commander'; +import ora from 'ora'; +import { isJsonMode, output, outputError } from '../output.js'; +import { dim } from '../theme.js'; +import { requirePayClient } from './pay.command.js'; + +// --------------------------------------------------------------------------- +// agenta invoices — read invoices and manage receipts (list, receipt, send-receipt) +// --------------------------------------------------------------------------- + +export const invoicesCommand = new Command('invoices').description( + 'Invoice & receipt management (list, receipt, send-receipt)', +); + +invoicesCommand + .command('list') + .description('List invoices') + .option('--limit ', 'Results per page (default 10)', '10') + .option('--json', 'Output as JSON') + .action(async (opts: { limit: string }) => { + const client = await requirePayClient(); + if (!client) return; + + const json = isJsonMode(); + const spinner = json ? null : ora({ text: 'Fetching invoices...', indent: 2 }).start(); + + try { + const data = await client.invoices.list({ limit: Number.parseInt(opts.limit, 10) || 10 }); + spinner?.stop(); + + if (json) { + console.log(JSON.stringify(data)); + return; + } + if (!data.items.length) { + console.log(dim('\n No invoices found.\n')); + return; + } + console.log(`\n ${chalk.bold(`Invoices (${data.total} total)`)}\n`); + for (const inv of data.items) { + console.log( + ` ${inv.status.padEnd(10)} ${inv.invoiceNumber.padEnd(16)} ${String(inv.amount).padEnd(10)} ${inv.currency.padEnd(6)} ${(inv.buyerEmail ?? '—').padEnd(28)} ${dim(inv.id)}`, + ); + } + if (data.hasMore) console.log(dim(`\n ${data.items.length} of ${data.total} shown.`)); + console.log(''); + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + if (spinner) { + spinner.fail(msg); + } else { + outputError(msg); + } + process.exitCode = 1; + } + }); + +// --------------------------------------------------------------------------- +// agenta invoices receipt +// --------------------------------------------------------------------------- + +invoicesCommand + .command('receipt ') + .description('Download the receipt PDF for a paid invoice') + .option('-o, --output ', 'File path to save the PDF to') + .option('--json', 'Output as JSON') + .action(async (id: string, opts: { output?: string }) => { + const client = await requirePayClient(); + if (!client) return; + + const json = isJsonMode(); + const spinner = json ? null : ora({ text: 'Fetching receipt...', indent: 2 }).start(); + const path = opts.output ?? `./receipt-${id}.pdf`; + + try { + const pdf = await client.invoices.getReceipt(id); + writeFileSync(path, pdf); + spinner?.stop(); + + output({ saved: path }); + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + if (spinner) { + spinner.fail(msg); + } else { + outputError(msg); + } + process.exitCode = 1; + } + }); + +// --------------------------------------------------------------------------- +// agenta invoices send-receipt +// --------------------------------------------------------------------------- + +invoicesCommand + .command('send-receipt ') + .description('Re-send the receipt email to the buyer on file') + .option('--json', 'Output as JSON') + .action(async (id: string) => { + const client = await requirePayClient(); + if (!client) return; + + const json = isJsonMode(); + const spinner = json ? null : ora({ text: 'Sending receipt...', indent: 2 }).start(); + + try { + const result = await client.invoices.sendReceipt(id); + spinner?.stop(); + + if (json) { + console.log(JSON.stringify(result)); + return; + } + console.log(`\n Receipt sent to ${chalk.bold(result.sentTo)}\n`); + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + if (spinner) { + spinner.fail(msg); + } else { + outputError(msg); + } + process.exitCode = 1; + } + }); diff --git a/packages/wallet/src/cli/commands/pay.command.ts b/packages/wallet/src/cli/commands/pay.command.ts index df9728d..c753b22 100644 --- a/packages/wallet/src/cli/commands/pay.command.ts +++ b/packages/wallet/src/cli/commands/pay.command.ts @@ -10,7 +10,7 @@ import { brand, dim } from '../theme.js'; // Shared // --------------------------------------------------------------------------- -async function requirePayClient(): Promise { +export async function requirePayClient(): Promise { const session = await ensureSession(); if (!session.ok) { outputError( diff --git a/packages/wallet/src/cli/commands/subscriptions.command.ts b/packages/wallet/src/cli/commands/subscriptions.command.ts new file mode 100644 index 0000000..e9ef2c2 --- /dev/null +++ b/packages/wallet/src/cli/commands/subscriptions.command.ts @@ -0,0 +1,103 @@ +import chalk from 'chalk'; +import { Command } from 'commander'; +import ora from 'ora'; +import { isJsonMode, output, outputError } from '../output.js'; +import { dim } from '../theme.js'; +import { requirePayClient } from './pay.command.js'; + +// --------------------------------------------------------------------------- +// agenta subscriptions — manage recurring subscribers (list, cancel) +// --------------------------------------------------------------------------- + +/** Format integer minor units for display. Platform currencies (EUR/USD) are 2-decimal. */ +function formatAmount(minor: number, currency: string): string { + try { + return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(minor / 100); + } catch { + return `${(minor / 100).toFixed(2)} ${currency}`; + } +} + +export const subscriptionsCommand = new Command('subscriptions').description( + 'Subscription management (list, cancel)', +); + +subscriptionsCommand + .command('list') + .description('List subscriptions') + .option('--limit ', 'Results per page (default 10)', '10') + .option('--json', 'Output as JSON') + .action(async (opts: { limit: string }) => { + const client = await requirePayClient(); + if (!client) return; + + const json = isJsonMode(); + const spinner = json ? null : ora({ text: 'Fetching subscriptions...', indent: 2 }).start(); + + try { + const data = await client.subscriptions.list({ + limit: Number.parseInt(opts.limit, 10) || 10, + }); + spinner?.stop(); + + if (json) { + console.log( + JSON.stringify({ total: data.total, hasMore: data.hasMore, items: data.items }), + ); + return; + } + if (!data.items.length) { + console.log(dim('\n No subscriptions found.\n')); + return; + } + console.log(`\n ${chalk.bold(`Subscriptions (${data.total} total)`)}\n`); + for (const s of data.items) { + const amt = `${formatAmount(s.unitAmountMinor, s.currency)}${s.billingInterval ? `/${s.billingInterval}` : ''}`; + const who = s.customerEmail ?? s.customerName ?? '—'; + console.log(` ${s.status.padEnd(10)} ${amt.padEnd(14)} ${who.padEnd(28)} ${dim(s.id)}`); + } + if (data.hasMore) console.log(dim(`\n ${data.items.length} of ${data.total} shown.`)); + console.log(''); + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + if (spinner) { + spinner.fail(msg); + } else { + outputError(msg); + } + process.exitCode = 1; + } + }); + +subscriptionsCommand + .command('cancel ') + .description('Cancel a subscription (at period end by default)') + .option('--now', 'Cancel immediately instead of at the end of the current period') + .option('--json', 'Output as JSON') + .action(async (id: string, opts: { now?: boolean }) => { + const client = await requirePayClient(); + if (!client) return; + + const json = isJsonMode(); + const spinner = json ? null : ora({ text: 'Cancelling subscription...', indent: 2 }).start(); + + try { + const result = await client.subscriptions.cancel(id, { atPeriodEnd: !opts.now }); + spinner?.stop(); + + output({ + status: result.status, + cancelAtPeriodEnd: result.cancelAtPeriodEnd, + effectiveCancelDate: result.effectiveCancelDate, + currentPeriodEnd: result.currentPeriodEnd, + }); + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + if (spinner) { + spinner.fail(msg); + } else { + outputError(msg); + } + process.exitCode = 1; + } + }); diff --git a/packages/wallet/src/cli/index.ts b/packages/wallet/src/cli/index.ts index bc7c5c6..3e8b35c 100644 --- a/packages/wallet/src/cli/index.ts +++ b/packages/wallet/src/cli/index.ts @@ -10,9 +10,11 @@ import { resumeCommand, } from './commands/admin.command.js'; import { balanceCommand } from './commands/balance.command.js'; +import { customersCommand } from './commands/customers.command.js'; import { deployCommand } from './commands/deploy.command.js'; import { infoCommand } from './commands/info.command.js'; import { createCommand, importCommand } from './commands/init.command.js'; +import { invoicesCommand } from './commands/invoices.command.js'; import { linkCommand } from './commands/link.command.js'; import { loginCommand, logoutCommand } from './commands/login.command.js'; import { networkCommand } from './commands/network.command.js'; @@ -22,6 +24,7 @@ import { receiveCommand } from './commands/receive.command.js'; import { sendCommand } from './commands/send.command.js'; import { signMessageCommand } from './commands/sign.command.js'; import { statusCommand } from './commands/status.command.js'; +import { subscriptionsCommand } from './commands/subscriptions.command.js'; import { switchCommand } from './commands/switch.command.js'; import { x402Command } from './commands/x402.command.js'; import { BRAND_BANNER, dim } from './theme.js'; @@ -74,6 +77,16 @@ ${dim('Payments (accept & track):')} $ agenta pay get Get checkout details $ agenta pay list List your checkouts +${dim('Subscriptions & customers (manage):')} + $ agenta subscriptions list List subscriptions + $ agenta subscriptions cancel Cancel (at period end; --now for immediate) + $ agenta customers list List customers + +${dim('Invoices & receipts:')} + $ agenta invoices list List invoices + $ agenta invoices receipt Download the receipt PDF + $ agenta invoices send-receipt Re-send the receipt email + ${dim('Agent sub-accounts (send & sign):')} $ agenta sub create --name bot1 Create a sub-account $ agenta sub import --name bot1 \\ @@ -102,6 +115,9 @@ ${dim('Docs: https://github.com/AgentaOS/agentaos')} program.addCommand(logoutCommand); program.addCommand(statusCommand); program.addCommand(payCommand); + program.addCommand(subscriptionsCommand); + program.addCommand(customersCommand); + program.addCommand(invoicesCommand); program.addCommand(subCommand); await program.parseAsync(); diff --git a/packages/wallet/src/mcp/index.ts b/packages/wallet/src/mcp/index.ts index 687e87b..de97cac 100644 --- a/packages/wallet/src/mcp/index.ts +++ b/packages/wallet/src/mcp/index.ts @@ -34,9 +34,13 @@ import { registerX402Discover } from './tools/x402-discover.js'; import { registerX402Fetch } from './tools/x402-fetch.js'; // Merchant payment tools (agenta_pay_*) — uses @agentaos/pay SDK +import { registerPayCancelSubscription } from './tools/pay-cancel-subscription.js'; import { registerPayCreateCheckout } from './tools/pay-create-checkout.js'; import { registerPayGetCheckout } from './tools/pay-get-checkout.js'; import { registerPayListCheckouts } from './tools/pay-list-checkouts.js'; +import { registerPayListCustomers } from './tools/pay-list-customers.js'; +import { registerPayListSubscriptions } from './tools/pay-list-subscriptions.js'; +import { registerPaySendReceipt } from './tools/pay-send-receipt.js'; /** * Start the AgentaOS MCP server with all tools. @@ -84,6 +88,10 @@ export async function runMcp() { registerPayCreateCheckout(server); registerPayGetCheckout(server); registerPayListCheckouts(server); + registerPayListSubscriptions(server); + registerPayCancelSubscription(server); + registerPayListCustomers(server); + registerPaySendReceipt(server); // Graceful shutdown — wipe key material const shutdown = () => { diff --git a/packages/wallet/src/mcp/tools/pay-cancel-subscription.ts b/packages/wallet/src/mcp/tools/pay-cancel-subscription.ts new file mode 100644 index 0000000..d4dff88 --- /dev/null +++ b/packages/wallet/src/mcp/tools/pay-cancel-subscription.ts @@ -0,0 +1,39 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { createPayClient, formatPayError } from './pay-utils.js'; + +export function registerPayCancelSubscription(server: McpServer) { + server.registerTool( + 'agenta_pay_cancel_subscription', + { + description: + 'Cancel a subscription. Defaults to cancel-at-period-end (the subscriber keeps the current paid period, no refund); pass atPeriodEnd=false to cancel immediately.', + inputSchema: { + subscriptionId: z.string().describe('The subscription ID to cancel'), + atPeriodEnd: z + .boolean() + .optional() + .describe('Cancel at period end (default true); false = cancel immediately'), + }, + }, + async ({ subscriptionId, atPeriodEnd }) => { + try { + const client = createPayClient(); + const result = await client.subscriptions.cancel(subscriptionId, { + atPeriodEnd: atPeriodEnd ?? true, + }); + + const lines = [ + `Status: ${result.status}`, + `Cancel at period end: ${result.cancelAtPeriodEnd}`, + result.effectiveCancelDate ? `Effective: ${result.effectiveCancelDate}` : null, + result.currentPeriodEnd ? `Current period ends: ${result.currentPeriodEnd}` : null, + ].filter(Boolean); + + return { content: [{ type: 'text' as const, text: lines.join('\n') }] }; + } catch (error) { + return formatPayError(error, 'Failed to cancel subscription'); + } + }, + ); +} diff --git a/packages/wallet/src/mcp/tools/pay-list-customers.ts b/packages/wallet/src/mcp/tools/pay-list-customers.ts new file mode 100644 index 0000000..86488cb --- /dev/null +++ b/packages/wallet/src/mcp/tools/pay-list-customers.ts @@ -0,0 +1,54 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { createPayClient, formatPayError } from './pay-utils.js'; + +export function registerPayListCustomers(server: McpServer) { + server.registerTool( + 'agenta_pay_list_customers', + { + description: 'List the customers who have paid you — email, name, country, and ID.', + inputSchema: { + limit: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe('Results per page (default 10)'), + offset: z.number().int().min(0).optional().describe('Pagination offset'), + }, + }, + async ({ limit, offset }) => { + try { + const client = createPayClient(); + const data = await client.customers.list({ + limit: limit ?? 10, + offset: offset ?? 0, + }); + + if (!data.items.length) { + return { content: [{ type: 'text' as const, text: 'No customers found.' }] }; + } + + const lines = [`Customers (${data.total} total)`, '']; + for (const c of data.items) { + const name = c.name ?? '—'; + const country = c.country ?? '—'; + lines.push(`• ${c.email} — ${name} (${country})`); + lines.push(` ID: ${c.id}`); + lines.push(''); + } + + if (data.hasMore) { + lines.push( + `Showing ${data.items.length} of ${data.total}. Use offset=${(offset ?? 0) + (limit ?? 10)} for more.`, + ); + } + + return { content: [{ type: 'text' as const, text: lines.join('\n') }] }; + } catch (error) { + return formatPayError(error, 'Failed to list customers'); + } + }, + ); +} diff --git a/packages/wallet/src/mcp/tools/pay-list-subscriptions.ts b/packages/wallet/src/mcp/tools/pay-list-subscriptions.ts new file mode 100644 index 0000000..2e24e7e --- /dev/null +++ b/packages/wallet/src/mcp/tools/pay-list-subscriptions.ts @@ -0,0 +1,64 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { createPayClient, formatPayError } from './pay-utils.js'; + +/** Format integer minor units for display. Platform currencies (EUR/USD) are 2-decimal. */ +function formatAmount(minor: number, currency: string): string { + try { + return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(minor / 100); + } catch { + return `${(minor / 100).toFixed(2)} ${currency}`; + } +} + +export function registerPayListSubscriptions(server: McpServer) { + server.registerTool( + 'agenta_pay_list_subscriptions', + { + description: 'List subscriptions — status, amount, billing interval, and subscriber.', + inputSchema: { + limit: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe('Results per page (default 10)'), + offset: z.number().int().min(0).optional().describe('Pagination offset'), + }, + }, + async ({ limit, offset }) => { + try { + const client = createPayClient(); + const data = await client.subscriptions.list({ + limit: limit ?? 10, + offset: offset ?? 0, + }); + + if (!data.items.length) { + return { content: [{ type: 'text' as const, text: 'No subscriptions found.' }] }; + } + + const lines = [`Subscriptions (${data.total} total)`, '']; + for (const s of data.items) { + const amt = `${formatAmount(s.unitAmountMinor, s.currency)}${s.billingInterval ? `/${s.billingInterval}` : ''}`; + const who = s.customerEmail ?? s.customerName ?? '—'; + lines.push(`• ${s.status.padEnd(10)} ${amt} — ${who}`); + lines.push(` ID: ${s.id}`); + if (s.currentPeriodEnd) lines.push(` Current period ends: ${s.currentPeriodEnd}`); + lines.push(''); + } + + if (data.hasMore) { + lines.push( + `Showing ${data.items.length} of ${data.total}. Use offset=${(offset ?? 0) + (limit ?? 10)} for more.`, + ); + } + + return { content: [{ type: 'text' as const, text: lines.join('\n') }] }; + } catch (error) { + return formatPayError(error, 'Failed to list subscriptions'); + } + }, + ); +} diff --git a/packages/wallet/src/mcp/tools/pay-send-receipt.ts b/packages/wallet/src/mcp/tools/pay-send-receipt.ts new file mode 100644 index 0000000..e259dc3 --- /dev/null +++ b/packages/wallet/src/mcp/tools/pay-send-receipt.ts @@ -0,0 +1,26 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { createPayClient, formatPayError } from './pay-utils.js'; + +export function registerPaySendReceipt(server: McpServer) { + server.registerTool( + 'agenta_pay_send_receipt', + { + description: + 'Re-send the receipt email for a paid invoice to the buyer on file. Paid invoices only.', + inputSchema: { + invoiceId: z.string().describe('The invoice ID to send the receipt for'), + }, + }, + async ({ invoiceId }) => { + try { + const client = createPayClient(); + const result = await client.invoices.sendReceipt(invoiceId); + + return { content: [{ type: 'text' as const, text: `Receipt sent to ${result.sentTo}` }] }; + } catch (error) { + return formatPayError(error, 'Failed to send receipt'); + } + }, + ); +} diff --git a/packages/wallet/tsconfig.build.json b/packages/wallet/tsconfig.build.json new file mode 100644 index 0000000..45ab7a4 --- /dev/null +++ b/packages/wallet/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"] +}