diff --git a/.changeset/agentaos-connect.md b/.changeset/agentaos-connect.md new file mode 100644 index 0000000..37ba65e --- /dev/null +++ b/.changeset/agentaos-connect.md @@ -0,0 +1,5 @@ +--- +"agentaos": minor +--- + +Connect — `agenta businesses list | get | create | invite | revoke-invite | id-link` for the businesses you manage (your clients' businesses and your other apps), and `--business ` on every command to act as one of them (`agenta products create … --business `). The MCP server has the same six `agenta_businesses_*` tools and an optional `business` input on every tool. diff --git a/.changeset/agentaos-subscription-pricing.md b/.changeset/agentaos-subscription-pricing.md new file mode 100644 index 0000000..d71bdd0 --- /dev/null +++ b/.changeset/agentaos-subscription-pricing.md @@ -0,0 +1,7 @@ +--- +"agentaos": minor +--- + +Six new operations, on both the CLI and the MCP tools (25 in total): `discounts create|list|show|archive` for codes buyers type at checkout, and `subscriptions credit|credits` for credit against a subscriber's next invoice. `subscriptions list --code` shows who one code brought in. +`products create --trial-amount` prices a trial, so a plan can sell "9 for the first 30 days, then 29 a month"; the created plan now says what the trial costs before it renews. +The credit command mints one idempotency key per invocation and reports it under `--json`, so running it twice is two deliberate credits while a network retry inside one call is not. diff --git a/.changeset/checkout-embedded.md b/.changeset/checkout-embedded.md new file mode 100644 index 0000000..e8317b8 --- /dev/null +++ b/.changeset/checkout-embedded.md @@ -0,0 +1,5 @@ +--- +"@agentaos/checkout": minor +--- + +New package: the AgentaOS checkout on your own page — inline or overlay. `loadAgentaOS()` loads the checkout script once (SSR-safe), typed options and events (`checkout.loaded`, `checkout.completed`, `checkout.closed`, `checkout.error`), and `@agentaos/checkout/react` with `` and `useAgentaOSCheckout()`. diff --git a/.changeset/mcp-remote-connect.md b/.changeset/mcp-remote-connect.md new file mode 100644 index 0000000..41d54b8 --- /dev/null +++ b/.changeset/mcp-remote-connect.md @@ -0,0 +1,5 @@ +--- +"@agentaos/mcp-remote": patch +--- + +The hosted connector answers GET with 405 and replies with plain JSON (a stateless server has nothing to stream), and every tool passes `business` on, so a platform acting for a managed business never runs as itself. diff --git a/.changeset/pay-connect.md b/.changeset/pay-connect.md new file mode 100644 index 0000000..f8901b4 --- /dev/null +++ b/.changeset/pay-connect.md @@ -0,0 +1,9 @@ +--- +"@agentaos/pay": minor +--- + +Connect — manage your clients' businesses and act for them, the way Stripe Connect does. +Add `businesses.list/retrieve/create/resendInvitation/revokeInvitation/createVerificationLink()`. `create` invites your client as admin and returns `inviteUrl`; pass `sendInvitationEmail: false` to send it yourself (white-label). `createVerificationLink` is the identity check link only your client can complete (Stripe's Account Links). +Act for a business with `new AgentaOS(key, { business })` or `{ business }` as the last argument of any call — sent as `AgentaOS-Account` (Stripe's `Stripe-Account`). +`WebhookEvent` now lists every event we send — adds `dispute.created`, `dispute.closed`, `account.updated` and `webhook.test` — and each carries `business`: the business it happened in. +Fix: a call answered with 204 No Content resolves instead of failing. diff --git a/.changeset/pay-subscription-pricing.md b/.changeset/pay-subscription-pricing.md new file mode 100644 index 0000000..64c51be --- /dev/null +++ b/.changeset/pay-subscription-pricing.md @@ -0,0 +1,7 @@ +--- +"@agentaos/pay": minor +--- + +Add `discountCodes.create/list/get/archive()`: codes a buyer types at checkout on a subscription plan, including tracking-only codes that change no price and exist to record who a referral brought in. `subscriptions.list({ discountCode })` filters by one, and every `Subscription` now carries the `discount` it was bought with. +Add `subscriptions.credit(id, params)` and `subscriptions.credits(id)`: credit against a subscriber's next invoice. `idempotencyKey` is a required parameter, not an optional one — there is no local record of a credit for the server to recognise a repeat by, so a retry without the caller's own key credits twice. +Add `trialAmount` to `paymentLinks.create()` and to `PaymentLink`, so a plan can sell a paid trial ("9 for the first 30 days, then 29 a month") instead of only a free one. diff --git a/examples/paywall-demo/.env.example b/examples/paywall-demo/.env.example new file mode 100644 index 0000000..2bcf69c --- /dev/null +++ b/examples/paywall-demo/.env.example @@ -0,0 +1,21 @@ +# Test key from the dashboard: Settings, Developers, Generate Test Key. Server only. +AGENTAOS_API_KEY=sk_test_replace_me + +# Leave unset to use https://api.agentaos.ai. Set it only to point at another API. +# AGENTAOS_API_URL= + +# The plan's link ID: on the plan's page, open "Building this into your site?" and click Copy ID. +PRO_LINK_ID= +# Optional second plan (same currency and billing interval) for the upgrade button. +PRO_PLUS_LINK_ID= + +# The dashboard's webhook signing secret (Settings, Developers, Webhooks, Reveal signing secret). +# For a local test with `pnpm webhook:test`, any value works: the script signs with this same value. +AGENTAOS_WEBHOOK_SECRET=whsec_replace_me + +# Where the app runs. Checkout only accepts an https return address, so run locally +# on https://127.0.0.1 with the certificate from `pnpm cert`. +APP_URL=https://127.0.0.1:4567 +PORT=4567 +TLS_CERT=certs/cert.pem +TLS_KEY=certs/key.pem diff --git a/examples/paywall-demo/.gitignore b/examples/paywall-demo/.gitignore new file mode 100644 index 0000000..eeb8189 --- /dev/null +++ b/examples/paywall-demo/.gitignore @@ -0,0 +1,4 @@ +.env +data.json +certs/ +node_modules/ diff --git a/examples/paywall-demo/README.md b/examples/paywall-demo/README.md new file mode 100644 index 0000000..c7cfba8 --- /dev/null +++ b/examples/paywall-demo/README.md @@ -0,0 +1,92 @@ +# Paywall demo: a Notes app with a Pro plan + +A small Express app that puts a paid plan behind a login with `@agentaos/pay`. It shows the whole integration from your server's side: + +- **Upgrade**: `POST /upgrade` creates a checkout for the signed-in user, with the user's ID in `metadata`. +- **Confirm**: `GET /success` reads the checkout with `checkouts.retrieve` and unlocks Pro when it is `completed`. +- **Webhook**: `POST /webhooks` verifies the signature, unlocks Pro on the first payment, and matches renewals by the stored subscription ID. +- **Access check**: `GET /pro` opens only for a subscription that is `active` or `trialing`. +- **Invoices**: the home page lists the user's invoices with `subscriptions.invoices` and streams the invoice PDF and the receipt. +- **Manage plan**: cancel at the period end, or quote an upgrade with `previewPlanChange` and apply it with `changePlan`. + +The login is fake (pick Ada or Grace) and the "database" is `data.json`. Everything else is the real API, in test mode. + +| File | What it does | +|---|---| +| `src/agentaos.ts` | The SDK client and the settings from `.env` | +| `src/checkout.ts` | `POST /upgrade` and `GET /success` | +| `src/webhooks.ts` | `POST /webhooks` | +| `src/grant.ts` | Unlocks Pro once per checkout, from the success page or the webhook | +| `src/access.ts` | Reads the subscription and guards Pro routes | +| `src/invoices.ts` | Lists the user's invoices and streams the PDFs | +| `src/plan.ts` | Cancel, quote and apply an upgrade | +| `src/store.ts`, `src/session.ts`, `src/views.ts` | The fake database, the fake login, and the HTML | +| `scripts/send-test-webhook.ts` | Sends signed sample events to `/webhooks` | + +## Run it + +You need Node.js 20.6 or later, pnpm, and `openssl`. The demo lives in the AgentaOS monorepo and uses the SDK from `packages/pay`. + +1. Install from the repository root, and build the SDK: + + ```bash + pnpm install + pnpm -F @agentaos/pay build + ``` + +2. Create the plan in the dashboard: **Products**, **New product**, **Subscription**, **Monthly**. On the plan's page, open **Building this into your site?** and click **Copy ID**. For the upgrade button, create a second plan with the same currency and interval. + +3. Copy the settings and fill them in: + + ```bash + cd examples/paywall-demo + cp .env.example .env + ``` + + Set `AGENTAOS_API_KEY` to a test key (Settings, Developers, **Generate Test Key**), `PRO_LINK_ID` and `PRO_PLUS_LINK_ID` to the two plan IDs, and `AGENTAOS_WEBHOOK_SECRET` to the dashboard's signing secret. + +4. Make a local certificate. The checkout only returns buyers to an `https` address, and it refuses `localhost`, so the demo runs on `https://127.0.0.1:4567`: + + ```bash + pnpm cert + ``` + +5. Start the app and open https://127.0.0.1:4567. Your browser warns about the self-signed certificate once; continue to the site. + + ```bash + pnpm dev + ``` + +6. Sign in as Ada, click **Upgrade to Pro**, and pay with the test card `4242 4242 4242 4242`, any future expiry, any CVC. You come back to `/success`, and the home page shows the plan, **Manage plan**, and **My invoices**. + +A plan with a free trial charges nothing at checkout. The subscription is `trialing`, and **My invoices** stays empty until the first charge at the end of the trial. Use a plan without a trial to see an invoice at once. + +## Test the webhook on your machine + +AgentaOS does not deliver webhooks to a private address such as `127.0.0.1`, so a local app gets no events. The success page unlocks Pro without them. To test the webhook route, send it signed sample events: + +```bash +pnpm webhook:test +``` + +The script signs each body the way AgentaOS does (`X-AgentaOS-Signature: t=,v1=.">`) with `AGENTAOS_WEBHOOK_SECRET`, and prints the status for each: + +```text +bad signature -> 400 +renewal -> 200 +same renewal again -> 200 +subscription.updated -> 200 +``` + +To receive real events, expose the app through a public HTTPS tunnel, enter the tunnel's `/webhooks` URL in Settings, Developers, **Webhook URL**, and click **Send test event**. + +## Take it to production + +- **Login**: replace `src/session.ts` with your own authentication. +- **Database**: replace `src/store.ts` with tables. Put a unique constraint on the processed session ID, so two deliveries of the same payment cannot both grant it. +- **Webhook**: register `https://yourapp.com/webhooks` in the dashboard and set its signing secret. +- **Keys**: use your live key (`sk_live_...`) on the live server, and keep it out of the browser and out of git. +- **Test and live**: every event carries `data.livemode`. Ignore test events on the live server. +- **HTTPS**: remove `TLS_CERT` and `TLS_KEY` when a proxy terminates TLS in front of the app, and set `APP_URL` to your public address. + +Outside this monorepo, replace `"@agentaos/pay": "workspace:*"` in `package.json` with the published version. diff --git a/examples/paywall-demo/package.json b/examples/paywall-demo/package.json new file mode 100644 index 0000000..7844830 --- /dev/null +++ b/examples/paywall-demo/package.json @@ -0,0 +1,26 @@ +{ + "name": "agentaos-example-paywall-demo", + "private": true, + "type": "module", + "description": "A tiny Notes app with a Pro plan: checkout, webhooks, access check, invoices, cancel and upgrade.", + "engines": { + "node": ">=20.6.0" + }, + "scripts": { + "dev": "tsx watch --env-file=.env src/server.ts", + "start": "tsx --env-file=.env src/server.ts", + "cert": "mkdir -p certs && openssl req -x509 -newkey rsa:2048 -nodes -days 30 -subj /CN=127.0.0.1 -addext subjectAltName=IP:127.0.0.1 -keyout certs/key.pem -out certs/cert.pem", + "webhook:test": "tsx --env-file=.env scripts/send-test-webhook.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@agentaos/pay": "workspace:*", + "express": "^5.2.1" + }, + "devDependencies": { + "@types/express": "^5.0.6", + "@types/node": "^20.19.32", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/examples/paywall-demo/scripts/send-test-webhook.ts b/examples/paywall-demo/scripts/send-test-webhook.ts new file mode 100644 index 0000000..7f2d828 --- /dev/null +++ b/examples/paywall-demo/scripts/send-test-webhook.ts @@ -0,0 +1,64 @@ +// Sends signed sample events to the demo's /webhooks route, the way AgentaOS signs them: +// header `X-AgentaOS-Signature: t=,v1=.">`. +// Use it when AgentaOS cannot reach your machine. Run: pnpm webhook:test +import { createHmac, randomUUID } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { request } from 'node:https'; + +const url = new URL('/webhooks', process.env.APP_URL); +const secret = process.env.AGENTAOS_WEBHOOK_SECRET ?? ''; + +function sign(body: string): string { + const t = Math.floor(Date.now() / 1000); + return `t=${t},v1=${createHmac('sha256', secret).update(`${t}.${body}`).digest('hex')}`; +} + +function post(body: string, signature: string): Promise { + return new Promise((resolve, reject) => { + const req = request( + url, + { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-agentaos-signature': signature }, + // Local only: the demo's certificate is self-signed. + rejectUnauthorized: false, + }, + (res) => resolve(res.statusCode ?? 0), + ); + req.on('error', reject); + req.end(body); + }); +} + +// Events use the wire format: snake_case keys, as AgentaOS sends them. +const users = JSON.parse(readFileSync('data.json', 'utf8')).users as Array<{ + id: string; + subscriptionId: string | null; +}>; +const subscribed = users.find((u) => u.subscriptionId); +if (!subscribed?.subscriptionId) throw new Error('Subscribe one user in the app first.'); +const subscriptionId = subscribed.subscriptionId; + +const renewal = JSON.stringify({ + id: `evt_${randomUUID()}`, + type: 'checkout.session.completed', + business: 'test', + data: { + session_id: `test_renewal_${Date.now()}`, + amount: '29.00', + currency: 'EUR', + livemode: false, + metadata: { subscriptionId, subscriptionCycle: true }, + }, +}); +const updated = JSON.stringify({ + id: `evt_${randomUUID()}`, + type: 'subscription.updated', + business: 'test', + data: { id: subscriptionId, status: 'active', cancel_at_period_end: true, livemode: false }, +}); + +console.log('bad signature ->', await post(renewal, 't=1,v1=00')); +console.log('renewal ->', await post(renewal, sign(renewal))); +console.log('same renewal again ->', await post(renewal, sign(renewal))); +console.log('subscription.updated ->', await post(updated, sign(updated))); diff --git a/examples/paywall-demo/src/access.ts b/examples/paywall-demo/src/access.ts new file mode 100644 index 0000000..b946589 --- /dev/null +++ b/examples/paywall-demo/src/access.ts @@ -0,0 +1,31 @@ +import type { Subscription } from '@agentaos/pay'; +import type { NextFunction, Request, Response } from 'express'; +import { agentaos } from './agentaos.js'; +import type { User } from './store.js'; + +/** Reads one subscription by ID. The list has no metadata, so the stored ID is the key. */ +export async function findSubscription(id: string | null): Promise { + if (!id) return null; + for (let offset = 0; ; offset += 100) { + const page = await agentaos.subscriptions.list({ limit: 100, offset }); + const found = page.items.find((s) => s.id === id); + if (found) return found; + if (!page.hasMore) return null; + } +} + +export function isPro(subscription: Subscription | null): boolean { + return subscription?.status === 'active' || subscription?.status === 'trialing'; +} + +/** Put in front of every Pro route. Needs `requireUser` first. */ +export async function requirePro(_req: Request, res: Response, next: NextFunction): Promise { + const user: User = res.locals.user; + const subscription = await findSubscription(user.subscriptionId); + if (!isPro(subscription)) { + res.redirect(303, '/?locked=1'); + return; + } + res.locals.subscription = subscription; + next(); +} diff --git a/examples/paywall-demo/src/agentaos.ts b/examples/paywall-demo/src/agentaos.ts new file mode 100644 index 0000000..df95999 --- /dev/null +++ b/examples/paywall-demo/src/agentaos.ts @@ -0,0 +1,22 @@ +import { AgentaOS } from '@agentaos/pay'; + +function required(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`Set ${name} in .env (copy .env.example to start).`); + return value; +} + +export const config = { + appUrl: required('APP_URL'), + port: Number(process.env.PORT ?? 4567), + proLinkId: required('PRO_LINK_ID'), + proPlusLinkId: process.env.PRO_PLUS_LINK_ID || null, + webhookSecret: required('AGENTAOS_WEBHOOK_SECRET'), + tlsCert: process.env.TLS_CERT || null, + tlsKey: process.env.TLS_KEY || null, +}; + +// Server side only: the key gives full access to your account. +export const agentaos = new AgentaOS(required('AGENTAOS_API_KEY'), { + baseUrl: process.env.AGENTAOS_API_URL || undefined, +}); diff --git a/examples/paywall-demo/src/checkout.ts b/examples/paywall-demo/src/checkout.ts new file mode 100644 index 0000000..cb9d300 --- /dev/null +++ b/examples/paywall-demo/src/checkout.ts @@ -0,0 +1,57 @@ +import type { Request, Response } from 'express'; +import { agentaos, config } from './agentaos.js'; +import { grantPro } from './grant.js'; +import type { User } from './store.js'; +import { updateUser } from './store.js'; +import { successPage } from './views.js'; + +// POST /upgrade: one checkout per click, tied to the signed-in user. +export async function upgrade(_req: Request, res: Response): Promise { + const user: User = res.locals.user; + + // A second click reuses the open checkout instead of creating another one. + if (user.pendingSessionId) { + const pending = await agentaos.checkouts.retrieve(user.pendingSessionId); + if (pending.status === 'open') { + res.redirect(303, pending.checkoutUrl); + return; + } + } + + const checkout = await agentaos.checkouts.create({ + linkId: config.proLinkId, + buyerEmail: user.email, + metadata: { customerId: user.id }, + successUrl: `${config.appUrl}/success`, + cancelUrl: `${config.appUrl}/`, + }); + updateUser(user.id, { pendingSessionId: checkout.sessionId }); + res.redirect(303, checkout.checkoutUrl); +} + +// GET /success?sessionId=...: the buyer lands here after paying. +export async function success(req: Request, res: Response): Promise { + const user: User = res.locals.user; + const sessionId = String(req.query.sessionId ?? ''); + if (!sessionId) { + res.status(400).send('sessionId missing'); + return; + } + + // Never unlock from the redirect alone: ask the API what happened. + const checkout = await agentaos.checkouts.retrieve(sessionId); + if (checkout.metadata.customerId !== user.id) { + res.status(404).send('Not found'); + return; + } + + if (checkout.status === 'completed') { + const { subscriptionId } = checkout.metadata; + grantPro({ + userId: user.id, + subscriptionId: typeof subscriptionId === 'string' ? subscriptionId : null, + sessionId, + }); + } + res.send(successPage(user, checkout.status)); +} diff --git a/examples/paywall-demo/src/grant.ts b/examples/paywall-demo/src/grant.ts new file mode 100644 index 0000000..9ca77ab --- /dev/null +++ b/examples/paywall-demo/src/grant.ts @@ -0,0 +1,32 @@ +// What a payment changes in your app. The success page and the webhook both call these, +// so each one runs once per checkout, whichever arrives first. +import { findUserBySubscription, onceForSession, updateUser } from './store.js'; + +/** First payment or trial start: link the subscription to the user. */ +export function grantPro(input: { + userId: string; + subscriptionId: string | null; + sessionId: string; +}): void { + const granted = onceForSession(input.sessionId, () => + updateUser(input.userId, { subscriptionId: input.subscriptionId, pendingSessionId: null }), + ); + console.log( + `grantPro ${input.userId} session=${input.sessionId}: ${granted ? 'granted' : 'already handled'}`, + ); +} + +/** A paid renewal. It carries no customerId, so the stored subscription ID finds the user. */ +export function recordRenewal(input: { subscriptionId: string; sessionId: string }): void { + const user = findUserBySubscription(input.subscriptionId); + if (!user) { + console.warn(`Renewal for unknown subscription ${input.subscriptionId}`); + return; + } + const recorded = onceForSession(input.sessionId, () => + updateUser(user.id, { subscriptionStatus: 'active' }), + ); + console.log( + `recordRenewal ${user.id} session=${input.sessionId}: ${recorded ? 'recorded' : 'already handled'}`, + ); +} diff --git a/examples/paywall-demo/src/invoices.ts b/examples/paywall-demo/src/invoices.ts new file mode 100644 index 0000000..8fd49ef --- /dev/null +++ b/examples/paywall-demo/src/invoices.ts @@ -0,0 +1,30 @@ +import type { SubscriptionInvoice } from '@agentaos/pay'; +import type { Request, Response } from 'express'; +import { agentaos } from './agentaos.js'; +import type { User } from './store.js'; + +/** Every invoice of this user's subscription, newest first. */ +export async function userInvoices(user: User): Promise { + if (!user.subscriptionId) return []; + return agentaos.subscriptions.invoices(user.subscriptionId); +} + +// GET /invoices/:id/pdf and /invoices/:id/receipt. The SDK returns the PDF bytes; +// your server streams them, after it checks that the invoice is this user's. +export async function downloadInvoice(req: Request, res: Response): Promise { + const user: User = res.locals.user; + const id = String(req.params.id); + const kind = req.path.endsWith('/receipt') ? 'receipt' : 'invoice'; + + const invoice = (await userInvoices(user)).find((i) => i.id === id); + if (!invoice) { + res.status(404).send('Not found'); + return; + } + + const pdf = + kind === 'receipt' + ? await agentaos.invoices.getReceipt(id) + : await agentaos.invoices.downloadPdf(id); + res.type('application/pdf').attachment(`${kind}-${invoice.invoiceNumber}.pdf`).send(pdf); +} diff --git a/examples/paywall-demo/src/plan.ts b/examples/paywall-demo/src/plan.ts new file mode 100644 index 0000000..7b5e5db --- /dev/null +++ b/examples/paywall-demo/src/plan.ts @@ -0,0 +1,43 @@ +import type { Request, Response } from 'express'; +import { agentaos, config } from './agentaos.js'; +import type { User } from './store.js'; +import { quotePage } from './views.js'; + +function subscriptionOf(res: Response): string { + const user: User = res.locals.user; + if (!user.subscriptionId) throw new Error('No subscription on this user'); + return user.subscriptionId; +} + +// POST /plan/cancel: ends at the period end. The user keeps Pro until then. +export async function cancel(_req: Request, res: Response): Promise { + await agentaos.subscriptions.cancel(subscriptionOf(res)); + res.redirect(303, '/'); +} + +// POST /plan/preview: quote the move to Pro Plus. Nothing is charged yet. +export async function preview(_req: Request, res: Response): Promise { + if (!config.proPlusLinkId) { + res.status(404).send('Set PRO_PLUS_LINK_ID'); + return; + } + const quote = await agentaos.subscriptions.previewPlanChange( + subscriptionOf(res), + config.proPlusLinkId, + ); + res.send(quotePage(res.locals.user, quote)); +} + +// POST /plan/change: apply the quote the user confirmed. +export async function change(req: Request, res: Response): Promise { + if (!config.proPlusLinkId) { + res.status(404).send('Set PRO_PLUS_LINK_ID'); + return; + } + await agentaos.subscriptions.changePlan(subscriptionOf(res), { + targetLinkId: config.proPlusLinkId, + // The quote's prorationDate makes the charge equal the amount the user saw. + prorationDate: Number(req.body.prorationDate), + }); + res.redirect(303, '/'); +} diff --git a/examples/paywall-demo/src/server.ts b/examples/paywall-demo/src/server.ts new file mode 100644 index 0000000..f0baad8 --- /dev/null +++ b/examples/paywall-demo/src/server.ts @@ -0,0 +1,63 @@ +import { readFileSync } from 'node:fs'; +import { createServer as createHttpsServer } from 'node:https'; +import express from 'express'; +import { findSubscription, isPro, requirePro } from './access.js'; +import { config } from './agentaos.js'; +import { success, upgrade } from './checkout.js'; +import { downloadInvoice, userInvoices } from './invoices.js'; +import { cancel, change, preview } from './plan.js'; +import { currentUser, requireUser, signIn, signOut } from './session.js'; +import { findUser, listUsers } from './store.js'; +import { homePage, loginPage, proPage } from './views.js'; +import { webhooks } from './webhooks.js'; + +const app = express(); + +// The webhook needs the raw body for the signature, so it is mounted before any body parser. +app.post('/webhooks', express.raw({ type: 'application/json' }), webhooks); +app.use(express.urlencoded({ extended: false })); + +app.get('/', async (req, res) => { + const user = currentUser(req); + if (!user) { + res.send(loginPage(listUsers())); + return; + } + const subscription = await findSubscription(user.subscriptionId); + res.send( + homePage({ + user, + subscription, + pro: isPro(subscription), + upgradeLinkId: config.proPlusLinkId, + invoices: await userInvoices(user), + locked: req.query.locked === '1', + }), + ); +}); + +app.post('/login', (req, res) => { + const user = findUser(String(req.body.userId)); + if (user) signIn(res, user.id); + res.redirect(303, '/'); +}); +app.post('/logout', (_req, res) => { + signOut(res); + res.redirect(303, '/'); +}); + +app.post('/upgrade', requireUser, upgrade); +app.get('/success', requireUser, success); +app.get('/pro', requireUser, requirePro, (_req, res) => void res.send(proPage(res.locals.user))); +app.get(['/invoices/:id/pdf', '/invoices/:id/receipt'], requireUser, downloadInvoice); +app.post('/plan/cancel', requireUser, cancel); +app.post('/plan/preview', requireUser, preview); +app.post('/plan/change', requireUser, change); + +const listening = () => console.log(`Notes demo on ${config.appUrl}`); +if (config.tlsCert && config.tlsKey) { + const tls = { cert: readFileSync(config.tlsCert), key: readFileSync(config.tlsKey) }; + createHttpsServer(tls, app).listen(config.port, listening); +} else { + app.listen(config.port, listening); +} diff --git a/examples/paywall-demo/src/session.ts b/examples/paywall-demo/src/session.ts new file mode 100644 index 0000000..30941d7 --- /dev/null +++ b/examples/paywall-demo/src/session.ts @@ -0,0 +1,29 @@ +// A fake login: the signed-in user's ID lives in a cookie. Use your real login instead. +import type { NextFunction, Request, Response } from 'express'; +import { type User, findUser } from './store.js'; + +const COOKIE = 'demo_user'; + +export function signIn(res: Response, userId: string): void { + res.cookie(COOKIE, userId, { httpOnly: true, sameSite: 'lax', secure: true }); +} + +export function signOut(res: Response): void { + res.clearCookie(COOKIE); +} + +export function currentUser(req: Request): User | null { + const match = (req.headers.cookie ?? '').match(new RegExp(`(?:^|; )${COOKIE}=([^;]+)`)); + return findUser(match?.[1]); +} + +/** Sends signed-out visitors to the login screen and puts the user on `res.locals.user`. */ +export function requireUser(req: Request, res: Response, next: NextFunction): void { + const user = currentUser(req); + if (!user) { + res.redirect(303, '/'); + return; + } + res.locals.user = user; + next(); +} diff --git a/examples/paywall-demo/src/store.ts b/examples/paywall-demo/src/store.ts new file mode 100644 index 0000000..246f687 --- /dev/null +++ b/examples/paywall-demo/src/store.ts @@ -0,0 +1,72 @@ +// The demo's "database": one JSON file. Replace it with your own tables. +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; + +export interface User { + id: string; + name: string; + email: string; + /** The checkout this user opened last, so a second click reuses it. */ + pendingSessionId: string | null; + /** From `metadata.subscriptionId`. Renewals are matched to the user by this ID. */ + subscriptionId: string | null; + /** Last status a webhook reported. The access check reads the API instead. */ + subscriptionStatus: string | null; +} + +interface Data { + users: User[]; + /** Every checkout already handled. A payment is granted once, whoever reports it first. */ + processedSessionIds: string[]; +} + +const FILE = 'data.json'; + +const SEED: Data = { + users: [ + { id: 'user_ada', name: 'Ada', email: 'ada@example.com' }, + { id: 'user_grace', name: 'Grace', email: 'grace@example.com' }, + ].map((u) => ({ ...u, pendingSessionId: null, subscriptionId: null, subscriptionStatus: null })), + processedSessionIds: [], +}; + +function load(): Data { + if (!existsSync(FILE)) return structuredClone(SEED); + return JSON.parse(readFileSync(FILE, 'utf8')) as Data; +} + +function save(data: Data): void { + writeFileSync(FILE, JSON.stringify(data, null, 2)); +} + +export function listUsers(): User[] { + return load().users; +} + +export function findUser(id: string | undefined): User | null { + return load().users.find((u) => u.id === id) ?? null; +} + +export function findUserBySubscription(subscriptionId: string): User | null { + return load().users.find((u) => u.subscriptionId === subscriptionId) ?? null; +} + +export function updateUser(id: string, patch: Partial): void { + const data = load(); + const user = data.users.find((u) => u.id === id); + if (!user) throw new Error(`Unknown user ${id}`); + Object.assign(user, patch); + save(data); +} + +/** + * Runs `apply` once per checkout. Returns false when the session was already handled. + * In a real database, a unique constraint on the session ID does this safely. + */ +export function onceForSession(sessionId: string, apply: () => void): boolean { + const data = load(); + if (data.processedSessionIds.includes(sessionId)) return false; + data.processedSessionIds.push(sessionId); + save(data); + apply(); + return true; +} diff --git a/examples/paywall-demo/src/views.ts b/examples/paywall-demo/src/views.ts new file mode 100644 index 0000000..3b08a8a --- /dev/null +++ b/examples/paywall-demo/src/views.ts @@ -0,0 +1,147 @@ +// Plain HTML templates. Every value from the API or the user goes through `esc`. +import type { Checkout, PlanChangePreview, Subscription, SubscriptionInvoice } from '@agentaos/pay'; +import type { User } from './store.js'; + +const esc = (value: unknown): string => + String(value ?? '').replace( + /[&<>"']/g, + (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] as string, + ); + +// The API does the math; these only print its numbers. +/** Invoices give currency units, such as 60.76. */ +const format = (units: number, currency: string): string => + new Intl.NumberFormat('en-US', { style: 'currency', currency: currency.toUpperCase() }).format( + units, + ); +/** Subscriptions and quotes give integer minor units: 3596 is 35.96. */ +const money = (minor: number, currency: string): string => format(minor / 100, currency); + +const date = (iso: string | null): string => + iso + ? new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) + : ''; + +function layout(title: string, user: User | null, body: string): string { + const nav = user + ? `Signed in as ${esc(user.name)}
` + : ''; + return ` +${esc(title)} · Notes +
Notes
${nav}
${body}
`; +} + +export function loginPage(users: User[]): string { + const buttons = users + .map( + (u) => + `
`, + ) + .join(' '); + return layout( + 'Sign in', + null, + `

Sign in

A fake login: pick a user.

${buttons}
`, + ); +} + +function planCard(sub: Subscription | null, pro: boolean, upgradeLinkId: string | null): string { + if (!pro || !sub) { + return `

Your plan Free

+

Pro unlocks unlimited notes and sharing.

+
`; + } + const renewal = sub.cancelAtPeriodEnd + ? `Pro ends on ${date(sub.effectiveCancelDate ?? sub.currentPeriodEnd)}. You keep it until then.` + : `${sub.status === 'trialing' ? 'Free trial. First charge' : 'Renews'} on ${date(sub.currentPeriodEnd)}: ${money(sub.unitAmountMinor, sub.currency)} per ${esc(sub.billingInterval)}.`; + const pending = sub.pendingPlanChange + ? `

Switches to ${esc(sub.pendingPlanChange.planName)} on ${date(sub.pendingPlanChange.effectiveAt)}.

` + : ''; + const canUpgrade = upgradeLinkId !== null && sub.linkId !== upgradeLinkId; + const upgrade = canUpgrade + ? '
' + : ''; + const actions = sub.cancelAtPeriodEnd + ? '' + : `
${upgrade}
`; + return `

Your plan ${esc(sub.planName)} ${esc(sub.status)}

+

${renewal}

${pending}

Open Pro notes

+

Manage plan

${actions || '

Cancellation scheduled.

'}
`; +} + +function invoicesCard(invoices: SubscriptionInvoice[]): string { + const rows = invoices + .map( + ( + i, + ) => `${esc(i.invoiceNumber)}${date(i.issuedAt)}${format(i.amount, i.currency)}${esc(i.status)} +Invoice PDF · Receipt`, + ) + .join(''); + const table = rows + ? `${rows}
NumberDateAmountStatus
` + : '

No invoices yet. The first one comes with the first charge.

'; + return `

My invoices

${table}
`; +} + +export function homePage(input: { + user: User; + subscription: Subscription | null; + pro: boolean; + upgradeLinkId: string | null; + invoices: SubscriptionInvoice[]; + locked: boolean; +}): string { + const locked = input.locked ? '
Pro notes need the Pro plan.
' : ''; + const invoices = input.user.subscriptionId ? invoicesCard(input.invoices) : ''; + return layout( + 'Home', + input.user, + `

Hello, ${esc(input.user.name)}

${esc(input.user.email)}

+${locked}${planCard(input.subscription, input.pro, input.upgradeLinkId)}${invoices}`, + ); +} + +export function successPage(user: User, status: Checkout['status']): string { + const body = + status === 'completed' + ? '

You are on Pro

Thanks. Your payment is confirmed and Pro is unlocked.

Go to Notes' + : status === 'open' + ? '

Still processing

We are waiting for the payment to confirm.

Refresh' + : `

Payment not completed

The checkout is ${esc(status)}.

`; + return layout('Upgrade', user, `
${body}
`); +} + +export function quotePage(user: User, quote: PlanChangePreview): string { + return layout( + 'Upgrade', + user, + `

Upgrade to Pro Plus

+

Due today: ${money(quote.dueTodayMinor, quote.currency)}

+

Then ${money(quote.nextInvoiceMinor, quote.currency)} on ${date(quote.nextInvoiceAt)}.

+
+Back
`, + ); +} + +export function proPage(user: User): string { + return layout( + 'Pro notes', + user, + '

Pro notes

Only Pro users see this page.

', + ); +} diff --git a/examples/paywall-demo/src/webhooks.ts b/examples/paywall-demo/src/webhooks.ts new file mode 100644 index 0000000..ec80519 --- /dev/null +++ b/examples/paywall-demo/src/webhooks.ts @@ -0,0 +1,46 @@ +import { type WebhookEvent, WebhookVerificationError } from '@agentaos/pay'; +import type { Request, Response } from 'express'; +import { agentaos, config } from './agentaos.js'; +import { grantPro, recordRenewal } from './grant.js'; +import { findUserBySubscription, updateUser } from './store.js'; + +// POST /webhooks, mounted with express.raw() so req.body is the raw bytes. +export function webhooks(req: Request, res: Response): void { + let event: WebhookEvent; + try { + event = agentaos.webhooks.verify( + req.body as Buffer, + req.header('x-agentaos-signature') ?? '', + config.webhookSecret, + ); + } catch (err) { + if (err instanceof WebhookVerificationError) { + res.status(400).send('Invalid signature'); + return; + } + throw err; + } + + switch (event.type) { + case 'checkout.session.completed': { + const { sessionId, metadata } = event.data; + const subscriptionId = + typeof metadata.subscriptionId === 'string' ? metadata.subscriptionId : null; + if (typeof metadata.customerId === 'string') { + // First payment, or the start of a free trial. + grantPro({ userId: metadata.customerId, subscriptionId, sessionId }); + } else if (subscriptionId) { + // A renewal carries no customerId: match it by the stored subscription ID. + recordRenewal({ subscriptionId, sessionId }); + } + break; + } + case 'subscription.updated': + case 'subscription.canceled': { + const user = findUserBySubscription(event.data.id); + if (user) updateUser(user.id, { subscriptionStatus: event.data.status }); + break; + } + } + res.sendStatus(200); +} diff --git a/examples/paywall-demo/tsconfig.json b/examples/paywall-demo/tsconfig.json new file mode 100644 index 0000000..749a02b --- /dev/null +++ b/examples/paywall-demo/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["src", "scripts"] +} diff --git a/packages/checkout/README.md b/packages/checkout/README.md new file mode 100644 index 0000000..b0fb6c2 --- /dev/null +++ b/packages/checkout/README.md @@ -0,0 +1,85 @@ +# @agentaos/checkout + +The AgentaOS checkout on your own page — **inline** in a spot you choose, or as an **overlay** over the page. Card, bank transfer, trials, discount codes, VAT and 3D Secure work exactly as on the hosted checkout; AgentaOS stays the merchant of record. + +Browser only. For server work (creating checkouts, reading subscriptions) use [`@agentaos/pay`](../pay). + +## Before you start + +1. **Approve your site** — in the dashboard, **Settings → Developers → Approved sites**, add each site you'll embed on (e.g. `https://acme.com`). The browser refuses to show your checkout anywhere else. Test products also work on `localhost` without adding it. +2. **Your look** — **Settings → Business → Checkout appearance**: button colour, font, corners, background. It applies to the hosted and the embedded checkout alike. +3. **The product's buyer link** — the `checkoutUrl` from `agenta products create`, the SDK, or the product page ("Embed on your site" has a ready snippet). + +## Script tag (no build step) + +```html + +
+ +``` + +## npm + +```bash +pnpm add @agentaos/checkout +``` + +```ts +import { loadAgentaOS } from '@agentaos/checkout'; + +const agentaos = await loadAgentaOS(); // null on the server +agentaos?.checkout.open({ link, target: '#checkout', onEvent }); +``` + +`loadAgentaOS()` adds the script once, however many times it's called (the first call's `origin` wins). While developing against a local AgentaOS, pass `loadAgentaOS({ origin: 'http://localhost:3000' })`. If the script can't load, the promise rejects with a plain sentence and a later call tries again; `` reports it to `onEvent` as `checkout.error` `not_loaded`. + +## React + +```tsx +import { AgentaOSCheckout, useAgentaOSCheckout } from '@agentaos/checkout/react'; + +// Inline — switching `link` (e.g. a Monthly/Yearly toggle) updates the open checkout. + + +// Overlay, from a button — it lives as long as this component (unmounting closes it) +const { open } = useAgentaOSCheckout(); + +``` + +## Options + +| Option | | +|---|---| +| `link` | The product's buyer link. | +| `session` | Instead of `link`: the `sessionId` of a checkout your server created with `checkouts.create` (logged-in users, with your own `metadata`). | +| `target` | Selector or element for inline. Leave out for the overlay. | +| `email`, `country` | Prefill (ISO country code). Sent to the checkout directly, never in a URL. | +| `successUrl` | Where the whole page goes after payment, with `sessionId` added (https only). Defaults to the checkout's success URL (the one given to `checkouts.create`, else the product's). `false` keeps the page where it is. | +| `onEvent` | Receives the events below. | + +`open()` returns `{ update({ link | session }), close() }`. Prefill and `successUrl` are read when the checkout opens; `update()` switches only the product or checkout. + +## Events + +| `name` | `data` | +|---|---| +| `checkout.loaded` | `{ product: { name, type, billingInterval, trialDays }, currency, testMode }` | +| `checkout.completed` | `{ sessionId, kind: 'payment' \| 'trial' \| 'bank_transfer', email, amountMinor, currency, successUrl }` | +| `checkout.closed` | `{}` — the overlay closed: by the buyer, by `close()`, or because the React component that opened it unmounted | +| `checkout.error` | `{ code: 'not_loaded' \| 'not_found' \| 'unavailable', message }` | + +`amountMinor` is what was charged today in minor units (0 for a free trial), or what the buyer was asked to send for a bank transfer. A bank transfer never moves the page: the buyer needs the bank details on screen. + +**An event is never proof of payment.** Unlock access from the webhook or `checkouts.retrieve(sessionId)`, and handle `checkout.completed` once per `sessionId` — a reload of a finished checkout sends it again. + +`not_loaded` after 10 seconds usually means the page's site is not on the business's approved sites. + +## Payment methods in a frame + +Cards (with 3D Secure) and bank transfer work everywhere. Apple Pay shows on Safari 17+; Google Pay and Link are limited by Stripe inside another site's frame. diff --git a/packages/checkout/package.json b/packages/checkout/package.json new file mode 100644 index 0000000..f37a527 --- /dev/null +++ b/packages/checkout/package.json @@ -0,0 +1,52 @@ +{ + "name": "@agentaos/checkout", + "version": "0.0.0", + "description": "AgentaOS embedded checkout for the browser — inline or overlay on your own page, with React support", + "license": "Apache-2.0", + "author": "AgentaOS ", + "repository": { + "type": "git", + "url": "https://github.com/AgentaOS/agentaos.git", + "directory": "packages/checkout" + }, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./react": { + "types": "./dist/react.d.ts", + "import": "./dist/react.js" + } + }, + "files": ["dist"], + "sideEffects": false, + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "react": ">=18" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.7", + "jsdom": "^25.0.0", + "react": "^18.3.0", + "react-dom": "^18.3.0" + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "lint": "biome check src/", + "test": "vitest run", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist" + } +} diff --git a/packages/checkout/src/index.ts b/packages/checkout/src/index.ts new file mode 100644 index 0000000..e700585 --- /dev/null +++ b/packages/checkout/src/index.ts @@ -0,0 +1,12 @@ +export { DEFAULT_ORIGIN, loadAgentaOS, type LoadOptions } from './load.js'; +export type { + AgentaOS, + CheckoutBehaviour, + CheckoutCompletedData, + CheckoutErrorData, + CheckoutEvent, + CheckoutHandle, + CheckoutLoadedData, + CheckoutSource, + OpenOptions, +} from './types.js'; diff --git a/packages/checkout/src/load.server.test.ts b/packages/checkout/src/load.server.test.ts new file mode 100644 index 0000000..69417e7 --- /dev/null +++ b/packages/checkout/src/load.server.test.ts @@ -0,0 +1,9 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { loadAgentaOS } from './load.js'; + +describe('loadAgentaOS on the server', () => { + it('resolves null, so it is safe in code that also renders on the server', async () => { + await expect(loadAgentaOS()).resolves.toBeNull(); + }); +}); diff --git a/packages/checkout/src/load.test.ts b/packages/checkout/src/load.test.ts new file mode 100644 index 0000000..eb06779 --- /dev/null +++ b/packages/checkout/src/load.test.ts @@ -0,0 +1,74 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest'; +import { loadAgentaOS, resetForTests } from './load.js'; +import type { AgentaOS } from './types.js'; + +const FAKE = { checkout: { open: () => ({ update() {}, close() {} }) } } as unknown as AgentaOS; + +const scripts = () => [...document.querySelectorAll('script')]; + +function scriptLoads(defineGlobal = true) { + if (defineGlobal) window.AgentaOS = FAKE; + scripts().at(-1)?.dispatchEvent(new Event('load')); +} + +afterEach(() => { + for (const script of scripts()) script.remove(); + window.AgentaOS = undefined; + resetForTests(); +}); + +describe('loadAgentaOS', () => { + it('adds the script once, however many callers ask at the same time', async () => { + const first = loadAgentaOS(); + const second = loadAgentaOS(); + expect(scripts()).toHaveLength(1); + expect(scripts()[0]?.src).toBe('https://app.agentaos.ai/v1/agentaos.js'); + + scriptLoads(); + + await expect(first).resolves.toBe(FAKE); + await expect(second).resolves.toBe(FAKE); + }); + + it('reuses AgentaOS already on the page (a script tag added by hand)', async () => { + window.AgentaOS = FAKE; + + await expect(loadAgentaOS()).resolves.toBe(FAKE); + expect(scripts()).toHaveLength(0); + }); + + it('loads from another origin while developing, trailing slash or not', () => { + void loadAgentaOS({ origin: 'http://localhost:3000/' }); + + expect(scripts()[0]?.src).toBe('http://localhost:3000/v1/agentaos.js'); + }); + + it('says in a sentence when the script cannot load, and a later call tries again', async () => { + const first = loadAgentaOS(); + scripts()[0]?.dispatchEvent(new Event('error')); + + await expect(first).rejects.toThrow( + /the checkout script didn't load from https:\/\/app\.agentaos\.ai\/v1\/agentaos\.js/, + ); + expect(scripts()).toHaveLength(0); + + const second = loadAgentaOS(); + expect(scripts()).toHaveLength(1); + scriptLoads(); + await expect(second).resolves.toBe(FAKE); + }); + + it('rejects when the file loads but is not the checkout script, and a later call tries again', async () => { + const loading = loadAgentaOS(); + scriptLoads(false); + + await expect(loading).rejects.toThrow(/didn't load/); + expect(scripts()).toHaveLength(0); + + const retry = loadAgentaOS(); + expect(scripts()).toHaveLength(1); + scriptLoads(); + await expect(retry).resolves.toBe(FAKE); + }); +}); diff --git a/packages/checkout/src/load.ts b/packages/checkout/src/load.ts new file mode 100644 index 0000000..d574400 --- /dev/null +++ b/packages/checkout/src/load.ts @@ -0,0 +1,67 @@ +import type { AgentaOS } from './types.js'; + +/** Where the checkout script and the checkout itself are served from. */ +export const DEFAULT_ORIGIN = 'https://app.agentaos.ai'; + +export interface LoadOptions { + /** + * The AgentaOS app origin. Leave out in production. Point it at a local stack while + * developing, e.g. `http://localhost:3000`. Only the first load on a page counts: the script + * is loaded once, and later calls reuse it whatever origin they pass. + */ + origin?: string; +} + +declare global { + interface Window { + AgentaOS?: AgentaOS; + } +} + +let loading: Promise | null = null; + +/** + * Loads `/v1/agentaos.js` once and resolves `window.AgentaOS`. On the server (no `window`) it + * resolves `null`, so it is safe to call from code that also renders on the server. + */ +export function loadAgentaOS(options: LoadOptions = {}): Promise { + if (typeof window === 'undefined') return Promise.resolve(null); + if (window.AgentaOS) return Promise.resolve(window.AgentaOS); + loading ??= injectScript(scriptUrl(options.origin ?? DEFAULT_ORIGIN)).catch((error: unknown) => { + loading = null; // a later call may try again (e.g. after the network comes back) + throw error; + }); + return loading; +} + +function scriptUrl(origin: string): string { + return `${origin.replace(/\/+$/, '')}/v1/agentaos.js`; +} + +function injectScript(src: string): Promise { + return new Promise((resolve, reject) => { + const script = + document.querySelector(`script[src="${src}"]`) ?? + document.createElement('script'); + const fail = () => { + script.remove(); // so a later call inserts a fresh tag instead of waiting on a dead one + reject( + new Error( + `AgentaOS: the checkout script didn't load from ${src}. Check the network, and that the origin is right.`, + ), + ); + }; + script.addEventListener('load', () => (window.AgentaOS ? resolve(window.AgentaOS) : fail())); + script.addEventListener('error', fail); + if (!script.isConnected) { + script.src = src; + script.async = true; + document.head.appendChild(script); + } + }); +} + +/** Test seam: forget a finished or failed load. */ +export function resetForTests(): void { + loading = null; +} diff --git a/packages/checkout/src/react.test.tsx b/packages/checkout/src/react.test.tsx new file mode 100644 index 0000000..2414fd7 --- /dev/null +++ b/packages/checkout/src/react.test.tsx @@ -0,0 +1,152 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { type Root, createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { resetForTests } from './load.js'; +import { AgentaOSCheckout, useAgentaOSCheckout } from './react.js'; +import type { CheckoutEvent, OpenOptions } from './types.js'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const MONTHLY = 'https://app.agentaos.ai/pay/monthly0000000000000A'; +const YEARLY = 'https://app.agentaos.ai/pay/yearly00000000000000A'; + +let handle: { update: ReturnType; close: ReturnType }; +let open: ReturnType typeof handle>>; +let root: Root; +let host: HTMLDivElement; + +beforeEach(() => { + handle = { update: vi.fn(), close: vi.fn() }; + open = vi.fn(() => handle); + window.AgentaOS = { checkout: { open } } as never; + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); +}); + +afterEach(() => { + act(() => root.unmount()); + for (const script of document.querySelectorAll('script')) script.remove(); + host.remove(); + window.AgentaOS = undefined; + resetForTests(); +}); + +/** Renders and lets the loader's promise settle. */ +async function render(node: React.ReactNode) { + await act(async () => { + root.render(node); + }); +} + +describe('', () => { + it('opens the checkout inline, into its own element, with the prefill', async () => { + await render( + , + ); + + expect(open).toHaveBeenCalledTimes(1); + const options = open.mock.calls[0]?.[0] as OpenOptions; + expect(options).toMatchObject({ link: MONTHLY, email: 'ann@example.com', country: 'DE' }); + expect(options.target).toBe(host.querySelector('div.pay')); + }); + + it('switches the open checkout when the product changes, without opening another', async () => { + await render(); + await render(); + + expect(open).toHaveBeenCalledTimes(1); + expect(handle.update).toHaveBeenCalledWith({ link: YEARLY }); + }); + + it('passes events to the latest onEvent', async () => { + const first = vi.fn(); + const latest = vi.fn(); + await render(); + await render(); + + const event: CheckoutEvent = { name: 'checkout.closed', data: {} }; + (open.mock.calls[0]?.[0] as OpenOptions).onEvent?.(event); + + expect(latest).toHaveBeenCalledWith(event); + expect(first).not.toHaveBeenCalled(); + }); + + it('removes the checkout when it unmounts', async () => { + await render(); + act(() => root.render(null)); + + expect(handle.close).toHaveBeenCalled(); + }); + + it('tells onEvent when the script cannot load, instead of leaving an empty box', async () => { + window.AgentaOS = undefined; + const onEvent = vi.fn(); + await render(); + + await act(async () => { + const script = document.querySelector('script'); + script?.dispatchEvent(new Event('error')); + }); + + expect(onEvent).toHaveBeenCalledWith({ + name: 'checkout.error', + data: { code: 'not_loaded', message: expect.stringMatching(/didn't load/) }, + }); + }); + + it('opens a checkout your server created', async () => { + await render(); + + expect(open.mock.calls[0]?.[0]).toMatchObject({ session: 'cs_abc' }); + }); +}); + +describe('useAgentaOSCheckout', () => { + function BuyButton() { + const { open: openOverlay } = useAgentaOSCheckout(); + return ( + + ); + } + + it('never opens after the component that asked has gone', async () => { + window.AgentaOS = undefined; + await render(); + await act(async () => { + host.querySelector('button')?.click(); + }); + act(() => root.render(null)); + + window.AgentaOS = { checkout: { open } } as never; + await act(async () => { + document.querySelector('script')?.dispatchEvent(new Event('load')); + }); + + expect(open).not.toHaveBeenCalled(); + }); + + it('requires exactly one of link or session (checked by the type)', () => { + const typeCheckOnly = (openOverlay: ReturnType['open']) => { + // @ts-expect-error — neither link nor session + void openOverlay({ email: 'ann@example.com' }); + // @ts-expect-error — both + void openOverlay({ link: MONTHLY, session: 'cs_1' }); + void openOverlay({ session: 'cs_1' }); + }; + expect(typeCheckOnly).toBeTypeOf('function'); + }); + + it('opens the overlay (no target) from a click', async () => { + await render(); + + await act(async () => { + host.querySelector('button')?.click(); + }); + + expect(open).toHaveBeenCalledWith({ link: MONTHLY, successUrl: false }); + }); +}); diff --git a/packages/checkout/src/react.tsx b/packages/checkout/src/react.tsx new file mode 100644 index 0000000..5dcb379 --- /dev/null +++ b/packages/checkout/src/react.tsx @@ -0,0 +1,129 @@ +'use client'; + +import { useCallback, useEffect, useRef } from 'react'; +import { loadAgentaOS } from './load.js'; +import type { + CheckoutBehaviour, + CheckoutEvent, + CheckoutHandle, + CheckoutSource, + OpenOptions, +} from './types.js'; + +export type AgentaOSCheckoutProps = CheckoutSource & { + /** Prefill; read when the checkout opens. */ + email?: string; + /** Prefill (ISO country code); read when the checkout opens. */ + country?: string; + /** Read when the checkout opens; switching `link` later does not change it. */ + successUrl?: string | false; + onEvent?: (event: CheckoutEvent) => void; + /** The AgentaOS app origin; leave out in production (see `loadAgentaOS`). */ + origin?: string; + className?: string; +}; + +/** What the overlay opens with: exactly one of `link` or `session`, never a `target`. */ +export type OverlayOptions = CheckoutSource & Omit; + +/** + * The checkout inline, where this component sits. Changing `link` or `session` switches the + * open checkout (e.g. a Monthly/Yearly toggle); unmounting removes it. If the checkout script + * cannot load, `onEvent` hears `checkout.error` with `code: 'not_loaded'`. + */ +export function AgentaOSCheckout({ + link, + session, + email, + country, + successUrl, + onEvent, + origin, + className, +}: AgentaOSCheckoutProps) { + const container = useRef(null); + const handle = useRef(null); + // The latest callback, without reopening the checkout whenever a parent re-renders. + const onEventRef = useRef(onEvent); + onEventRef.current = onEvent; + const source = sourceOf(link, session); + // The latest product, so a switch made while the script is still loading is not lost. + const sourceRef = useRef(source); + sourceRef.current = source; + + // Opens once per origin; later product switches go through update(). Prefill is a one-time hint. + // biome-ignore lint/correctness/useExhaustiveDependencies: opening again on every prop change would reset the buyer's form + useEffect(() => { + let cancelled = false; + loadAgentaOS({ origin }) + .then((agentaos) => { + if (cancelled || !agentaos || !container.current) return; + handle.current = agentaos.checkout.open({ + ...sourceRef.current, + target: container.current, + email, + country, + successUrl, + onEvent: (event) => onEventRef.current?.(event), + } as OpenOptions); + }) + .catch((error: Error) => { + if (cancelled) return; + onEventRef.current?.({ + name: 'checkout.error', + data: { code: 'not_loaded', message: error.message }, + }); + }); + return () => { + cancelled = true; + handle.current?.close(); + handle.current = null; + }; + }, [origin]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: keyed on the product itself, not the object identity + useEffect(() => { + handle.current?.update(sourceRef.current); + }, [source.link, source.session]); + + return
; +} + +/** + * The overlay: `open()` shows the checkout over the page, e.g. from a Buy button. The overlay + * lives as long as the component that called this hook: unmounting it closes the overlay, even + * mid-payment, and `onEvent` then hears `checkout.closed`. + */ +export function useAgentaOSCheckout({ origin }: { origin?: string } = {}) { + const handle = useRef(null); + const mounted = useRef(true); + + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + handle.current?.close(); + handle.current = null; + }; + }, []); + + const open = useCallback( + async (options: OverlayOptions): Promise => { + const agentaos = await loadAgentaOS({ origin }); + // The component that asked may be gone by the time the script arrives. + if (!agentaos || !mounted.current) return null; + handle.current?.close(); + handle.current = agentaos.checkout.open(options); + return handle.current; + }, + [origin], + ); + + return { open }; +} + +function sourceOf(link: string | undefined, session: string | undefined): CheckoutSource { + if (link) return { link }; + if (session) return { session }; + throw new Error('AgentaOSCheckout: pass `link` or `session`.'); +} diff --git a/packages/checkout/src/types.ts b/packages/checkout/src/types.ts new file mode 100644 index 0000000..3156e29 --- /dev/null +++ b/packages/checkout/src/types.ts @@ -0,0 +1,90 @@ +/** + * The v1 contract of the AgentaOS checkout script (`/v1/agentaos.js`). The script is the one + * implementation; this package loads it and types it. A breaking change ships as `/v2/`. + */ + +/** Where the checkout comes from: a product's buyer link, or a checkout your server created. */ +export type CheckoutSource = + | { + /** The product's buyer link, e.g. `https://app.agentaos.ai/pay/UID95sZlBqXrVKlUHKmLhQ`. */ + link: string; + session?: never; + } + | { + /** `sessionId` of a checkout your server created with `checkouts.create`. */ + session: string; + link?: never; + }; + +/** Everything `open()` takes besides where the checkout comes from. */ +export interface CheckoutBehaviour { + /** CSS selector or element to render into (inline). Leave out for the overlay. */ + target?: string | HTMLElement; + /** Prefills the buyer's email. Sent in the handshake, never in a URL. */ + email?: string; + /** Prefills the buyer's country, ISO 3166-1 alpha-2 (e.g. `DE`). */ + country?: string; + /** + * Where the whole page goes after a completed payment (https only; `sessionId` is added). + * Defaults to the checkout's success URL: the one given to + * `checkouts.create`, else the product's. `false` keeps the page where it is. + */ + successUrl?: string | false; + onEvent?: (event: CheckoutEvent) => void; +} + +/** Read when the checkout opens; `update()` changes only the product or checkout. */ +export type OpenOptions = CheckoutSource & CheckoutBehaviour; + +export interface CheckoutLoadedData { + product: { + name: string; + type: 'one_time' | 'subscription'; + billingInterval: 'month' | 'year' | null; + trialDays: number; + }; + currency: string; + testMode: boolean; +} + +export interface CheckoutCompletedData { + sessionId: string; + /** `bank_transfer`: the buyer has the bank details; the money has not arrived yet. */ + kind: 'payment' | 'trial' | 'bank_transfer'; + email: string; + /** What was charged today (0 for a free trial), or the amount to send for a bank transfer. */ + amountMinor: number; + currency: string; + successUrl: string | null; +} + +export interface CheckoutErrorData { + code: 'not_loaded' | 'not_found' | 'unavailable'; + message: string; +} + +/** + * Everything the checkout tells your page. An event is never proof of payment: unlock access + * from the webhook or `checkouts.retrieve`, and handle `checkout.completed` once per `sessionId` + * (a reload of a finished checkout sends it again). + */ +export type CheckoutEvent = + | { name: 'checkout.loaded'; data: CheckoutLoadedData } + | { name: 'checkout.completed'; data: CheckoutCompletedData } + /** The overlay closed — by the buyer, or by `close()` (including a React unmount). */ + | { name: 'checkout.closed'; data: Record } + | { name: 'checkout.error'; data: CheckoutErrorData }; + +export interface CheckoutHandle { + /** Switches the open checkout to another product or checkout (e.g. Monthly → Yearly). */ + update(source: CheckoutSource): void; + /** Removes the checkout from the page. */ + close(): void; +} + +/** `window.AgentaOS`, as the script defines it. */ +export interface AgentaOS { + checkout: { + open(options: OpenOptions): CheckoutHandle; + }; +} diff --git a/packages/checkout/tsconfig.build.json b/packages/checkout/tsconfig.build.json new file mode 100644 index 0000000..49ae17a --- /dev/null +++ b/packages/checkout/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] +} diff --git a/packages/checkout/tsconfig.json b/packages/checkout/tsconfig.json new file mode 100644 index 0000000..83444b4 --- /dev/null +++ b/packages/checkout/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx" + }, + "include": ["src/**/*"] +} diff --git a/packages/mcp-remote/src/__tests__/mcp.test.ts b/packages/mcp-remote/src/__tests__/mcp.test.ts index e3f8628..44e12f5 100644 --- a/packages/mcp-remote/src/__tests__/mcp.test.ts +++ b/packages/mcp-remote/src/__tests__/mcp.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Env } from '../env.js'; import { handleMcp } from '../mcp.js'; @@ -19,10 +19,22 @@ const EXPECTED_TOOLS = [ 'agenta_subscriptions_list', 'agenta_subscriptions_cancel', 'agenta_subscriptions_change_plan', + 'agenta_subscriptions_credit', + 'agenta_subscriptions_credits', + 'agenta_discounts_create', + 'agenta_discounts_list', + 'agenta_discounts_show', + 'agenta_discounts_archive', 'agenta_customers_list', 'agenta_invoices_list', 'agenta_invoices_receipt', 'agenta_invoices_send_receipt', + 'agenta_businesses_list', + 'agenta_businesses_get', + 'agenta_businesses_create', + 'agenta_businesses_invite', + 'agenta_businesses_revoke_invite', + 'agenta_businesses_id_link', ]; const env = { @@ -45,6 +57,25 @@ async function readJsonRpcResult(response: Response): Promise<{ tools: { name: s } describe('remote MCP endpoint', () => { + it('turns the server-initiated SSE stream down instead of holding one open', async () => { + // The stream this GET asks for is the one that put the Worker in a reconnect loop. + const request = new Request('http://mcp.test/mcp', { + method: 'GET', + headers: { accept: 'text/event-stream' }, + }); + + const response = await handleMcp(request, env, { + apiKey: 'sk_test_dummy', + keyPrefix: 'sk_test_dumm', + mode: 'test', + }); + + expect(response.status).toBe(405); + expect(response.headers.get('allow')).toBe('POST'); + // No body and no content type: nothing for the client to hold open. + expect(response.headers.get('content-type')).toBeNull(); + }); + it('lists every merchant operation as a tool for a connection', async () => { const request = new Request('http://mcp.test/mcp', { method: 'POST', @@ -65,4 +96,45 @@ describe('remote MCP endpoint', () => { const { tools } = await readJsonRpcResult(response); expect(tools.map((t) => t.name).sort()).toEqual([...EXPECTED_TOOLS].sort()); }); + + // Connect (PRD §6.1 R9-3): a tool called with `business` acts for that business upstream — + // on the hosted connector too, never silently as the platform. + it('a tool called with business sends AgentaOS-Account to the API', async () => { + const seen: Array = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (_url: string, init?: RequestInit) => { + seen.push(((init?.headers ?? {}) as Record)['agentaos-account'] ?? null); + return new Response(JSON.stringify({ items: [], total: 0, hasMore: false }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }), + ); + const call = (args: Record) => + handleMcp( + new Request('http://mcp.test/mcp', { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'agenta_products_list', arguments: args }, + }), + }), + env, + { apiKey: 'sk_test_dummy', keyPrefix: 'sk_test_dumm', mode: 'test' }, + ); + + await (await call({ business: 'biz_1' })).text(); + await (await call({})).text(); + + expect(seen).toEqual(['biz_1', null]); + }); }); + +afterEach(() => vi.unstubAllGlobals()); diff --git a/packages/mcp-remote/src/mcp.ts b/packages/mcp-remote/src/mcp.ts index dec2f69..443bafa 100644 --- a/packages/mcp-remote/src/mcp.ts +++ b/packages/mcp-remote/src/mcp.ts @@ -14,10 +14,37 @@ export async function handleMcp( env: Env, props: ConnectionProps, ): Promise { + // A GET asks to open the server-initiated SSE stream. This server is stateless and every + // tool is request/response, so there is nothing to push down it — and the transport would + // answer with a ReadableStream nobody ever writes to. A Worker does not keep such a request + // alive: it is torn down, the stream dies, and the client reconnects immediately. That loop + // reached 21,000 requests an hour and spent the account's whole daily KV read allowance, + // because the provider reads the token out of KV on the way in, before any of this runs. + // The spec's answer for a server with nothing to push is 405, and a client that gets it + // stops asking. See "Listening for Messages from the Server", point 3. + if (request.method === 'GET') { + return new Response(null, { status: 405, headers: { allow: 'POST' } }); + } + const server = new McpServer({ name: 'agentaos', version: '0.1.0' }); - registerAgentaTools(server, () => new AgentaOS(props.apiKey, { baseUrl: env.AGENTAOS_API_URL })); + // `business`: the managed business a tool call acts for (Connect) — passed on, never dropped, + // or the call would run as the platform itself. + registerAgentaTools( + server, + (opts) => + new AgentaOS(props.apiKey, { + baseUrl: env.AGENTAOS_API_URL, + ...(opts?.business ? { business: opts.business } : {}), + }), + ); - const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + // enableJsonResponse for the same reason: a stateless server has no use for a streamed + // answer, and a plain JSON response completes inside the request instead of leaving a + // stream open for the runtime to collect. + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); await server.connect(transport); return transport.handleRequest(request); } diff --git a/packages/pay/README.md b/packages/pay/README.md index 9574336..41cf919 100644 --- a/packages/pay/README.md +++ b/packages/pay/README.md @@ -166,6 +166,20 @@ const subscription = await agentaos.paymentLinks.create({ }); ``` +**Paid trial** — `trialAmount` is what the whole trial costs, in currency units. Leave it out and the trial is free. It needs `trialPeriodDays`; `amount` stays the recurring price charged when the trial ends: + +```typescript +// €9 for the first 30 days, then €29.99 a month +const plan = await agentaos.paymentLinks.create({ + amount: 29.99, + currency: 'EUR', + type: 'subscription', + billingInterval: 'month', + trialPeriodDays: 30, + trialAmount: 9, +}); +``` + **Response:** | Field | Type | Description | @@ -180,6 +194,8 @@ const subscription = await agentaos.paymentLinks.create({ | `sellerMode` | `'mor' \| 'crypto'` | How this link settles | | `type` | `'one_time' \| 'subscription'` | Link type | | `billingInterval` | `'month' \| 'year' \| null` | Cadence for subscriptions; null for one-time | +| `trialPeriodDays` | `number \| null` | Trial length in days; null when there is no trial | +| `trialAmount` | `number \| null` | What the whole trial costs; null when it is free | | `paymentCount` | `number` | Times this link has been paid | | `createdAt` | `string` | ISO 8601 | @@ -213,8 +229,9 @@ Paginated — returns `{ items, total, hasMore }` (`total` is the full count, `h ```typescript const page = await agentaos.subscriptions.list({ - limit: 20, // 1-100, default 20 + limit: 20, // 1-100, default 20 offset: 0, + discountCode: 'LAUNCH20', // optional: only subscribers who typed this code }); console.log(page.total, page.hasMore); ``` @@ -229,6 +246,7 @@ console.log(page.total, page.hasMore); | `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 | +| `discount` | `{ code, kind } \| null` | The code this subscriber typed; `kind` is `'discount'` or `'tracking'`. What it took off is on the first invoice | | `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 | @@ -255,6 +273,84 @@ await agentaos.subscriptions.cancel('uuid', { atPeriodEnd: false }); | `cancelAtPeriodEnd` | `boolean` | Whether the subscription is scheduled to cancel at period end | | `effectiveCancelDate` | `string \| null` | ISO 8601 date the cancellation takes effect | +### `subscriptions.credit(id, params)` + +Put credit on a subscriber's account with you. It comes off their next invoice, reducing what their card is charged; it is not a refund and no money moves out. Owner or admin on a signed-in session only — an API key cannot, because the credit has to name the person who gave it. + +`idempotencyKey` is **required**. It rides the `Idempotency-Key` header, and it is the only thing standing between a timeout you retry and a subscriber credited twice: there is no local record of a credit for the server to recognise a repeat by. Send the same key when you retry the same grant; send a different one when you mean to credit twice. + +```typescript +const credit = await agentaos.subscriptions.credit('uuid', { + amountMinor: 500, // 5.00 in the subscription's currency + reason: 'Two days of downtime in August', + idempotencyKey: 'credit-2026-08-downtime-acme', +}); +console.log(credit.nextInvoiceDueAfterCreditMinor); // what the next invoice will take +``` + +**Response:** + +| Field | Type | Description | +|-------|------|-------------| +| `id` | `string` | The card processor's own id for the entry | +| `amountMinor` | `number` | Credit given, integer minor units | +| `currency` | `string` | The subscription's currency | +| `creditBalanceMinor` | `number` | All unspent credit this customer has with you | +| `nextInvoiceMinor` | `number` | What the next invoice would have taken | +| `nextInvoiceDueAfterCreditMinor` | `number` | What it will take now | +| `nextInvoiceAt` | `string` | ISO 8601 | +| `createdBy` | `string` | Id of the person who gave it | + +### `subscriptions.credits(id)` + +Credits given on this subscription, newest first, and what is still unspent. `balanceMinor` is the **customer's** whole unspent credit with you: a buyer with two subscriptions shares one balance. + +```typescript +const { items, balanceMinor } = await agentaos.subscriptions.credits('uuid'); +``` + +--- + +## Discount codes + +Codes a buyer types at checkout on a subscription plan. A code can take a percentage off, a fixed amount off, or nothing at all — a code that changes no price still records who it brought in, which is how a referral works when the referred buyer pays full price. The code string is yours, so two merchants can both run `SAVE20`. + +### `discountCodes.create(params)` + +Terms are fixed once the code exists: to change them, archive it and create another. + +```typescript +const code = await agentaos.discountCodes.create({ + code: 'LAUNCH20', + name: 'Launch week', + percentOff: 20, // or amountOffMinor: 500 — at most one of the two + maxRedemptions: 100, // optional + linkId: 'plan-uuid', // optional: limit it to one plan +}); +``` + +Set neither `percentOff` nor `amountOffMinor` and you get a tracking-only code, which changes no price. + +### `discountCodes.list(params?)` · `get(id)` · `archive(id)` + +`list` is paginated and reads no terms — what each code takes off comes from the card processor, and fetching one per row would be a call per row. `get` is the one call that reports them. + +```typescript +const page = await agentaos.discountCodes.list({ limit: 20 }); +const detail = await agentaos.discountCodes.get('code-uuid'); +console.log(detail.termsLabel, detail.timesRedeemed); // '20% off', 14 + +await agentaos.discountCodes.archive('code-uuid'); // stops it now +``` + +Archiving stops the code working immediately; subscribers who already used it keep their discount. + +To see who one code brought in, filter the subscription list by it: + +```typescript +const page = await agentaos.subscriptions.list({ discountCode: 'LAUNCH20' }); +``` + --- ## Customers diff --git a/packages/pay/src/client.ts b/packages/pay/src/client.ts index c25627a..58c409e 100644 --- a/packages/pay/src/client.ts +++ b/packages/pay/src/client.ts @@ -1,6 +1,8 @@ import { AccountReviewResource } from './resources/account-review.js'; +import { BusinessesResource } from './resources/businesses.js'; import { CheckoutsResource } from './resources/checkouts.js'; import { CustomersResource } from './resources/customers.js'; +import { DiscountCodesResource } from './resources/discount-codes.js'; import { GoLiveResource } from './resources/go-live.js'; import { InvoicesResource } from './resources/invoices.js'; import { PaymentLinksResource } from './resources/payment-links.js'; @@ -20,8 +22,10 @@ export class AgentaOS { readonly invoices: InvoicesResource; readonly subscriptions: SubscriptionsResource; readonly customers: CustomersResource; + readonly discountCodes: DiscountCodesResource; readonly goLive: GoLiveResource; readonly accountReview: AccountReviewResource; + readonly businesses: BusinessesResource; readonly webhooks: WebhooksResource; constructor(apiKey: string, options?: AgentaOSOptions) { @@ -55,6 +59,7 @@ export class AgentaOS { // A key is bound to one org server-side; only a session user can be in // several, so only a session ever carries the choice. orgId: isApiKey ? undefined : options?.orgId, + business: options?.business, }; this.checkouts = new CheckoutsResource(baseUrl, apiKey, resourceOptions); @@ -63,8 +68,10 @@ export class AgentaOS { this.invoices = new InvoicesResource(baseUrl, apiKey, resourceOptions); this.subscriptions = new SubscriptionsResource(baseUrl, apiKey, resourceOptions); this.customers = new CustomersResource(baseUrl, apiKey, resourceOptions); + this.discountCodes = new DiscountCodesResource(baseUrl, apiKey, resourceOptions); this.goLive = new GoLiveResource(baseUrl, apiKey, resourceOptions); this.accountReview = new AccountReviewResource(baseUrl, apiKey, resourceOptions); + this.businesses = new BusinessesResource(baseUrl, apiKey, resourceOptions); this.webhooks = new WebhooksResource(); } } diff --git a/packages/pay/src/index.ts b/packages/pay/src/index.ts index 85bc600..aa47c60 100644 --- a/packages/pay/src/index.ts +++ b/packages/pay/src/index.ts @@ -14,10 +14,19 @@ export type { Invoice, ListInvoiceParams, Subscription, + SubscriptionDiscount, SubscriptionInvoice, SubscriptionStatus, + ListSubscriptionParams, CancelSubscriptionParams, CancelSubscriptionResult, + GrantCreditParams, + Credit, + CreditEntry, + CreditList, + CreateDiscountCodeParams, + DiscountCode, + DiscountCodeDetail, PendingPlanChange, PlanChangeDirection, PlanChangePreview, @@ -46,6 +55,16 @@ export type { SendCompletedData, SendFailedData, SubscriptionData, + DisputeData, + AccountUpdatedData, + WebhookTestData, + Business, + BusinessStatus, + CreateBusinessParams, + CreatedBusiness, + BusinessInvitation, + VerificationLink, + RequestOptions, PaginatedList, ListParams, } from './types.js'; diff --git a/packages/pay/src/resources/account-review.ts b/packages/pay/src/resources/account-review.ts index f986f20..9a75ae8 100644 --- a/packages/pay/src/resources/account-review.ts +++ b/packages/pay/src/resources/account-review.ts @@ -1,5 +1,11 @@ import { NotFoundError } from '../errors.js'; -import type { AccountReview, AuditRequested, RequestAudit, SubmitAccountReview } from '../types.js'; +import type { + AccountReview, + AuditRequested, + RequestAudit, + RequestOptions, + SubmitAccountReview, +} from '../types.js'; import { BaseResource } from './base.js'; const BASE_PATH = '/api/v1/gateway/account-review'; @@ -18,9 +24,13 @@ interface ReviewEnvelope { export class AccountReviewResource extends BaseResource { /** The verification on file, or null when the merchant has never asked for * an audit nor submitted anything. */ - async get(): Promise { + async get(req?: RequestOptions): Promise { try { - const { review } = await this.getJson>(BASE_PATH); + const { review } = await this.getJson>( + BASE_PATH, + undefined, + req, + ); return review; } catch (error) { if (error instanceof NotFoundError) return null; @@ -30,23 +40,23 @@ export class AccountReviewResource extends BaseResource { /** Submit business details for verification. The server rejects a submit * while a rejection cooldown is running or the account is on hold. */ - async submit(body: SubmitAccountReview): Promise { - const { review } = await this.postJson(BASE_PATH, body); + async submit(body: SubmitAccountReview, req?: RequestOptions): Promise { + const { review } = await this.postJson(BASE_PATH, body, req); return review; } /** Send the details already on file back for review after making the * changes ops asked for. No body: nothing is retyped. */ - async resubmit(): Promise { - const { review } = await this.postJson(`${BASE_PATH}/resubmit`, {}); + async resubmit(req?: RequestOptions): Promise { + const { review } = await this.postJson(`${BASE_PATH}/resubmit`, {}, req); return review; } /** Ask for the free audit. Gates nothing and never marks the account as * submitted; asking twice updates the product details but keeps the * original `requestedAt`. */ - async requestAudit(body: RequestAudit): Promise { - const { review } = await this.postJson(`${BASE_PATH}/audit`, body); + async requestAudit(body: RequestAudit, req?: RequestOptions): Promise { + const { review } = await this.postJson(`${BASE_PATH}/audit`, body, req); return review; } } diff --git a/packages/pay/src/resources/base.ts b/packages/pay/src/resources/base.ts index 2c1b9fe..edff744 100644 --- a/packages/pay/src/resources/base.ts +++ b/packages/pay/src/resources/base.ts @@ -1,3 +1,4 @@ +import type { RequestOptions } from '../types.js'; import { request, requestRaw, requestText } from '../utils/fetch.js'; type Query = Record; @@ -14,57 +15,63 @@ export class BaseResource { authMode?: 'api-key' | 'jwt'; /** Session users may belong to several orgs; this one goes on every request. */ orgId?: string; + /** The managed business every request of this client acts for (`AgentaOS-Account`). */ + business?: string; }, ) {} - protected async getJson(path: string, query?: Query): Promise { + protected async getJson(path: string, query?: Query, req?: RequestOptions): Promise { return request({ - ...this.transport(), + ...this.transport(req), method: 'GET', path, query: this.scoped(query), }); } - protected async postJson(path: string, body?: unknown, idempotencyKey?: string): Promise { + protected async postJson( + path: string, + body?: unknown, + req?: RequestOptions & { idempotencyKey?: string }, + ): Promise { return request({ - ...this.transport(), + ...this.transport(req), method: 'POST', path, query: this.scoped(), body, - idempotencyKey, + idempotencyKey: req?.idempotencyKey, }); } - protected async del(path: string): Promise { + protected async del(path: string, req?: RequestOptions): Promise { return request({ - ...this.transport(), + ...this.transport(req), method: 'DELETE', path, query: this.scoped(), }); } - protected async getRaw(path: string, query?: Query): Promise { + protected async getRaw(path: string, query?: Query, req?: RequestOptions): Promise { return requestRaw({ - ...this.transport(), + ...this.transport(req), method: 'GET', path, query: this.scoped(query), }); } - protected async getText(path: string, query?: Query): Promise { + protected async getText(path: string, query?: Query, req?: RequestOptions): Promise { return requestText({ - ...this.transport(), + ...this.transport(req), method: 'GET', path, query: this.scoped(query), }); } - private transport() { + private transport(req?: RequestOptions) { return { baseUrl: this.baseUrl, apiKey: this.apiKey, @@ -73,6 +80,8 @@ export class BaseResource { debug: this.options.debug, logger: this.options.logger, authMode: this.options.authMode, + // Per call (Stripe's per-request `stripeAccount`) wins over the client's. + business: req?.business ?? this.options.business, }; } diff --git a/packages/pay/src/resources/businesses.test.ts b/packages/pay/src/resources/businesses.test.ts new file mode 100644 index 0000000..8523cf2 --- /dev/null +++ b/packages/pay/src/resources/businesses.test.ts @@ -0,0 +1,188 @@ +// @vitest-environment node +/** + * Connect (PRD §6.1 R9-2): a platform manages its clients' businesses and acts for one — Stripe + * Connect's shape. `business` sends `AgentaOS-Account` (Stripe's `Stripe-Account`), on the client + * or on one call; `businesses` is the list / retrieve / create / invitation / ID-check-link API. + */ +import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; +import { AgentaOS } from '../client.js'; +import type { WebhookEvent } from '../types.js'; + +interface Call { + url: string; + method?: string; + body?: string; + account: string | null; +} + +function stub(responseBody: unknown = { ok: true }, status = 200): Call[] { + const calls: Call[] = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string, init?: RequestInit) => { + const headers = (init?.headers ?? {}) as Record; + calls.push({ + url: String(url), + method: init?.method, + body: init?.body as string | undefined, + account: headers['agentaos-account'] ?? null, + }); + return new Response(status === 204 ? null : JSON.stringify(responseBody), { + status, + headers: status === 204 ? {} : { 'content-type': 'application/json' }, + }); + }), + ); + return calls; +} + +const path = (call: Call | undefined) => new URL(call?.url ?? '').pathname; +const BASE = { baseUrl: 'https://api.example.com' }; +afterEach(() => vi.unstubAllGlobals()); + +describe('acting for a business (AgentaOS-Account)', () => { + it('a client made for a business sends it on every call', async () => { + const calls = stub({ items: [], total: 0, hasMore: false }); + const client = new AgentaOS('sk_test_key', { ...BASE, business: 'biz_1' }); + + await client.checkouts.list(); + await client.customers.list(); + + expect(calls.map((c) => c.account)).toEqual(['biz_1', 'biz_1']); + }); + + it('one call can act for another business; the rest stay as they were', async () => { + const calls = stub({ items: [], total: 0, hasMore: false }); + const platform = new AgentaOS('sk_test_key', BASE); + + await platform.checkouts.list(undefined, { business: 'biz_2' }); + await platform.checkouts.list(); + + expect(calls.map((c) => c.account)).toEqual(['biz_2', null]); + }); + + it('a per-call business wins over the client’s', async () => { + const calls = stub({ items: [], total: 0, hasMore: false }); + const client = new AgentaOS('sk_test_key', { ...BASE, business: 'biz_1' }); + + await client.paymentLinks.list(undefined, { business: 'biz_2' }); + + expect(calls[0]?.account).toBe('biz_2'); + }); +}); + +describe('businesses', () => { + const platform = () => new AgentaOS('sk_test_key', BASE); + + it('list → GET /gateway/businesses', async () => { + const calls = stub([]); + await platform().businesses.list(); + expect([calls[0]?.method, path(calls[0])]).toEqual(['GET', '/api/v1/gateway/businesses']); + }); + + it('retrieve → GET /gateway/businesses/:id', async () => { + const calls = stub({ id: 'biz_1' }); + await platform().businesses.retrieve('biz_1'); + expect([calls[0]?.method, path(calls[0])]).toEqual(['GET', '/api/v1/gateway/businesses/biz_1']); + }); + + it('create → POST with the fields as given; returns the invite link to send', async () => { + const calls = stub({ + business: { id: 'biz_1' }, + inviteToken: 't', + inviteUrl: 'https://app/invite/t', + }); + + const created = await platform().businesses.create({ + name: 'ClientCo', + country: 'EE', + clientEmail: 'ana@example.com', + sendInvitationEmail: false, + }); + + expect([calls[0]?.method, path(calls[0])]).toEqual(['POST', '/api/v1/gateway/businesses']); + expect(JSON.parse(calls[0]?.body ?? '{}')).toEqual({ + name: 'ClientCo', + country: 'EE', + clientEmail: 'ana@example.com', + sendInvitationEmail: false, + }); + expect(created.inviteUrl).toBe('https://app/invite/t'); + }); + + it('resendInvitation → POST /:id/invitation', async () => { + const calls = stub({ inviteToken: 't2', inviteUrl: 'https://app/invite/t2' }); + await platform().businesses.resendInvitation('biz_1', { sendInvitationEmail: false }); + expect([calls[0]?.method, path(calls[0])]).toEqual([ + 'POST', + '/api/v1/gateway/businesses/biz_1/invitation', + ]); + expect(JSON.parse(calls[0]?.body ?? '{}')).toEqual({ sendInvitationEmail: false }); + }); + + it('revokeInvitation → DELETE /:id/invitation', async () => { + const calls = stub(undefined, 204); + await platform().businesses.revokeInvitation('biz_1'); + expect([calls[0]?.method, path(calls[0])]).toEqual([ + 'DELETE', + '/api/v1/gateway/businesses/biz_1/invitation', + ]); + }); + + it('createVerificationLink → POST /:id/verification-link', async () => { + const calls = stub({ url: 'https://verify.didit.me/session/x', status: 'Not Started' }); + const link = await platform().businesses.createVerificationLink('biz_1'); + expect([calls[0]?.method, path(calls[0])]).toEqual([ + 'POST', + '/api/v1/gateway/businesses/biz_1/verification-link', + ]); + expect(link.url).toBe('https://verify.didit.me/session/x'); + }); +}); + +describe('WebhookEvent', () => { + it('lists every event the server sends, each naming its business', () => { + expectTypeOf().toEqualTypeOf< + | 'checkout.session.completed' + | 'send.completed' + | 'send.failed' + | 'subscription.created' + | 'subscription.renewed' + | 'subscription.payment_failed' + | 'subscription.updated' + | 'subscription.canceled' + | 'dispute.created' + | 'dispute.closed' + | 'account.updated' + | 'webhook.test' + >(); + expectTypeOf().toEqualTypeOf(); + }); +}); + +describe('webhooks.verify on Connect events', () => { + it('account.updated reads in camelCase, like every event the SDK returns', async () => { + const { createHmac } = await import('node:crypto'); + const agentaos = new AgentaOS('sk_test_key', BASE); + const body = JSON.stringify({ + id: 'evt_1', + type: 'account.updated', + business: 'biz_1', + data: { + verification: 'verified', + changes_requested: false, + identity_verified: true, + livemode: true, + }, + }); + const t = Math.floor(Date.now() / 1000); + const v1 = createHmac('sha256', 'whsec_test').update(`${t}.${body}`).digest('hex'); + + const event = agentaos.webhooks.verify(body, `t=${t},v1=${v1}`, 'whsec_test'); + + expect(event.business).toBe('biz_1'); + if (event.type !== 'account.updated') throw new Error('wrong type'); + expect(event.data.identityVerified).toBe(true); + expect(event.data.changesRequested).toBe(false); + }); +}); diff --git a/packages/pay/src/resources/businesses.ts b/packages/pay/src/resources/businesses.ts new file mode 100644 index 0000000..f340745 --- /dev/null +++ b/packages/pay/src/resources/businesses.ts @@ -0,0 +1,58 @@ +import type { + Business, + BusinessInvitation, + CreateBusinessParams, + CreatedBusiness, + RequestOptions, + VerificationLink, +} from '../types.js'; +import { BaseResource } from './base.js'; + +const BASE_PATH = '/api/v1/gateway/businesses'; + +/** + * The businesses you manage (Connect — Stripe's connected accounts). To act for one, pass + * `{ business: id }` to any call, or create the client with it: `new AgentaOS(key, { business })`. + */ +export class BusinessesResource extends BaseResource { + /** Every business you manage, with its status and your share. */ + async list(req?: RequestOptions): Promise { + return this.getJson(BASE_PATH, undefined, req); + } + + /** One business you manage. */ + async retrieve(id: string, req?: RequestOptions): Promise { + return this.getJson(`${BASE_PATH}/${id}`, undefined, req); + } + + /** + * Add a business. It starts in test mode and goes live after its own verification. With + * `clientEmail`, your client is invited as its admin: we email them, or — with + * `sendInvitationEmail: false` — you send them `inviteUrl` yourself. + */ + async create(params: CreateBusinessParams, req?: RequestOptions): Promise { + return this.postJson(BASE_PATH, params, req); + } + + /** Send the invitation again: a new link (the old one stops working), emailed unless you say not. */ + async resendInvitation( + id: string, + params?: { sendInvitationEmail?: boolean }, + req?: RequestOptions, + ): Promise { + return this.postJson(`${BASE_PATH}/${id}/invitation`, params ?? {}, req); + } + + /** Withdraw the invitation: its link stops working. */ + async revokeInvitation(id: string, req?: RequestOptions): Promise { + await this.del(`${BASE_PATH}/${id}/invitation`, req); + } + + /** + * The identity check link for your client — Stripe's Account Links. You send it; only your + * client can complete it. Needs a live key. `url` is null once they are verified. + */ + async createVerificationLink(id: string, req?: RequestOptions): Promise { + return this.postJson(`${BASE_PATH}/${id}/verification-link`, {}, req); + } +} diff --git a/packages/pay/src/resources/checkouts.ts b/packages/pay/src/resources/checkouts.ts index 419e71d..29a9c80 100644 --- a/packages/pay/src/resources/checkouts.ts +++ b/packages/pay/src/resources/checkouts.ts @@ -3,31 +3,33 @@ import type { CreateCheckoutParams, ListCheckoutParams, PaginatedList, + RequestOptions, } from '../types.js'; import { BaseResource } from './base.js'; const BASE_PATH = '/api/v1/gateway/sessions'; export class CheckoutsResource extends BaseResource { - async create(params: CreateCheckoutParams): Promise { + async create(params: CreateCheckoutParams, req?: RequestOptions): 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.postJson(BASE_PATH, params); + return this.postJson(BASE_PATH, params, req); } - async list(params?: ListCheckoutParams): Promise> { + async list(params?: ListCheckoutParams, req?: RequestOptions): Promise> { return this.getJson>( BASE_PATH, params as Record, + req, ); } - async retrieve(sessionId: string): Promise { - return this.getJson(`${BASE_PATH}/${sessionId}`); + async retrieve(sessionId: string, req?: RequestOptions): Promise { + return this.getJson(`${BASE_PATH}/${sessionId}`, undefined, req); } - async cancel(sessionId: string): Promise<{ success: boolean }> { - return this.postJson<{ success: boolean }>(`${BASE_PATH}/${sessionId}/cancel`); + async cancel(sessionId: string, req?: RequestOptions): Promise<{ success: boolean }> { + return this.postJson<{ success: boolean }>(`${BASE_PATH}/${sessionId}/cancel`, undefined, req); } } diff --git a/packages/pay/src/resources/customers.ts b/packages/pay/src/resources/customers.ts index 99d3800..8a62326 100644 --- a/packages/pay/src/resources/customers.ts +++ b/packages/pay/src/resources/customers.ts @@ -1,4 +1,4 @@ -import type { Customer, ListParams, PaginatedList } from '../types.js'; +import type { Customer, ListParams, PaginatedList, RequestOptions } from '../types.js'; import { BaseResource } from './base.js'; const BASE_PATH = '/api/v1/gateway/customers'; @@ -6,10 +6,11 @@ const BASE_PATH = '/api/v1/gateway/customers'; /** Read the customers who have paid you (mirrors the dashboard Customers list). */ export class CustomersResource extends BaseResource { /** List customers in the current environment (test/live from the API key), paginated. */ - async list(params?: ListParams): Promise> { + async list(params?: ListParams, req?: RequestOptions): Promise> { return this.getJson>( BASE_PATH, params as Record, + req, ); } } diff --git a/packages/pay/src/resources/discount-codes.ts b/packages/pay/src/resources/discount-codes.ts new file mode 100644 index 0000000..2f76d1b --- /dev/null +++ b/packages/pay/src/resources/discount-codes.ts @@ -0,0 +1,54 @@ +import type { + CreateDiscountCodeParams, + DiscountCode, + DiscountCodeDetail, + ListParams, + PaginatedList, + RequestOptions, +} from '../types.js'; +import { BaseResource } from './base.js'; + +const BASE_PATH = '/api/v1/gateway/discount-codes'; + +/** + * Codes a buyer types at checkout. A code may take a percentage off, a fixed amount + * off, or NOTHING AT ALL — a code with no discount still records who it brought in, + * which is how a referral works when the referred buyer pays full price. Codes apply + * to subscriptions only. + * + * The code string is yours, so two merchants can both run SAVE20 without colliding. + */ +export class DiscountCodesResource extends BaseResource { + /** + * Mint a code. Terms are fixed once it exists: to change them, archive this one + * and create another, so nobody is quoted a price that later changes under them. + */ + async create(params: CreateDiscountCodeParams, req?: RequestOptions): Promise { + return this.postJson(BASE_PATH, params, req); + } + + /** + * Your codes in this environment: the code, whether it discounts or only tracks, + * whether it still works, and the plan it is limited to. What each one takes off + * is on `get` — it comes from the card processor, and fetching one per row would + * be a call per row. + */ + async list(params?: ListParams, req?: RequestOptions): Promise> { + return this.getJson>( + BASE_PATH, + params as Record, + req, + ); + } + + /** One code with its terms, how many subscribers used it, and whether it is still + * valid — read live. */ + async get(id: string, req?: RequestOptions): Promise { + return this.getJson(`${BASE_PATH}/${id}`, undefined, req); + } + + /** Stops the code working now. Subscribers who already used it keep their discount. */ + async archive(id: string, req?: RequestOptions): Promise { + return this.postJson(`${BASE_PATH}/${id}/archive`, undefined, req); + } +} diff --git a/packages/pay/src/resources/go-live.ts b/packages/pay/src/resources/go-live.ts index 4cd7742..fca8e41 100644 --- a/packages/pay/src/resources/go-live.ts +++ b/packages/pay/src/resources/go-live.ts @@ -1,4 +1,4 @@ -import type { GoLiveReadiness } from '../types.js'; +import type { GoLiveReadiness, RequestOptions } from '../types.js'; import { BaseResource } from './base.js'; const BASE_PATH = '/api/v1/gateway/go-live'; @@ -11,7 +11,7 @@ const BASE_PATH = '/api/v1/gateway/go-live'; export class GoLiveResource extends BaseResource { /** Where the merchant is on the road to live payments: verification state, * payout account, open change requests, the free audit, milestones. */ - async get(): Promise { - return this.getJson(BASE_PATH); + async get(req?: RequestOptions): Promise { + return this.getJson(BASE_PATH, undefined, req); } } diff --git a/packages/pay/src/resources/invoices.ts b/packages/pay/src/resources/invoices.ts index fb62d1d..dd3c329 100644 --- a/packages/pay/src/resources/invoices.ts +++ b/packages/pay/src/resources/invoices.ts @@ -1,40 +1,48 @@ -import type { Invoice, ListInvoiceParams, PaginatedList } from '../types.js'; +import type { Invoice, ListInvoiceParams, PaginatedList, RequestOptions } from '../types.js'; import { BaseResource } from './base.js'; const BASE_PATH = '/api/v1/gateway/invoices'; export class InvoicesResource extends BaseResource { - async list(params?: ListInvoiceParams): Promise> { + async list(params?: ListInvoiceParams, req?: RequestOptions): Promise> { return this.getJson>( BASE_PATH, params as Record, + req, ); } - async retrieve(id: string): Promise { - return this.getJson(`${BASE_PATH}/${id}`); + async retrieve(id: string, req?: RequestOptions): Promise { + return this.getJson(`${BASE_PATH}/${id}`, undefined, req); } - async void(id: string): Promise<{ success: boolean }> { - return this.postJson<{ success: boolean }>(`${BASE_PATH}/${id}/void`); + async void(id: string, req?: RequestOptions): Promise<{ success: boolean }> { + return this.postJson<{ success: boolean }>(`${BASE_PATH}/${id}/void`, undefined, req); } - async downloadPdf(id: string): Promise { - return this.getRaw(`${BASE_PATH}/${id}/pdf`); + async downloadPdf(id: string, req?: RequestOptions): Promise { + return this.getRaw(`${BASE_PATH}/${id}/pdf`, undefined, req); } - async downloadStatement(params: { from: string; to: string }): Promise { - return this.getRaw(`${BASE_PATH}/statement`, params); + async downloadStatement( + params: { from: string; to: string }, + req?: RequestOptions, + ): Promise { + return this.getRaw(`${BASE_PATH}/statement`, params, req); } - async exportCsv(params?: { - from?: string; - to?: string; - status?: string; - }): Promise { + async exportCsv( + params?: { + from?: string; + to?: string; + status?: string; + }, + req?: RequestOptions, + ): Promise { return this.getText( `${BASE_PATH}/export`, params as Record, + req, ); } @@ -42,12 +50,16 @@ export class InvoicesResource extends BaseResource { * 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`); + async getReceipt(id: string, req?: RequestOptions): Promise { + return this.getRaw(`${BASE_PATH}/${id}/receipt`, undefined, req); } /** Re-send the receipt email to the buyer on file. Paid invoices only. */ - async sendReceipt(id: string): Promise<{ ok: true; sentTo: string }> { - return this.postJson<{ ok: true; sentTo: string }>(`${BASE_PATH}/${id}/send-receipt`); + async sendReceipt(id: string, req?: RequestOptions): Promise<{ ok: true; sentTo: string }> { + return this.postJson<{ ok: true; sentTo: string }>( + `${BASE_PATH}/${id}/send-receipt`, + undefined, + req, + ); } } diff --git a/packages/pay/src/resources/payment-links.ts b/packages/pay/src/resources/payment-links.ts index c8f606a..7487c78 100644 --- a/packages/pay/src/resources/payment-links.ts +++ b/packages/pay/src/resources/payment-links.ts @@ -1,28 +1,35 @@ -import type { CreatePaymentLinkParams, ListParams, PaginatedList, PaymentLink } from '../types.js'; +import type { + CreatePaymentLinkParams, + ListParams, + PaginatedList, + PaymentLink, + RequestOptions, +} from '../types.js'; import { BaseResource } from './base.js'; const BASE_PATH = '/api/v1/gateway/payment-links'; export class PaymentLinksResource extends BaseResource { - async create(params: CreatePaymentLinkParams): Promise { + async create(params: CreatePaymentLinkParams, req?: RequestOptions): 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.postJson(BASE_PATH, params); + return this.postJson(BASE_PATH, params, req); } - async list(params?: ListParams): Promise> { + async list(params?: ListParams, req?: RequestOptions): Promise> { return this.getJson>( BASE_PATH, params as Record, + req, ); } - async retrieve(id: string): Promise { - return this.getJson(`${BASE_PATH}/${id}`); + async retrieve(id: string, req?: RequestOptions): Promise { + return this.getJson(`${BASE_PATH}/${id}`, undefined, req); } - async cancel(id: string): Promise<{ success: boolean }> { - return this.del<{ success: boolean }>(`${BASE_PATH}/${id}`); + async cancel(id: string, req?: RequestOptions): Promise<{ success: boolean }> { + return this.del<{ success: boolean }>(`${BASE_PATH}/${id}`, req); } } diff --git a/packages/pay/src/resources/resources.test.ts b/packages/pay/src/resources/resources.test.ts index ec11f65..e877e81 100644 --- a/packages/pay/src/resources/resources.test.ts +++ b/packages/pay/src/resources/resources.test.ts @@ -325,6 +325,155 @@ describe('subscriptions', () => { issuedAt: '2026-08-18T00:00:00Z', }); }); + + it('list({ discountCode }) → sends the code as a query filter', async () => { + const calls = stubRoutes({ items: [], total: 0, has_more: false }); + await client().subscriptions.list({ discountCode: 'LAUNCH20' }); + expect(new URL(calls[0]?.url ?? '').searchParams.get('discountCode')).toBe('LAUNCH20'); + }); + + it('list carries each row’s redeemed code through', async () => { + stubRoutes({ + items: [{ id: 'sub_1', discount: { code: 'LAUNCH20', kind: 'discount' } }], + total: 1, + has_more: false, + }); + const page = await client().subscriptions.list(); + expect(page.items[0]?.discount).toEqual({ code: 'LAUNCH20', kind: 'discount' }); + }); +}); + +describe('subscription credits', () => { + const granted = { + id: 'cbtxn_1', + amount_minor: 500, + currency: 'eur', + reason: 'Two days of downtime', + credit_balance_minor: 500, + next_invoice_minor: 1900, + next_invoice_due_after_credit_minor: 1400, + next_invoice_at: '2026-10-01T00:00:00.000Z', + created_by: 'user_1', + created_at: '2026-09-14T10:00:00.000Z', + }; + + it('credit → POST /api/v1/gateway/subscriptions/:id/credits', async () => { + const calls = stubRoutes(granted); + await client().subscriptions.credit('sub_1', { + amountMinor: 500, + reason: 'Two days of downtime', + idempotencyKey: 'key-1', + }); + expect(calls[0]?.method).toBe('POST'); + expect(pathOf(calls[0])).toBe('/api/v1/gateway/subscriptions/sub_1/credits'); + }); + + // The key is a header, not a field: sending it in the body would make the server + // reject the call for an unknown property and leave the retry unprotected. + it('credit sends the caller’s key as the Idempotency-Key header, not in the body', async () => { + const headers: Array> = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (_url: string, init?: RequestInit) => { + headers.push(init?.headers as Record); + return new Response(JSON.stringify(granted), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }), + ); + await client().subscriptions.credit('sub_1', { + amountMinor: 500, + reason: 'Two days of downtime', + idempotencyKey: 'key-1', + }); + expect(headers[0]?.['idempotency-key']).toBe('key-1'); + }); + + it('credit keeps amountMinor and reason in the body', async () => { + const calls = stubRoutes(granted); + await client().subscriptions.credit('sub_1', { + amountMinor: 500, + reason: 'Two days of downtime', + idempotencyKey: 'key-1', + }); + expect(JSON.parse(calls[0]?.body ?? '{}')).toEqual({ + amountMinor: 500, + reason: 'Two days of downtime', + }); + }); + + it('credit camelizes the figures the merchant is shown', async () => { + stubRoutes(granted); + const credit = await client().subscriptions.credit('sub_1', { + amountMinor: 500, + reason: 'Two days of downtime', + idempotencyKey: 'key-1', + }); + expect(credit).toMatchObject({ + creditBalanceMinor: 500, + nextInvoiceDueAfterCreditMinor: 1400, + }); + }); + + it('credits → GET /api/v1/gateway/subscriptions/:id/credits', async () => { + const calls = stubRoutes({ items: [], balance_minor: 0 }); + const page = await client().subscriptions.credits('sub_1'); + expect(calls[0]?.method).toBe('GET'); + expect(pathOf(calls[0])).toBe('/api/v1/gateway/subscriptions/sub_1/credits'); + expect(page.balanceMinor).toBe(0); + }); +}); + +describe('discountCodes', () => { + it('create → POST /api/v1/gateway/discount-codes with the terms', async () => { + const calls = stubRoutes({ id: 'dc_1', code: 'LAUNCH20', kind: 'discount' }); + await client().discountCodes.create({ code: 'LAUNCH20', percentOff: 20 }); + expect(calls[0]?.method).toBe('POST'); + expect(pathOf(calls[0])).toBe('/api/v1/gateway/discount-codes'); + expect(JSON.parse(calls[0]?.body ?? '{}')).toEqual({ code: 'LAUNCH20', percentOff: 20 }); + }); + + it('list → GET /api/v1/gateway/discount-codes with pagination params', async () => { + const calls = stubRoutes({ items: [], total: 0, has_more: false }); + await client().discountCodes.list({ limit: 5, offset: 10 }); + const url = new URL(calls[0]?.url ?? ''); + expect(calls[0]?.method).toBe('GET'); + expect(url.pathname).toBe('/api/v1/gateway/discount-codes'); + expect(url.searchParams.get('limit')).toBe('5'); + }); + + it('list camelizes the plan each code is limited to', async () => { + stubRoutes({ + items: [{ id: 'dc_1', code: 'LAUNCH20', plan_link_id: 'link_1', plan_name: 'Pro' }], + total: 1, + has_more: false, + }); + const page = await client().discountCodes.list(); + expect(page.items[0]).toMatchObject({ planLinkId: 'link_1', planName: 'Pro' }); + }); + + it('get → GET /api/v1/gateway/discount-codes/:id, the one call that carries the terms', async () => { + const calls = stubRoutes({ + id: 'dc_1', + code: 'LAUNCH20', + terms_label: '20% off', + times_redeemed: 14, + max_redemptions: 100, + }); + const detail = await client().discountCodes.get('dc_1'); + expect(calls[0]?.method).toBe('GET'); + expect(pathOf(calls[0])).toBe('/api/v1/gateway/discount-codes/dc_1'); + expect(detail).toMatchObject({ termsLabel: '20% off', timesRedeemed: 14 }); + }); + + it('archive → POST /api/v1/gateway/discount-codes/:id/archive', async () => { + const calls = stubRoutes({ id: 'dc_1', code: 'LAUNCH20', active: false }); + const code = await client().discountCodes.archive('dc_1'); + expect(calls[0]?.method).toBe('POST'); + expect(pathOf(calls[0])).toBe('/api/v1/gateway/discount-codes/dc_1/archive'); + expect(code.active).toBe(false); + }); }); describe('customers', () => { diff --git a/packages/pay/src/resources/subscriptions.ts b/packages/pay/src/resources/subscriptions.ts index 0756ec4..c2bdbda 100644 --- a/packages/pay/src/resources/subscriptions.ts +++ b/packages/pay/src/resources/subscriptions.ts @@ -3,9 +3,13 @@ import type { CancelSubscriptionResult, ChangePlanParams, ChangePlanResult, - ListParams, + Credit, + CreditList, + GrantCreditParams, + ListSubscriptionParams, PaginatedList, PlanChangePreview, + RequestOptions, Subscription, SubscriptionInvoice, } from '../types.js'; @@ -20,11 +24,18 @@ const BASE_PATH = '/api/v1/gateway/subscriptions'; * the dashboard. There is no `create` here by design. */ export class SubscriptionsResource extends BaseResource { - /** List subscriptions in the current environment (test/live from the API key), paginated. */ - async list(params?: ListParams): Promise> { + /** + * List subscriptions in the current environment (test/live from the API key), + * paginated. Pass `discountCode` to see only the subscribers one code brought in. + */ + async list( + params?: ListSubscriptionParams, + req?: RequestOptions, + ): Promise> { return this.getJson>( BASE_PATH, params as Record, + req, ); } @@ -33,15 +44,45 @@ export class SubscriptionsResource extends BaseResource { * 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.postJson(`${BASE_PATH}/${id}/cancel`, { - atPeriodEnd: params?.atPeriodEnd ?? true, - }); + async cancel( + id: string, + params?: CancelSubscriptionParams, + req?: RequestOptions, + ): Promise { + return this.postJson( + `${BASE_PATH}/${id}/cancel`, + { + atPeriodEnd: params?.atPeriodEnd ?? true, + }, + req, + ); } /** Per-cycle invoices for one subscription, newest first. */ - async invoices(id: string): Promise { - return this.getJson(`${BASE_PATH}/${id}/invoices`); + async invoices(id: string, req?: RequestOptions): Promise { + return this.getJson(`${BASE_PATH}/${id}/invoices`, undefined, req); + } + + /** + * Put credit on this subscriber's account with you. It comes off their next + * invoice, reducing what their card is charged; it is not a refund and no money + * moves out. Owner or admin on a signed-in session only — an API key cannot, + * because the credit has to name the person who gave it. + * + * `idempotencyKey` is required, not optional. It rides the `Idempotency-Key` + * header, and it is the ONLY thing standing between a timeout you retry and a + * subscriber credited twice: there is no local record of a credit for the server + * to recognise a repeat by. Generating one for you would defeat it, because a + * retry would generate a second. + */ + async credit(id: string, params: GrantCreditParams, req?: RequestOptions): Promise { + const { idempotencyKey, ...body } = params; + return this.postJson(`${BASE_PATH}/${id}/credits`, body, { ...req, idempotencyKey }); + } + + /** Credits given on this subscription, newest first, and what is still unspent. */ + async credits(id: string, req?: RequestOptions): Promise { + return this.getJson(`${BASE_PATH}/${id}/credits`, undefined, req); } /** @@ -49,10 +90,18 @@ export class SubscriptionsResource extends BaseResource { * different subscription product in the same currency and billing interval; * the subscription must be active or trialing. */ - async previewPlanChange(id: string, targetLinkId: string): Promise { - return this.getJson(`${BASE_PATH}/${id}/plan-change/preview`, { - targetLinkId, - }); + async previewPlanChange( + id: string, + targetLinkId: string, + req?: RequestOptions, + ): Promise { + return this.getJson( + `${BASE_PATH}/${id}/plan-change/preview`, + { + targetLinkId, + }, + req, + ); } /** @@ -61,7 +110,11 @@ export class SubscriptionsResource extends BaseResource { * current period end and charges nothing today. Idempotent on * (subscription, target plan, prorationDate). */ - async changePlan(id: string, params: ChangePlanParams): Promise { - return this.postJson(`${BASE_PATH}/${id}/plan-change`, params); + async changePlan( + id: string, + params: ChangePlanParams, + req?: RequestOptions, + ): Promise { + return this.postJson(`${BASE_PATH}/${id}/plan-change`, params, req); } } diff --git a/packages/pay/src/resources/transactions.ts b/packages/pay/src/resources/transactions.ts index 50280a7..392894f 100644 --- a/packages/pay/src/resources/transactions.ts +++ b/packages/pay/src/resources/transactions.ts @@ -1,13 +1,22 @@ -import type { ListTransactionParams, PaginatedList, Transaction } from '../types.js'; +import type { + ListTransactionParams, + PaginatedList, + RequestOptions, + Transaction, +} from '../types.js'; import { BaseResource } from './base.js'; const BASE_PATH = '/api/v1/gateway/all-transactions'; export class TransactionsResource extends BaseResource { - async list(params?: ListTransactionParams): Promise> { + async list( + params?: ListTransactionParams, + req?: RequestOptions, + ): Promise> { return this.getJson>( BASE_PATH, params as Record, + req, ); } } diff --git a/packages/pay/src/types.ts b/packages/pay/src/types.ts index b18ab0b..bca4066 100644 --- a/packages/pay/src/types.ts +++ b/packages/pay/src/types.ts @@ -40,6 +40,18 @@ export interface AgentaOSOptions { * `?orgId=` on every request. API keys are bound to one org and need it not. */ orgId?: string; + + /** + * Act for a business you manage (Connect): every request of this client is sent with + * `AgentaOS-Account: ` — Stripe's `stripeAccount`. Omit to act as yourself. + */ + business?: string; +} + +/** Per-request options, the last argument of every method (Stripe's per-request options). */ +export interface RequestOptions { + /** Act for this managed business on this call only; overrides the client's `business`. */ + business?: string; } // --------------------------------------------------------------------------- @@ -100,8 +112,15 @@ export interface CreatePaymentLinkParams { type?: 'one_time' | 'subscription'; /** Billing cadence — REQUIRED when type is 'subscription', omit otherwise. */ billingInterval?: 'month' | 'year'; - /** Free-trial length in days (1–730). Subscription links only. */ + /** Trial length in days (1–730). Subscription links only. */ trialPeriodDays?: number; + /** + * What the trial costs, in currency units — `9` means 9.00 for the whole trial, + * not per month. Leave it out and the trial is free. Needs `trialPeriodDays`: a + * trial price with no trial length is refused. `amount` stays the recurring price, + * charged when the trial ends. + */ + trialAmount?: number; } export interface PaymentLink { @@ -122,8 +141,10 @@ export interface PaymentLink { type: 'one_time' | 'subscription'; /** Set only for subscription links; null for one-time links. */ billingInterval: 'month' | 'year' | null; - /** Free-trial length in days on a subscription link; null when there is no trial. */ + /** Trial length in days on a subscription link; null when there is no trial. */ trialPeriodDays: number | null; + /** What the trial costs, in currency units, for the whole trial; null when it is free. */ + trialAmount: number | null; checkoutUrl: string; metadata: Record; checkoutFields: CheckoutField[]; @@ -323,6 +344,29 @@ export interface Subscription { * stay what the buyer paid for until `effectiveAt`. */ pendingPlanChange: PendingPlanChange | null; + /** + * The code this subscriber typed at checkout, or null. What it took off is on the + * first invoice — read `invoices(id)` for the figure, not this. + */ + discount: SubscriptionDiscount | null; +} + +/** + * A redeemed code. `tracking` means the code took nothing off the price and exists so + * the merchant can see who it brought in; `discount` means it did reduce the first + * invoice. + */ +export interface SubscriptionDiscount { + code: string; + kind: 'discount' | 'tracking'; +} + +export interface ListSubscriptionParams extends ListParams { + /** + * Only subscribers who redeemed this code. The code string the merchant knows, not + * a discount-code id. Case-insensitive. + */ + discountCode?: string; } /** A scheduled downgrade, applied at the next renewal. */ @@ -426,6 +470,110 @@ export type ChangePlanResult = currency: string; }; +// --------------------------------------------------------------------------- +// Subscriber credits +// --------------------------------------------------------------------------- + +export interface GrantCreditParams { + /** + * Positive integer minor units of the subscription's currency (`500` = 5.00). There + * is no currency field: it is read off the subscription, because a mismatched one + * parks the money where no invoice can reach it. + */ + amountMinor: number; + /** Why you gave it. The subscriber never sees this; you and your operators do. */ + reason: string; + /** + * Required. Send one string per credit you mean to give, and the SAME one again if + * the call times out and you retry — that is what stops a retry becoming a second + * credit. Nothing on the server survives a retry to recognise it by, so there is no + * safe default we could pick for you. Two different keys for the same amount give + * two credits, which is how you deliberately credit someone twice. + */ + idempotencyKey: string; +} + +/** The whole screen after a grant: every figure is the server's, none is derived here. */ +export interface Credit { + /** The card processor's own id for the entry. There is no id of ours. */ + id: string; + amountMinor: number; + currency: string; + reason: string; + /** All unspent credit this customer has with you now, positive. */ + creditBalanceMinor: number; + /** What the next invoice would have taken before this credit. */ + nextInvoiceMinor: number; + /** What it will take after it. */ + nextInvoiceDueAfterCreditMinor: number; + nextInvoiceAt: string; + /** The id of the person who gave it. */ + createdBy: string; + createdAt: string; +} + +/** One row of the credit history. The grant response carries the figures; this does not. */ +export interface CreditEntry { + id: string; + amountMinor: number; + reason: string; + createdBy: string; + createdAt: string; +} + +export interface CreditList { + items: CreditEntry[]; + /** + * Unspent credit on the CUSTOMER, not on this subscription. A buyer with two + * subscriptions with you shares one balance. + */ + balanceMinor: number; +} + +// --------------------------------------------------------------------------- +// Discount codes +// --------------------------------------------------------------------------- + +export interface CreateDiscountCodeParams { + /** What the buyer types. Letters, digits, dashes and underscores, up to 64. */ + code: string; + /** Your label for it. A code with no discount cannot carry one — it is its own name. */ + name?: string; + /** 0.01–100. Set at most one of `percentOff` and `amountOffMinor`. */ + percentOff?: number; + /** Integer minor units of the PLAN's currency, which the server resolves. */ + amountOffMinor?: number; + /** Stop applying the code after this many redemptions. */ + maxRedemptions?: number; + /** ISO 8601. After this the code stops working. */ + expiresAt?: string; + /** Limit the code to ONE plan, by its `paymentLinks.id`. Omit for every plan you own. */ + linkId?: string; +} + +export interface DiscountCode { + id: string; + code: string; + /** `tracking` takes nothing off the price; `discount` reduces the first invoice. */ + kind: 'discount' | 'tracking'; + /** The plan the code is limited to; null means every subscription plan you own. */ + planLinkId: string | null; + planName: string | null; + archivedAt: string | null; + createdAt: string; + /** Whether the code still works. On `get` this also reflects the processor's view. */ + active: boolean; +} + +export interface DiscountCodeDetail extends DiscountCode { + /** Ready to print: `20% off`, `€5.00 off`, or `No discount — tracking only`. */ + termsLabel: string; + /** How many subscribers redeemed it. */ + timesRedeemed: number; + /** The redemption cap you set; null when you set none. */ + maxRedemptions: number | null; +} + // --------------------------------------------------------------------------- // Customers // --------------------------------------------------------------------------- @@ -722,7 +870,39 @@ export interface SubscriptionData { livemode: boolean; } -export type WebhookEvent = +/** `dispute.created` / `dispute.closed`: a buyer's bank opened, or closed, a chargeback on a sale. */ +export interface DisputeData { + disputeId: string; + status: string; + transactionId: string; + amountMinor: number; + currency: string; + livemode: boolean; +} + +/** `account.updated` (Connect, Stripe's name): a business's verification changed. Field names are + * camelCase, as `webhooks.verify()` returns every event (the wire JSON is snake_case). */ +export interface AccountUpdatedData { + /** Where the business's review stands, in the words the dashboard uses. */ + verification: 'unverified' | 'in_review' | 'verified' | 'on_hold' | 'rejected'; + /** We have asked the business something and are waiting on it. */ + changesRequested: boolean; + /** The identity check is done — its own, or its company's for another app of the same company. */ + identityVerified: boolean; + livemode: boolean; +} + +/** `webhook.test`: the sample event the dashboard sends so you can check your endpoint. */ +export interface WebhookTestData { + message: string; + livemode: boolean; +} + +/** + * Every event we send. `business` names the business it happened in — your own id, or one of + * the businesses you manage (Connect: your webhook also receives your clients' events). + */ +export type WebhookEvent = ( | { type: 'checkout.session.completed'; data: CheckoutCompletedData } | { type: 'send.completed'; data: SendCompletedData } | { type: 'send.failed'; data: SendFailedData } @@ -730,4 +910,75 @@ export type WebhookEvent = | { type: 'subscription.renewed'; data: SubscriptionData } | { type: 'subscription.payment_failed'; data: SubscriptionData } | { type: 'subscription.updated'; data: SubscriptionData } - | { type: 'subscription.canceled'; data: SubscriptionData }; + | { type: 'subscription.canceled'; data: SubscriptionData } + | { type: 'dispute.created'; data: DisputeData } + | { type: 'dispute.closed'; data: DisputeData } + | { type: 'account.updated'; data: AccountUpdatedData } + | { type: 'webhook.test'; data: WebhookTestData } +) & { business: string }; + +// --------------------------------------------------------------------------- +// Businesses (Connect) +// --------------------------------------------------------------------------- + +/** Where a business stands — the pill the Businesses list shows. */ +export type BusinessStatus = + | 'test_only' + | 'in_review' + | 'changes_needed' + | 'on_hold' + | 'rejected' + | 'live'; + +/** A business you manage: a client's business, or another app of your own company. */ +export interface Business { + id: string; + name: string; + country: string | null; + status: BusinessStatus; + /** You — the platform that manages it. */ + platformOrgId: string; + createdAt: string; + /** Your share of its sales: basis points of the price before VAT, plus a fixed amount. */ + platformFee: { bps: number; fixedMinor: number }; + /** Another app of your own company: verified and priced with you, carries no share. */ + sameLegalEntity: boolean; + /** Your share of its live sales this month, per currency, ready to show. */ + feesThisMonth: Array<{ currency: string; feesMinor: number; feesDisplay: string }>; + /** Who an open invitation is waiting on, or null. */ + invitedEmail: string | null; + /** Its identity check is done (its own, or its company's). */ + identityVerified: boolean; +} + +export interface CreateBusinessParams { + /** What buyers see. */ + name: string; + /** Where it is registered, ISO 3166-1 alpha-2. */ + country: string; + /** Invite your client as its admin. Omit to run it yourself. */ + clientEmail?: string; + /** Another app of your own company instead of a client's business. */ + sameLegalEntity?: boolean; + /** Default true. False: no email from us — send `inviteUrl` yourself (white-label). */ + sendInvitationEmail?: boolean; +} + +export interface CreatedBusiness { + business: Business; + /** Present when `clientEmail` was given. */ + inviteToken: string | null; + /** The link your client opens to accept. */ + inviteUrl: string | null; +} + +export interface BusinessInvitation { + inviteToken: string; + inviteUrl: string; +} + +export interface VerificationLink { + /** The identity check your client completes — only they can. Null: already verified. */ + url: string | null; + status: string; +} diff --git a/packages/pay/src/utils/fetch.ts b/packages/pay/src/utils/fetch.ts index 90b5958..cbe9bae 100644 --- a/packages/pay/src/utils/fetch.ts +++ b/packages/pay/src/utils/fetch.ts @@ -24,6 +24,8 @@ interface RequestOptions { debug?: boolean; logger?: (level: string, message: string) => void; authMode?: 'api-key' | 'jwt'; + /** Act for a business the key's platform manages: sent as `AgentaOS-Account` (Stripe's `Stripe-Account`). */ + business?: string; } function buildUrl( @@ -122,6 +124,7 @@ export async function request(options: RequestOptions): Promise { } else { headers['x-api-key'] = apiKey; } + if (options.business) headers['agentaos-account'] = options.business; if (method === 'POST' && body !== undefined) { headers['content-type'] = 'application/json'; @@ -157,6 +160,8 @@ export async function request(options: RequestOptions): Promise { const requestId = response.headers.get('x-request-id') ?? undefined; if (response.ok) { + // 204 No Content (a DELETE): success with no body to parse. + if (response.status === 204) return undefined as T; const json = await response.json(); return snakeToCamel(json) as T; } @@ -234,6 +239,7 @@ export async function requestRaw(options: RequestOptions): Promise { } else { headers['x-api-key'] = apiKey; } + if (options.business) headers['agentaos-account'] = options.business; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); @@ -280,6 +286,7 @@ export async function requestText(options: RequestOptions): Promise { } else { headers['x-api-key'] = apiKey; } + if (options.business) headers['agentaos-account'] = options.business; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); diff --git a/packages/wallet/README.md b/packages/wallet/README.md index b2940b1..e0968b5 100644 --- a/packages/wallet/README.md +++ b/packages/wallet/README.md @@ -61,7 +61,7 @@ Every row is also an MCP tool (`agenta products create` = `agenta_products_creat | `agenta verify submit --legal-name ... --accept-declaration` | Submit business verification so the merchant can accept live payments | | `agenta verify status` | Where the verification has got to | | `agenta verify resubmit` | Send back for review after making the changes we asked for | -| `agenta products create --name -a ` | Create a product (one-time) or a subscription plan (`--subscription --interval month\|year`, `--trial-days `) | +| `agenta products create --name -a ` | Create a product (one-time) or a subscription plan (`--subscription --interval month\|year`, `--trial-days `, `--trial-amount ` for a paid trial) | | `agenta products list` | List products and plans | | `agenta pay checkout -a ` | Create a checkout session | | `agenta pay get ` | Get checkout session status | @@ -69,6 +69,12 @@ Every row is also an MCP tool (`agenta products create` = `agenta_products_creat | `agenta subscriptions list` | List subscriptions | | `agenta subscriptions cancel ` | Cancel a subscription (at period end; `--now` for immediate) | | `agenta subscriptions change-plan --to ` | Move a subscription to another plan (`--dry-run` quotes only) | +| `agenta subscriptions credit -a --reason ` | Put credit on a subscriber's account, taken off their next invoice | +| `agenta subscriptions credits ` | Credits given on a subscription, and what is still unspent | +| `agenta discounts create --code ` | Create a discount code buyers type at checkout (`--percent-off`, `--amount-off`, or neither to only track referrals) | +| `agenta discounts list` | List discount codes | +| `agenta discounts show ` | What one code takes off, and how many people used it | +| `agenta discounts archive ` | Stop a discount code working | | `agenta customers list` | List customers | | `agenta invoices list` | List invoices | | `agenta invoices receipt ` | Download the receipt PDF | @@ -182,12 +188,18 @@ When invoked with no arguments, runs as an MCP server over stdio. This lets AI a | `agenta_subscriptions_list` | List subscriptions | | `agenta_subscriptions_cancel` | Cancel a subscription (at period end by default) | | `agenta_subscriptions_change_plan` | Move a subscription to another plan (upgrade charges now, downgrade at period end) | +| `agenta_subscriptions_credit` | Put credit on a subscriber's account, taken off their next invoice | +| `agenta_subscriptions_credits` | Credits given on a subscription, and what is still unspent | +| `agenta_discounts_create` | Create a discount code buyers type at checkout | +| `agenta_discounts_list` | List discount codes | +| `agenta_discounts_show` | What one code takes off, and how many people used it | +| `agenta_discounts_archive` | Stop a discount code working | | `agenta_customers_list` | List customers | | `agenta_invoices_list` | List invoices | | `agenta_invoices_receipt` | The receipt PDF for a paid invoice | | `agenta_invoices_send_receipt` | Re-send the receipt email to the buyer on file | -The 19 payment tools and the `agenta` CLI are one operations catalogue: `agenta_products_create` is `agenta products create`, with the same inputs and the same merchant-readable output. +The 25 payment tools and the `agenta` CLI are one operations catalogue: `agenta_products_create` is `agenta products create`, with the same inputs and the same merchant-readable output. ### Claude Desktop diff --git a/packages/wallet/src/__tests__/businesses-flags.test.ts b/packages/wallet/src/__tests__/businesses-flags.test.ts new file mode 100644 index 0000000..7e4284b --- /dev/null +++ b/packages/wallet/src/__tests__/businesses-flags.test.ts @@ -0,0 +1,33 @@ +/** + * Connect (PRD §6.1 R9-3): the two invitation switches are spelled as the docs promise. Both are + * booleans that default to on, so the shared `--email ` value flag must not win over them + * — it did, and `agenta businesses invite --no-email` answered "unknown option". + */ +import type { Command } from 'commander'; +import { describe, expect, it } from 'vitest'; +import { buildProgram } from '../cli/index.js'; + +function commandAt(parent: Command, path: string[]): Command { + const [head, ...rest] = path; + const found = parent.commands.find((c) => c.name() === head); + if (!found) throw new Error(`no command ${head} under ${parent.name()}`); + return rest.length === 0 ? found : commandAt(found, rest); +} + +const program = buildProgram(); + +describe('agenta businesses — invitation switches', () => { + it('`invite ` offers --no-email, not an --email value', () => { + const help = commandAt(program, ['businesses', 'invite']).helpInformation(); + + expect(help).toContain('--no-email'); + expect(help).not.toContain('--email '); + }); + + it('`create` offers --no-invite-email, worded as what it does', () => { + const help = commandAt(program, ['businesses', 'create']).helpInformation(); + + expect(help).toContain('--no-invite-email'); + expect(help).toContain('Send no email'); + }); +}); diff --git a/packages/wallet/src/__tests__/businesses-ops.test.ts b/packages/wallet/src/__tests__/businesses-ops.test.ts new file mode 100644 index 0000000..f57a3df --- /dev/null +++ b/packages/wallet/src/__tests__/businesses-ops.test.ts @@ -0,0 +1,85 @@ +/** + * Connect (PRD §6.1 R9-3): what `agenta businesses …` says — plain words and the next step. + */ +import { describe, expect, it, vi } from 'vitest'; +import { businessesCreate, businessesIdLink, businessesList } from '../ops/businesses.js'; + +const business = { + id: 'biz_1', + name: 'ClientCo', + country: 'EE', + status: 'test_only' as const, + platformOrgId: 'plat', + createdAt: '2026-09-28T00:00:00Z', + platformFee: { bps: 1000, fixedMinor: 50 }, + sameLegalEntity: false, + feesThisMonth: [], + invitedEmail: 'ana@example.com', + identityVerified: false, +}; + +describe('agenta businesses', () => { + it('create, white-label: hands back the link to send', async () => { + const sdk = { + businesses: { + create: vi + .fn() + .mockResolvedValue({ business, inviteToken: 't', inviteUrl: 'https://app/invite/t' }), + }, + }; + const input = businessesCreate.input.parse({ + name: 'ClientCo', + country: 'ee', + clientEmail: 'ana@example.com', + inviteEmail: false, + }); + + const result = await businessesCreate.run(sdk as never, input); + + expect(sdk.businesses.create).toHaveBeenCalledWith({ + name: 'ClientCo', + country: 'EE', + clientEmail: 'ana@example.com', + sendInvitationEmail: false, + }); + expect(businessesCreate.describe(result)).toContain( + 'Send ClientCo this link to accept: https://app/invite/t', + ); + }); + + it('create, emailed: says who we emailed', async () => { + const sdk = { + businesses: { + create: vi + .fn() + .mockResolvedValue({ business, inviteToken: 't', inviteUrl: 'https://app/invite/t' }), + }, + }; + const input = businessesCreate.input.parse({ + name: 'ClientCo', + country: 'EE', + clientEmail: 'ana@example.com', + }); + + const result = await businessesCreate.run(sdk as never, input); + + expect(businessesCreate.describe(result)).toContain( + 'We emailed the invitation to ana@example.com.', + ); + }); + + it('list names each business with its state', () => { + expect(businessesList.describe({ items: [business] })).toContain( + 'ClientCo (test mode, invitation open for ana@example.com) — biz_1', + ); + }); + + it('id-link: the link, or "already verified"', () => { + expect(businessesIdLink.describe({ url: 'https://verify/x', status: 'Not Started' })).toContain( + 'only they can complete the identity check: https://verify/x', + ); + expect(businessesIdLink.describe({ url: null, status: 'Approved' })).toContain( + 'already verified', + ); + }); +}); diff --git a/packages/wallet/src/__tests__/discounts-command.test.ts b/packages/wallet/src/__tests__/discounts-command.test.ts new file mode 100644 index 0000000..436be8c --- /dev/null +++ b/packages/wallet/src/__tests__/discounts-command.test.ts @@ -0,0 +1,131 @@ +import type { + AgentaOS, + CreateDiscountCodeParams, + DiscountCode, + DiscountCodeDetail, +} from '@agentaos/pay'; +import { describe, expect, it, vi } from 'vitest'; +import { discountsCreate, discountsList, discountsShow } from '../ops/discounts.js'; + +const code: DiscountCode = { + id: 'dc_1', + code: 'LAUNCH20', + kind: 'discount', + planLinkId: 'link_1', + planName: 'Pro monthly', + archivedAt: null, + createdAt: '2026-09-14T10:00:00.000Z', + active: true, +}; + +const tracking: DiscountCode = { + ...code, + id: 'dc_2', + code: 'ALICE', + kind: 'tracking', + planLinkId: null, + planName: null, +}; + +function sdkWith() { + const create = vi.fn(async (_params: CreateDiscountCodeParams) => code); + return { sdk: { discountCodes: { create } } as unknown as AgentaOS, create }; +} + +describe('agenta discounts create', () => { + it('sends a fixed amount off as integer minor units', async () => { + const { sdk, create } = sdkWith(); + await discountsCreate.run(sdk, { code: 'SAVE5', amountOff: 5 }); + expect(create.mock.calls[0]?.[0]).toMatchObject({ amountOffMinor: 500 }); + }); + + it('sends a percentage off untouched, because it is not money', async () => { + const { sdk, create } = sdkWith(); + await discountsCreate.run(sdk, { code: 'LAUNCH20', percentOff: 20 }); + expect(create.mock.calls[0]?.[0]).toMatchObject({ percentOff: 20 }); + }); + + it('sends the plan as linkId, which is what the API calls it', async () => { + const { sdk, create } = sdkWith(); + await discountsCreate.run(sdk, { code: 'LAUNCH20', percentOff: 20, plan: 'link_1' }); + expect(create.mock.calls[0]?.[0]).toMatchObject({ linkId: 'link_1' }); + }); + + // Stripe applies one or the other; two terms is a question only the merchant + // can answer, so it is asked before the round-trip, not after. + it('refuses both a percentage and an amount', async () => { + const { sdk } = sdkWith(); + await expect( + discountsCreate.run(sdk, { code: 'BOTH', percentOff: 20, amountOff: 5 }), + ).rejects.toThrow('Use --percent-off or --amount-off, not both.'); + }); + + it.each(['SAVE 20', 'SAVE%20', ''])('refuses the code %s', (bad) => { + expect(discountsCreate.input.safeParse({ code: bad }).success).toBe(false); + }); + + it('tells the merchant a tracking code changes no price', () => { + expect(discountsCreate.describe(tracking)).toContain('It changes no price'); + }); + + it('tells the merchant a discount code drops the first invoice', () => { + expect(discountsCreate.describe(code)).toContain('the first invoice drops'); + }); +}); + +describe('agenta discounts list', () => { + it('tells a merchant with no codes how to make one', () => { + expect(discountsList.describe({ total: 0, hasMore: false, items: [] })).toContain( + 'discounts create', + ); + }); + + it('names the plan a code is limited to', () => { + const text = discountsList.describe({ total: 1, hasMore: false, items: [code] }); + expect(text).toContain('LAUNCH20 — discount — active — on "Pro monthly"'); + }); + + it('says "all plans" for a code with no plan of its own', () => { + const text = discountsList.describe({ total: 1, hasMore: false, items: [tracking] }); + expect(text).toContain('ALICE — tracking only — active — all plans'); + }); + + it('says an archived code is stopped, not silently omitted', () => { + const archived = { ...code, active: false, archivedAt: '2026-09-14T11:00:00.000Z' }; + expect(discountsList.describe({ total: 1, hasMore: false, items: [archived] })).toContain( + 'stopped', + ); + }); +}); + +describe('agenta discounts show', () => { + const detail: DiscountCodeDetail = { + ...code, + termsLabel: '20% off', + timesRedeemed: 14, + maxRedemptions: 100, + }; + + it('prints the terms the server rendered, not terms of its own', () => { + expect(discountsShow.describe(detail)).toContain('LAUNCH20 — 20% off — used 14 of 100 times'); + }); + + it('drops the cap from the sentence when the merchant set none', () => { + const text = discountsShow.describe({ ...detail, maxRedemptions: null }); + expect(text).toContain('used 14 times'); + }); + + it('counts one redemption in the singular', () => { + const text = discountsShow.describe({ ...detail, maxRedemptions: null, timesRedeemed: 1 }); + expect(text).toContain('used 1 time'); + }); + + it('says which plan the code works on', () => { + expect(discountsShow.describe(detail)).toContain('It works on "Pro monthly" only.'); + }); + + it('says a code with no plan works on everything the merchant sells', () => { + const text = discountsShow.describe({ ...detail, planName: null, planLinkId: null }); + expect(text).toContain('It works on every plan you sell.'); + }); +}); diff --git a/packages/wallet/src/__tests__/mcp-server.test.ts b/packages/wallet/src/__tests__/mcp-server.test.ts index 5bb255f..9d440e6 100644 --- a/packages/wallet/src/__tests__/mcp-server.test.ts +++ b/packages/wallet/src/__tests__/mcp-server.test.ts @@ -23,10 +23,22 @@ const EXPECTED_TOOLS = [ 'agenta_subscriptions_list', 'agenta_subscriptions_cancel', 'agenta_subscriptions_change_plan', + 'agenta_subscriptions_credit', + 'agenta_subscriptions_credits', + 'agenta_discounts_create', + 'agenta_discounts_list', + 'agenta_discounts_show', + 'agenta_discounts_archive', 'agenta_customers_list', 'agenta_invoices_list', 'agenta_invoices_receipt', 'agenta_invoices_send_receipt', + 'agenta_businesses_list', + 'agenta_businesses_get', + 'agenta_businesses_create', + 'agenta_businesses_invite', + 'agenta_businesses_revoke_invite', + 'agenta_businesses_id_link', ]; describe('AgentaOS Terminal MCP Server', () => { diff --git a/packages/wallet/src/__tests__/parity.test.ts b/packages/wallet/src/__tests__/parity.test.ts index 4fffe1d..9b720af 100644 --- a/packages/wallet/src/__tests__/parity.test.ts +++ b/packages/wallet/src/__tests__/parity.test.ts @@ -49,14 +49,15 @@ describe.each(OPERATIONS.map((op) => [op.name, op] as const))('%s', (_name, op) expect(listedTools.map((tool) => tool.name)).toContain(mcpToolName(op)); }); - const catalogueKeys = Object.keys(op.input.shape).sort(); + // Connect (PRD §6.1 R9-3): every command and tool can act for a business you manage. + const catalogueKeys = [...Object.keys(op.input.shape), 'business'].sort(); - it('the CLI argument and flags are exactly the catalogue keys', () => { + it('the CLI argument and flags are exactly the catalogue keys, plus --business', () => { const command = resolve(program, cliCommand(op).split(' ')) as Command; expect([...inputKeysOf(command)].sort()).toEqual(catalogueKeys); }); - it('the tool schema properties are exactly the catalogue keys', () => { + it('the tool schema properties are exactly the catalogue keys, plus business', () => { const tool = listedTools.find((candidate) => candidate.name === mcpToolName(op)); expect(Object.keys(tool?.inputSchema.properties ?? {}).sort()).toEqual(catalogueKeys); }); diff --git a/packages/wallet/src/__tests__/products-command.test.ts b/packages/wallet/src/__tests__/products-command.test.ts index f882216..354863a 100644 --- a/packages/wallet/src/__tests__/products-command.test.ts +++ b/packages/wallet/src/__tests__/products-command.test.ts @@ -1,8 +1,23 @@ import { describe, expect, it } from 'vitest'; -import { buildCreateParams } from '../ops/products.js'; +import { type ProductView, buildCreateParams, productsCreate } from '../ops/products.js'; const oneTime = { name: 'Launch Kit', amount: '49', currency: 'EUR' }; +const plan: ProductView = { + id: 'link_1', + name: 'Pro', + type: 'subscription', + billingInterval: 'month', + amount: 29, + currency: 'EUR', + status: 'active', + checkoutUrl: 'https://app.agentaos.ai/pay/abc', + successUrl: null, + cancelUrl: null, + trialPeriodDays: null, + trialAmount: null, +}; + describe('agenta products create — flags to API params', () => { it('builds a one-time product by default', () => { const built = buildCreateParams({ ...oneTime, description: 'One-off' }); @@ -39,6 +54,53 @@ describe('agenta products create — flags to API params', () => { it('refuses plan-only flags on a one-time product', () => { expect(buildCreateParams({ ...oneTime, interval: 'month' }).ok).toBe(false); expect(buildCreateParams({ ...oneTime, trialDays: '7' }).ok).toBe(false); + expect(buildCreateParams({ ...oneTime, trialAmount: '9' }).ok).toBe(false); + }); + + it('carries a paid trial price through as the plan’s trialAmount', () => { + const built = buildCreateParams({ + ...oneTime, + subscription: true, + interval: 'month', + trialDays: '30', + trialAmount: '9', + }); + expect(built.ok).toBe(true); + if (!built.ok) return; + expect(built.params.trialAmount).toBe(9); + }); + + it('leaves trialAmount unset for a free trial, so the trial stays free', () => { + const built = buildCreateParams({ + ...oneTime, + subscription: true, + interval: 'month', + trialDays: '30', + }); + expect(built.ok).toBe(true); + if (!built.ok) return; + expect(built.params.trialAmount).toBeUndefined(); + }); + + // A price for a trial nobody gets would never be charged; the API refuses it too. + it('refuses a trial price with no trial length', () => { + expect( + buildCreateParams({ ...oneTime, subscription: true, interval: 'month', trialAmount: '9' }), + ).toEqual({ + ok: false, + error: '--trial-amount needs --trial-days: a trial price needs a trial to price.', + }); + }); + + it.each(['0', '-1', 'free'])('refuses trial price %s', (trialAmount) => { + const built = buildCreateParams({ + ...oneTime, + subscription: true, + interval: 'month', + trialDays: '30', + trialAmount, + }); + expect(built.ok).toBe(false); }); it('passes the return URLs through so buyers land back in the app', () => { @@ -86,3 +148,27 @@ describe('agenta products create — flags to API params', () => { expect(built.ok).toBe(false); }); }); + +describe('agenta products create — what the merchant reads back', () => { + it('says nothing about a trial on a plan that has none', () => { + expect(productsCreate.describe(plan)).not.toContain('New subscribers get'); + }); + + it('names the price of a paid trial and the price it renews at', () => { + const text = productsCreate.describe({ ...plan, trialPeriodDays: 30, trialAmount: 9 }); + expect(text).toContain( + 'New subscribers get 30 days for €9.00, then it renews at €29.00/month.', + ); + }); + + it('says a trial with no price is free', () => { + const text = productsCreate.describe({ ...plan, trialPeriodDays: 14 }); + expect(text).toContain('New subscribers get 14 days free'); + }); + + // "The first 1 day are free" is the sentence this phrasing exists to avoid. + it('reads correctly for a one-day trial', () => { + const text = productsCreate.describe({ ...plan, trialPeriodDays: 1 }); + expect(text).toContain('New subscribers get 1 day free'); + }); +}); diff --git a/packages/wallet/src/__tests__/subscriptions-credit.test.ts b/packages/wallet/src/__tests__/subscriptions-credit.test.ts new file mode 100644 index 0000000..1c48e38 --- /dev/null +++ b/packages/wallet/src/__tests__/subscriptions-credit.test.ts @@ -0,0 +1,133 @@ +import type { AgentaOS, Credit, GrantCreditParams } from '@agentaos/pay'; +import { describe, expect, it, vi } from 'vitest'; +import { subscriptionsCredit, subscriptionsCredits } from '../ops/subscriptions.js'; + +/** + * The credit is money out of the merchant's pocket, so two things are asserted + * here and nowhere else: the amount a founder types in euros reaches the API as + * integer minor units, and every figure printed back is one the server sent. + */ + +const granted: Credit = { + id: 'cbtxn_1', + amountMinor: 500, + currency: 'eur', + reason: 'Two days of downtime', + creditBalanceMinor: 500, + nextInvoiceMinor: 1900, + nextInvoiceDueAfterCreditMinor: 1400, + nextInvoiceAt: '2026-10-01T00:00:00.000Z', + createdBy: 'user_1', + createdAt: '2026-09-14T10:00:00.000Z', +}; + +function sdkWith() { + const credit = vi.fn(async (_id: string, _params: GrantCreditParams) => granted); + return { sdk: { subscriptions: { credit } } as unknown as AgentaOS, credit }; +} + +describe('agenta subscriptions credit', () => { + it('sends the euros typed as integer minor units', async () => { + const { sdk, credit } = sdkWith(); + await subscriptionsCredit.run(sdk, { id: 'sub_1', amount: 5, reason: 'Downtime' }); + expect(credit.mock.calls[0]?.[1]).toMatchObject({ amountMinor: 500 }); + }); + + it('rounds a sub-cent amount rather than sending a fraction the API would refuse', async () => { + const { sdk, credit } = sdkWith(); + await subscriptionsCredit.run(sdk, { id: 'sub_1', amount: 5.005, reason: 'Downtime' }); + expect(credit.mock.calls[0]?.[1]).toMatchObject({ amountMinor: 501 }); + }); + + it('mints an idempotency key so a network retry inside the call cannot credit twice', async () => { + const { sdk, credit } = sdkWith(); + await subscriptionsCredit.run(sdk, { id: 'sub_1', amount: 5, reason: 'Downtime' }); + expect(credit.mock.calls[0]?.[1]).toMatchObject({ idempotencyKey: expect.any(String) }); + }); + + // Running the command twice is two deliberate credits, so the keys must differ. + it('mints a different key on each invocation', async () => { + const { sdk, credit } = sdkWith(); + await subscriptionsCredit.run(sdk, { id: 'sub_1', amount: 5, reason: 'Downtime' }); + await subscriptionsCredit.run(sdk, { id: 'sub_1', amount: 5, reason: 'Downtime' }); + const [first, second] = credit.mock.calls.map((call) => call[1].idempotencyKey); + expect(first).not.toBe(second); + }); + + it('reports the key it used, so a caller who must retry can send the same one', async () => { + const { sdk } = sdkWith(); + const result = await subscriptionsCredit.run(sdk, { + id: 'sub_1', + amount: 5, + reason: 'Downtime', + }); + expect(result.idempotencyKey).toEqual(expect.any(String)); + }); + + it('names what is left to pay using the server’s figure, not its own subtraction', () => { + const text = subscriptionsCredit.describe({ ...granted, idempotencyKey: 'k' }); + expect(text).toContain('leaving €14.00 to pay'); + }); + + it('names the date the credit is used', () => { + const text = subscriptionsCredit.describe({ ...granted, idempotencyKey: 'k' }); + expect(text).toContain('1 Oct 2026'); + }); +}); + +describe('agenta subscriptions credits', () => { + it('tells a merchant with no credits what to do next', () => { + expect(subscriptionsCredits.describe({ items: [], balanceMinor: 0 })).toContain( + 'subscriptions credit', + ); + }); + + it('prints each credit with the merchant’s own reason', () => { + const text = subscriptionsCredits.describe({ + items: [ + { + id: 'cbtxn_1', + amountMinor: 500, + reason: 'Two days of downtime', + createdBy: 'user_1', + createdAt: '2026-09-14T10:00:00.000Z', + }, + ], + balanceMinor: 500, + }); + expect(text).toContain('5.00 — Two days of downtime — 14 Sep 2026'); + }); + + // The history carries no currency, so printing a symbol would be us guessing. + it('states no currency symbol it was never given', () => { + const text = subscriptionsCredits.describe({ + items: [ + { + id: 'cbtxn_1', + amountMinor: 500, + reason: 'Downtime', + createdBy: 'user_1', + createdAt: '2026-09-14T10:00:00.000Z', + }, + ], + balanceMinor: 500, + }); + expect(text).not.toContain('€'); + }); + + it('says the unspent balance spans every subscription the buyer has', () => { + const text = subscriptionsCredits.describe({ + items: [ + { + id: 'cbtxn_1', + amountMinor: 500, + reason: 'Downtime', + createdBy: 'user_1', + createdAt: '2026-09-14T10:00:00.000Z', + }, + ], + balanceMinor: 1000, + }); + expect(text).toContain('10.00 is still unspent, across every subscription'); + }); +}); diff --git a/packages/wallet/src/cli/commands/status.command.ts b/packages/wallet/src/cli/commands/status.command.ts index 30be72d..0eb498a 100644 --- a/packages/wallet/src/cli/commands/status.command.ts +++ b/packages/wallet/src/cli/commands/status.command.ts @@ -18,17 +18,22 @@ export function statusCommand(ctx: CliContext): Command { .alias('whoami') .usage('[options]') .description(statusGet.description) - .action(() => showStatus(ctx)); + // Connect (PRD §6.1 R9-3): see where a business you manage stands. + .option('--business ', 'Act for this business you manage. Omit to act as yourself.') + .action((opts: { business?: string }) => showStatus(ctx, opts.business)); // `agenta status get` is the catalogue's name for the same thing; hidden, // and the usage line above keeps `[command]` out of the help it had in 3.0.0. status.addCommand( - new Command('get').description(statusGet.description).action(() => showStatus(ctx)), + new Command('get') + .description(statusGet.description) + .option('--business ', 'Act for this business you manage. Omit to act as yourself.') + .action((opts: { business?: string }) => showStatus(ctx, opts.business)), { hidden: true }, ); return status; } -async function showStatus(ctx: CliContext): Promise { +async function showStatus(ctx: CliContext, business?: string): Promise { try { const session = await ensureSession(); if (!session.ok) { @@ -40,7 +45,7 @@ async function showStatus(ctx: CliContext): Promise { return; } - const { sdk, account } = await ctx.connect(); + const { sdk, account } = await ctx.connect(business ? { business } : undefined); if (!account.orgId) { console.log(JSON.stringify({ account: { ...account, serverReachable: false } })); return; diff --git a/packages/wallet/src/cli/connect.ts b/packages/wallet/src/cli/connect.ts index 7d8a436..2215411 100644 --- a/packages/wallet/src/cli/connect.ts +++ b/packages/wallet/src/cli/connect.ts @@ -21,7 +21,8 @@ export interface Connection { /** What a command needs to reach the API. Injected so tests can fake it. */ export interface CliContext { - connect(): Promise; + /** `business`: act for a business you manage (Connect, `--business`). */ + connect(opts?: { business?: string }): Promise; } /** @@ -29,7 +30,7 @@ export interface CliContext { * for was resolved once at first use and lives with the session; the SDK * stamps it on every request. */ -export async function connect(): Promise { +export async function connect(opts?: { business?: string }): Promise { const session = await ensureSession(); if (!session.ok) { throw new Error( @@ -38,7 +39,11 @@ export async function connect(): Promise { : 'Not logged in. Run agenta login.', ); } - const sdk = new AgentaOS(session.token, { baseUrl: session.serverUrl, orgId: session.orgId }); + const sdk = new AgentaOS(session.token, { + baseUrl: session.serverUrl, + orgId: session.orgId, + ...(opts?.business ? { business: opts.business } : {}), + }); return { sdk, account: accountOf(session) }; } diff --git a/packages/wallet/src/cli/flags.ts b/packages/wallet/src/cli/flags.ts index dc82dfa..d83aee7 100644 --- a/packages/wallet/src/cli/flags.ts +++ b/packages/wallet/src/cli/flags.ts @@ -47,6 +47,14 @@ const BY_OPERATION: Record> = { 'audit.show': { download: { flags: '--no-download', help: 'Return the link without saving the PDF' }, }, + // The invitation switches: `email` is a boolean here, so the shared `--email ` value + // flag must not win (Connect PRD §6.1 R9-3 promises `--no-email`). + 'businesses.create': { + inviteEmail: { flags: '--no-invite-email', help: 'Send no email; you pass the link on' }, + }, + 'businesses.invite': { + email: { flags: '--no-email', help: 'Send no email; you pass the link on' }, + }, }; export function flagSpec(operationName: string, key: string): FlagSpec | undefined { diff --git a/packages/wallet/src/cli/from-operation.ts b/packages/wallet/src/cli/from-operation.ts index f9f9bf2..de96c8c 100644 --- a/packages/wallet/src/cli/from-operation.ts +++ b/packages/wallet/src/cli/from-operation.ts @@ -80,6 +80,10 @@ export function commandFromOperation(op: Operation, ctx: CliContext): Command { for (const field of fields) { if (field !== positional) command.addOption(optionFor(op, field)); } + // Connect (PRD §6.1 R9-3): every command can act for a business you manage. + command.addOption( + new Option('--business ', 'Act for this business you manage. Omit to act as yourself.'), + ); command.action(async (...args: unknown[]) => { const invoked = args[args.length - 1] as Command; @@ -101,13 +105,14 @@ export async function runOperation( ctx: CliContext, raw: Record, ): Promise { - const parsed = op.input.safeParse(raw); + const { business, ...opRaw } = raw as Record & { business?: string }; + const parsed = op.input.safeParse(opRaw); if (!parsed.success) { fail(issuesText(parsed.error)); return; } try { - const { sdk } = await ctx.connect(); + const { sdk } = await ctx.connect(business ? { business } : undefined); const result = await applySideEffect(op, parsed.data, await op.run(sdk, parsed.data)); print(op, result); } catch (error: unknown) { diff --git a/packages/wallet/src/mcp/tools/index.ts b/packages/wallet/src/mcp/tools/index.ts index b8b3378..789a9b3 100644 --- a/packages/wallet/src/mcp/tools/index.ts +++ b/packages/wallet/src/mcp/tools/index.ts @@ -1,5 +1,6 @@ import type { AgentaOS } from '@agentaos/pay'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; import { OPERATIONS } from '../../ops/index.js'; import { cliCommand, mcpToolName } from '../../ops/naming.js'; import type { Operation } from '../../ops/types.js'; @@ -8,8 +9,16 @@ import { formatPayError } from './pay-utils.js'; export { formatPayError } from './pay-utils.js'; export { OPERATIONS, cliCommand, mcpToolName } from '../../ops/index.js'; -/** Builds the Pay SDK client a tool call runs against. Called per tool invocation. */ -export type PayClientFactory = () => AgentaOS; +/** Builds the Pay SDK client a tool call runs against. Called per tool invocation; `business` + * is the managed business the call acts for (Connect), when the caller named one. */ +export type PayClientFactory = (opts?: { business?: string }) => AgentaOS; + +/** Every tool can act for a business you manage (Connect, PRD §6.1 R9-3) — `--business` in the CLI. */ +const businessInput = z + .string() + .min(1) + .optional() + .describe('Act for this business you manage (its id). Omit to act as yourself.'); /** * Register every operation in the catalogue as an MCP tool on any McpServer: @@ -29,10 +38,11 @@ export const registerPayTools = registerAgentaTools; function registerOperation(server: McpServer, op: Operation, getClient: PayClientFactory): void { server.registerTool( mcpToolName(op), - { description: op.description, inputSchema: op.input.shape }, + { description: op.description, inputSchema: { ...op.input.shape, business: businessInput } }, async (input) => { try { - const result = await op.run(getClient(), input); + const { business, ...opInput } = input as Record & { business?: string }; + const result = await op.run(getClient(business ? { business } : undefined), opInput); return { content: [{ type: 'text' as const, text: op.describe(result) }], structuredContent: result as Record, diff --git a/packages/wallet/src/mcp/tools/pay-utils.ts b/packages/wallet/src/mcp/tools/pay-utils.ts index 4313b14..f194c79 100644 --- a/packages/wallet/src/mcp/tools/pay-utils.ts +++ b/packages/wallet/src/mcp/tools/pay-utils.ts @@ -4,7 +4,7 @@ import { AgentaOS } from '@agentaos/pay'; * Create an AgentaOS Pay SDK client from environment. * Requires AGENTAOS_GATEWAY_KEY (sk_live_... or sk_test_...). */ -export function createPayClient(): AgentaOS { +export function createPayClient(opts?: { business?: string }): AgentaOS { const apiKey = process.env.AGENTAOS_GATEWAY_KEY; if (!apiKey) { throw new Error( @@ -14,7 +14,10 @@ export function createPayClient(): AgentaOS { ); } const baseUrl = process.env.AGENTA_SERVER; - return new AgentaOS(apiKey, baseUrl ? { baseUrl } : undefined); + return new AgentaOS(apiKey, { + ...(baseUrl ? { baseUrl } : {}), + ...(opts?.business ? { business: opts.business } : {}), + }); } /** What the merchant can do when the API refuses the key: it was revoked on the diff --git a/packages/wallet/src/ops/businesses.ts b/packages/wallet/src/ops/businesses.ts new file mode 100644 index 0000000..6041d77 --- /dev/null +++ b/packages/wallet/src/ops/businesses.ts @@ -0,0 +1,175 @@ +import type { Business } from '@agentaos/pay'; +import { z } from 'zod'; +import { countryCode } from './schema.js'; +import { type OperationGroup, operation } from './types.js'; +import { count, lines } from './words.js'; + +/** + * Connect (PRD §6.1 R9-3): the businesses you manage — your clients' businesses and other apps + * of your own company — as Stripe Connect's connected accounts. To do anything AS one of them, + * add `--business ` to any command (`agenta products create … --business `). + */ + +const STATUS_WORDS: Record = { + test_only: 'test mode', + in_review: 'in review', + changes_needed: 'changes needed', + on_hold: 'on hold', + rejected: 'rejected', + live: 'live', +}; + +function businessLine(b: Business): string { + const facts = [ + STATUS_WORDS[b.status] ?? b.status, + b.sameLegalEntity ? 'your own company' : null, + b.identityVerified ? 'identity verified' : null, + b.invitedEmail ? `invitation open for ${b.invitedEmail}` : null, + ].filter(Boolean); + return ` - ${b.name} (${facts.join(', ')}) — ${b.id}`; +} + +const id = z.string().min(1).describe('The business id'); + +export const businessesList = operation({ + name: 'businesses.list', + description: 'List the businesses you manage', + input: z.object({}), + async run(sdk) { + return { items: await sdk.businesses.list() }; + }, + describe({ items }) { + if (!items.length) { + return 'You manage no businesses yet. Add one with: agenta businesses create --name … --country …'; + } + return lines(`${count(items.length, 'business', 'businesses')}:`, ...items.map(businessLine)); + }, +}); + +export const businessesGet = operation({ + name: 'businesses.get', + description: 'Show one business you manage', + input: z.object({ id }), + positional: 'id', + async run(sdk, input) { + return sdk.businesses.retrieve(input.id); + }, + describe(b) { + return lines( + businessLine(b).trimStart().slice(2), + `Act as it: add --business ${b.id} to any command.`, + ); + }, +}); + +export const businessesCreate = operation({ + name: 'businesses.create', + description: "Add a business you manage — a client's business, or another app of your company", + input: z.object({ + name: z.string().min(1).describe('Name buyers see'), + country: countryCode('--country').describe('Where it is registered, e.g. EE'), + clientEmail: z + .string() + .email() + .optional() + .describe('Invite your client as its admin. Omit to run it yourself'), + sameCompany: z + .boolean() + .optional() + .describe( + 'Another app of your own company (verified and priced with you) instead of a client', + ), + inviteEmail: z + .boolean() + .default(true) + .describe('Email the invitation (switch off to send the link yourself)'), + }), + async run(sdk, input) { + const created = await sdk.businesses.create({ + name: input.name, + country: input.country, + ...(input.clientEmail ? { clientEmail: input.clientEmail } : {}), + ...(input.sameCompany ? { sameLegalEntity: true } : {}), + ...(input.clientEmail ? { sendInvitationEmail: input.inviteEmail } : {}), + }); + return { + ...created, + emailedTo: input.clientEmail && input.inviteEmail ? input.clientEmail : null, + }; + }, + describe(result) { + const b = result.business; + return lines( + `Added ${b.name}. It starts in test mode and goes live after its own verification.`, + `Business id: ${b.id} — add --business ${b.id} to any command to act as it.`, + result.inviteUrl + ? result.emailedTo + ? `We emailed the invitation to ${result.emailedTo}.` + : `Send ${b.name} this link to accept: ${result.inviteUrl}` + : null, + ); + }, +}); + +export const businessesInvite = operation({ + name: 'businesses.invite', + description: "Send your client's invitation again (a new link; the old one stops working)", + input: z.object({ + id, + email: z.boolean().default(true).describe('Email it (switch off to send the link yourself)'), + }), + positional: 'id', + async run(sdk, input) { + const invitation = await sdk.businesses.resendInvitation(input.id, { + sendInvitationEmail: input.email, + }); + return { ...invitation, emailed: input.email }; + }, + describe(result) { + return result.emailed + ? `Emailed a new invitation. The link, if you need it: ${result.inviteUrl}` + : `Send your client this link to accept: ${result.inviteUrl}`; + }, +}); + +export const businessesRevokeInvite = operation({ + name: 'businesses.revokeInvite', + description: "Withdraw your client's open invitation", + input: z.object({ id }), + positional: 'id', + async run(sdk, input) { + await sdk.businesses.revokeInvitation(input.id); + return { id: input.id, revoked: true as const }; + }, + describe() { + return 'Invitation withdrawn: its link no longer works.'; + }, +}); + +export const businessesIdLink = operation({ + name: 'businesses.idLink', + description: 'Get the identity check link for your client (only they can complete it)', + input: z.object({ id }), + positional: 'id', + async run(sdk, input) { + return sdk.businesses.createVerificationLink(input.id); + }, + describe(link) { + return link.url + ? `Send your client this link — only they can complete the identity check: ${link.url}` + : 'Their identity is already verified. Nothing to send.'; + }, +}); + +export const BUSINESSES: OperationGroup = { + name: 'businesses', + description: "Businesses you manage: your clients' businesses and your other apps", + operations: [ + businessesList, + businessesGet, + businessesCreate, + businessesInvite, + businessesRevokeInvite, + businessesIdLink, + ], +}; diff --git a/packages/wallet/src/ops/discounts.ts b/packages/wallet/src/ops/discounts.ts new file mode 100644 index 0000000..bf24035 --- /dev/null +++ b/packages/wallet/src/ops/discounts.ts @@ -0,0 +1,146 @@ +import type { DiscountCode, DiscountCodeDetail } from '@agentaos/pay'; +import { z } from 'zod'; +import { minorUnits, pageLimit, positiveAmount } from './schema.js'; +import { type OperationGroup, operation } from './types.js'; +import { count, lines, moreLine } from './words.js'; + +/** + * Codes a buyer types at checkout on a subscription plan. A code can take a + * percentage off, a fixed amount off, or nothing at all — a code that changes no + * price still records who it brought in, which is how a referral works when the + * referred buyer pays full price. + * + * What a code takes off lives with the card processor, so only `show` reports it. + * `list` would need one call per row to say the same thing. + */ + +const CODE_MESSAGE = '--code can use letters, numbers, dashes and underscores, up to 64.'; +const BOTH_TERMS = 'Use --percent-off or --amount-off, not both.'; + +const createInput = z.object({ + code: z + .string() + .regex(/^[A-Za-z0-9_-]{1,64}$/, CODE_MESSAGE) + .describe('The code buyers type at checkout'), + name: z.string().optional().describe('Your label for it, shown on the buyer’s invoice'), + percentOff: z.coerce + .number() + .min(0.01) + .max(100) + .optional() + .describe('Percentage off the first invoice, e.g. 20'), + amountOff: positiveAmount.optional().describe('Fixed amount off the first invoice, e.g. 5.00'), + maxRedemptions: z.coerce + .number() + .int() + .min(1) + .optional() + .describe('Stop the code working after this many subscribers use it'), + expiresAt: z.string().optional().describe('Date the code stops working (YYYY-MM-DD)'), + plan: z.string().optional().describe('Limit it to one plan: the product id. Omit for all plans'), +}); + +export const discountsCreate = operation({ + name: 'discounts.create', + description: 'Create a discount code buyers type at checkout', + input: createInput, + async run(sdk, input): Promise { + if (input.percentOff !== undefined && input.amountOff !== undefined) { + throw new Error(BOTH_TERMS); + } + return sdk.discountCodes.create({ + code: input.code, + name: input.name, + percentOff: input.percentOff, + amountOffMinor: input.amountOff === undefined ? undefined : minorUnits(input.amountOff), + maxRedemptions: input.maxRedemptions, + expiresAt: input.expiresAt, + linkId: input.plan, + }); + }, + describe(code) { + return lines( + `Created ${code.code} — ${scope(code)}.`, + code.kind === 'tracking' + ? 'It changes no price; it tells you who it brought in.' + : 'Buyers type it on the checkout page and the first invoice drops.', + `Its id is ${code.id} (use it to show or archive the code).`, + ); + }, +}); + +export const discountsList = operation({ + name: 'discounts.list', + description: 'List discount codes', + input: z.object({ limit: pageLimit }), + async run(sdk, input) { + const page = await sdk.discountCodes.list({ limit: input.limit }); + return { total: page.total, hasMore: page.hasMore, items: page.items }; + }, + describe(page) { + if (!page.items.length) { + return 'No discount codes yet. Create one with discounts create --code LAUNCH20 --percent-off 20.'; + } + return lines( + `${count(page.total, 'discount code')}:`, + ...page.items.map((code) => ` - ${code.code} — ${scope(code)} — ${code.id}`), + 'What each one takes off is on discounts show.', + moreLine(page.items.length, page.total, page.hasMore), + ); + }, +}); + +export const discountsShow = operation({ + name: 'discounts.show', + description: 'Show one discount code: what it takes off and how many people used it', + input: z.object({ id: z.string().min(1).describe('The discount code id') }), + positional: 'id', + async run(sdk, input): Promise { + return sdk.discountCodes.get(input.id); + }, + describe(code) { + return lines( + `${code.code} — ${code.termsLabel} — ${usage(code)} — ${state(code)}.`, + code.planName ? `It works on "${code.planName}" only.` : 'It works on every plan you sell.', + ); + }, +}); + +export const discountsArchive = operation({ + name: 'discounts.archive', + description: 'Stop a discount code working', + input: z.object({ id: z.string().min(1).describe('The discount code id') }), + positional: 'id', + async run(sdk, input): Promise { + return sdk.discountCodes.archive(input.id); + }, + describe(code) { + return lines( + `${code.code} stops working now; nobody new can type it.`, + 'Subscribers who already used it keep the discount they were given.', + ); + }, +}); + +/** `discount — active — on "Pro monthly"` / `tracking only — active — all plans`. */ +function scope(code: DiscountCode): string { + const kind = code.kind === 'tracking' ? 'tracking only' : 'discount'; + const where = code.planName ? `on "${code.planName}"` : 'all plans'; + return `${kind} — ${state(code)} — ${where}`; +} + +function state(code: DiscountCode): string { + return code.active ? 'active' : 'stopped'; +} + +/** `used 14 of 100 times` when a cap was set, `used 14 times` when none was. */ +function usage(code: DiscountCodeDetail): string { + if (code.maxRedemptions === null) return `used ${count(code.timesRedeemed, 'time')}`; + return `used ${code.timesRedeemed} of ${code.maxRedemptions} times`; +} + +export const DISCOUNTS: OperationGroup = { + name: 'discounts', + description: 'Discount codes buyers type at checkout on a subscription plan', + operations: [discountsCreate, discountsList, discountsShow, discountsArchive], +}; diff --git a/packages/wallet/src/ops/index.ts b/packages/wallet/src/ops/index.ts index b2a8ecf..29fab53 100644 --- a/packages/wallet/src/ops/index.ts +++ b/packages/wallet/src/ops/index.ts @@ -1,5 +1,7 @@ import { AUDIT } from './audit.js'; +import { BUSINESSES } from './businesses.js'; import { CUSTOMERS } from './customers.js'; +import { DISCOUNTS } from './discounts.js'; import { INVOICES } from './invoices.js'; import { PAY } from './pay.js'; import { PRODUCTS } from './products.js'; @@ -21,8 +23,10 @@ const GROUPS: readonly OperationGroup[] = [ PRODUCTS, PAY, SUBSCRIPTIONS, + DISCOUNTS, CUSTOMERS, INVOICES, + BUSINESSES, ]; export const OPERATIONS: readonly Operation[] = GROUPS.flatMap((group) => group.operations); diff --git a/packages/wallet/src/ops/products.ts b/packages/wallet/src/ops/products.ts index 57d4a34..640a22b 100644 --- a/packages/wallet/src/ops/products.ts +++ b/packages/wallet/src/ops/products.ts @@ -28,7 +28,10 @@ const createInput = z.object({ .min(1, TRIAL_MESSAGE) .max(730, TRIAL_MESSAGE) .optional() - .describe('Free trial length for a plan, in days'), + .describe('Trial length for a plan, in days'), + trialAmount: positiveAmount + .optional() + .describe('Price of the whole trial (e.g. 9.00). Leave it out and the trial is free'), successUrl: httpsUrl('--success-url') .optional() .describe('https URL buyers return to after paying (we append ?sessionId=…)'), @@ -53,11 +56,20 @@ export function buildCreateParams( /** The rules that span two flags; zod checks one field at a time. */ function planRuleBroken(input: CreateInput): string | null { if (!input.subscription) { - return input.interval || input.trialDays !== undefined - ? '--interval and --trial-days need --subscription.' + return input.interval || hasTrial(input) + ? '--interval, --trial-days and --trial-amount need --subscription.' : null; } - return input.interval ? null : '--subscription needs --interval month or --interval year.'; + if (!input.interval) return '--subscription needs --interval month or --interval year.'; + // A price for a trial nobody gets is a price that is never charged; the API says the + // same, and a founder should hear it before the round-trip. + return input.trialAmount !== undefined && input.trialDays === undefined + ? '--trial-amount needs --trial-days: a trial price needs a trial to price.' + : null; +} + +function hasTrial(input: CreateInput): boolean { + return input.trialDays !== undefined || input.trialAmount !== undefined; } function toParams(input: CreateInput): CreatePaymentLinkParams { @@ -73,6 +85,7 @@ function toParams(input: CreateInput): CreatePaymentLinkParams { params.type = 'subscription'; params.billingInterval = input.interval; if (input.trialDays !== undefined) params.trialPeriodDays = input.trialDays; + if (input.trialAmount !== undefined) params.trialAmount = input.trialAmount; } return params; } @@ -88,6 +101,8 @@ export interface ProductView { checkoutUrl: string; successUrl: string | null; cancelUrl: string | null; + trialPeriodDays: number | null; + trialAmount: number | null; } function productView(link: PaymentLink): ProductView { @@ -102,6 +117,8 @@ function productView(link: PaymentLink): ProductView { checkoutUrl: link.checkoutUrl, successUrl: link.successUrl, cancelUrl: link.cancelUrl, + trialPeriodDays: link.trialPeriodDays ?? null, + trialAmount: link.trialAmount ?? null, }; } @@ -111,6 +128,17 @@ function price(product: ProductView): string { return `${money(product.amount, product.currency)}${cadence}`; } +/** What a new subscriber pays before the first full cycle, when there is a trial. */ +function trialLine(product: ProductView): string | null { + const days = product.trialPeriodDays; + if (days === null) return null; + const trial = + product.trialAmount === null + ? `${count(days, 'day')} free` + : `${count(days, 'day')} for ${money(product.trialAmount, product.currency)}`; + return `New subscribers get ${trial}, then it renews at ${price(product)}.`; +} + export const productsCreate = operation({ name: 'products.create', description: 'Create a product (one-time) or a subscription plan', @@ -124,6 +152,7 @@ export const productsCreate = operation({ const kind = product.type === 'subscription' ? 'plan' : 'product'; return lines( `Created the ${kind} "${product.name}" at ${price(product)}.`, + trialLine(product), `Buyers pay at ${product.checkoutUrl}; share it anywhere, it works for every sale.`, `Its id is ${product.id} (the linkId for checkouts and plan changes).`, ); diff --git a/packages/wallet/src/ops/schema.ts b/packages/wallet/src/ops/schema.ts index 138fa11..4200928 100644 --- a/packages/wallet/src/ops/schema.ts +++ b/packages/wallet/src/ops/schema.ts @@ -12,6 +12,15 @@ export const positiveAmount = z.coerce .number({ invalid_type_error: AMOUNT_MESSAGE }) .positive(AMOUNT_MESSAGE); +/** + * `5` → `500`. A founder types euros; two endpoints (credits and a fixed-amount + * discount) take integer minor units. This is the ONLY place the CLI turns one into + * the other, and it never touches a figure coming back — those arrive render-ready. + */ +export function minorUnits(amount: number): number { + return Math.round(amount * 100); +} + export const pageLimit = z.coerce .number() .int() diff --git a/packages/wallet/src/ops/subscriptions.ts b/packages/wallet/src/ops/subscriptions.ts index b3a3ba6..ca0b4bc 100644 --- a/packages/wallet/src/ops/subscriptions.ts +++ b/packages/wallet/src/ops/subscriptions.ts @@ -1,8 +1,15 @@ -import type { ChangePlanResult, PlanChangePreview, Subscription } from '@agentaos/pay'; +import { randomUUID } from 'node:crypto'; +import type { + ChangePlanResult, + Credit, + CreditList, + PlanChangePreview, + Subscription, +} from '@agentaos/pay'; import { z } from 'zod'; -import { pageLimit } from './schema.js'; +import { minorUnits, pageLimit, positiveAmount } from './schema.js'; import { type OperationGroup, operation } from './types.js'; -import { count, day, lines, moneyMinor, moreLine } from './words.js'; +import { count, day, lines, moneyMinor, moreLine, plainMinor } from './words.js'; /** * Recurring subscribers. Subscriptions are CREATED by buyers paying for a plan @@ -17,9 +24,12 @@ function planPrice(s: Subscription): string { export const subscriptionsList = operation({ name: 'subscriptions.list', description: 'List subscriptions', - input: z.object({ limit: pageLimit }), + input: z.object({ + limit: pageLimit, + code: z.string().optional().describe('Only subscribers who typed this discount code'), + }), async run(sdk, input) { - const page = await sdk.subscriptions.list({ limit: input.limit }); + const page = await sdk.subscriptions.list({ limit: input.limit, discountCode: input.code }); return { total: page.total, hasMore: page.hasMore, items: page.items }; }, describe(page) { @@ -28,13 +38,76 @@ export const subscriptionsList = operation({ `${count(page.total, 'subscription')}:`, ...page.items.map( (s) => - ` - ${s.status} — ${planPrice(s)} — ${s.customerEmail ?? s.customerName ?? 'unknown buyer'}${s.planName ? ` on ${s.planName}` : ''} — ${s.id}`, + ` - ${s.status} — ${planPrice(s)} — ${s.customerEmail ?? s.customerName ?? 'unknown buyer'}${s.planName ? ` on ${s.planName}` : ''}${codeNote(s)} — ${s.id}`, ), moreLine(page.items.length, page.total, page.hasMore), ); }, }); +/** ` via LAUNCH20` when the subscriber typed a code, nothing when they did not. */ +function codeNote(s: Subscription): string { + return s.discount ? ` via ${s.discount.code}` : ''; +} + +export const subscriptionsCredit = operation({ + name: 'subscriptions.credit', + description: 'Put credit on a subscriber’s account, taken off their next invoice', + input: z.object({ + id: z.string().min(1).describe('The subscription id'), + amount: positiveAmount.describe('Credit to give (e.g. 5.00), in the plan’s currency'), + reason: z.string().min(1).describe('Why you are giving it — for you, not the subscriber'), + }), + positional: 'id', + async run(sdk, input): Promise { + // One key per invocation. Running the command twice is two deliberate credits; + // a network retry inside this one call is not, and the key is what tells them + // apart. It is reported so a caller who must retry can send the same one. + const idempotencyKey = randomUUID(); + const credit = await sdk.subscriptions.credit(input.id, { + amountMinor: minorUnits(input.amount), + reason: input.reason, + idempotencyKey, + }); + return { ...credit, idempotencyKey }; + }, + describe(credit) { + return lines( + `${moneyMinor(credit.amountMinor, credit.currency)} credited.`, + `It comes off their next invoice on ${day(credit.nextInvoiceAt)}, leaving ${moneyMinor(credit.nextInvoiceDueAfterCreditMinor, credit.currency)} to pay.`, + `They now hold ${moneyMinor(credit.creditBalanceMinor, credit.currency)} in unspent credit with you.`, + ); + }, +}); + +type CreditGiven = Credit & { idempotencyKey: string }; + +export const subscriptionsCredits = operation({ + name: 'subscriptions.credits', + description: 'Credits given on a subscription, and what is still unspent', + input: z.object({ id: z.string().min(1).describe('The subscription id') }), + positional: 'id', + async run(sdk, input): Promise { + return sdk.subscriptions.credits(input.id); + }, + // The history reports amounts without naming a currency, so these print as plain + // figures: they are in the plan's own currency, and saying which one would be us + // guessing rather than the server telling. + describe(page) { + if (!page.items.length) { + return 'No credit given on this subscription yet. Give some with subscriptions credit.'; + } + return lines( + `${count(page.items.length, 'credit')} given, in the plan’s currency:`, + ...page.items.map( + (entry) => + ` - ${plainMinor(entry.amountMinor)} — ${entry.reason} — ${day(entry.createdAt)}`, + ), + `${plainMinor(page.balanceMinor)} is still unspent, across every subscription this buyer has with you.`, + ); + }, +}); + export const subscriptionsCancel = operation({ name: 'subscriptions.cancel', description: 'Cancel a subscription (at period end by default)', @@ -132,6 +205,12 @@ function appliedLine(result: ChangePlanResult): string { export const SUBSCRIPTIONS: OperationGroup = { name: 'subscriptions', - description: 'Subscription management (list, cancel, change-plan)', - operations: [subscriptionsChangePlan, subscriptionsList, subscriptionsCancel], + description: 'Subscription management (list, cancel, change-plan, credit)', + operations: [ + subscriptionsChangePlan, + subscriptionsList, + subscriptionsCancel, + subscriptionsCredit, + subscriptionsCredits, + ], }; diff --git a/packages/wallet/src/ops/words.ts b/packages/wallet/src/ops/words.ts index 576203c..1e37a9d 100644 --- a/packages/wallet/src/ops/words.ts +++ b/packages/wallet/src/ops/words.ts @@ -25,6 +25,15 @@ export function moneyMinor(minor: number, currency: string): string { return money(minor / 100, currency); } +/** + * `plainMinor(500)` → `5.00`. For the one response that carries amounts without the + * currency they are in — the credit history. Guessing a symbol there would state + * something the server never said. + */ +export function plainMinor(minor: number): string { + return (minor / 100).toFixed(2); +} + /** `day('2026-09-22T10:00:00Z')` → `22 Sep 2026`. */ export function day(iso: string): string { const d = new Date(iso); diff --git a/plugins/agentaos/skills/agentaos/SKILL.md b/plugins/agentaos/skills/agentaos/SKILL.md index c9de72e..dd509b8 100644 --- a/plugins/agentaos/skills/agentaos/SKILL.md +++ b/plugins/agentaos/skills/agentaos/SKILL.md @@ -1,7 +1,7 @@ --- name: agenta description: > - Use this skill when the user wants to set up AgentaOS, add payments or a paywall to an app they are building, create a product or subscription plan, accept a payment, create a checkout or payment link, get paid for an invoice, product or plan, apply for business verification or the free revenue audit, or manage subscriptions, customers, and invoices. Activate when the user mentions agenta, AgentaOS, product, plan, paywall, checkout, payment link, "add payments", "buy button", subscriptions, invoices, verification, audit, "accept payments", or "get paid". When the user says "use agenta", use the `agenta_*` tools (`agenta_status_get`, `agenta_products_*`, `agenta_pay_*`, `agenta_verify_*`, `agenta_audit_*`, `agenta_subscriptions_*`, `agenta_customers_*`, `agenta_invoices_*`) instead of other tools; if no MCP is available, the `agenta` CLI has the same operations. + Use this skill when the user wants to set up AgentaOS, add payments or a paywall to an app they are building, create a product or subscription plan, accept a payment, create a checkout or payment link, get paid for an invoice, product or plan, apply for business verification or the free revenue audit, or manage subscriptions, customers, and invoices. Activate when the user mentions agenta, AgentaOS, product, plan, paywall, checkout, payment link, "add payments", "buy button", subscriptions, invoices, verification, audit, "accept payments", or "get paid". When the user says "use agenta", use the `agenta_*` tools (`agenta_status_get`, `agenta_products_*`, `agenta_pay_*`, `agenta_verify_*`, `agenta_audit_*`, `agenta_subscriptions_*`, `agenta_discounts_*`, `agenta_customers_*`, `agenta_invoices_*`) instead of other tools; if no MCP is available, the `agenta` CLI has the same operations. --- # agenta @@ -60,6 +60,7 @@ From `agenta_status_get`, tell the user: test payments work now; the go-live ste |---|---| | Has no login, or sells to anyone (buy button, link in bio, invoice, landing page) | **Pattern A — frontend only**: links and redirect URLs, no server code | | Has logged-in users and must unlock something when they pay (paywall, Pro plan, credits) | **Pattern B — server side**: the server creates the checkout with the user's id, then webhook or polling | +| Wants the checkout ON its own page (Paddle/Stripe-style inline or overlay) instead of a hosted page | **Embedded checkout** (below) — with Pattern A's product link, or Pattern B's server-created checkout | Both patterns start from the same product. A product is created once, never once per buyer. @@ -135,6 +136,12 @@ await agentaos.subscriptions.cancel(sub.id); // stops the next renewal; acc **Free trials.** A trial belongs to the plan: `agenta_products_create` `{"name":"Pro","amount":29,"currency":"EUR","subscription":true,"interval":"month","trialDays":14}`. At checkout the card is saved with nothing due today, the subscription starts as `trialing`, and the first charge happens when the trial ends. The `checkout.session.completed` event at trial start has `amount: "0"`, `trial: true` and `trial_end`; treat it as "trial started", not as money received. +**Paid trials.** Add `trialAmount` and the trial is charged instead of free: `{"…":"…","trialDays":30,"trialAmount":9}` is "€9 for the first 30 days, then €29 a month". `amount` stays the recurring price. `trialAmount` needs `trialDays`; the buyer is charged at checkout, so this is money received, not a trial start. + +**Discount codes.** `agenta_discounts_create` `{"code":"LAUNCH20","percentOff":20}` mints a code buyers type at checkout; `amountOff` (e.g. `5`) takes a fixed amount off instead, and neither makes a tracking-only code that changes no price but records who it brought in — a referral code. `linkId` limits it to one plan; without one it works on every plan. Terms are fixed once created: to change them, archive and create another. `agenta_discounts_list` `{}` lists them, `agenta_discounts_show` `{"id":""}` is the only call that reports what a code takes off and how many people used it, and `agenta_discounts_archive` `{"id":""}` stops it (subscribers who already used it keep their discount). To see who a code brought in: `agenta_subscriptions_list` `{"code":"LAUNCH20"}`. + +**Credit a subscriber.** `agenta_subscriptions_credit` `{"id":"","amount":5,"reason":"Two days of downtime"}` puts credit on their account; it comes off their next invoice and no money moves out, so it is not a refund. Never more than the next invoice. `agenta_subscriptions_credits` `{"id":""}` lists what was given and what is still unspent — the balance is the buyer's, shared across every subscription they have with this merchant. + **Upgrade or downgrade.** Create the other plan as its own product (same currency, same interval), then move the subscription to it. Quote first, then apply; the tool echoes the quote's `prorationDate` back so the charge equals the quote. - Quote only: `agenta_subscriptions_change_plan` `{"id":"","to":"","dryRun":true}`. @@ -159,6 +166,25 @@ Rules for Pattern B: - Renewals fire `checkout.session.completed` without your `customerId`; match them by the stored `subscription.id`. - Full webhook reference (payloads, retries, other languages): `https://docs.agentaos.ai/llms-full.txt`. +## Embedded checkout (inline or overlay on the app's own page) + +The same checkout, shown on the app's page instead of a hosted one. Money, VAT, trials and 3D Secure are unchanged; AgentaOS stays the merchant of record. + +1. The user adds each site it will embed on in the dashboard: **Settings → Developers → Approved sites** (e.g. `https://yourapp.com`). The browser refuses every other site. Test products also work on `localhost`. Their look (button colour, font, corners, background) is set in **Settings → Business → Checkout appearance**. +2. Script tag, no build step — inline where `#checkout` is; leave out `target` for an overlay: + ```html + +
+ + ``` +3. React/Next: `pnpm add @agentaos/checkout`, then `` (inline) or `useAgentaOSCheckout().open({ link })` (overlay) from `@agentaos/checkout/react`. A Monthly/Yearly toggle just changes `link`. +4. Logged-in users (Pattern B): create the checkout on the server as usual and pass `session: checkout.sessionId` instead of `link`. +5. Events: `checkout.loaded`, `checkout.completed` (`kind`: `payment` | `trial` | `bank_transfer`, `amountMinor`, `sessionId`), `checkout.closed`, `checkout.error` (`not_loaded` usually means the site is not approved). After payment the whole page goes to the checkout's success URL (the one given to `checkouts.create`, else the product's) with `sessionId` (https only; never for `bank_transfer`, where the buyer must keep the bank details on screen), or pass `successUrl: false` and handle `checkout.completed` yourself. + +**Never unlock on the event** — confirm with the webhook or `agenta_pay_get`, once per `sessionId`. + ## Products and plans `agenta_products_create`, one product per price, created once: @@ -168,7 +194,7 @@ Rules for Pattern B: - Annual plan with a trial: `{"name":"Pro (annual)","amount":290,"currency":"EUR","subscription":true,"interval":"year","trialDays":14}` - `agenta_products_list` `{}` — everything so far (`limit`, default 10, max 100). -Rules: `subscription: true` needs `interval` `month` or `year`; `trialDays` (1–730) only on a plan. `successUrl` and `cancelUrl` must be `https://` (no plain http, no localhost); AgentaOS appends `?sessionId=` to the success URL. The result carries `id` (= `linkId`), `checkoutUrl` (= buyer link), `type`, `billingInterval`, `amount`, `currency`, `status`. A test-mode connection makes test products (real checkout page, test cards only); creating one is the merchant's first go-live milestone. +Rules: `subscription: true` needs `interval` `month` or `year`; `trialDays` (1–730) and `trialAmount` only on a plan, and `trialAmount` needs `trialDays`. `successUrl` and `cancelUrl` must be `https://` (no plain http, no localhost); AgentaOS appends `?sessionId=` to the success URL. The result carries `id` (= `linkId`), `checkoutUrl` (= buyer link), `type`, `billingInterval`, `amount`, `currency`, `status`. A test-mode connection makes test products (real checkout page, test cards only); creating one is the merchant's first go-live milestone. **Tell the user:** the product name and price, the link to share, and that its id is the `linkId` for server-side checkouts and plan changes. @@ -213,6 +239,8 @@ Rules: `subscription: true` needs `interval` `month` or `year`; `trialDays` (1 - `agenta_subscriptions_list` `{}` — recurring subscribers: status, price, buyer, plan, id. - `agenta_subscriptions_cancel` `{"id":""}` — at period end; `{"id":"…","now":true}` cancels immediately. Nothing is refunded either way. +- `agenta_subscriptions_credit` `{"id":"","amount":5,"reason":"…"}` — credit against their next invoice; `agenta_subscriptions_credits` `{"id":"…"}` reads the history. +- `agenta_discounts_create` `{"code":"LAUNCH20","percentOff":20}` / `agenta_discounts_list` `{}` / `agenta_discounts_show` `{"id":"…"}` / `agenta_discounts_archive` `{"id":"…"}` — codes buyers type at checkout. - `agenta_customers_list` `{}` — everyone who has paid. - `agenta_invoices_list` `{}` — tax-correct invoices, newest first (`issued` = not paid yet, `paid`, `voided`). - `agenta_invoices_receipt` `{"id":""}` — the receipt PDF as `pdfBase64`; decode it to a `.pdf` for the user (the CLI saves it to a file). @@ -220,9 +248,21 @@ Rules: `subscription: true` needs `interval` `month` or `year`; `trialDays` (1 **Tell the user:** for a cancellation, when access ends and that nothing is charged after; for a receipt, who it went to. +## Platforms: businesses you manage (Connect) + +For a user who runs other people's businesses (an agency, a marketplace, a SaaS) or several apps of one company. It works like Stripe Connect: each client is a **business** you manage, and you act as it by naming it. + +- `agenta_businesses_create` `{"name":"ClientCo","country":"EE","clientEmail":"ana@example.com","inviteEmail":false}` — adds the business in test mode and invites the client as its admin. With `inviteEmail:false` we send no email: give the user the returned `inviteUrl` to send themselves. `sameCompany:true` = another app of their own company (verified and priced with them, no share). +- To act **as** a business, pass `business: ""` to any tool (CLI: `--business ` on any command), e.g. `agenta_products_create` with `business`, or `agenta_verify_submit` with `business` to file the client's verification for them (recorded as filed by the platform). +- `agenta_businesses_id_link` `{"id":"…"}` — the identity check link. **Only the client can complete it**; give the link to the user to send. Needs a live key. A null link means already verified. +- `agenta_businesses_list` / `agenta_businesses_get` — status, `identityVerified`, open invitation. `agenta_businesses_invite` sends the invitation again (new link); `agenta_businesses_revoke_invite` withdraws it. +- The platform's share of each client sale is set in the dashboard only (a % of the price before VAT + a fixed amount). Every webhook event carries `business`; the platform's own webhook also receives its clients' events, and `account.updated` says when a client's verification changes. + +**Tell the user:** after creating a business, its id and either "we emailed ana@…" or the link to send; after an ID check link, that only their client can complete it. + ## Every operation -Same 19 operations on both surfaces. Inputs not listed as required are optional; `limit` defaults to 10. +Same 31 operations on both surfaces. Every one also takes `business` (CLI `--business `) to act for a business you manage. Inputs not listed as required are optional; `limit` defaults to 10. | What it does | MCP tool | CLI command | Required inputs | |---|---|---|---| @@ -233,7 +273,7 @@ Same 19 operations on both surfaces. Inputs not listed as required are optional; | Submit business verification for live payments | `agenta_verify_submit` | `agenta verify submit` | `legalName`, `country`, `street`, `city`, `url`, `acceptDeclaration`; `registrationNumber` for a business | | Where the verification has got to | `agenta_verify_status` | `agenta verify status` | none | | Send back for review after the changes we asked for | `agenta_verify_resubmit` | `agenta verify resubmit` | none | -| Create a product (one-time) or a subscription plan | `agenta_products_create` | `agenta products create` | `name`, `amount` (+ `interval` with `subscription`) | +| Create a product (one-time) or a subscription plan | `agenta_products_create` | `agenta products create` | `name`, `amount` (+ `interval` with `subscription`; `trialAmount` needs `trialDays`) | | List products and plans | `agenta_products_list` | `agenta products list` | none | | Create a checkout session | `agenta_pay_checkout` | `agenta pay checkout` | `amount` | | Get checkout session status | `agenta_pay_get` | `agenta pay get ` | `sessionId` | @@ -241,10 +281,22 @@ Same 19 operations on both surfaces. Inputs not listed as required are optional; | List subscriptions | `agenta_subscriptions_list` | `agenta subscriptions list` | none | | Cancel a subscription (at period end by default) | `agenta_subscriptions_cancel` | `agenta subscriptions cancel ` | `id` | | Move a subscription to another plan | `agenta_subscriptions_change_plan` | `agenta subscriptions change-plan ` | `id`, `to` | +| Credit a subscriber against their next invoice | `agenta_subscriptions_credit` | `agenta subscriptions credit ` | `id`, `amount`, `reason` | +| Credits given, and what is still unspent | `agenta_subscriptions_credits` | `agenta subscriptions credits ` | `id` | +| Create a discount code buyers type at checkout | `agenta_discounts_create` | `agenta discounts create` | `code` | +| List discount codes | `agenta_discounts_list` | `agenta discounts list` | none | +| What one code takes off, and how many used it | `agenta_discounts_show` | `agenta discounts show ` | `id` | +| Stop a discount code working | `agenta_discounts_archive` | `agenta discounts archive ` | `id` | | List customers | `agenta_customers_list` | `agenta customers list` | none | | List invoices | `agenta_invoices_list` | `agenta invoices list` | none | | The receipt PDF for a paid invoice | `agenta_invoices_receipt` | `agenta invoices receipt ` | `id` | | Re-send the receipt email to the buyer on file | `agenta_invoices_send_receipt` | `agenta invoices send-receipt ` | `id` | +| List the businesses you manage | `agenta_businesses_list` | `agenta businesses list` | none | +| One business you manage | `agenta_businesses_get` | `agenta businesses get ` | `id` | +| Add a business (a client's, or another app of yours) | `agenta_businesses_create` | `agenta businesses create` | `name`, `country` | +| Send the client's invitation again | `agenta_businesses_invite` | `agenta businesses invite ` | `id` | +| Withdraw the client's invitation | `agenta_businesses_revoke_invite` | `agenta businesses revoke-invite ` | `id` | +| The identity check link only the client can complete | `agenta_businesses_id_link` | `agenta businesses id-link ` | `id` | ## Facts to get right diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e1982d..d4b5324 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,7 +43,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.0.0 - version: 2.1.9(@types/node@20.19.32) + version: 2.1.9(@types/node@20.19.32)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@5.0.10)) examples/claude-agent-sdk: dependencies: @@ -109,6 +109,28 @@ importers: specifier: ^4.21.0 version: 4.21.0 + examples/paywall-demo: + dependencies: + '@agentaos/pay': + specifier: workspace:* + version: link:../../packages/pay + express: + specifier: ^5.2.1 + version: 5.2.1 + devDependencies: + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 + '@types/node': + specifier: ^20.19.32 + version: 20.19.32 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + examples/vercel-ai-sdk: dependencies: '@agentaos/sdk': @@ -153,6 +175,24 @@ importers: specifier: ^2.21.0 version: 2.45.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + packages/checkout: + devDependencies: + '@types/react': + specifier: ^18.3.0 + version: 18.3.31 + '@types/react-dom': + specifier: ^18.3.7 + version: 18.3.7(@types/react@18.3.31) + jsdom: + specifier: ^25.0.0 + version: 25.0.1(bufferutil@4.1.0)(utf-8-validate@5.0.10) + react: + specifier: ^18.3.0 + version: 18.3.1 + react-dom: + specifier: ^18.3.0 + version: 18.3.1(react@18.3.1) + packages/core: {} packages/mcp-remote: @@ -181,7 +221,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.0.0 - version: 2.1.9(@types/node@20.19.32) + version: 2.1.9(@types/node@20.19.32)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@5.0.10)) wrangler: specifier: 4.128.0 version: 4.128.0(@cloudflare/workers-types@5.20260901.1)(bufferutil@4.1.0)(utf-8-validate@5.0.10) @@ -309,6 +349,9 @@ packages: zod: optional: true + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@babel/runtime@7.28.6': resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} @@ -335,24 +378,28 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [musl] '@biomejs/cli-linux-arm64@1.9.4': resolution: {integrity: sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [glibc] '@biomejs/cli-linux-x64-musl@1.9.4': resolution: {integrity: sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [musl] '@biomejs/cli-linux-x64@1.9.4': resolution: {integrity: sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [glibc] '@biomejs/cli-win32-arm64@1.9.4': resolution: {integrity: sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==} @@ -483,6 +530,34 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + '@emnapi/runtime@1.11.3': resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} @@ -1003,144 +1078,170 @@ packages: resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm64@1.3.1': resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.0.5': resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.3.1': resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.3.1': resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.3.1': resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.3.1': resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.0.4': resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.3.1': resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.0.4': resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-arm64@1.3.1': resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.0.4': resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.3.1': resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.33.5': resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm64@0.35.2': resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.33.5': resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.35.2': resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.35.2': resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.35.2': resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.35.2': resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.33.5': resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.35.2': resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.33.5': resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-arm64@0.35.2': resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.33.5': resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.35.2': resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.35.2': resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==} @@ -1303,66 +1404,79 @@ packages: resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.57.1': resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.57.1': resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.57.1': resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.57.1': resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.57.1': resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.57.1': resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.57.1': resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.57.1': resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.57.1': resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.57.1': resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.57.1': resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.57.1': resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.57.1': resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} @@ -1437,12 +1551,23 @@ packages: '@types/node@20.19.32': resolution: {integrity: sha512-Ez8QE4DMfhjjTsES9K2dwfV258qBui7qxUsoaixZDiTzbde4U12e1pXGNu/ECsUIOi5/zoCxAQxIhQnaUQ2VvA==} + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/qs@6.15.0': resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==} '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.31': + resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} + '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} @@ -1503,6 +1628,10 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ai@4.3.19: resolution: {integrity: sha512-dIE2bfNpqHN3r6IINp9znguYdhIOheKW2LDigAMrgt/upT3B8eBGPSCblENvaZGoq+hxaN9fSMzjWpbqloP+7Q==} engines: {node: '>=18'} @@ -1569,6 +1698,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -1660,6 +1792,10 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + commander@12.1.0: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} @@ -1706,6 +1842,17 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + dataloader@1.4.0: resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==} @@ -1730,10 +1877,17 @@ packages: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -1783,6 +1937,10 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -1805,6 +1963,10 @@ packages: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -1899,6 +2061,10 @@ packages: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -1965,18 +2131,38 @@ packages: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + hono@4.11.9: resolution: {integrity: sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==} engines: {node: '>=16.9.0'} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + human-id@4.1.3: resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==} hasBin: true @@ -1990,6 +2176,10 @@ packages: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -2029,6 +2219,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -2073,6 +2266,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@25.0.1: + resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + json-schema-to-ts@3.1.1: resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} engines: {node: '>=16'} @@ -2146,6 +2348,9 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -2253,6 +2458,9 @@ packages: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true + nwsapi@2.2.27: + resolution: {integrity: sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -2350,6 +2558,9 @@ packages: package-manager-detector@0.2.11: resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -2418,6 +2629,10 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + qs@6.14.1: resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} engines: {node: '>=0.6'} @@ -2440,6 +2655,11 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -2483,6 +2703,12 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -2492,6 +2718,13 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + secure-json-parse@2.7.0: resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} @@ -2631,6 +2864,9 @@ packages: peerDependencies: react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} @@ -2657,6 +2893,13 @@ packages: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -2665,9 +2908,17 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + ts-algebra@2.0.0: resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} @@ -2834,9 +3085,30 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -2896,6 +3168,13 @@ packages: utf-8-validate: optional: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yaml@2.8.2: resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} engines: {node: '>= 14.6'} @@ -2975,6 +3254,14 @@ snapshots: optionalDependencies: zod: 4.3.6 + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + '@babel/runtime@7.28.6': {} '@biomejs/biome@1.9.4': @@ -3204,6 +3491,26 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + '@emnapi/runtime@1.11.3': dependencies: tslib: 2.8.1 @@ -3884,10 +4191,21 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/prop-types@15.7.15': {} + '@types/qs@6.15.0': {} '@types/range-parser@1.2.7': {} + '@types/react-dom@18.3.7(@types/react@18.3.31)': + dependencies: + '@types/react': 18.3.31 + + '@types/react@18.3.31': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + '@types/retry@0.12.0': {} '@types/send@1.2.1': @@ -3961,6 +4279,8 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 + agent-base@7.1.4: {} + ai@4.3.19(react@18.3.1)(zod@3.25.76): dependencies: '@ai-sdk/provider': 1.1.3 @@ -4014,6 +4334,8 @@ snapshots: assertion-error@2.0.1: {} + asynckit@0.4.0: {} + base64-js@1.5.1: {} better-path-resolve@1.0.0: @@ -4118,6 +4440,10 @@ snapshots: colorette@2.0.20: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@12.1.0: {} commander@14.0.3: {} @@ -4153,6 +4479,18 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + csstype@3.2.3: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + dataloader@1.4.0: {} debug@2.6.9: @@ -4165,8 +4503,12 @@ snapshots: decamelize@1.2.0: {} + decimal.js@10.6.0: {} + deep-eql@5.0.2: {} + delayed-stream@1.0.0: {} + depd@2.0.0: {} dequal@2.0.3: {} @@ -4202,6 +4544,8 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + entities@6.0.1: {} + environment@1.1.0: {} error-stack-parser-es@1.0.5: {} @@ -4216,6 +4560,13 @@ snapshots: dependencies: es-errors: 1.3.0 + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -4446,6 +4797,14 @@ snapshots: locate-path: 5.0.0 path-exists: 4.0.0 + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + forwarded@0.2.0: {} fresh@0.5.2: {} @@ -4514,12 +4873,24 @@ snapshots: has-symbols@1.1.0: {} + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + hasown@2.0.2: dependencies: function-bind: 1.1.2 + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + hono@4.11.9: {} + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -4528,6 +4899,20 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + human-id@4.1.3: {} husky@9.1.7: {} @@ -4536,6 +4921,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -4562,6 +4951,8 @@ snapshots: is-number@7.0.0: {} + is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} is-subdir@1.2.0: @@ -4597,6 +4988,34 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@5.0.10): + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.6 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.27 + parse5: 7.3.0 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + json-schema-to-ts@3.1.1: dependencies: '@babel/runtime': 7.28.6 @@ -4676,6 +5095,8 @@ snapshots: loupe@3.2.1: {} + lru-cache@10.4.3: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -4750,6 +5171,8 @@ snapshots: node-gyp-build@4.8.4: optional: true + nwsapi@2.2.27: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -4862,6 +5285,10 @@ snapshots: dependencies: quansync: 0.2.11 + parse5@7.3.0: + dependencies: + entities: 6.0.1 + parseurl@1.3.3: {} path-exists@4.0.0: {} @@ -4905,6 +5332,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + punycode@2.3.1: {} + qs@6.14.1: dependencies: side-channel: 1.1.0 @@ -4929,6 +5358,12 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + react@18.3.1: dependencies: loose-envify: 1.4.0 @@ -4998,6 +5433,10 @@ snapshots: transitivePeerDependencies: - supports-color + rrweb-cssom@0.7.1: {} + + rrweb-cssom@0.8.0: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -5006,6 +5445,14 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + secure-json-parse@2.7.0: {} semver@7.7.4: {} @@ -5197,6 +5644,8 @@ snapshots: react: 18.3.1 use-sync-external-store: 1.6.0(react@18.3.1) + symbol-tree@3.2.4: {} + term-size@2.2.1: {} throttleit@2.1.0: {} @@ -5211,14 +5660,28 @@ snapshots: tinyspy@3.0.2: {} + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 toidentifier@1.0.1: {} + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + tr46@0.0.3: {} + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + ts-algebra@2.0.0: {} tslib@2.8.1: @@ -5359,7 +5822,7 @@ snapshots: '@types/node': 20.19.32 fsevents: 2.3.3 - vitest@2.1.9(@types/node@20.19.32): + vitest@2.1.9(@types/node@20.19.32)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@5.0.10)): dependencies: '@vitest/expect': 2.1.9 '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@20.19.32)) @@ -5383,6 +5846,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 20.19.32 + jsdom: 25.0.1(bufferutil@4.1.0)(utf-8-validate@5.0.10) transitivePeerDependencies: - less - lightningcss @@ -5394,8 +5858,25 @@ snapshots: - supports-color - terser + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + webidl-conversions@3.0.1: {} + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -5453,6 +5934,10 @@ snapshots: bufferutil: 4.1.0 utf-8-validate: 5.0.10 + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yaml@2.8.2: {} youch-core@0.3.3: