diff --git a/docs/README.md b/docs/README.md index 564a4df..30c84ac 100644 --- a/docs/README.md +++ b/docs/README.md @@ -36,6 +36,7 @@ then follow [Installation](getting-started/installation.md). - [Quotes & estimates](guides/quotes.md) — priced proposals customers accept online - [Contacts & sub-accounts](guides/contacts.md) — scoped additional contacts - [Usage-metered billing](guides/metered-billing.md) — bill by consumption +- [Agent commerce](guides/agent-commerce.md) — delegated checkout, ACP, and x402 USDC payments - [Mass mail](guides/mass-mail.md) — bulk email to segments - [Announcements](guides/announcements.md) — the news/blog module - [Themes](guides/themes.md) — built-in themes and custom ones @@ -44,8 +45,9 @@ then follow [Installation](getting-started/installation.md). ## Extensions - [Extensions overview](extensions/overview.md) — how the driver system works -- [Payment gateways](extensions/payment-gateways.md) — all 19 gateways and their setup +- [Payment gateways](extensions/payment-gateways.md) — all 20 gateways and their setup - [Server modules](extensions/server-modules.md) — all 27 provisioning integrations +- [Server driver certification](extensions/server-certification.md) — disposable live lifecycle harness - [Resale modules](extensions/resale-modules.md) — domains, SSL, licenses, M365 seats - [Writing an extension](extensions/writing-extensions.md) — build your own driver diff --git a/docs/extensions/overview.md b/docs/extensions/overview.md index 96f871b..fe69fd0 100644 --- a/docs/extensions/overview.md +++ b/docs/extensions/overview.md @@ -42,7 +42,7 @@ The system follows a few deliberate principles: ## Reference pages -- [Payment gateways](payment-gateways.md) — all 19, with setup +- [Payment gateways](payment-gateways.md) — all 20, with setup - [Server modules](server-modules.md) — all 27, with setup - [Resale modules](resale-modules.md) — domains, SSL, licenses, seats - [Writing an extension](writing-extensions.md) — build your own diff --git a/docs/extensions/payment-gateways.md b/docs/extensions/payment-gateways.md index a96cfbf..ea475f4 100644 --- a/docs/extensions/payment-gateways.md +++ b/docs/extensions/payment-gateways.md @@ -1,6 +1,6 @@ # Payment gateways -OpenHosting includes 19 payment gateways. Enable and configure the ones you use +OpenHosting includes 20 payment gateways. Enable and configure the ones you use under **Admin → Extensions**; enabled gateways appear as payment options at checkout. Gateways that confirm payment asynchronously call back at `/api/webhooks/` — set that URL in the gateway's dashboard. @@ -35,6 +35,15 @@ tokenization key. ## Crypto +### x402 (`x402`) — USDC machine payments + +x402 v2 exact settlement for USDC invoices. Configure a facilitator URL, +CAIP-2 network, official USDC asset identifier, receiving wallet, and optional +facilitator credential. This driver is only offered for `USDC` invoices; the +other gateways are only offered for conventional three-letter currencies. +See [Agent commerce](../guides/agent-commerce.md#usdc-and-x402-v2) for the HTTP +flow and mainnet safety checklist. + ### Coinbase Commerce (`coinbase-commerce`) Hosted crypto checkout. Set the **API key** and webhook shared secret. diff --git a/docs/getting-started/introduction.md b/docs/getting-started/introduction.md index 452cd66..3a8aec5 100644 --- a/docs/getting-started/introduction.md +++ b/docs/getting-started/introduction.md @@ -16,7 +16,7 @@ no PHP runtime, and no per-client license fee — it's MIT licensed. |---|---| | **Storefront** | Categories, products, six billing cycles, configurable options, cart, coupons, taxes, multi-currency | | **Billing** | Orders → invoices → services, automated renewals, suspension and termination, account credit, auto-charge with saved cards | -| **Payments** | 19 gateways: cards, crypto, SEPA, merchant-of-record and regional | +| **Payments** | 20 gateways: cards, USDC/x402, crypto, SEPA, merchant-of-record and regional | | **Provisioning** | 27 server integrations (game panels, VPS/cloud, enterprise virt, web panels) | | **Resale** | Domains, SSL certificates, software licenses, Microsoft 365 / Google Workspace seats | | **Support** | Tickets with departments, priorities and attachments; notification center | diff --git a/docs/guides/agent-commerce.md b/docs/guides/agent-commerce.md new file mode 100644 index 0000000..85ea0e9 --- /dev/null +++ b/docs/guides/agent-commerce.md @@ -0,0 +1,141 @@ +# Agent commerce + +OpenHosting can let a signed-in customer delegate narrowly bounded purchases to +an autonomous client. The same checkout core powers a native JSON API, an +Agentic Commerce Protocol (ACP) adapter for Stripe delegated payments, and an +x402 v2 settlement endpoint for USDC. + +Anonymous autonomous account creation is not part of this release. It remains +on the [future roadmap](../roadmap.md): an agent grant always belongs to an +existing customer account whose normal identity, fraud, and recovery controls +already apply. + +## Safety model + +Customers create grants under **Dashboard → Account → Agent access**. The raw +`ag_…` bearer token is displayed once; only its SHA-256 hash is stored. Each +grant has all of these boundaries: + +- an expiry of at most 90 days; +- an exact product allowlist; +- an exact currency/asset allowlist; +- a maximum amount per order; and +- a cumulative committed-spend limit. + +Spend limits are denominated in the installation's base currency. Reservation +uses a serializable database transaction, so simultaneous agents cannot both +consume the same remaining allowance. Creating an unpaid order commits its +amount against the grant; this is deliberately conservative and prevents an +agent from evading the cap by opening many unpaid invoices. + +Revoke a grant immediately from the same page. Never put an agent token in a +URL, browser bundle, repository, log, or model prompt. + +## Machine-readable catalog + +`GET /api/agent/catalog` is public and returns visible products, billing-cycle +SKUs, configuration choices, metered-unit pricing, enabled currencies, asset +precision, settlement networks, and protocol endpoints. The discovery document +at `GET /.well-known/agent-commerce` points clients to that catalog. + +Amounts in the catalog are advisory presentment values. Checkout always reloads +the product, stock, coupon, tax, currency rate, and grant policy and returns the +authoritative total. + +## Native agent checkout + +Send the grant as a bearer credential and a unique idempotency key: + +```bash +curl -X POST https://billing.example.com/api/agent/checkout \ + -H 'Authorization: Bearer ag_…' \ + -H 'Idempotency-Key: deployment-2026-08-27-001' \ + -H 'Content-Type: application/json' \ + -d '{ + "currency": "USDC", + "lines": [{ + "product_id": "PRODUCT_ID", + "cycle": "MONTHLY", + "quantity": 1, + "option_value_ids": [] + }] + }' +``` + +Repeating an identical request with the same key returns the original checkout. +Reusing the key with a different body is rejected. The response contains the +order, invoice, current status, and compatible payment options. Poll +`GET /api/agent/checkout/:id` with the same bearer token when needed. + +## ACP 2026-04-17 + +The ACP adapter implements the versioned checkout-session lifecycle under +`/api/acp/checkout_sessions`: + +| Method | Endpoint | Purpose | +|---|---|---| +| `POST` | `/api/acp/checkout_sessions` | Create an authoritative cart | +| `GET` | `/api/acp/checkout_sessions/:id` | Retrieve current state | +| `POST` | `/api/acp/checkout_sessions/:id` | Update items, buyer data, or discounts | +| `POST` | `/api/acp/checkout_sessions/:id/complete` | Pay and create the order | +| `POST` | `/api/acp/checkout_sessions/:id/cancel` | Cancel a draft session | + +Every request needs `Authorization: Bearer ag_…` and +`API-Version: 2026-04-17`; every mutating request also needs +`Idempotency-Key`. ACP item IDs are the `sku` values from the agent catalog. +The adapter supports enabled, two-decimal ISO fiat currencies. USDC uses the +x402 path instead because ACP amounts and Stripe PaymentIntents are expressed +in fiat minor units. + +Completion currently accepts the `stripe_spt` handler with a Stripe Shared +Payment Token credential. The server binds the PaymentIntent to OpenHosting's +recalculated invoice total and uses a processor idempotency key before marking +the invoice paid. Stripe describes agentic-commerce and Shared Payment Token +features as private preview, so confirm account eligibility and test-mode +behavior before enabling this surface. + +## USDC and x402 v2 + +The seed includes USDC, disabled by default, with six-decimal precision and Base +and Solana mainnet CAIP-2 identifiers. See [Currencies](currencies.md) before +enabling it. + +Enable **x402 (USDC)** under **Admin → Extensions** and configure: + +- an x402 v2 facilitator base URL; +- an optional facilitator bearer credential; +- one CAIP-2 network that is also allowed on the USDC currency record; +- the official USDC contract/mint identifier for that exact network; +- the merchant receiving wallet; and +- a short authorization timeout. + +For an authorized customer's pending USDC invoice, POST to +`/api/x402/invoices/:invoice_id`. With no `PAYMENT-SIGNATURE`, OpenHosting +returns `402 Payment Required` and a base64-encoded `PAYMENT-REQUIRED` header. +The client signs those exact terms and retries with `PAYMENT-SIGNATURE`. +OpenHosting binds the payload to the invoice URL, amount, asset, network, and +recipient; asks the configured facilitator to verify and settle it; persists +the network transaction before applying billing side effects; and returns the +settlement in `PAYMENT-RESPONSE`. + +Settlement records make retries idempotent and prevent one proof from being +attached to multiple invoices. OpenHosting never receives or stores a payer +private key or seed phrase. + +Use testnet first. Confirm the facilitator's current network support and the +official USDC identifier directly with the facilitator and Circle before moving +to mainnet. A wrong asset or receiving address can make funds unrecoverable. + +## Metered services + +x402 works for any pending USDC invoice, including renewal invoices containing +metered usage. Usage accumulates at eight-decimal internal precision, then +converts once to the service's locked charge asset and rounds at the invoice +boundary—six decimals for USDC and normally two for fiat. + +## Protocol references + +- [ACP specification](https://github.com/agentic-commerce-protocol/agentic-commerce-protocol) +- [Stripe ACP integration](https://docs.stripe.com/agentic-commerce/protocol) +- [x402 v2 specification](https://github.com/x402-foundation/x402/blob/main/specs/x402-specification-v2.md) +- [Circle USDC documentation](https://developers.circle.com/stablecoins/usdc-contract-addresses) diff --git a/docs/guides/currencies.md b/docs/guides/currencies.md index 9c31166..11152b6 100644 --- a/docs/guides/currencies.md +++ b/docs/guides/currencies.md @@ -5,22 +5,39 @@ priced in a single **base currency**. ## Base currency -Set your base currency under **Admin → Settings → Currency** (an ISO code like -`USD` or `EUR`). All product prices, coupons, tax amounts and account credit are -stored in this currency. +Set your base currency under **Admin → Settings → Currency** (normally an ISO +code like `USD` or `EUR`). All product prices, coupons, tax amounts and account +credit are stored in this currency. ## Additional currencies Add more under **Admin → Currencies**. Each has: -- **ISO code** (e.g. `EUR`) +- **Currency or asset code** (e.g. `EUR` or `USDC`) - **Symbol** (optional, e.g. `€`) +- **Kind** — fiat or stablecoin +- **Decimals** — `2` for most fiat currencies and `6` for USDC - **Rate** — how many units of this currency equal 1 unit of the base currency +- **Settlement networks** — CAIP-2 network identifiers accepted by the payment rail - **Enabled** — whether customers can pick it For example, with a `USD` base and `EUR` at rate `0.92`, a `$5.99` product shows as `€5.51`. +## USDC + +The seed data includes USDC as a disabled stablecoin with six-decimal precision. +Its initial settlement networks are Base (`eip155:8453`) and Solana mainnet +(`solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp`). The `rate` is still relative to +your configured base currency; a value of `1` is only appropriate when that base +is USD and your pricing policy treats one USDC as one USD. + +Enabling the asset makes it available for pricing and invoice locking. It does +not, by itself, activate on-chain collection. Configure and test the x402 +gateway's facilitator, receiving address, network, and asset identifier before +accepting live payments. OpenHosting never stores a payer seed phrase or private +key. + ## How customers use it When more than one currency is enabled, a **currency picker** appears in the diff --git a/docs/roadmap.md b/docs/roadmap.md index ef95c0e..d8934f5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -42,15 +42,15 @@ contributions and design feedback shape the ordering. ## Phase 2 — "AI agents as customers" (v0.7+) -5. **Agent checkout API** — a first-class machine purchase flow: +5. **Agent checkout API — shipped** — a first-class machine purchase flow: machine-readable catalog feed, idempotent order placement, delegated purchase tokens with spend caps and product allow-lists, full audit trail. The fraud pipeline gains an "agent buyer" lane instead of treating all automation as abuse. -6. **Agentic Commerce Protocol (ACP)** — expose catalog + checkout in ACP - shape through the Stripe gateway, so purchases can complete natively - inside AI surfaces (ChatGPT, Gemini) as that channel opens to services. -7. **x402 machine payments** — a gateway driver accepting stablecoin +6. **Agentic Commerce Protocol (ACP) — beta shipped** — a versioned + 2026-04-17 checkout-session surface through Stripe Shared Payment Tokens, + backed by the same delegated limits and order engine. +7. **x402 machine payments — shipped** — an x402 v2 gateway accepting USDC per-use payments (HTTP 402 flow); pairs with metered billing for true pay-per-request / pay-per-hour infrastructure resale. 8. **Admin copilot** — a chat panel inside the admin that uses OpenHosting's @@ -72,6 +72,12 @@ contributions and design feedback shape the ordering. 12. **GPU / AI-infra reseller pack** — hourly and GPU-metered billing presets plus token-usage metering, targeting the fastest-growing hosting segment: AI compute resale. +13. **Anonymous autonomous account creation** — let an agent establish and + operate a pseudonymous account without a pre-existing human login. This is + intentionally a future capability, after delegated checkout is proven: it + requires durable agent identity, recovery and ownership rules, abuse and + fraud controls, jurisdiction-aware compliance, and tightly bounded payment + authority before an account can provision infrastructure safely. ## Principles (constant across all phases) diff --git a/prisma/migrations/z_14_usdc_money/migration.sql b/prisma/migrations/z_14_usdc_money/migration.sql new file mode 100644 index 0000000..f34a3cf --- /dev/null +++ b/prisma/migrations/z_14_usdc_money/migration.sql @@ -0,0 +1,35 @@ +CREATE TYPE "CurrencyKind" AS ENUM ('FIAT', 'STABLECOIN'); + +ALTER TABLE "Currency" +ADD COLUMN "kind" "CurrencyKind" NOT NULL DEFAULT 'FIAT', +ADD COLUMN "decimals" INTEGER NOT NULL DEFAULT 2, +ADD COLUMN "settlementNetworks" JSONB NOT NULL DEFAULT '[]', +ALTER COLUMN "rate" TYPE DECIMAL(20,8); + +ALTER TABLE "User" ALTER COLUMN "credits" TYPE DECIMAL(20,8); +ALTER TABLE "Product" ALTER COLUMN "affiliateCommissionValue" TYPE DECIMAL(20,8); +ALTER TABLE "Product" ALTER COLUMN "meteredUnitPrice" TYPE DECIMAL(20,8); +ALTER TABLE "ProductPrice" ALTER COLUMN "price" TYPE DECIMAL(20,8); +ALTER TABLE "ProductPrice" ALTER COLUMN "setupFee" TYPE DECIMAL(20,8); +ALTER TABLE "ConfigOptionValue" ALTER COLUMN "price" TYPE DECIMAL(20,8); +ALTER TABLE "Order" ALTER COLUMN "subtotal" TYPE DECIMAL(20,8); +ALTER TABLE "Order" ALTER COLUMN "discount" TYPE DECIMAL(20,8); +ALTER TABLE "Order" ALTER COLUMN "tax" TYPE DECIMAL(20,8); +ALTER TABLE "Order" ALTER COLUMN "total" TYPE DECIMAL(20,8); +ALTER TABLE "OrderItem" ALTER COLUMN "unitPrice" TYPE DECIMAL(20,8); +ALTER TABLE "OrderItem" ALTER COLUMN "setupFee" TYPE DECIMAL(20,8); +ALTER TABLE "Service" ALTER COLUMN "price" TYPE DECIMAL(20,8); +ALTER TABLE "Invoice" ALTER COLUMN "subtotal" TYPE DECIMAL(20,8); +ALTER TABLE "Invoice" ALTER COLUMN "discount" TYPE DECIMAL(20,8); +ALTER TABLE "Invoice" ALTER COLUMN "tax" TYPE DECIMAL(20,8); +ALTER TABLE "Invoice" ALTER COLUMN "total" TYPE DECIMAL(20,8); +ALTER TABLE "InvoiceItem" ALTER COLUMN "unitPrice" TYPE DECIMAL(20,8); +ALTER TABLE "Payment" ALTER COLUMN "amount" TYPE DECIMAL(20,8); +ALTER TABLE "Coupon" ALTER COLUMN "value" TYPE DECIMAL(20,8); +ALTER TABLE "Quote" ALTER COLUMN "subtotal" TYPE DECIMAL(20,8); +ALTER TABLE "Quote" ALTER COLUMN "tax" TYPE DECIMAL(20,8); +ALTER TABLE "Quote" ALTER COLUMN "total" TYPE DECIMAL(20,8); +ALTER TABLE "QuoteItem" ALTER COLUMN "unitPrice" TYPE DECIMAL(20,8); +ALTER TABLE "Affiliate" ALTER COLUMN "balance" TYPE DECIMAL(20,8); +ALTER TABLE "Affiliate" ALTER COLUMN "totalEarned" TYPE DECIMAL(20,8); +ALTER TABLE "AffiliateCommission" ALTER COLUMN "amount" TYPE DECIMAL(20,8); diff --git a/prisma/migrations/z_15_agent_commerce/migration.sql b/prisma/migrations/z_15_agent_commerce/migration.sql new file mode 100644 index 0000000..117cbf3 --- /dev/null +++ b/prisma/migrations/z_15_agent_commerce/migration.sql @@ -0,0 +1,105 @@ +CREATE TABLE "AgentGrant" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "prefix" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "allowedProductIds" JSONB NOT NULL DEFAULT '[]', + "allowedCurrencies" JSONB NOT NULL DEFAULT '[]', + "maxPerOrderBase" DECIMAL(20,8) NOT NULL, + "spendLimitBase" DECIMAL(20,8) NOT NULL, + "committedBase" DECIMAL(20,8) NOT NULL DEFAULT 0, + "expiresAt" TIMESTAMP(3), + "revokedAt" TIMESTAMP(3), + "lastUsedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "AgentGrant_pkey" PRIMARY KEY ("id") +); + +CREATE TYPE "AgentCheckoutStatus" AS ENUM ( + 'DRAFT', + 'PROCESSING', + 'PAYMENT_PENDING', + 'PAID', + 'CANCELED', + 'FAILED' +); + +CREATE TABLE "AgentCheckout" ( + "id" TEXT NOT NULL, + "grantId" TEXT NOT NULL, + "protocol" TEXT NOT NULL, + "idempotencyKey" TEXT NOT NULL, + "requestHash" TEXT NOT NULL, + "request" JSONB NOT NULL, + "status" "AgentCheckoutStatus" NOT NULL DEFAULT 'DRAFT', + "currency" TEXT NOT NULL, + "amount" DECIMAL(20,8) NOT NULL DEFAULT 0, + "baseAmount" DECIMAL(20,8) NOT NULL DEFAULT 0, + "orderId" TEXT, + "invoiceId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "AgentCheckout_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "AgentCheckoutOperation" ( + "id" TEXT NOT NULL, + "checkoutId" TEXT NOT NULL, + "operation" TEXT NOT NULL, + "idempotencyKey" TEXT NOT NULL, + "requestHash" TEXT NOT NULL, + "response" JSONB, + "completedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "AgentCheckoutOperation_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "AgentGrant_tokenHash_key" ON "AgentGrant"("tokenHash"); +CREATE INDEX "AgentGrant_userId_revokedAt_idx" ON "AgentGrant"("userId", "revokedAt"); +CREATE UNIQUE INDEX "AgentCheckout_orderId_key" ON "AgentCheckout"("orderId"); +CREATE UNIQUE INDEX "AgentCheckout_invoiceId_key" ON "AgentCheckout"("invoiceId"); +CREATE UNIQUE INDEX "AgentCheckout_grantId_protocol_idempotencyKey_key" +ON "AgentCheckout"("grantId", "protocol", "idempotencyKey"); +CREATE INDEX "AgentCheckout_grantId_status_idx" ON "AgentCheckout"("grantId", "status"); +CREATE UNIQUE INDEX "AgentCheckoutOperation_checkoutId_operation_idempotencyKey_key" +ON "AgentCheckoutOperation"("checkoutId", "operation", "idempotencyKey"); + +ALTER TABLE "AgentGrant" +ADD CONSTRAINT "AgentGrant_userId_fkey" +FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "AgentCheckout" +ADD CONSTRAINT "AgentCheckout_grantId_fkey" +FOREIGN KEY ("grantId") REFERENCES "AgentGrant"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "AgentCheckoutOperation" +ADD CONSTRAINT "AgentCheckoutOperation_checkoutId_fkey" +FOREIGN KEY ("checkoutId") REFERENCES "AgentCheckout"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE TYPE "X402SettlementStatus" AS ENUM ('VERIFYING', 'SETTLED', 'FAILED'); + +CREATE TABLE "X402Settlement" ( + "id" TEXT NOT NULL, + "invoiceId" TEXT NOT NULL, + "paymentSignatureHash" TEXT NOT NULL, + "requirementsHash" TEXT NOT NULL, + "status" "X402SettlementStatus" NOT NULL DEFAULT 'VERIFYING', + "network" TEXT NOT NULL, + "transaction" TEXT, + "payer" TEXT, + "response" JSONB, + "error" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "X402Settlement_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "X402Settlement_invoiceId_key" ON "X402Settlement"("invoiceId"); +CREATE UNIQUE INDEX "X402Settlement_paymentSignatureHash_key" ON "X402Settlement"("paymentSignatureHash"); +CREATE UNIQUE INDEX "X402Settlement_network_transaction_key" ON "X402Settlement"("network", "transaction"); + +ALTER TABLE "X402Settlement" +ADD CONSTRAINT "X402Settlement_invoiceId_fkey" +FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e6de773..0fea34d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,48 +13,49 @@ datasource db { // ── Users & auth ──────────────────────────────────────────────────────────── model User { - id String @id @default(cuid()) - email String @unique - password String - firstName String - lastName String - companyName String? - address String? - city String? - state String? - zip String? - country String? - phone String? - credits Decimal @default(0) @db.Decimal(12, 2) // in base currency - currency String? // preferred display/charge currency - vatId String? - vatValidatedAt DateTime? + id String @id @default(cuid()) + email String @unique + password String + firstName String + lastName String + companyName String? + address String? + city String? + state String? + zip String? + country String? + phone String? + credits Decimal @default(0) @db.Decimal(20, 8) // in base currency + currency String? // preferred display/charge currency + vatId String? + vatValidatedAt DateTime? referredByAffiliateId String? - referredBy Affiliate? @relation("ReferredUsers", fields: [referredByAffiliateId], references: [id], onDelete: SetNull) - emailVerifiedAt DateTime? - totpSecret String? - totpEnabledAt DateTime? - roleId String? - role Role? @relation(fields: [roleId], references: [id], onDelete: SetNull) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - sessions Session[] - orders Order[] - services Service[] - invoices Invoice[] - tickets Ticket[] - ticketMessages TicketMessage[] - assignedTickets Ticket[] @relation("AssignedTickets") - apiKeys ApiKey[] - auditLogs AuditLog[] - tokens VerificationToken[] - paymentMethods StoredPaymentMethod[] - notifications Notification[] + referredBy Affiliate? @relation("ReferredUsers", fields: [referredByAffiliateId], references: [id], onDelete: SetNull) + emailVerifiedAt DateTime? + totpSecret String? + totpEnabledAt DateTime? + roleId String? + role Role? @relation(fields: [roleId], references: [id], onDelete: SetNull) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + sessions Session[] + orders Order[] + services Service[] + invoices Invoice[] + tickets Ticket[] + ticketMessages TicketMessage[] + assignedTickets Ticket[] @relation("AssignedTickets") + apiKeys ApiKey[] + agentGrants AgentGrant[] + auditLogs AuditLog[] + tokens VerificationToken[] + paymentMethods StoredPaymentMethod[] + notifications Notification[] notificationPreferences NotificationPreference[] - affiliate Affiliate? - contacts Contact[] - quotes Quote[] + affiliate Affiliate? + contacts Contact[] + quotes Quote[] } // A sub-account / additional contact under a customer's account. May have its @@ -90,9 +91,9 @@ model Quote { user User @relation(fields: [userId], references: [id], onDelete: Cascade) status QuoteStatus @default(DRAFT) currency String @default("USD") - subtotal Decimal @default(0) @db.Decimal(12, 2) - tax Decimal @default(0) @db.Decimal(12, 2) - total Decimal @default(0) @db.Decimal(12, 2) + subtotal Decimal @default(0) @db.Decimal(20, 8) + tax Decimal @default(0) @db.Decimal(20, 8) + total Decimal @default(0) @db.Decimal(20, 8) notes String? validUntil DateTime? invoiceId String? // set once accepted and converted @@ -109,7 +110,7 @@ model QuoteItem { quote Quote @relation(fields: [quoteId], references: [id], onDelete: Cascade) description String quantity Int @default(1) - unitPrice Decimal @db.Decimal(12, 2) + unitPrice Decimal @db.Decimal(20, 8) } model Notification { @@ -220,34 +221,34 @@ model Category { } model Product { - id String @id @default(cuid()) - name String - slug String @unique - description String? - image String? - categoryId String - category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + name String + slug String @unique + description String? + image String? + categoryId String + category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade) // null = unlimited stock - stock Int? - hidden Boolean @default(false) - sortOrder Int @default(0) - allowQuantity Boolean @default(false) + stock Int? + hidden Boolean @default(false) + sortOrder Int @default(0) + allowQuantity Boolean @default(false) // Server-provisioning extension that fulfils this product, plus its per-product // settings (e.g. Pterodactyl egg/limits). - serverExtensionId String? - serverExtension Extension? @relation(fields: [serverExtensionId], references: [id], onDelete: SetNull) - serverConfig Json? + serverExtensionId String? + serverExtension Extension? @relation(fields: [serverExtensionId], references: [id], onDelete: SetNull) + serverConfig Json? // per-product affiliate commission override (null = program default) affiliateCommissionType CommissionType? - affiliateCommissionValue Decimal? @db.Decimal(12, 2) + affiliateCommissionValue Decimal? @db.Decimal(20, 8) // Resale extension (domain/SSL/license/M365) that fulfils this product. - resaleExtensionId String? - resaleExtension Extension? @relation("ProductResaleExtension", fields: [resaleExtensionId], references: [id], onDelete: SetNull) - resaleConfig Json? + resaleExtensionId String? + resaleExtension Extension? @relation("ProductResaleExtension", fields: [resaleExtensionId], references: [id], onDelete: SetNull) + resaleConfig Json? // Usage-metered billing: metered usage is added to each renewal invoice. - metered Boolean @default(false) - meteredUnit String? // e.g. "GB", "requests" - meteredUnitPrice Decimal? @db.Decimal(12, 4) + metered Boolean @default(false) + meteredUnit String? // e.g. "GB", "requests" + meteredUnitPrice Decimal? @db.Decimal(20, 8) prices ProductPrice[] configOptions ConfigOption[] @@ -256,8 +257,8 @@ model Product { coupons Coupon[] upgradesFrom ProductUpgrade[] @relation("UpgradeFrom") upgradesTo ProductUpgrade[] @relation("UpgradeTo") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } enum BillingCycle { @@ -274,8 +275,8 @@ model ProductPrice { productId String product Product @relation(fields: [productId], references: [id], onDelete: Cascade) cycle BillingCycle - price Decimal @db.Decimal(12, 2) - setupFee Decimal @default(0) @db.Decimal(12, 2) + price Decimal @db.Decimal(20, 8) + setupFee Decimal @default(0) @db.Decimal(20, 8) @@unique([productId, cycle]) } @@ -298,7 +299,7 @@ model ConfigOptionValue { label String value String // monthly price delta; scaled to the chosen cycle at checkout - price Decimal @default(0) @db.Decimal(12, 2) + price Decimal @default(0) @db.Decimal(20, 8) sortOrder Int @default(0) } @@ -338,18 +339,18 @@ model Order { ip String? riskScore Int? riskNotes String? - currency String @default("USD") - subtotal Decimal @default(0) @db.Decimal(12, 2) - discount Decimal @default(0) @db.Decimal(12, 2) - tax Decimal @default(0) @db.Decimal(12, 2) - total Decimal @default(0) @db.Decimal(12, 2) - couponId String? - coupon Coupon? @relation(fields: [couponId], references: [id], onDelete: SetNull) - items OrderItem[] - services Service[] - invoices Invoice[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + currency String @default("USD") + subtotal Decimal @default(0) @db.Decimal(20, 8) + discount Decimal @default(0) @db.Decimal(20, 8) + tax Decimal @default(0) @db.Decimal(20, 8) + total Decimal @default(0) @db.Decimal(20, 8) + couponId String? + coupon Coupon? @relation(fields: [couponId], references: [id], onDelete: SetNull) + items OrderItem[] + services Service[] + invoices Invoice[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([userId]) } @@ -362,8 +363,8 @@ model OrderItem { product Product @relation(fields: [productId], references: [id]) cycle BillingCycle quantity Int @default(1) - unitPrice Decimal @db.Decimal(12, 2) - setupFee Decimal @default(0) @db.Decimal(12, 2) + unitPrice Decimal @db.Decimal(20, 8) + setupFee Decimal @default(0) @db.Decimal(20, 8) // chosen config option values: [{option, value, label, price}] config Json? } @@ -378,30 +379,30 @@ enum ServiceStatus { // A provisioned instance of a product owned by a user. model Service { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - productId String - product Product @relation(fields: [productId], references: [id]) - orderId String? - order Order? @relation(fields: [orderId], references: [id], onDelete: SetNull) - status ServiceStatus @default(PENDING) - cycle BillingCycle - price Decimal @db.Decimal(12, 2) - currency String? // locked at order time; null = base currency - quantity Int @default(1) - config Json? + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + productId String + product Product @relation(fields: [productId], references: [id]) + orderId String? + order Order? @relation(fields: [orderId], references: [id], onDelete: SetNull) + status ServiceStatus @default(PENDING) + cycle BillingCycle + price Decimal @db.Decimal(20, 8) + currency String? // locked at order time; null = base currency + quantity Int @default(1) + config Json? // id of the resource created by the provisioning extension - externalId String? - expiresAt DateTime? - suspendedAt DateTime? - cancelledAt DateTime? + externalId String? + expiresAt DateTime? + suspendedAt DateTime? + cancelledAt DateTime? // customer requested cancellation at the end of the paid period - cancelAtPeriodEnd Boolean @default(false) + cancelAtPeriodEnd Boolean @default(false) // resale products capture extra input at checkout (domain, CSR, seats…) - resaleData Json? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + resaleData Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt invoiceItems InvoiceItem[] usageRecords UsageRecord[] @@ -433,26 +434,27 @@ enum InvoiceStatus { } model Invoice { - id String @id @default(cuid()) - number Int @unique @default(autoincrement()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - orderId String? - order Order? @relation(fields: [orderId], references: [id], onDelete: SetNull) - status InvoiceStatus @default(PENDING) - currency String @default("USD") - subtotal Decimal @default(0) @db.Decimal(12, 2) - discount Decimal @default(0) @db.Decimal(12, 2) - tax Decimal @default(0) @db.Decimal(12, 2) - total Decimal @default(0) @db.Decimal(12, 2) - dueAt DateTime? - paidAt DateTime? + id String @id @default(cuid()) + number Int @unique @default(autoincrement()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + orderId String? + order Order? @relation(fields: [orderId], references: [id], onDelete: SetNull) + status InvoiceStatus @default(PENDING) + currency String @default("USD") + subtotal Decimal @default(0) @db.Decimal(20, 8) + discount Decimal @default(0) @db.Decimal(20, 8) + tax Decimal @default(0) @db.Decimal(20, 8) + total Decimal @default(0) @db.Decimal(20, 8) + dueAt DateTime? + paidAt DateTime? // failed off-session charge attempts (capped by the billing engine) - autoChargeAttempts Int @default(0) - items InvoiceItem[] - payments Payment[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + autoChargeAttempts Int @default(0) + items InvoiceItem[] + payments Payment[] + x402Settlement X402Settlement? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([userId]) @@index([status, dueAt]) @@ -464,7 +466,7 @@ model InvoiceItem { invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) description String quantity Int @default(1) - unitPrice Decimal @db.Decimal(12, 2) + unitPrice Decimal @db.Decimal(20, 8) serviceId String? service Service? @relation(fields: [serviceId], references: [id], onDelete: SetNull) // side effects on payment, e.g. {"upgradeServiceId": "...", "toProductId": "...", "newPrice": 12.5} @@ -483,7 +485,7 @@ model Payment { invoiceId String invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) gateway String // extension slug, or "credits" / "manual" - amount Decimal @db.Decimal(12, 2) + amount Decimal @db.Decimal(20, 8) currency String @default("USD") status PaymentStatus @default(PENDING) transactionId String? // gateway-side reference @@ -493,6 +495,30 @@ model Payment { @@index([invoiceId]) } +enum X402SettlementStatus { + VERIFYING + SETTLED + FAILED +} + +model X402Settlement { + id String @id @default(cuid()) + invoiceId String @unique + invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) + paymentSignatureHash String @unique + requirementsHash String + status X402SettlementStatus @default(VERIFYING) + network String + transaction String? + payer String? + response Json? + error String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([network, transaction]) +} + // ── Promotions & taxes ────────────────────────────────────────────────────── enum CouponType { @@ -504,7 +530,7 @@ model Coupon { id String @id @default(cuid()) code String @unique type CouponType - value Decimal @db.Decimal(12, 2) + value Decimal @db.Decimal(20, 8) maxUses Int? // null = unlimited uses Int @default(0) expiresAt DateTime? @@ -542,10 +568,18 @@ model TaxRate { // Additional display/charge currencies. The base currency lives in settings // (`currency`); `rate` is units of this currency per 1 unit of base. model Currency { - code String @id // ISO 4217, e.g. EUR - symbol String? - rate Decimal @db.Decimal(12, 6) - enabled Boolean @default(true) + code String @id // ISO 4217 or digital asset symbol + symbol String? + kind CurrencyKind @default(FIAT) + decimals Int @default(2) + settlementNetworks Json @default("[]") + rate Decimal @db.Decimal(20, 8) + enabled Boolean @default(true) +} + +enum CurrencyKind { + FIAT + STABLECOIN } // ── Support ───────────────────────────────────────────────────────────────── @@ -594,7 +628,7 @@ model TicketMessage { createdAt DateTime @default(now()) } -// Database storage remains the default; S3-compatible storage keeps only a key. +// Stored in PostgreSQL by default, or in a private S3-compatible bucket. model TicketAttachment { id String @id @default(cuid()) messageId String @@ -626,16 +660,16 @@ enum ExtensionType { // Installed integration: payment gateway or server-provisioning backend. model Extension { - id String @id @default(cuid()) - type ExtensionType - slug String @unique // matches a driver in src/lib/extensions - name String - enabled Boolean @default(false) - config Json @default("{}") - products Product[] - resaleProducts Product[] @relation("ProductResaleExtension") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + type ExtensionType + slug String @unique // matches a driver in src/lib/extensions + name String + enabled Boolean @default(false) + config Json @default("{}") + products Product[] + resaleProducts Product[] @relation("ProductResaleExtension") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } model ApiKey { @@ -651,6 +685,79 @@ model ApiKey { createdAt DateTime @default(now()) } +// A customer-created credential delegated to an autonomous purchasing agent. +// Limits are denominated in the installation's base currency so one grant can +// safely allow more than one presentment currency. +model AgentGrant { + id String @id @default(cuid()) + name String + tokenHash String @unique + prefix String + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + allowedProductIds Json @default("[]") + allowedCurrencies Json @default("[]") + maxPerOrderBase Decimal @db.Decimal(20, 8) + spendLimitBase Decimal @db.Decimal(20, 8) + committedBase Decimal @default(0) @db.Decimal(20, 8) + expiresAt DateTime? + revokedAt DateTime? + lastUsedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + checkouts AgentCheckout[] + + @@index([userId, revokedAt]) +} + +enum AgentCheckoutStatus { + DRAFT + PROCESSING + PAYMENT_PENDING + PAID + CANCELED + FAILED +} + +// Canonical checkout state shared by the native agent API and protocol +// adapters. Protocol-specific payloads stay in `request`; OpenHosting remains +// the system of record for the resulting order and invoice. +model AgentCheckout { + id String @id @default(cuid()) + grantId String + grant AgentGrant @relation(fields: [grantId], references: [id], onDelete: Cascade) + protocol String + idempotencyKey String + requestHash String + request Json + status AgentCheckoutStatus @default(DRAFT) + currency String + amount Decimal @default(0) @db.Decimal(20, 8) + baseAmount Decimal @default(0) @db.Decimal(20, 8) + orderId String? @unique + invoiceId String? @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + operations AgentCheckoutOperation[] + + @@unique([grantId, protocol, idempotencyKey]) + @@index([grantId, status]) +} + +model AgentCheckoutOperation { + id String @id @default(cuid()) + checkoutId String + checkout AgentCheckout @relation(fields: [checkoutId], references: [id], onDelete: Cascade) + operation String + idempotencyKey String + requestHash String + response Json? + completedAt DateTime? + createdAt DateTime @default(now()) + + @@unique([checkoutId, operation, idempotencyKey]) +} + model EmailTemplate { key String @id // e.g. invoice_created, service_activated subject String @@ -685,8 +792,8 @@ model Affiliate { userId String @unique user User @relation(fields: [userId], references: [id], onDelete: Cascade) code String @unique - balance Decimal @default(0) @db.Decimal(12, 2) // base currency - totalEarned Decimal @default(0) @db.Decimal(12, 2) + balance Decimal @default(0) @db.Decimal(20, 8) // base currency + totalEarned Decimal @default(0) @db.Decimal(20, 8) visits Int @default(0) createdAt DateTime @default(now()) commissions AffiliateCommission[] @@ -699,7 +806,7 @@ model AffiliateCommission { affiliate Affiliate @relation(fields: [affiliateId], references: [id], onDelete: Cascade) referredUserId String invoiceId String? - amount Decimal @db.Decimal(12, 2) // base currency + amount Decimal @db.Decimal(20, 8) // base currency status CommissionStatus @default(EARNED) createdAt DateTime @default(now()) } @@ -760,16 +867,16 @@ model KbCategory { } model KbArticle { - id String @id @default(cuid()) - categoryId String? - category KbCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull) - title String - slug String @unique - body String - published Boolean @default(false) - views Int @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + categoryId String? + category KbCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull) + title String + slug String @unique + body String + published Boolean @default(false) + views Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([published]) } diff --git a/prisma/seed.ts b/prisma/seed.ts index ae610ed..5a20461 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -259,6 +259,22 @@ async function main() { update: {}, create: { code: "EUR", symbol: "€", rate: 0.92, enabled: true }, }); + await db.currency.upsert({ + where: { code: "USDC" }, + update: {}, + create: { + code: "USDC", + symbol: "USDC", + kind: "STABLECOIN", + decimals: 6, + settlementNetworks: [ + "eip155:8453", + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + ], + rate: 1, + enabled: false, + }, + }); // ── Settings & email templates ──────────────────────────────────────────── const PLACEHOLDER_URL = "http://localhost:3000"; diff --git a/src/app/.well-known/agent-commerce/route.ts b/src/app/.well-known/agent-commerce/route.ts new file mode 100644 index 0000000..fab02cf --- /dev/null +++ b/src/app/.well-known/agent-commerce/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { publicUrl } from "@/lib/settings"; + +export async function GET() { + const baseUrl = await publicUrl(); + return NextResponse.json({ + version: "2026-08-27", + catalog: `${baseUrl}/api/agent/catalog`, + native_checkout: `${baseUrl}/api/agent/checkout`, + agentic_commerce_protocol: { + version: "2026-04-17", + checkout_sessions: `${baseUrl}/api/acp/checkout_sessions`, + }, + x402: { version: 2 }, + }); +} diff --git a/src/app/admin/currencies/page.tsx b/src/app/admin/currencies/page.tsx index 9813010..28b526a 100644 --- a/src/app/admin/currencies/page.tsx +++ b/src/app/admin/currencies/page.tsx @@ -19,7 +19,8 @@ export default async function AdminCurrenciesPage() {

Base currency: {base} (set under Settings). Rates are units of the currency per 1 {base}; prices convert automatically and - orders lock their currency at checkout. + orders lock their currency at checkout. Stablecoins also declare their + supported settlement networks.

@@ -28,7 +29,10 @@ export default async function AdminCurrenciesPage() { Code Symbol + Kind + Decimals Rate (per 1 {base}) + Networks Enabled @@ -38,7 +42,14 @@ export default async function AdminCurrenciesPage() { {currency.code} {currency.symbol ?? "—"} + {currency.kind === "STABLECOIN" ? "Stablecoin" : "Fiat"} + {currency.decimals} {Number(currency.rate)} + + {Array.isArray(currency.settlementNetworks) + ? currency.settlementNetworks.filter((value) => typeof value === "string").join(", ") || "—" + : "—"} + {currency.enabled ? "Yes" : "No"}
@@ -55,7 +66,7 @@ export default async function AdminCurrenciesPage() { ))} {currencies.length === 0 && ( - + No additional currencies — customers pay in {base}. @@ -69,13 +80,13 @@ export default async function AdminCurrenciesPage() {
- +
@@ -83,6 +94,39 @@ export default async function AdminCurrenciesPage() {
+
+
+ + +
+
+ + +
+
+
+ + +

+ Comma-separated CAIP-2 identifiers; used by stablecoin payment rails. +

+
}, +) { + const grant = await authenticateAgentGrant(request); + if (!grant) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + const headers = validateAcpRequest(request); + if (headers.response) return headers.response; + const { id } = await context.params; + try { + const result = await cancelAcpSession({ + grant, + checkoutId: id, + idempotencyKey: headers.idempotencyKey!, + }); + return acpResponse(result.response, { + idempotencyKey: headers.idempotencyKey, + replayed: result.replayed, + }); + } catch (error) { + return acpError(error); + } +} diff --git a/src/app/api/acp/checkout_sessions/[id]/complete/route.ts b/src/app/api/acp/checkout_sessions/[id]/complete/route.ts new file mode 100644 index 0000000..8e61de4 --- /dev/null +++ b/src/app/api/acp/checkout_sessions/[id]/complete/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { authenticateAgentGrant } from "@/lib/agent-auth"; +import { acpError, acpResponse, validateAcpRequest } from "@/lib/acp-http"; +import { completeAcpSession } from "@/lib/services/acp"; + +const schema = z.object({ + payment_data: z.object({ + handler_id: z.literal("stripe_spt"), + instrument: z.object({ + type: z.string(), + credential: z.object({ type: z.literal("spt"), token: z.string().min(1).max(1_000) }), + }), + }), +}).passthrough(); + +export async function POST( + request: Request, + context: { params: Promise<{ id: string }> }, +) { + const grant = await authenticateAgentGrant(request); + if (!grant) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + const headers = validateAcpRequest(request); + if (headers.response) return headers.response; + const parsed = schema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) { + return acpResponse( + { type: "invalid_request_error", code: "invalid_payment_data", message: "A Stripe SPT credential is required.", details: parsed.error.issues }, + { status: 422 }, + ); + } + const { id } = await context.params; + try { + const result = await completeAcpSession({ + grant, + checkoutId: id, + idempotencyKey: headers.idempotencyKey!, + token: parsed.data.payment_data.instrument.credential.token, + ip: request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null, + }); + return acpResponse(result.response, { + idempotencyKey: headers.idempotencyKey, + replayed: result.replayed, + }); + } catch (error) { + return acpError(error); + } +} diff --git a/src/app/api/acp/checkout_sessions/[id]/route.ts b/src/app/api/acp/checkout_sessions/[id]/route.ts new file mode 100644 index 0000000..341c1af --- /dev/null +++ b/src/app/api/acp/checkout_sessions/[id]/route.ts @@ -0,0 +1,82 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { authenticateAgentGrant } from "@/lib/agent-auth"; +import { acpError, acpResponse, validateAcpRequest } from "@/lib/acp-http"; +import { acpRepresentation, updateAcpSession } from "@/lib/services/acp"; +import { db } from "@/lib/db"; + +const updateSchema = z.object({ + line_items: z + .array(z.object({ id: z.string().min(1), quantity: z.number().int().min(1).max(100) })) + .min(1) + .max(50) + .optional(), + capabilities: z.record(z.string(), z.unknown()).optional(), + buyer: z.record(z.string(), z.unknown()).optional(), + coupons: z.array(z.string()).max(1).optional(), + discounts: z.object({ codes: z.array(z.string()).max(1).optional() }).optional(), +}).passthrough(); + +export async function GET( + request: Request, + context: { params: Promise<{ id: string }> }, +) { + const grant = await authenticateAgentGrant(request); + if (!grant) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + const headers = validateAcpRequest(request, false); + if (headers.response) return headers.response; + const { id } = await context.params; + const exists = await db.agentCheckout.findFirst({ + where: { id, grantId: grant.id, protocol: "acp" }, + select: { id: true }, + }); + if (!exists) { + return acpResponse( + { type: "invalid_request_error", code: "not_found", message: "Checkout session not found." }, + { status: 404 }, + ); + } + try { + return acpResponse(await acpRepresentation(id)); + } catch (error) { + return acpError(error); + } +} + +export async function POST( + request: Request, + context: { params: Promise<{ id: string }> }, +) { + const grant = await authenticateAgentGrant(request); + if (!grant) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + const headers = validateAcpRequest(request); + if (headers.response) return headers.response; + const parsed = updateSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) { + return acpResponse( + { type: "invalid_request_error", code: "invalid_request", message: "Invalid ACP update request.", details: parsed.error.issues }, + { status: 422 }, + ); + } + const { id } = await context.params; + try { + const result = await updateAcpSession({ + grant, + checkoutId: id, + idempotencyKey: headers.idempotencyKey!, + changes: { + lineItems: parsed.data.line_items, + buyer: parsed.data.buyer, + capabilities: parsed.data.capabilities, + couponCode: + parsed.data.discounts?.codes?.[0] ?? parsed.data.coupons?.[0] ?? undefined, + }, + }); + return acpResponse(result.response, { + idempotencyKey: headers.idempotencyKey, + replayed: result.replayed, + }); + } catch (error) { + return acpError(error); + } +} diff --git a/src/app/api/acp/checkout_sessions/route.ts b/src/app/api/acp/checkout_sessions/route.ts new file mode 100644 index 0000000..701d68d --- /dev/null +++ b/src/app/api/acp/checkout_sessions/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { authenticateAgentGrant } from "@/lib/agent-auth"; +import { acpError, acpResponse, validateAcpRequest } from "@/lib/acp-http"; +import { createAcpSession, acpRepresentation } from "@/lib/services/acp"; + +const schema = z.object({ + line_items: z + .array(z.object({ id: z.string().min(1), quantity: z.number().int().min(1).max(100) })) + .min(1) + .max(50), + currency: z.string().length(3), + capabilities: z.record(z.string(), z.unknown()), + buyer: z.record(z.string(), z.unknown()).optional(), + coupons: z.array(z.string()).max(1).optional(), + discounts: z.object({ codes: z.array(z.string()).max(1).optional() }).optional(), +}).passthrough(); + +export async function POST(request: Request) { + const grant = await authenticateAgentGrant(request); + if (!grant) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + const headers = validateAcpRequest(request); + if (headers.response) return headers.response; + const parsed = schema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) { + return acpResponse( + { type: "invalid_request_error", code: "invalid_request", message: "Invalid ACP checkout request.", details: parsed.error.issues }, + { status: 422 }, + ); + } + try { + const created = await createAcpSession({ + grant, + idempotencyKey: headers.idempotencyKey!, + request: { + currency: parsed.data.currency, + lineItems: parsed.data.line_items, + couponCode: parsed.data.discounts?.codes?.[0] ?? parsed.data.coupons?.[0] ?? null, + buyer: parsed.data.buyer, + capabilities: parsed.data.capabilities, + }, + }); + return acpResponse(await acpRepresentation(created.checkout.id), { + status: 201, + idempotencyKey: headers.idempotencyKey, + replayed: created.replayed, + }); + } catch (error) { + return acpError(error); + } +} diff --git a/src/app/api/agent/catalog/route.ts b/src/app/api/agent/catalog/route.ts new file mode 100644 index 0000000..5384ff5 --- /dev/null +++ b/src/app/api/agent/catalog/route.ts @@ -0,0 +1,96 @@ +import { NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { publicUrl } from "@/lib/settings"; +import { + convertFromBase, + getEnabledCurrencies, +} from "@/lib/services/currency"; + +export async function GET() { + const [baseUrl, currencies, products] = await Promise.all([ + publicUrl(), + getEnabledCurrencies(), + db.product.findMany({ + where: { hidden: false }, + orderBy: [{ category: { sortOrder: "asc" } }, { sortOrder: "asc" }], + include: { + category: true, + prices: true, + configOptions: { + orderBy: { sortOrder: "asc" }, + include: { values: { orderBy: { sortOrder: "asc" } } }, + }, + }, + }), + ]); + + return NextResponse.json( + { + version: "2026-08-27", + merchant: { url: baseUrl }, + checkout: { + endpoint: `${baseUrl}/api/agent/checkout`, + authentication: "bearer_agent_grant", + idempotency_header: "Idempotency-Key", + protocols: { + native: `${baseUrl}/api/agent/checkout`, + acp: `${baseUrl}/api/acp/checkout_sessions`, + acp_version: "2026-04-17", + x402: "v2", + }, + }, + currencies: currencies.map((currency) => ({ + code: currency.code, + kind: currency.kind.toLowerCase(), + decimals: currency.decimals, + rate_from_base: currency.rate, + settlement_networks: currency.settlementNetworks, + })), + products: products.map((product) => ({ + id: product.id, + slug: product.slug, + name: product.name, + description: product.description, + image: product.image, + category: { + id: product.category.id, + slug: product.category.slug, + name: product.category.name, + }, + availability: product.stock === 0 ? "out_of_stock" : "in_stock", + available_quantity: product.stock, + allow_quantity: product.allowQuantity, + metered: product.metered + ? { + unit: product.meteredUnit, + unit_price_base: product.meteredUnitPrice?.toString() ?? null, + } + : null, + offers: product.prices.map((price) => ({ + sku: price.id, + cycle: price.cycle, + amounts: currencies.map((currency) => ({ + currency: currency.code, + price: convertFromBase(Number(price.price), currency).toFixed( + currency.decimals, + ), + setup_fee: convertFromBase(Number(price.setupFee), currency).toFixed( + currency.decimals, + ), + })), + })), + options: product.configOptions.map((option) => ({ + id: option.id, + name: option.name, + values: option.values.map((value) => ({ + id: value.id, + label: value.label, + value: value.value, + monthly_price_base: value.price.toString(), + })), + })), + })), + }, + { headers: { "Cache-Control": "public, max-age=60, stale-while-revalidate=300" } }, + ); +} diff --git a/src/app/api/agent/checkout/[id]/route.ts b/src/app/api/agent/checkout/[id]/route.ts new file mode 100644 index 0000000..d5b9241 --- /dev/null +++ b/src/app/api/agent/checkout/[id]/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import { authenticateAgentGrant } from "@/lib/agent-auth"; +import { db } from "@/lib/db"; +import { agentCheckoutRepresentation } from "@/lib/services/agent-checkout"; + +export async function GET( + request: Request, + context: { params: Promise<{ id: string }> }, +) { + const grant = await authenticateAgentGrant(request); + if (!grant) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const { id } = await context.params; + const checkout = await db.agentCheckout.findFirst({ + where: { id, grantId: grant.id }, + select: { id: true }, + }); + if (!checkout) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + return NextResponse.json({ data: await agentCheckoutRepresentation(id) }); +} diff --git a/src/app/api/agent/checkout/route.ts b/src/app/api/agent/checkout/route.ts new file mode 100644 index 0000000..96b7da7 --- /dev/null +++ b/src/app/api/agent/checkout/route.ts @@ -0,0 +1,95 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { authenticateAgentGrant } from "@/lib/agent-auth"; +import { + AgentCheckoutError, + agentCheckoutRepresentation, + createNativeAgentCheckout, +} from "@/lib/services/agent-checkout"; + +const cycle = z.enum([ + "ONE_TIME", + "MONTHLY", + "QUARTERLY", + "SEMI_ANNUALLY", + "ANNUALLY", + "BIENNIALLY", +]); + +const requestSchema = z.object({ + currency: z.string().regex(/^[A-Za-z0-9]{3,12}$/), + coupon_code: z.string().max(100).optional(), + lines: z + .array( + z.object({ + product_id: z.string().min(1), + cycle, + quantity: z.number().int().min(1).max(100).default(1), + option_value_ids: z.array(z.string()).max(50).default([]), + resale_data: z.record(z.string(), z.string().max(10_000)).optional(), + }), + ) + .min(1) + .max(50), +}); + +function errorResponse(error: unknown) { + if (error instanceof AgentCheckoutError) { + return NextResponse.json( + { error: { code: error.code, message: error.message } }, + { status: error.status }, + ); + } + return NextResponse.json( + { error: { code: "checkout_failed", message: "Checkout could not be completed." } }, + { status: 500 }, + ); +} + +export async function POST(request: Request) { + const grant = await authenticateAgentGrant(request); + if (!grant) { + return NextResponse.json( + { error: { code: "unauthorized", message: "Invalid purchasing grant." } }, + { status: 401 }, + ); + } + const idempotencyKey = request.headers.get("idempotency-key")?.trim(); + if (!idempotencyKey || idempotencyKey.length > 200) { + return NextResponse.json( + { error: { code: "idempotency_key_required", message: "A valid Idempotency-Key header is required." } }, + { status: 400 }, + ); + } + const parsed = requestSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json( + { error: { code: "invalid_request", message: "Invalid checkout request.", issues: parsed.error.issues } }, + { status: 422 }, + ); + } + try { + const checkout = await createNativeAgentCheckout({ + grant, + idempotencyKey, + ip: request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null, + request: { + currency: parsed.data.currency, + couponCode: parsed.data.coupon_code ?? null, + lines: parsed.data.lines.map((line) => ({ + productId: line.product_id, + cycle: line.cycle, + quantity: line.quantity, + optionValues: line.option_value_ids, + resaleData: line.resale_data, + })), + }, + }); + return NextResponse.json( + { data: await agentCheckoutRepresentation(checkout.id) }, + { status: checkout.createdAt.getTime() === checkout.updatedAt.getTime() ? 201 : 200 }, + ); + } catch (error) { + return errorResponse(error); + } +} diff --git a/src/app/api/x402/invoices/[id]/route.ts b/src/app/api/x402/invoices/[id]/route.ts new file mode 100644 index 0000000..f6f18e2 --- /dev/null +++ b/src/app/api/x402/invoices/[id]/route.ts @@ -0,0 +1,279 @@ +import { NextResponse } from "next/server"; +import type { Prisma } from "@/generated/prisma/client"; +import { authenticateAgentGrant } from "@/lib/agent-auth"; +import { getUser, sha256 } from "@/lib/auth"; +import { audit } from "@/lib/audit"; +import { db } from "@/lib/db"; +import { extensionConfig } from "@/lib/extensions/types"; +import { getChargeCurrency } from "@/lib/services/currency"; +import { markInvoicePaid } from "@/lib/billing"; +import { publicUrl } from "@/lib/settings"; +import { canonicalJson } from "@/lib/agent-commerce-policy"; +import { + decodeX402Header, + encodeX402Header, + paymentPayloadMatches, + verifyAndSettleX402, + x402PaymentRequired, + type X402Config, +} from "@/lib/x402"; + +const NO_STORE = { "Cache-Control": "no-store" }; + +function json402(required: ReturnType) { + return NextResponse.json(required, { + status: 402, + headers: { ...NO_STORE, "PAYMENT-REQUIRED": encodeX402Header(required) }, + }); +} + +function prismaCode(error: unknown): string | null { + return typeof error === "object" && error !== null && "code" in error + ? String(error.code) + : null; +} + +async function finalizeX402Invoice(input: { + invoiceId: string; + userId: string; + response: Awaited>; + auditPayment: boolean; +}) { + await markInvoicePaid(input.invoiceId, "x402", input.response.transaction); + const checkouts = await db.agentCheckout.updateMany({ + where: { invoiceId: input.invoiceId, status: { not: "PAID" } }, + data: { status: "PAID" }, + }); + if (input.auditPayment || checkouts.count > 0) { + await audit("payment.x402_settled", { + userId: input.userId, + targetType: "invoice", + targetId: input.invoiceId, + metadata: { + network: input.response.network, + transaction: input.response.transaction, + }, + }); + } +} + +export async function POST( + request: Request, + context: { params: Promise<{ id: string }> }, +) { + const { id } = await context.params; + const grant = await authenticateAgentGrant(request); + const sessionUser = grant ? null : await getUser(); + const [invoice, extension, baseUrl] = await Promise.all([ + db.invoice.findUnique({ where: { id } }), + db.extension.findUnique({ where: { slug: "x402" } }), + publicUrl(), + ]); + const userId = grant?.userId ?? sessionUser?.id; + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401, headers: NO_STORE }); + } + if (!invoice || invoice.userId !== userId) { + return NextResponse.json({ error: "Not found" }, { status: 404, headers: NO_STORE }); + } + if (!extension?.enabled || extension.type !== "GATEWAY") { + return NextResponse.json( + { error: "x402 payments are not enabled" }, + { status: 503, headers: NO_STORE }, + ); + } + + const currency = await getChargeCurrency(invoice.currency); + const config = extensionConfig(extension) as X402Config; + if ( + invoice.currency !== "USDC" || + currency?.kind !== "STABLECOIN" || + currency.decimals !== 6 || + !currency.settlementNetworks.includes(config.network) + ) { + return NextResponse.json( + { error: "This invoice is not payable on the configured USDC network" }, + { status: 409, headers: NO_STORE }, + ); + } + + const resourceUrl = `${baseUrl}/api/x402/invoices/${invoice.id}`; + let required; + try { + required = x402PaymentRequired({ invoice, config, resourceUrl }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Invalid x402 configuration" }, + { status: 503, headers: NO_STORE }, + ); + } + const requirements = required.accepts[0]; + + const completed = await db.x402Settlement.findUnique({ + where: { invoiceId: invoice.id }, + }); + if (invoice.status === "PAID" && completed?.status === "SETTLED") { + const response = completed.response as Record; + await db.agentCheckout.updateMany({ + where: { invoiceId: invoice.id, status: { not: "PAID" } }, + data: { status: "PAID" }, + }); + return NextResponse.json( + { data: { invoice_id: invoice.id, status: "paid" } }, + { headers: { ...NO_STORE, "PAYMENT-RESPONSE": encodeX402Header(response) } }, + ); + } + if (invoice.status !== "PENDING") { + return NextResponse.json( + { error: "Invoice is not payable" }, + { status: 409, headers: NO_STORE }, + ); + } + + const signatureHeader = request.headers.get("payment-signature"); + if (!signatureHeader) return json402(required); + + let paymentPayload: Record; + try { + paymentPayload = decodeX402Header(signatureHeader); + } catch (error) { + return json402( + x402PaymentRequired({ + invoice, + config, + resourceUrl, + error: error instanceof Error ? error.message : "Invalid payment payload", + }), + ); + } + if (!paymentPayloadMatches(paymentPayload, resourceUrl, requirements)) { + return json402( + x402PaymentRequired({ + invoice, + config, + resourceUrl, + error: "Payment payload does not match this invoice's exact terms", + }), + ); + } + + const signatureHash = sha256(signatureHeader); + const requirementsHash = sha256(canonicalJson(requirements)); + let settlement = completed; + if (settlement) { + if ( + settlement.paymentSignatureHash !== signatureHash || + settlement.requirementsHash !== requirementsHash + ) { + return NextResponse.json( + { error: "A different payment is already associated with this invoice" }, + { status: 409, headers: NO_STORE }, + ); + } + if (settlement.status === "SETTLED") { + const response = settlement.response as Awaited< + ReturnType + >; + await finalizeX402Invoice({ invoiceId: invoice.id, userId, response, auditPayment: true }); + return NextResponse.json( + { data: { invoice_id: invoice.id, status: "paid" } }, + { headers: { ...NO_STORE, "PAYMENT-RESPONSE": encodeX402Header(response) } }, + ); + } + const stale = settlement.updatedAt.getTime() < Date.now() - 120_000; + if (settlement.status === "VERIFYING" && !stale) { + return NextResponse.json( + { error: "Payment verification is already in progress" }, + { status: 409, headers: { ...NO_STORE, "Retry-After": "2" } }, + ); + } + const claimed = await db.x402Settlement.updateMany({ + where: { id: settlement.id, status: settlement.status, updatedAt: settlement.updatedAt }, + data: { status: "VERIFYING", error: null }, + }); + if (claimed.count === 0) { + return NextResponse.json( + { error: "Payment verification is already in progress" }, + { status: 409, headers: { ...NO_STORE, "Retry-After": "2" } }, + ); + } + } else { + try { + settlement = await db.x402Settlement.create({ + data: { + invoiceId: invoice.id, + paymentSignatureHash: signatureHash, + requirementsHash, + network: requirements.network, + }, + }); + } catch (error) { + if (prismaCode(error) === "P2002") { + return NextResponse.json( + { error: "Payment verification is already in progress" }, + { status: 409, headers: { ...NO_STORE, "Retry-After": "2" } }, + ); + } + throw error; + } + } + + let response: Awaited>; + try { + response = await verifyAndSettleX402({ + config, + paymentPayload, + paymentRequirements: requirements, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Payment settlement failed"; + await db.x402Settlement.update({ + where: { id: settlement.id }, + data: { status: "FAILED", error: message.slice(0, 1_000) }, + }); + return json402( + x402PaymentRequired({ invoice, config, resourceUrl, error: message }), + ); + } + + try { + await db.x402Settlement.update({ + where: { id: settlement.id }, + data: { + status: "SETTLED", + transaction: response.transaction, + payer: response.payer, + response: response as unknown as Prisma.InputJsonValue, + }, + }); + } catch { + // Settlement has already happened externally. Do not downgrade it to a + // failed payment or issue a fresh 402 just because local persistence needs + // to be retried with the same signed payload. + return NextResponse.json( + { error: "Payment settled; settlement recording is pending", retryable: true }, + { + status: 503, + headers: { ...NO_STORE, "PAYMENT-RESPONSE": encodeX402Header(response) }, + }, + ); + } + + try { + await finalizeX402Invoice({ invoiceId: invoice.id, userId, response, auditPayment: true }); + } catch { + // The on-chain settlement is final. Preserve it as SETTLED so a retry can + // finish local invoice side effects without requesting a second payment. + return NextResponse.json( + { error: "Payment settled; invoice finalization is pending", retryable: true }, + { + status: 503, + headers: { ...NO_STORE, "PAYMENT-RESPONSE": encodeX402Header(response) }, + }, + ); + } + return NextResponse.json( + { data: { invoice_id: invoice.id, status: "paid" } }, + { headers: { ...NO_STORE, "PAYMENT-RESPONSE": encodeX402Header(response) } }, + ); +} diff --git a/src/app/dashboard/account/agent-access/page.tsx b/src/app/dashboard/account/agent-access/page.tsx new file mode 100644 index 0000000..da0d2ec --- /dev/null +++ b/src/app/dashboard/account/agent-access/page.tsx @@ -0,0 +1,149 @@ +import { requireUser } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { getSetting } from "@/lib/settings"; +import { getEnabledCurrencies } from "@/lib/services/currency"; +import { formatDateTime, formatMoney } from "@/lib/format"; +import { + createAgentGrant, + revokeAgentGrant, +} from "@/lib/actions/agent-grants"; +import { ActionForm, SubmitButton } from "@/components/forms"; + +export const metadata = { title: "Agent access" }; + +function list(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : []; +} + +export default async function AgentAccessPage() { + const user = await requireUser(); + const [baseCurrency, currencies, products, grants] = await Promise.all([ + getSetting("currency"), + getEnabledCurrencies(), + db.product.findMany({ + where: { hidden: false }, + orderBy: [{ category: { sortOrder: "asc" } }, { sortOrder: "asc" }], + include: { category: true }, + }), + db.agentGrant.findMany({ + where: { userId: user.id }, + orderBy: { createdAt: "desc" }, + }), + ]); + + return ( +
+

Agent purchasing access

+

+ Delegate checkout to an agent without sharing your password. Every token + has an expiry, an exact product and currency allowlist, and base-currency + spend caps. Tokens are shown only once. +

+ +
+
+ + + + + + + + + + + + + {grants.map((grant) => ( + + + + + + + + + ))} + {grants.length === 0 && ( + + + + )} + +
NameTokenLimits ({baseCurrency})ScopeExpires
{grant.name}{grant.prefix}… + {formatMoney(grant.maxPerOrderBase, baseCurrency)} / order +
+ {formatMoney(grant.committedBase, baseCurrency)} of{" "} + {formatMoney(grant.spendLimitBase, baseCurrency)} committed +
+ {list(grant.allowedProductIds).length} product(s) +
+ {list(grant.allowedCurrencies).join(", ")} +
+ {grant.revokedAt ? "Revoked" : formatDateTime(grant.expiresAt)} + + {!grant.revokedAt && ( + + + + + )} +
+ No purchasing grants yet. +
+
+ +
+

New purchasing grant

+ +
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ Allowed currencies +
+ {currencies.map((currency) => ( + + ))} +
+
+
+ Allowed products +
+ {products.map((product) => ( + + ))} +
+
+ Create token +
+
+
+
+ ); +} diff --git a/src/app/dashboard/account/page.tsx b/src/app/dashboard/account/page.tsx index 3d0913d..1739b5b 100644 --- a/src/app/dashboard/account/page.tsx +++ b/src/app/dashboard/account/page.tsx @@ -49,6 +49,9 @@ export default async function AccountPage() { Billing methods + + Agent access +
diff --git a/src/app/dashboard/invoices/[id]/page.tsx b/src/app/dashboard/invoices/[id]/page.tsx index 592c161..5d55c4c 100644 --- a/src/app/dashboard/invoices/[id]/page.tsx +++ b/src/app/dashboard/invoices/[id]/page.tsx @@ -16,15 +16,15 @@ export default async function InvoiceDetailPage({ const { id } = await params; const user = await requireUser(); const t = await getT(); - const [settings, invoice, gateways] = await Promise.all([ + const [settings, invoice] = await Promise.all([ getSettings(["company_name"]), db.invoice.findUnique({ where: { id }, include: { items: true, payments: true }, }), - enabledGateways(), ]); if (!invoice || (invoice.userId !== user.id && !user.roleId)) notFound(); + const supportedGateways = await enabledGateways(invoice.currency); return (
@@ -116,7 +116,7 @@ export default async function InvoiceDetailPage({ total={Number(invoice.total)} currency={invoice.currency} credits={Number(user.credits)} - gateways={gateways.map((g) => ({ slug: g.slug, name: g.name }))} + gateways={supportedGateways.map((g) => ({ slug: g.slug, name: g.name }))} />
) : ( diff --git a/src/app/dashboard/invoices/[id]/pay-box.tsx b/src/app/dashboard/invoices/[id]/pay-box.tsx index 568d098..6e07351 100644 --- a/src/app/dashboard/invoices/[id]/pay-box.tsx +++ b/src/app/dashboard/invoices/[id]/pay-box.tsx @@ -3,6 +3,7 @@ import { useActionState } from "react"; import { payInvoice } from "@/lib/actions/client"; import { SubmitButton } from "@/components/forms"; +import { formatMoney } from "@/lib/format"; export function PayBox({ invoiceId, @@ -18,8 +19,7 @@ export function PayBox({ currency: string; }) { const [state, formAction] = useActionState(payInvoice, null); - const fmt = (n: number) => - new Intl.NumberFormat("en-US", { style: "currency", currency }).format(n); + const fmt = (n: number) => formatMoney(n, currency); const methods = [ ...gateways.map((g) => ({ value: g.slug, label: g.name })), diff --git a/src/lib/acp-http.ts b/src/lib/acp-http.ts new file mode 100644 index 0000000..3e85f2e --- /dev/null +++ b/src/lib/acp-http.ts @@ -0,0 +1,71 @@ +import { NextResponse } from "next/server"; +import { ACP_VERSION } from "@/lib/agent-commerce-policy"; +import { AgentCheckoutError } from "@/lib/services/agent-checkout"; + +export function validateAcpRequest(request: Request, requireIdempotency = true) { + const version = request.headers.get("api-version"); + if (version !== ACP_VERSION) { + return { + response: NextResponse.json( + { + type: "invalid_request_error", + code: version ? "unsupported_api_version" : "missing_api_version", + message: `API-Version: ${ACP_VERSION} is required.`, + supported_versions: [ACP_VERSION], + }, + { status: 400 }, + ), + idempotencyKey: null, + }; + } + const idempotencyKey = request.headers.get("idempotency-key")?.trim() ?? null; + if (requireIdempotency && (!idempotencyKey || idempotencyKey.length > 200)) { + return { + response: NextResponse.json( + { + type: "invalid_request_error", + code: "idempotency_key_required", + message: "A valid Idempotency-Key header is required.", + }, + { status: 400 }, + ), + idempotencyKey: null, + }; + } + return { response: null, idempotencyKey }; +} + +export function acpResponse( + body: unknown, + input: { status?: number; idempotencyKey?: string | null; replayed?: boolean } = {}, +) { + return NextResponse.json(body, { + status: input.status ?? 200, + headers: { + ...(input.idempotencyKey ? { "Idempotency-Key": input.idempotencyKey } : {}), + ...(input.replayed ? { "Idempotent-Replayed": "true" } : {}), + "Cache-Control": "no-store", + }, + }); +} + +export function acpError(error: unknown) { + if (error instanceof AgentCheckoutError) { + return NextResponse.json( + { + type: "invalid_request_error", + code: error.code, + message: error.message, + }, + { status: error.status, headers: { "Cache-Control": "no-store" } }, + ); + } + return NextResponse.json( + { + type: "api_error", + code: "checkout_failed", + message: "Checkout could not be completed.", + }, + { status: 500, headers: { "Cache-Control": "no-store" } }, + ); +} diff --git a/src/lib/actions/admin.ts b/src/lib/actions/admin.ts index ba379f7..d355a4a 100644 --- a/src/lib/actions/admin.ts +++ b/src/lib/actions/admin.ts @@ -418,18 +418,45 @@ export async function saveCurrency( ): Promise { await requireAdmin("settings"); const code = str(formData, "code").toUpperCase(); - if (!/^[A-Z]{3}$/.test(code)) return { error: "Use a 3-letter ISO code." }; + if (!/^[A-Z0-9]{3,12}$/.test(code)) { + return { error: "Use a 3–12 character currency or asset code." }; + } const rate = Number(formData.get("rate") ?? 0); - if (rate <= 0) return { error: "Rate must be greater than zero." }; + if (!Number.isFinite(rate) || rate <= 0) { + return { error: "Rate must be greater than zero." }; + } + const kind = + str(formData, "kind") === "STABLECOIN" + ? ("STABLECOIN" as const) + : ("FIAT" as const); + const decimals = Number(formData.get("decimals") ?? (kind === "STABLECOIN" ? 6 : 2)); + if (!Number.isInteger(decimals) || decimals < 0 || decimals > 8) { + return { error: "Decimals must be a whole number between 0 and 8." }; + } + const settlementNetworks = str(formData, "settlementNetworks") + .split(",") + .map((network) => network.trim()) + .filter(Boolean); + if ( + settlementNetworks.some( + (network) => + network.length > 100 || !/^[a-zA-Z0-9][a-zA-Z0-9:_-]*$/.test(network), + ) + ) { + return { error: "Use comma-separated CAIP-2 network identifiers." }; + } + const data = { + rate, + symbol: str(formData, "symbol") || null, + kind, + decimals, + settlementNetworks, + enabled: formData.get("enabled") === "on", + }; await db.currency.upsert({ where: { code }, - update: { rate, enabled: formData.get("enabled") === "on" }, - create: { - code, - rate, - symbol: str(formData, "symbol") || null, - enabled: formData.get("enabled") === "on", - }, + update: data, + create: { code, ...data }, }); revalidatePath("/admin/currencies"); return { success: "Currency saved." }; diff --git a/src/lib/actions/agent-grants.ts b/src/lib/actions/agent-grants.ts new file mode 100644 index 0000000..f6c8bc7 --- /dev/null +++ b/src/lib/actions/agent-grants.ts @@ -0,0 +1,97 @@ +"use server"; + +import { randomBytes } from "node:crypto"; +import { revalidatePath } from "next/cache"; +import { requireUser, sha256 } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { audit } from "@/lib/audit"; +import { getEnabledCurrencies } from "@/lib/services/currency"; +import type { FormState } from "@/lib/actions/auth"; + +const GRANTS_PATH = "/dashboard/account/agent-access"; + +export async function createAgentGrant( + _previous: FormState, + formData: FormData, +): Promise { + const user = await requireUser(); + const name = String(formData.get("name") ?? "").trim(); + const productIds = [...new Set(formData.getAll("productId").map(String))]; + const allowedCurrencies = [ + ...new Set(formData.getAll("currency").map((code) => String(code).toUpperCase())), + ]; + const maxPerOrderBase = Number(formData.get("maxPerOrderBase") ?? 0); + const spendLimitBase = Number(formData.get("spendLimitBase") ?? 0); + const expiresInDays = Number(formData.get("expiresInDays") ?? 30); + + if (!name) return { error: "Give the purchasing grant a name." }; + if (productIds.length === 0) return { error: "Select at least one product." }; + if (allowedCurrencies.length === 0) { + return { error: "Select at least one currency." }; + } + if ( + !Number.isFinite(maxPerOrderBase) || + maxPerOrderBase <= 0 || + !Number.isFinite(spendLimitBase) || + spendLimitBase < maxPerOrderBase + ) { + return { error: "The total limit must be at least the per-order limit." }; + } + if (!Number.isInteger(expiresInDays) || expiresInDays < 1 || expiresInDays > 90) { + return { error: "Expiry must be between 1 and 90 days." }; + } + + const [productCount, currencies] = await Promise.all([ + db.product.count({ where: { id: { in: productIds }, hidden: false } }), + getEnabledCurrencies(), + ]); + if (productCount !== productIds.length) { + return { error: "One or more selected products are unavailable." }; + } + const currencySet = new Set(currencies.map((currency) => currency.code)); + if (allowedCurrencies.some((code) => !currencySet.has(code))) { + return { error: "One or more selected currencies are unavailable." }; + } + + const raw = `ag_${randomBytes(32).toString("hex")}`; + const grant = await db.agentGrant.create({ + data: { + name, + tokenHash: sha256(raw), + prefix: raw.slice(0, 13), + userId: user.id, + allowedProductIds: productIds, + allowedCurrencies, + maxPerOrderBase, + spendLimitBase, + expiresAt: new Date(Date.now() + expiresInDays * 86_400_000), + }, + }); + await audit("agent.grant_created", { + userId: user.id, + targetType: "agent_grant", + targetId: grant.id, + metadata: { productIds, allowedCurrencies }, + }); + revalidatePath(GRANTS_PATH); + return { + success: `Grant created — copy this token now; it will not be shown again: ${raw}`, + }; +} + +export async function revokeAgentGrant(formData: FormData): Promise { + const user = await requireUser(); + const id = String(formData.get("id") ?? ""); + const result = await db.agentGrant.updateMany({ + where: { id, userId: user.id, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + if (result.count) { + await audit("agent.grant_revoked", { + userId: user.id, + targetType: "agent_grant", + targetId: id, + }); + } + revalidatePath(GRANTS_PATH); +} diff --git a/src/lib/actions/currency.ts b/src/lib/actions/currency.ts index 3420ca9..39b643b 100644 --- a/src/lib/actions/currency.ts +++ b/src/lib/actions/currency.ts @@ -5,6 +5,6 @@ import { setActiveCurrencyCookie } from "@/lib/services/currency"; export async function switchCurrency(formData: FormData): Promise { const code = String(formData.get("currency") ?? ""); - if (/^[A-Z]{3}$/.test(code)) await setActiveCurrencyCookie(code); + if (/^[A-Z0-9]{3,12}$/.test(code)) await setActiveCurrencyCookie(code); revalidatePath("/", "layout"); } diff --git a/src/lib/agent-auth.ts b/src/lib/agent-auth.ts new file mode 100644 index 0000000..266e7fa --- /dev/null +++ b/src/lib/agent-auth.ts @@ -0,0 +1,30 @@ +import "server-only"; +import type { Prisma } from "@/generated/prisma/client"; +import { db } from "@/lib/db"; +import { sha256 } from "@/lib/auth"; + +export type AgentPrincipal = Prisma.AgentGrantGetPayload<{ + include: { user: true }; +}>; + +export async function authenticateAgentGrant( + request: Request, + now = new Date(), +): Promise { + const authorization = request.headers.get("authorization") ?? ""; + const match = authorization.match(/^Bearer (ag_[A-Za-z0-9]+)$/); + if (!match) return null; + + const grant = await db.agentGrant.findUnique({ + where: { tokenHash: sha256(match[1]) }, + include: { user: true }, + }); + if (!grant || grant.revokedAt || (grant.expiresAt && grant.expiresAt <= now)) { + return null; + } + await db.agentGrant.update({ + where: { id: grant.id }, + data: { lastUsedAt: now }, + }); + return grant; +} diff --git a/src/lib/agent-commerce-policy.test.ts b/src/lib/agent-commerce-policy.test.ts new file mode 100644 index 0000000..f1c7d8c --- /dev/null +++ b/src/lib/agent-commerce-policy.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "vitest"; +import { + canonicalJson, + toAtomicUnits, + validateDelegatedPurchase, +} from "@/lib/agent-commerce-policy"; + +const activeGrant = { + allowedProductIds: ["product-a"], + allowedCurrencies: ["USD", "USDC"], + maxPerOrderBase: 50, + spendLimitBase: 100, + committedBase: 25, + expiresAt: new Date("2027-01-01T00:00:00Z"), + revokedAt: null, +}; + +describe("delegated purchase policy", () => { + test("accepts purchases inside every delegated boundary", () => { + expect( + validateDelegatedPurchase( + activeGrant, + { productIds: ["product-a"], currency: "USDC", baseAmount: 50 }, + new Date("2026-08-27T00:00:00Z"), + ), + ).toBeNull(); + }); + + test("rejects products, currencies, per-order spend, and aggregate spend", () => { + const now = new Date("2026-08-27T00:00:00Z"); + expect( + validateDelegatedPurchase( + activeGrant, + { productIds: ["product-b"], currency: "USD", baseAmount: 1 }, + now, + ), + ).toMatch(/products/); + expect( + validateDelegatedPurchase( + activeGrant, + { productIds: ["product-a"], currency: "EUR", baseAmount: 1 }, + now, + ), + ).toMatch(/currency/); + expect( + validateDelegatedPurchase( + activeGrant, + { productIds: ["product-a"], currency: "USD", baseAmount: 51 }, + now, + ), + ).toMatch(/per-order/); + expect( + validateDelegatedPurchase( + { ...activeGrant, committedBase: 60 }, + { productIds: ["product-a"], currency: "USD", baseAmount: 50 }, + now, + ), + ).toMatch(/total spend/); + }); +}); + +describe("protocol primitives", () => { + test("canonicalizes object keys for stable idempotency hashes", () => { + expect(canonicalJson({ z: 1, a: { y: 2, x: 3 } })).toBe( + '{"a":{"x":3,"y":2},"z":1}', + ); + }); + + test("converts USDC amounts to six-decimal atomic units", () => { + expect(toAtomicUnits("1.010000", 6)).toBe("1010000"); + expect(toAtomicUnits("0.000001", 6)).toBe("1"); + expect(() => toAtomicUnits("0.0000011", 6)).toThrow(/precision/); + }); +}); diff --git a/src/lib/agent-commerce-policy.ts b/src/lib/agent-commerce-policy.ts new file mode 100644 index 0000000..3c487ed --- /dev/null +++ b/src/lib/agent-commerce-policy.ts @@ -0,0 +1,82 @@ +export const ACP_VERSION = "2026-04-17"; + +export type AgentGrantLimits = { + allowedProductIds: unknown; + allowedCurrencies: unknown; + maxPerOrderBase: number; + spendLimitBase: number; + committedBase: number; + expiresAt: Date | null; + revokedAt: Date | null; +}; + +export function stringList(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === "string"); +} + +export function canonicalJson(value: unknown): string { + if (value === null || typeof value !== "object") { + return JSON.stringify(value) ?? "null"; + } + if (Array.isArray(value)) { + return `[${value.map((item) => canonicalJson(item)).join(",")}]`; + } + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`) + .join(",")}}`; +} + +export function validateDelegatedPurchase( + grant: AgentGrantLimits, + input: { productIds: string[]; currency: string; baseAmount: number }, + now: Date, +): string | null { + if (grant.revokedAt) return "The purchasing grant has been revoked."; + if (grant.expiresAt && grant.expiresAt <= now) { + return "The purchasing grant has expired."; + } + + const products = new Set(stringList(grant.allowedProductIds)); + if (input.productIds.some((productId) => !products.has(productId))) { + return "The purchasing grant does not allow one or more products."; + } + + const currencies = new Set( + stringList(grant.allowedCurrencies).map((code) => code.toUpperCase()), + ); + if (!currencies.has(input.currency.toUpperCase())) { + return "The purchasing grant does not allow this currency."; + } + + if (input.baseAmount <= 0 || !Number.isFinite(input.baseAmount)) { + return "The order amount must be greater than zero."; + } + if (input.baseAmount > grant.maxPerOrderBase) { + return "The order exceeds the grant's per-order limit."; + } + if (grant.committedBase + input.baseAmount > grant.spendLimitBase) { + return "The order exceeds the grant's total spend limit."; + } + return null; +} + +export function toAtomicUnits( + amount: number | string | { toString(): string }, + decimals: number, +): string { + const precision = Math.min(8, Math.max(0, Math.trunc(decimals))); + const raw = amount.toString(); + if (!/^\d+(?:\.\d+)?$/.test(raw)) { + throw new Error("Amount must be a non-negative decimal value"); + } + const [whole, fraction = ""] = raw.split("."); + const discarded = fraction.slice(precision); + if (discarded && /[1-9]/.test(discarded)) { + throw new Error(`Amount exceeds ${precision}-decimal asset precision`); + } + const padded = fraction.slice(0, precision).padEnd(precision, "0"); + return (BigInt(whole) * 10n ** BigInt(precision) + BigInt(padded || "0")).toString(); +} diff --git a/src/lib/billing.ts b/src/lib/billing.ts index 7a5b531..62b5eca 100644 --- a/src/lib/billing.ts +++ b/src/lib/billing.ts @@ -3,6 +3,7 @@ import { db } from "@/lib/db"; import { lifecycleCutoff, renewalInvoiceHorizon, + roundAsset, } from "@/lib/billing-policy"; import { addCycle, formatMoney, formatDate } from "@/lib/format"; import { getSetting, getSettings, publicUrlForEmail } from "@/lib/settings"; @@ -232,13 +233,20 @@ export async function generateRenewalInvoices(now = new Date()): Promise const usage = await consumeUnbilledUsage(service.id); let total = base; if (usage && usage.amount > 0) { + const { getChargeCurrency, convertFromBase } = await import( + "@/lib/services/currency" + ); + const chargeCurrency = await getChargeCurrency(currency); + const usageAmount = chargeCurrency + ? convertFromBase(usage.amount, chargeCurrency) + : usage.amount; items.push({ description: `${service.product.name} — usage: ${usage.quantity}${usage.unit ? " " + usage.unit : ""}`, quantity: 1, - unitPrice: usage.amount, + unitPrice: usageAmount, serviceId: service.id, }); - total += usage.amount; + total = roundAsset(total + usageAmount, chargeCurrency?.decimals ?? 2); } const invoice = await db.invoice.create({ data: { diff --git a/src/lib/extensions/gateways/stripe.ts b/src/lib/extensions/gateways/stripe.ts index 9d0f96d..54a10c5 100644 --- a/src/lib/extensions/gateways/stripe.ts +++ b/src/lib/extensions/gateways/stripe.ts @@ -6,6 +6,7 @@ async function stripeRequest( path: string, params?: Record, method: "POST" | "GET" = "POST", + idempotencyKey?: string, ) { const query = method === "GET" && params ? `?${new URLSearchParams(params)}` : ""; @@ -13,6 +14,7 @@ async function stripeRequest( method, headers: { Authorization: `Bearer ${secretKey}`, + ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}), ...(method === "POST" ? { "Content-Type": "application/x-www-form-urlencoded" } : {}), @@ -129,4 +131,28 @@ export const stripeGateway: GatewayDriver = { if (intent.status !== "succeeded") return null; return { transactionId: intent.id as string }; }, + + async chargeDelegated(invoice, token, config, idempotencyKey) { + if (!/^[A-Z]{3}$/.test(invoice.currency)) { + throw new Error("Stripe delegated payments require an ISO 4217 currency"); + } + const intent = await stripeRequest( + config.secret_key, + "/payment_intents", + { + amount: String(Math.round(Number(invoice.total) * 100)), + currency: invoice.currency.toLowerCase(), + shared_payment_granted_token: token, + confirm: "true", + description: `Invoice #${invoice.number}`, + "metadata[invoice_id]": invoice.id, + }, + "POST", + idempotencyKey, + ); + return { + transactionId: String(intent.id), + status: String(intent.status), + }; + }, }; diff --git a/src/lib/extensions/gateways/x402.ts b/src/lib/extensions/gateways/x402.ts new file mode 100644 index 0000000..8f30279 --- /dev/null +++ b/src/lib/extensions/gateways/x402.ts @@ -0,0 +1,61 @@ +import type { GatewayDriver } from "@/lib/extensions/types"; + +export const x402Gateway: GatewayDriver = { + slug: "x402", + name: "x402 (USDC)", + supportedCurrencies: ["USDC"], + configFields: [ + { + key: "facilitator_url", + label: "Facilitator URL", + type: "text", + required: true, + help: "Base URL of an x402 v2 facilitator. Use testnet before mainnet.", + }, + { + key: "authorization_token", + label: "Facilitator bearer token", + type: "password", + help: "Optional static bearer credential. Never use a wallet private key here.", + }, + { + key: "network", + label: "CAIP-2 network", + type: "text", + required: true, + help: "For example eip155:8453 for Base mainnet.", + }, + { + key: "asset", + label: "USDC asset identifier", + type: "text", + required: true, + help: "The official USDC contract or mint for the selected network.", + }, + { + key: "pay_to", + label: "Receiving wallet address", + type: "text", + required: true, + }, + { + key: "max_timeout_seconds", + label: "Payment timeout (seconds)", + type: "text", + help: "Defaults to 60; maximum 3600.", + }, + ], + async pay(invoice, _config, urls) { + if (invoice.currency !== "USDC") { + throw new Error("x402 is only available for USDC invoices"); + } + const endpoint = urls.webhook.replace( + /\/api\/webhooks\/x402$/, + `/api/x402/invoices/${invoice.id}`, + ); + return { + type: "instructions", + html: `

Use an x402 v2 wallet client to POST payment to ${endpoint}. The endpoint returns the exact signed-payment requirements.

`, + }; + }, +}; diff --git a/src/lib/extensions/registry.ts b/src/lib/extensions/registry.ts index 8f5a8c7..5352deb 100644 --- a/src/lib/extensions/registry.ts +++ b/src/lib/extensions/registry.ts @@ -6,6 +6,7 @@ import type { ServerDriver, ResaleDriver, } from "@/lib/extensions/types"; +import { gatewayAcceptsCurrency } from "@/lib/extensions/types"; import { stripeGateway } from "@/lib/extensions/gateways/stripe"; import { paypalGateway } from "@/lib/extensions/gateways/paypal"; import { mollieGateway } from "@/lib/extensions/gateways/mollie"; @@ -25,6 +26,7 @@ import { paystackGateway } from "@/lib/extensions/gateways/paystack"; import { flutterwaveGateway } from "@/lib/extensions/gateways/flutterwave"; import { midtransGateway } from "@/lib/extensions/gateways/midtrans"; import { xenditGateway } from "@/lib/extensions/gateways/xendit"; +import { x402Gateway } from "@/lib/extensions/gateways/x402"; import { pterodactylServer } from "@/lib/extensions/servers/pterodactyl"; import { convoyServer } from "@/lib/extensions/servers/convoy"; import { virtFusionServer } from "@/lib/extensions/servers/virtfusion"; @@ -83,6 +85,7 @@ export const GATEWAY_DRIVERS: GatewayDriver[] = [ flutterwaveGateway, midtransGateway, xenditGateway, + x402Gateway, ]; export const SERVER_DRIVERS: ServerDriver[] = [ @@ -177,9 +180,15 @@ export async function syncExtensions() { } } -export async function enabledGateways() { - return db.extension.findMany({ +export async function enabledGateways(currency?: string) { + const enabled = await db.extension.findMany({ where: { type: "GATEWAY", enabled: true }, orderBy: { name: "asc" }, }); + return currency + ? enabled.filter((extension) => { + const driver = getGatewayDriver(extension.slug); + return driver ? gatewayAcceptsCurrency(driver, currency) : false; + }) + : enabled; } diff --git a/src/lib/extensions/types.ts b/src/lib/extensions/types.ts index 14b8b8f..65362b6 100644 --- a/src/lib/extensions/types.ts +++ b/src/lib/extensions/types.ts @@ -24,12 +24,14 @@ export type StoredMethodDetails = { // A payment gateway driver. `pay` starts a payment for an invoice and either // redirects the customer to the gateway or shows manual instructions. -// The three optional `setup`/`chargeStored` members enable stored payment -// methods and off-session auto-charging (interface segregation: simple -// gateways skip them). +// Optional capabilities enable stored methods, off-session auto-charging, and +// delegated one-time credentials while simple gateways keep only `pay`. export interface GatewayDriver { slug: string; name: string; + // Omitted drivers accept conventional three-letter fiat codes. Digital + // asset rails declare their exact asset codes explicitly. + supportedCurrencies?: string[]; configFields: ConfigField[]; pay( invoice: Invoice & { user: User }, @@ -59,6 +61,23 @@ export interface GatewayDriver { method: StoredMethodDetails, config: Record, ): Promise<{ transactionId: string } | null>; + // Charge a single-use payment credential delegated by an agent. The driver + // must bind the processor request to the authoritative invoice amount. + chargeDelegated?( + invoice: Invoice & { user: User }, + token: string, + config: Record, + idempotencyKey: string, + ): Promise<{ transactionId: string; status: string }>; +} + +export function gatewayAcceptsCurrency( + driver: GatewayDriver, + currency: string, +): boolean { + return driver.supportedCurrencies + ? driver.supportedCurrencies.includes(currency) + : /^[A-Z]{3}$/.test(currency); } // A server-provisioning driver (Pterodactyl, Proxmox, ...). Lifecycle hooks diff --git a/src/lib/format.test.ts b/src/lib/format.test.ts new file mode 100644 index 0000000..351f469 --- /dev/null +++ b/src/lib/format.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "vitest"; +import { formatMoney } from "@/lib/format"; + +describe("formatMoney", () => { + test("formats ISO fiat currencies with Intl currency rules", () => { + expect(formatMoney(12.5, "USD")).toBe("$12.50"); + }); + + test("formats USDC without treating it as an ISO 4217 code", () => { + expect(formatMoney(12.123456, "USDC")).toBe("12.123456 USDC"); + expect(formatMoney(12, "USDC")).toBe("12.00 USDC"); + }); +}); diff --git a/src/lib/format.ts b/src/lib/format.ts index 19f0596..0ea3330 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -4,10 +4,20 @@ export function formatMoney( amount: number | string | { toString(): string }, currency = "USD", ): string { - return new Intl.NumberFormat("en-US", { - style: "currency", - currency, - }).format(Number(amount.toString())); + const numericAmount = Number(amount.toString()); + try { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency, + }).format(numericAmount); + } catch (error) { + if (!(error instanceof RangeError)) throw error; + const formatted = new Intl.NumberFormat("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: currency === "USDC" ? 6 : 8, + }).format(numericAmount); + return `${formatted} ${currency}`; + } } export function formatDate(date: Date | string | null | undefined): string { diff --git a/src/lib/services/acp.ts b/src/lib/services/acp.ts new file mode 100644 index 0000000..fe090a2 --- /dev/null +++ b/src/lib/services/acp.ts @@ -0,0 +1,523 @@ +import "server-only"; +import type { Prisma } from "@/generated/prisma/client"; +import { db } from "@/lib/db"; +import { sha256 } from "@/lib/auth"; +import type { AgentPrincipal } from "@/lib/agent-auth"; +import { + ACP_VERSION, + canonicalJson, + toAtomicUnits, + validateDelegatedPurchase, +} from "@/lib/agent-commerce-policy"; +import { roundCurrency } from "@/lib/billing-policy"; +import { getEnabledCurrencies } from "@/lib/services/currency"; +import { computeTotals, priceCart } from "@/lib/services/orders"; +import { + AgentCheckoutError, + createNativeAgentCheckout, + type NativeAgentCheckoutInput, +} from "@/lib/services/agent-checkout"; +import { getGatewayDriver } from "@/lib/extensions/registry"; +import { extensionConfig } from "@/lib/extensions/types"; +import { markInvoicePaid } from "@/lib/billing"; +import { publicUrl } from "@/lib/settings"; + +type AcpStoredRequest = { + native: NativeAgentCheckoutInput; + skus: string[]; + buyer?: Record; + capabilities: Record; +}; + +type AcpCreateInput = { + currency: string; + lineItems: Array<{ id: string; quantity: number }>; + couponCode: string | null; + buyer?: Record; + capabilities: Record; +}; + +function prismaCode(error: unknown): string | null { + return typeof error === "object" && error !== null && "code" in error + ? String(error.code) + : null; +} + +async function quoteAcpRequest(grant: AgentPrincipal, input: AcpCreateInput) { + const code = input.currency.toUpperCase(); + const currencies = await getEnabledCurrencies(); + const currency = currencies.find((item) => item.code === code); + if (!currency || currency.kind !== "FIAT" || currency.decimals !== 2 || code.length !== 3) { + throw new AgentCheckoutError( + "ACP checkout currently requires an enabled two-decimal ISO fiat currency.", + ); + } + const uniqueSkus = [...new Set(input.lineItems.map((item) => item.id))]; + const prices = await db.productPrice.findMany({ + where: { id: { in: uniqueSkus } }, + include: { product: true }, + }); + const byId = new Map(prices.map((price) => [price.id, price])); + const lines = input.lineItems.map((item) => { + const price = byId.get(item.id); + if (!price || price.product.hidden) { + throw new AgentCheckoutError("One or more ACP item IDs are unavailable."); + } + return { + productId: price.productId, + cycle: price.cycle, + quantity: item.quantity, + optionValues: [], + }; + }); + const priced = await priceCart(lines); + if (priced.length !== lines.length) { + throw new AgentCheckoutError("One or more ACP items are unavailable."); + } + const { isTaxExempt } = await import("@/lib/services/fraud"); + const totals = await computeTotals( + priced, + input.couponCode, + grant.user.country, + currency, + await isTaxExempt(grant.user), + ); + const baseAmount = roundCurrency(totals.total / totals.rate); + const policyError = validateDelegatedPurchase( + { + ...grant, + maxPerOrderBase: Number(grant.maxPerOrderBase), + spendLimitBase: Number(grant.spendLimitBase), + committedBase: Number(grant.committedBase), + }, + { productIds: lines.map((line) => line.productId), currency: code, baseAmount }, + new Date(), + ); + if (policyError) throw new AgentCheckoutError(policyError, 403, "grant_limit"); + const stored: AcpStoredRequest = { + native: { currency: code, couponCode: input.couponCode, lines }, + skus: input.lineItems.map((item) => item.id), + buyer: input.buyer, + capabilities: input.capabilities, + }; + return { stored, totals, baseAmount }; +} + +export async function createAcpSession(input: { + grant: AgentPrincipal; + request: AcpCreateInput; + idempotencyKey: string; +}) { + const requestHash = sha256(canonicalJson(input.request)); + const unique = { + grantId: input.grant.id, + protocol: "acp", + idempotencyKey: input.idempotencyKey, + }; + const existing = await db.agentCheckout.findUnique({ + where: { grantId_protocol_idempotencyKey: unique }, + }); + if (existing) { + if (existing.requestHash !== requestHash) { + throw new AgentCheckoutError( + "The idempotency key was already used with a different request.", + 422, + "idempotency_conflict", + ); + } + return { checkout: existing, replayed: true }; + } + const quote = await quoteAcpRequest(input.grant, input.request); + try { + const checkout = await db.agentCheckout.create({ + data: { + ...unique, + requestHash, + request: quote.stored as unknown as Prisma.InputJsonValue, + currency: quote.totals.currency, + amount: quote.totals.total, + baseAmount: quote.baseAmount, + }, + }); + return { checkout, replayed: false }; + } catch (error) { + if (prismaCode(error) !== "P2002") throw error; + const raced = await db.agentCheckout.findUnique({ + where: { grantId_protocol_idempotencyKey: unique }, + }); + if (!raced || raced.requestHash !== requestHash) { + throw new AgentCheckoutError("Idempotency conflict.", 422, "idempotency_conflict"); + } + return { checkout: raced, replayed: true }; + } +} + +async function beginOperation(input: { + checkoutId: string; + operation: string; + idempotencyKey: string; + request: unknown; +}) { + const requestHash = sha256(canonicalJson(input.request)); + const where = { + checkoutId_operation_idempotencyKey: { + checkoutId: input.checkoutId, + operation: input.operation, + idempotencyKey: input.idempotencyKey, + }, + }; + const existing = await db.agentCheckoutOperation.findUnique({ where }); + if (existing) { + if (existing.requestHash !== requestHash) { + throw new AgentCheckoutError( + "The idempotency key was already used with a different request.", + 422, + "idempotency_conflict", + ); + } + if (existing.response) return { operation: existing, replayed: true }; + if (existing.createdAt.getTime() > Date.now() - 120_000) { + throw new AgentCheckoutError( + "The original operation is still processing.", + 409, + "idempotency_in_flight", + ); + } + return { operation: existing, replayed: false }; + } + try { + return { + operation: await db.agentCheckoutOperation.create({ + data: { + checkoutId: input.checkoutId, + operation: input.operation, + idempotencyKey: input.idempotencyKey, + requestHash, + }, + }), + replayed: false, + }; + } catch (error) { + if (prismaCode(error) === "P2002") { + throw new AgentCheckoutError( + "The original operation is still processing.", + 409, + "idempotency_in_flight", + ); + } + throw error; + } +} + +export async function updateAcpSession(input: { + grant: AgentPrincipal; + checkoutId: string; + idempotencyKey: string; + changes: { + lineItems?: Array<{ id: string; quantity: number }>; + couponCode?: string | null; + buyer?: Record; + capabilities?: Record; + }; +}) { + const checkout = await db.agentCheckout.findFirst({ + where: { id: input.checkoutId, grantId: input.grant.id, protocol: "acp" }, + }); + if (!checkout) throw new AgentCheckoutError("Checkout session not found.", 404, "not_found"); + const started = await beginOperation({ + checkoutId: checkout.id, + operation: "update", + idempotencyKey: input.idempotencyKey, + request: input.changes, + }); + if (started.replayed) { + return { response: started.operation.response, replayed: true }; + } + if (checkout.status !== "DRAFT") { + throw new AgentCheckoutError("Only draft checkout sessions can be updated.", 409); + } + const previous = checkout.request as unknown as AcpStoredRequest; + const lineItems = input.changes.lineItems ?? previous.skus.map((id, index) => ({ + id, + quantity: previous.native.lines[index]?.quantity ?? 1, + })); + const quote = await quoteAcpRequest(input.grant, { + currency: checkout.currency, + lineItems, + couponCode: + input.changes.couponCode === undefined + ? previous.native.couponCode + : input.changes.couponCode, + buyer: input.changes.buyer ?? previous.buyer, + capabilities: input.changes.capabilities ?? previous.capabilities, + }); + await db.agentCheckout.update({ + where: { id: checkout.id }, + data: { + request: quote.stored as unknown as Prisma.InputJsonValue, + amount: quote.totals.total, + baseAmount: quote.baseAmount, + }, + }); + const response = await acpRepresentation(checkout.id); + await db.agentCheckoutOperation.update({ + where: { id: started.operation.id }, + data: { + response: response as unknown as Prisma.InputJsonValue, + completedAt: new Date(), + }, + }); + return { response, replayed: false }; +} + +export async function completeAcpSession(input: { + grant: AgentPrincipal; + checkoutId: string; + idempotencyKey: string; + token: string; + ip: string | null; +}) { + let checkout = await db.agentCheckout.findFirst({ + where: { id: input.checkoutId, grantId: input.grant.id, protocol: "acp" }, + }); + if (!checkout) throw new AgentCheckoutError("Checkout session not found.", 404, "not_found"); + const started = await beginOperation({ + checkoutId: checkout.id, + operation: "complete", + idempotencyKey: input.idempotencyKey, + request: { tokenHash: sha256(input.token) }, + }); + if (started.replayed) { + return { response: started.operation.response, replayed: true }; + } + if (checkout.status === "CANCELED") { + throw new AgentCheckoutError("Checkout session is canceled.", 409); + } + + if (!checkout.invoiceId) { + const stored = checkout.request as unknown as AcpStoredRequest; + const native = await createNativeAgentCheckout({ + grant: input.grant, + request: stored.native, + idempotencyKey: `acp:${checkout.id}:${started.operation.id}`, + ip: input.ip, + }); + if (!native.orderId || !native.invoiceId) { + throw new AgentCheckoutError("Order creation is still in progress.", 409); + } + const acpCheckoutId = checkout.id; + checkout = await db.$transaction(async (tx) => { + await tx.agentCheckout.delete({ where: { id: native.id } }); + return tx.agentCheckout.update({ + where: { id: acpCheckoutId }, + data: { + orderId: native.orderId, + invoiceId: native.invoiceId, + status: native.status, + amount: native.amount, + baseAmount: native.baseAmount, + }, + }); + }); + } + + if (checkout.status !== "PAID") { + const [invoice, stripeExtension] = await Promise.all([ + db.invoice.findUnique({ + where: { id: checkout.invoiceId! }, + include: { user: true }, + }), + db.extension.findUnique({ where: { slug: "stripe" } }), + ]); + const driver = getGatewayDriver("stripe"); + if ( + !invoice || + !stripeExtension?.enabled || + stripeExtension.type !== "GATEWAY" || + !driver?.chargeDelegated + ) { + throw new AgentCheckoutError( + "Stripe delegated payments are not enabled.", + 503, + "payment_unavailable", + ); + } + if (invoice.status !== "PAID") { + const payment = await driver.chargeDelegated( + invoice, + input.token, + extensionConfig(stripeExtension), + `acp-${started.operation.id}`, + ); + if (payment.status !== "succeeded") { + throw new AgentCheckoutError( + `Stripe payment requires attention (${payment.status}).`, + 402, + "payment_not_completed", + ); + } + await markInvoicePaid(invoice.id, "stripe-acp", payment.transactionId); + } + checkout = await db.agentCheckout.update({ + where: { id: checkout.id }, + data: { status: "PAID" }, + }); + } + + const response = await acpRepresentation(checkout.id); + await db.agentCheckoutOperation.update({ + where: { id: started.operation.id }, + data: { + response: response as unknown as Prisma.InputJsonValue, + completedAt: new Date(), + }, + }); + return { response, replayed: false }; +} + +export async function cancelAcpSession(input: { + grant: AgentPrincipal; + checkoutId: string; + idempotencyKey: string; +}) { + const checkout = await db.agentCheckout.findFirst({ + where: { id: input.checkoutId, grantId: input.grant.id, protocol: "acp" }, + }); + if (!checkout) throw new AgentCheckoutError("Checkout session not found.", 404, "not_found"); + const started = await beginOperation({ + checkoutId: checkout.id, + operation: "cancel", + idempotencyKey: input.idempotencyKey, + request: {}, + }); + if (started.replayed) return { response: started.operation.response, replayed: true }; + if (checkout.orderId || checkout.status === "PAID") { + throw new AgentCheckoutError("A completed checkout cannot be canceled.", 409); + } + await db.agentCheckout.update({ + where: { id: checkout.id }, + data: { status: "CANCELED" }, + }); + const response = await acpRepresentation(checkout.id); + await db.agentCheckoutOperation.update({ + where: { id: started.operation.id }, + data: { response: response as unknown as Prisma.InputJsonValue, completedAt: new Date() }, + }); + return { response, replayed: false }; +} + +function minor(amount: number): number { + return Number(toAtomicUnits(roundCurrency(amount).toFixed(2), 2)); +} + +export async function acpRepresentation(checkoutId: string) { + const checkout = await db.agentCheckout.findUnique({ where: { id: checkoutId } }); + if (!checkout) throw new AgentCheckoutError("Checkout session not found.", 404, "not_found"); + const stored = checkout.request as unknown as AcpStoredRequest; + const [priced, baseUrl, stripeEnabled, order] = await Promise.all([ + priceCart(stored.native.lines), + publicUrl(), + db.extension.findFirst({ where: { slug: "stripe", type: "GATEWAY", enabled: true } }), + checkout.orderId + ? db.order.findUnique({ where: { id: checkout.orderId }, include: { items: { include: { product: true } } } }) + : null, + ]); + const currencies = await getEnabledCurrencies(); + const currency = currencies.find((item) => item.code === checkout.currency); + if (!currency) throw new AgentCheckoutError("Checkout currency is unavailable.", 409); + const lineItems = priced.map((line, index) => { + const unit = roundCurrency(line.unitPrice * currency.rate); + const lineTotal = roundCurrency(line.lineTotal * currency.rate); + return { + id: `line_${index + 1}`, + item: { id: stored.skus[index], name: line.name, unit_amount: minor(unit) }, + product_id: line.productId, + name: line.name, + quantity: line.quantity, + unit_amount: minor(unit), + availability_status: "in_stock", + totals: [ + { type: "items_base_amount", display_text: "Items", amount: minor(lineTotal) }, + { type: "subtotal", display_text: "Subtotal", amount: minor(lineTotal) }, + { type: "total", display_text: "Total", amount: minor(lineTotal) }, + ], + }; + }); + const status = { + DRAFT: "ready_for_payment", + PROCESSING: "complete_in_progress", + PAYMENT_PENDING: "in_progress", + PAID: "completed", + CANCELED: "canceled", + FAILED: "requires_escalation", + }[checkout.status]; + const response: Record = { + id: checkout.id, + protocol: { version: ACP_VERSION }, + status, + currency: checkout.currency.toLowerCase(), + line_items: lineItems, + totals: [ + { type: "subtotal", display_text: "Subtotal", amount: minor(Number(checkout.amount)) }, + { type: "total", display_text: "Total", amount: minor(Number(checkout.amount)) }, + ], + fulfillment_options: [], + messages: [], + links: [], + capabilities: { + payment: { + handlers: stripeEnabled + ? [ + { + id: "stripe_spt", + name: "dev.acp.tokenized.card", + display_name: "Card via Stripe", + version: ACP_VERSION, + spec: "https://docs.stripe.com/agentic-commerce/protocol", + requires_delegate_payment: false, + requires_pci_compliance: false, + psp: "stripe", + config_schema: "https://github.com/agentic-commerce-protocol/agentic-commerce-protocol", + instrument_schemas: [ + "https://github.com/agentic-commerce-protocol/agentic-commerce-protocol", + ], + config: {}, + }, + ] + : [], + }, + }, + buyer: stored.buyer, + created_at: checkout.createdAt.toISOString(), + updated_at: checkout.updatedAt.toISOString(), + continue_url: checkout.invoiceId + ? `${baseUrl}/dashboard/invoices/${checkout.invoiceId}` + : `${baseUrl}/api/acp/checkout_sessions/${checkout.id}`, + }; + if (checkout.status === "PAID" && order) { + response.order = { + id: order.id, + order_number: String(order.number), + checkout_session_id: checkout.id, + status: "confirmed", + currency: checkout.currency.toLowerCase(), + total: minor(Number(checkout.amount)), + line_items: order.items.map((item) => ({ + id: item.id, + title: item.product.name, + product_id: item.productId, + quantity: { + ordered: item.quantity, + current: item.quantity, + fulfilled: 0, + }, + unit_price: minor(Number(item.unitPrice)), + subtotal: minor(Number(item.unitPrice) * item.quantity + Number(item.setupFee)), + status: "processing", + })), + created_at: order.createdAt.toISOString(), + permalink_url: `${baseUrl}/dashboard/invoices/${checkout.invoiceId}`, + }; + } + return response; +} diff --git a/src/lib/services/agent-checkout.ts b/src/lib/services/agent-checkout.ts new file mode 100644 index 0000000..a749da1 --- /dev/null +++ b/src/lib/services/agent-checkout.ts @@ -0,0 +1,309 @@ +import "server-only"; +import type { BillingCycle, Prisma } from "@/generated/prisma/client"; +import { db } from "@/lib/db"; +import { sha256 } from "@/lib/auth"; +import { audit } from "@/lib/audit"; +import { + canonicalJson, + validateDelegatedPurchase, +} from "@/lib/agent-commerce-policy"; +import type { AgentPrincipal } from "@/lib/agent-auth"; +import { roundCurrency } from "@/lib/billing-policy"; +import { getEnabledCurrencies } from "@/lib/services/currency"; +import { computeTotals, placeOrder, priceCart } from "@/lib/services/orders"; +import { assessOrder, isTaxExempt } from "@/lib/services/fraud"; +import { markInvoicePaid } from "@/lib/billing"; +import { publicUrl } from "@/lib/settings"; + +export type AgentCheckoutLineInput = { + productId: string; + cycle: BillingCycle; + quantity: number; + optionValues: string[]; + resaleData?: Record; +}; + +export type NativeAgentCheckoutInput = { + currency: string; + couponCode: string | null; + lines: AgentCheckoutLineInput[]; +}; + +export class AgentCheckoutError extends Error { + constructor( + message: string, + public status = 422, + public code = "checkout_rejected", + ) { + super(message); + } +} + +function prismaCode(error: unknown): string | null { + return typeof error === "object" && error !== null && "code" in error + ? String(error.code) + : null; +} + +async function reserveGrantSpend(checkoutId: string, now: Date): Promise { + for (let attempt = 0; attempt < 3; attempt++) { + try { + await db.$transaction( + async (tx) => { + const checkout = await tx.agentCheckout.findUnique({ + where: { id: checkoutId }, + include: { grant: true }, + }); + if (!checkout || checkout.status !== "DRAFT") { + throw new AgentCheckoutError( + "Checkout is already being processed.", + 409, + "idempotency_in_flight", + ); + } + const request = checkout.request as NativeAgentCheckoutInput; + const policyError = validateDelegatedPurchase( + { + ...checkout.grant, + maxPerOrderBase: Number(checkout.grant.maxPerOrderBase), + spendLimitBase: Number(checkout.grant.spendLimitBase), + committedBase: Number(checkout.grant.committedBase), + }, + { + productIds: request.lines.map((line) => line.productId), + currency: checkout.currency, + baseAmount: Number(checkout.baseAmount), + }, + now, + ); + if (policyError) throw new AgentCheckoutError(policyError, 403, "grant_limit"); + + await tx.agentGrant.update({ + where: { id: checkout.grantId }, + data: { committedBase: { increment: checkout.baseAmount } }, + }); + await tx.agentCheckout.update({ + where: { id: checkout.id }, + data: { status: "PROCESSING" }, + }); + }, + { isolationLevel: "Serializable" }, + ); + return; + } catch (error) { + if (prismaCode(error) === "P2034" && attempt < 2) continue; + throw error; + } + } +} + +async function releaseGrantSpend(checkoutId: string): Promise { + await db.$transaction(async (tx) => { + const checkout = await tx.agentCheckout.findUnique({ where: { id: checkoutId } }); + if (!checkout || checkout.status !== "PROCESSING") return; + await tx.agentGrant.update({ + where: { id: checkout.grantId }, + data: { committedBase: { decrement: checkout.baseAmount } }, + }); + await tx.agentCheckout.update({ + where: { id: checkout.id }, + data: { status: "FAILED" }, + }); + }); +} + +export async function createNativeAgentCheckout(input: { + grant: AgentPrincipal; + request: NativeAgentCheckoutInput; + idempotencyKey: string; + ip: string | null; + now?: Date; +}) { + const now = input.now ?? new Date(); + const normalizedRequest = { + ...input.request, + currency: input.request.currency.toUpperCase(), + }; + const requestHash = sha256(canonicalJson(normalizedRequest)); + const existing = await db.agentCheckout.findUnique({ + where: { + grantId_protocol_idempotencyKey: { + grantId: input.grant.id, + protocol: "native", + idempotencyKey: input.idempotencyKey, + }, + }, + }); + if (existing) { + if (existing.requestHash !== requestHash) { + throw new AgentCheckoutError( + "The idempotency key was already used with a different request.", + 422, + "idempotency_conflict", + ); + } + if (existing.status === "DRAFT" || existing.status === "PROCESSING") { + throw new AgentCheckoutError( + "The original checkout is still processing.", + 409, + "idempotency_in_flight", + ); + } + return existing; + } + + const currencies = await getEnabledCurrencies(); + const currency = currencies.find((item) => item.code === normalizedRequest.currency); + if (!currency) { + throw new AgentCheckoutError("Currency is not enabled."); + } + + const priced = await priceCart(normalizedRequest.lines); + if (priced.length !== normalizedRequest.lines.length) { + throw new AgentCheckoutError("One or more products or billing cycles are unavailable."); + } + + const verdict = await assessOrder(input.grant.user, input.ip); + if (verdict.action === "block") { + throw new AgentCheckoutError("Checkout was declined by the order policy.", 403, "order_blocked"); + } + const taxExempt = await isTaxExempt(input.grant.user); + const totals = await computeTotals( + priced, + normalizedRequest.couponCode, + input.grant.user.country, + currency, + taxExempt, + ); + const baseAmount = roundCurrency(totals.total / totals.rate); + const policyError = validateDelegatedPurchase( + { + ...input.grant, + maxPerOrderBase: Number(input.grant.maxPerOrderBase), + spendLimitBase: Number(input.grant.spendLimitBase), + committedBase: Number(input.grant.committedBase), + }, + { + productIds: normalizedRequest.lines.map((line) => line.productId), + currency: currency.code, + baseAmount, + }, + now, + ); + if (policyError) throw new AgentCheckoutError(policyError, 403, "grant_limit"); + + let checkout; + try { + checkout = await db.agentCheckout.create({ + data: { + grantId: input.grant.id, + protocol: "native", + idempotencyKey: input.idempotencyKey, + requestHash, + request: normalizedRequest as unknown as Prisma.InputJsonValue, + currency: currency.code, + amount: totals.total, + baseAmount, + }, + }); + } catch (error) { + if (prismaCode(error) !== "P2002") throw error; + const raced = await db.agentCheckout.findUnique({ + where: { + grantId_protocol_idempotencyKey: { + grantId: input.grant.id, + protocol: "native", + idempotencyKey: input.idempotencyKey, + }, + }, + }); + if (!raced || raced.requestHash !== requestHash) { + throw new AgentCheckoutError("Idempotency conflict.", 422, "idempotency_conflict"); + } + return raced; + } + + await reserveGrantSpend(checkout.id, now); + try { + const placed = await placeOrder( + input.grant.userId, + priced, + normalizedRequest.couponCode, + currency, + { + reviewStatus: verdict.action === "review" ? "PENDING_REVIEW" : "AUTO_APPROVED", + ip: input.ip, + riskScore: verdict.score, + riskNotes: verdict.notes.join("; ") || null, + taxExempt, + }, + ); + let status: "PAYMENT_PENDING" | "PAID" = "PAYMENT_PENDING"; + if (Number(placed.invoice.total) === 0) { + await markInvoicePaid(placed.invoice.id, "free", undefined, now); + status = "PAID"; + } + checkout = await db.agentCheckout.update({ + where: { id: checkout.id }, + data: { + status, + orderId: placed.order.id, + invoiceId: placed.invoice.id, + amount: placed.invoice.total, + }, + }); + await audit("agent.checkout_created", { + userId: input.grant.userId, + targetType: "order", + targetId: placed.order.id, + metadata: { grantId: input.grant.id, protocol: "native" }, + }); + return checkout; + } catch (error) { + await releaseGrantSpend(checkout.id); + throw error; + } +} + +export async function agentCheckoutRepresentation(checkoutId: string) { + const checkout = await db.agentCheckout.findUnique({ where: { id: checkoutId } }); + if (!checkout) throw new AgentCheckoutError("Checkout not found.", 404, "not_found"); + const baseUrl = await publicUrl(); + const extensions = await db.extension.findMany({ + where: { enabled: true, type: "GATEWAY", slug: { in: ["stripe", "x402"] } }, + select: { slug: true }, + }); + const enabled = new Set(extensions.map((extension) => extension.slug)); + const paymentOptions: Array> = []; + if (checkout.invoiceId && checkout.status === "PAYMENT_PENDING") { + if (checkout.currency === "USDC" && enabled.has("x402")) { + paymentOptions.push({ + type: "x402", + method: "POST", + url: `${baseUrl}/api/x402/invoices/${checkout.invoiceId}`, + protocol_version: 2, + }); + } + if (checkout.currency !== "USDC" && enabled.has("stripe")) { + paymentOptions.push({ + type: "hosted_checkout", + provider: "stripe", + url: `${baseUrl}/dashboard/invoices/${checkout.invoiceId}`, + }); + } + } + return { + id: checkout.id, + status: checkout.status.toLowerCase(), + currency: checkout.currency, + amount: checkout.amount.toString(), + order_id: checkout.orderId, + invoice_id: checkout.invoiceId, + invoice_url: checkout.invoiceId + ? `${baseUrl}/dashboard/invoices/${checkout.invoiceId}` + : null, + payment_options: paymentOptions, + created_at: checkout.createdAt.toISOString(), + updated_at: checkout.updatedAt.toISOString(), + }; +} diff --git a/src/lib/services/currency.ts b/src/lib/services/currency.ts index 039dc23..220331b 100644 --- a/src/lib/services/currency.ts +++ b/src/lib/services/currency.ts @@ -1,6 +1,7 @@ import "server-only"; import { cookies } from "next/headers"; import { db } from "@/lib/db"; +import { roundAsset } from "@/lib/billing-policy"; import { getSetting } from "@/lib/settings"; // Currency service: the only module that knows about exchange rates. @@ -9,24 +10,46 @@ import { getSetting } from "@/lib/settings"; const CURRENCY_COOKIE = "oh_currency"; -function round2(n: number): number { - return Math.round(n * 100) / 100; +export type ChargeCurrency = { + code: string; + rate: number; + kind: "FIAT" | "STABLECOIN"; + decimals: number; + settlementNetworks: string[]; +}; + +function networksFromJson(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((network): network is string => typeof network === "string"); } export async function getBaseCurrency(): Promise { return getSetting("currency"); } -export async function getEnabledCurrencies(): Promise< - Array<{ code: string; rate: number }> -> { +export async function getEnabledCurrencies(): Promise { const base = await getBaseCurrency(); - const extra = await db.currency.findMany({ where: { enabled: true } }); + const extra = await db.currency.findMany({ + where: { OR: [{ enabled: true }, { code: base }] }, + }); + const baseRecord = extra.find((currency) => currency.code === base); return [ - { code: base, rate: 1 }, + { + code: base, + rate: 1, + kind: baseRecord?.kind ?? "FIAT", + decimals: baseRecord?.decimals ?? 2, + settlementNetworks: networksFromJson(baseRecord?.settlementNetworks), + }, ...extra .filter((c) => c.code !== base) - .map((c) => ({ code: c.code, rate: Number(c.rate) })), + .map((c) => ({ + code: c.code, + rate: Number(c.rate), + kind: c.kind, + decimals: c.decimals, + settlementNetworks: networksFromJson(c.settlementNetworks), + })), ]; } @@ -34,7 +57,7 @@ export async function getEnabledCurrencies(): Promise< // preference, else base. export async function getActiveCurrency( userPreference?: string | null, -): Promise<{ code: string; rate: number }> { +): Promise { const currencies = await getEnabledCurrencies(); const cookieStore = await cookies(); const fromCookie = cookieStore.get(CURRENCY_COOKIE)?.value; @@ -45,6 +68,29 @@ export async function getActiveCurrency( ); } +export async function getChargeCurrency(code: string): Promise { + const normalized = code.toUpperCase(); + const base = await getBaseCurrency(); + const record = await db.currency.findUnique({ where: { code: normalized } }); + if (normalized === base) { + return { + code: base, + rate: 1, + kind: record?.kind ?? "FIAT", + decimals: record?.decimals ?? 2, + settlementNetworks: networksFromJson(record?.settlementNetworks), + }; + } + if (!record) return null; + return { + code: record.code, + rate: Number(record.rate), + kind: record.kind, + decimals: record.decimals, + settlementNetworks: networksFromJson(record.settlementNetworks), + }; +} + export async function setActiveCurrencyCookie(code: string): Promise { const cookieStore = await cookies(); cookieStore.set(CURRENCY_COOKIE, code, { @@ -56,9 +102,9 @@ export async function setActiveCurrencyCookie(code: string): Promise { export function convertFromBase( amount: number, - currency: { code: string; rate: number }, + currency: Pick, ): number { - return round2(amount * currency.rate); + return roundAsset(amount * currency.rate, currency.decimals); } // Convert an amount in `code` back to the base currency at current rates. @@ -69,5 +115,5 @@ export async function convertToBase( const currencies = await getEnabledCurrencies(); const currency = currencies.find((c) => c.code === code); if (!currency || currency.rate === 0) return amount; - return round2(amount / currency.rate); + return roundAsset(amount / currency.rate, currencies[0].decimals); } diff --git a/src/lib/services/orders.ts b/src/lib/services/orders.ts index 3d59c18..2cab351 100644 --- a/src/lib/services/orders.ts +++ b/src/lib/services/orders.ts @@ -7,11 +7,10 @@ import { roundAsset, roundCurrency, } from "@/lib/billing-policy"; +import type { ChargeCurrency } from "@/lib/services/currency"; import { CYCLE_MONTHS } from "@/lib/format"; import { getSettings } from "@/lib/settings"; -type CheckoutCurrency = { code: string; rate: number; decimals?: number }; - // Order service: turns a cart into an order + invoice + pending services, // applying coupons and taxes. The only module that computes checkout math. @@ -54,7 +53,13 @@ export async function priceCart(lines: CartLine[]): Promise { configOptions: { include: { values: true } }, }, }); - if (!product || product.hidden) continue; + if ( + !product || + product.hidden || + (product.stock !== null && product.stock < Math.max(1, line.quantity)) + ) { + continue; + } const price = product.prices.find((p) => p.cycle === line.cycle); if (!price) continue; @@ -104,7 +109,7 @@ export async function computeTotals( lines: PricedLine[], couponCode: string | null, country: string | null, - currency?: CheckoutCurrency, + currency?: ChargeCurrency, taxExempt = false, ) { // All math happens in base currency, then converts once at the end. @@ -157,7 +162,7 @@ export async function placeOrder( userId: string, lines: PricedLine[], couponCode: string | null, - currency?: CheckoutCurrency, + currency?: ChargeCurrency, guard: OrderGuard = {}, ) { if (lines.length === 0) throw new Error("Cart is empty"); diff --git a/src/lib/services/payments.ts b/src/lib/services/payments.ts index d3faed9..0a3c3fa 100644 --- a/src/lib/services/payments.ts +++ b/src/lib/services/payments.ts @@ -1,7 +1,11 @@ import "server-only"; import { db } from "@/lib/db"; import { getGatewayDriver } from "@/lib/extensions/registry"; -import { extensionConfig, type PayResult } from "@/lib/extensions/types"; +import { + extensionConfig, + gatewayAcceptsCurrency, + type PayResult, +} from "@/lib/extensions/types"; import { getSetting, publicUrl } from "@/lib/settings"; import { markInvoicePaid } from "@/lib/billing"; import { convertToBase } from "@/lib/services/currency"; @@ -29,6 +33,9 @@ export async function startGatewayPayment( } const driver = getGatewayDriver(gatewaySlug); if (!driver) throw new Error("Unknown payment gateway"); + if (!gatewayAcceptsCurrency(driver, invoice.currency)) { + throw new Error("Payment method does not support this invoice currency"); + } const baseUrl = await publicUrl(); return driver.pay(invoice, extensionConfig(extension), { diff --git a/src/lib/x402.test.ts b/src/lib/x402.test.ts new file mode 100644 index 0000000..1b71234 --- /dev/null +++ b/src/lib/x402.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "vitest"; +import { + decodeX402Header, + encodeX402Header, + paymentPayloadMatches, + x402Requirements, +} from "@/lib/x402"; + +const config = { + facilitator_url: "https://facilitator.example", + network: "eip155:8453", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + pay_to: "0x1111111111111111111111111111111111111111", +}; + +test("builds exact USDC requirements in atomic units", () => { + expect( + x402Requirements({ total: { toString: () => "1.010000" }, currency: "USDC" }, config), + ).toMatchObject({ + scheme: "exact", + network: "eip155:8453", + amount: "1010000", + }); +}); + +describe("x402 header validation", () => { + test("round trips base64 JSON and binds it to the exact resource and terms", () => { + const requirements = x402Requirements( + { total: { toString: () => "2.5" }, currency: "USDC" }, + config, + ); + const payload = { + x402Version: 2, + resource: { url: "https://merchant.example/api/x402/invoices/one" }, + accepted: requirements, + payload: { signature: "0xabc" }, + }; + const decoded = decodeX402Header(encodeX402Header(payload)); + expect( + paymentPayloadMatches( + decoded, + "https://merchant.example/api/x402/invoices/one", + requirements, + ), + ).toBe(true); + }); + + test("rejects payloads rebound to another invoice or amount", () => { + const requirements = x402Requirements( + { total: { toString: () => "2.5" }, currency: "USDC" }, + config, + ); + expect( + paymentPayloadMatches( + { + x402Version: 2, + resource: { url: "https://merchant.example/api/x402/invoices/two" }, + accepted: { ...requirements, amount: "1" }, + }, + "https://merchant.example/api/x402/invoices/one", + requirements, + ), + ).toBe(false); + }); +}); diff --git a/src/lib/x402.ts b/src/lib/x402.ts new file mode 100644 index 0000000..ba49cd3 --- /dev/null +++ b/src/lib/x402.ts @@ -0,0 +1,202 @@ +import { canonicalJson, toAtomicUnits } from "@/lib/agent-commerce-policy"; + +type X402Invoice = { + number: number; + total: number | string | { toString(): string }; + currency: string; +}; + +export type X402Config = { + facilitator_url: string; + network: string; + asset: string; + pay_to: string; + authorization_token?: string; + max_timeout_seconds?: string; +}; + +export type X402Requirements = { + scheme: "exact"; + network: string; + amount: string; + asset: string; + payTo: string; + maxTimeoutSeconds: number; + extra: { name: "USDC"; version: "2" }; +}; + +export type X402PaymentRequired = { + x402Version: 2; + error?: string; + resource: { + url: string; + description: string; + mimeType: "application/json"; + serviceName: "OpenHosting"; + tags: ["hosting", "invoice"]; + }; + accepts: X402Requirements[]; + extensions: Record; +}; + +export function validateX402Config(config: X402Config): string | null { + try { + const facilitator = new URL(config.facilitator_url); + if (facilitator.protocol !== "https:" && facilitator.hostname !== "localhost") { + return "Facilitator URL must use HTTPS."; + } + } catch { + return "Facilitator URL is invalid."; + } + if (!/^[a-z0-9-]+:[A-Za-z0-9]+$/.test(config.network)) { + return "Network must be a CAIP-2 identifier."; + } + if (!config.asset || !config.pay_to) return "Asset and receiving address are required."; + const timeout = Number(config.max_timeout_seconds || 60); + if (!Number.isInteger(timeout) || timeout < 1 || timeout > 3_600) { + return "Payment timeout must be between 1 and 3600 seconds."; + } + return null; +} + +export function x402Requirements( + invoice: Pick, + config: X402Config, +): X402Requirements { + if (invoice.currency !== "USDC") { + throw new Error("x402 requires a USDC invoice"); + } + const configError = validateX402Config(config); + if (configError) throw new Error(configError); + return { + scheme: "exact", + network: config.network, + amount: toAtomicUnits(invoice.total, 6), + asset: config.asset, + payTo: config.pay_to, + maxTimeoutSeconds: Number(config.max_timeout_seconds || 60), + extra: { name: "USDC", version: "2" }, + }; +} + +export function x402PaymentRequired(input: { + invoice: X402Invoice; + config: X402Config; + resourceUrl: string; + error?: string; +}): X402PaymentRequired { + return { + x402Version: 2, + ...(input.error ? { error: input.error } : {}), + resource: { + url: input.resourceUrl, + description: `Settle OpenHosting invoice #${input.invoice.number}`, + mimeType: "application/json", + serviceName: "OpenHosting", + tags: ["hosting", "invoice"], + }, + accepts: [x402Requirements(input.invoice, input.config)], + extensions: {}, + }; +} + +export function encodeX402Header(value: unknown): string { + return Buffer.from(JSON.stringify(value), "utf8").toString("base64"); +} + +export function decodeX402Header(value: string): Record { + if (!value || value.length > 65_536 || !/^[A-Za-z0-9+/=_-]+$/.test(value)) { + throw new Error("Invalid PAYMENT-SIGNATURE header"); + } + const decoded = Buffer.from(value, "base64").toString("utf8"); + const parsed: unknown = JSON.parse(decoded); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Invalid x402 payment payload"); + } + return parsed as Record; +} + +export function paymentPayloadMatches( + payload: Record, + resourceUrl: string, + requirements: X402Requirements, +): boolean { + if (payload.x402Version !== 2) return false; + const resource = payload.resource as Record | undefined; + if (!resource || resource.url !== resourceUrl) return false; + return canonicalJson(payload.accepted) === canonicalJson(requirements); +} + +async function facilitatorCall( + config: X402Config, + path: "verify" | "settle", + paymentPayload: Record, + paymentRequirements: X402Requirements, +): Promise> { + const url = new URL(path, `${config.facilitator_url.replace(/\/+$/, "")}/`); + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(config.authorization_token + ? { Authorization: `Bearer ${config.authorization_token}` } + : {}), + }, + body: JSON.stringify({ x402Version: 2, paymentPayload, paymentRequirements }), + signal: AbortSignal.timeout(15_000), + }); + const body: unknown = await response.json().catch(() => null); + if (!response.ok || !body || typeof body !== "object" || Array.isArray(body)) { + throw new Error(`Facilitator ${path} failed with HTTP ${response.status}`); + } + return body as Record; +} + +export async function verifyAndSettleX402(input: { + config: X402Config; + paymentPayload: Record; + paymentRequirements: X402Requirements; +}) { + const verified = await facilitatorCall( + input.config, + "verify", + input.paymentPayload, + input.paymentRequirements, + ); + if (verified.isValid !== true) { + throw new Error( + typeof verified.invalidReason === "string" + ? verified.invalidReason + : "Facilitator rejected the payment authorization", + ); + } + const settled = await facilitatorCall( + input.config, + "settle", + input.paymentPayload, + input.paymentRequirements, + ); + if ( + settled.success !== true || + typeof settled.transaction !== "string" || + !settled.transaction || + settled.network !== input.paymentRequirements.network || + (settled.amount !== undefined && settled.amount !== input.paymentRequirements.amount) + ) { + throw new Error( + typeof settled.errorReason === "string" + ? settled.errorReason + : "Facilitator did not confirm an exact settlement", + ); + } + return { + success: true as const, + transaction: settled.transaction, + network: String(settled.network), + payer: typeof settled.payer === "string" ? settled.payer : undefined, + amount: input.paymentRequirements.amount, + ...(settled.extensions && typeof settled.extensions === "object" + ? { extensions: settled.extensions } + : {}), + }; +} diff --git a/tests/integration/agent-commerce.test.ts b/tests/integration/agent-commerce.test.ts new file mode 100644 index 0000000..73d6a66 --- /dev/null +++ b/tests/integration/agent-commerce.test.ts @@ -0,0 +1,117 @@ +import { afterAll, beforeEach, describe, expect, test, vi } from "vitest"; +import { db } from "@/lib/db"; +import { createNativeAgentCheckout } from "@/lib/services/agent-checkout"; + +vi.mock("@/lib/services/notifications", () => ({ notifyUser: vi.fn() })); +vi.mock("@/lib/services/provisioning", () => ({ + provisionCreate: vi.fn(), + provisionSuspend: vi.fn(), + provisionTerminate: vi.fn(), + provisionUnsuspend: vi.fn(), +})); +vi.mock("@/lib/services/resale", () => ({ + resaleProvision: vi.fn(), + resaleRenew: vi.fn(), + resaleCancel: vi.fn(), +})); + +beforeEach(async () => { + await db.user.deleteMany(); + await db.category.deleteMany(); + await db.currency.deleteMany(); + await db.setting.deleteMany(); +}); + +afterAll(async () => { + await db.$disconnect(); +}); + +async function fixture(limit = 20) { + await db.setting.create({ data: { key: "currency", value: "USD" } }); + const user = await db.user.create({ + data: { + email: "agent-buyer@example.test", + password: "not-used", + firstName: "Agent", + lastName: "Buyer", + }, + }); + const category = await db.category.create({ + data: { name: "Agent products", slug: `agent-products-${limit}` }, + }); + const product = await db.product.create({ + data: { + name: "Agent VPS", + slug: `agent-vps-${limit}`, + categoryId: category.id, + prices: { create: { cycle: "MONTHLY", price: 10 } }, + }, + }); + const grant = await db.agentGrant.create({ + data: { + name: "Test grant", + tokenHash: `hash-${limit}`, + prefix: "ag_test", + userId: user.id, + allowedProductIds: [product.id], + allowedCurrencies: ["USD"], + maxPerOrderBase: 10, + spendLimitBase: limit, + expiresAt: new Date(Date.now() + 86_400_000), + }, + include: { user: true }, + }); + return { grant, product }; +} + +function checkoutInput( + grant: Awaited>["grant"], + productId: string, + idempotencyKey: string, +) { + return { + grant, + idempotencyKey, + ip: "192.0.2.10", + request: { + currency: "USD", + couponCode: null, + lines: [ + { + productId, + cycle: "MONTHLY" as const, + quantity: 1, + optionValues: [], + }, + ], + }, + }; +} + +describe("delegated agent checkout", () => { + test("replays an idempotent checkout without creating a second order", async () => { + const { grant, product } = await fixture(); + const input = checkoutInput(grant, product.id, "deploy-one"); + + const first = await createNativeAgentCheckout(input); + const replay = await createNativeAgentCheckout(input); + + expect(replay.id).toBe(first.id); + expect(replay.orderId).toBe(first.orderId); + expect(await db.order.count()).toBe(1); + expect((await db.agentGrant.findUniqueOrThrow({ where: { id: grant.id } })).committedBase.toString()).toBe("10"); + }); + + test("serializes concurrent reservations so aggregate spend cannot exceed the cap", async () => { + const { grant, product } = await fixture(10); + const results = await Promise.allSettled([ + createNativeAgentCheckout(checkoutInput(grant, product.id, "deploy-a")), + createNativeAgentCheckout(checkoutInput(grant, product.id, "deploy-b")), + ]); + + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); + expect(await db.order.count()).toBe(1); + expect((await db.agentGrant.findUniqueOrThrow({ where: { id: grant.id } })).committedBase.toString()).toBe("10"); + }); +}); diff --git a/tests/integration/billing.test.ts b/tests/integration/billing.test.ts index beb7a8e..2fd1c04 100644 --- a/tests/integration/billing.test.ts +++ b/tests/integration/billing.test.ts @@ -118,6 +118,52 @@ describe("renewal invoice generation", () => { expect(Number(invoices[0].items[0].unitPrice)).toBe(25); }); + test("converts sub-cent metered usage into a USDC-locked renewal", async () => { + const now = new Date("2026-08-27T12:00:00.000Z"); + const { user, product } = await createCustomerAndProduct(); + await db.setting.createMany({ + data: [ + { key: "currency", value: "USD" }, + { key: "invoice_days_before", value: "7" }, + ], + }); + await db.currency.create({ + data: { + code: "USDC", + kind: "STABLECOIN", + decimals: 6, + settlementNetworks: ["eip155:8453"], + rate: 1, + }, + }); + await db.product.update({ + where: { id: product.id }, + data: { metered: true, meteredUnit: "request", meteredUnitPrice: 0.000001 }, + }); + const service = await db.service.create({ + data: { + userId: user.id, + productId: product.id, + status: "ACTIVE", + cycle: "MONTHLY", + price: 5, + currency: "USDC", + expiresAt: now, + }, + }); + await db.usageRecord.create({ + data: { serviceId: service.id, quantity: 1 }, + }); + + expect(await generateRenewalInvoices(now)).toBe(1); + const invoice = await db.invoice.findFirstOrThrow({ + where: { userId: user.id }, + include: { items: true }, + }); + expect(invoice.currency).toBe("USDC"); + expect(invoice.total.toString()).toBe("5.000001"); + expect(invoice.items[1].unitPrice.toString()).toBe("0.000001"); + }); }); describe("service lifecycle cutoffs", () => { diff --git a/tests/integration/x402.test.ts b/tests/integration/x402.test.ts new file mode 100644 index 0000000..130ea9a --- /dev/null +++ b/tests/integration/x402.test.ts @@ -0,0 +1,151 @@ +import { afterAll, beforeEach, expect, test, vi } from "vitest"; +import { POST as settleInvoice } from "@/app/api/x402/invoices/[id]/route"; +import { sha256 } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { decodeX402Header, encodeX402Header } from "@/lib/x402"; + +const rawGrant = "ag_integrationtesttoken"; + +beforeEach(async () => { + vi.unstubAllGlobals(); + await db.user.deleteMany(); + await db.extension.deleteMany(); + await db.currency.deleteMany(); + await db.setting.deleteMany(); +}); + +afterAll(async () => { + vi.unstubAllGlobals(); + await db.$disconnect(); +}); + +test("advertises, verifies, settles, and idempotently records an exact USDC payment", async () => { + await db.setting.create({ + data: { key: "company_url", value: "https://merchant.example.test" }, + }); + const user = await db.user.create({ + data: { + email: "x402-buyer@example.test", + password: "not-used", + firstName: "USDC", + lastName: "Buyer", + }, + }); + await db.agentGrant.create({ + data: { + name: "x402 test", + tokenHash: sha256(rawGrant), + prefix: "ag_integratio", + userId: user.id, + allowedProductIds: [], + allowedCurrencies: ["USDC"], + maxPerOrderBase: 100, + spendLimitBase: 100, + expiresAt: new Date(Date.now() + 60_000), + }, + }); + await db.currency.create({ + data: { + code: "USDC", + kind: "STABLECOIN", + decimals: 6, + settlementNetworks: ["eip155:8453"], + rate: 1, + }, + }); + await db.extension.create({ + data: { + slug: "x402", + name: "x402 (USDC)", + type: "GATEWAY", + enabled: true, + config: { + facilitator_url: "https://facilitator.example.test", + network: "eip155:8453", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + pay_to: "0x1111111111111111111111111111111111111111", + }, + }, + }); + const invoice = await db.invoice.create({ + data: { + userId: user.id, + currency: "USDC", + subtotal: 1.01, + total: 1.01, + }, + }); + const url = `https://merchant.example.test/api/x402/invoices/${invoice.id}`; + const authorization = { Authorization: `Bearer ${rawGrant}` }; + + const challenge = await settleInvoice( + new Request(url, { method: "POST", headers: authorization }), + { params: Promise.resolve({ id: invoice.id }) }, + ); + expect(challenge.status).toBe(402); + const required = decodeX402Header(challenge.headers.get("payment-required")!); + expect(required).toMatchObject({ + x402Version: 2, + accepts: [{ network: "eip155:8453", amount: "1010000" }], + }); + + const paymentPayload = { + x402Version: 2, + resource: required.resource, + accepted: (required.accepts as unknown[])[0], + payload: { signature: "0xsigned", authorization: { nonce: "0xnonce" } }, + }; + const facilitator = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ isValid: true, payer: "0xbuyer" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + success: true, + transaction: "0xtransaction", + network: "eip155:8453", + payer: "0xbuyer", + amount: "1010000", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + vi.stubGlobal("fetch", facilitator); + + const paid = await settleInvoice( + new Request(url, { + method: "POST", + headers: { + ...authorization, + "PAYMENT-SIGNATURE": encodeX402Header(paymentPayload), + }, + }), + { params: Promise.resolve({ id: invoice.id }) }, + ); + expect(paid.status).toBe(200); + expect(paid.headers.get("payment-response")).toBeTruthy(); + expect(facilitator).toHaveBeenCalledTimes(2); + expect(await db.invoice.findUniqueOrThrow({ where: { id: invoice.id } })).toEqual( + expect.objectContaining({ status: "PAID" }), + ); + expect(await db.payment.count({ where: { invoiceId: invoice.id } })).toBe(1); + + const replay = await settleInvoice( + new Request(url, { + method: "POST", + headers: { + ...authorization, + "PAYMENT-SIGNATURE": encodeX402Header(paymentPayload), + }, + }), + { params: Promise.resolve({ id: invoice.id }) }, + ); + expect(replay.status).toBe(200); + expect(facilitator).toHaveBeenCalledTimes(2); + expect(await db.payment.count({ where: { invoiceId: invoice.id } })).toBe(1); +});