From a16193567d0a9223f5463394149d1d07bea93b83 Mon Sep 17 00:00:00 2001 From: "Panche I." Date: Wed, 5 Aug 2026 20:38:28 +0200 Subject: [PATCH 01/11] feat(pay): server-owned seller mode + subscriptions/dueDate; new API surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seller mode is derived by the server from the account — never a client input. Remove sellerMode from the create params (CreatePaymentLinkParams, CreateCheckoutParams); the resolved value is still returned on responses (PaymentLink, Checkout) for display. Add the additive new API surface: subscription payment links (type, billingInterval), invoice dueDate on checkouts, and the response fields sellerMode/type/billingInterval (PaymentLink) + sellerMode/invoiceId/ invoiceNumber (Checkout). Changeset: minor. Co-Authored-By: Claude Opus 4.8 --- .changeset/pay-seller-mode-subscriptions.md | 17 +++++ packages/pay/README.md | 23 +++++- packages/pay/src/resources/checkouts.ts | 3 + .../src/resources/create-request-body.test.ts | 73 +++++++++++++++++++ packages/pay/src/resources/payment-links.ts | 3 + packages/pay/src/types.ts | 17 +++++ 6 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 .changeset/pay-seller-mode-subscriptions.md create mode 100644 packages/pay/src/resources/create-request-body.test.ts diff --git a/.changeset/pay-seller-mode-subscriptions.md b/.changeset/pay-seller-mode-subscriptions.md new file mode 100644 index 0000000..2a11d1a --- /dev/null +++ b/.changeset/pay-seller-mode-subscriptions.md @@ -0,0 +1,17 @@ +--- +"@agentaos/pay": minor +--- + +Support the new gateway API surface (additive). + +**New request fields** +- `type: 'one_time' | 'subscription'` + `billingInterval: 'month' | 'year'` on `paymentLinks.create` for recurring links (`billingInterval` is required when `type` is `'subscription'`, forbidden otherwise; subscriptions require a Merchant-of-Record account, i.e. a verified business). +- `dueDate` (YYYY-MM-DD) on `checkouts.create` for invoice-authored sessions. + +**Seller mode is now server-derived — not a parameter.** How a link/checkout settles (`'mor'` = card + bank via Merchant of Record, `'crypto'` = on-chain to your wallet) is derived from your account (Merchant of Record once your business is verified, otherwise on-chain to the wallet on file). It is no longer a create parameter; the resolved value is returned on the response for rendering. + +**New response fields** +- `PaymentLink`: `sellerMode`, `type`, `billingInterval`. +- `Checkout`: `sellerMode`, `invoiceId`, `invoiceNumber`. + +**API migration note for raw-HTTP integrations (not SDK users).** `POST /payment-links` no longer accepts `acceptsWallet` / `acceptsSepa` — the whitelist rejects them with a 400. Seller mode is derived from the account; the `accepts_wallet` / `accepts_sepa` response keys are replaced by `sellerMode`. The published SDK never sent or read those fields. diff --git a/packages/pay/README.md b/packages/pay/README.md index 153fb74..a45e6a2 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 | 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/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/types.ts b/packages/pay/src/types.ts index 545a435..7dbf411 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; From 8e53742d80ad79e7ee85ed14fd955b755fe9e9e2 Mon Sep 17 00:00:00 2001 From: "Panche I." Date: Wed, 5 Aug 2026 22:46:33 +0200 Subject: [PATCH 02/11] chore(pay): release @agentaos/pay as 2.0.0 (new-API-era major) --- .changeset/pay-seller-mode-subscriptions.md | 28 +++++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/.changeset/pay-seller-mode-subscriptions.md b/.changeset/pay-seller-mode-subscriptions.md index 2a11d1a..b8f00a5 100644 --- a/.changeset/pay-seller-mode-subscriptions.md +++ b/.changeset/pay-seller-mode-subscriptions.md @@ -1,17 +1,23 @@ --- -"@agentaos/pay": minor +"@agentaos/pay": major --- -Support the new gateway API surface (additive). +2.0.0 — target the new server-owned gateway API. -**New request fields** -- `type: 'one_time' | 'subscription'` + `billingInterval: 'month' | 'year'` on `paymentLinks.create` for recurring links (`billingInterval` is required when `type` is `'subscription'`, forbidden otherwise; subscriptions require a Merchant-of-Record account, i.e. a verified business). -- `dueDate` (YYYY-MM-DD) on `checkouts.create` for invoice-authored sessions. +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. -**Seller mode is now server-derived — not a parameter.** How a link/checkout settles (`'mor'` = card + bank via Merchant of Record, `'crypto'` = on-chain to your wallet) is derived from your account (Merchant of Record once your business is verified, otherwise on-chain to the wallet on file). It is no longer a create parameter; the resolved value is returned on the response for rendering. +**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 response fields** -- `PaymentLink`: `sellerMode`, `type`, `billingInterval`. -- `Checkout`: `sellerMode`, `invoiceId`, `invoiceNumber`. - -**API migration note for raw-HTTP integrations (not SDK users).** `POST /payment-links` no longer accepts `acceptsWallet` / `acceptsSepa` — the whitelist rejects them with a 400. Seller mode is derived from the account; the `accepts_wallet` / `accepts_sepa` response keys are replaced by `sellerMode`. The published SDK never sent or read those fields. +**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`. From 2dd39ca02c68c4cf893a7a5c5f68de8da56bd8d6 Mon Sep 17 00:00:00 2001 From: "Panche I." Date: Thu, 6 Aug 2026 00:05:09 +0200 Subject: [PATCH 03/11] test(pay): real test suite for the SDK core (was zero tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK shipped untested. Add 91 behavioral tests across the untested core: - fetch.ts: header/auth selection, idempotency key, body-sent-as-is + response camelization, retries (429/5xx/network → success and exhaustion), and the full status->error-class mapping (400 ValidationError w/ errors[], 401/403/404/409, 429 RateLimitError, 5xx ApiError, AbortError->TimeoutError). - client.ts: sk_ vs JWT auth detection, invalid-key + browser guards, baseUrl, resource wiring. - transform.ts: snake<->camel (nested/arrays/Date/round-trip). - resources: every checkouts/paymentLinks/transactions/invoices method asserted against captured METHOD+PATH + camelized returns. - webhooks.verify: REAL node:crypto HMAC + timingSafeEqual (valid/tampered/expired). Only the fetch boundary is mocked. Also drop a dead camelToSnake import in fetch.ts. typecheck 0 · 96 tests green · lint clean · build ok. Co-Authored-By: Claude Opus 4.8 --- packages/pay/src/client.test.ts | 122 ++++++ packages/pay/src/resources/resources.test.ts | 197 ++++++++++ packages/pay/src/resources/webhooks.test.ts | 129 ++++++ packages/pay/src/utils/fetch.test.ts | 388 +++++++++++++++++++ packages/pay/src/utils/fetch.ts | 2 +- packages/pay/src/utils/transform.test.ts | 92 +++++ 6 files changed, 929 insertions(+), 1 deletion(-) create mode 100644 packages/pay/src/client.test.ts create mode 100644 packages/pay/src/resources/resources.test.ts create mode 100644 packages/pay/src/resources/webhooks.test.ts create mode 100644 packages/pay/src/utils/fetch.test.ts create mode 100644 packages/pay/src/utils/transform.test.ts 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/resources/resources.test.ts b/packages/pay/src/resources/resources.test.ts new file mode 100644 index 0000000..85789ff --- /dev/null +++ b/packages/pay/src/resources/resources.test.ts @@ -0,0 +1,197 @@ +// @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 → DELETE /api/v1/gateway/invoices/:id', async () => { + const calls = stubRoutes(); + await client().invoices.void('inv_5'); + expect(calls[0]?.method).toBe('DELETE'); + expect(pathOf(calls[0])).toBe('/api/v1/gateway/invoices/inv_5'); + }); + + 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'); + }); +}); 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/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); + }); +}); From 0dca07b2d9ff55fa7ee4985b92cea40bdd79d209 Mon Sep 17 00:00:00 2001 From: "Panche I." Date: Thu, 6 Aug 2026 00:18:24 +0200 Subject: [PATCH 04/11] =?UTF-8?q?build:=20exclude=20tests=20from=20publish?= =?UTF-8?q?ed=20packages=20(OSS=20=E2=80=94=20don't=20ship=20*.test.js=20t?= =?UTF-8?q?o=20npm)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every published package compiled its src/**/*.test.ts into dist/, so the npm tarball shipped the test files to consumers. Add a per-package tsconfig.build.json (extends tsconfig.json + excludes *.test.ts/*.spec.ts) and point `build` at it, so dist ships only production code. `typecheck` still uses tsconfig.json and keeps type-checking the tests. Applied to the 5 packages that had tests: pay, chains, schemes (@agentaos/engine), signer (@agentaos/sdk), wallet (agentaos CLI). Verified: each rebuilds with 0 test.js in dist; typecheck still covers tests. Co-Authored-By: Claude Opus 4.8 --- packages/chains/package.json | 18 +++++++++++++----- packages/chains/tsconfig.build.json | 4 ++++ packages/pay/package.json | 6 ++++-- packages/pay/tsconfig.build.json | 4 ++++ packages/schemes/package.json | 21 +++++++++++++++------ packages/schemes/tsconfig.build.json | 4 ++++ packages/signer/package.json | 17 ++++++++++++++--- packages/signer/tsconfig.build.json | 4 ++++ packages/wallet/package.json | 19 ++++++++++++++++--- packages/wallet/tsconfig.build.json | 4 ++++ 10 files changed, 82 insertions(+), 19 deletions(-) create mode 100644 packages/chains/tsconfig.build.json create mode 100644 packages/pay/tsconfig.build.json create mode 100644 packages/schemes/tsconfig.build.json create mode 100644 packages/signer/tsconfig.build.json create mode 100644 packages/wallet/tsconfig.build.json diff --git a/packages/chains/package.json b/packages/chains/package.json index dccb1a5..50f6d2a 100644 --- a/packages/chains/package.json +++ b/packages/chains/package.json @@ -11,7 +11,14 @@ }, "homepage": "https://github.com/AgentaOS/agentaos", "bugs": "https://github.com/AgentaOS/agentaos/issues", - "keywords": ["threshold", "wallet", "ethereum", "chain", "viem", "agent"], + "keywords": [ + "threshold", + "wallet", + "ethereum", + "chain", + "viem", + "agent" + ], "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -21,7 +28,9 @@ "import": "./dist/index.js" } }, - "files": ["dist"], + "files": [ + "dist" + ], "engines": { "node": ">=20.0.0" }, @@ -29,7 +38,7 @@ "access": "public" }, "scripts": { - "build": "tsc", + "build": "tsc -p tsconfig.build.json", "lint": "biome check src/", "test": "vitest run", "typecheck": "tsc --noEmit", @@ -38,6 +47,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/pay/package.json b/packages/pay/package.json index 02ef4e2..3bd9584 100644 --- a/packages/pay/package.json +++ b/packages/pay/package.json @@ -18,7 +18,9 @@ "import": "./dist/index.js" } }, - "files": ["dist"], + "files": [ + "dist" + ], "engines": { "node": ">=20.0.0" }, @@ -29,7 +31,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/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/package.json b/packages/schemes/package.json index f1a225f..c81d563 100644 --- a/packages/schemes/package.json +++ b/packages/schemes/package.json @@ -11,7 +11,15 @@ }, "homepage": "https://github.com/AgentaOS/agentaos", "bugs": "https://github.com/AgentaOS/agentaos/issues", - "keywords": ["threshold", "wallet", "mpc", "ecdsa", "cggmp24", "wasm", "signing"], + "keywords": [ + "threshold", + "wallet", + "mpc", + "ecdsa", + "cggmp24", + "wasm", + "signing" + ], "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -21,7 +29,9 @@ "import": "./dist/index.js" } }, - "files": ["dist"], + "files": [ + "dist" + ], "engines": { "node": ">=20.0.0" }, @@ -29,7 +39,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 +47,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/package.json b/packages/signer/package.json index 7204e6e..c972a96 100644 --- a/packages/signer/package.json +++ b/packages/signer/package.json @@ -11,7 +11,16 @@ }, "homepage": "https://github.com/AgentaOS/agentaos", "bugs": "https://github.com/AgentaOS/agentaos/issues", - "keywords": ["threshold", "wallet", "mpc", "ecdsa", "sdk", "signer", "agent", "viem"], + "keywords": [ + "threshold", + "wallet", + "mpc", + "ecdsa", + "sdk", + "signer", + "agent", + "viem" + ], "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -21,7 +30,9 @@ "import": "./dist/index.js" } }, - "files": ["dist"], + "files": [ + "dist" + ], "engines": { "node": ">=20.0.0" }, @@ -29,7 +40,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/package.json b/packages/wallet/package.json index 61cd35e..bdedb25 100644 --- a/packages/wallet/package.json +++ b/packages/wallet/package.json @@ -11,13 +11,26 @@ }, "homepage": "https://github.com/AgentaOS/agentaos", "bugs": "https://github.com/AgentaOS/agentaos/issues", - "keywords": ["mcp", "threshold", "wallet", "mpc", "ethereum", "agent", "signing", "cli", "x402"], + "keywords": [ + "mcp", + "threshold", + "wallet", + "mpc", + "ethereum", + "agent", + "signing", + "cli", + "x402" + ], "type": "module", "bin": { "agentaos": "./dist/index.js", "agenta": "./dist/index.js" }, - "files": ["dist", "!dist/__tests__"], + "files": [ + "dist", + "!dist/__tests__" + ], "engines": { "node": ">=20.0.0" }, @@ -25,7 +38,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/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"] +} From 054c0de699993d1c5261bab5c6b93d0f0269fe9f Mon Sep 17 00:00:00 2001 From: "Panche I." Date: Thu, 6 Aug 2026 02:22:49 +0200 Subject: [PATCH 05/11] feat(pay): subscription + customer management surface & receipt methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the dashboard's merchant management/read API in the SDK, matching the professional MoR-SDK bar (resource.operation, like Creem/Polar/Paddle). Read + manage only — subscriptions are created by buyers on the hosted checkout, never by the SDK. - subscriptions.list() / subscriptions.cancel(id, { atPeriodEnd }) — defaults to cancel-at-period-end (subscriber keeps the paid period, no refund). - customers.list(). - invoices.getReceipt(id) (receipt PDF) / invoices.sendReceipt(id). - Types typed against the live BE contracts (list endpoints return plain arrays, not paginated envelopes; camelCase keys verified). - CLI parity: agenta subscriptions list|cancel, agenta customers list. - Tests (102 pay green) + README + folded into the 2.0.0 changeset. - Independently reviewed (spec + quality), lint-green, live local smoke 7/7. Co-Authored-By: Claude Opus 4.8 --- .changeset/pay-seller-mode-subscriptions.md | 8 ++ packages/pay/README.md | 90 +++++++++++++++++ packages/pay/src/client.ts | 6 ++ packages/pay/src/index.ts | 5 + packages/pay/src/resources/customers.ts | 12 +++ packages/pay/src/resources/invoices.ts | 13 +++ packages/pay/src/resources/resources.test.ts | 73 ++++++++++++++ packages/pay/src/resources/subscriptions.ts | 28 ++++++ packages/pay/src/types.ts | 63 ++++++++++++ .../src/cli/commands/customers.command.ts | 55 +++++++++++ .../wallet/src/cli/commands/pay.command.ts | 2 +- .../src/cli/commands/subscriptions.command.ts | 97 +++++++++++++++++++ packages/wallet/src/cli/index.ts | 9 ++ 13 files changed, 460 insertions(+), 1 deletion(-) create mode 100644 packages/pay/src/resources/customers.ts create mode 100644 packages/pay/src/resources/subscriptions.ts create mode 100644 packages/wallet/src/cli/commands/customers.command.ts create mode 100644 packages/wallet/src/cli/commands/subscriptions.command.ts diff --git a/.changeset/pay-seller-mode-subscriptions.md b/.changeset/pay-seller-mode-subscriptions.md index b8f00a5..844f2ff 100644 --- a/.changeset/pay-seller-mode-subscriptions.md +++ b/.changeset/pay-seller-mode-subscriptions.md @@ -21,3 +21,11 @@ are unaffected on the wire; the major bump reflects that 2.x targets the new API - `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`. diff --git a/packages/pay/README.md b/packages/pay/README.md index a45e6a2..71ab49c 100644 --- a/packages/pay/README.md +++ b/packages/pay/README.md @@ -200,6 +200,78 @@ 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()` + +```typescript +const subscriptions = await agentaos.subscriptions.list(); +``` + +**Response item:** + +| 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()` + +```typescript +const customers = await agentaos.customers.list(); +``` + +**Response item:** + +| 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. @@ -306,6 +378,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/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/customers.ts b/packages/pay/src/resources/customers.ts new file mode 100644 index 0000000..4555c20 --- /dev/null +++ b/packages/pay/src/resources/customers.ts @@ -0,0 +1,12 @@ +import type { Customer } 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 every customer in the current environment (test/live from the API key). */ + async list(): Promise { + return this.get(BASE_PATH); + } +} diff --git a/packages/pay/src/resources/invoices.ts b/packages/pay/src/resources/invoices.ts index 9cc872a..a2933a7 100644 --- a/packages/pay/src/resources/invoices.ts +++ b/packages/pay/src/resources/invoices.ts @@ -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/resources.test.ts b/packages/pay/src/resources/resources.test.ts index 85789ff..b4f9fc9 100644 --- a/packages/pay/src/resources/resources.test.ts +++ b/packages/pay/src/resources/resources.test.ts @@ -194,4 +194,77 @@ describe('invoices', () => { 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 returning a camelized array', async () => { + const calls = stubRoutes([ + { + 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', + }, + ]); + const subs = await client().subscriptions.list(); + expect(calls[0]?.method).toBe('GET'); + expect(pathOf(calls[0])).toBe('/api/v1/gateway/subscriptions'); + expect(Array.isArray(subs)).toBe(true); + expect(subs[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 returning a camelized array', async () => { + const calls = stubRoutes([ + { id: 'cus_1', email: 'a@b.com', vat_number: 'DE123', stripe_customer_id: 'cus_x' }, + ]); + const customers = await client().customers.list(); + expect(calls[0]?.method).toBe('GET'); + expect(pathOf(calls[0])).toBe('/api/v1/gateway/customers'); + expect(Array.isArray(customers)).toBe(true); + expect(customers[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..5c31c26 --- /dev/null +++ b/packages/pay/src/resources/subscriptions.ts @@ -0,0 +1,28 @@ +import type { CancelSubscriptionParams, CancelSubscriptionResult, 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 every subscription in the current environment (test/live from the API key). */ + async list(): Promise { + return this.get(BASE_PATH); + } + + /** + * 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/types.ts b/packages/pay/src/types.ts index 7dbf411..c840cf9 100644 --- a/packages/pay/src/types.ts +++ b/packages/pay/src/types.ts @@ -261,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/wallet/src/cli/commands/customers.command.ts b/packages/wallet/src/cli/commands/customers.command.ts new file mode 100644 index 0000000..efcbf17 --- /dev/null +++ b/packages/wallet/src/cli/commands/customers.command.ts @@ -0,0 +1,55 @@ +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('--json', 'Output as JSON') + .action(async () => { + const client = await requirePayClient(); + if (!client) return; + + const json = isJsonMode(); + const spinner = json ? null : ora({ text: 'Fetching customers...', indent: 2 }).start(); + + try { + const customers = await client.customers.list(); + spinner?.stop(); + + if (json) { + console.log(JSON.stringify(customers)); + return; + } + if (!customers.length) { + console.log(dim('\n No customers found.\n')); + return; + } + console.log(`\n ${chalk.bold(`Customers (${customers.length})`)}\n`); + for (const c of customers) { + const name = c.name ?? '—'; + const country = c.country ?? ''; + console.log( + ` ${(c.email ?? '—').padEnd(30)} ${name.padEnd(24)} ${country.padEnd(4)} ${dim(c.id)}`, + ); + } + 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/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..491fe26 --- /dev/null +++ b/packages/wallet/src/cli/commands/subscriptions.command.ts @@ -0,0 +1,97 @@ +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('--json', 'Output as JSON') + .action(async () => { + const client = await requirePayClient(); + if (!client) return; + + const json = isJsonMode(); + const spinner = json ? null : ora({ text: 'Fetching subscriptions...', indent: 2 }).start(); + + try { + const subs = await client.subscriptions.list(); + spinner?.stop(); + + if (json) { + console.log(JSON.stringify(subs)); + return; + } + if (!subs.length) { + console.log(dim('\n No subscriptions found.\n')); + return; + } + console.log(`\n ${chalk.bold(`Subscriptions (${subs.length})`)}\n`); + for (const s of subs) { + 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)}`); + } + 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..a35ccbe 100644 --- a/packages/wallet/src/cli/index.ts +++ b/packages/wallet/src/cli/index.ts @@ -10,6 +10,7 @@ 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'; @@ -22,6 +23,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 +76,11 @@ ${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('Agent sub-accounts (send & sign):')} $ agenta sub create --name bot1 Create a sub-account $ agenta sub import --name bot1 \\ @@ -102,6 +109,8 @@ ${dim('Docs: https://github.com/AgentaOS/agentaos')} program.addCommand(logoutCommand); program.addCommand(statusCommand); program.addCommand(payCommand); + program.addCommand(subscriptionsCommand); + program.addCommand(customersCommand); program.addCommand(subCommand); await program.parseAsync(); From fc672b802997174b6e79ceed7f4cc77de5dc3e27 Mon Sep 17 00:00:00 2001 From: "Panche I." Date: Thu, 6 Aug 2026 09:28:30 +0200 Subject: [PATCH 06/11] feat(agentaos): mirror the management surface into MCP tools + CLI + docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the CLI and MCP server to parity with @agentaos/pay's management surface. MCP (4 new agenta_pay_* tools): list_subscriptions, cancel_subscription, list_customers, send_receipt. (No get_receipt tool — a binary PDF isn't useful over MCP text; it stays CLI/SDK.) CLI: new 'agenta invoices' group — list, receipt (downloads the PDF), send-receipt . Rounds out subscriptions/customers (already shipped). Docs: wallet + top-level README CLI tables + MCP tool list; corrected the tool count to the real total (25) — the wallet README had also silently omitted the pre-existing pay tools, now backfilled. Changeset for agentaos (minor). typecheck + lint + build + mcp-server test green. Co-Authored-By: Claude Opus 4.8 --- .changeset/cli-mcp-management-surface.md | 10 ++ README.md | 12 +- packages/wallet/README.md | 15 ++- .../wallet/src/__tests__/mcp-server.test.ts | 4 + .../src/cli/commands/invoices.command.ts | 127 ++++++++++++++++++ packages/wallet/src/cli/index.ts | 7 + packages/wallet/src/mcp/index.ts | 8 ++ .../src/mcp/tools/pay-cancel-subscription.ts | 39 ++++++ .../src/mcp/tools/pay-list-customers.ts | 34 +++++ .../src/mcp/tools/pay-list-subscriptions.ts | 44 ++++++ .../wallet/src/mcp/tools/pay-send-receipt.ts | 26 ++++ 11 files changed, 324 insertions(+), 2 deletions(-) create mode 100644 .changeset/cli-mcp-management-surface.md create mode 100644 packages/wallet/src/cli/commands/invoices.command.ts create mode 100644 packages/wallet/src/mcp/tools/pay-cancel-subscription.ts create mode 100644 packages/wallet/src/mcp/tools/pay-list-customers.ts create mode 100644 packages/wallet/src/mcp/tools/pay-list-subscriptions.ts create mode 100644 packages/wallet/src/mcp/tools/pay-send-receipt.ts diff --git a/.changeset/cli-mcp-management-surface.md b/.changeset/cli-mcp-management-surface.md new file mode 100644 index 0000000..526764a --- /dev/null +++ b/.changeset/cli-mcp-management-surface.md @@ -0,0 +1,10 @@ +--- +"agentaos": minor +--- + +Add subscription/customer/invoice management to the CLI and MCP server, mirroring the @agentaos/pay 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` 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/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/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/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/index.ts b/packages/wallet/src/cli/index.ts index a35ccbe..3e8b35c 100644 --- a/packages/wallet/src/cli/index.ts +++ b/packages/wallet/src/cli/index.ts @@ -14,6 +14,7 @@ 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'; @@ -81,6 +82,11 @@ ${dim('Subscriptions & customers (manage):')} $ 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 \\ @@ -111,6 +117,7 @@ ${dim('Docs: https://github.com/AgentaOS/agentaos')} 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..1746e44 --- /dev/null +++ b/packages/wallet/src/mcp/tools/pay-list-customers.ts @@ -0,0 +1,34 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +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.', + }, + async () => { + try { + const client = createPayClient(); + const customers = await client.customers.list(); + + if (!customers.length) { + return { content: [{ type: 'text' as const, text: 'No customers found.' }] }; + } + + const lines = [`Customers (${customers.length})`, '']; + for (const c of customers) { + const name = c.name ?? '—'; + const country = c.country ?? '—'; + lines.push(`• ${c.email} — ${name} (${country})`); + lines.push(` ID: ${c.id}`); + lines.push(''); + } + + 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..e48a286 --- /dev/null +++ b/packages/wallet/src/mcp/tools/pay-list-subscriptions.ts @@ -0,0 +1,44 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +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.', + }, + async () => { + try { + const client = createPayClient(); + const subs = await client.subscriptions.list(); + + if (!subs.length) { + return { content: [{ type: 'text' as const, text: 'No subscriptions found.' }] }; + } + + const lines = [`Subscriptions (${subs.length})`, '']; + for (const s of subs) { + 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(''); + } + + 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'); + } + }, + ); +} From e2245632241900c7e3b0e2dee41c30a6d3a26e99 Mon Sep 17 00:00:00 2001 From: "Panche I." Date: Thu, 6 Aug 2026 10:29:27 +0200 Subject: [PATCH 07/11] feat(pay): paginate subscriptions.list + customers.list (match sibling resources) The BE now paginates these two endpoints, so bring the SDK in line with every other list resource: list(params?: ListParams) -> PaginatedList ({items, total, hasMore}) instead of a bare array. CLI gains --limit + a '(N total)' header and 'X of Y shown' note; MCP tools accept limit/offset. README updated. Tests: 102 pay tests green; CLI/MCP typecheck + build clean. Co-Authored-By: Claude Opus 4.8 --- packages/pay/README.md | 24 ++++++-- packages/pay/src/resources/customers.ts | 11 ++-- packages/pay/src/resources/resources.test.ts | 58 ++++++++++++------- packages/pay/src/resources/subscriptions.ts | 17 ++++-- .../src/cli/commands/customers.command.ts | 16 +++-- .../src/cli/commands/subscriptions.command.ts | 18 ++++-- .../src/mcp/tools/pay-list-customers.ts | 30 ++++++++-- .../src/mcp/tools/pay-list-subscriptions.ts | 30 ++++++++-- 8 files changed, 146 insertions(+), 58 deletions(-) diff --git a/packages/pay/README.md b/packages/pay/README.md index 71ab49c..06e7eaf 100644 --- a/packages/pay/README.md +++ b/packages/pay/README.md @@ -204,13 +204,19 @@ await agentaos.paymentLinks.cancel('uuid'); 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()` +### `subscriptions.list(params?)` + +Paginated — returns `{ items, total, hasMore }` (`total` is the full count, `hasMore` tells you whether another page remains). ```typescript -const subscriptions = await agentaos.subscriptions.list(); +const page = await agentaos.subscriptions.list({ + limit: 20, // 1-100, default 20 + offset: 0, +}); +console.log(page.total, page.hasMore); ``` -**Response item:** +**Each item in `page.items`:** | Field | Type | Description | |-------|------|-------------| @@ -252,13 +258,19 @@ await agentaos.subscriptions.cancel('uuid', { atPeriodEnd: false }); Read the customers who have paid you (mirrors the dashboard Customers list). -### `customers.list()` +### `customers.list(params?)` + +Paginated — returns `{ items, total, hasMore }` (`total` is the full count, `hasMore` tells you whether another page remains). ```typescript -const customers = await agentaos.customers.list(); +const page = await agentaos.customers.list({ + limit: 20, // 1-100, default 20 + offset: 0, +}); +console.log(page.total, page.hasMore); ``` -**Response item:** +**Each item in `page.items`:** | Field | Type | Description | |-------|------|-------------| diff --git a/packages/pay/src/resources/customers.ts b/packages/pay/src/resources/customers.ts index 4555c20..3f7d243 100644 --- a/packages/pay/src/resources/customers.ts +++ b/packages/pay/src/resources/customers.ts @@ -1,12 +1,15 @@ -import type { Customer } from '../types.js'; +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 every customer in the current environment (test/live from the API key). */ - async list(): Promise { - return this.get(BASE_PATH); + /** 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/resources.test.ts b/packages/pay/src/resources/resources.test.ts index b4f9fc9..e21bb2c 100644 --- a/packages/pay/src/resources/resources.test.ts +++ b/packages/pay/src/resources/resources.test.ts @@ -214,21 +214,29 @@ describe('invoices', () => { }); describe('subscriptions', () => { - it('list → GET /api/v1/gateway/subscriptions returning a camelized array', async () => { - const calls = stubRoutes([ - { - 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', - }, - ]); - const subs = await client().subscriptions.list(); + 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(pathOf(calls[0])).toBe('/api/v1/gateway/subscriptions'); - expect(Array.isArray(subs)).toBe(true); - expect(subs[0]).toMatchObject({ + 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', @@ -253,15 +261,21 @@ describe('subscriptions', () => { }); describe('customers', () => { - it('list → GET /api/v1/gateway/customers returning a camelized array', async () => { - const calls = stubRoutes([ - { id: 'cus_1', email: 'a@b.com', vat_number: 'DE123', stripe_customer_id: 'cus_x' }, - ]); - const customers = await client().customers.list(); + 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(pathOf(calls[0])).toBe('/api/v1/gateway/customers'); - expect(Array.isArray(customers)).toBe(true); - expect(customers[0]).toMatchObject({ + 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 index 5c31c26..f8e9a5d 100644 --- a/packages/pay/src/resources/subscriptions.ts +++ b/packages/pay/src/resources/subscriptions.ts @@ -1,4 +1,10 @@ -import type { CancelSubscriptionParams, CancelSubscriptionResult, Subscription } from '../types.js'; +import type { + CancelSubscriptionParams, + CancelSubscriptionResult, + ListParams, + PaginatedList, + Subscription, +} from '../types.js'; import { BaseResource } from './base.js'; const BASE_PATH = '/api/v1/gateway/subscriptions'; @@ -10,9 +16,12 @@ const BASE_PATH = '/api/v1/gateway/subscriptions'; * the dashboard. There is no `create` here by design. */ export class SubscriptionsResource extends BaseResource { - /** List every subscription in the current environment (test/live from the API key). */ - async list(): Promise { - return this.get(BASE_PATH); + /** 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, + ); } /** diff --git a/packages/wallet/src/cli/commands/customers.command.ts b/packages/wallet/src/cli/commands/customers.command.ts index efcbf17..9e9e63f 100644 --- a/packages/wallet/src/cli/commands/customers.command.ts +++ b/packages/wallet/src/cli/commands/customers.command.ts @@ -14,8 +14,9 @@ export const customersCommand = new Command('customers').description('Customer m customersCommand .command('list') .description('List customers') + .option('--limit ', 'Results per page (default 10)', '10') .option('--json', 'Output as JSON') - .action(async () => { + .action(async (opts: { limit: string }) => { const client = await requirePayClient(); if (!client) return; @@ -23,25 +24,28 @@ customersCommand const spinner = json ? null : ora({ text: 'Fetching customers...', indent: 2 }).start(); try { - const customers = await client.customers.list(); + const data = await client.customers.list({ limit: Number.parseInt(opts.limit, 10) || 10 }); spinner?.stop(); if (json) { - console.log(JSON.stringify(customers)); + console.log( + JSON.stringify({ total: data.total, hasMore: data.hasMore, items: data.items }), + ); return; } - if (!customers.length) { + if (!data.items.length) { console.log(dim('\n No customers found.\n')); return; } - console.log(`\n ${chalk.bold(`Customers (${customers.length})`)}\n`); - for (const c of customers) { + 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'; diff --git a/packages/wallet/src/cli/commands/subscriptions.command.ts b/packages/wallet/src/cli/commands/subscriptions.command.ts index 491fe26..e9ef2c2 100644 --- a/packages/wallet/src/cli/commands/subscriptions.command.ts +++ b/packages/wallet/src/cli/commands/subscriptions.command.ts @@ -25,8 +25,9 @@ export const subscriptionsCommand = new Command('subscriptions').description( subscriptionsCommand .command('list') .description('List subscriptions') + .option('--limit ', 'Results per page (default 10)', '10') .option('--json', 'Output as JSON') - .action(async () => { + .action(async (opts: { limit: string }) => { const client = await requirePayClient(); if (!client) return; @@ -34,23 +35,28 @@ subscriptionsCommand const spinner = json ? null : ora({ text: 'Fetching subscriptions...', indent: 2 }).start(); try { - const subs = await client.subscriptions.list(); + const data = await client.subscriptions.list({ + limit: Number.parseInt(opts.limit, 10) || 10, + }); spinner?.stop(); if (json) { - console.log(JSON.stringify(subs)); + console.log( + JSON.stringify({ total: data.total, hasMore: data.hasMore, items: data.items }), + ); return; } - if (!subs.length) { + if (!data.items.length) { console.log(dim('\n No subscriptions found.\n')); return; } - console.log(`\n ${chalk.bold(`Subscriptions (${subs.length})`)}\n`); - for (const s of subs) { + 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'; diff --git a/packages/wallet/src/mcp/tools/pay-list-customers.ts b/packages/wallet/src/mcp/tools/pay-list-customers.ts index 1746e44..86488cb 100644 --- a/packages/wallet/src/mcp/tools/pay-list-customers.ts +++ b/packages/wallet/src/mcp/tools/pay-list-customers.ts @@ -1,4 +1,5 @@ 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) { @@ -6,18 +7,31 @@ export function registerPayListCustomers(server: McpServer) { '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 () => { + async ({ limit, offset }) => { try { const client = createPayClient(); - const customers = await client.customers.list(); + const data = await client.customers.list({ + limit: limit ?? 10, + offset: offset ?? 0, + }); - if (!customers.length) { + if (!data.items.length) { return { content: [{ type: 'text' as const, text: 'No customers found.' }] }; } - const lines = [`Customers (${customers.length})`, '']; - for (const c of customers) { + 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})`); @@ -25,6 +39,12 @@ export function registerPayListCustomers(server: McpServer) { 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 index e48a286..2e24e7e 100644 --- a/packages/wallet/src/mcp/tools/pay-list-subscriptions.ts +++ b/packages/wallet/src/mcp/tools/pay-list-subscriptions.ts @@ -1,4 +1,5 @@ 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. */ @@ -15,18 +16,31 @@ export function registerPayListSubscriptions(server: McpServer) { '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 () => { + async ({ limit, offset }) => { try { const client = createPayClient(); - const subs = await client.subscriptions.list(); + const data = await client.subscriptions.list({ + limit: limit ?? 10, + offset: offset ?? 0, + }); - if (!subs.length) { + if (!data.items.length) { return { content: [{ type: 'text' as const, text: 'No subscriptions found.' }] }; } - const lines = [`Subscriptions (${subs.length})`, '']; - for (const s of subs) { + 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}`); @@ -35,6 +49,12 @@ export function registerPayListSubscriptions(server: McpServer) { 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'); From 2ac1871a8fe10d55c9874d838af563a10970cd99 Mon Sep 17 00:00:00 2001 From: "Panche I." Date: Thu, 6 Aug 2026 16:33:10 +0200 Subject: [PATCH 08/11] fix(pay): invoices.void() posts to /void instead of DELETE void() called DELETE /gateway/invoices/{id}, which is not a route (404). The real endpoint is POST /gateway/invoices/{id}/void. Corrects the method and the test that was asserting the wrong DELETE behavior. Co-Authored-By: Claude Opus 4.8 --- packages/pay/src/resources/invoices.ts | 2 +- packages/pay/src/resources/resources.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/pay/src/resources/invoices.ts b/packages/pay/src/resources/invoices.ts index a2933a7..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 { diff --git a/packages/pay/src/resources/resources.test.ts b/packages/pay/src/resources/resources.test.ts index e21bb2c..e077bda 100644 --- a/packages/pay/src/resources/resources.test.ts +++ b/packages/pay/src/resources/resources.test.ts @@ -159,11 +159,11 @@ describe('invoices', () => { expect(pathOf(calls[0])).toBe('/api/v1/gateway/invoices/inv_5'); }); - it('void → DELETE /api/v1/gateway/invoices/:id', async () => { + it('void → POST /api/v1/gateway/invoices/:id/void', async () => { const calls = stubRoutes(); await client().invoices.void('inv_5'); - expect(calls[0]?.method).toBe('DELETE'); - expect(pathOf(calls[0])).toBe('/api/v1/gateway/invoices/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 () => { From a62f5fca496ed840e8aac2c4820e3709504461fc Mon Sep 17 00:00:00 2001 From: "Panche I." Date: Fri, 7 Aug 2026 08:46:38 +0200 Subject: [PATCH 09/11] =?UTF-8?q?chore:=20version=20packages=20=E2=80=94?= =?UTF-8?q?=20AgentaOS=202.0.0=20(unified:=20agentaos=20CLI,=20@agentaos/p?= =?UTF-8?q?ay,=20core=20libs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied the queued changesets: agentaos (CLI) major -> 2.0.0, cascading the fixed group (@agentaos/core, /crypto, /engine, /chains, /sdk) to 2.0.0; @agentaos/pay major -> 2.0.0. CLI now ships the MoR management surface (subscriptions, customers, invoices) at 2.0.0, matching the SDK. Co-Authored-By: Claude Opus 4.8 --- .changeset/cli-mcp-management-surface.md | 10 --- .changeset/pay-seller-mode-subscriptions.md | 31 --------- packages/chains/CHANGELOG.md | 40 ++++++++--- packages/chains/package.json | 15 +---- packages/core/CHANGELOG.md | 26 ++++++-- packages/core/package.json | 2 +- packages/mpc-wasm/CHANGELOG.md | 24 +++++-- packages/mpc-wasm/package.json | 2 +- packages/pay/CHANGELOG.md | 49 +++++++++++++- packages/pay/package.json | 6 +- packages/schemes/CHANGELOG.md | 38 ++++++++--- packages/schemes/package.json | 16 +---- packages/signer/CHANGELOG.md | 35 +++++++--- packages/signer/package.json | 17 +---- packages/wallet/CHANGELOG.md | 74 +++++++++++++++++---- packages/wallet/package.json | 19 +----- 16 files changed, 250 insertions(+), 154 deletions(-) delete mode 100644 .changeset/cli-mcp-management-surface.md delete mode 100644 .changeset/pay-seller-mode-subscriptions.md diff --git a/.changeset/cli-mcp-management-surface.md b/.changeset/cli-mcp-management-surface.md deleted file mode 100644 index 526764a..0000000 --- a/.changeset/cli-mcp-management-surface.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"agentaos": minor ---- - -Add subscription/customer/invoice management to the CLI and MCP server, mirroring the @agentaos/pay 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` diff --git a/.changeset/pay-seller-mode-subscriptions.md b/.changeset/pay-seller-mode-subscriptions.md deleted file mode 100644 index 844f2ff..0000000 --- a/.changeset/pay-seller-mode-subscriptions.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -"@agentaos/pay": major ---- - -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`. 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 50f6d2a..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 ", @@ -11,14 +11,7 @@ }, "homepage": "https://github.com/AgentaOS/agentaos", "bugs": "https://github.com/AgentaOS/agentaos/issues", - "keywords": [ - "threshold", - "wallet", - "ethereum", - "chain", - "viem", - "agent" - ], + "keywords": ["threshold", "wallet", "ethereum", "chain", "viem", "agent"], "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -28,9 +21,7 @@ "import": "./dist/index.js" } }, - "files": [ - "dist" - ], + "files": ["dist"], "engines": { "node": ">=20.0.0" }, 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/package.json b/packages/pay/package.json index 3bd9584..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 ", @@ -18,9 +18,7 @@ "import": "./dist/index.js" } }, - "files": [ - "dist" - ], + "files": ["dist"], "engines": { "node": ">=20.0.0" }, 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 c81d563..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 ", @@ -11,15 +11,7 @@ }, "homepage": "https://github.com/AgentaOS/agentaos", "bugs": "https://github.com/AgentaOS/agentaos/issues", - "keywords": [ - "threshold", - "wallet", - "mpc", - "ecdsa", - "cggmp24", - "wasm", - "signing" - ], + "keywords": ["threshold", "wallet", "mpc", "ecdsa", "cggmp24", "wasm", "signing"], "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -29,9 +21,7 @@ "import": "./dist/index.js" } }, - "files": [ - "dist" - ], + "files": ["dist"], "engines": { "node": ">=20.0.0" }, 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 c972a96..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 ", @@ -11,16 +11,7 @@ }, "homepage": "https://github.com/AgentaOS/agentaos", "bugs": "https://github.com/AgentaOS/agentaos/issues", - "keywords": [ - "threshold", - "wallet", - "mpc", - "ecdsa", - "sdk", - "signer", - "agent", - "viem" - ], + "keywords": ["threshold", "wallet", "mpc", "ecdsa", "sdk", "signer", "agent", "viem"], "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -30,9 +21,7 @@ "import": "./dist/index.js" } }, - "files": [ - "dist" - ], + "files": ["dist"], "engines": { "node": ">=20.0.0" }, 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/package.json b/packages/wallet/package.json index bdedb25..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 ", @@ -11,26 +11,13 @@ }, "homepage": "https://github.com/AgentaOS/agentaos", "bugs": "https://github.com/AgentaOS/agentaos/issues", - "keywords": [ - "mcp", - "threshold", - "wallet", - "mpc", - "ethereum", - "agent", - "signing", - "cli", - "x402" - ], + "keywords": ["mcp", "threshold", "wallet", "mpc", "ethereum", "agent", "signing", "cli", "x402"], "type": "module", "bin": { "agentaos": "./dist/index.js", "agenta": "./dist/index.js" }, - "files": [ - "dist", - "!dist/__tests__" - ], + "files": ["dist", "!dist/__tests__"], "engines": { "node": ">=20.0.0" }, From 354b900d1c9205cc71d91f5ba9c3249ca002a2eb Mon Sep 17 00:00:00 2001 From: "Panche I." Date: Fri, 7 Aug 2026 08:52:06 +0200 Subject: [PATCH 10/11] test: exclude flaky live-network x402 tests from the pre-push gate x402-client.test.ts hit httpbin.org live (flaky: 15s timeouts, a different assert failing each run). Renamed it to *.integration.test.ts and excluded integration tests from 'test' (matching @agentaos/engine); added 'test:integration' to run them on demand. Gate now runs only deterministic unit tests. Co-Authored-By: Claude Opus 4.8 --- packages/wallet/package.json | 3 ++- .../{x402-client.test.ts => x402-client.integration.test.ts} | 0 2 files changed, 2 insertions(+), 1 deletion(-) rename packages/wallet/src/__tests__/{x402-client.test.ts => x402-client.integration.test.ts} (100%) diff --git a/packages/wallet/package.json b/packages/wallet/package.json index b85b540..6c93c39 100644 --- a/packages/wallet/package.json +++ b/packages/wallet/package.json @@ -28,7 +28,8 @@ "build": "tsc -p tsconfig.build.json", "dev": "tsx src/index.ts", "lint": "biome check src/", - "test": "vitest run --passWithNoTests", + "test": "vitest run --passWithNoTests --exclude='**/*.integration.test.*'", + "test:integration": "vitest run src/__tests__/*.integration.test.ts", "typecheck": "tsc --noEmit", "clean": "rm -rf dist" }, diff --git a/packages/wallet/src/__tests__/x402-client.test.ts b/packages/wallet/src/__tests__/x402-client.integration.test.ts similarity index 100% rename from packages/wallet/src/__tests__/x402-client.test.ts rename to packages/wallet/src/__tests__/x402-client.integration.test.ts From e31c4af678cc4380b0c5991ae222d4931969c6f2 Mon Sep 17 00:00:00 2001 From: "Panche I." Date: Fri, 7 Aug 2026 09:00:07 +0200 Subject: [PATCH 11/11] test: fix flaky x402-client by mocking fetch (no exclusion) Reverses the earlier exclusion. x402-client is a UNIT test that lazily hit httpbin.org live (flaky: 15s timeouts, a different assert failing each run). Renamed it back from *.integration and mocked the global fetch so checkX402/discoverX402 are deterministic (added JSON-402 + .well-known manifest coverage). Restored the wallet 'test' script to run everything (no --exclude). x402-live.integration.test.ts (a genuine live test vs a real x402 server) stays in the gate as before. Suite green. Co-Authored-By: Claude Opus 4.8 --- packages/wallet/package.json | 3 +- .../__tests__/x402-client.integration.test.ts | 24 ----- .../wallet/src/__tests__/x402-client.test.ts | 99 +++++++++++++++++++ 3 files changed, 100 insertions(+), 26 deletions(-) delete mode 100644 packages/wallet/src/__tests__/x402-client.integration.test.ts create mode 100644 packages/wallet/src/__tests__/x402-client.test.ts diff --git a/packages/wallet/package.json b/packages/wallet/package.json index 6c93c39..b85b540 100644 --- a/packages/wallet/package.json +++ b/packages/wallet/package.json @@ -28,8 +28,7 @@ "build": "tsc -p tsconfig.build.json", "dev": "tsx src/index.ts", "lint": "biome check src/", - "test": "vitest run --passWithNoTests --exclude='**/*.integration.test.*'", - "test:integration": "vitest run src/__tests__/*.integration.test.ts", + "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit", "clean": "rm -rf dist" }, diff --git a/packages/wallet/src/__tests__/x402-client.integration.test.ts b/packages/wallet/src/__tests__/x402-client.integration.test.ts deleted file mode 100644 index 179883a..0000000 --- a/packages/wallet/src/__tests__/x402-client.integration.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { checkX402, discoverX402 } from '../lib/x402-client.js'; - -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'); - expect(result.requires402).toBe(false); - expect(result.url).toBe('https://httpbin.org/get'); - }, 20_000); - - 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'); - 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); -}); diff --git a/packages/wallet/src/__tests__/x402-client.test.ts b/packages/wallet/src/__tests__/x402-client.test.ts new file mode 100644 index 0000000..5e11e40 --- /dev/null +++ b/packages/wallet/src/__tests__/x402-client.test.ts @@ -0,0 +1,99 @@ +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 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://example.com/free'); + }); + + it('checkX402 returns requires402=true for a 402 URL', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => response(402)), + ); + const result = await checkX402('https://example.com/paid'); + expect(result.requires402).toBe(true); + }); + + 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'); + }); +});