From a6613f9d8630e88b4eb12f81beedb418b5b1091f Mon Sep 17 00:00:00 2001 From: Pascal Kaufmann Date: Fri, 12 Jun 2026 09:50:07 +0200 Subject: [PATCH 1/3] Implement ACP checkout adapter --- packages/api/src/acp/README.md | 57 +++ packages/api/src/acp/auth.ts | 66 ++++ packages/api/src/acp/config.ts | 41 +++ packages/api/src/acp/error.ts | 33 ++ packages/api/src/acp/feed.ts | 118 ++++++ packages/api/src/acp/handler.ts | 347 ++++++++++++++++++ packages/api/src/acp/idempotency.test.ts | 54 +++ packages/api/src/acp/idempotency.ts | 80 ++++ packages/api/src/acp/serializer.ts | 131 +++++++ packages/api/src/acp/webhook.test.ts | 13 + packages/api/src/acp/webhook.ts | 116 ++++++ .../api/src/express/createACPMiddleware.ts | 31 ++ packages/api/src/express/index.ts | 6 + packages/api/src/fastify/acpHandler.ts | 16 + packages/api/src/fastify/index.ts | 17 + .../src/payment/acp-stripe-spt/index.ts | 97 +++++ packages/plugins/src/presets/all.ts | 1 + 17 files changed, 1224 insertions(+) create mode 100644 packages/api/src/acp/README.md create mode 100644 packages/api/src/acp/auth.ts create mode 100644 packages/api/src/acp/config.ts create mode 100644 packages/api/src/acp/error.ts create mode 100644 packages/api/src/acp/feed.ts create mode 100644 packages/api/src/acp/handler.ts create mode 100644 packages/api/src/acp/idempotency.test.ts create mode 100644 packages/api/src/acp/idempotency.ts create mode 100644 packages/api/src/acp/serializer.ts create mode 100644 packages/api/src/acp/webhook.test.ts create mode 100644 packages/api/src/acp/webhook.ts create mode 100644 packages/api/src/express/createACPMiddleware.ts create mode 100644 packages/api/src/fastify/acpHandler.ts create mode 100644 packages/plugins/src/payment/acp-stripe-spt/index.ts diff --git a/packages/api/src/acp/README.md b/packages/api/src/acp/README.md new file mode 100644 index 000000000..63884c1be --- /dev/null +++ b/packages/api/src/acp/README.md @@ -0,0 +1,57 @@ +# Agentic Commerce Protocol + +The ACP transport is mounted at `/acp` by both the Express and Fastify API +adapters. It implements the `2026-04-17` checkout-session contract: + +- `POST /acp/checkout_sessions` +- `POST /acp/checkout_sessions/:id` +- `GET /acp/checkout_sessions/:id` +- `POST /acp/checkout_sessions/:id/complete` +- `POST /acp/checkout_sessions/:id/cancel` +- `GET /acp/feed.jsonl` +- `GET /.well-known/acp.json` + +Required checkout configuration: + +```text +UNCHAINED_ACP_API_KEY= +UNCHAINED_ACP_PAYMENT_PROVIDER_ID= +ACP_CHECKOUT_CONTINUE_URL=https://shop.example.com/orders +``` + +The configured payment provider must use adapter key +`shop.unchained.payment.acp-stripe-spt`. Its optional configuration keys are +`secret`, `stripeVersion`, and `description`; `STRIPE_SECRET` is used when the +provider has no `secret`. + +Product-feed configuration: + +```text +ACP_SELLER_NAME=Example Store +ACP_SELLER_URL=https://shop.example.com +ACP_SELLER_PRIVACY_POLICY=https://shop.example.com/privacy +ACP_SELLER_TOS=https://shop.example.com/terms +ACP_PRODUCT_URL_BASE=https://shop.example.com/products +ACP_TARGET_COUNTRIES=US,CH +``` + +Webhook configuration: + +```text +ACP_WEBHOOK_URL=https://example.openai.com/agentic_checkout/webhooks/order_events +ACP_WEBHOOK_SECRET= +ACP_WEBHOOK_RETRIES=5 +ACP_WEBHOOK_EVENT_TENSE=past +``` + +`OPENAI_WEBHOOK_URL` and `OPENAI_WEBHOOK_SECRET` are accepted as aliases. +`ACP_WEBHOOK_EVENT_TENSE=present` emits the canonical repository values +`order_create` and `order_update`; the default emits the OpenAI certification +values `order_created` and `order_updated`. + +Every request requires `Authorization: Bearer`, `API-Version: 2026-04-17`, and +every POST also requires `Idempotency-Key`. + +The current idempotency cache is process-local with a 24-hour TTL. It provides +the ACP wire behavior for a single process, but production multi-instance +deployments need a shared persistent implementation behind the same helper. diff --git a/packages/api/src/acp/auth.ts b/packages/api/src/acp/auth.ts new file mode 100644 index 000000000..dbccafee6 --- /dev/null +++ b/packages/api/src/acp/auth.ts @@ -0,0 +1,66 @@ +import { timingSafeStringEqual } from '@unchainedshop/utils'; +import { ACP_API_VERSION, acpConfig } from './config.ts'; +import { ACPError } from './error.ts'; + +export type ACPHeaders = Record; + +export const getHeader = (headers: ACPHeaders, name: string) => { + const value = headers[name.toLowerCase()]; + return Array.isArray(value) ? value[0] : value; +}; + +export const verifyACPRequest = async ({ headers, method }: { headers: ACPHeaders; method: string }) => { + if (!acpConfig.apiKey) { + throw new ACPError( + 503, + 'api_error', + 'acp_not_configured', + 'UNCHAINED_ACP_API_KEY is not configured', + ); + } + + const authorization = getHeader(headers, 'authorization'); + const [scheme, token] = authorization?.split(' ') || []; + if ( + scheme?.toLowerCase() !== 'bearer' || + !token || + !(await timingSafeStringEqual(token, acpConfig.apiKey)) + ) { + throw new ACPError( + 401, + 'invalid_api_key_error', + 'invalid_api_key', + 'A valid Bearer token is required', + ); + } + + const apiVersion = getHeader(headers, 'api-version'); + if (!apiVersion) { + throw new ACPError( + 400, + 'invalid_request', + 'missing_api_version', + `API-Version is required. Supported versions: ${ACP_API_VERSION}`, + '$.headers.API-Version', + ); + } + if (apiVersion !== ACP_API_VERSION) { + throw new ACPError( + 400, + 'invalid_request', + 'unsupported_api_version', + `Unsupported API-Version. Supported versions: ${ACP_API_VERSION}`, + '$.headers.API-Version', + ); + } + + if (method === 'POST' && !getHeader(headers, 'idempotency-key')) { + throw new ACPError( + 400, + 'invalid_request', + 'idempotency_key_required', + 'Idempotency-Key is required for POST requests', + '$.headers.Idempotency-Key', + ); + } +}; diff --git a/packages/api/src/acp/config.ts b/packages/api/src/acp/config.ts new file mode 100644 index 000000000..72967b9d3 --- /dev/null +++ b/packages/api/src/acp/config.ts @@ -0,0 +1,41 @@ +export const ACP_API_VERSION = '2026-04-17'; + +const { + ACP_API_PATH = '/acp', + UNCHAINED_ACP_API_KEY, + UNCHAINED_ACP_PAYMENT_PROVIDER_ID, + ACP_CHECKOUT_CONTINUE_URL, + ACP_IDEMPOTENCY_CONFLICT_STATUS = '422', + ACP_SELLER_NAME, + ACP_SELLER_URL, + ACP_SELLER_PRIVACY_POLICY, + ACP_SELLER_TOS, + ACP_PRODUCT_URL_BASE, + ACP_TARGET_COUNTRIES, + ACP_WEBHOOK_URL, + OPENAI_WEBHOOK_URL, + ACP_WEBHOOK_SECRET, + OPENAI_WEBHOOK_SECRET, + ACP_WEBHOOK_RETRIES = '5', + ACP_WEBHOOK_EVENT_TENSE = 'past', +} = process.env; + +export const acpConfig = { + apiPath: ACP_API_PATH, + apiKey: UNCHAINED_ACP_API_KEY, + paymentProviderId: UNCHAINED_ACP_PAYMENT_PROVIDER_ID, + continueUrl: ACP_CHECKOUT_CONTINUE_URL, + sellerName: ACP_SELLER_NAME, + sellerUrl: ACP_SELLER_URL, + sellerPrivacyPolicy: ACP_SELLER_PRIVACY_POLICY, + sellerTerms: ACP_SELLER_TOS, + productUrlBase: ACP_PRODUCT_URL_BASE, + targetCountries: ACP_TARGET_COUNTRIES?.split(',') + .map((country) => country.trim().toUpperCase()) + .filter(Boolean), + webhookUrl: ACP_WEBHOOK_URL || OPENAI_WEBHOOK_URL, + webhookSecret: ACP_WEBHOOK_SECRET || OPENAI_WEBHOOK_SECRET, + webhookRetries: Math.max(0, Number.parseInt(ACP_WEBHOOK_RETRIES, 10) || 0), + webhookEventTense: ACP_WEBHOOK_EVENT_TENSE === 'present' ? 'present' : 'past', + idempotencyConflictStatus: ACP_IDEMPOTENCY_CONFLICT_STATUS === '409' ? 409 : 422, +} as const; diff --git a/packages/api/src/acp/error.ts b/packages/api/src/acp/error.ts new file mode 100644 index 000000000..9994196a1 --- /dev/null +++ b/packages/api/src/acp/error.ts @@ -0,0 +1,33 @@ +export type ACPErrorType = + | 'invalid_request' + | 'authentication_error' + | 'permission_error' + | 'not_found_error' + | 'conflict_error' + | 'invalid_api_key_error' + | 'api_error' + | 'api_connection_error'; + +export class ACPError extends Error { + status: number; + type: ACPErrorType; + code: string; + param?: string; + + constructor(status: number, type: ACPErrorType, code: string, message: string, param?: string) { + super(message); + this.status = status; + this.type = type; + this.code = code; + this.param = param; + } + + toJSON() { + return { + type: this.type, + code: this.code, + message: this.message, + ...(this.param ? { param: this.param } : {}), + }; + } +} diff --git a/packages/api/src/acp/feed.ts b/packages/api/src/acp/feed.ts new file mode 100644 index 000000000..14d2bae56 --- /dev/null +++ b/packages/api/src/acp/feed.ts @@ -0,0 +1,118 @@ +import { ProductStatus } from '@unchainedshop/core-products'; +import type { Context } from '../context.ts'; +import normalizeMediaUrl from '../mcp/utils/normalizeMediaUrl.ts'; +import { acpConfig } from './config.ts'; +import { ACPError } from './error.ts'; + +const formatPrice = (amount: number, currencyCode: string, decimals = 2) => + `${(amount / 10 ** decimals).toFixed(decimals)} ${currencyCode.toUpperCase()}`; + +export const buildACPProductFeed = async (context: Context) => { + if (!acpConfig.sellerName || !acpConfig.sellerUrl || !acpConfig.productUrlBase) { + throw new ACPError( + 503, + 'api_error', + 'feed_not_configured', + 'ACP_SELLER_NAME, ACP_SELLER_URL, and ACP_PRODUCT_URL_BASE are required', + ); + } + + const targetCountries = acpConfig.targetCountries?.length + ? acpConfig.targetCountries + : [context.countryCode.toUpperCase()]; + const rows: Record[] = []; + const limit = 250; + + for (let offset = 0; ; offset += limit) { + const products = await context.modules.products.findProducts( + { includeDrafts: false, limit, offset }, + {}, + ); + if (!products.length) break; + + for (const product of products) { + const text = await context.modules.products.texts.findLocalizedText({ + productId: product._id, + locale: context.locale, + }); + if (!text?.title || !text.description) continue; + + const pricing = await context.services.products.simulateProductPricing({ + product, + countryCode: context.countryCode, + currencyCode: context.currencyCode, + quantity: 1, + discounts: [], + }); + const unitPrice = pricing?.unitPrice({ useNetPrice: false }); + if (!unitPrice) continue; + + const currency = await context.modules.currencies.findCurrency({ + isoCode: unitPrice.currencyCode, + }); + const medias = await context.modules.products.media.findProductMedias({ + productId: product._id, + }); + const normalizedMedia = await normalizeMediaUrl(medias, context); + const imageUrl = (normalizedMedia[0] as any)?.file?.url; + if (!imageUrl) continue; + + const inventory = await context.services.products.simulateProductInventory({ product }); + const knownStock = inventory + .map(({ quantity }) => quantity) + .filter((quantity): quantity is number => typeof quantity === 'number'); + const availability = knownStock.length + ? knownStock.some((quantity) => quantity > 0) + ? 'in_stock' + : 'out_of_stock' + : 'unknown'; + const checkoutEligible = Boolean( + acpConfig.paymentProviderId && + acpConfig.sellerPrivacyPolicy && + acpConfig.sellerTerms && + availability !== 'out_of_stock', + ); + const slug = text.slug || product.slugs[0] || product._id; + const productUrl = `${acpConfig.productUrlBase.replace(/\/$/, '')}/${slug}`; + + rows.push({ + item_id: product._id, + title: text.title, + description: text.description, + url: productUrl, + image_url: imageUrl, + ...(normalizedMedia.length > 1 + ? { + additional_image_urls: normalizedMedia + .slice(1) + .map((media) => (media as any).file?.url) + .filter(Boolean) + .join(','), + } + : {}), + brand: text.brand || text.vendor || acpConfig.sellerName, + price: formatPrice(unitPrice.amount, unitPrice.currencyCode, currency?.decimals ?? 2), + availability, + is_eligible_search: product.status === ProductStatus.ACTIVE, + is_eligible_checkout: checkoutEligible, + seller_name: acpConfig.sellerName, + seller_url: acpConfig.sellerUrl, + ...(checkoutEligible + ? { + seller_privacy_policy: acpConfig.sellerPrivacyPolicy, + seller_tos: acpConfig.sellerTerms, + } + : {}), + target_countries: targetCountries, + store_country: context.countryCode.toUpperCase(), + group_id: product._id, + listing_has_variations: Boolean(product.proxy?.assignments?.length), + mpn: product.warehousing?.sku, + }); + } + + if (products.length < limit) break; + } + + return rows.map((row) => JSON.stringify(row)).join('\n') + (rows.length ? '\n' : ''); +}; diff --git a/packages/api/src/acp/handler.ts b/packages/api/src/acp/handler.ts new file mode 100644 index 000000000..5c936a2a0 --- /dev/null +++ b/packages/api/src/acp/handler.ts @@ -0,0 +1,347 @@ +import type { User } from '@unchainedshop/core-users'; +import type { Context } from '../context.ts'; +import { getHeader, type ACPHeaders, verifyACPRequest } from './auth.ts'; +import { acpConfig } from './config.ts'; +import { ACPError } from './error.ts'; +import { buildACPProductFeed } from './feed.ts'; +import { withIdempotency } from './idempotency.ts'; +import { serializeCheckoutSession } from './serializer.ts'; + +export interface ACPRequest { + method: string; + path: string; + headers: ACPHeaders; + body?: any; + context: Context; +} + +export interface ACPResponse { + status: number; + body: unknown; + headers?: Record; + contentType?: string; +} + +const createGuest = async (context: Context) => { + const guestname = `guest-${crypto.randomUUID()}`; + const guestUserId = await context.modules.users.createUser( + { + email: `${guestname}@unchained.local`, + guest: true, + password: null, + initialPassword: true, + }, + { skipMessaging: true }, + ); + return context.modules.users.updateHeartbeat(guestUserId, { + remoteAddress: context.remoteAddress, + remotePort: context.remotePort, + userAgent: context.getHeader('user-agent'), + locale: context.locale?.baseName, + countryCode: context.countryCode, + }) as Promise; +}; + +const mapBuyer = (buyer: any) => + buyer + ? { + emailAddress: buyer.email, + telNumber: buyer.phone_number, + } + : undefined; + +const mapAddress = (address: any) => + address + ? { + firstName: address.name, + lastName: '', + company: address.company, + addressLine: address.line_one, + addressLine2: address.line_two, + postalCode: address.postal_code, + city: address.city, + regionCode: address.state, + countryCode: address.country, + } + : undefined; + +const extractItems = (lineItems: any[] = []) => + lineItems.map((item) => ({ + productId: item.id, + quantity: Number(item.quantity ?? 1), + })); + +const loadOrder = async (context: Context, orderId: string) => { + const order = await context.modules.orders.findOrder({ orderId }); + if (!order) { + throw new ACPError(404, 'not_found_error', 'checkout_session_not_found', 'Session not found'); + } + return order; +}; + +const updateOrder = async (context: Context, order: any, body: any) => { + if (body.line_items) { + await context.modules.orders.positions.removePositions({ orderId: order._id }); + await context.services.orders.addMultipleCartProducts({ + orderId: order._id, + items: extractItems(body.line_items), + context: { + localeContext: context.locale, + userId: order.userId, + countryCode: order.countryCode, + }, + }); + } + + const contact = mapBuyer(body.buyer); + const address = mapAddress(body.fulfillment_details?.address); + if (contact || address) { + await context.modules.orders.updateCartFields(order._id, { + ...(contact ? { contact } : {}), + ...(address ? { billingAddress: address } : {}), + }); + } + + const selected = body.selected_fulfillment_options?.[0]; + if (selected?.option_id) { + order = (await context.modules.orders.setDeliveryProvider(order._id, selected.option_id)) || order; + } + if (address && order.deliveryId) { + await context.modules.orders.deliveries.updateContext(order.deliveryId, { address }); + } + + return context.services.orders.updateCalculation(order._id); +}; + +const createSession = async (context: Context, body: any) => { + if (!Array.isArray(body.line_items) || body.line_items.length === 0) { + throw new ACPError( + 400, + 'invalid_request', + 'invalid_request', + 'line_items must contain at least one item', + '$.line_items', + ); + } + if (!body.currency || typeof body.currency !== 'string') { + throw new ACPError(400, 'invalid_request', 'invalid_request', 'currency is required', '$.currency'); + } + if (!body.capabilities || typeof body.capabilities !== 'object') { + throw new ACPError( + 400, + 'invalid_request', + 'invalid_request', + 'capabilities is required', + '$.capabilities', + ); + } + if (!acpConfig.paymentProviderId) { + throw new ACPError( + 503, + 'api_error', + 'payment_provider_not_configured', + 'UNCHAINED_ACP_PAYMENT_PROVIDER_ID is required', + ); + } + const paymentProvider = await context.modules.payment.paymentProviders.findProvider({ + paymentProviderId: acpConfig.paymentProviderId, + }); + if (paymentProvider?.adapterKey !== 'shop.unchained.payment.acp-stripe-spt') { + throw new ACPError( + 503, + 'api_error', + 'payment_provider_not_configured', + 'UNCHAINED_ACP_PAYMENT_PROVIDER_ID must use shop.unchained.payment.acp-stripe-spt', + ); + } + + const user = await createGuest(context); + let order = await context.services.orders.nextUserCart({ + user, + countryCode: context.countryCode, + forceCartCreation: true, + }); + if (!order) throw new ACPError(500, 'api_error', 'cart_creation_failed', 'Could not create cart'); + if (order.currencyCode.toLowerCase() !== body.currency.toLowerCase()) { + throw new ACPError( + 400, + 'invalid_request', + 'unsupported_currency', + `Currency ${body.currency} is not available for this checkout context`, + '$.currency', + ); + } + order = + (await context.modules.orders.updateContext(order._id, { + acp: { + apiVersion: '2026-04-17', + createdAt: new Date().toISOString(), + }, + })) || order; + + order = + (await context.modules.orders.setPaymentProvider(order._id, acpConfig.paymentProviderId)) || order; + + order = await updateOrder(context, order, body); + if (!order) throw new ACPError(500, 'api_error', 'cart_update_failed', 'Could not update cart'); + return { status: 201, body: await serializeCheckoutSession(order, context) }; +}; + +const extractPaymentData = (paymentData: any) => { + const token = paymentData?.instrument?.credential?.token || paymentData?.token; + const provider = paymentData?.handler_id || paymentData?.provider || 'stripe_spt'; + if (!token) { + throw new ACPError( + 400, + 'invalid_request', + 'invalid_payment_data', + 'A delegated payment token is required', + '$.payment_data', + ); + } + if (provider !== 'stripe_spt' && provider !== 'stripe') { + throw new ACPError( + 400, + 'invalid_request', + 'unsupported_payment_handler', + `Unsupported payment handler: ${provider}`, + '$.payment_data.handler_id', + ); + } + return { acpToken: token, acpHandlerId: provider }; +}; + +const route = async ({ method, path, body = {}, context }: ACPRequest): Promise => { + const segments = path.split('/').filter(Boolean); + if (method === 'GET' && segments.length === 1 && segments[0] === 'feed.jsonl') { + return { + status: 200, + body: await buildACPProductFeed(context), + contentType: 'application/x-ndjson; charset=utf-8', + }; + } + if (segments[0] !== 'checkout_sessions') { + throw new ACPError(404, 'not_found_error', 'not_found', 'ACP endpoint not found'); + } + + if (method === 'POST' && segments.length === 1) return createSession(context, body); + + const order = await loadOrder(context, segments[1]); + + if (method === 'GET' && segments.length === 2) { + return { status: 200, body: await serializeCheckoutSession(order, context) }; + } + + if (method === 'POST' && segments.length === 2) { + if (order.status !== null || order.context?.acp?.canceled) { + throw new ACPError(405, 'invalid_request', 'session_terminal', 'Session is terminal'); + } + const updated = await updateOrder(context, order, body); + return { status: 200, body: await serializeCheckoutSession(updated!, context) }; + } + + if (method === 'POST' && segments[2] === 'complete') { + if (order.status !== null || order.context?.acp?.canceled) { + throw new ACPError(405, 'invalid_request', 'session_terminal', 'Session is terminal'); + } + if (!body.buyer?.email) { + throw new ACPError( + 400, + 'invalid_request', + 'invalid_request', + 'buyer.email is required', + '$.buyer.email', + ); + } + const buyer = mapBuyer(body.buyer); + const billingAddress = mapAddress(body.payment_data?.billing_address); + if (buyer || billingAddress) { + await context.modules.orders.updateCartFields(order._id, { + ...(buyer ? { contact: buyer } : {}), + ...(billingAddress ? { billingAddress } : {}), + }); + await context.services.orders.updateCalculation(order._id); + } + const completed = await context.services.orders.checkoutOrder(order._id, { + paymentContext: extractPaymentData(body.payment_data), + }); + if (!completed) { + throw new ACPError(500, 'api_error', 'checkout_failed', 'Could not complete checkout'); + } + const session = await serializeCheckoutSession(completed, context); + const permalinkBase = acpConfig.continueUrl?.replace(/\/$/, ''); + return { + status: 200, + body: { + ...session, + order: { + id: completed._id, + checkout_session_id: completed._id, + permalink_url: permalinkBase + ? `${permalinkBase}/${completed.orderNumber || completed._id}` + : `https://example.invalid/orders/${completed.orderNumber || completed._id}`, + status: completed.status?.toLowerCase(), + }, + }, + }; + } + + if (method === 'POST' && segments[2] === 'cancel') { + if (order.status !== null || order.context?.acp?.canceled) { + throw new ACPError(405, 'invalid_request', 'session_terminal', 'Session is terminal'); + } + const canceled = await context.modules.orders.updateCartFields(order._id, { + meta: { + acp: { ...(order.context?.acp || {}), canceled: true }, + }, + }); + return { status: 200, body: await serializeCheckoutSession(canceled!, context) }; + } + + throw new ACPError(404, 'not_found_error', 'not_found', 'ACP endpoint not found'); +}; + +export const handleACPRequest = async (request: ACPRequest): Promise => { + const requestId = getHeader(request.headers, 'request-id') || crypto.randomUUID(); + try { + await verifyACPRequest(request); + const idempotencyKey = getHeader(request.headers, 'idempotency-key'); + const response = + request.method === 'POST' && idempotencyKey + ? await withIdempotency( + `${getHeader(request.headers, 'authorization')}:${request.path}`, + idempotencyKey, + request.body, + () => route(request), + ) + : { ...(await route(request)), replayed: false }; + + return { + status: response.status, + body: response.body, + headers: { + 'Request-Id': requestId, + ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}), + ...(response.replayed ? { 'Idempotent-Replayed': 'true' } : {}), + }, + contentType: response.contentType, + }; + } catch (error) { + if (error instanceof ACPError) { + return { + status: error.status, + body: error.toJSON(), + headers: { 'Request-Id': requestId }, + }; + } + return { + status: 500, + body: { + type: 'api_error', + code: 'internal_error', + message: error instanceof Error ? error.message : 'Internal error', + }, + headers: { 'Request-Id': requestId }, + }; + } +}; diff --git a/packages/api/src/acp/idempotency.test.ts b/packages/api/src/acp/idempotency.test.ts new file mode 100644 index 000000000..eb4b2d537 --- /dev/null +++ b/packages/api/src/acp/idempotency.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert'; +import test from 'node:test'; +import { ACPError } from './error.ts'; +import { withIdempotency } from './idempotency.ts'; + +test('replays a response for canonically equal request bodies', async () => { + const key = crypto.randomUUID(); + let calls = 0; + const execute = async () => { + calls += 1; + return { status: 201, body: { id: 'session-1' } }; + }; + + const first = await withIdempotency('create', key, { a: 1, b: { c: 2 } }, execute); + const replay = await withIdempotency('create', key, { b: { c: 2 }, a: 1 }, execute); + + assert.strictEqual(first.replayed, false); + assert.strictEqual(replay.replayed, true); + assert.deepStrictEqual(replay.body, { id: 'session-1' }); + assert.strictEqual(calls, 1); +}); + +test('rejects reuse with a different request body', async () => { + const key = crypto.randomUUID(); + await withIdempotency('update', key, { quantity: 1 }, async () => ({ + status: 200, + body: {}, + })); + + await assert.rejects( + withIdempotency('update', key, { quantity: 2 }, async () => ({ + status: 200, + body: {}, + })), + (error: ACPError) => error.code === 'idempotency_conflict', + ); +}); + +test('rejects a duplicate request while the first request is in flight', async () => { + const key = crypto.randomUUID(); + let resolve; + const pending = new Promise<{ status: number; body: unknown }>((done) => { + resolve = done; + }); + const first = withIdempotency('complete', key, {}, () => pending); + + await assert.rejects( + withIdempotency('complete', key, {}, async () => ({ status: 200, body: {} })), + (error: ACPError) => error.code === 'idempotency_in_flight', + ); + + resolve({ status: 200, body: {} }); + await first; +}); diff --git a/packages/api/src/acp/idempotency.ts b/packages/api/src/acp/idempotency.ts new file mode 100644 index 000000000..05a70f6c6 --- /dev/null +++ b/packages/api/src/acp/idempotency.ts @@ -0,0 +1,80 @@ +import { createHash } from 'node:crypto'; +import { acpConfig } from './config.ts'; +import { ACPError } from './error.ts'; + +interface StoredResponse { + requestHash: string; + status: number; + body: unknown; + contentType?: string; + expiresAt: number; +} + +const entries = new Map>(); +const TTL = 24 * 60 * 60 * 1000; + +const canonicalize = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(canonicalize); + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, canonicalize(entry)]), + ); + } + return value; +}; + +const requestHash = (body: unknown) => + createHash('sha256') + .update(JSON.stringify(canonicalize(body))) + .digest('hex'); + +export const withIdempotency = async ( + scope: string, + key: string, + body: unknown, + execute: () => Promise<{ status: number; body: unknown; contentType?: string }>, +) => { + const cacheKey = `${scope}:${key}`; + const hash = requestHash(body); + const existing = entries.get(cacheKey); + + if (existing instanceof Promise) { + throw new ACPError( + 409, + 'conflict_error', + 'idempotency_in_flight', + 'A request with this Idempotency-Key is still processing', + ); + } + + if (existing && existing.expiresAt > Date.now()) { + if (existing.requestHash !== hash) { + throw new ACPError( + acpConfig.idempotencyConflictStatus, + 'conflict_error', + 'idempotency_conflict', + 'The Idempotency-Key was already used with a different request body', + ); + } + return { ...existing, replayed: true }; + } + + const pending = execute().then((response) => ({ + ...response, + requestHash: hash, + expiresAt: Date.now() + TTL, + })); + entries.set(cacheKey, pending); + + try { + const stored = await pending; + if (stored.status < 500) entries.set(cacheKey, stored); + else entries.delete(cacheKey); + return { ...stored, replayed: false }; + } catch (error) { + entries.delete(cacheKey); + throw error; + } +}; diff --git a/packages/api/src/acp/serializer.ts b/packages/api/src/acp/serializer.ts new file mode 100644 index 000000000..e66a2fb46 --- /dev/null +++ b/packages/api/src/acp/serializer.ts @@ -0,0 +1,131 @@ +import { OrderPricingRowCategory, OrderPricingSheet } from '@unchainedshop/core'; +import { OrderStatus, type Order } from '@unchainedshop/core-orders'; +import type { Context } from '../context.ts'; + +const statusForOrder = async (order: Order, context: Context) => { + if (order.context?.acp?.canceled) return 'canceled'; + if (order.status === OrderStatus.PENDING) return 'complete_in_progress'; + if (order.status === OrderStatus.CONFIRMED || order.status === OrderStatus.FULFILLED) { + return 'completed'; + } + if (order.status === OrderStatus.REJECTED) return 'requires_escalation'; + + const positions = await context.modules.orders.positions.findOrderPositions({ + orderId: order._id, + }); + return order.contact && + order.billingAddress && + order.deliveryId && + order.paymentId && + positions.length > 0 + ? 'ready_for_payment' + : 'not_ready_for_payment'; +}; + +const priceTotal = ( + order: Order, + category?: (typeof OrderPricingRowCategory)[keyof typeof OrderPricingRowCategory], +) => + OrderPricingSheet({ + calculation: order.calculation, + currencyCode: order.currencyCode, + }).total({ category, useNetPrice: false }).amount; + +export const serializeCheckoutSession = async (order: Order, context: Context) => { + const positions = await context.modules.orders.positions.findOrderPositions({ + orderId: order._id, + }); + const status = await statusForOrder(order, context); + const total = priceTotal(order); + const items = priceTotal(order, OrderPricingRowCategory.Items); + const discount = priceTotal(order, OrderPricingRowCategory.Discounts); + const fulfillment = priceTotal(order, OrderPricingRowCategory.Delivery); + const fee = priceTotal(order, OrderPricingRowCategory.Payment); + const tax = OrderPricingSheet({ + calculation: order.calculation, + currencyCode: order.currencyCode, + }).taxSum(); + + const fulfillmentOptions = await context.services.orders.supportedDeliveryProviders({ + order, + }); + const selectedDelivery = order.deliveryId + ? await context.modules.orders.deliveries.findDelivery({ + orderDeliveryId: order.deliveryId, + }) + : null; + + return { + id: order._id, + status, + currency: order.currencyCode.toLowerCase(), + buyer: order.contact?.emailAddress + ? { + email: order.contact.emailAddress, + phone_number: order.contact.telNumber, + } + : undefined, + line_items: positions.map((position) => ({ + id: position._id, + item: { id: position.originalProductId || position.productId }, + product_id: position.originalProductId || position.productId, + variant_id: position.productId, + quantity: position.quantity, + totals: [], + })), + totals: [ + { type: 'items_base_amount', display_text: 'Items', amount: items }, + ...(discount ? [{ type: 'discount', display_text: 'Discount', amount: discount }] : []), + ...(fulfillment + ? [{ type: 'fulfillment', display_text: 'Fulfillment', amount: fulfillment }] + : []), + ...(tax ? [{ type: 'tax', display_text: 'Tax', amount: Math.round(tax) }] : []), + ...(fee ? [{ type: 'fee', display_text: 'Payment fee', amount: fee }] : []), + { type: 'total', display_text: 'Total', amount: total }, + ], + fulfillment_options: fulfillmentOptions.map((provider) => ({ + type: 'shipping', + id: provider._id, + title: provider._id, + totals: [], + })), + selected_fulfillment_options: selectedDelivery + ? [ + { + type: 'shipping', + option_id: selectedDelivery.deliveryProviderId, + item_ids: positions.map(({ _id }) => _id), + }, + ] + : undefined, + messages: [], + links: [], + capabilities: { + payment: { + handlers: [ + { + id: 'stripe_spt', + name: 'dev.acp.tokenized.card', + display_name: 'Card', + version: '2026-01-22', + spec: 'https://github.com/agentic-commerce-protocol/agentic-commerce-protocol/blob/main/rfcs/rfc.payment_handlers.md', + requires_delegate_payment: true, + requires_pci_compliance: false, + psp: 'stripe', + config_schema: + 'https://raw.githubusercontent.com/agentic-commerce-protocol/agentic-commerce-protocol/main/spec/2026-04-17/json-schema/schema.agentic_checkout.json#/$defs/PaymentHandler', + instrument_schemas: [ + 'https://raw.githubusercontent.com/agentic-commerce-protocol/agentic-commerce-protocol/main/spec/2026-04-17/json-schema/schema.agentic_checkout.json#/$defs/PaymentData', + ], + config: {}, + }, + ], + }, + interventions: {}, + extensions: [], + }, + continue_url: context.getHeader('origin') || undefined, + created_at: order.created?.toISOString(), + updated_at: order.updated?.toISOString(), + }; +}; diff --git a/packages/api/src/acp/webhook.test.ts b/packages/api/src/acp/webhook.test.ts new file mode 100644 index 000000000..b8b7c6b5b --- /dev/null +++ b/packages/api/src/acp/webhook.test.ts @@ -0,0 +1,13 @@ +import assert from 'node:assert'; +import test from 'node:test'; +import { createHmac } from 'node:crypto'; +import { signACPWebhookPayload } from './webhook.ts'; + +test('signs the exact webhook body with timestamp dot body', () => { + const body = '{"type":"order_created","data":{"id":"order-1"}}'; + const secret = 'secret'; + const timestamp = 1710000000; + const expected = createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex'); + + assert.strictEqual(signACPWebhookPayload(body, secret, timestamp), `t=${timestamp},v1=${expected}`); +}); diff --git a/packages/api/src/acp/webhook.ts b/packages/api/src/acp/webhook.ts new file mode 100644 index 000000000..0922a8e28 --- /dev/null +++ b/packages/api/src/acp/webhook.ts @@ -0,0 +1,116 @@ +import { createHmac } from 'node:crypto'; +import { OrderPricingSheet } from '@unchainedshop/core'; +import { OrderStatus, type Order } from '@unchainedshop/core-orders'; +import { subscribe, type RawPayloadType } from '@unchainedshop/events'; +import { createLogger } from '@unchainedshop/logger'; +import { acpConfig } from './config.ts'; + +const logger = createLogger('unchained:api:acp-webhook'); +let configured = false; +const recentlyDelivered = new Map(); + +export const signACPWebhookPayload = ( + rawBody: string, + secret: string, + timestamp = Math.floor(Date.now() / 1000), +) => { + const signature = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex'); + return `t=${timestamp},v1=${signature}`; +}; + +const orderStatus = (order: Order) => { + if (order.context?.acp?.canceled || order.status === OrderStatus.REJECTED) return 'canceled'; + if (order.status === OrderStatus.FULFILLED) return 'completed'; + if (order.status === OrderStatus.CONFIRMED) return 'confirmed'; + if (order.status === OrderStatus.PENDING) return 'processing'; + return 'created'; +}; + +const serializeOrder = async (order: Order) => { + const pricing = OrderPricingSheet({ + calculation: order.calculation, + currencyCode: order.currencyCode, + }); + const permalinkBase = acpConfig.continueUrl?.replace(/\/$/, ''); + + return { + type: 'order', + id: order._id, + checkout_session_id: order._id, + order_number: order.orderNumber, + permalink_url: permalinkBase + ? `${permalinkBase}/${order.orderNumber || order._id}` + : `https://example.invalid/orders/${order.orderNumber || order._id}`, + status: orderStatus(order), + totals: [ + { + type: 'total', + display_text: 'Total', + amount: pricing.total({ useNetPrice: false }).amount, + }, + ], + }; +}; + +const eventName = (created: boolean) => { + if (acpConfig.webhookEventTense === 'present') { + return created ? 'order_create' : 'order_update'; + } + return created ? 'order_created' : 'order_updated'; +}; + +const wait = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + +const sendWebhook = async (order: Order, created: boolean) => { + if (!acpConfig.webhookUrl || !acpConfig.webhookSecret) return; + + const rawBody = JSON.stringify({ + type: eventName(created), + data: await serializeOrder(order), + }); + + for (let attempt = 0; attempt <= acpConfig.webhookRetries; attempt += 1) { + try { + const response = await fetch(acpConfig.webhookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Merchant-Signature': signACPWebhookPayload(rawBody, acpConfig.webhookSecret), + 'Request-Id': crypto.randomUUID(), + }, + body: rawBody, + }); + if (response.ok) return; + throw new Error(`Webhook receiver returned HTTP ${response.status}`); + } catch (error) { + if (attempt === acpConfig.webhookRetries) { + logger.error(`ACP webhook delivery failed: ${error}`); + return; + } + await wait(Math.min(1000 * 2 ** attempt, 30000)); + } + } +}; + +export const configureACPWebhooks = () => { + if (configured || !acpConfig.webhookUrl || !acpConfig.webhookSecret) { + return; + } + configured = true; + + const register = (name: string, created: boolean) => { + subscribe(name, async ({ payload }: RawPayloadType<{ order: Order }>) => { + if (!payload.order?.context?.acp) return; + const dedupeKey = `${name}:${payload.order._id}:${payload.order.status}`; + const lastDelivered = recentlyDelivered.get(dedupeKey); + if (lastDelivered && Date.now() - lastDelivered < 60_000) return; + recentlyDelivered.set(dedupeKey, Date.now()); + await sendWebhook(payload.order, created); + }); + }; + + register('ORDER_CHECKOUT', true); + register('ORDER_CONFIRMED', false); + register('ORDER_FULFILLED', false); + register('ORDER_REJECTED', false); +}; diff --git a/packages/api/src/express/createACPMiddleware.ts b/packages/api/src/express/createACPMiddleware.ts new file mode 100644 index 000000000..6362ff693 --- /dev/null +++ b/packages/api/src/express/createACPMiddleware.ts @@ -0,0 +1,31 @@ +import e from 'express'; +import { acpConfig } from '../acp/config.ts'; +import { handleACPRequest } from '../acp/handler.ts'; + +export const createACPMiddleware = e.Router(); + +createACPMiddleware.use(e.json({ limit: '1mb' })); +createACPMiddleware.all(/(.*)/, async (req, res) => { + const response = await handleACPRequest({ + method: req.method, + path: req.path, + headers: req.headers, + body: req.body, + context: (req as any).unchainedContext, + }); + Object.entries(response.headers || {}).forEach(([name, value]) => res.setHeader(name, value)); + if (response.contentType) { + res.type(response.contentType).status(response.status).send(response.body); + } else { + res.status(response.status).json(response.body); + } +}); + +export const wellKnownACPHandler = (_req: e.Request, res: e.Response) => { + res.json({ + protocol: 'agentic-commerce-protocol', + api_versions: ['2026-04-17'], + checkout_endpoint: `${acpConfig.apiPath}/checkout_sessions`, + capabilities: ['checkout', 'stripe_spt'], + }); +}; diff --git a/packages/api/src/express/index.ts b/packages/api/src/express/index.ts index 6cb175ac5..68dbc3902 100644 --- a/packages/api/src/express/index.ts +++ b/packages/api/src/express/index.ts @@ -22,10 +22,12 @@ import createBulkImportMiddleware from './createBulkImportMiddleware.ts'; import createERCMetadataMiddleware from './createERCMetadataMiddleware.ts'; import createTempUploadMiddleware from './createTempUploadMiddleware.ts'; import createMCPMiddleware from './createMCPMiddleware.ts'; +import { createACPMiddleware, wellKnownACPHandler } from './createACPMiddleware.ts'; import { API_EVENTS } from '../events.ts'; import type { ChatConfiguration } from '../chat/utils.ts'; import { connectChat } from './chatHandler.ts'; import type { CipherKey } from 'node:crypto'; +import { configureACPWebhooks } from '../acp/webhook.ts'; export interface AdminUIRouterOptions { prefix: string; enabled?: boolean; @@ -66,6 +68,7 @@ const { ERC_METADATA_API_PATH = '/erc-metadata', TEMP_UPLOAD_API_PATH = '/temp-upload', MCP_API_PATH = '/mcp', + ACP_API_PATH = '/acp', GRAPHQL_API_PATH = '/graphql', UNCHAINED_COOKIE_NAME = 'unchained_token', UNCHAINED_COOKIE_PATH = '/', @@ -271,6 +274,9 @@ export const connect = ( expressApp.use(MCP_API_PATH, e.json({ limit: '10mb' })); expressApp.use(MCP_API_PATH, createMCPMiddleware); + expressApp.use(ACP_API_PATH, createACPMiddleware); + expressApp.get('/.well-known/acp.json', wellKnownACPHandler); + configureACPWebhooks(); if (chat) { connectChat(expressApp, chat); diff --git a/packages/api/src/fastify/acpHandler.ts b/packages/api/src/fastify/acpHandler.ts new file mode 100644 index 000000000..f4902015a --- /dev/null +++ b/packages/api/src/fastify/acpHandler.ts @@ -0,0 +1,16 @@ +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { handleACPRequest } from '../acp/handler.ts'; + +export default async function acpHandler(req: FastifyRequest, reply: FastifyReply) { + const wildcard = (req.params as { '*': string })['*'] || ''; + const response = await handleACPRequest({ + method: req.method, + path: `/${wildcard}`, + headers: req.headers, + body: req.body, + context: (req as any).unchainedContext, + }); + Object.entries(response.headers || {}).forEach(([name, value]) => reply.header(name, value)); + if (response.contentType) reply.type(response.contentType); + return reply.status(response.status).send(response.body); +} diff --git a/packages/api/src/fastify/index.ts b/packages/api/src/fastify/index.ts index 34b3167af..aa62a7c28 100644 --- a/packages/api/src/fastify/index.ts +++ b/packages/api/src/fastify/index.ts @@ -22,10 +22,12 @@ import fastifyMultipart from '@fastify/multipart'; import type { FastifyBaseLogger, FastifyInstance, FastifyPluginAsync, FastifyRequest } from 'fastify'; import { createLogger } from '@unchainedshop/logger'; import mcpHandler from './mcpHandler.ts'; +import acpHandler from './acpHandler.ts'; import tempUploadHandler from './tempUploadHandler.ts'; import { connectChat } from './chatHandler.ts'; import type { ChatConfiguration } from '../chat/utils.ts'; import { readFileSync } from 'node:fs'; +import { configureACPWebhooks } from '../acp/webhook.ts'; export interface AdminUIRouterOptions { prefix: string; enabled?: boolean; @@ -44,6 +46,7 @@ const resolveUserRemoteAddress = (req: FastifyRequest) => { const { MCP_API_PATH = '/mcp', + ACP_API_PATH = '/acp', GRAPHQL_API_PATH = '/graphql', BULK_IMPORT_API_PATH = '/bulk-import', TEMP_UPLOAD_API_PATH = '/temp-upload', @@ -240,6 +243,20 @@ export const connect = ( handler: mcpHandler, }); + fastify.route({ + url: `${ACP_API_PATH}/*`, + method: ['GET', 'POST'], + handler: acpHandler, + }); + + fastify.get('/.well-known/acp.json', async () => ({ + protocol: 'agentic-commerce-protocol', + api_versions: ['2026-04-17'], + checkout_endpoint: `${ACP_API_PATH}/checkout_sessions`, + capabilities: ['checkout', 'stripe_spt'], + })); + configureACPWebhooks(); + fastify.register((s, opts, registered) => { s.register(fastifyMultipart, { throwFileSizeLimit: true, limits: { fileSize: 1024 * 1024 * 35 } }); // 35MB s.route({ diff --git a/packages/plugins/src/payment/acp-stripe-spt/index.ts b/packages/plugins/src/payment/acp-stripe-spt/index.ts new file mode 100644 index 000000000..eae3432dc --- /dev/null +++ b/packages/plugins/src/payment/acp-stripe-spt/index.ts @@ -0,0 +1,97 @@ +import { + OrderPricingSheet, + type IPaymentAdapter, + PaymentAdapter, + PaymentDirector, + PaymentError, +} from '@unchainedshop/core'; + +const configurationValue = (config: { key: string; value: string | null }[], key: string) => + config.find((entry) => entry.key === key)?.value; + +const StripeSPT: IPaymentAdapter = { + ...PaymentAdapter, + key: 'shop.unchained.payment.acp-stripe-spt', + label: 'ACP Stripe Shared Payment Token', + version: '1.0.0', + initialConfiguration: [ + { key: 'secret', value: null }, + { key: 'stripeVersion', value: '2026-04-22.preview' }, + { key: 'description', value: 'Unchained agentic checkout' }, + ], + + typeSupported(type) { + return type === 'GENERIC'; + }, + + actions: (config, context) => { + const secret = configurationValue(config, 'secret') || process.env.STRIPE_SECRET; + const stripeVersion = configurationValue(config, 'stripeVersion') || '2026-04-22.preview'; + const description = configurationValue(config, 'description') || 'Unchained agentic checkout'; + const baseActions = PaymentAdapter.actions(config, context); + + return { + ...baseActions, + configurationError() { + return secret ? null : PaymentError.INCOMPLETE_CONFIGURATION; + }, + isActive() { + return Boolean(secret); + }, + isPayLaterAllowed() { + return false; + }, + async charge(transactionContext = {}) { + const token = transactionContext.acpToken; + if (!token) throw new Error('ACP delegated payment token is required'); + if (!secret) throw new Error('Stripe secret is not configured'); + if (!context.order || !context.orderPayment) { + throw new Error('Order and order payment are required'); + } + + const pricing = OrderPricingSheet({ + calculation: context.order.calculation, + currencyCode: context.order.currencyCode, + }); + const { amount, currencyCode } = pricing.total({ useNetPrice: false }); + const body = new URLSearchParams({ + amount: String(Math.round(amount)), + currency: currencyCode.toLowerCase(), + confirm: 'true', + description, + 'payment_method_data[shared_payment_granted_token]': token, + 'metadata[orderId]': context.order._id, + 'metadata[orderPaymentId]': context.orderPayment._id, + }); + + const response = await fetch('https://api.stripe.com/v1/payment_intents', { + method: 'POST', + headers: { + Authorization: `Bearer ${secret}`, + 'Content-Type': 'application/x-www-form-urlencoded', + 'Idempotency-Key': `acp-${context.orderPayment._id}`, + 'Stripe-Version': stripeVersion, + }, + body, + }); + const paymentIntent = (await response.json()) as any; + + if (!response.ok) { + throw new Error( + paymentIntent?.error?.message || + `Stripe Shared Payment Token charge failed (${response.status})`, + ); + } + if (paymentIntent.status !== 'succeeded') return false; + + return { + transactionId: paymentIntent.id, + status: paymentIntent.status, + paymentMethod: paymentIntent.payment_method, + }; + }, + }; + }, +}; + +PaymentDirector.registerAdapter(StripeSPT); diff --git a/packages/plugins/src/presets/all.ts b/packages/plugins/src/presets/all.ts index c93d6c55a..a52ec68c9 100644 --- a/packages/plugins/src/presets/all.ts +++ b/packages/plugins/src/presets/all.ts @@ -12,6 +12,7 @@ import '../payment/paypal-checkout.ts'; import appleTransactionsModules from '../payment/apple-iap/index.ts'; import saferpayTransactionsModules from '../payment/saferpay/index.ts'; import '../payment/stripe/index.ts'; +import '../payment/acp-stripe-spt/index.ts'; import '../payment/postfinance-checkout/index.ts'; import '../payment/datatrans-v2/index.ts'; import '../payment/payrexx/index.ts'; From d2b396a05b1b1c638c81a23c0c804d11ce0f1345 Mon Sep 17 00:00:00 2001 From: Pascal Kaufmann Date: Fri, 12 Jun 2026 10:16:38 +0200 Subject: [PATCH 2/3] Merge ACP SPT into Stripe adapter --- packages/api/src/acp/README.md | 8 +- packages/api/src/acp/handler.ts | 4 +- .../src/payment/acp-stripe-spt/index.ts | 97 ------------------- packages/plugins/src/payment/stripe/index.ts | 56 +++++++++-- packages/plugins/src/presets/all.ts | 1 - tests/plugins-datatrans.test.js | 23 +++-- 6 files changed, 70 insertions(+), 119 deletions(-) delete mode 100644 packages/plugins/src/payment/acp-stripe-spt/index.ts diff --git a/packages/api/src/acp/README.md b/packages/api/src/acp/README.md index 63884c1be..9a9aba912 100644 --- a/packages/api/src/acp/README.md +++ b/packages/api/src/acp/README.md @@ -19,10 +19,10 @@ UNCHAINED_ACP_PAYMENT_PROVIDER_ID= ACP_CHECKOUT_CONTINUE_URL=https://shop.example.com/orders ``` -The configured payment provider must use adapter key -`shop.unchained.payment.acp-stripe-spt`. Its optional configuration keys are -`secret`, `stripeVersion`, and `description`; `STRIPE_SECRET` is used when the -provider has no `secret`. +The configured payment provider must be a `GENERIC` provider using the existing +Stripe adapter key `shop.unchained.payment.stripe`. ACP Shared Payment Token +charges use `STRIPE_SECRET` and Stripe's `2026-04-22.preview` API version for +that single charge request. Product-feed configuration: diff --git a/packages/api/src/acp/handler.ts b/packages/api/src/acp/handler.ts index 5c936a2a0..6bc914118 100644 --- a/packages/api/src/acp/handler.ts +++ b/packages/api/src/acp/handler.ts @@ -146,12 +146,12 @@ const createSession = async (context: Context, body: any) => { const paymentProvider = await context.modules.payment.paymentProviders.findProvider({ paymentProviderId: acpConfig.paymentProviderId, }); - if (paymentProvider?.adapterKey !== 'shop.unchained.payment.acp-stripe-spt') { + if (paymentProvider?.adapterKey !== 'shop.unchained.payment.stripe') { throw new ACPError( 503, 'api_error', 'payment_provider_not_configured', - 'UNCHAINED_ACP_PAYMENT_PROVIDER_ID must use shop.unchained.payment.acp-stripe-spt', + 'UNCHAINED_ACP_PAYMENT_PROVIDER_ID must use shop.unchained.payment.stripe', ); } diff --git a/packages/plugins/src/payment/acp-stripe-spt/index.ts b/packages/plugins/src/payment/acp-stripe-spt/index.ts deleted file mode 100644 index eae3432dc..000000000 --- a/packages/plugins/src/payment/acp-stripe-spt/index.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { - OrderPricingSheet, - type IPaymentAdapter, - PaymentAdapter, - PaymentDirector, - PaymentError, -} from '@unchainedshop/core'; - -const configurationValue = (config: { key: string; value: string | null }[], key: string) => - config.find((entry) => entry.key === key)?.value; - -const StripeSPT: IPaymentAdapter = { - ...PaymentAdapter, - key: 'shop.unchained.payment.acp-stripe-spt', - label: 'ACP Stripe Shared Payment Token', - version: '1.0.0', - initialConfiguration: [ - { key: 'secret', value: null }, - { key: 'stripeVersion', value: '2026-04-22.preview' }, - { key: 'description', value: 'Unchained agentic checkout' }, - ], - - typeSupported(type) { - return type === 'GENERIC'; - }, - - actions: (config, context) => { - const secret = configurationValue(config, 'secret') || process.env.STRIPE_SECRET; - const stripeVersion = configurationValue(config, 'stripeVersion') || '2026-04-22.preview'; - const description = configurationValue(config, 'description') || 'Unchained agentic checkout'; - const baseActions = PaymentAdapter.actions(config, context); - - return { - ...baseActions, - configurationError() { - return secret ? null : PaymentError.INCOMPLETE_CONFIGURATION; - }, - isActive() { - return Boolean(secret); - }, - isPayLaterAllowed() { - return false; - }, - async charge(transactionContext = {}) { - const token = transactionContext.acpToken; - if (!token) throw new Error('ACP delegated payment token is required'); - if (!secret) throw new Error('Stripe secret is not configured'); - if (!context.order || !context.orderPayment) { - throw new Error('Order and order payment are required'); - } - - const pricing = OrderPricingSheet({ - calculation: context.order.calculation, - currencyCode: context.order.currencyCode, - }); - const { amount, currencyCode } = pricing.total({ useNetPrice: false }); - const body = new URLSearchParams({ - amount: String(Math.round(amount)), - currency: currencyCode.toLowerCase(), - confirm: 'true', - description, - 'payment_method_data[shared_payment_granted_token]': token, - 'metadata[orderId]': context.order._id, - 'metadata[orderPaymentId]': context.orderPayment._id, - }); - - const response = await fetch('https://api.stripe.com/v1/payment_intents', { - method: 'POST', - headers: { - Authorization: `Bearer ${secret}`, - 'Content-Type': 'application/x-www-form-urlencoded', - 'Idempotency-Key': `acp-${context.orderPayment._id}`, - 'Stripe-Version': stripeVersion, - }, - body, - }); - const paymentIntent = (await response.json()) as any; - - if (!response.ok) { - throw new Error( - paymentIntent?.error?.message || - `Stripe Shared Payment Token charge failed (${response.status})`, - ); - } - if (paymentIntent.status !== 'succeeded') return false; - - return { - transactionId: paymentIntent.id, - status: paymentIntent.status, - paymentMethod: paymentIntent.payment_method, - }; - }, - }; - }, -}; - -PaymentDirector.registerAdapter(StripeSPT); diff --git a/packages/plugins/src/payment/stripe/index.ts b/packages/plugins/src/payment/stripe/index.ts index 7b1a91028..d340001f1 100644 --- a/packages/plugins/src/payment/stripe/index.ts +++ b/packages/plugins/src/payment/stripe/index.ts @@ -1,4 +1,5 @@ import { createLogger } from '@unchainedshop/logger'; +import type { Stripe as StripeSDK } from 'stripe'; import { stripe, createOrderPaymentIntent, createRegistrationIntent } from './stripe.ts'; import { OrderPricingSheet, @@ -10,6 +11,10 @@ import { const logger = createLogger('unchained:stripe'); +type AcpSharedPaymentMethodData = StripeSDK.PaymentIntentCreateParams.PaymentMethodData & { + shared_payment_granted_token: string; +}; + const Stripe: IPaymentAdapter = { ...PaymentAdapter, @@ -103,9 +108,9 @@ const Stripe: IPaymentAdapter = { return paymentIntent.client_secret; }, - charge: async ({ paymentIntentId, paymentCredentials }) => { - if (!paymentIntentId && !paymentCredentials) { - throw new Error('You have to provide paymentIntentId or paymentCredentials'); + charge: async ({ acpToken, paymentIntentId, paymentCredentials }) => { + if (!acpToken && !paymentIntentId && !paymentCredentials) { + throw new Error('You have to provide acpToken, paymentIntentId or paymentCredentials'); } const { order, orderPayment } = context; @@ -113,12 +118,53 @@ const Stripe: IPaymentAdapter = { if (!order) throw new Error('order not found in context'); if (!orderPayment) throw new Error('orderPayment not found in context'); - const { userId, name, email } = await assertUserData(order?.userId); const pricing = OrderPricingSheet({ calculation: order.calculation, currencyCode: order.currencyCode, }); + const { currencyCode, amount } = pricing.total({ useNetPrice: false }); + + if (acpToken) { + const paymentIntentObject = await stripe.paymentIntents.create( + { + amount: Math.round(amount), + currency: currencyCode.toLowerCase(), + confirm: true, + description: descriptorPrefix || 'Unchained agentic checkout', + statement_descriptor_suffix: `${order._id.substring(0, 4)}..${order._id.substring(order._id.length - 4)}`, + receipt_email: order.contact?.emailAddress, + metadata: { + orderPaymentId: orderPayment._id, + orderId: order._id, + userId: order.userId, + }, + payment_method_data: { + shared_payment_granted_token: acpToken, + } as AcpSharedPaymentMethodData, + }, + { + apiVersion: '2026-04-22.preview', + idempotencyKey: `acp-${orderPayment._id}`, + }, + ); + + if (paymentIntentObject.status === 'succeeded') { + return { + transactionId: paymentIntentObject.id, + status: paymentIntentObject.status, + paymentMethod: paymentIntentObject.payment_method, + }; + } + + logger.info('ACP SPT charge postponed because paymentIntent has wrong status', { + orderPaymentId: paymentIntentObject.id, + }); + + return false; + } + + const { userId, name, email } = await assertUserData(order?.userId); const paymentIntentObject = paymentIntentId ? await stripe.paymentIntents.retrieve(paymentIntentId) : await createOrderPaymentIntent( @@ -132,8 +178,6 @@ const Stripe: IPaymentAdapter = { }, ); - const { currencyCode, amount } = pricing.total({ useNetPrice: false }); - if ( paymentIntentObject.currency !== currencyCode.toLowerCase() || paymentIntentObject.amount !== Math.round(amount) diff --git a/packages/plugins/src/presets/all.ts b/packages/plugins/src/presets/all.ts index a52ec68c9..c93d6c55a 100644 --- a/packages/plugins/src/presets/all.ts +++ b/packages/plugins/src/presets/all.ts @@ -12,7 +12,6 @@ import '../payment/paypal-checkout.ts'; import appleTransactionsModules from '../payment/apple-iap/index.ts'; import saferpayTransactionsModules from '../payment/saferpay/index.ts'; import '../payment/stripe/index.ts'; -import '../payment/acp-stripe-spt/index.ts'; import '../payment/postfinance-checkout/index.ts'; import '../payment/datatrans-v2/index.ts'; import '../payment/payrexx/index.ts'; diff --git a/tests/plugins-datatrans.test.js b/tests/plugins-datatrans.test.js index 32ce15f07..f8307de8d 100644 --- a/tests/plugins-datatrans.test.js +++ b/tests/plugins-datatrans.test.js @@ -276,24 +276,25 @@ test.describe('Plugins: Datatrans', () => { const { data: { addCartProduct, updateCart, checkoutCart } = {} } = await graphqlFetch({ query: /* GraphQL */ ` - mutation addAndCheckout($productId: ID!, $paymentContext: JSON) { - emptyCart { + mutation addAndCheckout($orderId: ID!, $productId: ID!, $paymentContext: JSON) { + emptyCart(orderId: $orderId) { _id } - addCartProduct(productId: $productId) { + addCartProduct(orderId: $orderId, productId: $productId) { _id } - updateCart(paymentProviderId: "d4d4d4d4d4") { + updateCart(orderId: $orderId, paymentProviderId: "d4d4d4d4d4") { _id status } - checkoutCart(paymentContext: $paymentContext) { + checkoutCart(orderId: $orderId, paymentContext: $paymentContext) { _id status } } `, variables: { + orderId: 'simple-order', productId: 'simpleproduct', paymentContext: { paymentCredentials: credentials, @@ -311,21 +312,25 @@ test.describe('Plugins: Datatrans', () => { test('checkout with preferred alias', async () => { const { data: { addCartProduct, updateCart, checkoutCart } = {} } = await graphqlFetch({ query: /* GraphQL */ ` - mutation addAndCheckout($productId: ID!) { - addCartProduct(productId: $productId) { + mutation addAndCheckout($orderId: ID!, $productId: ID!) { + emptyCart(orderId: $orderId) { _id } - updateCart(paymentProviderId: "d4d4d4d4d4") { + addCartProduct(orderId: $orderId, productId: $productId) { + _id + } + updateCart(orderId: $orderId, paymentProviderId: "d4d4d4d4d4") { _id status } - checkoutCart { + checkoutCart(orderId: $orderId) { _id status } } `, variables: { + orderId: 'generic-payment-order', productId: 'simpleproduct', }, }); From 64a59761fcbc42cba7e054f00457b86baea6cb73 Mon Sep 17 00:00:00 2001 From: Pascal Kaufmann Date: Fri, 12 Jun 2026 10:50:16 +0200 Subject: [PATCH 3/3] Refactor Stripe payment integration --- .../src/payment/stripe/charge-request.test.ts | 65 ++++++ .../src/payment/stripe/charge-request.ts | 54 +++++ .../plugins/src/payment/stripe/customers.ts | 48 ++++ .../src/payment/stripe/handler-express.ts | 136 +----------- .../src/payment/stripe/handler-fastify.ts | 147 +------------ packages/plugins/src/payment/stripe/index.ts | 115 ++++------ .../src/payment/stripe/metadata.test.ts | 98 +++++++++ .../plugins/src/payment/stripe/metadata.ts | 56 +++++ .../payment/stripe/payment-intents.test.ts | 119 ++++++++++ .../src/payment/stripe/payment-intents.ts | 117 ++++++++++ .../src/payment/stripe/setup-intents.ts | 59 +++++ packages/plugins/src/payment/stripe/stripe.ts | 120 +--------- .../src/payment/stripe/webhook.test.ts | 205 ++++++++++++++++++ .../plugins/src/payment/stripe/webhook.ts | 196 +++++++++++++++++ 14 files changed, 1072 insertions(+), 463 deletions(-) create mode 100644 packages/plugins/src/payment/stripe/charge-request.test.ts create mode 100644 packages/plugins/src/payment/stripe/charge-request.ts create mode 100644 packages/plugins/src/payment/stripe/customers.ts create mode 100644 packages/plugins/src/payment/stripe/metadata.test.ts create mode 100644 packages/plugins/src/payment/stripe/metadata.ts create mode 100644 packages/plugins/src/payment/stripe/payment-intents.test.ts create mode 100644 packages/plugins/src/payment/stripe/payment-intents.ts create mode 100644 packages/plugins/src/payment/stripe/setup-intents.ts create mode 100644 packages/plugins/src/payment/stripe/webhook.test.ts create mode 100644 packages/plugins/src/payment/stripe/webhook.ts diff --git a/packages/plugins/src/payment/stripe/charge-request.test.ts b/packages/plugins/src/payment/stripe/charge-request.test.ts new file mode 100644 index 000000000..8c77e325b --- /dev/null +++ b/packages/plugins/src/payment/stripe/charge-request.test.ts @@ -0,0 +1,65 @@ +import test from 'node:test'; +import assert from 'node:assert'; +import { normalizeStripeChargeRequest } from './charge-request.ts'; + +test.describe('normalizeStripeChargeRequest', () => { + test('chooses ACP SPT over injected credentials and payment intents', () => { + assert.deepStrictEqual( + normalizeStripeChargeRequest({ + acpToken: 'spt_123', + acpHandlerId: 'stripe_spt', + paymentIntentId: 'pi_123', + paymentCredentials: { token: 'pm_123' }, + }), + { + mode: 'acp-spt', + acpToken: 'spt_123', + acpHandlerId: 'stripe_spt', + }, + ); + }); + + test('chooses payment intent over injected credentials', () => { + assert.deepStrictEqual( + normalizeStripeChargeRequest({ + paymentIntentId: 'pi_123', + paymentCredentials: { token: 'pm_123' }, + }), + { + mode: 'payment-intent', + paymentIntentId: 'pi_123', + }, + ); + }); + + test('chooses stored credentials when no higher priority mode is present', () => { + const paymentCredentials = { token: 'pm_123', meta: { customer: 'cus_123' } }; + + assert.deepStrictEqual(normalizeStripeChargeRequest({ paymentCredentials }), { + mode: 'stored-credential', + paymentCredentials, + }); + }); + + test('rejects invalid ACP tokens instead of falling through to credentials', () => { + assert.throws( + () => + normalizeStripeChargeRequest({ + acpToken: '', + paymentCredentials: { token: 'pm_123' }, + }), + /non-empty acpToken/, + ); + }); + + test('rejects missing usable charge input', () => { + assert.throws( + () => normalizeStripeChargeRequest({ paymentCredentials: {} }), + /paymentCredentials with a token/, + ); + assert.throws( + () => normalizeStripeChargeRequest({}), + /acpToken, paymentIntentId or paymentCredentials/, + ); + }); +}); diff --git a/packages/plugins/src/payment/stripe/charge-request.ts b/packages/plugins/src/payment/stripe/charge-request.ts new file mode 100644 index 000000000..fb6e35fad --- /dev/null +++ b/packages/plugins/src/payment/stripe/charge-request.ts @@ -0,0 +1,54 @@ +export type StripeChargeRequest = + | { + mode: 'acp-spt'; + acpToken: string; + acpHandlerId?: string; + } + | { + mode: 'payment-intent'; + paymentIntentId: string; + } + | { + mode: 'stored-credential'; + paymentCredentials: any; + }; + +const hasValue = (value: unknown) => value !== undefined && value !== null; + +export const normalizeStripeChargeRequest = (transactionContext: any = {}): StripeChargeRequest => { + if (hasValue(transactionContext.acpToken)) { + if (typeof transactionContext.acpToken !== 'string' || !transactionContext.acpToken.trim()) { + throw new Error('You have to provide a non-empty acpToken'); + } + return { + mode: 'acp-spt', + acpToken: transactionContext.acpToken, + acpHandlerId: transactionContext.acpHandlerId, + }; + } + + if (hasValue(transactionContext.paymentIntentId)) { + if ( + typeof transactionContext.paymentIntentId !== 'string' || + !transactionContext.paymentIntentId.trim() + ) { + throw new Error('You have to provide a non-empty paymentIntentId'); + } + return { + mode: 'payment-intent', + paymentIntentId: transactionContext.paymentIntentId, + }; + } + + if (hasValue(transactionContext.paymentCredentials)) { + if (!transactionContext.paymentCredentials?.token) { + throw new Error('You have to provide paymentCredentials with a token'); + } + return { + mode: 'stored-credential', + paymentCredentials: transactionContext.paymentCredentials, + }; + } + + throw new Error('You have to provide acpToken, paymentIntentId or paymentCredentials'); +}; diff --git a/packages/plugins/src/payment/stripe/customers.ts b/packages/plugins/src/payment/stripe/customers.ts new file mode 100644 index 000000000..e1ddd8d00 --- /dev/null +++ b/packages/plugins/src/payment/stripe/customers.ts @@ -0,0 +1,48 @@ +import type { Stripe } from 'stripe'; +import { stripe, stripeEnvironment } from './stripe.ts'; + +export interface StripeUserData { + userId: string; + name?: string; + email?: string; +} + +export const upsertCustomer = async ( + { userId, name, email }: StripeUserData, + stripeClient: Stripe = stripe, +): Promise => { + try { + const { data } = await stripeClient.customers.search({ + query: `metadata["userId"]:"${userId}"`, + }); + const existingCustomer = data[0]; + + if ( + existingCustomer.name !== name || + existingCustomer.email !== email || + existingCustomer.metadata.environment !== stripeEnvironment + ) { + const updatedCustomer = await stripeClient.customers.update(existingCustomer.id, { + metadata: { + userId, + environment: stripeEnvironment, + }, + name, + email, + }); + return updatedCustomer.id; + } + + return existingCustomer.id; + } catch { + const customer = await stripeClient.customers.create({ + metadata: { + userId, + environment: stripeEnvironment, + }, + name, + email, + }); + return customer.id; + } +}; diff --git a/packages/plugins/src/payment/stripe/handler-express.ts b/packages/plugins/src/payment/stripe/handler-express.ts index 5ad033769..839ec559a 100644 --- a/packages/plugins/src/payment/stripe/handler-express.ts +++ b/packages/plugins/src/payment/stripe/handler-express.ts @@ -1,135 +1,11 @@ import type { Context } from '@unchainedshop/api'; -import { createLogger } from '@unchainedshop/logger'; -import { stripe } from './stripe.ts'; - -const logger = createLogger('unchained:stripe:handler'); - -export const WebhookEventTypes = { - PAYMENT_INTENT_SUCCEEDED: 'payment_intent.succeeded', - SETUP_INTENT_SUCCEEDED: 'setup_intent.succeeded', -}; +import { handleStripeWebhook } from './webhook.ts'; export const stripeHandler = async (request, response) => { - const resolvedContext = request.unchainedContext as Context; - const { modules, services } = resolvedContext; - - let event; - - try { - const sig = request.headers['stripe-signature']; - if (!sig) { - throw new Error('stripe-signature header was not provided for webhook'); - } - if (!process.env.STRIPE_ENDPOINT_SECRET) { - throw new Error('env STRIPE_ENDPOINT_SECRET is required for webhook handling'); - } - event = stripe.webhooks.constructEvent(request.body, sig, process.env.STRIPE_ENDPOINT_SECRET); - } catch (err) { - logger.error(`Error constructing event: ${err.message}`); - response.status(400).send({ - message: err.message, - name: err.name, - }); - return; - } - - if (!Object.values(WebhookEventTypes).includes(event.type)) { - logger.info(`unhandled event type`, { - type: event.type, - }); - response.status(200).send({ - ignored: true, - message: `Unhandled event type: ${event.type}. Supported types: ${Object.values(WebhookEventTypes).join(', ')}`, - }); - return; - } - - const environmentInMetadata = event.data?.object?.metadata?.environment || ''; - const environmentInEnv = process.env.STRIPE_WEBHOOK_ENVIRONMENT || ''; - if (environmentInMetadata !== environmentInEnv) { - logger.info(`unhandled event environment`, { - type: event.type, - environment: environmentInMetadata, - }); - response.status(200).send({ - ignored: true, - message: `Unhandled event environment: ${environmentInMetadata}. Supported environment: ${environmentInEnv}`, - }); - return; - } - - logger.info(`Processing event`, { - type: event.type, + const result = await handleStripeWebhook({ + rawBody: request.body, + signature: request.headers['stripe-signature'], + context: request.unchainedContext as Context, }); - try { - if (event.type === WebhookEventTypes.PAYMENT_INTENT_SUCCEEDED) { - const paymentIntent = event.data.object; - const { orderPaymentId } = paymentIntent.metadata || {}; - - logger.info(`checkout with orderPaymentId: ${orderPaymentId}`, { - type: event.type, - }); - - await modules.orders.payments.logEvent(orderPaymentId, event); - const orderPayment = await modules.orders.payments.findOrderPayment({ - orderPaymentId, - }); - - if (!orderPayment) { - throw new Error(`order payment not found with orderPaymentId: ${orderPaymentId}`); - } - - const order = await services.orders.checkoutOrder(orderPayment.orderId, { - paymentContext: { - paymentIntentId: paymentIntent.id, - }, - }); - - if (!order) throw new Error(`Order with id ${orderPayment.orderId} not found`); - - logger.info(`checkout successful`, { - orderPaymentId, - orderId: order._id, - type: event.type, - }); - response.status(200).send({ - message: 'checkout successful', - orderId: order._id, - }); - } else if (event.type === WebhookEventTypes.SETUP_INTENT_SUCCEEDED) { - const setupIntent = event.data.object; - const { paymentProviderId, userId } = setupIntent.metadata || {}; - - logger.info(`registered payment credential with paymentProviderId: ${paymentProviderId}`, { - type: event.type, - userId, - }); - - const paymentCredentials = await services.orders.registerPaymentCredentials(paymentProviderId, { - transactionContext: { - setupIntentId: setupIntent.id, - }, - userId, - }); - - logger.info(`payment credentials registration successful`, { - userId, - paymentProviderId, - paymentCredentialsId: paymentCredentials?._id, - type: event.type, - }); - response.status(200).send({ - message: 'payment credentials registration successful', - paymentCredentialsId: paymentCredentials?._id, - }); - } - } catch (error) { - logger.error(error, { - type: event.type, - }); - response.status(500).send({ - message: error.message, - name: error.name, - }); - } + response.status(result.statusCode).send(result.body); }; diff --git a/packages/plugins/src/payment/stripe/handler-fastify.ts b/packages/plugins/src/payment/stripe/handler-fastify.ts index 771e4c028..a955c0417 100644 --- a/packages/plugins/src/payment/stripe/handler-fastify.ts +++ b/packages/plugins/src/payment/stripe/handler-fastify.ts @@ -1,14 +1,6 @@ import type { Context } from '@unchainedshop/api'; -import { createLogger } from '@unchainedshop/logger'; import type { FastifyRequest, RouteHandlerMethod } from 'fastify'; -import { stripe } from './stripe.ts'; - -const logger = createLogger('unchained:stripe:handler'); - -export const WebhookEventTypes = { - PAYMENT_INTENT_SUCCEEDED: 'payment_intent.succeeded', - SETUP_INTENT_SUCCEEDED: 'setup_intent.succeeded', -}; +import { handleStripeWebhook } from './webhook.ts'; export const stripeHandler: RouteHandlerMethod = async ( req: FastifyRequest & { @@ -16,136 +8,11 @@ export const stripeHandler: RouteHandlerMethod = async ( }, reply, ) => { - const resolvedContext = req.unchainedContext as Context; - const { modules, services } = resolvedContext; - - let event; - - try { - const sig = req.headers['stripe-signature']; - if (!process.env.STRIPE_ENDPOINT_SECRET) { - throw new Error('env STRIPE_ENDPOINT_SECRET is required for webhook handling'); - } - if (!sig) { - throw new Error('stripe-signature header was not provided for webhook'); - } - event = stripe.webhooks.constructEvent(req.body as string, sig, process.env.STRIPE_ENDPOINT_SECRET); - } catch (err) { - logger.error(`Error constructing event: ${err.message}`); - reply.status(400); - return reply.send({ - success: false, - message: err.message, - name: err.name, - }); - } - - if (!Object.values(WebhookEventTypes).includes(event.type)) { - logger.info(`unhandled event type`, { - type: event.type, - }); - reply.status(200); - return reply.send({ - success: false, - ignored: true, - name: 'UNHANDLED_EVENT_TYPE', - message: `Unhandled event type: ${event.type}. Supported types: ${Object.values(WebhookEventTypes).join(', ')}`, - }); - } - - const environmentInMetadata = event.data?.object?.metadata?.environment || ''; - const environmentInEnv = process.env.STRIPE_WEBHOOK_ENVIRONMENT || ''; - if (environmentInMetadata !== environmentInEnv) { - logger.info(`unhandled event environment`, { - type: event.type, - environment: environmentInMetadata, - }); - reply.status(200); - return reply.send({ - success: false, - ignored: true, - name: 'UNHANDLED_EVENT_ENVIRONMENT', - message: `Unhandled event environment: ${environmentInMetadata}. Supported environment: ${environmentInEnv}`, - }); - } - - logger.info(`Processing event`, { - type: event.type, + const result = await handleStripeWebhook({ + rawBody: req.body as string, + signature: req.headers['stripe-signature'], + context: req.unchainedContext as Context, }); - try { - if (event.type === WebhookEventTypes.PAYMENT_INTENT_SUCCEEDED) { - const paymentIntent = event.data.object; - const { orderPaymentId } = paymentIntent.metadata || {}; - - logger.info(`checkout with orderPaymentId: ${orderPaymentId}`, { - type: event.type, - }); - - await modules.orders.payments.logEvent(orderPaymentId, event); - const orderPayment = await modules.orders.payments.findOrderPayment({ - orderPaymentId, - }); - - if (!orderPayment) { - throw new Error(`order payment not found with orderPaymentId: ${orderPaymentId}`); - } - - const order = await services.orders.checkoutOrder(orderPayment.orderId, { - paymentContext: { - paymentIntentId: paymentIntent.id, - }, - }); - - if (!order) throw new Error(`Order with id ${orderPayment.orderId} not found`); - - logger.info(`checkout successful`, { - orderPaymentId, - orderId: order._id, - type: event.type, - }); - reply.status(200); - return reply.send({ - success: true, - orderId: order._id, - }); - } else if (event.type === WebhookEventTypes.SETUP_INTENT_SUCCEEDED) { - const setupIntent = event.data.object; - const { paymentProviderId, userId } = setupIntent.metadata || {}; - - logger.info(`registered payment credential with paymentProviderId: ${paymentProviderId}`, { - type: event.type, - userId, - }); - - const paymentCredentials = await services.orders.registerPaymentCredentials(paymentProviderId, { - transactionContext: { - setupIntentId: setupIntent.id, - }, - userId, - }); - - logger.info(`payment credentials registration successful`, { - userId, - paymentProviderId, - paymentCredentialsId: paymentCredentials?._id, - type: event.type, - }); - reply.status(200); - return reply.send({ - success: true, - message: 'payment credentials registration successful', - paymentCredentialsId: paymentCredentials?._id, - }); - } - } catch (error) { - logger.error(error, { - type: event.type, - }); - reply.status(500); - return reply.send({ - success: false, - message: error.message, - name: error.name, - }); - } + reply.status(result.statusCode); + return reply.send(result.body); }; diff --git a/packages/plugins/src/payment/stripe/index.ts b/packages/plugins/src/payment/stripe/index.ts index d340001f1..50d7c08ea 100644 --- a/packages/plugins/src/payment/stripe/index.ts +++ b/packages/plugins/src/payment/stripe/index.ts @@ -1,6 +1,14 @@ import { createLogger } from '@unchainedshop/logger'; -import type { Stripe as StripeSDK } from 'stripe'; -import { stripe, createOrderPaymentIntent, createRegistrationIntent } from './stripe.ts'; +import { stripe } from './stripe.ts'; +import { createRegistrationIntent, retrieveSetupIntentCredentials } from './setup-intents.ts'; +import { + createAcpSharedPaymentTokenIntent, + createOrderPaymentIntent, + createStoredCredentialPaymentIntent, + retrievePaymentIntent, +} from './payment-intents.ts'; +import { assertPaymentIntentMatchesOrderPayment } from './metadata.ts'; +import { normalizeStripeChargeRequest } from './charge-request.ts'; import { OrderPricingSheet, type IPaymentAdapter, @@ -11,10 +19,6 @@ import { const logger = createLogger('unchained:stripe'); -type AcpSharedPaymentMethodData = StripeSDK.PaymentIntentCreateParams.PaymentMethodData & { - shared_payment_granted_token: string; -}; - const Stripe: IPaymentAdapter = { ...PaymentAdapter, @@ -66,23 +70,7 @@ const Stripe: IPaymentAdapter = { }, register: async ({ setupIntentId }) => { - if (!setupIntentId) { - throw new Error('You have to provide a setupIntentId'); - } - - const setupIntent = await stripe.setupIntents.retrieve(setupIntentId); - if (setupIntent.status === 'succeeded') { - return { - token: setupIntent.payment_method, - customer: setupIntent.customer, - // payment_method_options: setupIntent.payment_method_options, - payment_method_types: setupIntent.payment_method_types, - usage: setupIntent.usage, - }; - } - - logger.warn('Registration declined', setupIntentId); - return null; + return retrieveSetupIntentCredentials({ setupIntentId }); }, sign: async (transactionContext = {}) => { @@ -108,11 +96,8 @@ const Stripe: IPaymentAdapter = { return paymentIntent.client_secret; }, - charge: async ({ acpToken, paymentIntentId, paymentCredentials }) => { - if (!acpToken && !paymentIntentId && !paymentCredentials) { - throw new Error('You have to provide acpToken, paymentIntentId or paymentCredentials'); - } - + charge: async (transactionContext = {}) => { + const chargeRequest = normalizeStripeChargeRequest(transactionContext); const { order, orderPayment } = context; if (!order) throw new Error('order not found in context'); @@ -123,31 +108,14 @@ const Stripe: IPaymentAdapter = { currencyCode: order.currencyCode, }); - const { currencyCode, amount } = pricing.total({ useNetPrice: false }); - - if (acpToken) { - const paymentIntentObject = await stripe.paymentIntents.create( - { - amount: Math.round(amount), - currency: currencyCode.toLowerCase(), - confirm: true, - description: descriptorPrefix || 'Unchained agentic checkout', - statement_descriptor_suffix: `${order._id.substring(0, 4)}..${order._id.substring(order._id.length - 4)}`, - receipt_email: order.contact?.emailAddress, - metadata: { - orderPaymentId: orderPayment._id, - orderId: order._id, - userId: order.userId, - }, - payment_method_data: { - shared_payment_granted_token: acpToken, - } as AcpSharedPaymentMethodData, - }, - { - apiVersion: '2026-04-22.preview', - idempotencyKey: `acp-${orderPayment._id}`, - }, - ); + if (chargeRequest.mode === 'acp-spt') { + const paymentIntentObject = await createAcpSharedPaymentTokenIntent({ + acpToken: chargeRequest.acpToken, + order, + orderPayment, + pricing, + descriptorPrefix, + }); if (paymentIntentObject.status === 'succeeded') { return { @@ -165,28 +133,25 @@ const Stripe: IPaymentAdapter = { } const { userId, name, email } = await assertUserData(order?.userId); - const paymentIntentObject = paymentIntentId - ? await stripe.paymentIntents.retrieve(paymentIntentId) - : await createOrderPaymentIntent( - { userId, name, email, orderPayment, order, pricing, descriptorPrefix }, - { - customer: paymentCredentials.meta?.customer, - confirm: true, - payment_method: paymentCredentials.token, - payment_method_types: paymentCredentials.meta?.payment_method_types, - // payment_method_options: paymentCredentials.meta?.payment_method_options, // eslint-disable-line - }, - ); - - if ( - paymentIntentObject.currency !== currencyCode.toLowerCase() || - paymentIntentObject.amount !== Math.round(amount) - ) { - throw new Error('The price has changed since the intent has been created'); - } - if (paymentIntentObject.metadata?.orderPaymentId !== orderPayment?._id) { - throw new Error('The order payment is different from the initiating intent'); - } + const paymentIntentObject = + chargeRequest.mode === 'payment-intent' + ? await retrievePaymentIntent(chargeRequest.paymentIntentId) + : await createStoredCredentialPaymentIntent({ + userId, + name, + email, + orderPayment, + order, + pricing, + descriptorPrefix, + paymentCredentials: chargeRequest.paymentCredentials, + }); + + assertPaymentIntentMatchesOrderPayment({ + paymentIntent: paymentIntentObject, + orderPayment, + pricing, + }); if (paymentIntentObject.status === 'succeeded') { return paymentIntentObject; diff --git a/packages/plugins/src/payment/stripe/metadata.test.ts b/packages/plugins/src/payment/stripe/metadata.test.ts new file mode 100644 index 000000000..e011c3683 --- /dev/null +++ b/packages/plugins/src/payment/stripe/metadata.test.ts @@ -0,0 +1,98 @@ +import test from 'node:test'; +import assert from 'node:assert'; +import { + assertPaymentIntentMatchesOrderPayment, + buildOrderPaymentMetadata, + buildStatementDescriptorSuffix, + resolveStripePaymentTotal, +} from './metadata.ts'; + +const pricing = { + total: () => ({ amount: 1234.4, currencyCode: 'CHF' }), +} as any; + +const order = { + _id: 'order-123456789', + userId: 'user', +} as any; + +const orderPayment = { + _id: 'payment-123', +} as any; + +test.describe('Stripe metadata helpers', () => { + test('builds order payment metadata in one shape', () => { + assert.deepStrictEqual(buildOrderPaymentMetadata({ order, orderPayment }), { + orderPaymentId: 'payment-123', + orderId: 'order-123456789', + userId: 'user', + environment: process.env.STRIPE_WEBHOOK_ENVIRONMENT ?? null, + }); + }); + + test('builds statement descriptor suffix and rounded total', () => { + assert.strictEqual(buildStatementDescriptorSuffix(order._id), 'orde..6789'); + assert.deepStrictEqual(resolveStripePaymentTotal(pricing), { + amount: 1234, + currency: 'chf', + }); + }); + + test('validates matching payment intents', () => { + assert.doesNotThrow(() => + assertPaymentIntentMatchesOrderPayment({ + paymentIntent: { + amount: 1234, + currency: 'chf', + metadata: { orderPaymentId: 'payment-123' }, + } as any, + orderPayment, + pricing, + }), + ); + }); + + test('rejects amount, currency, and order payment mismatches', () => { + assert.throws( + () => + assertPaymentIntentMatchesOrderPayment({ + paymentIntent: { + amount: 1235, + currency: 'chf', + metadata: { orderPaymentId: 'payment-123' }, + } as any, + orderPayment, + pricing, + }), + /price has changed/, + ); + + assert.throws( + () => + assertPaymentIntentMatchesOrderPayment({ + paymentIntent: { + amount: 1234, + currency: 'eur', + metadata: { orderPaymentId: 'payment-123' }, + } as any, + orderPayment, + pricing, + }), + /price has changed/, + ); + + assert.throws( + () => + assertPaymentIntentMatchesOrderPayment({ + paymentIntent: { + amount: 1234, + currency: 'chf', + metadata: { orderPaymentId: 'other-payment' }, + } as any, + orderPayment, + pricing, + }), + /order payment is different/, + ); + }); +}); diff --git a/packages/plugins/src/payment/stripe/metadata.ts b/packages/plugins/src/payment/stripe/metadata.ts new file mode 100644 index 000000000..1204d8506 --- /dev/null +++ b/packages/plugins/src/payment/stripe/metadata.ts @@ -0,0 +1,56 @@ +import type { IOrderPricingSheet } from '@unchainedshop/core'; +import type { Order, OrderPayment } from '@unchainedshop/core-orders'; +import type { Stripe } from 'stripe'; +import { stripeEnvironment } from './stripe.ts'; + +export type StripeOrderPaymentMetadata = Record & { + orderPaymentId: string; + orderId: string; + userId: string; + environment: string | null; +}; + +export const buildStatementDescriptorSuffix = (orderId: string) => + `${orderId.substring(0, 4)}..${orderId.substring(orderId.length - 4)}`; + +export const buildOrderPaymentMetadata = ({ + order, + orderPayment, + userId = order.userId, +}: { + order: Order; + orderPayment: OrderPayment; + userId?: string; +}): StripeOrderPaymentMetadata => ({ + orderPaymentId: orderPayment._id, + orderId: order._id, + userId, + environment: stripeEnvironment, +}); + +export const resolveStripePaymentTotal = (pricing: IOrderPricingSheet) => { + const { currencyCode, amount } = pricing.total({ useNetPrice: false }); + return { + amount: Math.round(amount), + currency: currencyCode.toLowerCase(), + }; +}; + +export const assertPaymentIntentMatchesOrderPayment = ({ + paymentIntent, + orderPayment, + pricing, +}: { + paymentIntent: Stripe.PaymentIntent; + orderPayment: OrderPayment; + pricing: IOrderPricingSheet; +}) => { + const { amount, currency } = resolveStripePaymentTotal(pricing); + + if (paymentIntent.currency !== currency || paymentIntent.amount !== amount) { + throw new Error('The price has changed since the intent has been created'); + } + if (paymentIntent.metadata?.orderPaymentId !== orderPayment._id) { + throw new Error('The order payment is different from the initiating intent'); + } +}; diff --git a/packages/plugins/src/payment/stripe/payment-intents.test.ts b/packages/plugins/src/payment/stripe/payment-intents.test.ts new file mode 100644 index 000000000..6cda411ff --- /dev/null +++ b/packages/plugins/src/payment/stripe/payment-intents.test.ts @@ -0,0 +1,119 @@ +import test from 'node:test'; +import assert from 'node:assert'; +import { + createAcpSharedPaymentTokenIntent, + createStoredCredentialPaymentIntent, +} from './payment-intents.ts'; + +const order = { + _id: 'order-123456789', + userId: 'user', + contact: { emailAddress: 'customer@example.com' }, +} as any; + +const orderPayment = { + _id: 'payment-123', +} as any; + +const pricing = { + total: () => ({ amount: 4200, currencyCode: 'CHF' }), +} as any; + +const createFakeStripe = () => { + const calls: any[] = []; + return { + calls, + client: { + paymentIntents: { + create: async (...args: any[]) => { + calls.push(args); + return { + id: 'pi_123', + status: 'succeeded', + payment_method: 'pm_123', + }; + }, + }, + } as any, + }; +}; + +test.describe('Stripe payment intent helpers', () => { + test('creates ACP SPT intents with preview-only params and idempotency', async () => { + const { calls, client } = createFakeStripe(); + + await createAcpSharedPaymentTokenIntent( + { + acpToken: 'spt_123', + order, + orderPayment, + pricing, + descriptorPrefix: 'Book Shop', + }, + client, + ); + + assert.strictEqual(calls.length, 1); + assert.deepStrictEqual(calls[0][1], { + apiVersion: '2026-04-22.preview', + idempotencyKey: 'acp-payment-123', + }); + assert.partialDeepStrictEqual(calls[0][0], { + amount: 4200, + currency: 'chf', + confirm: true, + description: 'Book Shop', + statement_descriptor_suffix: 'orde..6789', + receipt_email: 'customer@example.com', + metadata: { + orderPaymentId: 'payment-123', + orderId: 'order-123456789', + userId: 'user', + environment: process.env.STRIPE_WEBHOOK_ENVIRONMENT ?? null, + }, + payment_method_data: { + shared_payment_granted_token: 'spt_123', + }, + }); + }); + + test('creates stored-credential intents using vaulted payment details', async () => { + const { calls, client } = createFakeStripe(); + + await createStoredCredentialPaymentIntent( + { + userId: 'user', + name: 'Customer', + email: 'customer@example.com', + order, + orderPayment, + pricing, + descriptorPrefix: 'Book Shop', + paymentCredentials: { + token: 'pm_123', + meta: { + customer: 'cus_123', + payment_method_types: ['card'], + }, + }, + }, + client, + ); + + assert.strictEqual(calls.length, 1); + assert.partialDeepStrictEqual(calls[0][0], { + amount: 4200, + currency: 'chf', + customer: 'cus_123', + confirm: true, + payment_method: 'pm_123', + payment_method_types: ['card'], + metadata: { + orderPaymentId: 'payment-123', + orderId: 'order-123456789', + userId: 'user', + environment: process.env.STRIPE_WEBHOOK_ENVIRONMENT ?? null, + }, + }); + }); +}); diff --git a/packages/plugins/src/payment/stripe/payment-intents.ts b/packages/plugins/src/payment/stripe/payment-intents.ts new file mode 100644 index 000000000..f3ff58a19 --- /dev/null +++ b/packages/plugins/src/payment/stripe/payment-intents.ts @@ -0,0 +1,117 @@ +import type { IOrderPricingSheet } from '@unchainedshop/core'; +import type { Order, OrderPayment } from '@unchainedshop/core-orders'; +import type { Stripe } from 'stripe'; +import { EMAIL_WEBSITE_NAME, stripe } from './stripe.ts'; +import { upsertCustomer, type StripeUserData } from './customers.ts'; +import { + buildOrderPaymentMetadata, + buildStatementDescriptorSuffix, + resolveStripePaymentTotal, +} from './metadata.ts'; + +type AcpSharedPaymentMethodData = Stripe.PaymentIntentCreateParams.PaymentMethodData & { + shared_payment_granted_token: string; +}; + +export const createOrderPaymentIntent = async ( + { + userId, + name, + email, + order, + orderPayment, + pricing, + descriptorPrefix, + }: StripeUserData & { + order: Order; + orderPayment: OrderPayment; + pricing: IOrderPricingSheet; + descriptorPrefix?: string; + }, + options: Record = {}, + stripeClient: Stripe = stripe, +) => { + const description = + `${options?.description || descriptorPrefix || EMAIL_WEBSITE_NAME || 'Unchained'}`.trim(); + const customer = options?.customer || (await upsertCustomer({ userId, name, email }, stripeClient)); + const { amount, currency } = resolveStripePaymentTotal(pricing); + + return stripeClient.paymentIntents.create({ + amount, + currency, + description, + statement_descriptor_suffix: buildStatementDescriptorSuffix(order._id), + setup_future_usage: 'off_session', + customer, + receipt_email: order.contact?.emailAddress, + metadata: buildOrderPaymentMetadata({ order, orderPayment, userId }), + ...options, + }); +}; + +export const createStoredCredentialPaymentIntent = async ( + { + paymentCredentials, + ...input + }: StripeUserData & { + order: Order; + orderPayment: OrderPayment; + pricing: IOrderPricingSheet; + descriptorPrefix?: string; + paymentCredentials: any; + }, + stripeClient: Stripe = stripe, +) => { + return createOrderPaymentIntent( + input, + { + customer: paymentCredentials.meta?.customer, + confirm: true, + payment_method: paymentCredentials.token, + payment_method_types: paymentCredentials.meta?.payment_method_types, + }, + stripeClient, + ); +}; + +export const retrievePaymentIntent = async (paymentIntentId: string, stripeClient: Stripe = stripe) => { + return stripeClient.paymentIntents.retrieve(paymentIntentId); +}; + +export const createAcpSharedPaymentTokenIntent = async ( + { + acpToken, + order, + orderPayment, + pricing, + descriptorPrefix, + }: { + acpToken: string; + order: Order; + orderPayment: OrderPayment; + pricing: IOrderPricingSheet; + descriptorPrefix?: string; + }, + stripeClient: Stripe = stripe, +) => { + const { amount, currency } = resolveStripePaymentTotal(pricing); + + return stripeClient.paymentIntents.create( + { + amount, + currency, + confirm: true, + description: descriptorPrefix || 'Unchained agentic checkout', + statement_descriptor_suffix: buildStatementDescriptorSuffix(order._id), + receipt_email: order.contact?.emailAddress, + metadata: buildOrderPaymentMetadata({ order, orderPayment }), + payment_method_data: { + shared_payment_granted_token: acpToken, + } as AcpSharedPaymentMethodData, + }, + { + apiVersion: '2026-04-22.preview', + idempotencyKey: `acp-${orderPayment._id}`, + }, + ); +}; diff --git a/packages/plugins/src/payment/stripe/setup-intents.ts b/packages/plugins/src/payment/stripe/setup-intents.ts new file mode 100644 index 000000000..8ca9f3ae7 --- /dev/null +++ b/packages/plugins/src/payment/stripe/setup-intents.ts @@ -0,0 +1,59 @@ +import { createLogger } from '@unchainedshop/logger'; +import type { Stripe } from 'stripe'; +import { EMAIL_WEBSITE_NAME, stripe, stripeEnvironment } from './stripe.ts'; +import { upsertCustomer, type StripeUserData } from './customers.ts'; + +const logger = createLogger('unchained:stripe'); + +export const createRegistrationIntent = async ( + { + userId, + name, + email, + paymentProviderId, + descriptorPrefix, + }: StripeUserData & { + paymentProviderId: string; + descriptorPrefix?: string; + }, + options: Record = {}, + stripeClient: Stripe = stripe, +) => { + const customer = options?.customer || (await upsertCustomer({ userId, name, email }, stripeClient)); + const description = + `${options?.description || descriptorPrefix || EMAIL_WEBSITE_NAME || 'Unchained'}`.trim(); + + return stripeClient.setupIntents.create({ + description, + customer, + metadata: { + userId, + paymentProviderId, + environment: stripeEnvironment, + }, + usage: 'off_session', + ...options, + }); +}; + +export const retrieveSetupIntentCredentials = async ( + { setupIntentId }: { setupIntentId?: string }, + stripeClient: Stripe = stripe, +) => { + if (!setupIntentId) { + throw new Error('You have to provide a setupIntentId'); + } + + const setupIntent = await stripeClient.setupIntents.retrieve(setupIntentId); + if (setupIntent.status === 'succeeded') { + return { + token: setupIntent.payment_method, + customer: setupIntent.customer, + payment_method_types: setupIntent.payment_method_types, + usage: setupIntent.usage, + }; + } + + logger.warn('Registration declined', setupIntentId); + return null; +}; diff --git a/packages/plugins/src/payment/stripe/stripe.ts b/packages/plugins/src/payment/stripe/stripe.ts index 9896608a4..4868ad009 100644 --- a/packages/plugins/src/payment/stripe/stripe.ts +++ b/packages/plugins/src/payment/stripe/stripe.ts @@ -1,14 +1,12 @@ import type { Stripe } from 'stripe'; -import type { IOrderPricingSheet } from '@unchainedshop/core'; -import type { Order, OrderPayment } from '@unchainedshop/core-orders'; import { createLogger } from '@unchainedshop/logger'; const logger = createLogger('unchained:stripe'); -const { STRIPE_SECRET, STRIPE_WEBHOOK_ENVIRONMENT, EMAIL_WEBSITE_NAME } = process.env; +export const { STRIPE_SECRET, STRIPE_WEBHOOK_ENVIRONMENT, EMAIL_WEBSITE_NAME } = process.env; export let stripe: Stripe; -const environment = STRIPE_WEBHOOK_ENVIRONMENT ?? null; +export const stripeEnvironment = STRIPE_WEBHOOK_ENVIRONMENT ?? null; if (!STRIPE_SECRET) { logger.warn('STRIPE_SECRET is not set, skipping initialization'); @@ -22,117 +20,3 @@ if (!STRIPE_SECRET) { logger.warn(`optional peer npm package 'stripe' not installed, stripe adapter will not work`); } } - -export const upsertCustomer = async ({ userId, name, email }): Promise => { - try { - const { data } = await stripe.customers.search({ query: `metadata["userId"]:"${userId}"` }); - const existingCustomer = data[0]; - - if ( - existingCustomer.name !== name || - existingCustomer.email !== email || - existingCustomer.metadata.environment !== environment - ) { - const updatedCustomer = await stripe.customers.update(existingCustomer.id, { - metadata: { - userId, - environment, - }, - name, - email, - }); - return updatedCustomer.id; - } - - return existingCustomer.id; - } catch { - const customer = await stripe.customers.create({ - metadata: { - userId, - environment: STRIPE_WEBHOOK_ENVIRONMENT ?? null, - }, - name, - email, - }); - return customer.id; - } -}; - -export const createRegistrationIntent = async ( - { - userId, - name, - email, - paymentProviderId, - descriptorPrefix, - }: { - userId: string; - name: string; - email: string; - paymentProviderId: string; - descriptorPrefix?: string; - }, - options: Record = {}, -) => { - const customer = options?.customer || (await upsertCustomer({ userId, name, email })); - const description = - `${options?.description || descriptorPrefix || EMAIL_WEBSITE_NAME || 'Unchained'}`.trim(); - - const setupIntent = await stripe.setupIntents.create({ - description, - customer, - metadata: { - userId, - paymentProviderId, - environment, - }, - usage: 'off_session', - ...options, - }); - return setupIntent; -}; - -export const createOrderPaymentIntent = async ( - { - userId, - name, - email, - order, - orderPayment, - pricing, - descriptorPrefix, - }: { - userId: string; - name: string; - email: string; - order: Order; - orderPayment: OrderPayment; - pricing: IOrderPricingSheet; - descriptorPrefix?: string; - }, - options: Record = {}, -) => { - const description = - `${options?.description || descriptorPrefix || EMAIL_WEBSITE_NAME || 'Unchained'}`.trim(); - const customer = options?.customer || (await upsertCustomer({ userId, name, email })); - - const { currencyCode, amount } = pricing.total({ useNetPrice: false }); - - const paymentIntent = await stripe.paymentIntents.create({ - amount: Math.round(amount), - currency: currencyCode.toLowerCase(), - description, - statement_descriptor_suffix: `${order._id.substring(0, 4)}..${order._id.substring(order._id.length - 4)}`, - setup_future_usage: 'off_session', // Verify your integration in this guide by including this parameter - customer, - receipt_email: order.contact?.emailAddress, - metadata: { - orderPaymentId: orderPayment._id, - orderId: order._id, - userId, - environment, - }, - ...options, - }); - return paymentIntent; -}; diff --git a/packages/plugins/src/payment/stripe/webhook.test.ts b/packages/plugins/src/payment/stripe/webhook.test.ts new file mode 100644 index 000000000..76d1dc9ee --- /dev/null +++ b/packages/plugins/src/payment/stripe/webhook.test.ts @@ -0,0 +1,205 @@ +import test from 'node:test'; +import assert from 'node:assert'; +import { handleStripeWebhook, WebhookEventTypes } from './webhook.ts'; + +const createContext = () => { + const calls: any[] = []; + const context = { + modules: { + orders: { + payments: { + logEvent: async (...args: any[]) => calls.push(['logEvent', ...args]), + findOrderPayment: async ({ orderPaymentId }) => { + calls.push(['findOrderPayment', orderPaymentId]); + if (orderPaymentId === 'missing-payment') return null; + return { _id: orderPaymentId, orderId: 'order-123' }; + }, + }, + }, + }, + services: { + orders: { + checkoutOrder: async (...args: any[]) => { + calls.push(['checkoutOrder', ...args]); + return { _id: 'order-123' }; + }, + registerPaymentCredentials: async (...args: any[]) => { + calls.push(['registerPaymentCredentials', ...args]); + return { _id: 'credentials-123' }; + }, + }, + }, + } as any; + + return { context, calls }; +}; + +const createStripeClient = (event: any) => + ({ + webhooks: { + constructEvent: () => event, + }, + }) as any; + +const env = process.env.STRIPE_WEBHOOK_ENVIRONMENT || ''; + +test.describe('handleStripeWebhook', () => { + test('returns 400 when signature is missing', async () => { + const { context } = createContext(); + + const result = await handleStripeWebhook({ + rawBody: '{}', + context, + endpointSecret: 'secret', + stripeClient: createStripeClient({}), + }); + + assert.strictEqual(result.statusCode, 400); + assert.partialDeepStrictEqual(result.body, { + success: false, + name: 'Error', + }); + }); + + test('ignores unsupported event types', async () => { + const { context } = createContext(); + + const result = await handleStripeWebhook({ + rawBody: '{}', + signature: 'sig', + context, + endpointSecret: 'secret', + stripeClient: createStripeClient({ + type: 'customer.created', + data: { object: { metadata: { environment: env } } }, + }), + }); + + assert.strictEqual(result.statusCode, 200); + assert.partialDeepStrictEqual(result.body, { + success: false, + ignored: true, + name: 'UNHANDLED_EVENT_TYPE', + }); + }); + + test('ignores events for a different environment', async () => { + const { context } = createContext(); + + const result = await handleStripeWebhook({ + rawBody: '{}', + signature: 'sig', + context, + endpointSecret: 'secret', + stripeClient: createStripeClient({ + type: WebhookEventTypes.PAYMENT_INTENT_SUCCEEDED, + data: { object: { metadata: { environment: 'other-env' } } }, + }), + }); + + assert.strictEqual(result.statusCode, 200); + assert.partialDeepStrictEqual(result.body, { + success: false, + ignored: true, + name: 'UNHANDLED_EVENT_ENVIRONMENT', + }); + }); + + test('checks out orders for successful payment intents', async () => { + const { context, calls } = createContext(); + + const result = await handleStripeWebhook({ + rawBody: '{}', + signature: 'sig', + context, + endpointSecret: 'secret', + stripeClient: createStripeClient({ + type: WebhookEventTypes.PAYMENT_INTENT_SUCCEEDED, + data: { + object: { + id: 'pi_123', + metadata: { environment: env, orderPaymentId: 'payment-123' }, + }, + }, + }), + }); + + assert.strictEqual(result.statusCode, 200); + assert.partialDeepStrictEqual(result.body, { + success: true, + message: 'checkout successful', + orderId: 'order-123', + }); + assert.deepStrictEqual(calls[1], ['findOrderPayment', 'payment-123']); + assert.deepStrictEqual(calls[2], [ + 'checkoutOrder', + 'order-123', + { paymentContext: { paymentIntentId: 'pi_123' } }, + ]); + }); + + test('registers credentials for successful setup intents', async () => { + const { context, calls } = createContext(); + + const result = await handleStripeWebhook({ + rawBody: '{}', + signature: 'sig', + context, + endpointSecret: 'secret', + stripeClient: createStripeClient({ + type: WebhookEventTypes.SETUP_INTENT_SUCCEEDED, + data: { + object: { + id: 'seti_123', + metadata: { + environment: env, + paymentProviderId: 'provider-123', + userId: 'user-123', + }, + }, + }, + }), + }); + + assert.strictEqual(result.statusCode, 200); + assert.partialDeepStrictEqual(result.body, { + success: true, + message: 'payment credentials registration successful', + paymentCredentialsId: 'credentials-123', + }); + assert.deepStrictEqual(calls[0], [ + 'registerPaymentCredentials', + 'provider-123', + { + transactionContext: { setupIntentId: 'seti_123' }, + userId: 'user-123', + }, + ]); + }); + + test('returns 500 for processing failures', async () => { + const { context } = createContext(); + + const result = await handleStripeWebhook({ + rawBody: '{}', + signature: 'sig', + context, + endpointSecret: 'secret', + stripeClient: createStripeClient({ + type: WebhookEventTypes.PAYMENT_INTENT_SUCCEEDED, + data: { + object: { + id: 'pi_123', + metadata: { environment: env, orderPaymentId: 'missing-payment' }, + }, + }, + }), + }); + + assert.strictEqual(result.statusCode, 500); + assert.partialDeepStrictEqual(result.body, { + success: false, + name: 'Error', + }); + }); +}); diff --git a/packages/plugins/src/payment/stripe/webhook.ts b/packages/plugins/src/payment/stripe/webhook.ts new file mode 100644 index 000000000..8a27914cd --- /dev/null +++ b/packages/plugins/src/payment/stripe/webhook.ts @@ -0,0 +1,196 @@ +import type { Context } from '@unchainedshop/api'; +import { createLogger } from '@unchainedshop/logger'; +import type { Stripe } from 'stripe'; +import { stripe } from './stripe.ts'; + +const logger = createLogger('unchained:stripe:handler'); + +export const WebhookEventTypes = { + PAYMENT_INTENT_SUCCEEDED: 'payment_intent.succeeded', + SETUP_INTENT_SUCCEEDED: 'setup_intent.succeeded', +}; + +export interface StripeWebhookResult { + statusCode: number; + body: { + success: boolean; + ignored?: boolean; + name?: string; + message?: string; + orderId?: string; + paymentCredentialsId?: string; + }; +} + +export const handleStripeWebhook = async ({ + rawBody, + signature, + context, + endpointSecret = process.env.STRIPE_ENDPOINT_SECRET, + stripeClient = stripe, +}: { + rawBody: string | Buffer; + signature?: string | string[]; + context: Context; + endpointSecret?: string; + stripeClient?: Stripe; +}): Promise => { + const { modules, services } = context; + let event: Stripe.Event; + + try { + if (!endpointSecret) { + throw new Error('env STRIPE_ENDPOINT_SECRET is required for webhook handling'); + } + if (!signature) { + throw new Error('stripe-signature header was not provided for webhook'); + } + event = stripeClient.webhooks.constructEvent(rawBody, signature, endpointSecret); + } catch (err) { + logger.error(`Error constructing event: ${err.message}`); + return { + statusCode: 400, + body: { + success: false, + message: err.message, + name: err.name, + }, + }; + } + + if (!Object.values(WebhookEventTypes).includes(event.type)) { + logger.info(`unhandled event type`, { + type: event.type, + }); + return { + statusCode: 200, + body: { + success: false, + ignored: true, + name: 'UNHANDLED_EVENT_TYPE', + message: `Unhandled event type: ${event.type}. Supported types: ${Object.values(WebhookEventTypes).join(', ')}`, + }, + }; + } + + const eventObject = event.data?.object as { metadata?: Record }; + const environmentInMetadata = eventObject?.metadata?.environment || ''; + const environmentInEnv = process.env.STRIPE_WEBHOOK_ENVIRONMENT || ''; + if (environmentInMetadata !== environmentInEnv) { + logger.info(`unhandled event environment`, { + type: event.type, + environment: environmentInMetadata, + }); + return { + statusCode: 200, + body: { + success: false, + ignored: true, + name: 'UNHANDLED_EVENT_ENVIRONMENT', + message: `Unhandled event environment: ${environmentInMetadata}. Supported environment: ${environmentInEnv}`, + }, + }; + } + + logger.info(`Processing event`, { + type: event.type, + }); + + try { + if (event.type === WebhookEventTypes.PAYMENT_INTENT_SUCCEEDED) { + const paymentIntent = event.data.object as Stripe.PaymentIntent; + const { orderPaymentId } = paymentIntent.metadata || {}; + + logger.info(`checkout with orderPaymentId: ${orderPaymentId}`, { + type: event.type, + }); + + await modules.orders.payments.logEvent(orderPaymentId, event); + const orderPayment = await modules.orders.payments.findOrderPayment({ + orderPaymentId, + }); + + if (!orderPayment) { + throw new Error(`order payment not found with orderPaymentId: ${orderPaymentId}`); + } + + const order = await services.orders.checkoutOrder(orderPayment.orderId, { + paymentContext: { + paymentIntentId: paymentIntent.id, + }, + }); + + if (!order) throw new Error(`Order with id ${orderPayment.orderId} not found`); + + logger.info(`checkout successful`, { + orderPaymentId, + orderId: order._id, + type: event.type, + }); + + return { + statusCode: 200, + body: { + success: true, + message: 'checkout successful', + orderId: order._id, + }, + }; + } + + if (event.type === WebhookEventTypes.SETUP_INTENT_SUCCEEDED) { + const setupIntent = event.data.object as Stripe.SetupIntent; + const { paymentProviderId, userId } = setupIntent.metadata || {}; + + logger.info(`registered payment credential with paymentProviderId: ${paymentProviderId}`, { + type: event.type, + userId, + }); + + const paymentCredentials = await services.orders.registerPaymentCredentials(paymentProviderId, { + transactionContext: { + setupIntentId: setupIntent.id, + }, + userId, + }); + + logger.info(`payment credentials registration successful`, { + userId, + paymentProviderId, + paymentCredentialsId: paymentCredentials?._id, + type: event.type, + }); + + return { + statusCode: 200, + body: { + success: true, + message: 'payment credentials registration successful', + paymentCredentialsId: paymentCredentials?._id, + }, + }; + } + } catch (error) { + logger.error(error, { + type: event.type, + }); + return { + statusCode: 500, + body: { + success: false, + message: error.message, + name: error.name, + }, + }; + } + + return { + statusCode: 200, + body: { + success: false, + ignored: true, + name: 'UNHANDLED_EVENT_TYPE', + message: `Unhandled event type: ${event.type}. Supported types: ${Object.values(WebhookEventTypes).join(', ')}`, + }, + }; +};