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/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 new file mode 100644 index 0000000..a55783a --- /dev/null +++ b/src/data/products.json @@ -0,0 +1,104 @@ +{ + "products": [ + { + "sku": "DEMO-001", + "name": "Wireless Headphones", + "description": "Premium wireless headphones with active noise cancellation and 30-hour battery life.", + "price": 900, + "currency": "NOK", + "stock": 47, + "image_url": "https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=400&h=400&fit=crop", + "type": "product" + }, + { + "sku": "DEMO-002", + "name": "USB-C Cable", + "description": "High-speed USB-C to USB-C cable, 6ft length, supports 100W charging.", + "price": 400, + "currency": "nok", + "stock": 191, + "image_url": "https://images.clasohlson.com/medias/sys_master/h00/hb3/68622828175390.jpg", + "type": "product" + }, + { + "sku": "DEMO-003", + "name": "Laptop Stand", + "description": "Ergonomic aluminum laptop stand with adjustable height and angle.", + "price": 49900, + "currency": "NOK", + "stock": 30, + "image_url": "https://images.unsplash.com/photo-1527864550417-7fd91fc51a46?w=400&h=400&fit=crop", + "type": "product" + }, + { + "sku": "DEMO-004", + "name": "Mechanical Keyboard", + "description": "Full-size mechanical keyboard with RGB backlighting and Cherry MX switches.", + "price": 159900, + "currency": "NOK", + "stock": 25, + "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", + "name": "Webcam HD", + "description": "1080p HD webcam with auto-focus, built-in microphone, and privacy cover.", + "price": 99900, + "currency": "NOK", + "stock": 40, + "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/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/infrastructure/ucp_profile.ts b/src/infrastructure/ucp_profile.ts new file mode 100644 index 0000000..83ba089 --- /dev/null +++ b/src/infrastructure/ucp_profile.ts @@ -0,0 +1,211 @@ +/** + * 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 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. + */ +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 369872e..801b843 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,9 +11,15 @@ import { handleUpdateCheckoutSession, handleVippsCallback, } from "./routes/checkout.ts"; -import { handleGetUCPProfile } from "./routes/ucp.ts"; import { handleShippingCallback } from "./routes/shipping.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 loadConfig(); await initSigningKeys(); diff --git a/src/routes/checkout.ts b/src/routes/checkout.ts index e51875f..a378c27 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, @@ -22,10 +30,8 @@ import type { Item, LineItemResponse, Link, - PaymentHandler, TotalEntry, UCPMessage, - UCPResponseMetadata, UpdateCheckoutSessionRequest, } from "../types/ucp/checkout.ts"; import type { @@ -38,9 +44,9 @@ import { saveSessions, } from "../infrastructure/sessions.ts"; import type { VippsEPaymentAmount } from "../types/vipps/epayment.ts"; +import { mapPaymentHandlers } from "../infrastructure/ucp_profile.ts"; const SESSION_EXPIRY_HOURS = 24; -const UCP_VERSION = "2026-01-11"; // Default links for checkout responses (required per UCP spec) const DEFAULT_LINKS: Link[] = [ @@ -54,22 +60,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; @@ -81,34 +71,6 @@ const VIPPS_POLL_MAX_ATTEMPTS = 150; // Max ~5 minutes of polling const TAX_RATE = 25; // Norwegian VAT 25% -/** Service capabilities advertised in responses */ -const SERVICE_CAPABILITIES = [ - { name: "checkout", version: UCP_VERSION }, - { name: "payment" }, - { name: "shipping" }, - { name: "products" }, -]; - -/** Payment handler matching the /.well-known/ucp profile */ -const PAYMENT_HANDLERS: PaymentHandler[] = [ - { - id: "vippsmobilepay_wallet_handler", - name: "com.vippsmobilepay.pay.payment_handler", - version: "2026-01-23", - spec: - "https://vippsmobilepay.com/pay/ucp/2026-01-23/vipps_mp_payment_handler", - config_schema: - "https://vippsmobilepay.com/pay/ucp/2026-01-23/schemas/wallet_payment_handler.json", - instrument_schemas: [ - "https://vippsmobilepay.com/pay/ucp/2026-01-23/schemas/wallet_payment_instrument.json", - ], - config: { - merchant_serial_number: "168850", - environment: "TEST", - }, - }, -]; - function jsonError( status: number, type: string, @@ -178,7 +140,7 @@ export async function handleCreateCheckoutSession( } // Build line items from request data (UCP spec format) - const lineItems: LineItemResponse[] = []; + let lineItems: LineItemResponse[] = []; const currency = body.currency ?? "NOK"; for (let idx = 0; idx < body.line_items.length; idx++) { @@ -213,6 +175,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); @@ -242,9 +210,10 @@ export async function handleCreateCheckoutSession( now.getTime() + SESSION_EXPIRY_HOURS * 60 * 60 * 1000, ); + const paymentHandlers = mapPaymentHandlers(); // Create the UCP session object (spec-compliant format) const session: CheckoutSession = { - ucp: UCP_CAPABILITIES, + ucp: getUCPResponseMetadata(), id: generateSessionId(), status: "incomplete", currency, @@ -258,9 +227,7 @@ export async function handleCreateCheckoutSession( methods: fulfillmentMethods, available_methods: availableMethods, }, - payment: { - handlers: PAYMENT_HANDLERS, - }, + payment: paymentHandlers ? { handlers: paymentHandlers } : undefined, created_at: now.toISOString(), updated_at: now.toISOString(), expires_at: expiresAt.toISOString(), @@ -268,6 +235,7 @@ export async function handleCreateCheckoutSession( // Store platform webhook URL from UCP-Agent profile for order events platform_webhook_url: platformWebhookUrl, platform_profile_url: platformProfileUrl, + ancillaries: Object.keys(ancillaries).length > 0 ? ancillaries : undefined, }; // Save session @@ -284,11 +252,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) { @@ -367,9 +335,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( @@ -479,6 +447,39 @@ 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; + + // 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(", ") + }`, + ); + } + } + // Recalculate totals with new fulfillment selection const subtotal = session.line_items.reduce((sum, li) => { const subtotalEntry = li.totals.find((t) => t.type === "subtotal"); @@ -518,9 +519,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( @@ -699,9 +700,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, @@ -726,9 +727,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( @@ -957,9 +958,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 new file mode 100644 index 0000000..71f1fce --- /dev/null +++ b/src/routes/products.ts @@ -0,0 +1,89 @@ +import type { Product, ProductsStore } from "../types/merchant.ts"; + +const DATA_FILE = new URL("../data/products.json", import.meta.url); + +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; + } catch (error) { + console.error( + `[PRODUCTS] Failed to load products from ${DATA_FILE.pathname}:`, + error, + ); + return { products: [] }; + } +} + +async function saveProducts(store: ProductsStore): Promise { + await Deno.writeTextFile(DATA_FILE, JSON.stringify(store, null, 2)); +} + +export async function handleGetProducts(_req: Request): Promise { + const products = await loadProducts(); + + return new Response(JSON.stringify({ products }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +export async function handleGetProduct( + _req: Request, + sku: string, +): Promise { + const products = await loadProducts(); + const product = products.products.find((p) => p.sku === sku); + if (!product) { + 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, + headers: { "Content-Type": "application/json" }, + }); +} + +// Helper to update stock (used by checkout) +export async function updateStock( + sku: string, + quantityChange: number, +): Promise { + const products = await loadProducts(); + const productIndex = products.products.findIndex((p) => p.sku === sku); + + if (productIndex === -1) { + return false; + } + + const newStock = products.products[productIndex].stock + quantityChange; + if (newStock < 0) { + return false; + } + + products.products[productIndex].stock = newStock; + await saveProducts(products); + return true; +} + +// Helper to get product by SKU +export async function getProductBySku(sku: string): Promise { + const products = await loadProducts(); + return products.products.find((p) => p.sku === sku) ?? null; +} diff --git a/src/schema/payment_instrument.json b/src/schema/payment_instrument.json deleted file mode 100644 index 02cd04a..0000000 --- a/src/schema/payment_instrument.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://vippsmobilepay.com/pay/ucp/2026-01-23/schemas/payment_instrument.json", - "title": "Vipps MobilePay Payment Instrument", - "description": "Payment instrument schema for the com.vippsmobilepay.pay.payment_handler payment handler. Supports WALLET (push notification) and CARD (redirect) payment methods.", - "type": "object", - "required": [ - "type" - ], - "properties": { - "type": { - "enum": [ - "WALLET", - "CARD" - ], - "description": "The payment instrument type. WALLET uses push notification flow with customer credentials. CARD uses redirect flow for card payments (no wallet required)." - }, - "credential": { - "$ref": "https://vippsmobilepay.com/pay/ucp/2026-01-23/schemas/wallet_payment_credential.json", - "description": "The credential information required to process WALLET payments. Required for WALLET type, not used for CARD type." - }, - "return_url": { - "type": "string", - "format": "uri", - "description": "The URL to redirect the user back to after completing the CARD payment. Required for CARD type. Can be a universal link (e.g., myapp://payment-complete) or website URL (e.g., https://example.com/payment-complete)." - } - }, - "allOf": [ - { - "if": { - "properties": { - "type": { "const": "WALLET" } - } - }, - "then": { - "required": ["credential"], - "properties": { - "credential": { - "description": "Required for WALLET payments. Contains the customer identifier (MSISDN or TOKEN)." - } - } - } - }, - { - "if": { - "properties": { - "type": { "const": "CARD" } - } - }, - "then": { - "required": ["return_url"], - "properties": { - "return_url": { - "description": "Required for CARD payments. The URL to redirect the user back to after payment completion." - } - } - } - } - ] -} diff --git a/src/schema/vipps_mp_payment_handler.md b/src/schema/vipps_mp_payment_handler.md deleted file mode 100644 index 1252b8d..0000000 --- a/src/schema/vipps_mp_payment_handler.md +++ /dev/null @@ -1,1095 +0,0 @@ - - -# Vipps Mobilepay Payment Handler - -- **Status:** `DRAFT` -- **Handler Name:** `com.vippsmobilepay.pay.payment_handler` -- **Version:** `2026-01-23` - -## Introduction - -This payment handler supports payment creation through the -[Vipps MobilePay ePayment API](https://developer.vippsmobilepay.com/api/epayment/#tag/CreatePayments/operation/createPayment). -Two payment methods are supported: - -- **WALLET** - For customers with a Vipps MobilePay wallet. Uses push - notification flow (`userFlow: PUSH_MESSAGE`). -- **CARD** - For customers without a wallet. Uses redirect flow - (`userFlow: WEB_REDIRECT`) where users enter card details on the Vipps - MobilePay payment page. - -### Key Benefits - -- Efficient integration with Vipps MobilePay payment services. -- Seamless checkout experience for customers with or without a Vipps MobilePay - wallet. -- Secure handling of payment credentials and transactions. - -### Integration Guide - -| Participant | Integration Section | -| :----------- | :-------------------------------------------- | -| **Business** | [Business Integration](#business-integration) | -| **Platform** | [Platform Integration](#platform-integration) | - ---- - -## Participants - -This payment handler covers two participants: the **business** for which a -_customer_ is shopping from and the **platform** that facilitates the checkout -process (such as an agent). - -- The **business** is responsible for processing payments using this handler, -- The **platform** offers the supported _payment instrument_ to the customer in - the checkout flow. -- The **business** registers this handler and configures/implements it, and the - **platform** discovers and utilizes the handler during checkout. -- The **business** needs to have an active merchant agreement and an ecommerce - sales unit registered with Vipps Mobilepay -- The **platform** needs to validate that a user has a valid Vipps Mobilepay - identity before offering this payment option. Suggestion is to utilize the - [Vipps Login Product](https://developer.vippsmobilepay.com/docs/APIs/login-api/) - to do so - -> **Note on Terminology:** While this specification refers to the participant as -> the **"business,"** technical schema fields may retain the standard industry -> nomenclature **`merchant_*`** (e.g., `merchant_id`). Mappings are documented -> below. - -| Participant | Role | Prerequisites | -| :----------- | :----------------------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------- | -| **Business** | Creates a payment for the defined customer in the Vipps MobilePay ecosystem | Needs to have an active merchant agreement and ecom sales unit with Vipps Mobilepay | -| **Platform** | Collects the customer information, along with validating that the customer has the Vipps wallet. | Needs to validate that the user has the Vipps MobilePay wallet | - -### Payment Flow Diagram - -The following diagram shows the async payment flow for this handler. The -credential can be either MSISDN (phone number) or TOKEN (customer token): - -``` -Platform Business Vipps User - │ │ │ │ - │ POST /complete │ │ │ - │ (MSISDN or TOKEN) │ │ │ - │──────────────────────────>│ │ │ - │ │ CreatePayment │ │ - │ │ (phoneNumber or │ │ - │ │ customerToken) │ │ - │ │─────────────────────────>│ │ - │ │ state: CREATED │ Push Notification │ - │ │<─────────────────────────│───────────────────────>│ - │ status: complete_in_progress │ │ - │<──────────────────────────│ │ │ - │ │ │ │ - │ (tells user to check Vipps) │ User Approves │ - │ │ │<───────────────────────│ - │ │ Callback: AUTHORIZED │ │ - │ │<─────────────────────────│ │ - │ │ (updates session) │ │ - │ │ │ │ - │ GET /checkout-sessions/id │ │ │ - │──────────────────────────>│ │ │ - │ status: completed + order │ │ │ - │<──────────────────────────│ │ │ -``` - ---- - -> **Note:** To create a payment, the business **MUST** create a -> [Vipps Mobilepay Access Token](https://developer.vippsmobilepay.com/docs/APIs/access-token-api/) -> using their credentials and then call the -> [Vipps Mobilepay Epayment API CreatePayments endpoint](https://developer.vippsmobilepay.com/docs/APIs/epayment-api/) -> to create a payment. The suggested payment `userFlow` is `PUSH_MESSAGE`, which -> triggers a push notification to the user's Vipps Mobilepay app to approve the -> payment. - -## Business Integration - -### Prerequisites - -Before advertising this handler, businesses **MUST** complete: - -1. Create a merchant agreement and register an Ecommerce sales unit with Vipps - Mobilepay -2. Implement server-side logic to generate access tokens and create payments - using the Vipps Mobilepay - [AccessToken](https://developer.vippsmobilepay.com/docs/APIs/access-token-api/) - and [ePayment](https://developer.vippsmobilepay.com/docs/APIs/epayment-api/) - APIs. - -**Prerequisites Output:** - -| Field | Description | -| :------------------------------ | :---------------------------------------------------------- | -| `identity.clientId` | The client id to call the Vipps Mobilepay APIs with | -| `identity.clientSecret` | The secret to call the Vipps Mobilepay APIs with | -| `identity.subscriptionKey` | The subscription key to call the Vipps Mobilepay APIs with. | -| `merchant.merchantSerialNumber` | The merchant identifier in the Vipps Mobilepay systems | - -### Handler Configuration - -Businesses advertise support for this handler in their `/.well-known/ucp` -profile under the `payment.handlers` array. - -#### Configuration Schema - -**Schema URL:** -`https://vippsmobilepay.com/pay/ucp/2026-01-23/schemas/wallet_payment_handler.json` - -| Field | Type | Required | Description | -| :---------------------------- | :----- | :------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `merchant_serial_number` | string | **Yes** | The merchant identifier (MSN) in the Vipps MobilePay systems. This is obtained when registering an ecommerce sales unit with Vipps MobilePay. Format varies by country (5-6 digits typically). | -| `environment` | enum | No | The Vipps MobilePay environment. One of `TEST` or `PRODUCTION`. Defaults to `PRODUCTION` if not specified. Use `TEST` for sandbox testing with test credentials. | -| `allowed_payment_instruments` | array | No | The payment instruments allowed for this handler. If not specified, all instrument types are allowed. Supported types: `WALLET` (push notification flow) and `CARD` (redirect flow). | - -#### Example Handler Declaration - -```json -{ - "payment": { - "handlers": [ - { - "id": "vipps_mobilepay", - "name": "com.vippsmobilepay.pay.payment_handler", - "version": "2026-01-23", - "spec": "https://vippsmobilepay.com/pay/ucp/2026-01-23/vipps_mp_payment_handler", - "config_schema": "https://vippsmobilepay.com/pay/ucp/2026-01-23/schemas/wallet_payment_handler.json", - "instrument_schemas": [ - "https://vippsmobilepay.com/pay/ucp/2026-01-23/schemas/payment_instrument.json" - ], - "config": { - "merchant_serial_number": "123456", - "environment": "PRODUCTION" - } - } - ] - } -} -``` - -### Instrument Schema - -**Schema URL:** -`https://vippsmobilepay.com/pay/ucp/2026-01-23/schemas/payment_instrument.json` - -The Vipps MobilePay payment instrument defines the structure for payments -submitted by platforms. - -| Field | Type | Required | Description | -| :----------- | :----- | :-------------------- | :----------------------------------------------------------------------------------------------------------- | -| `type` | enum | **Yes** | The payment instrument type. One of `WALLET` or `CARD`. | -| `credential` | object | **Yes** (WALLET only) | The credential information required to process WALLET payments. See [Credential Schema](#credential-schema). | -| `return_url` | string | **Yes** (CARD only) | The URL to redirect the user back to after CARD payment. Can be a universal link or website URL. | - -#### Instrument Types - -| Type | Vipps `paymentMethod.type` | Vipps `userFlow` | Description | -| :------- | :------------------------- | :--------------- | :------------------------------------------------------------------------------------- | -| `WALLET` | `WALLET` | `PUSH_MESSAGE` | Push notification to user's Vipps app. Requires customer credential (MSISDN or TOKEN). | -| `CARD` | `CARD` | `WEB_REDIRECT` | Redirect user to Vipps payment page to enter card details. No wallet required. | - -#### Credential Schema - -**Schema URL:** -`https://vippsmobilepay.com/pay/ucp/2026-01-23/schemas/wallet_payment_credential.json` - -The credential object contains the buyer's Vipps MobilePay wallet identifier. -Two credential types are supported: - -| Field | Type | Required | Description | -| :------ | :----- | :------- | :----------------------------------------------------------------------- | -| `type` | enum | **Yes** | The credential type. One of `MSISDN` or `TOKEN`. | -| `value` | string | **Yes** | The credential value. Format depends on the credential type (see below). | - -##### MSISDN Credential (Public) - -The MSISDN credential uses the customer's phone number. This maps to the -`customer.phoneNumber` field in the -[Vipps ePayment API](https://developer.vippsmobilepay.com/api/epayment/#tag/CreatePayments/operation/createPayment). - -| Field | Description | -| :------ | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `value` | The phone number in MSISDN format: digits only with country code and subscriber number, no leading zeros or plus sign. Example: `4712345678` for Norwegian number +47 12 34 56 78. | - -**Example MSISDN Instrument:** - -```json -{ - "type": "WALLET", - "credential": { - "type": "MSISDN", - "value": "4712345678" - } -} -``` - -##### TOKEN Credential - -The TOKEN credential uses an encoded customer token obtained from authenticated -user sessions. This maps to the `customer.customerToken` field in the -[Vipps ePayment API](https://developer.vippsmobilepay.com/api/epayment/#tag/CreatePayments/operation/createPayment). - -| Field | Description | -| :------ | :--------------------------------------------------------------------- | -| `value` | An encoded customer token string obtained from authenticated sessions. | - -**Example TOKEN Instrument:** - -```json -{ - "type": "WALLET", - "credential": { - "type": "TOKEN", - "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." - } -} -``` - -#### CARD Instrument (No Wallet Required) - -The CARD instrument allows customers who do not have a Vipps MobilePay wallet to -pay using their card. This uses the redirect flow where the user is sent to the -Vipps MobilePay payment page to enter their card details. - -| Field | Type | Required | Description | -| :----------- | :----- | :------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `type` | enum | **Yes** | Must be `CARD`. | -| `return_url` | string | **Yes** | The URL to redirect the user back to after payment. Can be a universal link (e.g., `myapp://payment-complete`) or website URL (e.g., `https://example.com/payment-complete`). | - -**Example CARD Instrument:** - -```json -{ - "type": "CARD", - "return_url": "https://chat.vippsmobilepay.com/payment-complete?session_id=cs-ABC123XYZ" -} -``` - -**Example CARD Instrument with Universal Link:** - -```json -{ - "type": "CARD", - "return_url": "vipps-chat://payment-complete?session_id=cs-ABC123XYZ" -} -``` - ---- - -### Processing Payments - -Upon receiving a call for completing a checkout with this handler, businesses -**MUST**: - -1. **Validate Handler:** Confirm `instrument.handler_id` matches an advertised - handler. -2. **Ensure Idempotency:** If the request is a retry (matches a previous - `checkout_id` or idempotency key), return the previous result immediately - without re-processing funds. -3. **Determine Payment Method:** Based on the instrument type: - - `WALLET` → Use `paymentMethod: { type: "WALLET" }` and - `userFlow: "PUSH_MESSAGE"` - - `CARD` → Use `paymentMethod: { type: "CARD" }` and - `userFlow: "WEB_REDIRECT"` -4. **Build Vipps API Request:** See [WALLET Payment Flow](#wallet-payment-flow) - or [CARD Payment Flow](#card-payment-flow-redirect) below. -5. Call the - [Vipps ePayment API CreatePayment](https://developer.vippsmobilepay.com/api/epayment/#tag/CreatePayments/operation/createPayment) - endpoint. -6. **Return Response:** Based on instrument type: - - `WALLET` → Return `status: complete_in_progress` with message to check - Vipps app - - `CARD` → Return `status: complete_in_progress` with `continue_url` for - platform to redirect user -7. **Handle Vipps Callback and Poll:** When the user approves/rejects, Vipps - sends a callback. Update the checkout session accordingly. As callbacks are - not guaranteed, implement polling as a backup to check payment status. It is - also possible to base this on webhooks if the platform supports that. -8. **Return Completed Order:** When the platform polls and payment is - authorized, return `status: completed` with the order. - -#### Vipps API `paymentMethod` Mapping - -The `paymentMethod.type` field in the Vipps ePayment API **MUST** match the -instrument type: - -| Instrument Type | Vipps `paymentMethod.type` | Vipps `userFlow` | Description | -| :-------------- | :------------------------- | :--------------- | :-------------------------------------------- | -| `WALLET` | `WALLET` | `PUSH_MESSAGE` | Push notification to user's Vipps app | -| `CARD` | `CARD` | `WEB_REDIRECT` | Redirect to Vipps payment page for card entry | - -See the -[Vipps ePayment API documentation](https://developer.vippsmobilepay.com/api/epayment/#tag/CreatePayments/operation/createPayment) -for full details on the `paymentMethod` and `userFlow` fields. - ---- - -### WALLET Payment Flow - -The Vipps MobilePay `PUSH_MESSAGE` flow is **asynchronous** - the user must -approve the payment in their Vipps app. This can take seconds to minutes. -Businesses **MUST NOT** block the HTTP response waiting for user approval. - -#### Why Async? - -| Approach | Problem | -| -------------------- | ---------------------------------------------- | -| Block HTTP request | Timeouts after 30s, user often needs more time | -| Short timeout (5s) | User can't approve that fast | -| Keep connection open | Unreliable, poor scalability | - -#### Recommended Flow - -1. **Business creates payment** → Vipps returns `state: CREATED` -2. **Business returns immediately** with `status: complete_in_progress` -3. **Vipps sends push notification** to user's phone -4. **User approves** in Vipps app -5. **Vipps sends callback** to business with `state: AUTHORIZED` -6. **Business updates checkout** to `status: completed` -7. **Platform polls** and receives completed order - -#### Complete Checkout Response (Pending Approval) - -When the payment is created but awaiting user approval: - -```json -{ - "id": "cs-ABC123XYZ", - "status": "complete_in_progress", - "messages": [ - { - "type": "info", - "code": "payment_pending_user_approval", - "content": "A payment request has been sent to your Vipps app. Please open Vipps and approve the payment to complete your order." - } - ], - "payment": { - "state": "pending_approval", - "expires_at": "2026-01-26T12:35:00Z" - }, - "ucp": { - "version": "2026-01-11", - "capability": "dev.ucp.shopping.checkout" - } -} -``` - -#### Message Codes - -| Code | Type | Instrument | Description | -| ------------------------------- | ------- | ---------- | ------------------------------------------------------------------- | -| `payment_pending_user_approval` | `info` | WALLET | Payment request sent, awaiting user approval in Vipps app | -| `payment_redirect_required` | `info` | CARD | User must be redirected to Vipps payment page to enter card details | -| `payment_approved` | `info` | Both | User approved/completed the payment | -| `payment_rejected` | `error` | Both | User rejected or cancelled the payment | -| `payment_expired` | `error` | Both | Payment request expired before user responded | - -#### Timeout Handling - -Businesses **SHOULD** implement timeout handling: - -- **Default timeout:** 5 minutes from payment creation -- **On timeout:** Cancel the Vipps payment and set checkout to error state -- **Expose expiry:** Include `payment.expires_at` in responses - ---- - -### Vipps Callback Handling - -Businesses **MUST** implement a callback endpoint to receive payment status -updates from Vipps. - -> **Important:** Vipps does not guarantee callback delivery. Businesses **MUST** -> also implement polling as a backup. See -> [Vipps Polling Guidelines](https://developer.vippsmobilepay.com/docs/knowledge-base/polling-guidelines/). - -**Callback URL:** Configured in `VippsMerchantInfo.callbackUrl` - -**Callback Payload:** - -```json -{ - "reference": "cs-ABC123XYZ", - "state": "AUTHORIZED", - "pspReference": "1234567890" -} -``` - -**Callback States:** - -| Vipps State | Action | -| ------------ | --------------------------------------------- | -| `AUTHORIZED` | Update checkout to `completed`, create order | -| `ABORTED` | Update checkout with error: user rejected | -| `EXPIRED` | Update checkout with error: payment expired | -| `TERMINATED` | Update checkout with error: payment cancelled | - ---- - -### Business-Side Polling (Backup) - -Since Vipps callbacks are not guaranteed, businesses **MUST** implement polling -as a backup mechanism to check payment status. - -**Vipps Polling Guidelines:** - -- **Start polling:** 5 seconds after payment creation -- **Poll interval:** Every 2 seconds -- **Endpoint:** `GET /epayment/v1/payments/{reference}` - -**Example Polling Logic:** - -```javascript -async function pollVippsPaymentStatus(reference: string, maxAttempts = 150) { - const POLL_INTERVAL_MS = 2000; // 2 seconds - const INITIAL_DELAY_MS = 5000; // 5 seconds - - // Wait 5 seconds before starting - await new Promise(resolve => setTimeout(resolve, INITIAL_DELAY_MS)); - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const payment = await getVippsPayment(reference); - - if (payment.state === "AUTHORIZED") { - // Payment approved - complete the order - return { success: true, state: "AUTHORIZED" }; - } - - if (["ABORTED", "EXPIRED", "TERMINATED"].includes(payment.state)) { - // Payment failed - update checkout with error - return { success: false, state: payment.state }; - } - - // Still CREATED - wait and poll again - await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)); - } - - // Timeout - treat as expired - return { success: false, state: "TIMEOUT" }; -} -``` - -> **Note:** The callback and polling mechanisms are complementary. Whichever -> receives the status update first should process it, and the other should -> recognize that processing has already occurred (idempotency). - ---- - -### CARD Payment Flow (Redirect) - -The CARD payment flow uses `WEB_REDIRECT` user flow. The user is redirected to -the Vipps MobilePay payment page to enter their card details. This flow is for -customers who do not have a Vipps MobilePay wallet. - -#### CARD Flow Diagram - -``` -Platform Business Vipps User - │ │ │ │ - │ POST /complete │ │ │ - │ (type: CARD, return_url) │ │ │ - │──────────────────────────>│ │ │ - │ │ CreatePayment │ │ - │ │ (paymentMethod: CARD, │ │ - │ │ userFlow: WEB_REDIRECT, │ │ - │ │ returnUrl) │ │ - │ │─────────────────────────>│ │ - │ │ state: CREATED │ │ - │ │ + redirectUrl │ │ - │ │<─────────────────────────│ │ - │ status: complete_in_progress │ │ - │ + continue_url │ │ │ - │<──────────────────────────│ │ │ - │ │ │ │ - │ (redirects user) │ │ │ - │───────────────────────────────────────────────────────────────────────────────>│ - │ │ │ User enters card │ - │ │ │<───────────────────────│ - │ │ Callback: AUTHORIZED │ │ - │ │<─────────────────────────│ │ - │ │ │ Redirect to returnUrl │ - │<───────────────────────────────────────────────────────────────────────────────│ - │ │ │ │ - │ GET /checkout-sessions/id │ │ │ - │──────────────────────────>│ │ │ - │ status: completed + order │ │ │ - │<──────────────────────────│ │ │ -``` - -#### CARD-Specific Request Fields - -When creating a payment with `paymentMethod: { type: "CARD" }`, the business -**MUST** include: - -| Field | Description | -| :------------------- | :-------------------------------------------------------------------------------------------- | -| `paymentMethod.type` | **MUST** be `CARD` | -| `userFlow` | **MUST** be `WEB_REDIRECT` | -| `returnUrl` | The URL to redirect the user back to after payment. Use the `return_url` from the instrument. | - -#### CARD Business Response - -When the payment is created, Vipps returns a `redirectUrl`. The business -**MUST** return this to the platform as `continue_url` per the -[UCP Continue URL specification](https://ucp.dev/latest/specification/checkout/#continue-url). - -The `continue_url` enables checkout handoff from platform to business UI, -allowing the buyer to complete the payment on the Vipps MobilePay payment page. -Per UCP, when `status` is `complete_in_progress`, the business is processing the -Complete Checkout request and may provide a `continue_url` for user interaction. - -```json -{ - "id": "cs-ABC123XYZ", - "status": "complete_in_progress", - "continue_url": "https://api.vipps.no/checkout/v3/session/...", - "messages": [ - { - "type": "info", - "code": "payment_redirect_required", - "content": "Please complete your payment on the Vipps MobilePay payment page." - } - ], - "payment": { - "state": "pending_redirect", - "expires_at": "2026-01-26T12:35:00Z" - } -} -``` - -#### Platform Redirect Handling - -When the platform receives a `complete_in_progress` response with a -`continue_url` and message code `payment_redirect_required`: - -1. **Redirect the user:** Open the `continue_url` in a browser or webview to - hand off to the Vipps payment page -2. **User completes payment:** User enters card details on the Vipps payment - page -3. **Return redirect:** After payment, Vipps redirects user to the `returnUrl` - provided in the instrument -4. **Poll for completion:** After redirect, poll `GET /checkout-sessions/{id}` - to get the final status (`completed` or error) - -**Return URL Formats:** - -| Type | Example | Use Case | -| :------------- | :---------------------------------------------------------------- | :------------------------- | -| Universal Link | `vipps-chat://payment-complete?session_id=cs-ABC123XYZ` | Native mobile app | -| Website URL | `https://chat.vippsmobilepay.com/payment-complete?session_id=...` | Web-based chat or fallback | - -> **Note:** The `returnUrl` should include sufficient context (e.g., -> `session_id`) for the platform to resume the checkout flow after the user -> returns. - ---- - -### Example Business Implementation - -```javascript -const instrument: PaymentInstrument; // The payment instrument from the request -const checkout: Checkout; // The checkout with lineItems -const vippsCredentials: VippsCredentials; // Vipps Credentials -const accessToken: string; // The accessToken you received from Vipps -const idempotencyKey: string; // Your idempotency key - -const url = "https://apitest.vipps.no/epayment/v1/payments"; // Replace for production - -/** - * Build the Vipps API request body based on instrument type. - * See: https://developer.vippsmobilepay.com/api/epayment/#tag/CreatePayments/operation/createPayment - */ -function buildVippsPaymentRequest(instrument: PaymentInstrument, checkout: Checkout) { - const baseRequest = { - amount: { - currency: checkout.currency, - value: checkout.totals.amount - }, - reference: checkout.id, - paymentDescription: `Order ${checkout.id}` - }; - - if (instrument.type === "WALLET") { - // WALLET: Use PUSH_MESSAGE flow with customer credential - return { - ...baseRequest, - customer: buildCustomerObject(instrument.credential), - paymentMethod: { - type: "WALLET" // REQUIRED for WALLET instruments - }, - userFlow: "PUSH_MESSAGE" - }; - } else if (instrument.type === "CARD") { - // CARD: Use WEB_REDIRECT flow, no customer credential needed - return { - ...baseRequest, - paymentMethod: { - type: "CARD" // REQUIRED for CARD instruments - }, - userFlow: "WEB_REDIRECT", - returnUrl: instrument.return_url // Platform provides this for redirect back - }; - } - - throw new Error(`Unsupported instrument type: ${instrument.type}`); -} - -/** - * Build the customer object for WALLET payments based on credential type. - */ -function buildCustomerObject(credential: WalletCredential): { phoneNumber?: string; customerToken?: string } { - switch (credential.type) { - case "MSISDN": - return { phoneNumber: credential.value }; - case "TOKEN": - return { customerToken: credential.value }; - default: - throw new Error(`Unsupported credential type: ${credential.type}`); - } -} - -const body = buildVippsPaymentRequest(instrument, checkout); - -const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${accessToken}`, - "Ocp-Apim-Subscription-Key": vippsCredentials.subscriptionKey, - "Merchant-Serial-Number": vippsCredentials.merchantSerialNumber, - "Idempotency-Key": idempotencyKey - }, - body: JSON.stringify(body) -}); - -const payment = await response.json(); - -if (!response.ok) { - return mapVippsErrorToUCPResponse(response.status, payment); -} - -// Build response based on instrument type -checkout.status = "complete_in_progress"; -checkout.payment = { - vipps_reference: payment.reference, - expires_at: new Date(Date.now() + 5 * 60 * 1000).toISOString() -}; - -if (instrument.type === "WALLET") { - // WALLET: Return message to check Vipps app - checkout.payment.state = "pending_approval"; - checkout.messages = [{ - type: "info", - code: "payment_pending_user_approval", - content: "A payment request has been sent to your Vipps app. Please open Vipps and approve the payment." - }]; -} else if (instrument.type === "CARD") { - // CARD: Return continue_url for platform to redirect user (per UCP spec) - checkout.payment.state = "pending_redirect"; - checkout.continue_url = payment.redirectUrl; // From Vipps response, returned as continue_url - checkout.messages = [{ - type: "info", - code: "payment_redirect_required", - content: "Please complete your payment on the Vipps MobilePay payment page." - }]; -} - -return checkout; -``` - -**TypeScript Types:** - -```typescript -type InstrumentType = "WALLET" | "CARD"; -type CredentialType = "MSISDN" | "TOKEN"; - -interface WalletCredential { - type: CredentialType; - value: string; -} - -interface WalletInstrument { - type: "WALLET"; - credential: WalletCredential; -} - -interface CardInstrument { - type: "CARD"; - return_url: string; // Universal link or website URL -} - -type PaymentInstrument = WalletInstrument | CardInstrument; - -// Example Vipps API payloads: - -// WALLET with MSISDN -> paymentMethod.type: "WALLET", customer.phoneNumber -const walletMsisdnPayload = { - paymentMethod: { type: "WALLET" }, - userFlow: "PUSH_MESSAGE", - customer: { phoneNumber: "4712345678" }, - // ... other fields -}; - -// WALLET with TOKEN -> paymentMethod.type: "WALLET", customer.customerToken -const walletTokenPayload = { - paymentMethod: { type: "WALLET" }, - userFlow: "PUSH_MESSAGE", - customer: { customerToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." }, - // ... other fields -}; - -// CARD -> paymentMethod.type: "CARD", no customer field needed -const cardPayload = { - paymentMethod: { type: "CARD" }, - userFlow: "WEB_REDIRECT", - returnUrl: "vipps-chat://payment-complete?session_id=cs-ABC123XYZ", - // ... other fields -}; -``` - -### Error Handling - -Businesses **MUST** map Vipps MobilePay API errors to UCP error format. The -following table shows the recommended mapping: - -| HTTP Status | Vipps Error Context | UCP Error Code | UCP Severity | User-Facing Message | -| :---------- | :------------------------- | :---------------------------- | :--------------------- | :------------------------------------------------------------------------------------------------------------- | -| 400 | Phone/MSISDN validation | `invalid_phone_number` | `recoverable` | The provided phone number is invalid. Please verify the format. | -| 400 | Customer token validation | `invalid_customer_token` | `recoverable` | The provided customer token is invalid or expired. Please re-authenticate. | -| 400 | Other validation errors | `invalid_payment_data` | `recoverable` | The payment request contains invalid data. Please verify and try again. | -| 401 | Authentication failed | `payment_service_error` | `recoverable` | Payment service encountered an error. Please try again. | -| 403 | Authorization denied | `payment_service_error` | `recoverable` | Payment service encountered an error. Please try again. | -| 404 | Customer/Wallet not found | `wallet_not_found` | `requires_buyer_input` | No wallet account found for the provided identifier. Please ensure the wallet app is installed and registered. | -| 409 | Duplicate payment/Conflict | `duplicate_payment` | `recoverable` | A payment request for this checkout is already being processed. | -| 429 | Rate limited | `too_many_requests` | `recoverable` | Too many requests. Please wait a moment and try again. | -| 5xx | Server errors | `payment_service_unavailable` | `recoverable` | Payment service is temporarily unavailable. Please try again. | -| Other | Unknown/Unhandled | `payment_failed` | `recoverable` | Payment processing failed. Please try again. | - -**Important:** Error messages returned to platforms should be opaque and -user-friendly. Do not expose internal Vipps API error details to prevent -information leakage. Detailed errors should be logged server-side for debugging -purposes. - -**Example UCP Error Response:** - -```json -{ - "messages": [ - { - "type": "error", - "code": "wallet_not_found", - "severity": "requires_buyer_input", - "content": "No wallet account found for this phone number. Please ensure the wallet app is installed and registered.", - "path": "$.payment.instruments[0]" - } - ] -} -``` - ---- - -## Platform Integration - -### Prerequisites - -Before using this handler, Platforms **MUST** complete: - -1. If applicable: Register with merchant to allow to call their - `checkout_sessions` endpoint. This could involve getting an access token -2. Obtain a valid customer identifier based on the credential type: - - **MSISDN:** Capture the user's MSISDN registered with Vipps MobilePay. A - suggestion is to implement the - [Vipps Login Product](https://developer.vippsmobilepay.com/docs/APIs/login-api/) - in your application. - - **TOKEN:** Obtain a customer token from an authenticated session. - -**Prerequisites Output:** - -| Field | Description | -| :------------------------ | :----------------------------------------------------------------------- | -| `merchant.access_token` | Access token to call merchant (if applicable) | -| `vippsUser.msisdn` | Vipps user MSISDN registered on their wallet (for MSISDN credential) | -| `vippsUser.customerToken` | Encoded customer token from authenticated session (for TOKEN credential) | - -### Payment Protocol - -Platforms **MUST** follow this flow to acquire a payment instrument: - -#### Step 1: Discover Handler - -The Platform identifies `com.vippsmobilepay.pay.payment_handler` in the -business's `payment.handlers` array. - -```json -{ - "id": "vipps_mobilepay", - "name": "com.vippsmobilepay.pay.payment_handler", - "version": "2026-01-23", - "config": { - "merchant_serial_number": "123456", - "environment": "PRODUCTION" - } -} -``` - -#### Step 2: Choose Payment Method and Gather Required Data - -The Platform **MUST** determine which payment method to use based on whether the -customer has a Vipps MobilePay wallet: - -**Option A: WALLET (User has Vipps MobilePay wallet)** - -For users with a Vipps MobilePay wallet, use the WALLET instrument. The Platform -**MUST** obtain a customer identifier: - -- **MSISDN:** Prompt the user for their MSISDN registered with Vipps MobilePay. - Use the - [Vipps Login Product](https://developer.vippsmobilepay.com/docs/APIs/login-api/) - to authenticate and retrieve the MSISDN. -- **TOKEN:** Use an encoded customer token obtained from an authenticated - session. - -**Option B: CARD (User does not have Vipps MobilePay wallet)** - -For users without a Vipps MobilePay wallet, use the CARD instrument. The -Platform **MUST** provide: - -- **return_url:** The URL to redirect the user back to after payment. This can - be: - - **Universal Link:** e.g., `vipps-chat://payment-complete?session_id=...` - (for native mobile apps) - - **Website URL:** e.g., - `https://chat.vippsmobilepay.com/payment-complete?session_id=...` (for - web-based platforms) - -> **Tip:** Include the checkout session ID in the return URL so the platform can -> resume the checkout flow after redirect. - -#### Step 3: Complete Checkout - -The Platform submits the checkout with the constructed payment instrument. - -**Example with MSISDN credential:** - -```json -POST /checkout-sessions/{checkout_id}/complete -Content-Type: application/json - -{ - "payment_data": { - "id": "instr_vipps_1", - "handler_id": "vipps_mobilepay", - "type": "WALLET", - "credential": { - "type": "MSISDN", - "value": "4712345678" - } - }, - "risk_signals": { - "device_id": "device_abc123", - "session_id": "session_xyz789", - "ip_address": "192.168.1.1", - "user_agent": "Mozilla/5.0 ...", - "accept_language": "nb-NO" - } -} -``` - -**Example with TOKEN credential:** - -```json -POST /checkout-sessions/{checkout_id}/complete -Content-Type: application/json - -{ - "payment_data": { - "id": "instr_vipps_1", - "handler_id": "vipps_mobilepay", - "type": "WALLET", - "credential": { - "type": "TOKEN", - "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." - } - } -} -``` - -**Example with CARD instrument (No wallet required):** - -```json -POST /checkout-sessions/{checkout_id}/complete -Content-Type: application/json - -{ - "payment_data": { - "id": "instr_card_1", - "handler_id": "vipps_mobilepay", - "type": "CARD", - "return_url": "vipps-chat://payment-complete?session_id=cs-ABC123XYZ" - } -} -``` - -#### Step 4: Handle Async Response - -Both WALLET and CARD flows return `status: complete_in_progress`. The Platform -**MUST** handle the response based on the message code: - -**WALLET Response (pending user approval in Vipps app):** - -```json -{ - "id": "cs-ABC123XYZ", - "status": "complete_in_progress", - "messages": [ - { - "type": "info", - "code": "payment_pending_user_approval", - "content": "A payment request has been sent to your Vipps app. Please open Vipps and approve the payment to complete your order." - } - ], - "payment": { - "state": "pending_approval", - "expires_at": "2026-01-26T12:35:00Z" - } -} -``` - -**CARD Response (redirect required):** - -```json -{ - "id": "cs-ABC123XYZ", - "status": "complete_in_progress", - "continue_url": "https://api.vipps.no/checkout/v3/session/...", - "messages": [ - { - "type": "info", - "code": "payment_redirect_required", - "content": "Please complete your payment on the Vipps MobilePay payment page." - } - ], - "payment": { - "state": "pending_redirect", - "expires_at": "2026-01-26T12:35:00Z" - } -} -``` - -**Platform behavior for WALLET (`payment_pending_user_approval`):** - -1. **Inform the user:** Display the message content (e.g., "Please check your - Vipps app to approve the payment") -2. **Poll for completion:** Call `GET /checkout-sessions/{checkout_id}` every - 2-5 seconds -3. **Check status:** Continue polling until `status` is `completed` or an error - state -4. **Handle timeout:** If `payment.expires_at` passes, inform user the payment - expired - -**Platform behavior for CARD (`payment_redirect_required`):** - -Per the -[UCP Continue URL specification](https://ucp.dev/latest/specification/checkout/#continue-url), -the `continue_url` enables checkout handoff from platform to business UI: - -1. **Redirect the user:** Open the `continue_url` in a browser or webview to - hand off to the Vipps payment page -2. **User completes payment:** User enters card details on the Vipps payment - page -3. **Handle return:** After payment, Vipps redirects user back to the - `return_url` provided in the instrument -4. **Resume checkout:** Extract session ID from URL and poll - `GET /checkout-sessions/{id}` for final status (`completed` or error) - -#### Step 5: Poll for Completion - -```json -GET /checkout-sessions/{checkout_id} -``` - -**Response when payment is approved:** - -```json -{ - "id": "cs-ABC123XYZ", - "status": "completed", - "order": { - "id": "order-789", - "reference": "ORD-2026-001234", - "created_at": "2026-01-26T12:32:15Z" - }, - "messages": [ - { - "type": "info", - "code": "payment_approved", - "content": "Payment approved. Your order has been placed." - } - ] -} -``` - -**Response when user rejects:** - -```json -{ - "id": "cs-ABC123XYZ", - "status": "incomplete", - "messages": [ - { - "type": "error", - "code": "payment_rejected", - "severity": "requires_buyer_input", - "content": "Payment was declined. Please try again or choose a different payment method." - } - ] -} -``` - -#### Polling Best Practices - -| Recommendation | Details | -| ----------------------- | ----------------------------------------------- | -| **Poll interval** | 2-5 seconds initially, increase if many retries | -| **Max duration** | Stop after `payment.expires_at` + small buffer | -| **Exponential backoff** | Consider increasing interval after 30 seconds | -| **User feedback** | Show progress indicator while polling | - ---- - -## Security Considerations - -| Requirement | Description | -| :-------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **TLS Required** | All communication with the Vipps MobilePay API **MUST** use TLS 1.2 or higher. Connections using older TLS versions will be rejected. | -| **MSISDN Validation** | For MSISDN credentials, the value **MUST** be validated to ensure it is a valid phone number format (digits only, 7-15 digits, no leading zero) before sending to the Vipps MobilePay API. | -| **TOKEN Validation** | For TOKEN credentials, the value **MUST** be validated to ensure it is a non-empty string. Token validity is verified by the Vipps MobilePay API. | -| **Data Residency** | All payment data is processed within the EEA (European Economic Area). PII is handled according to GDPR requirements. | - ---- - -## References - -- **Handler Spec:** - `https://vippsmobilepay.com/pay/ucp/2026-01-23/vipps_mp_payment_handler` -- **Config Schema:** - `https://vippsmobilepay.com/pay/ucp/2026-01-23/schemas/wallet_payment_handler.json` -- **Instrument Schema:** - `https://vippsmobilepay.com/pay/ucp/2026-01-23/schemas/payment_instrument.json` -- **Credential Schema:** - `https://vippsmobilepay.com/pay/ucp/2026-01-23/schemas/wallet_payment_credential.json` diff --git a/src/schema/wallet_payment_credential.json b/src/schema/wallet_payment_credential.json deleted file mode 100644 index f3a772f..0000000 --- a/src/schema/wallet_payment_credential.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://vippsmobilepay.com/pay/ucp/2026-01-23/schemas/wallet_payment_credential.json", - "title": "Vipps MobilePay Wallet Payment Credential", - "description": "Credential schema for the com.vippsmobilepay.pay.payment_handler payment handler. Supports MSISDN and TOKEN credential types.", - "type": "object", - "required": [ - "type", - "value" - ], - "properties": { - "type": { - "enum": [ - "MSISDN", - "TOKEN" - ], - "description": "The credential type used for wallet payments. MSISDN uses phone number identification. TOKEN uses an encoded customer token." - }, - "value": { - "type": "string", - "description": "The credential value. For MSISDN: phone number in MSISDN format (digits only with country code, no leading zeros or plus sign, e.g., 4712345678). For TOKEN: an encoded customer token string obtained from authenticated sessions." - } - }, - "allOf": [ - { - "if": { - "properties": { - "type": { "const": "MSISDN" } - } - }, - "then": { - "properties": { - "value": { - "pattern": "^[1-9]\\d{6,14}$", - "description": "The phone number in MSISDN format: digits only with country code and subscriber number, no leading zeros or plus sign. Example: 4712345678 for Norwegian number +47 12 34 56 78." - } - } - } - }, - { - "if": { - "properties": { - "type": { "const": "TOKEN" } - } - }, - "then": { - "properties": { - "value": { - "minLength": 1, - "description": "An encoded customer token string. This token is obtained from authenticated user sessions and maps to the customerToken field in the Vipps ePayment API." - } - } - } - } - ] -} diff --git a/src/schema/wallet_payment_handler.json b/src/schema/wallet_payment_handler.json deleted file mode 100644 index 1d6da2e..0000000 --- a/src/schema/wallet_payment_handler.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://vippsmobilepay.com/pay/ucp/2026-01-23/schemas/wallet_payment_handler.json", - "title": "Vipps MobilePay Payment Handler Configuration", - "description": "Configuration schema for the com.vippsmobilepay.pay.payment_handler payment handler.", - "type": "object", - "required": [ - "merchant_serial_number" - ], - "properties": { - "environment": { - "enum": [ - "TEST", - "PRODUCTION" - ], - "description": "The Vipps MobilePay environment. Defaults to PRODUCTION if not specified." - }, - "merchant_serial_number": { - "type": "string", - "description": "The merchant identifier (MSN) in the Vipps MobilePay systems. This is obtained when registering an ecommerce sales unit with Vipps MobilePay. Format varies by Nordic country (typically 5-6 digits).", - "pattern": "^[0-9]{5,6}$" - }, - "allowed_payment_instruments": { - "type": "array", - "items": { - "$ref": "https://vippsmobilepay.com/pay/ucp/2026-01-23/schemas/payment_instrument.json" - }, - "description": "The payment instruments allowed for this handler. If not specified, all instrument types are allowed." - } - } -} diff --git a/src/services/ancillaries-service.ts b/src/services/ancillaries-service.ts new file mode 100644 index 0000000..e91a78c --- /dev/null +++ b/src/services/ancillaries-service.ts @@ -0,0 +1,430 @@ +/** + * 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 { + AncillariesResponse, + AncillaryCategory, + AncillaryItem, + AncillaryRequestItem, + AncillarySuggestion, + AncillarySuggestionType, + AppliedAncillary, +} from "../types/ucp/ancillaries.ts"; +import { getProductBySku } from "../routes/products.ts"; + +// ============================================ +// 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 (VAT-exclusive, consistent with checkout.ts). + */ +function calculateItemTotals(price: number, quantity: number): TotalEntry[] { + const subtotal = price * quantity; + // 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: "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 buildAncillariesResponse( + 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: AncillariesResponse = {}; + + 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: AncillariesResponse; +}> { + // Apply required ancillaries automatically + const { newLineItems, newApplied } = await applyRequiredAncillaries( + lineItems, + [], + ); + + const updatedLineItems = [...lineItems, ...newLineItems]; + + // Build the ancillaries object + const ancillaries = await buildAncillariesResponse( + 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: AncillariesResponse; + 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 buildAncillariesResponse( + lineItems, + appliedAncillaries, + ); + + return { updatedLineItems: lineItems, ancillaries, errors }; +} diff --git a/src/services/checkout-service.ts b/src/services/checkout-service.ts new file mode 100644 index 0000000..5cbe0b3 --- /dev/null +++ b/src/services/checkout-service.ts @@ -0,0 +1,219 @@ +/** + * Checkout Session Service + * + * Manages checkout session persistence and business logic. + * Handles session creation, retrieval, updates, and status transitions. + */ + +import type { + CheckoutSession, + CheckoutSessionStatus, + LineItemResponse, + TotalEntry, +} from "../types/ucp/checkout.ts"; +import type { SessionsStore } from "../types/merchant.ts"; + +// ============================================ +// Configuration +// ============================================ + +const DATA_FILE = new URL("../data/sessions.json", import.meta.url).pathname; +const SESSION_EXPIRY_HOURS = 24; + +// ============================================ +// Session Persistence +// ============================================ + +/** + * Loads all checkout sessions from the data file. + */ +export async function loadSessions(): Promise { + try { + const data = await Deno.readTextFile(DATA_FILE); + const store: SessionsStore = JSON.parse(data); + return store.sessions; + } catch { + return []; + } +} + +/** + * Saves all checkout sessions to the data file. + */ +export async function saveSessions(sessions: CheckoutSession[]): Promise { + const store: SessionsStore = { sessions }; + await Deno.writeTextFile(DATA_FILE, JSON.stringify(store, null, 2)); +} + +/** + * Generates a unique session ID. + */ +export function generateSessionId(): string { + return `cs-${crypto.randomUUID()}`; +} + +// ============================================ +// Session Retrieval +// ============================================ + +/** + * Finds a checkout session by ID. + */ +export async function findSession( + sessionId: string, +): Promise { + const sessions = await loadSessions(); + return sessions.find((s) => s.id === sessionId) ?? null; +} + +/** + * Finds a checkout session by Vipps reference. + */ +export async function findSessionByVippsReference( + vippsReference: string, +): Promise { + const sessions = await loadSessions(); + return sessions.find( + (s) => + s.metadata?.vipps_reference === vippsReference || + s.id === vippsReference, + ) ?? null; +} + +// ============================================ +// Session Status Management +// ============================================ + +/** + * Checks if a session has expired and updates its status if needed. + * Returns the updated session. + */ +export async function checkAndUpdateExpiry( + session: CheckoutSession, +): Promise { + const sessions = await loadSessions(); + const index = sessions.findIndex((s) => s.id === session.id); + if (index === -1) return session; + + const currentSession = sessions[index]; + let updated = false; + + // Check if session expired + if ( + currentSession.expires_at && + new Date(currentSession.expires_at) < new Date() && + currentSession.status === "incomplete" + ) { + currentSession.status = "canceled"; + currentSession.messages = [{ + type: "error", + code: "session_expired", + severity: "recoverable", + content: "This checkout session has expired.", + }]; + updated = true; + } + + // Check if payment expired (for complete_in_progress sessions) + if ( + currentSession.status === "complete_in_progress" && + currentSession.payment?.expires_at && + new Date(currentSession.payment.expires_at) < new Date() + ) { + currentSession.status = "incomplete"; + currentSession.payment.state = "expired"; + currentSession.messages = [{ + type: "error", + code: "payment_expired", + severity: "requires_buyer_input", + content: "Payment request expired. Please try again.", + }]; + updated = true; + } + + if (updated) { + await saveSessions(sessions); + } + + return currentSession; +} + +/** + * Updates a session's status and saves it. + */ +export async function updateSessionStatus( + sessionId: string, + status: CheckoutSessionStatus, + additionalUpdates?: Partial, +): Promise { + const sessions = await loadSessions(); + const index = sessions.findIndex((s) => s.id === sessionId); + if (index === -1) return null; + + const session = sessions[index]; + session.status = status; + session.updated_at = new Date().toISOString(); + + if (additionalUpdates) { + Object.assign(session, additionalUpdates); + } + + await saveSessions(sessions); + return session; +} + +// ============================================ +// Totals Calculation +// ============================================ + +/** Norwegian VAT rate */ +export const TAX_RATE = 25; + +/** + * Calculates line item subtotal from the totals array. + */ +export function calculateSubtotal(lineItems: LineItemResponse[]): number { + return lineItems.reduce((sum, li) => { + const subtotalEntry = li.totals.find((t) => t.type === "subtotal"); + return sum + (subtotalEntry?.amount ?? 0); + }, 0); +} + +/** + * Calculates tax from subtotal. + */ +export function calculateTax(subtotal: number): number { + return Math.round(subtotal * (TAX_RATE / 100)); +} + +/** + * Builds the totals array for a checkout session. + */ +export function buildTotals( + subtotal: number, + tax: number, + fulfillmentCost: number, +): TotalEntry[] { + const totals: TotalEntry[] = [ + { type: "subtotal", amount: subtotal }, + { type: "tax", amount: tax }, + ]; + + if (fulfillmentCost > 0) { + totals.push({ type: "shipping", amount: fulfillmentCost }); + } + + const total = subtotal + tax + fulfillmentCost; + totals.push({ type: "total", amount: total }); + + return totals; +} + +/** + * Gets the session expiry timestamp. + */ +export function getSessionExpiryTime(): string { + const expiresAt = new Date(); + expiresAt.setHours(expiresAt.getHours() + SESSION_EXPIRY_HOURS); + return expiresAt.toISOString(); +} diff --git a/src/types/merchant.ts b/src/types/merchant.ts new file mode 100644 index 0000000..3d420cd --- /dev/null +++ b/src/types/merchant.ts @@ -0,0 +1,32 @@ +// Demo merchant types + +import type { CheckoutSession } from "./ucp/checkout.ts"; + +export type ProductType = "product" | "service"; + +// Product catalog types +export interface Product { + sku: string; + name: string; + description: string; + price: number; // minor units (cents/øre) + currency: string; + image_url?: string; + type: ProductType; + stock: number; + relationships?: ProductRelationship[]; +} + +export interface ProductRelationship { + type: "suggested" | "required" | "complementary"; + sku: string; +} + +// Data store types +export interface ProductsStore { + products: Product[]; +} + +export interface SessionsStore { + sessions: CheckoutSession[]; +} 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 e5badf3..d6fa2c3 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 { AncillariesRequest, AncillariesResponse } from "./ancillaries.ts"; export interface TotalEntry { type: "subtotal" | "tax" | "shipping" | "discount" | "total"; @@ -98,7 +99,8 @@ export interface PaymentHandler { * populated once a complete request has been initiated. */ export interface CheckoutPaymentInfo { - handlers: PaymentHandler[]; + /** Payment handlers available, keyed by handler name with list of supported versions */ + handlers?: Record; state?: PaymentState; vipps_reference?: string; expires_at?: string; // RFC 3339 @@ -178,7 +180,8 @@ export interface CheckoutSession { shipping_address?: Address; billing_address?: Address; fulfillment?: FulfillmentResponse; - payment: CheckoutPaymentInfo; + ancillaries?: AncillariesResponse; + payment?: CheckoutPaymentInfo; messages?: UCPMessage[]; order?: Order; expires_at?: string; // RFC 3339 @@ -208,6 +211,7 @@ export interface CreateCheckoutSessionRequest { buyer?: Buyer; shipping_address?: Address; billing_address?: Address; + ancillaries?: AncillariesRequest; metadata?: Record; } @@ -216,4 +220,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 new file mode 100644 index 0000000..ffd427a --- /dev/null +++ b/src/well-known/profile.json @@ -0,0 +1,79 @@ +{ + "ucp": { + "version": "2026-01-11", + "services": { + "checkout": { + "url": "/checkout-sessions", + "transport": "rest" + } + }, + "capabilities": { + "dev.ucp.shopping.checkout": [ + { + "version": "2026-01-11", + "spec": "https://ucp.dev/specification/checkout", + "schema": "https://ucp.dev/schemas/shopping/checkout.json" + } + ], + "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" + } + ], + "dev.ucp.shopping.order": [ + { + "version": "2026-01-11", + "spec": "https://ucp.dev/specification/order", + "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-02-09", + "extends": "dev.ucp.shopping.checkout", + "spec": "https://ucp.dev/specification/ancillaries", + "schema": "https://ucp.dev/schemas/shopping/ancillaries.json" + } + ] + }, + "payment_handlers": { + "com.vippsmobilepay.pay.payment_handler": [ + { + "id": "vippsmobilepay_wallet_handler", + "version": "2026-01-23", + "spec": "https://vippsmobilepay.com/pay/ucp/2026-01-23/vipps_mp_payment_handler", + "config_schema": "https://vippsmobilepay.com/pay/ucp/2026-01-23/schemas/wallet_payment_handler.json", + "instrument_schemas": [ + "https://vippsmobilepay.com/pay/ucp/2026-01-23/schemas/wallet_payment_instrument.json" + ], + "config": { + "merchant_serial_number": "168850", + "environment": "TEST" + } + } + ] + } + }, + "signing_keys": [ + { + "kty": "EC", + "crv": "P-256", + "x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU", + "y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0", + "kid": "dev-signing-key-1", + "alg": "ES256", + "use": "sig" + } + ] +}