From d9fa462b2bafa9edc7c3576db6ab83b5939fe16f Mon Sep 17 00:00:00 2001 From: KingParmenides Date: Mon, 11 May 2026 06:17:22 -0600 Subject: [PATCH] feat: add APort AP2 payment bridge --- README.md | 2 +- examples/protocol-bridges/ap2/.env.example | 6 + examples/protocol-bridges/ap2/README.md | 109 ++++++ .../ap2/examples/mock-payment-flow.js | 89 +++++ examples/protocol-bridges/ap2/index.js | 326 ++++++++++++++++++ examples/protocol-bridges/ap2/package.json | 28 ++ .../ap2/test/ap2-bridge.test.js | 289 ++++++++++++++++ 7 files changed, 848 insertions(+), 1 deletion(-) create mode 100644 examples/protocol-bridges/ap2/.env.example create mode 100644 examples/protocol-bridges/ap2/README.md create mode 100644 examples/protocol-bridges/ap2/examples/mock-payment-flow.js create mode 100644 examples/protocol-bridges/ap2/index.js create mode 100644 examples/protocol-bridges/ap2/package.json create mode 100644 examples/protocol-bridges/ap2/test/ap2-bridge.test.js diff --git a/README.md b/README.md index 77cbea9..3dd0362 100644 --- a/README.md +++ b/README.md @@ -235,7 +235,7 @@ Position APort as the universal verify layer. | Bridge | Description | Status | Maintainer | |--------|-------------|--------|------------| | [OpenAPI 3.1 Spec](examples/protocol-bridges/openapi/) | Complete OpenAPI specification | ✅ Active | Community | -| [AP2 Bridge](examples/protocol-bridges/ap2/) | APort passport authorization for AP2 payments | 📋 Planned | Community | +| [AP2 Bridge](examples/protocol-bridges/ap2/) | APort passport authorization for AP2 payments | ✅ Active | Community | | [SPIFFE/SPIRE Integration](examples/protocol-bridges/spiffe/) | Enterprise identity federation | 📋 Planned | Community | ### 🛠️ **Core Framework SDKs & Middleware** diff --git a/examples/protocol-bridges/ap2/.env.example b/examples/protocol-bridges/ap2/.env.example new file mode 100644 index 0000000..2a2486b --- /dev/null +++ b/examples/protocol-bridges/ap2/.env.example @@ -0,0 +1,6 @@ +APORT_API_KEY=aport_live_or_sandbox_key +APORT_BASE_URL=https://api.aport.io +APORT_AP2_POLICY_ID=finance.payment.authorization.v1 + +AP2_BASE_URL=https://merchant.example/v1/ap2 +AP2_BEARER_TOKEN=ap2_control_plane_token diff --git a/examples/protocol-bridges/ap2/README.md b/examples/protocol-bridges/ap2/README.md new file mode 100644 index 0000000..bcef1a6 --- /dev/null +++ b/examples/protocol-bridges/ap2/README.md @@ -0,0 +1,109 @@ +# APort AP2 Payment Authorization Bridge + +This example shows how to use APort passport verification as the authorization gate before an AP2 payment intent is created or confirmed. + +AP2 uses signed mandates and payment intents to prove user authorization. APort adds an independent policy check for the agent passport, including limits, assurance level, and suspension state. The bridge writes the APort decision back into the AP2 `policyTrace` so the AP2 flow keeps a verifiable authorization record. + +## What Is Included + +- `APortAP2Bridge`: verifies an AP2 payment intent with `@aporthq/sdk-node`, appends an AP2 `policyTrace` entry, then creates or confirms the payment intent through an AP2 control plane. +- `AP2Client`: a small REST wrapper for AP2 `/v1/ap2/payment-intents` endpoints. +- Deny handling: denied APort decisions stop AP2 intent creation and can either return a structured result or throw. +- Tests for context mapping, policy trace generation, allow/deny behavior, confirmation payloads, and AP2 HTTP errors. +- A mocked payment-flow example that runs without external credentials. + +## Install + +```bash +cd examples/protocol-bridges/ap2 +npm install +``` + +## Run The Local Demo + +```bash +npm run example +``` + +The demo uses mocked APort and AP2 clients so you can inspect the authorization flow locally. For a live integration, set the variables from `.env.example` and instantiate the bridge without mock clients. + +## Environment + +```bash +APORT_API_KEY=aport_live_or_sandbox_key +APORT_BASE_URL=https://api.aport.io +APORT_AP2_POLICY_ID=finance.payment.authorization.v1 + +AP2_BASE_URL=https://merchant.example/v1/ap2 +AP2_BEARER_TOKEN=ap2_control_plane_token +``` + +Use `APORT_AP2_POLICY_ID` for the APort policy pack that enforces your AP2 spending rules. The example defaults to `finance.payment.authorization.v1`; deployments can replace it with a policy pack that matches their APort tenant. + +## Usage + +```javascript +const { APortAP2Bridge } = require("./index"); + +const bridge = new APortAP2Bridge(); + +const result = await bridge.confirmAuthorizedPaymentIntent( + { + id: "ap2_pi_123", + amount: { + value: "149.99", + currency: "USDC" + }, + participants: { + buyer: "did:ap2:buyer-bot-9f32", + seller: "did:ap2:merchant-agent" + }, + terms: { + settlementRail: "x402", + captureType: "escrow_release", + releaseCondition: "shipment-confirmed", + disputeWindow: "P5D" + }, + lineItems: [ + { + sku: "RUN-SHOE-01", + quantity: 1, + unitPrice: "149.99" + } + ], + mandates: { + intentMandateId: "mandate_intent_123", + cartMandateId: "mandate_cart_123", + paymentMandateId: "mandate_payment_123" + } + }, + { + agentId: "agt_inst_buyer", + policyId: "finance.payment.authorization.v1" + } +); + +if (!result.authorized) { + console.log("Payment blocked", result.decision.reasons); +} +``` + +## Authorization Flow + +1. Validate the AP2 payment intent shape. +2. Resolve the APort agent ID from `options.agentId`, `paymentIntent.agentId`, or the AP2 buyer DID. +3. Build APort verification context from the AP2 amount, currency, buyer/seller DIDs, settlement rail, line items, evidence, and mandate references. +4. Call `APortClient.verifyPolicy(agentId, policyId, context, idempotencyKey)`. +5. Append the decision to AP2 `policyTrace`. +6. If allowed, create the AP2 payment intent and optionally call `/confirm`. +7. If denied, return a `denied` result or throw `APortAuthorizationError` when `throwOnDeny` is enabled. + +## Test + +```bash +npm test +``` + +## AP2 Notes + +This bridge follows the AP2 payment-intent and policy-trace shape from the AP2 specification and keeps AP2 settlement separate from APort authorization. AP2 still owns mandate signature verification, payment-intent lifecycle, and settlement proof creation. APort only decides whether the agent passport is authorized to initiate or confirm the payment. diff --git a/examples/protocol-bridges/ap2/examples/mock-payment-flow.js b/examples/protocol-bridges/ap2/examples/mock-payment-flow.js new file mode 100644 index 0000000..964a484 --- /dev/null +++ b/examples/protocol-bridges/ap2/examples/mock-payment-flow.js @@ -0,0 +1,89 @@ +const { APortAP2Bridge } = require("../index"); + +async function main() { + const aportClient = { + async verifyPolicy(agentId, policyId, context, idempotencyKey) { + console.log("APort verifyPolicy request:"); + console.log(JSON.stringify({ agentId, policyId, context, idempotencyKey }, null, 2)); + + return { + allow: true, + decision_id: "dec_demo_ap2_001", + assurance_level: "github", + reasons: [], + }; + }, + }; + + const ap2Client = { + async createPaymentIntent(paymentIntent) { + return { + ...paymentIntent, + status: "authorized", + }; + }, + async confirmPaymentIntent(intentId, confirmation) { + return { + id: intentId, + status: "confirmed", + confirmation, + }; + }, + }; + + const bridge = new APortAP2Bridge({ + aportClient, + ap2Client, + now: () => new Date("2026-05-11T12:00:00.000Z"), + }); + + const result = await bridge.confirmAuthorizedPaymentIntent( + { + id: "ap2_pi_demo_001", + amount: { + value: "149.99", + currency: "USDC", + }, + participants: { + buyer: "did:ap2:buyer-bot-9f32", + seller: "did:ap2:shoe-store-agent", + }, + terms: { + settlementRail: "x402", + captureType: "escrow_release", + releaseCondition: "shipment-confirmed", + disputeWindow: "P5D", + }, + lineItems: [ + { + sku: "RUN-SHOE-01", + quantity: 1, + unitPrice: "149.99", + metadata: { + category: "running-shoes", + }, + }, + ], + mandates: { + intentMandateId: "mandate_intent_demo_001", + cartMandateId: "mandate_cart_demo_001", + paymentMandateId: "mandate_payment_demo_001", + }, + }, + { + agentId: "agt_inst_demo_buyer", + policyId: "finance.payment.authorization.v1", + sellerAcceptance: { + acceptedBy: "did:ap2:shoe-store-agent", + }, + } + ); + + console.log("\nAuthorized AP2 payment flow:"); + console.log(JSON.stringify(result, null, 2)); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/examples/protocol-bridges/ap2/index.js b/examples/protocol-bridges/ap2/index.js new file mode 100644 index 0000000..e5527fd --- /dev/null +++ b/examples/protocol-bridges/ap2/index.js @@ -0,0 +1,326 @@ +const { APortClient } = require("@aporthq/sdk-node"); + +class AP2BridgeError extends Error { + constructor(message, details = {}) { + super(message); + this.name = "AP2BridgeError"; + this.details = details; + } +} + +class APortAuthorizationError extends AP2BridgeError { + constructor(message, decision, details = {}) { + super(message, details); + this.name = "APortAuthorizationError"; + this.decision = decision; + } +} + +class AP2Client { + constructor(options = {}) { + this.baseUrl = trimTrailingSlash( + options.baseUrl || + process.env.AP2_BASE_URL || + "http://localhost:8080/v1/ap2" + ); + this.token = options.token || process.env.AP2_BEARER_TOKEN; + this.fetchImpl = options.fetchImpl || globalThis.fetch; + + if (!this.fetchImpl) { + throw new AP2BridgeError( + "AP2Client requires Node.js 18+ fetch or a custom fetchImpl" + ); + } + } + + async createPaymentIntent(paymentIntent) { + return this.request("POST", "/payment-intents", paymentIntent); + } + + async getPaymentIntent(intentId) { + return this.request("GET", `/payment-intents/${encodeURIComponent(intentId)}`); + } + + async confirmPaymentIntent(intentId, confirmation) { + return this.request( + "POST", + `/payment-intents/${encodeURIComponent(intentId)}/confirm`, + confirmation + ); + } + + async cancelPaymentIntent(intentId, cancellation = {}) { + return this.request( + "POST", + `/payment-intents/${encodeURIComponent(intentId)}/cancel`, + cancellation + ); + } + + async request(method, path, body) { + const response = await this.fetchImpl(`${this.baseUrl}${path}`, { + method, + headers: { + "Content-Type": "application/json", + ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + + const text = await response.text(); + const payload = text ? JSON.parse(text) : {}; + + if (!response.ok) { + throw new AP2BridgeError(`AP2 API error ${response.status}`, { + status: response.status, + payload, + }); + } + + return payload; + } +} + +class APortAP2Bridge { + constructor(options = {}) { + this.policyId = + options.policyId || + process.env.APORT_AP2_POLICY_ID || + "finance.payment.authorization.v1"; + this.aportClient = + options.aportClient || + new APortClient({ + baseUrl: + options.aportBaseUrl || + process.env.APORT_BASE_URL || + "https://api.aport.io", + apiKey: options.aportApiKey || process.env.APORT_API_KEY, + timeoutMs: options.timeoutMs, + }); + this.ap2Client = options.ap2Client || new AP2Client(options.ap2 || {}); + this.now = options.now || (() => new Date()); + } + + async authorizePaymentIntent(paymentIntent, options = {}) { + const normalizedIntent = normalizePaymentIntent(paymentIntent); + const agentId = resolveAgentId(normalizedIntent, options.agentId); + const context = buildAPortContext(normalizedIntent, options.context); + const idempotencyKey = + options.idempotencyKey || normalizedIntent.id || context.ap2.intentId; + + const decision = await this.aportClient.verifyPolicy( + agentId, + options.policyId || this.policyId, + context, + idempotencyKey + ); + + const tracedIntent = appendAPortPolicyTrace( + normalizedIntent, + decision, + options.policyId || this.policyId, + this.now() + ); + + if (!decision.allow) { + if (options.throwOnDeny) { + throw new APortAuthorizationError( + "APort denied AP2 payment authorization", + decision, + { paymentIntent: tracedIntent } + ); + } + + return { + authorized: false, + status: "denied", + decision, + paymentIntent: tracedIntent, + }; + } + + return { + authorized: true, + status: "authorized", + decision, + paymentIntent: tracedIntent, + }; + } + + async createAuthorizedPaymentIntent(paymentIntent, options = {}) { + const authorization = await this.authorizePaymentIntent(paymentIntent, options); + + if (!authorization.authorized) { + return authorization; + } + + const ap2PaymentIntent = await this.ap2Client.createPaymentIntent( + authorization.paymentIntent + ); + + return { + ...authorization, + status: ap2PaymentIntent.status || authorization.status, + ap2PaymentIntent, + }; + } + + async confirmAuthorizedPaymentIntent(paymentIntent, options = {}) { + const authorization = await this.createAuthorizedPaymentIntent( + paymentIntent, + options + ); + + if (!authorization.authorized) { + return authorization; + } + + const intentId = + authorization.ap2PaymentIntent.id || authorization.paymentIntent.id; + const confirmation = await this.ap2Client.confirmPaymentIntent(intentId, { + sellerAcceptance: options.sellerAcceptance || {}, + aportDecision: summarizeDecision(authorization.decision), + }); + + return { + ...authorization, + status: confirmation.status || "confirmed", + confirmation, + }; + } +} + +function buildAPortContext(paymentIntent, extraContext = {}) { + const amount = paymentIntent.amount || {}; + const terms = paymentIntent.terms || {}; + const participants = paymentIntent.participants || {}; + + return { + ...extraContext, + ap2: { + intentId: paymentIntent.id, + amount: amount.value, + amountMinor: toMinorUnits(amount.value), + currency: amount.currency, + buyerDid: participants.buyer, + sellerDid: participants.seller, + settlementRail: terms.settlementRail, + captureType: terms.captureType, + releaseCondition: terms.releaseCondition, + disputeWindow: terms.disputeWindow, + lineItems: paymentIntent.lineItems || [], + evidence: paymentIntent.evidence || [], + mandates: paymentIntent.mandates || {}, + status: paymentIntent.status || "pending", + }, + }; +} + +function appendAPortPolicyTrace(paymentIntent, decision, policyId, timestamp) { + const reasons = Array.isArray(decision.reasons) ? decision.reasons : []; + const message = + reasons.length > 0 + ? reasons + .map((reason) => reason.message || reason.code || String(reason)) + .join("; ") + : decision.allow + ? "APort passport authorization passed" + : "APort passport authorization denied"; + + return { + ...paymentIntent, + policyTrace: [ + ...(paymentIntent.policyTrace || []), + { + verifier: "aport", + ruleId: policyId, + outcome: decision.allow ? "allow" : "deny", + message, + decisionId: decision.decision_id || decision.decisionId, + assuranceLevel: decision.assurance_level || decision.assuranceLevel, + evaluatedAt: timestamp.toISOString(), + }, + ], + }; +} + +function normalizePaymentIntent(paymentIntent) { + if (!paymentIntent || typeof paymentIntent !== "object") { + throw new AP2BridgeError("paymentIntent must be an object"); + } + + if (!paymentIntent.amount || typeof paymentIntent.amount !== "object") { + throw new AP2BridgeError("paymentIntent.amount is required"); + } + + if (!paymentIntent.amount.value || !paymentIntent.amount.currency) { + throw new AP2BridgeError( + "paymentIntent.amount.value and paymentIntent.amount.currency are required" + ); + } + + if ( + !paymentIntent.participants || + !paymentIntent.participants.buyer || + !paymentIntent.participants.seller + ) { + throw new AP2BridgeError( + "paymentIntent.participants.buyer and paymentIntent.participants.seller are required" + ); + } + + return { + status: "pending", + lineItems: [], + evidence: [], + ...paymentIntent, + }; +} + +function resolveAgentId(paymentIntent, overrideAgentId) { + const agentId = + overrideAgentId || + paymentIntent.agentId || + paymentIntent.agent_id || + paymentIntent.participants.buyer; + + if (!agentId) { + throw new AP2BridgeError( + "APort agent id is required. Pass options.agentId or include paymentIntent.agentId." + ); + } + + return agentId; +} + +function summarizeDecision(decision) { + return { + allow: Boolean(decision.allow), + decisionId: decision.decision_id || decision.decisionId, + assuranceLevel: decision.assurance_level || decision.assuranceLevel, + reasons: decision.reasons || [], + }; +} + +function toMinorUnits(value) { + const numberValue = Number(value); + + if (!Number.isFinite(numberValue)) { + return undefined; + } + + return Math.round(numberValue * 100); +} + +function trimTrailingSlash(value) { + return value.replace(/\/+$/, ""); +} + +module.exports = { + AP2Client, + APortAP2Bridge, + AP2BridgeError, + APortAuthorizationError, + appendAPortPolicyTrace, + buildAPortContext, +}; diff --git a/examples/protocol-bridges/ap2/package.json b/examples/protocol-bridges/ap2/package.json new file mode 100644 index 0000000..5a0a354 --- /dev/null +++ b/examples/protocol-bridges/ap2/package.json @@ -0,0 +1,28 @@ +{ + "name": "aport-ap2-bridge-example", + "version": "1.0.0", + "description": "APort passport authorization bridge for AP2 payment intents", + "main": "index.js", + "scripts": { + "test": "jest", + "example": "node examples/mock-payment-flow.js" + }, + "keywords": [ + "aport", + "ap2", + "agent-payments", + "payment-authorization", + "passport" + ], + "author": "APort Community", + "license": "MIT", + "dependencies": { + "@aporthq/sdk-node": "^0.1.3" + }, + "devDependencies": { + "jest": "^29.7.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/examples/protocol-bridges/ap2/test/ap2-bridge.test.js b/examples/protocol-bridges/ap2/test/ap2-bridge.test.js new file mode 100644 index 0000000..cb2112f --- /dev/null +++ b/examples/protocol-bridges/ap2/test/ap2-bridge.test.js @@ -0,0 +1,289 @@ +const { + AP2Client, + APortAP2Bridge, + AP2BridgeError, + APortAuthorizationError, + appendAPortPolicyTrace, + buildAPortContext, +} = require("../index"); + +const sampleIntent = { + id: "ap2_pi_123", + amount: { + value: "149.99", + currency: "USDC", + }, + participants: { + buyer: "did:ap2:buyer-bot", + seller: "did:ap2:merchant-agent", + }, + terms: { + settlementRail: "x402", + captureType: "escrow_release", + releaseCondition: "shipment-confirmed", + disputeWindow: "P5D", + }, + lineItems: [ + { + sku: "RUN-SHOE-01", + quantity: 1, + unitPrice: "149.99", + }, + ], + mandates: { + intentMandateId: "mandate_intent_123", + cartMandateId: "mandate_cart_123", + }, +}; + +describe("APort AP2 bridge", () => { + it("builds APort verification context from an AP2 payment intent", () => { + const context = buildAPortContext(sampleIntent, { tenantId: "merchant-1" }); + + expect(context.tenantId).toBe("merchant-1"); + expect(context.ap2).toMatchObject({ + intentId: "ap2_pi_123", + amount: "149.99", + amountMinor: 14999, + currency: "USDC", + buyerDid: "did:ap2:buyer-bot", + sellerDid: "did:ap2:merchant-agent", + settlementRail: "x402", + captureType: "escrow_release", + releaseCondition: "shipment-confirmed", + }); + expect(context.ap2.lineItems).toHaveLength(1); + expect(context.ap2.mandates.intentMandateId).toBe("mandate_intent_123"); + }); + + it("adds AP2 policyTrace evidence for an allowed APort decision", () => { + const tracedIntent = appendAPortPolicyTrace( + sampleIntent, + { + allow: true, + decision_id: "dec_123", + assurance_level: "domain", + }, + "finance.payment.authorization.v1", + new Date("2026-05-11T12:00:00.000Z") + ); + + expect(tracedIntent.policyTrace).toEqual([ + { + verifier: "aport", + ruleId: "finance.payment.authorization.v1", + outcome: "allow", + message: "APort passport authorization passed", + decisionId: "dec_123", + assuranceLevel: "domain", + evaluatedAt: "2026-05-11T12:00:00.000Z", + }, + ]); + }); + + it("authorizes and creates an AP2 payment intent when APort allows", async () => { + const aportClient = { + verifyPolicy: jest.fn().mockResolvedValue({ + allow: true, + decision_id: "dec_allow", + assurance_level: "github", + }), + }; + const ap2Client = { + createPaymentIntent: jest.fn().mockResolvedValue({ + id: "ap2_pi_123", + status: "authorized", + }), + }; + const bridge = new APortAP2Bridge({ + aportClient, + ap2Client, + now: () => new Date("2026-05-11T12:00:00.000Z"), + }); + + const result = await bridge.createAuthorizedPaymentIntent(sampleIntent, { + agentId: "agt_inst_buyer", + policyId: "finance.payment.authorization.v1", + context: { + merchantRiskTier: "low", + }, + }); + + expect(aportClient.verifyPolicy).toHaveBeenCalledWith( + "agt_inst_buyer", + "finance.payment.authorization.v1", + expect.objectContaining({ + merchantRiskTier: "low", + ap2: expect.objectContaining({ + intentId: "ap2_pi_123", + amountMinor: 14999, + }), + }), + "ap2_pi_123" + ); + expect(ap2Client.createPaymentIntent).toHaveBeenCalledWith( + expect.objectContaining({ + id: "ap2_pi_123", + policyTrace: [ + expect.objectContaining({ + verifier: "aport", + outcome: "allow", + decisionId: "dec_allow", + }), + ], + }) + ); + expect(result).toMatchObject({ + authorized: true, + status: "authorized", + ap2PaymentIntent: { + id: "ap2_pi_123", + }, + }); + }); + + it("does not create an AP2 payment intent when APort denies", async () => { + const aportClient = { + verifyPolicy: jest.fn().mockResolvedValue({ + allow: false, + decision_id: "dec_deny", + reasons: [{ code: "limit_exceeded", message: "Payment cap exceeded" }], + }), + }; + const ap2Client = { + createPaymentIntent: jest.fn(), + }; + const bridge = new APortAP2Bridge({ + aportClient, + ap2Client, + now: () => new Date("2026-05-11T12:00:00.000Z"), + }); + + const result = await bridge.createAuthorizedPaymentIntent(sampleIntent, { + agentId: "agt_inst_buyer", + }); + + expect(ap2Client.createPaymentIntent).not.toHaveBeenCalled(); + expect(result.authorized).toBe(false); + expect(result.status).toBe("denied"); + expect(result.paymentIntent.policyTrace[0]).toMatchObject({ + outcome: "deny", + message: "Payment cap exceeded", + }); + }); + + it("can throw on denied APort authorization", async () => { + const bridge = new APortAP2Bridge({ + aportClient: { + verifyPolicy: jest.fn().mockResolvedValue({ allow: false }), + }, + ap2Client: { + createPaymentIntent: jest.fn(), + }, + }); + + await expect( + bridge.createAuthorizedPaymentIntent(sampleIntent, { + agentId: "agt_inst_buyer", + throwOnDeny: true, + }) + ).rejects.toBeInstanceOf(APortAuthorizationError); + }); + + it("confirms the AP2 payment intent with a summarized APort decision", async () => { + const bridge = new APortAP2Bridge({ + aportClient: { + verifyPolicy: jest.fn().mockResolvedValue({ + allow: true, + decision_id: "dec_confirm", + assurance_level: "domain", + }), + }, + ap2Client: { + createPaymentIntent: jest.fn().mockResolvedValue({ + id: "ap2_pi_123", + status: "authorized", + }), + confirmPaymentIntent: jest.fn().mockResolvedValue({ + id: "ap2_pi_123", + status: "confirmed", + }), + }, + }); + + const result = await bridge.confirmAuthorizedPaymentIntent(sampleIntent, { + agentId: "agt_inst_buyer", + sellerAcceptance: { acceptedBy: "merchant-agent" }, + }); + + expect(result.confirmation.status).toBe("confirmed"); + expect(bridge.ap2Client.confirmPaymentIntent).toHaveBeenCalledWith( + "ap2_pi_123", + { + sellerAcceptance: { acceptedBy: "merchant-agent" }, + aportDecision: { + allow: true, + decisionId: "dec_confirm", + assuranceLevel: "domain", + reasons: [], + }, + } + ); + }); + + it("validates required AP2 fields before calling APort", async () => { + const bridge = new APortAP2Bridge({ + aportClient: { verifyPolicy: jest.fn() }, + ap2Client: { createPaymentIntent: jest.fn() }, + }); + + await expect( + bridge.createAuthorizedPaymentIntent({ amount: { value: "10" } }) + ).rejects.toThrow("paymentIntent.amount.value and paymentIntent.amount.currency"); + + expect(bridge.aportClient.verifyPolicy).not.toHaveBeenCalled(); + }); +}); + +describe("AP2Client", () => { + it("posts payment intents to the configured AP2 base URL", async () => { + const fetchImpl = jest.fn().mockResolvedValue({ + ok: true, + text: async () => JSON.stringify({ id: "ap2_pi_123", status: "pending" }), + }); + const client = new AP2Client({ + baseUrl: "https://merchant.example/v1/ap2/", + token: "ap2-token", + fetchImpl, + }); + + const result = await client.createPaymentIntent(sampleIntent); + + expect(result.status).toBe("pending"); + expect(fetchImpl).toHaveBeenCalledWith( + "https://merchant.example/v1/ap2/payment-intents", + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer ap2-token", + }, + body: JSON.stringify(sampleIntent), + }) + ); + }); + + it("raises AP2BridgeError for AP2 API failures", async () => { + const client = new AP2Client({ + fetchImpl: jest.fn().mockResolvedValue({ + ok: false, + status: 409, + text: async () => JSON.stringify({ error: "expired mandate" }), + }), + }); + + await expect(client.createPaymentIntent(sampleIntent)).rejects.toBeInstanceOf( + AP2BridgeError + ); + }); +});