From 96774fb70e579f765f4eb9cf2b44fac0b14d5ae8 Mon Sep 17 00:00:00 2001 From: alldentobias Date: Tue, 17 Feb 2026 10:35:36 +0100 Subject: [PATCH 1/4] Implements Ancillary suggestion --- README.md | 85 +++- ...P_Business_Service.postman_collection.json | 253 +++++++++- src/data/products.json | 65 ++- src/data/sessions.json | 382 +++++++++++++++ src/infrastructure/ucp_profile.ts | 197 ++++++++ src/main.ts | 8 +- src/routes/checkout.ts | 96 ++-- src/routes/products.ts | 32 +- src/services/ancillaries-service.ts | 437 ++++++++++++++++++ src/services/checkout-service.ts | 30 -- src/types/merchant.ts | 11 +- src/types/ucp/ancillaries.ts | 228 +++++++++ src/types/ucp/checkout.ts | 4 + src/types/ucp/core.ts | 0 src/well-known/profile.json | 11 +- 15 files changed, 1733 insertions(+), 106 deletions(-) create mode 100644 src/data/sessions.json create mode 100644 src/infrastructure/ucp_profile.ts create mode 100644 src/services/ancillaries-service.ts create mode 100644 src/types/ucp/ancillaries.ts delete mode 100644 src/types/ucp/core.ts diff --git a/README.md b/README.md index b9de7cc..4d424d2 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ This service demonstrates: fulfillment options, and totals - **UCP Fulfillment Extension** - Shipping and pickup options with dynamic pricing +- **UCP Ancillaries Extension** - Related products, upsells, and required + add-ons - **Vipps MobilePay Integration** - Wallet payments via ePayment API with PUSH_MESSAGE flow - **UCP Headers** - Structured fields for agent identification, capabilities, @@ -94,6 +96,43 @@ curl -X POST http://localhost:8080/checkout_sessions/{session_id}/complete \ }' ``` +### Create Checkout with Ancillaries + +Products can have relationships that generate ancillary suggestions. For +example, DEMO-006 (Washing Machine) has a required Drip Tray and suggested +Insurance: + +```bash +curl -X POST http://localhost:8080/checkout_sessions \ + -H "Content-Type: application/json" \ + -d '{ + "line_items": [ + { "sku": "DEMO-006", "quantity": 1 } + ] + }' +``` + +The response includes: + +- **Required ancillaries** - Automatically added to line items (e.g., Drip Tray) +- **Suggested ancillaries** - Returned in `ancillaries.suggested` array + +### Add a Suggested Ancillary + +```bash +curl -X PUT http://localhost:8080/checkout_sessions/{session_id} \ + -H "Content-Type: application/json" \ + -d '{ + "ancillaries": { + "items": [{ + "item": { "id": "DEMO-007" }, + "quantity": 1, + "for": "li_1" + }] + } + }' +``` + ## Configuration ### Environment Variables @@ -130,16 +169,28 @@ VIPPS_EMBEDDED_CHECKOUT=false ``` src/ ├── main.ts # Entry point & HTTP router -├── types.ts # TypeScript type definitions +├── types/ +│ ├── merchant.ts # Demo merchant types (Product, etc.) +│ ├── ucp/ +│ │ ├── checkout.ts # UCP Checkout types +│ │ ├── fulfillment.ts # UCP Fulfillment Extension types +│ │ ├── payment.ts # UCP Payment Handler types +│ │ └── ancillaries.ts # UCP Ancillaries Extension types +│ └── vipps/ +│ ├── checkout.ts # Vipps Checkout API v3 types +│ ├── epayment.ts # Vipps ePayment API types +│ └── auth.ts # Vipps Access Token API types ├── routes/ │ ├── checkout.ts # Checkout session handlers │ ├── products.ts # Product catalog handlers │ └── ucp.ts # UCP profile endpoint ├── services/ │ ├── checkout-service.ts # Session management & business logic -│ └── payment-service.ts # Payment processing & callbacks +│ ├── payment-service.ts # Payment processing & callbacks +│ └── ancillaries-service.ts # Ancillary suggestions & processing ├── infrastructure/ │ ├── fulfillment.ts # Fulfillment options builder +│ ├── ucp_profile.ts # UCP profile utilities │ ├── vipps_epayment_client.ts # Vipps ePayment API client │ ├── vipps-checkout-mapper.ts # UCP to Vipps mapping │ ├── ucp_headers.ts # UCP header parsing/serialization @@ -155,6 +206,32 @@ src/ └── *.json # JSON schemas ``` +### Type Organization + +Types are organized into domain-specific modules: + +| Module | Description | +| -------------------------- | -------------------------------------------------- | +| `types/ucp/checkout.ts` | Core checkout types (Session, LineItem, Totals) | +| `types/ucp/fulfillment.ts` | Fulfillment extension types (Shipping, Pickup) | +| `types/ucp/payment.ts` | Payment handler types (Wallet, Credentials) | +| `types/ucp/ancillaries.ts` | Ancillaries extension types (Suggestions, Applied) | +| `types/vipps/checkout.ts` | Vipps Checkout API v3 types | +| `types/vipps/epayment.ts` | Vipps ePayment API types | +| `types/vipps/auth.ts` | Vipps Access Token API types | +| `types/merchant.ts` | Demo merchant types (Product, ProductsStore) | + +Import types from their specific modules: + +```typescript +import type { + CheckoutSession, + LineItemResponse, +} from "./types/ucp/checkout.ts"; +import type { AncillarySuggestion } from "./types/ucp/ancillaries.ts"; +import type { Product } from "./types/merchant.ts"; +``` + ## UCP Specification This service implements: @@ -163,6 +240,8 @@ This service implements: flow - [UCP Fulfillment](https://ucp.dev/specification/fulfillment/) - Shipping and pickup options +- [UCP Ancillaries](https://ucp.dev/specification/ancillaries/) - Related + products and upsells - [Vipps MobilePay Payment Handler](https://vippsmobilepay.com/pay/ucp/2026-01-23/vipps_mp_payment_handler) - Wallet payments @@ -212,7 +291,7 @@ deno lint The service implements the Vipps PUSH_MESSAGE payment flow: 1. **Create Session** - Platform creates checkout session -2. **Update Session** - User selects fulfillment options +2. **Update Session** - User selects fulfillment options or ancillaries 3. **Complete Checkout** - Platform submits payment with MSISDN 4. **Push Notification** - Vipps sends push to user's phone 5. **User Approves** - User opens Vipps app and approves payment diff --git a/postman/UCP_Business_Service.postman_collection.json b/postman/UCP_Business_Service.postman_collection.json index ffa1dc6..16e1c1b 100644 --- a/postman/UCP_Business_Service.postman_collection.json +++ b/postman/UCP_Business_Service.postman_collection.json @@ -2,7 +2,7 @@ "info": { "_postman_id": "ucp-business-service-collection", "name": "UCP Business Service", - "description": "API collection for the UCP Business Service - a demo merchant implementing the Universal Commerce Protocol with Vipps MobilePay payment handler and UCP Fulfillment Extension.\n\n## Getting Started\n\n1. Start the server: `deno task start`\n2. Server runs on `http://localhost:8080`\n\n## Environment Variables\n\nThe collection uses `{{baseUrl}}` which defaults to `http://localhost:8080`.\n\n## UCP Headers (Recommended)\n\nThese headers are pre-configured on checkout requests:\n- **UCP-Agent**: Identifies the calling platform/agent\n- **UCP-Request-Context**: Provides request tracing information\n\n## Payment Handler\n\nThis merchant supports the `com.vippsmobilepay.pay.payment_handler` with handler ID `vippsmobilepay_wallet_handler`. To complete checkout, provide a WALLET instrument with MSISDN credential.\n\n## Fulfillment Options\n\nThe merchant supports UCP Fulfillment Extension with Norwegian delivery options:\n- **PostNord Standard** - kr 199, 3-5 business days\n- **PorterBuddy Express** - kr 129, same day delivery\n- **Hent i butikk** - Free, in-store pickup", + "description": "API collection for the UCP Business Service - a demo merchant implementing the Universal Commerce Protocol with Vipps MobilePay payment handler, UCP Fulfillment Extension, and UCP Ancillaries Extension.\n\n## Getting Started\n\n1. Start the server: `deno task start`\n2. Server runs on `http://localhost:8080`\n\n## Environment Variables\n\nThe collection uses `{{baseUrl}}` which defaults to `http://localhost:8080`.\n\n## UCP Headers (Recommended)\n\nThese headers are pre-configured on checkout requests:\n- **UCP-Agent**: Identifies the calling platform/agent\n- **UCP-Request-Context**: Provides request tracing information\n\n## Payment Handler\n\nThis merchant supports the `com.vippsmobilepay.pay.payment_handler` with handler ID `vippsmobilepay_wallet_handler`. To complete checkout, provide a WALLET instrument with MSISDN credential.\n\n## Fulfillment Options\n\nThe merchant supports UCP Fulfillment Extension with Norwegian delivery options:\n- **PostNord Standard** - kr 199, 3-5 business days\n- **PorterBuddy Express** - kr 129, same day delivery\n- **Hent i butikk** - Free, in-store pickup\n\n## Ancillaries\n\nThe merchant supports UCP Ancillaries Extension for related products:\n- **Suggested ancillaries**: Recommended products based on cart items (e.g., USB-C Cable with Mechanical Keyboard)\n- **Required ancillaries**: Automatically added products (e.g., Drip Tray with Washing Machine)\n- Products with relationships: DEMO-004 (Keyboard), DEMO-006 (Washing Machine)", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, "variable": [ @@ -309,6 +309,150 @@ }, "response": [] }, + { + "name": "Create Checkout (With Ancillaries)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 201) {", + " const response = pm.response.json();", + " pm.collectionVariables.set('checkoutSessionId', response.id);", + " console.log('Saved checkout session ID:', response.id);", + " if (response.ancillaries) {", + " console.log('Ancillaries:', JSON.stringify(response.ancillaries, null, 2));", + " }", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "UCP-Agent", + "value": "profile=\"http://localhost:3000/.well-known/ucp\", name=\"Vipps Assistant\"", + "description": "UCP Agent header - required for order webhook discovery" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"line_items\": [\n {\n \"sku\": \"DEMO-006\",\n \"quantity\": 1\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/checkout_sessions", + "host": ["{{baseUrl}}"], + "path": ["checkout_sessions"] + }, + "description": "Create a checkout session with a product that has ancillary relationships.\n\n**DEMO-006 (Washing Machine)** has:\n- **Required**: DEMO-008 (Drip Tray) - automatically added to line items\n- **Suggested**: DEMO-007 (12mo Insurance) - returned in ancillaries.suggested\n\nThe response will include:\n- The Drip Tray as a separate line item (automatically added)\n- The Insurance in `ancillaries.suggested` array\n- Applied ancillary info in `ancillaries.applied` array" + }, + "response": [ + { + "name": "Session With Required Ancillary", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"line_items\": [\n {\n \"sku\": \"DEMO-006\",\n \"quantity\": 1\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/checkout_sessions", + "host": ["{{baseUrl}}"], + "path": ["checkout_sessions"] + } + }, + "status": "Created", + "code": 201, + "_postman_previewlanguage": "json", + "body": "{\n \"id\": \"cs-ABC123XYZ\",\n \"status\": \"incomplete\",\n \"currency\": \"NOK\",\n \"line_items\": [\n {\n \"id\": \"li_1\",\n \"item\": {\n \"id\": \"DEMO-006\",\n \"title\": \"Washing Machine\",\n \"price\": 1000000\n },\n \"quantity\": 1,\n \"totals\": [{ \"type\": \"subtotal\", \"amount\": 1000000 }, { \"type\": \"total\", \"amount\": 1000000 }]\n },\n {\n \"id\": \"li_abc123\",\n \"item\": {\n \"id\": \"DEMO-008\",\n \"title\": \"Drip Tray\",\n \"price\": 500,\n \"description\": \"Mandatory drip tray for the washing machine.\"\n },\n \"quantity\": 1,\n \"totals\": [{ \"type\": \"subtotal\", \"amount\": 500 }, { \"type\": \"tax\", \"amount\": 100 }, { \"type\": \"total\", \"amount\": 500 }]\n }\n ],\n \"ancillaries\": {\n \"title\": \"Recommended additions\",\n \"suggested\": [\n {\n \"item\": {\n \"id\": \"DEMO-007\",\n \"title\": \"Insurance 12MO\",\n \"price\": 1000,\n \"description\": \"12-month insurance coverage for your product.\"\n },\n \"type\": \"suggested\",\n \"category\": \"service\",\n \"for\": \"li_1\",\n \"description\": \"12-month insurance coverage for your product.\"\n }\n ],\n \"applied\": [\n {\n \"id\": \"li_abc123\",\n \"for\": \"li_1\",\n \"type\": \"required\",\n \"category\": \"product\",\n \"description\": \"Drip Tray (required)\",\n \"automatic\": true,\n \"reason_code\": \"legal_requirement\"\n }\n ]\n },\n \"totals\": [\n { \"type\": \"subtotal\", \"amount\": 1000500 },\n { \"type\": \"tax\", \"amount\": 250125 },\n { \"type\": \"shipping\", \"amount\": 19900 },\n { \"type\": \"total\", \"amount\": 1270525 }\n ]\n}" + } + ] + }, + { + "name": "Create Checkout (With Suggested Ancillaries)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 201) {", + " const response = pm.response.json();", + " pm.collectionVariables.set('checkoutSessionId', response.id);", + " console.log('Saved checkout session ID:', response.id);", + " if (response.ancillaries && response.ancillaries.suggested) {", + " console.log('Suggested ancillaries:', response.ancillaries.suggested.length);", + " }", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "UCP-Agent", + "value": "profile=\"http://localhost:3000/.well-known/ucp\", name=\"Vipps Assistant\"", + "description": "UCP Agent header - required for order webhook discovery" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"line_items\": [\n {\n \"sku\": \"DEMO-004\",\n \"quantity\": 1\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/checkout_sessions", + "host": ["{{baseUrl}}"], + "path": ["checkout_sessions"] + }, + "description": "Create a checkout session with a product that has suggested ancillaries (no required ones).\n\n**DEMO-004 (Mechanical Keyboard)** has:\n- **Suggested**: DEMO-002 (USB-C Cable)\n- **Suggested**: DEMO-007 (12mo Insurance)\n\nThe response will include these in `ancillaries.suggested` array. They can be added via the Update endpoint." + }, + "response": [ + { + "name": "Session With Suggested Ancillaries", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"line_items\": [\n {\n \"sku\": \"DEMO-004\",\n \"quantity\": 1\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/checkout_sessions", + "host": ["{{baseUrl}}"], + "path": ["checkout_sessions"] + } + }, + "status": "Created", + "code": 201, + "_postman_previewlanguage": "json", + "body": "{\n \"id\": \"cs-XYZ789ABC\",\n \"status\": \"incomplete\",\n \"currency\": \"NOK\",\n \"line_items\": [\n {\n \"id\": \"li_1\",\n \"item\": {\n \"id\": \"DEMO-004\",\n \"title\": \"Mechanical Keyboard\",\n \"price\": 159900\n },\n \"quantity\": 1,\n \"totals\": [{ \"type\": \"subtotal\", \"amount\": 159900 }, { \"type\": \"total\", \"amount\": 159900 }]\n }\n ],\n \"ancillaries\": {\n \"title\": \"Recommended additions\",\n \"suggested\": [\n {\n \"item\": {\n \"id\": \"DEMO-002\",\n \"title\": \"USB-C Cable\",\n \"price\": 400,\n \"description\": \"High-speed USB-C to USB-C cable, 6ft length, supports 100W charging.\"\n },\n \"type\": \"suggested\",\n \"category\": \"product\",\n \"for\": \"li_1\"\n },\n {\n \"item\": {\n \"id\": \"DEMO-007\",\n \"title\": \"Insurance 12MO\",\n \"price\": 1000,\n \"description\": \"12-month insurance coverage for your product.\"\n },\n \"type\": \"suggested\",\n \"category\": \"service\",\n \"for\": \"li_1\"\n }\n ]\n },\n \"totals\": [\n { \"type\": \"subtotal\", \"amount\": 159900 },\n { \"type\": \"tax\", \"amount\": 39975 },\n { \"type\": \"shipping\", \"amount\": 19900 },\n { \"type\": \"total\", \"amount\": 219775 }\n ]\n}" + } + ] + }, { "name": "Get Checkout Session", "request": { @@ -459,6 +603,113 @@ } ] }, + { + "name": "Update Checkout Session (Add Ancillary)", + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "UCP-Request-Context", + "value": "request-id=\"{{$guid}}\"; timestamp=\"{{$isoTimestamp}}\"", + "description": "Optional: UCP request context for tracing" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"ancillaries\": {\n \"items\": [\n {\n \"item\": {\n \"id\": \"DEMO-007\"\n },\n \"quantity\": 1,\n \"for\": \"li_1\"\n }\n ]\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/checkout_sessions/{{checkoutSessionId}}", + "host": ["{{baseUrl}}"], + "path": ["checkout_sessions", "{{checkoutSessionId}}"] + }, + "description": "Add a suggested ancillary to the checkout session.\n\nThis endpoint allows adding ancillaries that were returned in `ancillaries.suggested`. The ancillary will be:\n1. Added as a new line item\n2. Tracked in `ancillaries.applied`\n3. Reflected in updated totals\n\n**Request fields:**\n- `item.id`: The SKU of the ancillary to add\n- `quantity`: Must match the related line item quantity\n- `for`: The line item ID this ancillary relates to\n\n**Note:** Submitting `ancillaries.items` replaces any previously submitted (non-automatic) ancillaries. Send an empty array to clear all user-selected ancillaries.\n\n**Example:** First create a checkout with DEMO-004 (Mechanical Keyboard) to get suggestions, then use this request to add the suggested USB-C Cable (DEMO-002) or Insurance (DEMO-007)." + }, + "response": [ + { + "name": "Ancillary Added", + "originalRequest": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"ancillaries\": {\n \"items\": [\n {\n \"item\": {\n \"id\": \"DEMO-007\"\n },\n \"quantity\": 1,\n \"for\": \"li_1\"\n }\n ]\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/checkout_sessions/cs-XYZ789ABC", + "host": ["{{baseUrl}}"], + "path": ["checkout_sessions", "cs-XYZ789ABC"] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "body": "{\n \"id\": \"cs-XYZ789ABC\",\n \"status\": \"ready_for_complete\",\n \"currency\": \"NOK\",\n \"line_items\": [\n {\n \"id\": \"li_1\",\n \"item\": {\n \"id\": \"DEMO-004\",\n \"title\": \"Mechanical Keyboard\",\n \"price\": 159900\n },\n \"quantity\": 1,\n \"totals\": [{ \"type\": \"subtotal\", \"amount\": 159900 }, { \"type\": \"total\", \"amount\": 159900 }]\n },\n {\n \"id\": \"li_xyz789\",\n \"item\": {\n \"id\": \"DEMO-007\",\n \"title\": \"Insurance 12MO\",\n \"price\": 1000,\n \"description\": \"12-month insurance coverage for your product.\"\n },\n \"quantity\": 1,\n \"totals\": [{ \"type\": \"subtotal\", \"amount\": 1000 }, { \"type\": \"tax\", \"amount\": 200 }, { \"type\": \"total\", \"amount\": 1000 }]\n }\n ],\n \"ancillaries\": {\n \"title\": \"Recommended additions\",\n \"suggested\": [\n {\n \"item\": {\n \"id\": \"DEMO-002\",\n \"title\": \"USB-C Cable\",\n \"price\": 400\n },\n \"type\": \"suggested\",\n \"category\": \"product\",\n \"for\": \"li_1\"\n }\n ],\n \"applied\": [\n {\n \"id\": \"li_xyz789\",\n \"for\": \"li_1\",\n \"type\": \"suggested\",\n \"category\": \"service\",\n \"description\": \"Insurance 12MO\"\n }\n ]\n },\n \"totals\": [\n { \"type\": \"subtotal\", \"amount\": 160900 },\n { \"type\": \"tax\", \"amount\": 40225 },\n { \"type\": \"shipping\", \"amount\": 19900 },\n { \"type\": \"total\", \"amount\": 221025 }\n ]\n}" + }, + { + "name": "Clear All Ancillaries", + "originalRequest": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"ancillaries\": {\n \"items\": []\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/checkout_sessions/cs-XYZ789ABC", + "host": ["{{baseUrl}}"], + "path": ["checkout_sessions", "cs-XYZ789ABC"] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "body": "{\n \"id\": \"cs-XYZ789ABC\",\n \"status\": \"ready_for_complete\",\n \"line_items\": [\n {\n \"id\": \"li_1\",\n \"item\": { \"id\": \"DEMO-004\", \"title\": \"Mechanical Keyboard\", \"price\": 159900 },\n \"quantity\": 1\n }\n ],\n \"ancillaries\": {\n \"title\": \"Recommended additions\",\n \"suggested\": [\n { \"item\": { \"id\": \"DEMO-002\", \"title\": \"USB-C Cable\" }, \"type\": \"suggested\" },\n { \"item\": { \"id\": \"DEMO-007\", \"title\": \"Insurance 12MO\" }, \"type\": \"suggested\" }\n ]\n },\n \"totals\": [\n { \"type\": \"subtotal\", \"amount\": 159900 },\n { \"type\": \"tax\", \"amount\": 39975 },\n { \"type\": \"shipping\", \"amount\": 19900 },\n { \"type\": \"total\", \"amount\": 219775 }\n ]\n}" + } + ] + }, + { + "name": "Update Checkout Session (Multiple Ancillaries)", + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "UCP-Request-Context", + "value": "request-id=\"{{$guid}}\"; timestamp=\"{{$isoTimestamp}}\"", + "description": "Optional: UCP request context for tracing" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"ancillaries\": {\n \"items\": [\n {\n \"item\": {\n \"id\": \"DEMO-002\"\n },\n \"quantity\": 1,\n \"for\": \"li_1\"\n },\n {\n \"item\": {\n \"id\": \"DEMO-007\"\n },\n \"quantity\": 1,\n \"for\": \"li_1\"\n }\n ]\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/checkout_sessions/{{checkoutSessionId}}", + "host": ["{{baseUrl}}"], + "path": ["checkout_sessions", "{{checkoutSessionId}}"] + }, + "description": "Add multiple suggested ancillaries to the checkout session at once.\n\nThis example adds both the USB-C Cable and Insurance to a Mechanical Keyboard checkout.\n\n**Note:** Each submission replaces the previous ancillary selection. To add to existing ancillaries, include them all in the request." + }, + "response": [] + }, { "name": "Complete Checkout Session", "request": { diff --git a/src/data/products.json b/src/data/products.json index 2ba5df5..a55783a 100644 --- a/src/data/products.json +++ b/src/data/products.json @@ -7,7 +7,8 @@ "price": 900, "currency": "NOK", "stock": 47, - "image_url": "https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=400&h=400&fit=crop" + "image_url": "https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=400&h=400&fit=crop", + "type": "product" }, { "sku": "DEMO-002", @@ -16,7 +17,8 @@ "price": 400, "currency": "nok", "stock": 191, - "image_url": "https://images.clasohlson.com/medias/sys_master/h00/hb3/68622828175390.jpg" + "image_url": "https://images.clasohlson.com/medias/sys_master/h00/hb3/68622828175390.jpg", + "type": "product" }, { "sku": "DEMO-003", @@ -25,7 +27,8 @@ "price": 49900, "currency": "NOK", "stock": 30, - "image_url": "https://images.unsplash.com/photo-1527864550417-7fd91fc51a46?w=400&h=400&fit=crop" + "image_url": "https://images.unsplash.com/photo-1527864550417-7fd91fc51a46?w=400&h=400&fit=crop", + "type": "product" }, { "sku": "DEMO-004", @@ -34,7 +37,18 @@ "price": 159900, "currency": "NOK", "stock": 25, - "image_url": "https://images.unsplash.com/photo-1618384887929-16ec33fab9ef?w=400&h=400&fit=crop" + "image_url": "https://images.unsplash.com/photo-1618384887929-16ec33fab9ef?w=400&h=400&fit=crop", + "type": "product", + "relationships": [ + { + "type": "suggested", + "sku": "DEMO-002" + }, + { + "type": "suggested", + "sku": "DEMO-007" + } + ] }, { "sku": "DEMO-005", @@ -43,7 +57,48 @@ "price": 99900, "currency": "NOK", "stock": 40, - "image_url": "https://images.clasohlson.com/medias/sys_master/h3d/h09/68622764671006.jpg" + "image_url": "https://images.clasohlson.com/medias/sys_master/h3d/h09/68622764671006.jpg", + "type": "product" + }, + { + "sku": "DEMO-006", + "name": "Washing Machine", + "description": "Washing machine with 10kg capacity and 1000 RPM.", + "price": 1000000, + "currency": "NOK", + "stock": 10, + "image_url": "https://images.pexels.com/photos/8774651/pexels-photo-8774651.jpeg", + "type": "product", + "relationships": [ + { + "type": "suggested", + "sku": "DEMO-007" + }, + { + "type": "required", + "sku": "DEMO-008" + } + ] + }, + { + "sku": "DEMO-007", + "name": "Insurance 12MO", + "description": "12-month insurance coverage for your product.", + "price": 1000, + "currency": "NOK", + "stock": 1000, + "image_url": "https://www.example.com/insurance.png", + "type": "service" + }, + { + "sku": "DEMO-008", + "name": "Drip Tray", + "description": "Mandatory drip tray for the washing machine.", + "price": 500, + "currency": "NOK", + "stock": 10, + "image_url": "https://www.example.com/drip-tray.png", + "type": "product" } ] } diff --git a/src/data/sessions.json b/src/data/sessions.json new file mode 100644 index 0000000..2670c26 --- /dev/null +++ b/src/data/sessions.json @@ -0,0 +1,382 @@ +{ + "sessions": [ + { + "ucp": { + "version": "2026-01-11", + "capabilities": [ + { + "name": "dev.ucp.shopping.checkout", + "version": "2026-01-11", + "spec": "https://ucp.dev/specification/checkout", + "schema": "https://ucp.dev/schemas/shopping/checkout.json" + }, + { + "name": "dev.ucp.shopping.fulfillment", + "version": "2026-01-11", + "spec": "https://ucp.dev/specification/fulfillment", + "schema": "https://ucp.dev/schemas/shopping/fulfillment.json", + "extends": "dev.ucp.shopping.checkout" + }, + { + "name": "dev.ucp.shopping.order", + "version": "2026-01-11", + "spec": "https://ucp.dev/specification/order", + "schema": "https://ucp.dev/schemas/shopping/order.json" + }, + { + "name": "dev.ucp.shopping.products", + "version": "2026-02-09", + "spec": "https://ucp.dev/specification/products", + "schema": "https://ucp.dev/schemas/shopping/products.json", + "extends": "dev.ucp.shopping.checkout" + }, + { + "name": "dev.ucp.shopping.ancillaries", + "version": "2026-02-09", + "spec": "https://ucp.dev/specification/ancillaries", + "schema": "https://ucp.dev/schemas/shopping/ancillaries.json", + "extends": "dev.ucp.shopping.checkout" + } + ] + }, + "id": "cs-c2e23c18-c8bd-4ef9-861c-f22199370909", + "status": "ready_for_complete", + "currency": "NOK", + "line_items": [ + { + "id": "li_1", + "item": { + "id": "DEMO-006", + "title": "Washing Machine", + "price": 1000000, + "description": "Washing machine with 10kg capacity and 1000 RPM.", + "image_url": "https://images.pexels.com/photos/8774651/pexels-photo-8774651.jpeg" + }, + "quantity": 1, + "totals": [ + { + "type": "subtotal", + "amount": 1000000 + }, + { + "type": "total", + "amount": 1000000 + } + ] + }, + { + "id": "li_47467506", + "item": { + "id": "DEMO-008", + "title": "Drip Tray", + "price": 500, + "description": "Mandatory drip tray for the washing machine.", + "image_url": "https://www.example.com/drip-tray.png" + }, + "quantity": 1, + "totals": [ + { + "type": "subtotal", + "amount": 500 + }, + { + "type": "tax", + "amount": 100 + }, + { + "type": "total", + "amount": 500 + } + ] + }, + { + "id": "li_a525a6ce", + "item": { + "id": "DEMO-007", + "title": "Insurance 12MO", + "price": 1000, + "description": "12-month insurance coverage for your product.", + "image_url": "https://www.example.com/insurance.png" + }, + "quantity": 1, + "totals": [ + { + "type": "subtotal", + "amount": 1000 + }, + { + "type": "tax", + "amount": 200 + }, + { + "type": "total", + "amount": 1000 + } + ] + } + ], + "totals": [ + { + "type": "subtotal", + "amount": 1001500 + }, + { + "type": "tax", + "amount": 250375 + }, + { + "type": "total", + "amount": 1251875 + } + ], + "links": [ + { + "type": "terms_of_service", + "url": "https://example.com/terms" + }, + { + "type": "privacy_policy", + "url": "https://example.com/privacy" + } + ], + "fulfillment": { + "methods": [ + { + "id": "shipping_1", + "type": "shipping", + "line_item_ids": [ + "li_1", + "li_47467506" + ], + "selected_destination_id": "dest_default", + "destinations": [ + { + "id": "dest_default", + "street_address": "Karl Johans gate 1", + "address_locality": "Oslo", + "address_region": "Oslo", + "postal_code": "0154", + "address_country": "NO" + } + ], + "groups": [ + { + "id": "group_1", + "line_item_ids": [ + "li_1", + "li_47467506" + ], + "selected_option_id": "postnord_standard", + "options": [ + { + "id": "porterbuddy_express", + "title": "Hjemlevering ekspress", + "description": "Levering i morgen med valgt tidsluke", + "carrier": "PorterBuddy", + "totals": [ + { + "type": "total", + "amount": 7900 + } + ], + "earliest_fulfillment_time": "2026-02-18T17:00:00.000Z", + "latest_fulfillment_time": "2026-02-18T17:00:00.000Z" + }, + { + "id": "postnord_parcel_box_express", + "title": "Pakkeboks – Ekspresshåndtering", + "description": "Rask levering til nærmeste pakkeboks", + "carrier": "PostNord", + "totals": [ + { + "type": "total", + "amount": 5900 + } + ], + "earliest_fulfillment_time": "2026-02-18T17:00:00.000Z", + "latest_fulfillment_time": "2026-02-19T17:00:00.000Z" + }, + { + "id": "postnord_parcel_box_standard", + "title": "Pakkeboks – Standard", + "description": "Gratis levering til nærmeste pakkeboks", + "carrier": "PostNord", + "totals": [ + { + "type": "total", + "amount": 0 + } + ], + "earliest_fulfillment_time": "2026-02-20T17:00:00.000Z", + "latest_fulfillment_time": "2026-02-22T17:00:00.000Z" + }, + { + "id": "postnord_pickup_point", + "title": "Til butikken i nærheten", + "description": "Levering til nærmeste hentested", + "carrier": "PostNord", + "totals": [ + { + "type": "total", + "amount": 5900 + } + ], + "earliest_fulfillment_time": "2026-02-19T17:00:00.000Z", + "latest_fulfillment_time": "2026-02-21T17:00:00.000Z" + }, + { + "id": "postnord_home_delivery", + "title": "Hjemlevering", + "description": "Levering hjem, leveringsdag avtales", + "carrier": "Posten", + "totals": [ + { + "type": "total", + "amount": 15900 + } + ], + "earliest_fulfillment_time": "2026-02-19T17:00:00.000Z", + "latest_fulfillment_time": "2026-02-22T17:00:00.000Z" + }, + { + "id": "bring_home_carry_in", + "title": "Hjemlevering med innbæring", + "description": "Levering hjem med innbæring. Sjåfør tar kontakt 30-60 min før ankomst.", + "carrier": "Bring", + "totals": [ + { + "type": "total", + "amount": 89900 + } + ], + "earliest_fulfillment_time": "2026-02-20T17:00:00.000Z", + "latest_fulfillment_time": "2026-02-24T17:00:00.000Z" + } + ] + } + ] + }, + { + "id": "pickup_1", + "type": "pickup", + "line_item_ids": [ + "li_1", + "li_47467506" + ], + "selected_destination_id": null, + "destinations": [ + { + "id": "elkjop_oslo_city", + "name": "Elkjøp Oslo City", + "address": { + "street_address": "Stenersgata 1", + "address_locality": "Oslo", + "postal_code": "0050", + "address_country": "NO" + } + }, + { + "id": "elkjop_byporten", + "name": "Elkjøp Byporten", + "address": { + "street_address": "Jernbanetorget 6", + "address_locality": "Oslo", + "postal_code": "0154", + "address_country": "NO" + } + }, + { + "id": "elkjop_storo", + "name": "Elkjøp Storo Storsenter", + "address": { + "street_address": "Vitaminveien 6", + "address_locality": "Oslo", + "postal_code": "0485", + "address_country": "NO" + } + }, + { + "id": "elkjop_cc_vest", + "name": "Elkjøp CC Vest", + "address": { + "street_address": "Lilleakerveien 16", + "address_locality": "Oslo", + "postal_code": "0283", + "address_country": "NO" + } + } + ], + "groups": [ + { + "id": "pickup_group_1", + "line_item_ids": [ + "li_1", + "li_47467506" + ], + "selected_option_id": null, + "options": [ + { + "id": "store_pickup", + "title": "Hent i butikk", + "description": "Klar for henting innen 2 timer", + "earliest_fulfillment_time": "2026-02-17T11:34:31.879Z", + "totals": [ + { + "type": "total", + "amount": 0 + } + ] + } + ] + } + ] + } + ], + "available_methods": [ + { + "type": "shipping", + "line_item_ids": [ + "li_1", + "li_47467506" + ], + "fulfillable_on": "now" + }, + { + "type": "pickup", + "line_item_ids": [ + "li_1", + "li_47467506" + ], + "fulfillable_on": "now", + "description": "Tilgjengelig for henting hos Elkjøp Oslo City" + } + ] + }, + "ancillaries": { + "applied": [ + { + "id": "li_47467506", + "for": "li_1", + "type": "required", + "category": "product", + "description": "Drip Tray (required)", + "automatic": true, + "reason_code": "legal_requirement" + }, + { + "id": "li_a525a6ce", + "for": "li_1", + "type": "suggested", + "category": "service", + "description": "Insurance 12MO" + } + ] + }, + "created_at": "2026-02-17T09:34:31.879Z", + "updated_at": "2026-02-17T09:35:12.911Z", + "expires_at": "2026-02-18T09:34:31.879Z", + "platform_webhook_url": "http://localhost:3000/order/callback", + "platform_profile_url": "http://localhost:3000/.well-known/ucp" + } + ] +} diff --git a/src/infrastructure/ucp_profile.ts b/src/infrastructure/ucp_profile.ts new file mode 100644 index 0000000..e2f2707 --- /dev/null +++ b/src/infrastructure/ucp_profile.ts @@ -0,0 +1,197 @@ +/** + * UCP Profile Utility + * + * Reads the business UCP profile from well-known/profile.json and provides + * helpers for accessing UCP version, capabilities, and response metadata. + */ + +import type { + UCPCapability, + UCPResponseMetadata, +} from "../types/ucp/checkout.ts"; + +// ============================================ +// Profile Types +// ============================================ + +interface UCPProfileCapability { + version: string; + spec?: string; + schema?: string; + extends?: string; +} + +interface UCPProfilePaymentHandler { + id: string; + version: string; + spec?: string; + config_schema?: string; + instrument_schemas?: string[]; + config?: Record; +} + +interface UCPProfileService { + url: string; + transport: string; +} + +interface UCPProfile { + ucp: { + version: string; + services?: Record; + capabilities: Record; + payment_handlers?: Record; + }; + signing_keys?: unknown[]; +} + +// ============================================ +// Profile Loading +// ============================================ + +const PROFILE_FILE = new URL("../well-known/profile.json", import.meta.url); + +let cachedProfile: UCPProfile | null = null; + +/** + * Loads the UCP profile from the well-known/profile.json file. + * Caches the result for subsequent calls. + */ +async function loadProfile(): Promise { + if (cachedProfile) { + return cachedProfile; + } + + const data = await Deno.readTextFile(PROFILE_FILE); + cachedProfile = JSON.parse(data) as UCPProfile; + return cachedProfile; +} + +/** + * Synchronously get the cached profile. + * Throws if profile hasn't been loaded yet. + */ +function getProfile(): UCPProfile { + if (!cachedProfile) { + throw new Error( + "UCP profile not loaded. Call initUCPProfile() during startup.", + ); + } + return cachedProfile; +} + +// ============================================ +// Public API +// ============================================ + +/** + * Initialize the UCP profile. Call this during application startup. + */ +export async function initUCPProfile(): Promise { + await loadProfile(); + const profile = cachedProfile!; + + const capabilities = Object.keys(profile.ucp.capabilities); + const paymentHandlers = Object.keys(profile.ucp.payment_handlers ?? {}); + + console.log(`[UCP] Profile loaded:`); + console.log(` Version: ${profile.ucp.version}`); + console.log(` Capabilities: ${capabilities.join(", ")}`); + console.log(` Payment handlers: ${paymentHandlers.join(", ") || "none"}`); +} + +/** + * Get the UCP API version from the profile. + */ +export function getUCPVersion(): string { + return getProfile().ucp.version; +} + +/** + * Get the list of capability names supported by this business. + */ +export function getCapabilityNames(): string[] { + return Object.keys(getProfile().ucp.capabilities); +} + +/** + * Check if a specific capability is supported. + */ +export function hasCapability(capabilityName: string): boolean { + return capabilityName in getProfile().ucp.capabilities; +} + +/** + * Get capability details for a specific capability. + */ +export function getCapability( + capabilityName: string, +): UCPProfileCapability | undefined { + const capabilities = getProfile().ucp.capabilities[capabilityName]; + return capabilities?.[0]; +} + +/** + * Get the UCPResponseMetadata for use in checkout session responses. + * This includes the version and all capabilities in the format expected by UCP. + */ +export function getUCPResponseMetadata(): UCPResponseMetadata { + const profile = getProfile(); + const capabilities: UCPCapability[] = []; + + for (const [name, caps] of Object.entries(profile.ucp.capabilities)) { + for (const cap of caps) { + capabilities.push({ + name, + version: cap.version, + spec: cap.spec, + schema: cap.schema, + extends: cap.extends, + }); + } + } + + return { + version: profile.ucp.version, + capabilities, + }; +} + +/** + * Get a specific payment handler by its namespace. + */ +export function getPaymentHandler( + namespace: string, +): UCPProfilePaymentHandler | undefined { + const handlers = getProfile().ucp.payment_handlers?.[namespace]; + return handlers?.[0]; +} + +/** + * Get the payment handler ID for a given namespace. + */ +export function getPaymentHandlerId(namespace: string): string | undefined { + return getPaymentHandler(namespace)?.id; +} + +/** + * Get all payment handler IDs supported by this business. + */ +export function getPaymentHandlerIds(): string[] { + const handlers = getProfile().ucp.payment_handlers ?? {}; + return Object.values(handlers).flatMap((h) => h.map((handler) => handler.id)); +} + +/** + * Get the service URL for a given service name. + */ +export function getServiceUrl(serviceName: string): string | undefined { + return getProfile().ucp.services?.[serviceName]?.url; +} + +/** + * Get the raw profile object (for advanced use cases). + */ +export function getRawProfile(): UCPProfile { + return getProfile(); +} diff --git a/src/main.ts b/src/main.ts index cb4c81c..5372c6b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,8 +2,8 @@ import { load } from "@std/dotenv"; import { unknownToError } from "./infrastructure/errors.ts"; // Load .env file BEFORE importing other modules that use env vars -// Use the directory where main.ts is located to find .env -const envFile = new URL("./.env", import.meta.url); +// Look for .env in the project root (parent of src/) +const envFile = new URL("../.env", import.meta.url); try { // Try to read the file first to verify it exists and get proper path handling await Deno.readTextFile(envFile); @@ -69,6 +69,10 @@ const { } = await import("./infrastructure/webhook_sender.ts"); const { handleGetUCPProfile } = await import("./routes/ucp.ts"); +const { initUCPProfile } = await import("./infrastructure/ucp_profile.ts"); + +// Initialize UCP profile (loads capabilities from well-known/profile.json) +await initUCPProfile(); // Initialize signing keys for webhook signatures await initSigningKeys(); diff --git a/src/routes/checkout.ts b/src/routes/checkout.ts index d3cdf0d..00f9d56 100644 --- a/src/routes/checkout.ts +++ b/src/routes/checkout.ts @@ -10,6 +10,14 @@ import { UCP_HEADERS, } from "../infrastructure/ucp_headers.ts"; import { discoverPlatformWebhookUrl } from "../infrastructure/platform_profile.ts"; +import { + getUCPResponseMetadata, + getUCPVersion, +} from "../infrastructure/ucp_profile.ts"; +import { + initializeAncillaries, + updateAncillaries, +} from "../services/ancillaries-service.ts"; import { clearAccessToken, createPayment, @@ -25,7 +33,6 @@ import type { Link, TotalEntry, UCPMessage, - UCPResponseMetadata, UpdateCheckoutSessionRequest, } from "../types/ucp/checkout.ts"; import type { @@ -49,7 +56,6 @@ import { getProductBySku, updateStock } from "./products.ts"; const DATA_FILE = new URL("../data/sessions.json", import.meta.url).pathname; const SESSION_EXPIRY_HOURS = 24; -const UCP_VERSION = "2026-01-11"; // Default links for checkout responses (required per UCP spec) const DEFAULT_LINKS: Link[] = [ @@ -63,22 +69,6 @@ const DEFAULT_LINKS: Link[] = [ }, ]; -// UCP capabilities for this business -const UCP_CAPABILITIES: UCPResponseMetadata = { - version: UCP_VERSION, - capabilities: [ - { - name: "dev.ucp.shopping.checkout", - version: UCP_VERSION, - }, - { - name: "dev.ucp.shopping.fulfillment", - version: UCP_VERSION, - extends: "dev.ucp.shopping.checkout", - }, - ], -}; - // Payment timeout for async PUSH_MESSAGE flow (5 minutes) const PAYMENT_TIMEOUT_MS = 5 * 60 * 1000; @@ -169,14 +159,6 @@ export async function getSessionById( return sessions.find((s) => s.id === sessionId); } -/** Service capabilities advertised in responses */ -const SERVICE_CAPABILITIES = [ - { name: "checkout", version: UCP_VERSION }, - { name: "payment" }, - { name: "shipping" }, - { name: "products" }, -]; - export async function handleCreateCheckoutSession( req: Request, ): Promise { @@ -243,7 +225,7 @@ export async function handleCreateCheckoutSession( } // Build line items with product details (UCP spec format) - const lineItems: LineItemResponse[] = []; + let lineItems: LineItemResponse[] = []; let currency = body.currency ?? "NOK"; // Default to NOK for Vipps for (let idx = 0; idx < body.line_items.length; idx++) { @@ -305,6 +287,12 @@ export async function handleCreateCheckoutSession( }); } + // Initialize ancillaries (applies required ancillaries automatically) + const { updatedLineItems, ancillaries } = await initializeAncillaries( + lineItems, + ); + lineItems = updatedLineItems; + // Build fulfillment options const lineItemIds = lineItems.map((li) => li.id); const fulfillmentMethods = await buildFulfillmentMethods(lineItemIds); @@ -336,7 +324,7 @@ export async function handleCreateCheckoutSession( // Create the UCP session object (spec-compliant format) const session: CheckoutSession = { - ucp: UCP_CAPABILITIES, + ucp: getUCPResponseMetadata(), id: generateSessionId(), status: "incomplete", currency, @@ -350,6 +338,7 @@ export async function handleCreateCheckoutSession( methods: fulfillmentMethods, available_methods: availableMethods, }, + ancillaries: Object.keys(ancillaries).length > 0 ? ancillaries : undefined, created_at: now.toISOString(), updated_at: now.toISOString(), expires_at: expiresAt.toISOString(), @@ -384,7 +373,7 @@ export async function handleCreateCheckoutSession( }); } continueUrl = - `${vippsCheckoutResult.data.checkoutFrontendUrl}?token=${vippsCheckoutResult.data.token}&ec_version=${UCP_VERSION}`; + `${vippsCheckoutResult.data.checkoutFrontendUrl}?token=${vippsCheckoutResult.data.token}&ec_version=${getUCPVersion()}`; } // Save session @@ -401,11 +390,11 @@ export async function handleCreateCheckoutSession( // Add UCP-Capabilities header responseHeaders.set( UCP_HEADERS.CAPABILITIES, - serializeUCPCapabilities(SERVICE_CAPABILITIES), + serializeUCPCapabilities(getUCPResponseMetadata().capabilities), ); // Add UCP-API-Version header - responseHeaders.set(UCP_HEADERS.API_VERSION, UCP_VERSION); + responseHeaders.set(UCP_HEADERS.API_VERSION, getUCPVersion()); // Echo back request context with response timestamp if (ucpHeaders.requestContext) { @@ -493,9 +482,9 @@ export async function handleGetCheckoutSession( const responseHeaders = new Headers({ "Content-Type": "application/json" }); responseHeaders.set( UCP_HEADERS.CAPABILITIES, - serializeUCPCapabilities(SERVICE_CAPABILITIES), + serializeUCPCapabilities(getUCPResponseMetadata().capabilities), ); - responseHeaders.set(UCP_HEADERS.API_VERSION, UCP_VERSION); + responseHeaders.set(UCP_HEADERS.API_VERSION, getUCPVersion()); if (ucpHeaders.requestContext) { responseHeaders.set( @@ -620,6 +609,31 @@ export async function handleUpdateCheckoutSession( } } + // Handle ancillary updates + if (body.ancillaries !== undefined) { + const ancillaryResult = await updateAncillaries( + session, + body.ancillaries.items, + ); + + // Update line items (adds ancillary line items) + session.line_items = ancillaryResult.updatedLineItems; + + // Update ancillaries object + session.ancillaries = Object.keys(ancillaryResult.ancillaries).length > 0 + ? ancillaryResult.ancillaries + : undefined; + + // Log any errors (but don't fail the request) + if (ancillaryResult.errors.length > 0) { + console.log( + `[UpdateCheckout] Ancillary processing warnings: ${ + ancillaryResult.errors.join(", ") + }`, + ); + } + } + // Recalculate totals with new fulfillment selection const subtotal = session.line_items.reduce((sum, li) => { const subtotalEntry = li.totals.find((t) => t.type === "subtotal"); @@ -659,9 +673,9 @@ export async function handleUpdateCheckoutSession( const responseHeaders = new Headers({ "Content-Type": "application/json" }); responseHeaders.set( UCP_HEADERS.CAPABILITIES, - serializeUCPCapabilities(SERVICE_CAPABILITIES), + serializeUCPCapabilities(getUCPResponseMetadata().capabilities), ); - responseHeaders.set(UCP_HEADERS.API_VERSION, UCP_VERSION); + responseHeaders.set(UCP_HEADERS.API_VERSION, getUCPVersion()); if (ucpHeaders.requestContext) { responseHeaders.set( @@ -850,9 +864,9 @@ export async function handleCancelCheckout( const responseHeaders = new Headers({ "Content-Type": "application/json" }); responseHeaders.set( UCP_HEADERS.CAPABILITIES, - serializeUCPCapabilities(SERVICE_CAPABILITIES), + serializeUCPCapabilities(getUCPResponseMetadata().capabilities), ); - responseHeaders.set(UCP_HEADERS.API_VERSION, UCP_VERSION); + responseHeaders.set(UCP_HEADERS.API_VERSION, getUCPVersion()); return new Response(JSON.stringify(session), { status: 200, @@ -877,9 +891,9 @@ export async function handleCancelCheckout( const responseHeaders = new Headers({ "Content-Type": "application/json" }); responseHeaders.set( UCP_HEADERS.CAPABILITIES, - serializeUCPCapabilities(SERVICE_CAPABILITIES), + serializeUCPCapabilities(getUCPResponseMetadata().capabilities), ); - responseHeaders.set(UCP_HEADERS.API_VERSION, UCP_VERSION); + responseHeaders.set(UCP_HEADERS.API_VERSION, getUCPVersion()); if (ucpHeaders.requestContext) { responseHeaders.set( @@ -1142,9 +1156,9 @@ export async function handleCompleteCheckout( const responseHeaders = new Headers({ "Content-Type": "application/json" }); responseHeaders.set( UCP_HEADERS.CAPABILITIES, - serializeUCPCapabilities(SERVICE_CAPABILITIES), + serializeUCPCapabilities(getUCPResponseMetadata().capabilities), ); - responseHeaders.set(UCP_HEADERS.API_VERSION, UCP_VERSION); + responseHeaders.set(UCP_HEADERS.API_VERSION, getUCPVersion()); if (ucpHeaders.requestContext) { responseHeaders.set( diff --git a/src/routes/products.ts b/src/routes/products.ts index c36c820..14d494e 100644 --- a/src/routes/products.ts +++ b/src/routes/products.ts @@ -1,27 +1,25 @@ -import type { ErrorResponse } from "../types/ucp/checkout.ts"; import type { Product, ProductsStore } from "../types/merchant.ts"; const DATA_FILE = new URL("../data/products.json", import.meta.url); -async function loadProducts(): Promise { +async function loadProducts(): Promise { try { const data = await Deno.readTextFile(DATA_FILE); const store: ProductsStore = JSON.parse(data); console.log( `[PRODUCTS] Loaded ${store.products.length} products from ${DATA_FILE.pathname}`, ); - return store.products; + return store; } catch (error) { console.error( `[PRODUCTS] Failed to load products from ${DATA_FILE.pathname}:`, error, ); - return []; + return { products: [] }; } } -async function saveProducts(products: Product[]): Promise { - const store: ProductsStore = { products }; +async function saveProducts(store: ProductsStore): Promise { await Deno.writeTextFile(DATA_FILE, JSON.stringify(store, null, 2)); } @@ -39,23 +37,13 @@ export async function handleGetProduct( sku: string, ): Promise { const products = await loadProducts(); - const product = products.find((p) => p.sku === sku); - + const product = products.products.find((p) => p.sku === sku); if (!product) { - const error: ErrorResponse = { - error: { - type: "not_found", - code: "product_not_found", - message: `Product with SKU '${sku}' not found`, - param: "sku", - }, - }; - return new Response(JSON.stringify(error), { + return new Response(JSON.stringify({ error: "Product not found" }), { status: 404, headers: { "Content-Type": "application/json" }, }); } - return new Response(JSON.stringify(product), { status: 200, headers: { "Content-Type": "application/json" }, @@ -68,18 +56,18 @@ export async function updateStock( quantityChange: number, ): Promise { const products = await loadProducts(); - const productIndex = products.findIndex((p) => p.sku === sku); + const productIndex = products.products.findIndex((p) => p.sku === sku); if (productIndex === -1) { return false; } - const newStock = products[productIndex].stock + quantityChange; + const newStock = products.products[productIndex].stock + quantityChange; if (newStock < 0) { return false; } - products[productIndex].stock = newStock; + products.products[productIndex].stock = newStock; await saveProducts(products); return true; } @@ -87,5 +75,5 @@ export async function updateStock( // Helper to get product by SKU export async function getProductBySku(sku: string): Promise { const products = await loadProducts(); - return products.find((p) => p.sku === sku) ?? null; + return products.products.find((p) => p.sku === sku) ?? null; } diff --git a/src/services/ancillaries-service.ts b/src/services/ancillaries-service.ts new file mode 100644 index 0000000..cefcc70 --- /dev/null +++ b/src/services/ancillaries-service.ts @@ -0,0 +1,437 @@ +/** + * Ancillaries Service + * + * Handles ancillary suggestions, automatic ancillaries, and processing + * ancillary requests for checkout sessions. + */ + +import type { Product } from "../types/merchant.ts"; +import type { + CheckoutSession, + Item, + LineItemResponse, + TotalEntry, +} from "../types/ucp/checkout.ts"; +import type { + AncillariesObject, + AncillaryCategory, + AncillaryItem, + AncillaryRequestItem, + AncillarySuggestion, + AncillarySuggestionType, + AppliedAncillary, +} from "../types/ucp/ancillaries.ts"; +import { getProductBySku } from "../routes/products.ts"; + +// ============================================ +// Configuration +// ============================================ + +/** Tax rate for calculating totals */ +const TAX_RATE = 25; + +// ============================================ +// Helper Functions +// ============================================ + +/** + * Map product type to ancillary category. + */ +function productTypeToCategory(productType: string): AncillaryCategory { + switch (productType) { + case "service": + return "service"; + case "insurance": + return "insurance"; + default: + return "product"; + } +} + +/** + * Map relationship type to suggestion type. + */ +function relationshipTypeToSuggestionType( + relType: string, +): AncillarySuggestionType { + switch (relType) { + case "required": + return "required"; + case "complementary": + return "complementary"; + default: + return "suggested"; + } +} + +/** + * Convert a Product to an AncillaryItem. + */ +function productToAncillaryItem(product: Product): AncillaryItem { + return { + id: product.sku, + title: product.name, + price: product.price, + description: product.description, + image_url: product.image_url, + }; +} + +/** + * Convert a Product to a LineItem Item. + */ +function productToItem(product: Product): Item { + return { + id: product.sku, + title: product.name, + price: product.price, + description: product.description, + image_url: product.image_url, + }; +} + +/** + * Calculate totals for an item. + */ +function calculateItemTotals(price: number, quantity: number): TotalEntry[] { + const subtotal = price * quantity; + const tax = Math.round(subtotal * (TAX_RATE / (100 + TAX_RATE))); + return [ + { type: "subtotal", amount: subtotal }, + { type: "tax", amount: tax }, + { type: "total", amount: subtotal }, + ]; +} + +/** + * Generate a unique line item ID. + */ +function generateLineItemId(): string { + return `li_${crypto.randomUUID().slice(0, 8)}`; +} + +// ============================================ +// Suggestion Building +// ============================================ + +/** + * Build ancillary suggestions for a checkout session based on product relationships. + */ +export async function buildAncillarySuggestions( + lineItems: LineItemResponse[], + appliedAncillarySkus: Set, +): Promise { + const suggestions: AncillarySuggestion[] = []; + const seenSkus = new Set(); + + for (const lineItem of lineItems) { + const product = await getProductBySku(lineItem.item.id); + if (!product?.relationships) continue; + + for (const relationship of product.relationships) { + // Skip if already applied or already suggested + if (appliedAncillarySkus.has(relationship.sku)) continue; + if (seenSkus.has(relationship.sku)) continue; + + const relatedProduct = await getProductBySku(relationship.sku); + if (!relatedProduct) continue; + + seenSkus.add(relationship.sku); + + suggestions.push({ + item: productToAncillaryItem(relatedProduct), + type: relationshipTypeToSuggestionType(relationship.type), + category: productTypeToCategory(relatedProduct.type), + for: lineItem.id, + description: relatedProduct.description, + }); + } + } + + return suggestions; +} + +// ============================================ +// Automatic Ancillaries +// ============================================ + +/** + * Find and apply required ancillaries automatically. + * Returns new line items and applied ancillary records. + */ +export async function applyRequiredAncillaries( + lineItems: LineItemResponse[], + existingApplied: AppliedAncillary[], +): Promise<{ + newLineItems: LineItemResponse[]; + newApplied: AppliedAncillary[]; +}> { + const newLineItems: LineItemResponse[] = []; + const newApplied: AppliedAncillary[] = []; + const appliedSkus = new Set( + existingApplied.map((a) => lineItems.find((li) => li.id === a.id)?.item.id) + .filter(Boolean) as string[], + ); + + for (const lineItem of lineItems) { + const product = await getProductBySku(lineItem.item.id); + if (!product?.relationships) continue; + + const requiredRelationships = product.relationships.filter( + (r) => r.type === "required", + ); + + for (const relationship of requiredRelationships) { + // Skip if already applied + if (appliedSkus.has(relationship.sku)) continue; + + const relatedProduct = await getProductBySku(relationship.sku); + if (!relatedProduct) continue; + + appliedSkus.add(relationship.sku); + + const newLineItemId = generateLineItemId(); + + // Create line item for the required ancillary + newLineItems.push({ + id: newLineItemId, + item: productToItem(relatedProduct), + quantity: lineItem.quantity, + totals: calculateItemTotals(relatedProduct.price, lineItem.quantity), + }); + + // Create applied ancillary record + newApplied.push({ + id: newLineItemId, + for: lineItem.id, + type: "required", + category: productTypeToCategory(relatedProduct.type), + description: `${relatedProduct.name} (required)`, + automatic: true, + reason_code: "legal_requirement", + }); + } + } + + return { newLineItems, newApplied }; +} + +// ============================================ +// Processing Ancillary Requests +// ============================================ + +/** + * Process ancillary request items and create line items. + * Returns new line items and applied ancillary records. + */ +export async function processAncillaryRequest( + requestItems: AncillaryRequestItem[], + existingLineItems: LineItemResponse[], + existingApplied: AppliedAncillary[], +): Promise<{ + newLineItems: LineItemResponse[]; + newApplied: AppliedAncillary[]; + errors: string[]; +}> { + const newLineItems: LineItemResponse[] = []; + const newApplied: AppliedAncillary[] = []; + const errors: string[] = []; + + // Get SKUs of already applied ancillaries + const appliedSkus = new Set( + existingApplied.map((a) => + existingLineItems.find((li) => li.id === a.id)?.item.id + ).filter(Boolean) as string[], + ); + + for (const requestItem of requestItems) { + const sku = requestItem.item.id; + + // Skip if already applied + if (appliedSkus.has(sku)) { + errors.push(`Ancillary ${sku} is already applied`); + continue; + } + + const product = await getProductBySku(sku); + if (!product) { + errors.push(`Product ${sku} not found`); + continue; + } + + // Validate quantity matches related line item if specified + if (requestItem.for) { + const relatedLineItem = existingLineItems.find( + (li) => li.id === requestItem.for, + ); + if (!relatedLineItem) { + errors.push(`Related line item ${requestItem.for} not found`); + continue; + } + if (requestItem.quantity !== relatedLineItem.quantity) { + errors.push( + `Ancillary quantity must match related line item quantity`, + ); + continue; + } + } + + appliedSkus.add(sku); + + const newLineItemId = generateLineItemId(); + + // Create line item + newLineItems.push({ + id: newLineItemId, + item: productToItem(product), + quantity: requestItem.quantity, + totals: calculateItemTotals(product.price, requestItem.quantity), + }); + + // Create applied ancillary record + // Try to find the suggestion type from existing suggestions + let suggestionType: AncillarySuggestionType = "suggested"; + if (requestItem.for) { + const relatedLineItem = existingLineItems.find( + (li) => li.id === requestItem.for, + ); + if (relatedLineItem) { + const parentProduct = await getProductBySku(relatedLineItem.item.id); + const relationship = parentProduct?.relationships?.find( + (r) => r.sku === sku, + ); + if (relationship) { + suggestionType = relationshipTypeToSuggestionType(relationship.type); + } + } + } + + newApplied.push({ + id: newLineItemId, + for: requestItem.for, + type: suggestionType, + category: productTypeToCategory(product.type), + description: product.name, + input: requestItem.input, + }); + } + + return { newLineItems, newApplied, errors }; +} + +// ============================================ +// Main Integration Functions +// ============================================ + +/** + * Build the complete ancillaries object for a checkout session. + * Includes suggestions and applied ancillaries. + */ +export async function buildAncillariesObject( + lineItems: LineItemResponse[], + appliedAncillaries: AppliedAncillary[], +): Promise { + const appliedSkus = new Set( + appliedAncillaries.map((a) => + lineItems.find((li) => li.id === a.id)?.item.id + ).filter(Boolean) as string[], + ); + + const suggested = await buildAncillarySuggestions(lineItems, appliedSkus); + + const result: AncillariesObject = {}; + + if (suggested.length > 0) { + result.title = "Recommended additions"; + result.suggested = suggested; + } + + if (appliedAncillaries.length > 0) { + result.applied = appliedAncillaries; + } + + return result; +} + +/** + * Initialize ancillaries for a new checkout session. + * Applies required ancillaries automatically and builds suggestions. + */ +export async function initializeAncillaries( + lineItems: LineItemResponse[], +): Promise<{ + updatedLineItems: LineItemResponse[]; + ancillaries: AncillariesObject; +}> { + // Apply required ancillaries automatically + const { newLineItems, newApplied } = await applyRequiredAncillaries( + lineItems, + [], + ); + + const updatedLineItems = [...lineItems, ...newLineItems]; + + // Build the ancillaries object + const ancillaries = await buildAncillariesObject( + updatedLineItems, + newApplied, + ); + + return { updatedLineItems, ancillaries }; +} + +/** + * Update ancillaries for an existing checkout session. + * Processes ancillary requests and rebuilds suggestions. + */ +export async function updateAncillaries( + session: CheckoutSession, + requestItems: AncillaryRequestItem[] | undefined, +): Promise<{ + updatedLineItems: LineItemResponse[]; + ancillaries: AncillariesObject; + errors: string[]; +}> { + let lineItems = [...session.line_items]; + let appliedAncillaries = session.ancillaries?.applied ?? []; + const errors: string[] = []; + + // If request items provided, process them + // Note: Per spec, submitting items replaces previously submitted (non-automatic) ancillaries + if (requestItems !== undefined) { + // Remove previously applied non-automatic ancillaries + const automaticApplied = appliedAncillaries.filter((a) => a.automatic); + const automaticLineItemIds = new Set(automaticApplied.map((a) => a.id)); + + // Keep only line items that are not non-automatic ancillaries + const nonAncillaryLineItems = lineItems.filter((li) => { + const isApplied = appliedAncillaries.some((a) => a.id === li.id); + const isAutomatic = automaticLineItemIds.has(li.id); + return !isApplied || isAutomatic; + }); + + lineItems = nonAncillaryLineItems; + appliedAncillaries = automaticApplied; + + // Process new ancillary requests + if (requestItems.length > 0) { + const result = await processAncillaryRequest( + requestItems, + lineItems, + appliedAncillaries, + ); + + lineItems = [...lineItems, ...result.newLineItems]; + appliedAncillaries = [...appliedAncillaries, ...result.newApplied]; + errors.push(...result.errors); + } + } + + // Build the ancillaries object + const ancillaries = await buildAncillariesObject( + lineItems, + appliedAncillaries, + ); + + return { updatedLineItems: lineItems, ancillaries, errors }; +} diff --git a/src/services/checkout-service.ts b/src/services/checkout-service.ts index 4ba3994..5cbe0b3 100644 --- a/src/services/checkout-service.ts +++ b/src/services/checkout-service.ts @@ -10,7 +10,6 @@ import type { CheckoutSessionStatus, LineItemResponse, TotalEntry, - UCPResponseMetadata, } from "../types/ucp/checkout.ts"; import type { SessionsStore } from "../types/merchant.ts"; @@ -21,35 +20,6 @@ import type { SessionsStore } from "../types/merchant.ts"; const DATA_FILE = new URL("../data/sessions.json", import.meta.url).pathname; const SESSION_EXPIRY_HOURS = 24; -/** UCP API version supported by this service */ -export const UCP_VERSION = "2026-01-11"; - -/** UCP capabilities advertised by this business service */ -export const UCP_CAPABILITIES: UCPResponseMetadata = { - version: UCP_VERSION, - capabilities: [ - { - name: "dev.ucp.shopping.checkout", - version: UCP_VERSION, - }, - { - name: "dev.ucp.shopping.fulfillment", - version: UCP_VERSION, - extends: "dev.ucp.shopping.checkout", - }, - ], -}; - -/** Service capabilities for response headers */ -export const SERVICE_CAPABILITIES: Array< - { name: string; version?: string } -> = [ - { name: "checkout", version: UCP_VERSION }, - { name: "payment" }, - { name: "shipping" }, - { name: "products" }, -]; - // ============================================ // Session Persistence // ============================================ diff --git a/src/types/merchant.ts b/src/types/merchant.ts index 7b4d3a3..264181f 100644 --- a/src/types/merchant.ts +++ b/src/types/merchant.ts @@ -2,6 +2,8 @@ import type { CheckoutSession } from "./ucp/checkout.ts"; +export type ProductType = "product" | "service"; + // Product catalog types export interface Product { sku: string; @@ -9,8 +11,15 @@ export interface Product { description: string; price: number; // minor units (cents/øre) currency: string; - stock: number; image_url?: string; + type: ProductType; + stock: number; + relationships?: ProductRelationship[]; +} + +export interface ProductRelationship { + type: "suggested" | "required"; + sku: string; } // Data store types diff --git a/src/types/ucp/ancillaries.ts b/src/types/ucp/ancillaries.ts new file mode 100644 index 0000000..2ddca5a --- /dev/null +++ b/src/types/ucp/ancillaries.ts @@ -0,0 +1,228 @@ +// UCP Ancillaries Extension - https://ucp.dev/specification/ancillaries/ + +/** + * Item details for ancillary suggestions. + * Mirrors the Item type from checkout but defined here to avoid circular imports. + */ +export interface AncillaryItem { + id: string; + title: string; + price: number; // minor units + description?: string; + image_url?: string; +} + +// ============================================ +// Enums / Union Types +// ============================================ + +/** + * Relationship type between ancillary and checkout/line item. + * - complementary: Directly related to a line item (e.g., cables for phone) + * - suggested: General upsell recommendation + * - required: Legally or functionally required addition + */ +export type AncillarySuggestionType = + | "complementary" + | "suggested" + | "required"; + +/** + * Category of ancillary. + * - product: Physical/digital goods (cables, cases, accessories) + * - service: One-time services (installation, setup, gift wrapping) + * - insurance: Protection/warranty plans + */ +export type AncillaryCategory = "product" | "service" | "insurance"; + +/** + * Reason codes for automatically applied ancillaries. + */ +export type AncillaryReasonCode = + | "legal_requirement" + | "promotional_gift" + | "bundle_component" + | "loyalty_benefit"; + +// ============================================ +// Input Types +// ============================================ + +/** + * Option for selection-type inputs. + */ +export interface AncillaryInputOption { + /** Option identifier. Sent as input.value when selected. */ + id: string; + /** Human-readable option label for display to the buyer. */ + label: string; +} + +/** + * Constraints for input validation. + */ +export interface AncillaryInputConstraints { + /** Maximum character length for 'text' type. */ + max_length?: number; +} + +/** + * Schema describing what input is required for an ancillary. + */ +export interface AncillaryInputSchema { + /** + * Input type identifier. + * Well-known types: 'text' (free-form input), 'selection' (choice from options). + */ + type: string; + /** Human-readable label for the input field. */ + label?: string; + /** Additional instructions or context for the input. */ + description?: string; + /** Whether input is required to add this ancillary. Defaults to true. */ + required?: boolean; + /** + * Available options for 'selection' type inputs. + * Options can represent time slots, configuration choices, or other selectable values. + */ + options?: AncillaryInputOption[]; + /** Constraints for input validation. */ + constraints?: AncillaryInputConstraints; +} + +/** + * Input data for ancillaries that require buyer input. + */ +export interface AncillaryInput { + /** + * Well-known input type or custom identifier. + * Well-known types: 'text', 'selection'. + */ + type: string; + /** + * The input value. + * For 'selection': the id of the selected option. + * For 'text': the entered string. + */ + value: string; +} + +// ============================================ +// Request Types +// ============================================ + +/** + * Item reference for ancillary requests. + */ +export interface AncillaryItemReference { + /** The product identifier (SKU) of the ancillary to add. */ + id: string; +} + +/** + * An ancillary item to add to the checkout. + */ +export interface AncillaryRequestItem { + /** Item reference for the ancillary. */ + item: AncillaryItemReference; + /** Line item ID this ancillary relates to. Required for relational ancillaries. */ + for?: string; + /** Quantity of the ancillary. For relational ancillaries, MUST equal the quantity of the related line item. */ + quantity: number; + /** Input data for ancillaries that require buyer input. */ + input?: AncillaryInput; +} + +// ============================================ +// Response Types +// ============================================ + +/** + * A suggested ancillary item offered by the business. + */ +export interface AncillarySuggestion { + /** Full item details for the suggested ancillary. */ + item: AncillaryItem; + /** Relationship type. */ + type: AncillarySuggestionType; + /** Category of ancillary. */ + category: AncillaryCategory; + /** Line item ID this suggestion relates to. Present for complementary/required types. */ + for?: string; + /** + * Groups mutually exclusive product alternatives. + * Each grouped ancillary is a distinct SKU with its own price—only one can be selected. + */ + group_id?: string; + /** Human-readable description explaining why this ancillary is suggested. */ + description?: string; + /** Original price before promotional discount, in minor currency units. */ + original_price?: number; + /** True if this ancillary requires buyer input before it can be added. */ + requires_input?: boolean; + /** Schema describing what input is required. Present when requires_input is true. */ + input_schema?: AncillaryInputSchema; + /** URL to external page with full terms, conditions, or details. */ + terms_url?: string; +} + +/** + * An ancillary that was successfully applied to the checkout. + */ +export interface AppliedAncillary { + /** Line item ID of the applied ancillary in the checkout's line_items array. */ + id: string; + /** Line item ID this ancillary relates to. Present for relational ancillaries. */ + for?: string; + /** Relationship type. Matches the type from the original suggestion. */ + type: AncillarySuggestionType; + /** Category of the applied ancillary. */ + category: AncillaryCategory; + /** Group identifier for mutually exclusive alternatives. */ + group_id?: string; + /** Human-readable description of the applied ancillary. */ + description: string; + /** URL to external page with full terms, conditions, or details. */ + terms_url?: string; + /** True if applied automatically by the business. Cannot be removed by the platform. */ + automatic?: boolean; + /** Why the ancillary was automatically applied. Present when automatic is true. */ + reason_code?: AncillaryReasonCode; + /** Input data provided for this ancillary. Present when the ancillary required input. */ + input?: AncillaryInput; +} + +// ============================================ +// Main Ancillaries Object +// ============================================ + +/** + * Ancillaries request data (for create/update operations). + */ +export interface AncillariesRequest { + /** + * Ancillaries to add. Replaces previously submitted ancillaries. + * Send empty array to clear all non-automatic ancillaries. + */ + items?: AncillaryRequestItem[]; +} + +/** + * Ancillaries response data (included in checkout session responses). + */ +export interface AncillariesResponse { + /** Optional header text for ancillary suggestions. */ + title?: string; + /** Suggested ancillaries offered by the business. */ + suggested?: AncillarySuggestion[]; + /** Ancillaries successfully applied to the checkout. */ + applied?: AppliedAncillary[]; +} + +/** + * Combined ancillaries object for checkout session. + * In requests: only `items` is used. + * In responses: `title`, `suggested`, and `applied` are returned. + */ +export interface AncillariesObject + extends AncillariesRequest, AncillariesResponse {} diff --git a/src/types/ucp/checkout.ts b/src/types/ucp/checkout.ts index 6384bd7..50b93b6 100644 --- a/src/types/ucp/checkout.ts +++ b/src/types/ucp/checkout.ts @@ -4,6 +4,7 @@ import type { FulfillmentResponse, FulfillmentUpdateRequest, } from "./fulfillment.ts"; +import type { AncillariesObject, AncillariesRequest } from "./ancillaries.ts"; export interface TotalEntry { type: "subtotal" | "tax" | "shipping" | "discount" | "total"; @@ -156,6 +157,7 @@ export interface CheckoutSession { shipping_address?: Address; billing_address?: Address; fulfillment?: FulfillmentResponse; + ancillaries?: AncillariesObject; payment?: CheckoutPaymentInfo; messages?: UCPMessage[]; order?: Order; @@ -176,6 +178,7 @@ export interface CreateCheckoutSessionRequest { buyer?: Buyer; shipping_address?: Address; billing_address?: Address; + ancillaries?: AncillariesRequest; metadata?: Record; } @@ -184,4 +187,5 @@ export interface UpdateCheckoutSessionRequest { shipping_address?: Address; billing_address?: Address; fulfillment?: FulfillmentUpdateRequest; + ancillaries?: AncillariesRequest; } diff --git a/src/types/ucp/core.ts b/src/types/ucp/core.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/well-known/profile.json b/src/well-known/profile.json index 02c2c22..ffd427a 100644 --- a/src/well-known/profile.json +++ b/src/well-known/profile.json @@ -30,9 +30,18 @@ "schema": "https://ucp.dev/schemas/shopping/order.json" } ], + "dev.ucp.shopping.products": [ + { + "version": "2026-02-09", + "extends": "dev.ucp.shopping.checkout", + "spec": "https://ucp.dev/specification/products", + "schema": "https://ucp.dev/schemas/shopping/products.json" + } + ], "dev.ucp.shopping.ancillaries": [ { - "version": "2026-01-11", + "version": "2026-02-09", + "extends": "dev.ucp.shopping.checkout", "spec": "https://ucp.dev/specification/ancillaries", "schema": "https://ucp.dev/schemas/shopping/ancillaries.json" } From dfcae9785e032b7f57862feb4ae6c99d44040753 Mon Sep 17 00:00:00 2001 From: alldentobias Date: Tue, 17 Feb 2026 12:58:50 +0100 Subject: [PATCH 2/4] Fixes feedback --- .gitignore | 3 +- src/data/sessions.json | 382 ---------------------------- src/routes/checkout.ts | 14 +- src/routes/products.ts | 18 +- src/services/ancillaries-service.ts | 29 +-- src/types/merchant.ts | 2 +- src/types/ucp/checkout.ts | 4 +- 7 files changed, 40 insertions(+), 412 deletions(-) delete mode 100644 src/data/sessions.json diff --git a/.gitignore b/.gitignore index 2eea525..c850796 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -.env \ No newline at end of file +.env +src/data/sessions.json \ No newline at end of file diff --git a/src/data/sessions.json b/src/data/sessions.json deleted file mode 100644 index 2670c26..0000000 --- a/src/data/sessions.json +++ /dev/null @@ -1,382 +0,0 @@ -{ - "sessions": [ - { - "ucp": { - "version": "2026-01-11", - "capabilities": [ - { - "name": "dev.ucp.shopping.checkout", - "version": "2026-01-11", - "spec": "https://ucp.dev/specification/checkout", - "schema": "https://ucp.dev/schemas/shopping/checkout.json" - }, - { - "name": "dev.ucp.shopping.fulfillment", - "version": "2026-01-11", - "spec": "https://ucp.dev/specification/fulfillment", - "schema": "https://ucp.dev/schemas/shopping/fulfillment.json", - "extends": "dev.ucp.shopping.checkout" - }, - { - "name": "dev.ucp.shopping.order", - "version": "2026-01-11", - "spec": "https://ucp.dev/specification/order", - "schema": "https://ucp.dev/schemas/shopping/order.json" - }, - { - "name": "dev.ucp.shopping.products", - "version": "2026-02-09", - "spec": "https://ucp.dev/specification/products", - "schema": "https://ucp.dev/schemas/shopping/products.json", - "extends": "dev.ucp.shopping.checkout" - }, - { - "name": "dev.ucp.shopping.ancillaries", - "version": "2026-02-09", - "spec": "https://ucp.dev/specification/ancillaries", - "schema": "https://ucp.dev/schemas/shopping/ancillaries.json", - "extends": "dev.ucp.shopping.checkout" - } - ] - }, - "id": "cs-c2e23c18-c8bd-4ef9-861c-f22199370909", - "status": "ready_for_complete", - "currency": "NOK", - "line_items": [ - { - "id": "li_1", - "item": { - "id": "DEMO-006", - "title": "Washing Machine", - "price": 1000000, - "description": "Washing machine with 10kg capacity and 1000 RPM.", - "image_url": "https://images.pexels.com/photos/8774651/pexels-photo-8774651.jpeg" - }, - "quantity": 1, - "totals": [ - { - "type": "subtotal", - "amount": 1000000 - }, - { - "type": "total", - "amount": 1000000 - } - ] - }, - { - "id": "li_47467506", - "item": { - "id": "DEMO-008", - "title": "Drip Tray", - "price": 500, - "description": "Mandatory drip tray for the washing machine.", - "image_url": "https://www.example.com/drip-tray.png" - }, - "quantity": 1, - "totals": [ - { - "type": "subtotal", - "amount": 500 - }, - { - "type": "tax", - "amount": 100 - }, - { - "type": "total", - "amount": 500 - } - ] - }, - { - "id": "li_a525a6ce", - "item": { - "id": "DEMO-007", - "title": "Insurance 12MO", - "price": 1000, - "description": "12-month insurance coverage for your product.", - "image_url": "https://www.example.com/insurance.png" - }, - "quantity": 1, - "totals": [ - { - "type": "subtotal", - "amount": 1000 - }, - { - "type": "tax", - "amount": 200 - }, - { - "type": "total", - "amount": 1000 - } - ] - } - ], - "totals": [ - { - "type": "subtotal", - "amount": 1001500 - }, - { - "type": "tax", - "amount": 250375 - }, - { - "type": "total", - "amount": 1251875 - } - ], - "links": [ - { - "type": "terms_of_service", - "url": "https://example.com/terms" - }, - { - "type": "privacy_policy", - "url": "https://example.com/privacy" - } - ], - "fulfillment": { - "methods": [ - { - "id": "shipping_1", - "type": "shipping", - "line_item_ids": [ - "li_1", - "li_47467506" - ], - "selected_destination_id": "dest_default", - "destinations": [ - { - "id": "dest_default", - "street_address": "Karl Johans gate 1", - "address_locality": "Oslo", - "address_region": "Oslo", - "postal_code": "0154", - "address_country": "NO" - } - ], - "groups": [ - { - "id": "group_1", - "line_item_ids": [ - "li_1", - "li_47467506" - ], - "selected_option_id": "postnord_standard", - "options": [ - { - "id": "porterbuddy_express", - "title": "Hjemlevering ekspress", - "description": "Levering i morgen med valgt tidsluke", - "carrier": "PorterBuddy", - "totals": [ - { - "type": "total", - "amount": 7900 - } - ], - "earliest_fulfillment_time": "2026-02-18T17:00:00.000Z", - "latest_fulfillment_time": "2026-02-18T17:00:00.000Z" - }, - { - "id": "postnord_parcel_box_express", - "title": "Pakkeboks – Ekspresshåndtering", - "description": "Rask levering til nærmeste pakkeboks", - "carrier": "PostNord", - "totals": [ - { - "type": "total", - "amount": 5900 - } - ], - "earliest_fulfillment_time": "2026-02-18T17:00:00.000Z", - "latest_fulfillment_time": "2026-02-19T17:00:00.000Z" - }, - { - "id": "postnord_parcel_box_standard", - "title": "Pakkeboks – Standard", - "description": "Gratis levering til nærmeste pakkeboks", - "carrier": "PostNord", - "totals": [ - { - "type": "total", - "amount": 0 - } - ], - "earliest_fulfillment_time": "2026-02-20T17:00:00.000Z", - "latest_fulfillment_time": "2026-02-22T17:00:00.000Z" - }, - { - "id": "postnord_pickup_point", - "title": "Til butikken i nærheten", - "description": "Levering til nærmeste hentested", - "carrier": "PostNord", - "totals": [ - { - "type": "total", - "amount": 5900 - } - ], - "earliest_fulfillment_time": "2026-02-19T17:00:00.000Z", - "latest_fulfillment_time": "2026-02-21T17:00:00.000Z" - }, - { - "id": "postnord_home_delivery", - "title": "Hjemlevering", - "description": "Levering hjem, leveringsdag avtales", - "carrier": "Posten", - "totals": [ - { - "type": "total", - "amount": 15900 - } - ], - "earliest_fulfillment_time": "2026-02-19T17:00:00.000Z", - "latest_fulfillment_time": "2026-02-22T17:00:00.000Z" - }, - { - "id": "bring_home_carry_in", - "title": "Hjemlevering med innbæring", - "description": "Levering hjem med innbæring. Sjåfør tar kontakt 30-60 min før ankomst.", - "carrier": "Bring", - "totals": [ - { - "type": "total", - "amount": 89900 - } - ], - "earliest_fulfillment_time": "2026-02-20T17:00:00.000Z", - "latest_fulfillment_time": "2026-02-24T17:00:00.000Z" - } - ] - } - ] - }, - { - "id": "pickup_1", - "type": "pickup", - "line_item_ids": [ - "li_1", - "li_47467506" - ], - "selected_destination_id": null, - "destinations": [ - { - "id": "elkjop_oslo_city", - "name": "Elkjøp Oslo City", - "address": { - "street_address": "Stenersgata 1", - "address_locality": "Oslo", - "postal_code": "0050", - "address_country": "NO" - } - }, - { - "id": "elkjop_byporten", - "name": "Elkjøp Byporten", - "address": { - "street_address": "Jernbanetorget 6", - "address_locality": "Oslo", - "postal_code": "0154", - "address_country": "NO" - } - }, - { - "id": "elkjop_storo", - "name": "Elkjøp Storo Storsenter", - "address": { - "street_address": "Vitaminveien 6", - "address_locality": "Oslo", - "postal_code": "0485", - "address_country": "NO" - } - }, - { - "id": "elkjop_cc_vest", - "name": "Elkjøp CC Vest", - "address": { - "street_address": "Lilleakerveien 16", - "address_locality": "Oslo", - "postal_code": "0283", - "address_country": "NO" - } - } - ], - "groups": [ - { - "id": "pickup_group_1", - "line_item_ids": [ - "li_1", - "li_47467506" - ], - "selected_option_id": null, - "options": [ - { - "id": "store_pickup", - "title": "Hent i butikk", - "description": "Klar for henting innen 2 timer", - "earliest_fulfillment_time": "2026-02-17T11:34:31.879Z", - "totals": [ - { - "type": "total", - "amount": 0 - } - ] - } - ] - } - ] - } - ], - "available_methods": [ - { - "type": "shipping", - "line_item_ids": [ - "li_1", - "li_47467506" - ], - "fulfillable_on": "now" - }, - { - "type": "pickup", - "line_item_ids": [ - "li_1", - "li_47467506" - ], - "fulfillable_on": "now", - "description": "Tilgjengelig for henting hos Elkjøp Oslo City" - } - ] - }, - "ancillaries": { - "applied": [ - { - "id": "li_47467506", - "for": "li_1", - "type": "required", - "category": "product", - "description": "Drip Tray (required)", - "automatic": true, - "reason_code": "legal_requirement" - }, - { - "id": "li_a525a6ce", - "for": "li_1", - "type": "suggested", - "category": "service", - "description": "Insurance 12MO" - } - ] - }, - "created_at": "2026-02-17T09:34:31.879Z", - "updated_at": "2026-02-17T09:35:12.911Z", - "expires_at": "2026-02-18T09:34:31.879Z", - "platform_webhook_url": "http://localhost:3000/order/callback", - "platform_profile_url": "http://localhost:3000/.well-known/ucp" - } - ] -} diff --git a/src/routes/checkout.ts b/src/routes/checkout.ts index 00f9d56..d503687 100644 --- a/src/routes/checkout.ts +++ b/src/routes/checkout.ts @@ -624,12 +624,18 @@ export async function handleUpdateCheckoutSession( ? ancillaryResult.ancillaries : undefined; - // Log any errors (but don't fail the request) + // Add any ancillary processing errors as warning messages if (ancillaryResult.errors.length > 0) { + const warningMessages: UCPMessage[] = ancillaryResult.errors.map( + (error) => ({ + type: "warning" as const, + code: "ancillary_processing_warning", + content: error, + }), + ); + session.messages = [...(session.messages ?? []), ...warningMessages]; console.log( - `[UpdateCheckout] Ancillary processing warnings: ${ - ancillaryResult.errors.join(", ") - }`, + `[UpdateCheckout] Ancillary processing warnings: ${ancillaryResult.errors.join(", ")}`, ); } } diff --git a/src/routes/products.ts b/src/routes/products.ts index 14d494e..71f1fce 100644 --- a/src/routes/products.ts +++ b/src/routes/products.ts @@ -39,10 +39,20 @@ export async function handleGetProduct( const products = await loadProducts(); const product = products.products.find((p) => p.sku === sku); if (!product) { - return new Response(JSON.stringify({ error: "Product not found" }), { - status: 404, - headers: { "Content-Type": "application/json" }, - }); + return new Response( + JSON.stringify({ + error: { + type: "not_found", + code: "product_not_found", + message: `Product with SKU '${sku}' not found`, + param: "sku", + }, + }), + { + status: 404, + headers: { "Content-Type": "application/json" }, + }, + ); } return new Response(JSON.stringify(product), { status: 200, diff --git a/src/services/ancillaries-service.ts b/src/services/ancillaries-service.ts index cefcc70..e91a78c 100644 --- a/src/services/ancillaries-service.ts +++ b/src/services/ancillaries-service.ts @@ -13,7 +13,7 @@ import type { TotalEntry, } from "../types/ucp/checkout.ts"; import type { - AncillariesObject, + AncillariesResponse, AncillaryCategory, AncillaryItem, AncillaryRequestItem, @@ -23,13 +23,6 @@ import type { } from "../types/ucp/ancillaries.ts"; import { getProductBySku } from "../routes/products.ts"; -// ============================================ -// Configuration -// ============================================ - -/** Tax rate for calculating totals */ -const TAX_RATE = 25; - // ============================================ // Helper Functions // ============================================ @@ -91,14 +84,14 @@ function productToItem(product: Product): Item { } /** - * Calculate totals for an item. + * Calculate totals for an item (VAT-exclusive, consistent with checkout.ts). */ function calculateItemTotals(price: number, quantity: number): TotalEntry[] { const subtotal = price * quantity; - const tax = Math.round(subtotal * (TAX_RATE / (100 + TAX_RATE))); + // Note: Line item totals don't include tax - tax is calculated at session level + // This matches how checkout.ts handles line items return [ { type: "subtotal", amount: subtotal }, - { type: "tax", amount: tax }, { type: "total", amount: subtotal }, ]; } @@ -327,10 +320,10 @@ export async function processAncillaryRequest( * Build the complete ancillaries object for a checkout session. * Includes suggestions and applied ancillaries. */ -export async function buildAncillariesObject( +export async function buildAncillariesResponse( lineItems: LineItemResponse[], appliedAncillaries: AppliedAncillary[], -): Promise { +): Promise { const appliedSkus = new Set( appliedAncillaries.map((a) => lineItems.find((li) => li.id === a.id)?.item.id @@ -339,7 +332,7 @@ export async function buildAncillariesObject( const suggested = await buildAncillarySuggestions(lineItems, appliedSkus); - const result: AncillariesObject = {}; + const result: AncillariesResponse = {}; if (suggested.length > 0) { result.title = "Recommended additions"; @@ -361,7 +354,7 @@ export async function initializeAncillaries( lineItems: LineItemResponse[], ): Promise<{ updatedLineItems: LineItemResponse[]; - ancillaries: AncillariesObject; + ancillaries: AncillariesResponse; }> { // Apply required ancillaries automatically const { newLineItems, newApplied } = await applyRequiredAncillaries( @@ -372,7 +365,7 @@ export async function initializeAncillaries( const updatedLineItems = [...lineItems, ...newLineItems]; // Build the ancillaries object - const ancillaries = await buildAncillariesObject( + const ancillaries = await buildAncillariesResponse( updatedLineItems, newApplied, ); @@ -389,7 +382,7 @@ export async function updateAncillaries( requestItems: AncillaryRequestItem[] | undefined, ): Promise<{ updatedLineItems: LineItemResponse[]; - ancillaries: AncillariesObject; + ancillaries: AncillariesResponse; errors: string[]; }> { let lineItems = [...session.line_items]; @@ -428,7 +421,7 @@ export async function updateAncillaries( } // Build the ancillaries object - const ancillaries = await buildAncillariesObject( + const ancillaries = await buildAncillariesResponse( lineItems, appliedAncillaries, ); diff --git a/src/types/merchant.ts b/src/types/merchant.ts index 264181f..3d420cd 100644 --- a/src/types/merchant.ts +++ b/src/types/merchant.ts @@ -18,7 +18,7 @@ export interface Product { } export interface ProductRelationship { - type: "suggested" | "required"; + type: "suggested" | "required" | "complementary"; sku: string; } diff --git a/src/types/ucp/checkout.ts b/src/types/ucp/checkout.ts index 50b93b6..f3bd8c4 100644 --- a/src/types/ucp/checkout.ts +++ b/src/types/ucp/checkout.ts @@ -4,7 +4,7 @@ import type { FulfillmentResponse, FulfillmentUpdateRequest, } from "./fulfillment.ts"; -import type { AncillariesObject, AncillariesRequest } from "./ancillaries.ts"; +import type { AncillariesRequest, AncillariesResponse } from "./ancillaries.ts"; export interface TotalEntry { type: "subtotal" | "tax" | "shipping" | "discount" | "total"; @@ -157,7 +157,7 @@ export interface CheckoutSession { shipping_address?: Address; billing_address?: Address; fulfillment?: FulfillmentResponse; - ancillaries?: AncillariesObject; + ancillaries?: AncillariesResponse; payment?: CheckoutPaymentInfo; messages?: UCPMessage[]; order?: Order; From 3c535b6e9ed8e64335a0078395317d61cc74e152 Mon Sep 17 00:00:00 2001 From: alldentobias Date: Thu, 26 Feb 2026 09:36:49 +0100 Subject: [PATCH 3/4] Formatting --- src/infrastructure/ucp_headers.ts | 2 +- src/routes/checkout.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/infrastructure/ucp_headers.ts b/src/infrastructure/ucp_headers.ts index 0a2b072..c4297e8 100644 --- a/src/infrastructure/ucp_headers.ts +++ b/src/infrastructure/ucp_headers.ts @@ -12,8 +12,8 @@ import { date, integer, isItem, - item, type Item, + item, parseDictionary, parseItem, parseList, diff --git a/src/routes/checkout.ts b/src/routes/checkout.ts index b88933a..36744c2 100644 --- a/src/routes/checkout.ts +++ b/src/routes/checkout.ts @@ -100,7 +100,8 @@ export async function handleCreateCheckoutSession( if (ucpHeaders.agent) { console.log( - `📱 Checkout request from agent: ${ucpHeaders.agent.name ?? "unknown" + `📱 Checkout request from agent: ${ + ucpHeaders.agent.name ?? "unknown" } (${ucpHeaders.agent.profile})`, ); @@ -489,7 +490,8 @@ export async function handleUpdateCheckoutSession( ); session.messages = [...(session.messages ?? []), ...warningMessages]; console.log( - `[UpdateCheckout] Ancillary processing warnings: ${ancillaryResult.errors.join(", ") + `[UpdateCheckout] Ancillary processing warnings: ${ + ancillaryResult.errors.join(", ") }`, ); } From 57b5f84dca9b56e0e8ae98fe72ed2947f33580cd Mon Sep 17 00:00:00 2001 From: alldentobias Date: Thu, 26 Feb 2026 09:44:59 +0100 Subject: [PATCH 4/4] Moves payment handler resolving to the ucp profile helper --- src/infrastructure/ucp_profile.ts | 14 ++++++++++++++ src/routes/checkout.ts | 21 ++------------------- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/src/infrastructure/ucp_profile.ts b/src/infrastructure/ucp_profile.ts index e2f2707..83ba089 100644 --- a/src/infrastructure/ucp_profile.ts +++ b/src/infrastructure/ucp_profile.ts @@ -174,6 +174,20 @@ export function getPaymentHandlerId(namespace: string): string | undefined { return getPaymentHandler(namespace)?.id; } +/** + * Get the payment handlers in the format expected by UCP. + * { handler_name: [versions] } + */ +export function mapPaymentHandlers(): Record | undefined { + const handlers = getProfile().ucp.payment_handlers; + if (!handlers) return undefined; + const mapped: Record = {}; + for (const [name, versions] of Object.entries(handlers)) { + mapped[name] = versions.map((v) => v.version); + } + return mapped; +} + /** * Get all payment handler IDs supported by this business. */ diff --git a/src/routes/checkout.ts b/src/routes/checkout.ts index 36744c2..a378c27 100644 --- a/src/routes/checkout.ts +++ b/src/routes/checkout.ts @@ -44,7 +44,7 @@ import { saveSessions, } from "../infrastructure/sessions.ts"; import type { VippsEPaymentAmount } from "../types/vipps/epayment.ts"; -import { ucpProfile } from "../data/ucp-profile.ts"; +import { mapPaymentHandlers } from "../infrastructure/ucp_profile.ts"; const SESSION_EXPIRY_HOURS = 24; @@ -92,8 +92,6 @@ export async function handleCreateCheckoutSession( // Parse UCP headers from the request const ucpHeaders = parseUCPHeaders(req); - const platformUcpProfile = ucpProfile; - // Log agent information if present let platformWebhookUrl: string | undefined; let platformProfileUrl: string | undefined; @@ -212,22 +210,7 @@ export async function handleCreateCheckoutSession( now.getTime() + SESSION_EXPIRY_HOURS * 60 * 60 * 1000, ); - // Map payment handlers to UCP spec format: { handler_name: [versions] } - const mapPaymentHandlers = ( - handlers: Record> | undefined, - ): Record | undefined => { - if (!handlers) return undefined; - const mapped: Record = {}; - for (const [name, versions] of Object.entries(handlers)) { - mapped[name] = versions.map((v) => v.version); - } - return mapped; - }; - - const paymentHandlers = mapPaymentHandlers( - platformUcpProfile?.ucp.payment_handlers, - ); - + const paymentHandlers = mapPaymentHandlers(); // Create the UCP session object (spec-compliant format) const session: CheckoutSession = { ucp: getUCPResponseMetadata(),