diff --git a/docs/src/loaders/skills-loader.ts b/docs/src/loaders/skills-loader.ts index cc8424d..d6b9840 100644 --- a/docs/src/loaders/skills-loader.ts +++ b/docs/src/loaders/skills-loader.ts @@ -1,6 +1,6 @@ import type { Loader } from "astro/loaders"; import { readdir, readFile } from "node:fs/promises"; -import { join } from "node:path"; +import { basename, join, relative, resolve, sep } from "node:path"; const SKILLS_DIR = join(new URL(".", import.meta.url).pathname, "../../../use-crystallize/skills"); @@ -40,6 +40,31 @@ function parseFrontmatter(content: string) { }; } +function referenceAnchor(file: string) { + return `ref-${file.replace(/\.md$/, "")}`; +} + +// A skill page inlines SKILL.md and every references/*.md into one document, so the +// relative .md links between those files (`references/x.md`, `../SKILL.md#y`, +// `../mutation/SKILL.md`) resolve to URLs that were never built. Point each link that +// lands on a skill file at where that file ended up: a reference is the anchor above +// its section, a SKILL.md is the top of its skill page. +function rewriteSkillLinks(markdown: string, fromDir: string, currentSkill: string): string { + return markdown.replace(/\]\(([^)\s#]+\.md)(#[^)\s]*)?\)/g, (link, path: string, hash = "") => { + if (/^([a-z]+:|\/)/i.test(path)) return link; + + const [skill, ...rest] = relative(SKILLS_DIR, resolve(fromDir, path)).split(sep); + const page = skill === currentSkill ? "" : `/ai/skills/${skill}/`; + if (rest.length === 1 && rest[0] === "SKILL.md") { + return `](${page}${hash || (page ? "" : "#_top")})`; + } + if (rest.length === 2 && rest[0] === "references") { + return `](${page}${hash || `#${referenceAnchor(rest[1])}`})`; + } + return link; + }); +} + async function readReferences(skillDir: string): Promise { const refsDir = join(skillDir, "references"); try { @@ -49,7 +74,8 @@ async function readReferences(skillDir: string): Promise { if (!file.endsWith(".md")) continue; const content = await readFile(join(refsDir, file), "utf-8"); const { body } = parseFrontmatter(content); - parts.push(body.trim()); + const linked = rewriteSkillLinks(body.trim(), refsDir, basename(skillDir)); + parts.push(`
\n\n${linked}`); } if (parts.length > 0) { return "\n\n---\n\n## Reference Details\n\n" + parts.join("\n\n"); @@ -86,7 +112,7 @@ export function skillsLoader(): Loader { const { name, description, body } = parseFrontmatter(content); const references = await readReferences(join(SKILLS_DIR, dir)); - const fullBody = body.trim() + references; + const fullBody = rewriteSkillLinks(body.trim(), join(SKILLS_DIR, dir), dir) + references; const rendered = await renderMarkdown(fullBody); store.set({ diff --git a/use-crystallize/mcp-servers/crystallize/bin/eval-reference-selection.cases.json b/use-crystallize/mcp-servers/crystallize/bin/eval-reference-selection.cases.json index 41eb783..8419748 100644 --- a/use-crystallize/mcp-servers/crystallize/bin/eval-reference-selection.cases.json +++ b/use-crystallize/mcp-servers/crystallize/bin/eval-reference-selection.cases.json @@ -5,6 +5,16 @@ "skills": ["payments"], "expected": ["payments/vipps-mobilepay", "payments/stripe"] }, + { + "task": "accept iDEAL and Bancontact through Mollie in our Crystallize checkout", + "skills": ["payments"], + "expected": ["payments/mollie"] + }, + { + "task": "let business customers pay by invoice with Two, including a company lookup at checkout", + "skills": ["payments"], + "expected": ["payments/two"] + }, { "task": "create a product shape through the API with a paragraph collection and a grid relation", "skills": ["content-model"], diff --git a/use-crystallize/skills/js-api-client/SKILL.md b/use-crystallize/skills/js-api-client/SKILL.md index dadd389..3bbaccb 100644 --- a/use-crystallize/skills/js-api-client/SKILL.md +++ b/use-crystallize/skills/js-api-client/SKILL.md @@ -1,9 +1,9 @@ --- name: js-api-client -description: Use the @crystallize/js-api-client package to interact with Crystallize APIs in JavaScript/TypeScript. Use when setting up the Crystallize API client, configuring credentials (including bearer tokens for plugins and delegated calls), calling catalogueApi/discoveryApi/pimApi/nextPimApi/meApi/shopCartApi, working with high-level helpers for catalogue fetching, cart management, orders, customers, subscriptions, navigation, or using any helper from the @crystallize/js-api-client npm package. +description: Use the @crystallize/js-api-client package to interact with Crystallize APIs in JavaScript/TypeScript. Use when setting up the Crystallize API client, configuring credentials (including bearer tokens for plugins and delegated calls), calling catalogueApi/discoveryApi/pimApi/nextPimApi/meApi or the Shop API callers (shopCartApi, shopOrderApi, shopCustomerApi, shopLockApi, shopSubscriptionContractApi, shopBookingApi), configuring Shop API token scopes, working with high-level helpers for catalogue fetching, cart management, Shop API orders (createFromCart, addPayments, setPayments, pipeline stages), Shop API customers, distributed locks, Core orders, customers, subscriptions, navigation, or using any helper from the @crystallize/js-api-client npm package (createCartManager, createShopOrderManager, createShopCustomerManager, createShopLock, createOrderManager, createCustomerManager, …). metadata: author: Crystallize - version: "2.0" + version: "2.1" --- # Crystallize JS API Client @@ -16,7 +16,10 @@ Before writing code, understand the context. Ask clarifying questions: 1. **What are you trying to do?** Read data, write data, manage carts, handle webhooks? 2. **Do you need raw GraphQL or a high-level helper?** Helpers reduce boilerplate for common workflows (orders, carts, navigation). Use raw GraphQL via API callers for custom queries or when helpers don't cover the use case. -3. **Which API?** Catalogue/Discovery for storefront reads, PIM for admin writes, Shop Cart for checkout flows. +3. **Which API?** Catalogue/Discovery for storefront reads, Core (`nextPimApi`) for back-office reads and writes, the + Shop API for checkout: carts (`/cart`), orders made from them (`/order`), customers (`/customer`), locks (`/lock`). + Orders and customers exist on both sides — pick the helper for the side you are on (see + [Shop API](#shop-api-carts-orders-customers-locks)). 4. **What auth do you have?** `staticAuthToken` is enough for read-only. PIM/Shop operations need `accessTokenId` + `accessTokenSecret`. For plugins or delegated calls where the caller already holds a JWT, pass it as `bearerToken` — it becomes `Authorization: Bearer `. 5. **Is this server-side or client-side?** The client works in both, but credentials should only live server-side. @@ -73,18 +76,18 @@ createClient(configuration, options?) | `sessionId` | Alternative to token-based auth | | `staticAuthToken` | For read-only catalogue/discovery | | `bearerToken` | JWT forwarded as `Authorization: Bearer ` (e.g. a plugin `backendToken`) | -| `shopApiToken` | Auto-fetched if not provided | +| `shopApiToken` | A Shop API token to use as is; auto-fetched when omitted | | `shopApiStaging` | Use staging Shop API | | `origin` | Custom host suffix (default: `.crystallize.com`) | ### Client Options -| Option | Description | -| -------------- | ----------------------------------- | -| `useHttp2` | Enable HTTP/2 transport | -| `profiling` | Profiling callbacks for debugging | -| `extraHeaders` | Extra request headers for all calls | -| `shopApiToken` | Control auto-fetch behavior | +| Option | Description | +| -------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `useHttp2` | Enable HTTP/2 transport | +| `profiling` | Profiling callbacks for debugging | +| `extraHeaders` | Extra request headers for all calls | +| `shopApiToken` | `{ scopes?, expiresIn?, doNotFetch? }` — Shop API token fetching, see [Shop API](#shop-api-carts-orders-customers-locks) | ## API Callers @@ -94,30 +97,119 @@ All callers share the same signature: (query: string, variables?: Record) => Promise; ``` -| Caller | Purpose | -| -------------- | --------------------------------------------------------------------- | -| `catalogueApi` | Catalogue GraphQL | -| `discoveryApi` | Discovery GraphQL (search/browse) | -| `pimApi` | PIM GraphQL (legacy — prefer `nextPimApi`) | -| `nextPimApi` | PIM Next GraphQL (scoped to tenant, recommended) | -| `meApi` | Per-user GraphQL (`@me`) — call on behalf of the authenticated viewer | -| `shopCartApi` | Shop Cart GraphQL (token auto-handled) | +| Caller | Purpose | +| ----------------------------- | --------------------------------------------------------------------- | +| `catalogueApi` | Catalogue GraphQL | +| `discoveryApi` | Discovery GraphQL (search/browse) | +| `pimApi` | PIM GraphQL (legacy — prefer `nextPimApi`) | +| `nextPimApi` | PIM Next GraphQL (scoped to tenant, recommended) | +| `meApi` | Per-user GraphQL (`@me`) — call on behalf of the authenticated viewer | +| `shopCartApi` | Shop API `/cart` GraphQL (token auto-handled) | +| `shopOrderApi` | Shop API `/order` GraphQL (7.5+) | +| `shopCustomerApi` | Shop API `/customer` GraphQL (7.5+) | +| `shopLockApi` | Shop API `/lock` GraphQL (7.5+) | +| `shopSubscriptionContractApi` | Shop API `/subscription-contract` GraphQL (7.5+) | +| `shopBookingApi` | Shop API `/booking` GraphQL (7.5+) | ## High-Level Helpers Available helpers: -| Helper | Import | Purpose | -| ----------------------------------- | ---------------------------- | ----------------------------------------------------------- | -| `createCatalogueFetcher` | `@crystallize/js-api-client` | Build typed catalogue queries with object syntax | -| `createNavigationFetcher` | `@crystallize/js-api-client` | Fetch navigation trees by depth | -| `createProductHydrater` | `@crystallize/js-api-client` | Fetch products/variants by path or SKU | -| `createOrderFetcher` | `@crystallize/js-api-client` | Fetch orders with type-safe field selection | -| `createOrderManager` | `@crystallize/js-api-client` | Create/update orders, set payments, move pipeline stages | -| `createCustomerManager` | `@crystallize/js-api-client` | Create and update customers | -| `createCustomerGroupManager` | `@crystallize/js-api-client` | Manage customer groups | -| `createSubscriptionContractManager` | `@crystallize/js-api-client` | Create and manage subscription contracts | -| `createCartManager` | `@crystallize/js-api-client` | Hydrate carts, add items, place orders (token auto-handled) | +| Helper | Import | Purpose | +| ----------------------------------- | ---------------------------- | ---------------------------------------------------------- | +| `createCatalogueFetcher` | `@crystallize/js-api-client` | Build typed catalogue queries with object syntax | +| `createNavigationFetcher` | `@crystallize/js-api-client` | Fetch navigation trees by depth | +| `createProductHydrater` | `@crystallize/js-api-client` | Fetch products/variants by path or SKU | +| `createOrderFetcher` | `@crystallize/js-api-client` | **Core**: fetch orders with type-safe field selection | +| `createOrderManager` | `@crystallize/js-api-client` | **Core**: register/update orders, set payments, pipelines | +| `createCustomerManager` | `@crystallize/js-api-client` | **Core**: create and update customers | +| `createCustomerGroupManager` | `@crystallize/js-api-client` | **Core**: manage customer groups | +| `createSubscriptionContractManager` | `@crystallize/js-api-client` | **Core**: create and manage subscription contracts | +| `createCartManager` | `@crystallize/js-api-client` | **Shop**: hydrate, fetch, place, fulfill and abandon carts | +| `createShopOrderManager` | `@crystallize/js-api-client` | **Shop**: order from a cart, payments, pipelines (7.5+) | +| `createShopCustomerManager` | `@crystallize/js-api-client` | **Shop**: upsert customers, addresses, meta (7.5+) | +| `createShopLock` | `@crystallize/js-api-client` | **Shop**: acquire/release a distributed lock (7.5+) | + +## Shop API: carts, orders, customers, locks + +The Shop API is one GraphQL endpoint per concern at `https://shop-api.crystallize.com/@{tenant}/{endpoint}`, each with +its own `shop*Api` caller. Since **7.5.0** (which needs `@crystallize/schema` 6.15.0) there are helpers for `/order`, +`/customer` and `/lock` next to the cart manager. + +**One token for all of them.** The client fetches a Shop API token with your access token pair (or `sessionId` / +`bearerToken`), asking for `shopApiToken.scopes` (default `['cart']`). When a caller needs an endpoint the token does +not cover, the token is refetched with the union of the scopes; it is also refreshed 5 minutes before it expires. List +every endpoint you use up front to get a single token. A `shopApiToken` passed in the configuration is used as is +when it carries no `scopes` claim; with `doNotFetch: true` nothing is ever fetched. + +```typescript +import { createCartManager, createClient, createShopOrderManager } from "@crystallize/js-api-client"; + +const api = createClient( + { tenantIdentifier: "your-tenant", accessTokenId: "…", accessTokenSecret: "…" }, + { shopApiToken: { scopes: ["cart", "order"] } }, +); +const carts = createCartManager(api); +const orders = createShopOrderManager(api); + +const placed = await carts.place(cartId, { total: { gross: true, currency: true } }); // frozen: charge this total +// … the payment provider confirms the payment (webhook) … +const order = await orders.createFromCart(cartId, { + type: "standard", + paymentStatus: "paid", // set once: the Shop API has no mutation to change it later + payments: [{ provider: "stripe", transactionId: "pi_123", amount: 100, method: "card" }], + pipelines: [{ identifier: "fulfilment", stage: "new" }], +}); +await orders.addPayments(order.id, [ + { provider: "stripe", transactionId: "re_1", amount: 20, meta: [{ key: "type", value: "refund" }] }, +]); +await orders.setPayments(order.id, [/* the whole list */]); // replaces every payment +await orders.addToStage(order.id, "fulfilment", "shipped"); +const read = await orders.fetch(order.id, { payments: { provider: true, transactionId: true, meta: true } }); +``` + +- **The order id is the cart id**, and `createFromCart` only accepts a `placed` cart. +- **Payments are generic records**: `provider` is any string, nothing is charged. `meta` is written as + `[{ key, value }]` but read back as a JSON object. +- **Writes persist just after the response**: a read right after `createFromCart`, `addPayments` or `setPayments` + can still see the previous state. `fetch` rejects (`JSApiClientCallError`) when the order does not exist. +- **Optional fields reject `null`** (the `@crystallize/schema/shop` inputs are `.optional()`, not nullable, and the + customer input is strict): strip `null`s from data you read back before sending it. +- `listByCustomer(identifier, { limit, skip })`, `setMeta(id, { meta, merge })`, `setCustomer(id, customer)` and + `removeFromPipeline(id, pipeline)` complete the order manager. + +```typescript +import { createShopCustomerManager, createShopLock } from "@crystallize/js-api-client"; + +const customers = createShopCustomerManager(api); // `customer` scope +const exists = await customers.fetch("jane@example.com").then( + () => true, + () => false, +); // rejects when missing +if (!exists) await customers.upsert({ identifier: "jane@example.com", email: "jane@example.com", firstName: "Jane" }); +await customers.addAddress("jane@example.com", { type: "delivery", street: "Main St 1", city: "Oslo" }); +// also: setAddress(identifier, index, address), removeAddress(identifier, index), setMeta(identifier, { meta }) + +const lock = createShopLock(api); // `lock` scope +if (await lock.acquire(`order:${cartId}`, 30)) { + // ttl in seconds (default 60); false when already held + try { + // … work that must not run twice at once + } finally { + await lock.release(`order:${cartId}`); + } +} +``` + +**Shop or Core?** An order made from a cart lives on the Shop API: create it with `createShopOrderManager` +(`createFromCart`) and keep recording its payments there. `createOrderManager` writes Core orders (`register`, +`update`, `setPayments`) — for orders that never had a cart (imports, POS, back office). A Core write on a Shop order +is pushed back to the Shop order as a whole and can overwrite payments just set through the Shop API. The same split +holds for customers (`createShopCustomerManager` vs `createCustomerManager`). The [payments skill](../payments/SKILL.md) +shows the whole checkout. + +**Upgrading to 7.5:** nothing changes for `createClient` users. A hand-built `ClientInterface` (a typed test mock) +needs the five new callers. Cart and Shop customer `birthDate` is now sent as an ISO string. ## Authentication @@ -127,7 +219,7 @@ Available helpers: | `accessTokenId` + `accessTokenSecret` | `X-Crystallize-Access-Token-Id` + `X-Crystallize-Access-Token-Secret` | PIM/Shop operations | | `sessionId` | `Cookie: connect.sid=` | Alternative to token pair | | `bearerToken` | `Authorization: Bearer ` | JWT-based auth — plugins forward `envelope.backendToken`; delegated/per-user calls | -| `shopApiToken` | `Authorization: Bearer ` (Shop API only) | Optional; auto-fetched if omitted | +| `shopApiToken` | `Authorization: Bearer ` (Shop API only) | Optional; fetched with the auth above (never `staticAuthToken`) if omitted | **Priority** when multiple are set on the same client: `sessionId` > `bearerToken` > `staticAuthToken` > `accessTokenId`/`accessTokenSecret`. Only one is sent per request. Per-caller restrictions: @@ -157,3 +249,6 @@ const { tenant } = await api.nextPimApi<{ tenant: { id: string; name: string } } - [Official Documentation](https://crystallize.com/docs/developer/sdk/js-api-client) - High-level helpers and utilities (GraphQL Builder, Signature Verification, Binary File Manager, Pricing Utilities, Request Profiling) - [GitHub Repository](https://github.com/CrystallizeAPI/libraries/tree/main/components/js-api-client) - [NPM Package](https://www.npmjs.com/package/@crystallize/js-api-client) + +Related: [[payments]] for the checkout built on the Shop API helpers, [[mutation]] for the raw GraphQL behind them, +[[plugins]] for `bearerToken` and payload decryption. diff --git a/use-crystallize/skills/mutation/SKILL.md b/use-crystallize/skills/mutation/SKILL.md index cbd8dbc..2d99ab8 100644 --- a/use-crystallize/skills/mutation/SKILL.md +++ b/use-crystallize/skills/mutation/SKILL.md @@ -181,10 +181,17 @@ const api = createClient({ accessTokenSecret: "...", }); -// Use pimApi for Core API mutations -const result = await api.pimApi(mutationString, variables); +// Core API mutations (api.crystallize.com/@tenant) +const result = await api.nextPimApi(mutationString, variables); + +// Shop API mutations: one caller per endpoint, the Shop API token is fetched for you +await api.shopCartApi(cartMutation, variables); // /cart +await api.shopOrderApi(orderMutation, variables); // /order (js-api-client 7.5+) ``` +For checkout, prefer the helpers: `createCartManager` (`hydrate`, `place`) and `createShopOrderManager` +(`createFromCart`, `addPayments`, `setPayments`, `addToStage`). + ## Output Format When generating mutations for the user, produce: diff --git a/use-crystallize/skills/payments/SKILL.md b/use-crystallize/skills/payments/SKILL.md index 7c2d1c8..befb556 100644 --- a/use-crystallize/skills/payments/SKILL.md +++ b/use-crystallize/skills/payments/SKILL.md @@ -1,250 +1,628 @@ --- name: payments description: > - Take payment for a Crystallize order with a payment provider — Stripe, Adyen, Klarna, Qliro, - Dintero, Vipps MobilePay, QuickPay, Montonio or Razorpay. Covers the payment step between placing a - Shop API cart and creating the order from it: creating the provider session with the cart id, - redirect or embedded checkout, verifying webhooks and callbacks, creating exactly one order per cart, - recording payments and paymentStatus, and later capture, refund and cancellation. Use when the user - wants to add a payment provider or payment method to a Crystallize storefront, build or fix checkout - payment, handle a payment webhook, capture on shipment, refund an order, or port payment code from - the old furniture boilerplates. Trigger on "payment", "payment provider", "PSP", "checkout payment", - "webhook", "capture", "refund", "authorize", "paymentStatus", "addPayments", "setPayments", - "Stripe", "Adyen", "Klarna", "Qliro", "Dintero", "Vipps", "MobilePay", "QuickPay", "Montonio", - "Razorpay", "Checkout Session", "PaymentIntent", "Drop-in", "Hosted Payment Page", "ePayment". + Take payment for a Crystallize Shop API checkout with Stripe, Adyen, Klarna, Qliro, Dintero, Vipps MobilePay, + Mollie, Montonio, QuickPay, Two or Razorpay: placing (locking) the cart before charging, creating the provider + session from the placed cart, verifying webhooks, creating exactly one order per cart, recording payments, and + capture, refund and cancellation from fulfilment pipelines. Use when adding a payment provider or method to a + Crystallize storefront, building or fixing checkout payment or a payment webhook, stopping duplicate or unpaid + orders, a cart changed after paying (two tabs, back button), capturing on shipment or refunding. Trigger on + "payment", "payment gateway", "webhook", "capture", "refund", "place cart", "lock the cart", "duplicate order", + "paymentStatus", "addPayments", "setPayments", "createFromCart", "createShopOrderManager", "Stripe", "Adyen", + "Klarna", "Qliro", "Dintero", "Vipps", "MobilePay", "Mollie", "Montonio", "QuickPay", "Two", "Razorpay", + "B2B invoice". metadata: author: Crystallize - version: "1.0" + version: "2.0" --- # Crystallize Payments -Crystallize does not take payment. It holds the cart, the order and a record of every payment; the -money moves at a payment provider. This skill is the join: how to get from a **placed cart** to a -**paid order** through a provider, and how to keep the order right when the payment later changes -(captured, refunded, cancelled). +Crystallize is agnostic about payments: it holds the cart, the order and a record of every payment, and +the money moves at a payment gateway of your choice. This skill is the join between them — how to get +from a cart to a **paid order** through any provider without ever charging one amount and delivering +another, and how to keep the order right when the payment later changes (captured, refunded, +cancelled). Each provider has its own reference; this page is what they all share. -The cart and order calls themselves live in the [mutation skill](../mutation/SKILL.md) — +The cart and order calls themselves are documented in the [mutation skill](../mutation/SKILL.md) — [`hydrate` and `place`](../mutation/references/shop-api-mutations.md) on `/cart`, [`createFromCart`, `addPayments`, `setPayments`](../mutation/references/shop-api-order-mutations.md) on `/order`. This skill says **when** to call them and **what to put in them**. -> Crystallize side verified on 2026-09-29 against the live Core API schema and existing orders on the -> `sofa-configurator` tenant. Each provider reference states its own verification level. +## The common flow -## The payment step +The flow is the same with every gateway: -```text -Shop /cart hydrate → place the cart is frozen; its total is what gets charged -Provider create session amount from the placed cart, cart id in the provider's reference/metadata -Browser pay redirect to the provider, or its embedded component -Provider webhook / callback → verify it → re-read the payment from the provider if the reference says so -Shop /order createFromCart once per cart, with payments[] and paymentStatus (see mapping below) - … later … -Provider capture / refund / cancel → setPayments / addPayments on /order, paymentStatus on Core -``` - -**Place before you charge.** Create the provider session from the placed cart's `total`, never from a -total the browser sent. If the shopper goes back and changes the basket, the placed cart is dead: hydrate -a new cart and create a new session. - -**Order lines** (Klarna, Qliro, Dintero, Montonio send them): on the placed cart, `items[].price` is the -**line total** (unit × quantity) and `items[].variant.price` is the unit price. Carts can -hold zero-priced lines (options, components of a bundle); keep them or drop them, but the lines must sum -to `total.gross` exactly. - -**The order comes from the webhook, not the browser.** The return URL only shows a confirmation page -(poll for the order, as the order may not exist yet). The browser can close, lie or arrive twice. - -## Rules for every provider +1. Towards the end of checkout, the shopper has a cart and wants to pay. +2. The checkout page loads the gateway's form, or links to a page hosted by the gateway. +3. The shopper enters their payment details. +4. The gateway hands the shopper back to your site (often a redirect) and the page updates. +5. **Invisibly, asynchronously, server-to-server:** the gateway calls your service to report the + payment status. This is the step that matters. -| Rule | Why | -| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| **Verify every webhook** | An unverified endpoint lets anyone POST "paid" and get an order. Each reference gives the exact algorithm | -| **Read the raw body** (`await req.text()`) | Signatures are computed over the exact bytes. `req.json()` then `JSON.stringify` breaks them | -| **One order per cart** | Providers deliver at least once and retry. Only the "authorized/paid" event creates the order; later events update it | -| **Answer fast, and 5xx on your own failure** | Timeouts are short (Klarna 2 s, Vipps and Adyen 10 s). A 4xx is permanent for some (Dintero); 5xx makes the provider retry | -| **Round when converting amounts** | `Math.round(gross * 100)`, never `gross * 100`. Check each provider's zero- and three-decimal currencies | -| **Secrets stay on the server** | Only publishable/client keys reach the browser. Never put the Crystallize token in a client bundle | -| **Test mode first** | Every reference names its sandbox, test cards and how to reach localhost (CLI forwarder or tunnel) | +**Never validate a payment from the client.** The browser can close, lie, replay a URL or arrive twice; +only a server-to-server notification you have verified (most gateways sign them) or a status you +fetched from the gateway yourself proves a payment. On Crystallize that becomes: -Unsigned notifications (Klarna's authorization callback, Qliro's push) are made safe by **re-fetching the -payment from the provider's API** and by a signed, per-session token in the callback URL. - -## Mapping to Crystallize - -### The payment record +```text +Storefront hydrate → set customer, shipping, selections everything the order needs, on the cart +Shop /cart place cart frozen; place's total = what you charge +Server create provider session / intent amount from place, cart id as the reference +Browser pay provider's hosted page or embedded component +Provider ───► your webhook verify → customer → createFromCart once +Browser return page read-only: wait until the cart is `ordered` + … later … +Crystallize order enters the "Shipped" stage ───► your hook provider capture → setPayments +``` -`createFromCart`, `addPayments` and `setPayments` take `OrderPaymentInput`: +**When to save the order.** You can create the order before payment and add the payment to it later, +or create it once the payment is confirmed. This skill does the second: the placed cart _is_ your saved +checkout, and `createFromCart` turns it into an order (with the same id) only when the money is there. +Creating orders up front leaves an unpaid order behind for every abandoned payment. + +## Lock the cart before you charge + +**The attack this prevents.** A shopper opens checkout in two tabs. In tab A they start paying for a +cart worth 100. In tab B they add a sofa: the cart is now worth 2 100. Tab A completes the payment of +100, the webhook arrives with the cart id, the server turns "the cart" into an order — and ships 2 100 +worth of goods for 100. Any flow that charges an amount computed from a cart that can still change has +this hole, with or without malice (back button, a stale tab, a slow network). + +Crystallize closes it with `place`: + +- **`place` freezes the cart.** A placed cart cannot be hydrated or edited, and there is no way back to + the `cart` state. `createFromCart` refuses a cart that is not `placed` ("The cart is not placed yet."), + so an order can only ever come from a frozen cart. +- **Place first, then create the provider session — never the other way round.** Charge the `total` that + `place` returns: `place` re-prices the cart from the catalogue one last time, so it can differ from the + last `hydrate`. Never take an amount from the browser. +- **Everything the order needs goes on the cart before `place`:** customer and addresses + (`setCustomer`), shipping as an external item so it is part of the total, and checkout choices that + shape the order, such as a pickup point or a B2B company (cart `meta`). A payment method or bank + preselection does not change the amount: keep it on the cart, or pass it when you create the session + (and put it in the session's idempotency key) so the shopper can switch method on the same placed cart. +- **Writes to a placed cart fail silently.** `addSkuItem`, `addExternalItem`, `setCustomer`, `setMeta` + and item changes answer with a cart that shows your change — but nothing is saved. Read + the cart's `state` before editing it. +- **Back from the payment page = a new cart.** If the shopper wants to change anything, `hydrate` a new + cart without an id (copy the items over) and swap your cookie. The old placed cart keeps its own + session; if that session is paid later, it pays for exactly the old cart — still consistent. Expire or + cancel that old session where the provider allows it (each reference says how). +- On a placed cart, `isStale` turns `true` after about an hour and means nothing: placed prices never + change. Carts (placed included) are deleted about three months after they expire. ```ts -{ - provider: 'klarna', // free-form string, lower-case provider name - method: 'pay_later', // what the shopper used: card, vipps, invoice, bank, … - transactionId: 'a8c3…', // the provider's id for this payment (see each reference) - amount: 1499.0, // MAJOR units, like the order total — not cents - createdAt: '2026-09-29T10:12:00Z', - meta: [{ key: 'state', value: 'authorized' }], +// app/api/checkout/pay/route.ts — the storefront's "Pay" button +import { carts, PLACED_CART, type PlacedCart } from "@/lib/crystallize-payments"; + +export async function POST(req: Request) { + const cartId = getCartIdFromCookie(req); // your session handling + const cart = (await carts.fetch(cartId, { state: true, ...PLACED_CART })) as unknown as PlacedCart & { + state: "cart" | "placed" | "ordered" | "abandoned"; + }; + if (cart.state !== "cart" && cart.state !== "placed") return Response.json({ error: "closed" }, { status: 409 }); + const placed = cart.state === "placed" ? cart : ((await carts.place(cartId, PLACED_CART)) as unknown as PlacedCart); + // → the provider reference's create function: amount = placed.total.gross, reference = placed.id. It reuses + // the cart's existing session, and answers "already paid" (→ the return page) when that session completed. } ``` -In Core and the admin UI, this record appears as a `custom` payment whose `properties` are `provider`, -`transactionId`, `amount`, `method`, `createdAt` and every `meta` key — so do not use those five names -as meta keys. Keep meta small: `state` (`authorized`, `captured`, `refunded`, `cancelled`), `type` -(`payment` or `refund`), and the few provider ids needed to capture or refund later. - -### paymentStatus - -Crystallize has no "authorized" status — `OrderPaymentStatus` is `paid`, `partiallyPaid`, -`partiallyRefunded`, `refunded`, `unpaid`. Many providers authorize first and capture when the goods ship -(Klarna, Qliro, Dintero, Vipps, QuickPay; Stripe and Adyen when manual capture is on). Record that as: - -| Provider event | Crystallize | -| ------------------------------- | -------------------------------------------------------------------------------------------- | -| Authorized, capture later | `createFromCart` with `paymentStatus: unpaid`, payment `meta state=authorized` | -| Paid (captured immediately) | `createFromCart` with `paymentStatus: paid`, payment `meta state=captured` | -| Captured later (full) | `setPayments` with `state=captured`; `paymentStatus: paid` | -| Captured later (part) | `setPayments` with the captured amount; `paymentStatus: partiallyPaid` | -| Refunded (part / full) | `addPayments` with `type=refund`, the refund id and amount; `partiallyRefunded` / `refunded` | -| Authorization cancelled/expired | `setPayments` with `state=cancelled`; leave `unpaid`, move the order to a cancelled stage | -| Failed / declined | No order. The cart stays placed; the shopper retries with a new session | - -`paymentStatus` is changed after creation on the **Core API**: -`updateOrder(id: , input: { paymentStatus: paid })`. Take `coreId` from the Shop API order. Do -**not** pass `payment` to Core `updateOrder` — Core's payment input is the older typed union and would -replace the records written through the Shop API. Pipeline stages (`addToStage` on `/order`) are the -right place for fulfilment state such as "awaiting capture" or "shipped". - -## Creating the order exactly once - -The Shop API order id **is** the cart id, and `createFromCart` moves the cart to `ordered`. Check first, -create second, and treat losing a race as success. The provider references import these helpers from -`lib/crystallize-payments.ts`: +## One payment per cart + +Placing stops the cart from changing; it does not stop two tabs from both paying for the **same** +placed cart. Make the provider session idempotent per cart, so both tabs get the same session — every +reference names its provider's mechanism (an idempotency key derived from the cart id, a reference the +provider refuses twice, or looking the payment up by cart id before creating one), or says there is none +(Klarna Payments), in which case the duplicate flag below is the safety net. + +If a second successful payment still arrives for a cart that is already an order, `createOrderOnce` +below records it on the order with `meta attention=duplicate-payment` and logs it: someone must refund +it. Never create a second order and never refund automatically from a webhook. + +## The webhook + +The same skeleton for every provider: + +1. **Verify** the request (each reference gives the exact algorithm) over the **raw body** — read it + with `await req.text()` before anything else. Unsigned notifications (Klarna's authorization + callback and HPP `status_update`, Mollie, Qliro) prove nothing on their own: **re-fetch the payment + from the provider's API** and act on what the provider returns. +2. **Find the cart id** in the provider's reference or metadata — never in a URL the shopper controls, + unless it is protected by a signed token. +3. **Decide by the provider's status** (paid, authorized, pending, failed — the reference has the table). + Pending and failed create nothing. +4. **`createOrderOnce`**: reads the cart, creates the Core customer if needed, and calls `createFromCart` once. + Deliveries that collide in the same instant are a trade-off: see + [Serialising order writes](#serialising-order-writes-optional). +5. **Answer fast.** 2xx when done or deliberately ignored; **5xx when it failed on your side**, so the + provider retries. Never answer 2xx to a bad signature (answer 401/400) and never 3xx (a redirect from + auth or i18n middleware counts as delivered or failed, depending on the provider). The one exception: + a provider that demands 2xx before the work (Klarna's widget authorization callback) gets it, the work + runs after the answer, and a scheduled re-check of carts still `placed` catches what failed. + +| Rule | Why | +| ------------------------------------ | --------------------------------------------------------------------------------------------- | +| Verify, then trust | An unverified endpoint lets anyone POST "paid" and receive goods | +| Raw bytes, timing-safe compare | `JSON.stringify(await req.json())` is not the bytes that were signed; `===` leaks timing | +| Re-fetch when unsigned | The notification is only a hint that something changed | +| One order per cart | Providers deliver at least once, retry for hours or days, and may deliver twice at once | +| 5xx on your own failure | 4xx is permanent for some providers; 5xx makes them retry | +| `Math.round(major * 100)` | `19.99 * 100` is `1998.9999…`. Check zero- and three-decimal currencies in each reference | +| Currency and country from the market | Never hardcode `NOK` / `NO`, and never guess the country from the currency | +| Secrets stay on the server | Only publishable or client keys reach the browser; never put a Crystallize token in a bundle | +| Test mode first, then a tunnel | Gateways cannot reach localhost: ngrok, cloudflared or a CLI forwarder (some block ngrok) | +| No long polling in the request | It dies on serverless. Webhook first; a scheduled job may re-check carts still `placed` later | + +## The return page + +The page the provider sends the shopper back to **only reads**. Put the cart id in the return URL (an app +switch can open it in another browser, without your cookie) and fetch the cart: `ordered` → show the +confirmation (the order id is the cart id) and clear the cart cookie; still `placed` → ask the provider +for that payment's status (keep its id in a cookie or the URL): failed or cancelled → offer to pay again; +otherwise "We are confirming your payment…" and refresh every few seconds — the webhook usually lands +within seconds. +Adyen's redirect methods need a call from this page (`submitDetails`); Qliro's thank-you snippet, Two's confirm +and reading Klarna's HPP session are optional. The reference says so; the page still never creates the order. + +## `lib/crystallize-payments.ts` + +The provider references import these helpers. Everything goes through `@crystallize/js-api-client` (7.5 or later): +`createCartManager` for the Shop API `/cart`, `createShopOrderManager` for `/order` (`createFromCart`, +`addPayments`, `setPayments`) and `createShopCustomerManager` for `/customer`. The client fetches one Shop API +token for all of them. ```ts // lib/crystallize-payments.ts +import { + createCartManager, + createClient, + createShopCustomerManager, + createShopOrderManager, +} from "@crystallize/js-api-client"; + +export const api = createClient( + { + tenantIdentifier: process.env.CRYSTALLIZE_TENANT_IDENTIFIER!, + accessTokenId: process.env.CRYSTALLIZE_ACCESS_TOKEN_ID!, + accessTokenSecret: process.env.CRYSTALLIZE_ACCESS_TOKEN_SECRET!, + }, + { shopApiToken: { scopes: ["cart", "order", "customer"] } }, // one token for every endpoint used here +); +export const carts = createCartManager(api); +export const orders = createShopOrderManager(api); +const customers = createShopCustomerManager(api); + +// What a provider session needs from the placed cart. `price` is the line total, `variant.price` the unit. +// `type` tells product lines from external ones (`shipping`, `fee`, `promotion`, …). +const ADDRESS = { + type: true, + firstName: true, + lastName: true, + street: true, + street2: true, + streetNumber: true, + postalCode: true, + city: true, + state: true, + country: true, + phone: true, + email: true, +}; +const CUSTOMER = { + identifier: true, + isGuest: true, + type: true, + email: true, + firstName: true, + lastName: true, + phone: true, + companyName: true, + taxNumber: true, + addresses: ADDRESS, +}; +export const PLACED_CART = { + total: { gross: true, net: true, taxAmount: true, currency: true }, + items: { + lineId: true, + type: true, + name: true, + quantity: true, + variant: { sku: true, price: { gross: true, net: true, taxPercent: true } }, + price: { gross: true, net: true, taxAmount: true, taxPercent: true }, + }, + customer: CUSTOMER, + meta: true, +}; +type Address = { type: "delivery" | "billing" | "other" } & Partial< + Record, string | null> +>; +type CartCustomer = { + identifier?: string | null; + isGuest?: boolean; + type?: "individual" | "organization" | null; + email?: string | null; + firstName?: string | null; + lastName?: string | null; + phone?: string | null; + companyName?: string | null; + taxNumber?: string | null; + addresses?: Address[] | null; +}; +export type PlacedCart = { + id: string; + total: { gross: number; net: number; taxAmount: number; currency: string }; + items: { + lineId: string | null; + type: "standard" | "shipping" | "fee" | "promotion" | "service" | "digital" | string | null; + name: string; + quantity: number; + variant: { sku: string | null; price: { gross: number; net: number; taxPercent: number } } | null; + price: { gross: number; net: number; taxAmount: number; taxPercent: number }; + }[]; + customer: { + identifier?: string; + type?: "individual" | "organization"; + email?: string; + firstName?: string; + lastName?: string; + phone?: string; + companyName?: string; + taxNumber?: string; + addresses?: Address[]; + } | null; + meta: Record | null; +}; + export type PaymentStatus = "paid" | "partiallyPaid" | "partiallyRefunded" | "refunded" | "unpaid"; export type Payment = { - provider: string; - method?: string; - transactionId?: string; - amount?: number; + provider: string; // lower-case provider name: "stripe", "klarna", "two", … + method?: string; // what the shopper used: card, vipps, invoice, bank, … + transactionId: string; // the provider's id for this payment or refund + amount: number; // MAJOR units, like the cart total createdAt?: string; meta?: { key: string; value: string }[]; }; -// shop(scope, query, variables): POST https://shop-api.crystallize.com/{tenant}/{scope} with the -// Shop API bearer token — see the mutation skill for the token. core(query, variables): POST -// https://api.crystallize.com/@{tenant} with the access token id/secret headers. +/** Thrown when the provider should retry: answer 5xx. */ +export class RetryLater extends Error {} + +type CartState = { id: string; state: "cart" | "placed" | "ordered" | "abandoned"; customer: CartCustomer | null }; +export const readCart = async (id: string) => + (await carts.fetch(id, { state: true, customer: CUSTOMER })) as unknown as CartState | null; + type OrderRead = { id: string; coreId: string | null; - paymentStatus: PaymentStatus; - payments: { provider: string; transactionId: string | null; amount: number | null }[] | null; + payments: + | { + provider: string; + method: string | null; + transactionId: string | null; + amount: number | null; + createdAt: string | null; + meta: Record | null; // written as [{ key, value }], read back as an object + }[] + | null; +}; +const ORDER = { + coreId: true, + payments: { provider: true, method: true, transactionId: true, amount: true, createdAt: true, meta: true }, }; +/** null while the order is not readable yet: createFromCart and payment writes persist just after answering. */ +export const readOrder = (id: string) => orders.fetch(id, ORDER).catch(() => null); + +/** The order id is the cart id. Creates it once; a later, different payment is recorded and flagged. */ +export async function createOrderOnce( + cartId: string, + paymentStatus: PaymentStatus, + payment: Payment, + pipelines?: { identifier: string; stage?: string }[], // e.g. [{ identifier: "fulfilment", stage: "new" }] +) { + const cart = await readCart(cartId); + if (cart?.state === "ordered") return addRecord(cartId, payment, true); + if (cart?.state !== "placed") throw new Error(`cart ${cartId} is ${cart?.state ?? "missing"}`); // alert a human + await ensureCustomer(cart.customer); + await orders.createFromCart(cartId, { type: "standard", paymentStatus, payments: [payment], pipelines }); +} + +const toInput = (p: NonNullable[number]): Payment => ({ + provider: p.provider, + method: p.method ?? undefined, + transactionId: p.transactionId ?? "", + amount: p.amount ?? 0, + createdAt: p.createdAt ?? undefined, + meta: Object.entries(p.meta ?? {}).map(([key, value]) => ({ key, value: String(value) })), +}); -export async function readOrder(id: string) { - const data = await shop<{ order: OrderRead | null }>( - "order", - `query($id: UUID!) { order(id: $id) { id coreId paymentStatus payments { provider transactionId amount } } }`, - { id }, - ).catch(() => ({ order: null })); - return data.order; +/** Refunds: append a record unless this transactionId is already on the order. */ +export const recordPayment = (orderId: string, payment: Payment) => addRecord(orderId, payment, false); + +async function addRecord(orderId: string, payment: Payment, isAnotherCharge: boolean) { + const order = await readOrder(orderId); + if (!order) throw new RetryLater(`order ${orderId} not readable yet`); + if (order.payments?.some((p) => p.transactionId === payment.transactionId)) return; // a redelivery + const flagged = isAnotherCharge && payment.meta?.find((m) => m.key === "type")?.value !== "refund"; + if (flagged) console.error(`[payments] second payment ${payment.transactionId} for order ${orderId}: refund it`); + const record = flagged + ? { ...payment, meta: [...(payment.meta ?? []), { key: "attention", value: "duplicate-payment" }] } + : payment; + await orders.addPayments(orderId, [record]); +} + +/** Capture, cancel: change one record and write the whole list back (setPayments replaces all). */ +export async function updatePayment(orderId: string, transactionId: string, change: (p: Payment) => Payment) { + const order = await readOrder(orderId); + if (!order?.payments) throw new RetryLater(`order ${orderId} not readable yet`); + const payments = order.payments.map(toInput).map((p) => (p.transactionId === transactionId ? change(p) : p)); + await orders.setPayments(orderId, payments); } -export async function createOrderOnce(cartId: string, paymentStatus: PaymentStatus, payment: Payment) { - const existing = await readOrder(cartId); - if (existing) return existing; // a retry or a duplicate event +export const withMeta = (p: Payment, values: Record, amount = p.amount): Payment => ({ + ...p, + amount, + meta: [ + ...(p.meta ?? []).filter((m) => !(m.key in values)), + ...Object.entries(values).map(([key, value]) => ({ key, value })), + ], +}); + +/** The Shop API rejects null where a field is optional: send only what the cart has. */ +const defined = (o: T) => + Object.fromEntries(Object.entries(o).filter(([, v]) => v !== null && v !== undefined)) as { + [K in keyof T]?: NonNullable; + }; + +/** The docs' "create the customer in Crystallize if it does not exist yet". Guests are skipped. */ +async function ensureCustomer(customer: CartCustomer | null) { + if (!customer?.identifier || customer.isGuest) return; + const exists = await customers.fetch(customer.identifier).then( + () => true, + () => false, // fetch rejects when the customer does not exist + ); + if (exists) return; // never overwrite a known customer with checkout data + const { isGuest, addresses, ...fields } = customer; + await customers.upsert({ + ...defined(fields), + identifier: customer.identifier, + type: customer.type ?? (customer.companyName ? "organization" : "individual"), + addresses: addresses?.map((address) => ({ ...defined(address), type: address.type })), + }); +} +``` +A provider webhook route then reads: + +```ts +try { + // … verified, cart id found, provider says "paid" … + await createOrderOnce(cartId, "paid", { provider: "stripe", transactionId: pi.id, amount, meta: [...] }); + return new Response("ok"); +} catch (error) { + console.error(error); + return new Response("retry", { status: 500 }); // RetryLater and real failures alike +} +``` + +## Serialising order writes (optional) + +`createOrderOnce` reads the cart's state, then calls `createFromCart`. That is enough for most shops and adds +nothing to the webhook's latency. What it leaves open is two deliveries for the same cart arriving at the same +moment: `createFromCart` answers before it moves the cart to `ordered`, so both can see `placed` and both create +the order. Crystallize still keeps one order (its id is the cart id); the second call rewrites it with its own +payment list: + +- **The same payment twice** (a provider redelivering): nothing is lost. +- **Two different payments for one cart, in the same second** (two tabs both paid): the first payment's record + disappears from the order. The money was taken, and nothing flags it for a refund. +- **A capture and a refund handled at the same instant**: `updatePayment` reads, changes and writes the whole + list, so one of the two changes is lost. + +The Shop API's `/lock` endpoint closes those gaps, at a cost on every webhook: two more round trips, the request +held until the cart shows `ordered` (up to a few seconds), and a 5xx (so a provider retry) whenever two deliveries +collide. It is a trade-off for the merchant: opt in for expensive or made-to-order goods, for a provider with no +per-cart idempotency that delivers concurrently, or for orders with frequent after-sales operations. Never put it +in the shopper's path (the Pay route). + +To opt in, add `"lock"` to the `shopApiToken` scopes and wrap the writes: + +```ts +// lib/crystallize-payments.ts — opt-in serialisation through the Shop API lock +import { createShopLock } from "@crystallize/js-api-client"; + +const lock = createShopLock(api); + +export async function withCartLock(cartId: string, work: () => Promise): Promise { + const key = `order:${cartId}`; + if (!(await lock.acquire(key, 60))) throw new RetryLater(`cart ${cartId} is being processed`); // → 5xx, retry try { - const data = await shop<{ createFromCart: { id: string; coreId: string | null } }>( - "order", - `mutation($id: UUID!, $input: OrderFromCartInput) { createFromCart(id: $id, input: $input) { id coreId } }`, - { id: cartId, input: { type: "standard", paymentStatus, payments: [payment] } }, - ); - return data.createFromCart; - } catch (error) { - const raced = await readOrder(cartId); // another delivery created it first - if (raced) return raced; - throw error; // let the route answer 5xx so the provider retries + return await work(); + } finally { + await lock.release(key).catch(() => {}); } } -// Capture, cancel: replace the payment record. Refund: addPayments with a type=refund record instead. -export async function replacePayments(orderId: string, payments: Payment[]) { - await shop( - "order", - `mutation($id: UUID!, $payments: [OrderPaymentInput!]!) { - setPayments(id: $id, payments: $payments) { id } }`, - { id: orderId, payments }, - ); +/** createFromCart answers before it moves the cart to `ordered`: hold the lock until it has. */ +export async function waitUntilOrdered(cartId: string) { + for (let i = 0; i < 10; i++) { + if ((await readCart(cartId))?.state === "ordered") return; + await new Promise((r) => setTimeout(r, 500)); + } + throw new RetryLater(`order ${cartId} created, cart not ordered yet`); } -// Refunds: providers resend refund events too, so skip a transactionId the order already has. -export async function addPayment(orderId: string, payment: Payment) { - const order = await readOrder(orderId); - if (!order) throw new Error(`Order ${orderId} not readable yet`); // → 5xx, provider retries - if (order.payments?.some((p) => p.transactionId === payment.transactionId)) return; - await shop( - "order", - `mutation($id: UUID!, $payments: [OrderPaymentInput!]!) { - addPayments(id: $id, payments: $payments) { id } }`, - { id: orderId, payments: [payment] }, - ); +// In a webhook: +// await withCartLock(cartId, async () => { +// await createOrderOnce(cartId, "paid", payment); +// await waitUntilOrdered(cartId); +// }); +// Capture vs refund: +// await withCartLock(cartId, () => updatePayment(cartId, transactionId, change)); +``` + +## Mapping to Crystallize + +### The payment record + +`createFromCart`, `addPayments` and `setPayments` take the same **generic** record (`OrderPaymentInput`) +for every provider — `provider` is a free string. Use it for all of them: + +```ts +{ + provider: 'klarna', // lower-case provider name + method: 'pay_later', // what the shopper used + transactionId: 'a8c3…', // the provider's id: capture, refund and cancel need it + amount: 1499.0, // MAJOR units, like the cart total — not cents + createdAt: '2026-10-06T10:12:00Z', + meta: [ + { key: 'state', value: 'authorized' }, // authorized | captured | cancelled + { key: 'cartId', value: cartId }, // lets a Core-side webhook find the Shop order (see capture) + ], } +``` -// paymentStatus lives on Core. Never pass `payment` here — see "paymentStatus" above. -export async function setPaymentStatus(orderId: string, paymentStatus: PaymentStatus) { - const order = await readOrder(orderId); - if (!order?.coreId) throw new Error(`Order ${orderId} not readable yet`); // → 5xx, provider retries - await core( - `mutation($id: ID!, $input: UpdateOrderInput!) { - updateOrder(id: $id, input: $input) { ... on Order { id } ... on BasicError { errorName message } } }`, - { id: order.coreId, input: { paymentStatus } }, +Crystallize stores it as a custom payment whose properties are `provider`, `transactionId`, `amount`, +`method`, `createdAt` and every `meta` key — so never use those five names as meta keys. Keep meta +small: `state`, `type` (`refund` on refund records), `cartId`, and the few provider ids needed later. +The Shop API returns `meta` as an object (`{ state: "authorized" }`); `toInput` above turns it back into +the `[{ key, value }]` list the mutations take. + +### paymentStatus + +`paymentStatus` is one of `paid`, `partiallyPaid`, `partiallyRefunded`, `refunded`, `unpaid` — there is +no "authorized". It is set **once**, by `createFromCart`; the Shop API has no mutation to change it +later. Many providers authorize first and capture when the goods ship, so the payment record's `state` +is what tracks the money afterwards, and the pipeline stage tracks the fulfilment. + +| Provider event | Crystallize | +| ------------------------------- | ----------------------------------------------------------------------------------- | +| Authorized, capture later | `createOrderOnce(…, 'unpaid', …)` with `meta state=authorized` | +| Paid (captured immediately) | `createOrderOnce(…, 'paid', …)` with `meta state=captured` | +| Captured later (full or part) | `updatePayment` → `state=captured`, `amount` = captured amount | +| Refunded (part or full) | `recordPayment` with `transactionId` = refund id, `amount`, `meta type=refund` | +| Authorization cancelled/expired | `updatePayment` → `state=cancelled`; move the order to your cancelled stage | +| Pending (bank transfer, SEPA) | Nothing yet: the provider sends another event when it settles | +| Failed / declined | No order. The shopper retries: same session if the provider allows, else a new cart | + +Do **not** patch `paymentStatus` or payments through the Core API's `updateOrder` on these orders: a +Core write is pushed back to the Shop order as a whole and can overwrite payments you have just set +through the Shop API. Keep every write to a checkout order on the Shop API `/order` endpoint. + +## Capture on shipment, from fulfilment pipelines + +Payment is the end of checkout and the start of the order's life. Put orders in a +[fulfilment pipeline](https://crystallize.com/docs/commerce/order-management/fulfilment-pipelines) at +creation (the last argument of `createOrderOnce`, passed to `createFromCart` as `pipelines`), and +let the stage drive the provider: + +1. Crystallize → Settings → Webhooks: concern **Order**, event **pipeline stage change**, POST to + `/api/crystallize/order-stage`, **no GraphQL query**. The body is then + `{ orderId, pipelineId, stageId, tenantId, webhookId }` — `orderId` is the **Core** order id. +2. Verify `X-Crystallize-Signature` with the tenant's signature secret. +3. Read the order's payment records from Core, take the `cartId` (= Shop order id), `provider` and + `transactionId`, call the provider's capture (or cancel) with an idempotency key, then + `updatePayment`. + +```ts +// app/api/crystallize/order-stage/route.ts +import { createSignatureVerifier } from "@crystallize/js-api-client"; +import { api, updatePayment, withMeta } from "@/lib/crystallize-payments"; +// provider name → each reference's `capture(transactionId, amount, record)`: the captured amount, or null when +// the provider captures asynchronously and reports the result in its own webhook (which then calls updatePayment). +// `record` is the payment's stored properties (meta included), for providers that need more (Adyen: currency). +import { captureByProvider } from "@/lib/payments"; + +const verify = createSignatureVerifier({ secret: process.env.CRYSTALLIZE_SIGNATURE_SECRET! }); + +export async function POST(req: Request) { + const body = await req.text(); + try { + // url must be the URL Crystallize called — behind a proxy, rebuild it from your public host + await verify(req.headers.get("x-crystallize-signature") ?? "", { url: req.url, method: "POST", body }); + } catch { + return new Response("bad signature", { status: 401 }); + } + const { orderId, stageId } = JSON.parse(body) as { orderId: string; pipelineId: string; stageId: string }; + if (stageId !== process.env.CRYSTALLIZE_SHIPPED_STAGE_ID) return new Response("ignored"); + + const { order } = await api.nextPimApi<{ + order: { payment?: { properties?: { property: string; value: string | null }[] }[] }; + }>( + `query($id: ID!) { order(id: $id) { ... on Order { payment { ... on CustomPayment { properties { property value } } } } } }`, + { id: orderId }, ); + const records = (order.payment ?? []).map((p) => + Object.fromEntries((p.properties ?? []).map(({ property, value }) => [property, value ?? ""])), + ); + const authorized = records.find((r) => r.state === "authorized" && r.type !== "refund"); + if (!authorized) return new Response("nothing to capture"); // a redelivery, or captured at checkout + try { + const capture = captureByProvider[authorized.provider]; + const captured = await capture(authorized.transactionId, Number(authorized.amount), authorized); + if (captured !== null) { + await updatePayment(authorized.cartId, authorized.transactionId, (p) => + withMeta(p, { state: "captured" }, captured), + ); + } + return new Response("ok"); + } catch (error) { + console.error(error); + return new Response("retry", { status: 500 }); // Crystallize retries failed webhooks + } } ``` -`setPayments` replaces **all** payment records, and the Shop API does not return a payment's `meta`, so -rebuild the full list from your provider's data: the payment record with its new `state`, plus every -refund record (refund ids and amounts come back from the provider's payment/refund lookup). Capture and -cancel normally happen before any refund, when the order has only the one record. - -A just-created order can take a moment to be readable (`coreId` may still be `null`). If the provider -may deliver two events at once (Stripe says so explicitly), serialise per cart where your platform -allows — a queue, or a lock keyed on the cart id — or store processed event ids. +Find the stage ids by logging one delivery. The same handler can cancel on a "Cancelled" stage. Where a +provider captures asynchronously (its `capture()` returns `null`), the record flips to `captured` from that +provider's own webhook, never before the capture has succeeded. Check +how long each provider keeps an authorization alive (in its reference): made-to-order goods often ship +after it expires. ## Choosing a provider -| Provider | Where it sells | Style | Capture | -| ------------------------------------------------ | ------------------------------------------------ | -------------------------------------- | ---------------- | -| [Stripe](references/stripe.md) | Global; cards, wallets, Klarna, Vipps, MobilePay | Checkout Session + Payment Element | Auto (or manual) | -| [Adyen](references/adyen.md) | Global, enterprise onboarding | Sessions flow + Web Drop-in | Auto (or manual) | -| [Klarna](references/klarna.md) | EU, Nordics, UK, US, AU | Klarna Payments, Hosted Payment Page | Manual | -| [Qliro](references/qliro.md) | Nordics (SEK, NOK, DKK, EUR) | Qliro Checkout, embedded | Manual | -| [Dintero](references/dintero.md) | Nordics | Checkout session, redirect or embedded | Manual or auto | -| [Vipps MobilePay](references/vipps-mobilepay.md) | Norway, Denmark, Finland (NOK, DKK, EUR) | ePayment API, redirect / app switch | Manual | -| [QuickPay](references/quickpay.md) | Denmark and EU acquiring | Payment Link, redirect | Manual | -| [Montonio](references/montonio.md) | Baltics, Finland, Poland (EUR, PLN) | Order → payment URL, redirect | None (paid) | -| [Razorpay](references/razorpay.md) | Merchants in India, Malaysia/Singapore, US only | Standard Checkout + server Order | Auto, 3-day cap | - -Pick by where the merchant is incorporated and where its shoppers are, then by capture model: furniture -and other made-to-order goods want **authorize now, capture on shipment**, so check how long each -provider keeps an authorization alive (in each reference). - -## Porting the old boilerplates - -`furniture-remix` and `nextjs-furnitut` contain payment code for these providers. Use it to see what a -provider needs, **never** for the Crystallize side: it creates orders with the deprecated -`createOrderPusher` / `@crystallize/node-service-api-request-handlers` and the typed -`provider: 'stripe', stripe: {…}` payment shape. Every provider reference lists what is wrong in the old -code — several had unverified webhooks, created an order on every callback, or call APIs that no longer -exist. +Crystallize works with any gateway; these have a reference here: + +| Provider | Where it sells | Style | Capture | +| ------------------------------------------------ | ---------------------------------------------------------- | ------------------------------------------------- | ----------------------- | +| [Stripe](references/stripe.md) | Global; cards, wallets, Klarna, MobilePay, Vipps (preview) | Checkout Session + Payment Element | Auto (or manual) | +| [Adyen](references/adyen.md) | Global, enterprise onboarding | Sessions flow + Web Drop-in | Auto (or manual) | +| [Klarna](references/klarna.md) | 26 countries: Europe, US, CA, MX, AU, NZ | Klarna Payments, Hosted Payment Page | Manual | +| [Qliro](references/qliro.md) | Nordics (SEK, NOK, DKK, EUR) | Qliro Checkout, embedded | Manual (async) | +| [Dintero](references/dintero.md) | Nordics | Checkout session, redirect or embedded | Manual or auto | +| [Vipps MobilePay](references/vipps-mobilepay.md) | Norway, Denmark, Finland (NOK, DKK, EUR) | ePayment API, redirect / app switch / QR | Manual | +| [Mollie](references/mollie.md) | Merchants in the EEA, UK and Switzerland | Payments API, hosted checkout | Auto, or `captureMode` | +| [Montonio](references/montonio.md) | Baltics, Finland, Poland (EUR, PLN) | Order → payment URL, bank picker, parcel machines | None (paid) | +| [QuickPay](references/quickpay.md) | Denmark and EU acquiring | Payment link, redirect | Manual | +| [Two](references/two.md) | B2B invoice: Nordics, UK, EU, US | Company search + hosted verification | On fulfilment (invoice) | +| [Razorpay](references/razorpay.md) | Merchants in India, Malaysia/Singapore, US only | Standard Checkout + server Order | Auto (manual: ~3 days) | + +Pick by where the merchant is incorporated and where its shoppers are, then by capture model: +made-to-order goods want **authorize now, capture on shipment**. Payment details can also be stored on +a subscription contract; recurring payments are not covered here — the references only point at each +provider's recurring API. + +## Common mistakes + +- Charging an amount from a cart that is not placed, or from the browser — the two-tab attack. +- Verifying a signature over `JSON.stringify(parsedBody)`, or answering 200 (or `{}`) to a bad one. +- Creating the order on the return page, or from a verification the browser sends (only the provider's + server-to-server notification, or a status you fetched yourself, counts). +- Creating an order on every callback, for refused or pending payments, or twice when two deliveries + overlap — use `createOrderOnce`. +- Reading the live cart in a callback instead of the placed one: lines and total must match what was charged. +- `gross * 100` without rounding; sending `0` for VAT; mixing a per-unit price with a line discount. +- Hardcoding currency, country or locale, or deriving the country from the currency. +- Letting the shopper pick shipping inside the provider's checkout: the provider then charges more than + the placed cart, and the order has no shipping line. Choose shipping before `place`. +- Calling a provider's old API family (eCom v2, Payments v1, `charges.data`) — each reference names the + current one. +- Patching `paymentStatus` or payments through Core `updateOrder` on a Shop API order. +- Long polling a provider inside one request instead of handling its webhook. ## References -Each reference follows the same outline — at a glance, setup, create session, client, webhook, -capture/refund, mapping, gotchas — and opens with its verification level. +Each reference opens with the provider at a glance, then: credentials and setup, creating the payment +from the placed cart, the client, the webhook, capture/refund/cancel, the mapping, provider specifics, +going further, and common mistakes. - [references/stripe.md](references/stripe.md) - [references/adyen.md](references/adyen.md) @@ -252,10 +630,12 @@ capture/refund, mapping, gotchas — and opens with its verification level. - [references/qliro.md](references/qliro.md) - [references/dintero.md](references/dintero.md) - [references/vipps-mobilepay.md](references/vipps-mobilepay.md) -- [references/quickpay.md](references/quickpay.md) +- [references/mollie.md](references/mollie.md) - [references/montonio.md](references/montonio.md) +- [references/quickpay.md](references/quickpay.md) +- [references/two.md](references/two.md) - [references/razorpay.md](references/razorpay.md) -Related: [[mutation]] for the cart and order mutations, [[query]] for reading orders back, -[[pricing]] for markets and currencies, [[bookable-resources]] when the cart holds bookings (confirm -them after `createFromCart`). +Related: [[mutation]] for the cart and order mutations, [[query]] for reading orders back, [[js-api-client]] +for the client and `createSignatureVerifier`, [[pricing]] for markets and currencies, [[bookable-resources]] +when the cart holds bookings (confirm them after `createFromCart`). diff --git a/use-crystallize/skills/payments/references/adyen.md b/use-crystallize/skills/payments/references/adyen.md index 38b8406..b4e2ac0 100644 --- a/use-crystallize/skills/payments/references/adyen.md +++ b/use-crystallize/skills/payments/references/adyen.md @@ -1,306 +1,551 @@ # Adyen with Crystallize -> Verification: Written from Adyen's official docs, checked 2026-09-29. Not run end-to-end. -> Official docs: [Sessions flow][sessions], [Hosted Checkout][hosted], [Upgrade to Checkout API v72][v72], -> [Upgrade to Web v6][web6], [Webhooks][webhooks], [Verify HMAC][hmac], [Capture][capture], [Refund][refund], -> [Currency codes][currency]. Every docs page is served as Markdown (append `.md`); index at -> . OpenAPI: [CheckoutService-v72.json][openapi]. +Adyen is a Dutch global payment platform (cards, Apple Pay, Google Pay and local methods such as iDEAL, Klarna, Vipps, +MobilePay, Swish, Trustly) that onboards merchants per legal entity and sells in most currencies. The recommended +integration is the Sessions flow: one server-side `/sessions` call from the placed cart (Checkout API v72), the Web +Drop-in (`@adyen/adyen-web` v6) on the checkout page, and the order created from the HMAC-signed `AUTHORISATION` +webhook. Capture is automatic by default; for methods that support separate capture, manual capture (authorize now, +capture on shipment) or a delayed automatic capture is set per merchant account or per session. + +> Verification: Written from Adyen's official docs, checked 2026-10-06. Not run end-to-end. +> Official docs: [Sessions flow][sessions], [Checkout API v72][v72], [Web v6][web6], [Handle webhooks][handle], +> [Verify HMAC][hmac], [Capture][capture], [Refund][refund], [Cancel][cancel], [Currency codes][currency]. Every page +> is served as Markdown (append `.md`); the index is [llms.txt](https://docs.adyen.com/llms.txt). ## At a glance -| Topic | Adyen | -| ----------------------- | --------------------------------------------------------------------------------------------------- | -| Markets & currencies | Global, enterprise onboarding. Nordics: cards, Vipps, MobilePay, Swish, Klarna, Trustly | -| Recommended integration | Sessions flow + Web Drop-in v6 (inline; handles 3DS and redirects) | -| Alternative | Hosted Checkout: `/sessions` with `mode: 'hosted'` + `themeId`, redirect to `response.url` | -| API version | Checkout API **v72** (breaking: stricter field validation) | -| Server SDK | `@adyen/api-library@32` (Node ≥ 18) | -| Client SDK | `@adyen/adyen-web@6` (needs Checkout API ≥ v69) | -| Amount units | Minor units. Adyen's own exponents: JPY 0, IDR 0, **ISK 2**, CLP 2, BHD/KWD 3 | -| Capture | Auto by default. Manual: `additionalData.manualCapture: 'true'` (Vipps/MobilePay/Klarna; not Swish) | -| Cart id | `reference` (≤ 80 chars, echoed as `merchantReference`) + `metadata` (20 keys, 20/80 chars) | -| Webhook verification | HMAC-SHA256 of 8 colon-joined fields, hex key, Base64 in `additionalData.hmacSignature` | - -## Setup - -- Test Customer Area (ca-test.adyen.com): **Developers → API credentials** → create a credential; copy the - **API key** and **Client key**, and add `http://localhost:3000` plus preview domains to **allowed - origins** (otherwise Drop-in fails to load). Note the **merchant account** name. -- Enable payment methods per merchant account (Vipps, MobilePay, Swish, Klarna must be added). -- Webhook: **Developers → Webhooks → Standard webhook** at company level. URL - `https:///api/payments/adyen/webhook` — public, **no redirects**. Set Basic auth (or OAuth 2.0), - generate an **HMAC key**. Live has different keys. "Test configuration" sends sample events. -- Localhost: no CLI forwarder — use a tunnel (ngrok, cloudflared) and point the test webhook at it. -- Live: `liveEndpointUrlPrefix` on the server, `environment: 'live'`/`'live-us'`/… on the client, one region. +| | | +| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Markets & currencies | Global; any currency in Adyen's [table][currency]. Payment methods are enabled per merchant account | +| Recommended integration | Sessions flow: `POST /sessions` on the server + Web Drop-in v6 on the page (it handles 3D Secure and redirects) | +| Alternative | Hosted Checkout: the same `/sessions` with `mode: "hosted"` + `themeId`, then redirect to the returned `url` ([guide][hosted]) | +| API version | Checkout API **v72** (April 2026: validates `reference`, `returnUrl`, `shopperEmail`, postal codes; enhanced scheme data moved) | +| SDKs | Browser `@adyen/adyen-web@6.46` (v6 needs API ≥ v69). Server `@adyen/api-library@32` (v72, Node ≥ 18) or plain `fetch`, shown here | +| Amount units | Integer minor units by **Adyen's** table: JPY, KRW, IDR, CVE… 0; BHD, KWD, JOD, OMR, TND… 3; ISK and CLP **2** (ISO says 0) | +| Capture | Auto by default; manual, or delayed (`captureDelayHours` ≤ 672). Adyen expires authorisations after 28 days; Visa e-commerce 10, Mastercard final 7, Amex 7 ([validity][validity]) | +| Cart id | `reference` (≥ 3, ≤ 80 chars; a cart UUID is 36), echoed as the webhook's `merchantReference` | +| One session per cart | `Idempotency-Key: session-` (≤ 64 chars, remembered 7–14 days). Adyen does not refuse a repeated `reference` | +| Notification verification | Basic auth on the endpoint + HMAC-SHA256 of each item (8 fields, hex key), Base64 in `additionalData.hmacSignature` | + +## Credentials and setup + +- **Test account**: sign up at adyen.com for a test Customer Area (). The crystallize.com + page names two values, both under **Developers → API credentials** → your credential ([docs][credentials]): + - **API key** (Server settings → Authentication → Generate API key): server only, sent as `X-API-Key`; shown + once. + - **Client key** (Client settings → Authentication, `test_…` / `live_…`): public, for Drop-in. Add **allowed + origins** there (`http://localhost:3000` works in test; live needs `https`) or Drop-in will not load. +- **Merchant account**: its name goes in every request (`YourCompanyECOM`). Add the payment methods you want to it. + Capture mode: **Settings → Account settings → Capture delay** (immediate, 1–7 days, Manual). +- **HMAC key**: **Developers → Webhooks → Create new webhook → Standard webhook** (company level, limited to your + merchant account), URL `https:///api/payments/adyen/webhook`, method JSON, **Basic authentication** user and + password, **HMAC key → Generate** (hex). Live has its own webhook, HMAC key, API key and client key. +- **Test data** ([cards][test-cards]): `4111 1111 1111 1111`, `03/2030`, `737`; 3DS2-enrolled `4917 6100 0000 0000`; + holder name `DECLINED` forces a refusal (show the field with `hasHolderName: true`) ([result codes][test-results]). +- **Reaching localhost**: the webhook URL must be public and must not redirect (test: HTTP on 80/8080/8888 or HTTPS on + 443/8443/8843; live: HTTPS only). Use a tunnel (ngrok, cloudflared); **Test configuration** sends sample events. ```bash -ADYEN_API_KEY=AQE... +ADYEN_API_KEY=AQE… +ADYEN_CHECKOUT_URL=https://checkout-test.adyen.com/v72 # live: https://-checkout-live.adyenpayments.com/…/v72 ADYEN_MERCHANT_ACCOUNT=YourCompanyECOM -ADYEN_HMAC_KEY=44782DEF... # hex string from the webhook page -ADYEN_WEBHOOK_USER=... -ADYEN_WEBHOOK_PASSWORD=... -ADYEN_ENVIRONMENT=TEST # LIVE also needs ADYEN_LIVE_PREFIX -NEXT_PUBLIC_ADYEN_CLIENT_KEY=test_... +ADYEN_HMAC_KEY=44782DEF… +ADYEN_WEBHOOK_USERNAME=… +ADYEN_WEBHOOK_PASSWORD=… +PUBLIC_URL=https://shop.example # the tunnel in development +NEXT_PUBLIC_ADYEN_CLIENT_KEY=test_… +NEXT_PUBLIC_ADYEN_ENVIRONMENT=test # live, live-us, live-au, live-nea, live-in: prefix's region ``` -## Create the session +Live base URL: `https://-checkout-live.adyenpayments.com/checkout/v72`, the prefix from the live Customer +Area's **Developers → API URLs** ([live endpoints][live]). -After `place` ([SKILL.md](../SKILL.md#the-payment-step)): +## Create the payment -```ts -import { CheckoutAPI, Client, EnvironmentEnum, Types } from "@adyen/api-library"; +Call it from the Pay route right after `place`, which makes the cart immutable +([SKILL.md](../SKILL.md#lock-the-cart-before-you-charge)). The amount is the placed total, the `reference` the cart id. +`countryCode` filters the payment methods: take it from the cart's delivery address or the market the cart was priced +in, never from the currency. Klarna, Riverty, Afterpay, Affirm, Ratepay and Oney require `lineItems` that add up to +the amount exactly. -const client = new Client({ - apiKey: process.env.ADYEN_API_KEY!, - environment: process.env.ADYEN_ENVIRONMENT === "LIVE" ? EnvironmentEnum.LIVE : EnvironmentEnum.TEST, - liveEndpointUrlPrefix: process.env.ADYEN_LIVE_PREFIX, -}); -export const checkout = new CheckoutAPI(client); +```ts +// lib/adyen.ts +import type { PlacedCart } from "@/lib/crystallize-payments"; + +export const MERCHANT = process.env.ADYEN_MERCHANT_ACCOUNT!; +/** Authorize at checkout, capture when the order ships. Keep it in step with the Customer Area's capture delay. */ +export const CAPTURE_ON_SHIPMENT = true; + +// Adyen's decimals win over ISO 4217 (ISK and CLP have 2 at Adyen). +const ZERO = ["CVE", "DJF", "GNF", "IDR", "JPY", "KMF", "KRW", "PYG", "RWF", "UGX", "VND", "VUV", "XAF", "XOF", "XPF"]; +const THREE = ["BHD", "IQD", "JOD", "KWD", "LYD", "OMR", "TND"]; +const exp = (c: string) => (ZERO.includes(c.toUpperCase()) ? 0 : THREE.includes(c.toUpperCase()) ? 3 : 2); +export const toMinor = (major: number, currency: string) => Math.round(major * 10 ** exp(currency)); +export const toMajor = (minor: number, currency: string) => minor / 10 ** exp(currency); + +/** POST to the Checkout API. A repeated key returns the first result; "704 in progress" and transient errors retry. */ +export async function adyen(path: string, body: object, idempotencyKey: string): Promise { + for (let attempt = 0; ; attempt++) { + const res = await fetch(`${process.env.ADYEN_CHECKOUT_URL}${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-API-Key": process.env.ADYEN_API_KEY!, + "Idempotency-Key": idempotencyKey, + }, + body: JSON.stringify(body), + }); + if (res.ok) return (await res.json()) as T; + const text = await res.text(); + const retry = res.headers.get("transient-error") === "true" || /"errorCode"\s*:\s*"704"/.test(text); + if (!retry || attempt === 3) throw new Error(`Adyen ${path} ${res.status}: ${text}`); + await new Promise((r) => setTimeout(r, 500 * 2 ** attempt)); + } +} -const EXPONENT: Record = { JPY: 0, IDR: 0, BHD: 3, KWD: 3 }; // default 2; see currency codes -const exp = (c: string) => EXPONENT[c.toUpperCase()] ?? 2; -export const toMinor = (major: number, c: string) => Math.round(major * 10 ** exp(c)); -export const toMajor = (minor: number, c: string) => minor / 10 ** exp(c); +/** Unit amounts × quantity add up to the placed total; a line whose discount does not split per unit stays whole. */ +function lineItems(placed: PlacedCart) { + const c = placed.total.currency; + const lines = placed.items.map((item) => { + const gross = toMinor(item.price.gross, c); // line totals + const net = toMinor(item.price.net, c); + const qty = gross % item.quantity === 0 && net % item.quantity === 0 ? item.quantity : 1; + return { + id: item.variant?.sku ?? item.lineId ?? item.name, + description: qty === item.quantity ? item.name : `${item.quantity} × ${item.name}`, + quantity: qty, + amountIncludingTax: gross / qty, + amountExcludingTax: net / qty, + taxAmount: (gross - net) / qty, + taxPercentage: Math.round(item.price.taxPercent * 100), // basis points: 2500 = 25 % + }; + }); + const rest = toMinor(placed.total.gross, c) - lines.reduce((s, l) => s + l.amountIncludingTax * l.quantity, 0); + if (rest !== 0) + lines.push({ + id: "adjustment", + description: "Adjustment", + quantity: 1, + amountIncludingTax: rest, + amountExcludingTax: rest, + taxAmount: 0, + taxPercentage: 0, + }); + return lines; +} -type PlacedCart = { id: string; total: { gross: number; net: number; currency: string } }; +type CartAddress = NonNullable["addresses"]>[number]; +const toAddress = (a?: CartAddress) => + // Adyen wants all five fields; postalCode ≤ 10 chars + a?.street && a.city && a.postalCode && a.country + ? { + street: a.street, + houseNumberOrName: a.streetNumber ?? "", + postalCode: a.postalCode, + city: a.city, + country: a.country, + } + : undefined; export async function createAdyenSession( - cart: PlacedCart, - origin: string, - shopper: { - country: string; - locale: string; - email?: string; - }, + placed: PlacedCart, + market: { origin: string; country: string; locale: string }, // resolved on the server, not sent by the browser ) { - const currency = cart.total.currency.toUpperCase(); - const request: Types.checkout.CreateCheckoutSessionRequest = { - merchantAccount: process.env.ADYEN_MERCHANT_ACCOUNT!, - amount: { currency, value: toMinor(cart.total.gross, currency) }, - reference: cart.id, // ≤ 80 chars; comes back as merchantReference - metadata: { crystallizeCartId: cart.id }, // key ≤ 20, value ≤ 80 chars - returnUrl: `${origin}/checkout/adyen/return?cartId=${cart.id}`, // ≤ 1024 chars, no PII - countryCode: shopper.country, // shipping country — it filters payment methods - shopperLocale: shopper.locale, // e.g. 'nb-NO' - channel: Types.checkout.CreateCheckoutSessionRequest.ChannelEnum.Web, - shopperEmail: shopper.email, // risk checks and 3DS; shopperReference: 3–256 chars - // lineItems (Klarna, Riverty, Afterpay): { id, sku, description, quantity, amountIncludingTax, - // amountExcludingTax, taxAmount, taxPercentage } — minor units; taxPercentage 2500 = 25 % - // additionalData: { manualCapture: 'true' }, // authorize now, capture on shipment - }; - const session = await checkout.PaymentsApi.sessions(request, { idempotencyKey: `session-${cart.id}` }); - return { id: session.id, sessionData: session.sessionData }; // + session.url when mode: 'hosted' + const currency = placed.total.currency.toUpperCase(); + const c = placed.customer; + const delivery = c?.addresses?.find((a) => a.type === "delivery"); + const countryCode = (delivery?.country ?? market.country).toUpperCase(); + const session = await adyen<{ id: string; sessionData: string }>( + "/sessions", + { + merchantAccount: MERCHANT, + amount: { currency, value: toMinor(placed.total.gross, currency) }, // the PLACED total + reference: placed.id, + returnUrl: `${market.origin}/order/cart/${placed.id}`, // ≤ 1024 chars, no PII + countryCode, // Drop-in reads it, the amount and the locale from the session + shopperLocale: market.locale, // e.g. nb-NO + channel: "Web", + shopperEmail: c?.email, // risk checks and 3DS; v72 rejects a malformed one + lineItems: lineItems(placed), // with the four below, what Klarna, Riverty and Afterpay need + shopperName: c?.firstName && c.lastName ? { firstName: c.firstName, lastName: c.lastName } : undefined, + telephoneNumber: c?.phone?.startsWith("+") ? c.phone : undefined, + deliveryAddress: toAddress(delivery), + billingAddress: toAddress(c?.addresses?.find((a) => a.type === "billing")), + ...(placed.meta?.paymentMethod ? { allowedPaymentMethods: [placed.meta.paymentMethod] } : {}), + ...(CAPTURE_ON_SHIPMENT && { additionalData: { manualCapture: "true" } }), // a string, not a boolean + }, + `session-${placed.id}`, // one session per cart: a second tab gets the same session back + ); + return { id: session.id, sessionData: session.sessionData }; // all the page needs; nothing secret } ``` +The Pay route answers `Response.json(await createAdyenSession(placed, { origin: process.env.PUBLIC_URL!, ...market }))`. +A session lives 1 hour (`expiresAt`, at most 24): a shopper who comes back later gets a new cart, as in +[SKILL.md](../SKILL.md#lock-the-cart-before-you-charge), never a second key for the same placed cart. + ## Client +Initialise Drop-in once, client side, on a ref (not with selectors, not in an iframe on another domain). +`@adyen/adyen-web/auto` shows every method enabled on the merchant account; import from `@adyen/adyen-web` and pass +`paymentMethodComponents: [Card, …]` to `Dropin` for a smaller bundle. + ```tsx +// app/checkout/adyen.tsx "use client"; import { useEffect, useRef } from "react"; -import { AdyenCheckout, Dropin } from "@adyen/adyen-web/auto"; +import { AdyenCheckout, Dropin, type CoreConfiguration } from "@adyen/adyen-web/auto"; import "@adyen/adyen-web/styles/adyen.css"; -export function AdyenPay({ session, cartId, amount, country, locale }: Props) { +const config = { + environment: (process.env.NEXT_PUBLIC_ADYEN_ENVIRONMENT ?? "test") as CoreConfiguration["environment"], + clientKey: process.env.NEXT_PUBLIC_ADYEN_CLIENT_KEY!, +}; + +export function AdyenDropin({ cartId, session }: { cartId: string; session: { id: string; sessionData: string } }) { const ref = useRef(null); useEffect(() => { - // initialise once, client side only let dropin: Dropin | undefined; + let gone = false; // React strict mode mounts twice AdyenCheckout({ - session, - amount, - countryCode: country, - locale, // countryCode is mandatory in v6 - environment: "test", - clientKey: process.env.NEXT_PUBLIC_ADYEN_CLIENT_KEY!, - onPaymentCompleted: (r) => location.assign(`/order/cart/${cartId}?rc=${r.resultCode}`), - onPaymentFailed: (r) => alert(r.resultCode), // Refused | Cancelled | Error + ...config, + session, // v6 requires a countryCode: with a session it comes from /sessions, like amount and locale + onPaymentCompleted: () => location.assign(`/order/cart/${cartId}`), // Authorised, Pending, Received + onPaymentFailed: (r) => console.warn(r?.resultCode), // Refused, Cancelled, Error: the shopper can retry + onError: (e) => console.error(e.name, e.message), }).then((checkout) => { - dropin = new Dropin(checkout).mount(ref.current!); + if (!gone && ref.current) dropin = new Dropin(checkout).mount(ref.current); }); - return () => dropin?.unmount(); - }, []); + return () => { + gone = true; + dropin?.unmount(); + }; + }, [cartId, session]); return
; } + +/** On the return page, once, after a redirect (iDEAL, Vipps, MobilePay, some 3D Secure): completes the payment. */ +export async function finishAdyenRedirect(onResult: (resultCode?: string) => void) { + const q = new URLSearchParams(location.search); + const [sessionId, redirectResult] = [q.get("sessionId"), q.get("redirectResult")]; + if (!sessionId || !redirectResult) return; + history.replaceState(null, "", location.pathname); // a reload must not submit it twice + const checkout = await AdyenCheckout({ + ...config, + session: { id: sessionId }, + onPaymentCompleted: (r) => onResult(r.resultCode), + onPaymentFailed: (r) => onResult(r?.resultCode), + }); + checkout.submitDetails({ details: { redirectResult } }); +} ``` -Redirect methods (Vipps, MobilePay, some 3DS) return to `returnUrl?sessionId=…&redirectResult=…`: create -`AdyenCheckout({ session: { id: sessionId }, … })`, call `checkout.submitDetails({ details: { redirectResult } })` -for a display `resultCode`, and **poll for the order** — the webhook creates it. +`/order/cart/[cartId]` is the [return page](../SKILL.md#the-return-page): it takes the cart id from its own URL (a +redirect can land in another browser, without your cookie), only reads the cart and waits until it is `ordered`. It +calls `finishAdyenRedirect` in an effect, so a redirect method completes and a refusal shows at once ("try again" +leads back to the same placed cart and session) instead of waiting forever. ## Webhook +Adyen signs each `NotificationRequestItem`, not the body: HMAC-SHA256 with the hex-decoded key over +`pspReference:originalReference:merchantAccountCode:merchantReference:value:currency:eventCode:success` (empty string +for a missing field), Base64 in `additionalData.hmacSignature`. It does not cover `paymentMethod`, `eventDate` or +`additionalData`; basic auth and TLS protect those. (`hmacValidator` in `@adyen/api-library` does the same check.) +Keep accepting the previous key for a while after rotating it. + ```ts +// lib/adyen.ts (continued) import { createHmac, timingSafeEqual } from "node:crypto"; -type Item = { +export type AdyenItem = { + eventCode: string; + success: "true" | "false"; pspReference: string; originalReference?: string; merchantAccountCode: string; - eventDate: string; merchantReference: string; amount: { value: number; currency: string }; - eventCode: string; - success: "true" | "false"; + eventDate: string; paymentMethod?: string; + reason?: string; additionalData?: Record; }; +const same = (a: Buffer, b: Buffer) => a.length === b.length && timingSafeEqual(a, b); + +function validHmac(i: AdyenItem, hexKey: string) { + const signed = [ + i.pspReference, + i.originalReference ?? "", + i.merchantAccountCode, + i.merchantReference, + i.amount.value, + i.amount.currency, + i.eventCode, + i.success, + ].join(":"); + const expected = createHmac("sha256", Buffer.from(hexKey, "hex")).update(signed, "utf8").digest(); + return same(Buffer.from(i.additionalData?.hmacSignature ?? "", "base64"), expected); +} -export async function verifyAdyen(req: Request): Promise { - const basic = "Basic " + btoa(`${process.env.ADYEN_WEBHOOK_USER}:${process.env.ADYEN_WEBHOOK_PASSWORD}`); - if (req.headers.get("authorization") !== basic) throw new Error("bad basic auth"); - const body = JSON.parse(await req.text()) as { notificationItems: { NotificationRequestItem: Item }[] }; - const key = Buffer.from(process.env.ADYEN_HMAC_KEY!, "hex"); - return body.notificationItems.map(({ NotificationRequestItem: i }) => { - const signed = [ - i.pspReference, - i.originalReference ?? "", - i.merchantAccountCode, - i.merchantReference, - i.amount.value, - i.amount.currency, - i.eventCode, - i.success, - ].join(":"); - const expected = createHmac("sha256", key).update(signed, "utf8").digest(); - const received = Buffer.from(i.additionalData?.hmacSignature ?? "", "base64"); - if (received.length !== expected.length || !timingSafeEqual(received, expected)) throw new Error("bad hmac"); - return i; - }); +/** Basic auth, then the HMAC of EVERY item. null → answer 401. */ +export function verifyAdyen(authorization: string | null, raw: string): AdyenItem[] | null { + const user = Buffer.from(`${process.env.ADYEN_WEBHOOK_USERNAME}:${process.env.ADYEN_WEBHOOK_PASSWORD}`); + if (!same(Buffer.from(authorization ?? ""), Buffer.from(`Basic ${user.toString("base64")}`))) return null; + let items: AdyenItem[]; + try { + const body = JSON.parse(raw) as { notificationItems?: { NotificationRequestItem: AdyenItem }[] }; + items = (body.notificationItems ?? []).map((n) => n.NotificationRequestItem); + } catch { + return null; + } + const keys = [process.env.ADYEN_HMAC_KEY, process.env.ADYEN_HMAC_KEY_PREVIOUS].filter((k): k is string => !!k); + return items.length > 0 && items.every((i) => keys.some((k) => validHmac(i, k))) ? items : null; } ``` -The HMAC covers these fields, not the body bytes (`hmacValidator().validateHMAC(item, key)` does the same). -Header-signed webhooks (`hmacsignature`, e.g. `recurring.token.created`) sign the **raw body** instead. +On a successful `AUTHORISATION`, `createOrderOnce` does what the crystallize.com page lists: it creates the customer in +Crystallize if missing, creates the order, and moves the cart from `placed` to `ordered`, which releases the waiting +order page. ```ts // app/api/payments/adyen/webhook/route.ts -import { createOrderOnce } from "@/lib/crystallize-payments"; // from SKILL.md -import { toMajor, verifyAdyen } from "@/lib/adyen"; +import { createOrderOnce, recordPayment, updatePayment, withMeta } from "@/lib/crystallize-payments"; +import { CAPTURE_ON_SHIPMENT, MERCHANT, toMajor, verifyAdyen, type AdyenItem } from "@/lib/adyen"; export const runtime = "nodejs"; -const CAPTURES_LATER = false; // true when manualCapture or captureDelayHours is used +const CART_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const NO_SEPARATE_CAPTURE = new Set(["ideal", "swish", "trustly"]); // captured at authorisation even when manual export async function POST(req: Request) { - const items = await verifyAdyen(req).catch(() => null); + const raw = await req.text(); + const items = verifyAdyen(req.headers.get("authorization"), raw); if (!items) return new Response("unauthorized", { status: 401 }); try { - for (const i of items) { - // a delivery can hold several items — handle all of them - if (i.eventCode !== "AUTHORISATION" || i.success !== "true") continue; // refusals: 2xx, no order - await createOrderOnce(i.merchantReference, CAPTURES_LATER ? "unpaid" : "paid", { - provider: "adyen", - method: i.paymentMethod ?? "unknown", - transactionId: i.pspReference, - amount: toMajor(i.amount.value, i.amount.currency), - createdAt: i.eventDate, - meta: [ - { key: "state", value: CAPTURES_LATER ? "authorized" : "captured" }, - { key: "checkoutSessionId", value: i.additionalData?.checkoutSessionId ?? "" }, - ], - }); - // CAPTURE / CANCELLATION / REFUND: see the table and "Capture, refund, cancel". - } - return new Response("[accepted]"); // any 2xx; must arrive within 10 s + for (const item of items) await handle(item); // every item, not only the first + return new Response("[accepted]"); // any 2xx, within 10 seconds } catch (error) { console.error(error); - return new Response("retry", { status: 500 }); // retry queue: up to 30 days + return new Response("retry", { status: 500 }); // Adyen retries, then queues for up to 30 days } } -``` -| Adyen `eventCode` | Crystallize ([SKILL.md](../SKILL.md#paymentstatus)) | -| -------------------------------------------- | -------------------------------------------------------------------------- | -| `AUTHORISATION` `success=true`, auto capture | `createOrderOnce(…, 'paid', state=captured)` | -| `AUTHORISATION` `success=true`, manual | `createOrderOnce(…, 'unpaid', state=authorized)` | -| `AUTHORISATION` `success=false` | No order; answer 2xx (a 4xx/5xx makes Adyen retry for 30 days) | -| `OFFER_CLOSED` | No order; shopper abandoned (card offers close after 12 h) | -| `CAPTURE` `success=true` | `setPayments` state=captured; Core `paymentStatus: paid` / `partiallyPaid` | -| `CAPTURE_FAILED` | Keep `unpaid`; alert a human | -| `CANCELLATION` / `CANCEL_OR_REFUND` | `setPayments` state=cancelled (or refund row if it was captured) | -| `REFUND` `success=true` | `addPayments` type=refund; `partiallyRefunded` / `refunded` | -| `REFUND_FAILED`, `CHARGEBACK` | Alert a human | +async function handle(i: AdyenItem) { + // A company-level webhook also carries other merchant accounts and POS payments with non-cart references. + if (i.merchantAccountCode !== MERCHANT || !CART_ID.test(i.merchantReference)) return; + const cartId = i.merchantReference; + const ok = i.success === "true"; + const amount = toMajor(i.amount.value, i.amount.currency); + const payment = i.originalReference ?? i.pspReference; // the authorisation = the record's transactionId + const set = (values: Record, to?: number) => + updatePayment(cartId, payment, (p) => withMeta(p, values, to)); + const log = () => console.error(`[adyen] ${i.eventCode} ${i.success} ${payment} cart ${cartId}: ${i.reason}`); + const action = i.additionalData?.["modification.action"]; // CANCEL_OR_REFUND (from /reversals): cancel | refund + const event = i.eventCode === "CANCEL_OR_REFUND" ? `REVERSAL_${action}` : i.eventCode; + + if (event === "AUTHORISATION" && ok) { + const later = CAPTURE_ON_SHIPMENT && !NO_SEPARATE_CAPTURE.has(i.paymentMethod ?? ""); + return createOrderOnce(cartId, later ? "unpaid" : "paid", { + provider: "adyen", + method: i.paymentMethod ?? "unknown", + transactionId: i.pspReference, + amount, + createdAt: i.eventDate, + meta: [ + { key: "state", value: later ? "authorized" : "captured" }, + { key: "cartId", value: cartId }, + { key: "currency", value: i.amount.currency }, // capture() needs it + ], + }); + } + if (event === "CAPTURE" && ok) return set({ state: "captured" }, amount); + if (event === "CAPTURE" || event === "CAPTURE_FAILED") { + log(); // a refused capture: the money is still only authorized + return set({ state: "authorized", attention: "capture-failed" }); + } + if (["CANCELLATION", "EXPIRE", "REVERSAL_cancel"].includes(event)) return ok ? set({ state: "cancelled" }) : log(); + if (event === "REFUND" || event === "REVERSAL_refund") { + if (!ok) return log(); + return recordPayment(cartId, { + provider: "adyen", + method: i.paymentMethod, + transactionId: i.pspReference, // the refund's own pspReference + amount, + createdAt: i.eventDate, + meta: [ + { key: "type", value: "refund" }, + { key: "cartId", value: cartId }, + { key: "paymentPspReference", value: payment }, + ], + }); + } + if (["REFUND_FAILED", "REFUNDED_REVERSED", "CHARGEBACK", "NOTIFICATION_OF_CHARGEBACK"].includes(event)) log(); + // AUTHORISATION success=false: refused — no order, the placed cart stays, the shopper retries. Others: ignored. +} +``` -Duplicates share `eventCode` + `pspReference`; order is not guaranteed — compare `eventDate`. +| Adyen `eventCode` | Crystallize ([SKILL.md](../SKILL.md#paymentstatus)) | +| ----------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| `AUTHORISATION` `true`, auto capture or iDEAL/Swish… | `createOrderOnce(…, 'paid', …)`, `state=captured` | +| `AUTHORISATION` `true`, manual or delayed capture | `createOrderOnce(…, 'unpaid', …)`, `state=authorized` | +| `AUTHORISATION` `false`, `OFFER_CLOSED` (opt-in) | Nothing; answer 2xx (a non-2xx makes Adyen retry for 30 days) | +| `CAPTURE` `true` | `updatePayment` → `state=captured`, captured amount. Not sent for immediate auto-capture | +| `CAPTURE` `false`, `CAPTURE_FAILED` | `updatePayment` → `state=authorized`, `attention=capture-failed`; a human retries | +| `CANCELLATION`, `EXPIRE`, `CANCEL_OR_REFUND` (cancel) | `updatePayment` → `state=cancelled`; move the order to your cancelled stage | +| `REFUND` `true`, `CANCEL_OR_REFUND` (refund) | `recordPayment` with `meta type=refund` | +| `REFUND_FAILED`, `REFUNDED_REVERSED`, `CHARGEBACK`… | Log for a human; answer 2xx | + +Duplicates share `eventCode` and `pspReference` and may arrive out of order (`eventDate` tells); every write above is +idempotent. JSON webhooks carry one item today (SOAP up to six): loop anyway. Past 10 seconds the event goes to the +retry queue — safe, for the same reason. ## Capture, refund, cancel -Pass `reference: cartId` on every modification. **UNVERIFIED:** Adyen's docs do not state that a -modification webhook's `merchantReference` echoes that reference. Do not depend on it: the webhook's -`originalReference` is the authorization's `pspReference`, which is the Crystallize payment's -`transactionId`. Store it on the order with `setMeta` (keys `adyenPspReference` and `cartId`) and, when -`merchantReference` is not a cart id, find the order with Core -`orders(filter: { meta: [{ key: "adyenPspReference", value: originalReference }] })` and read its -`cartId` meta — the Shop API order id the helpers take. All results arrive **asynchronously** by webhook. +Each call answers `"status": "received"`; the outcome arrives by webhook. Pass the cart id as `reference`: Adyen's +refund example echoes a modification's `reference` as the webhook's `merchantReference` (for modifications made in the +Customer Area without a reference this is unconfirmed — the `CART_ID` check then skips them). ```ts -const merchantAccount = process.env.ADYEN_MERCHANT_ACCOUNT!, - amount = { value: toMinor(major, currency), currency }; -// Capture (manual capture). A partial capture cancels the remainder unless Adyen enabled multiple partial captures. -await checkout.ModificationsApi.captureAuthorisedPayment( - psp, - { merchantAccount, amount, reference: cartId }, - { idempotencyKey: `capture-${psp}` }, -); // → CAPTURE → setPayments + Core paymentStatus -// Refund a captured payment (partial allowed; up to 40 business days to reach the shopper). -await checkout.ModificationsApi.refundCapturedPayment( - psp, - { merchantAccount, amount, reference: cartId }, - { idempotencyKey: `refund-${psp}-${refundNo}` }, -); // → REFUND → addPayments type=refund -// Cancel an uncaptured authorization; use /reversals when unsure whether it was captured. -await checkout.ModificationsApi.cancelAuthorisedPaymentByPspReference( - psp, - { merchantAccount, reference: cartId }, - { idempotencyKey: `cancel-${psp}` }, -); // → CANCELLATION → setPayments state=cancelled +// lib/adyen.ts (continued) +/** captureByProvider.adyen. Adyen needs the currency: pass the payment record (meta `currency`, `cartId`). */ +export async function capture(transactionId: string, amount: number, record?: Record) { + const currency = record?.currency; + if (!currency) throw new Error(`Adyen capture ${transactionId}: the record has no currency`); + const value = toMinor(amount, currency); + const body = { merchantAccount: MERCHANT, amount: { currency, value }, reference: record?.cartId }; + await adyen(`/payments/${transactionId}/captures`, body, `capture-${transactionId}`); + return null; // asynchronous: the CAPTURE webhook writes state=captured, or flags capture-failed +} + +/** `n` numbers your refunds of this payment: a retry with the same n never refunds twice. */ +export async function refund(cartId: string, transactionId: string, amount: number, currency: string, n = 1) { + const value = toMinor(amount, currency); + const res = await adyen<{ pspReference: string }>( + `/payments/${transactionId}/refunds`, + { merchantAccount: MERCHANT, amount: { currency, value }, reference: cartId }, + `refund-${transactionId}-${n}`, + ); + return res.pspReference; // the REFUND webhook records it +} + +/** Before capture (a "Cancelled" stage). Use /reversals when you do not know whether it was captured. */ +export async function cancel(cartId: string, transactionId: string) { + await adyen( + `/payments/${transactionId}/cancels`, + { merchantAccount: MERCHANT, reference: cartId }, + `cancel-${transactionId}`, + ); // the CANCELLATION webhook sets state=cancelled +} ``` -Delayed auto-capture: `captureDelayHours` (≤ 672) + the `CAPTURE` event. See [SKILL.md](../SKILL.md#paymentstatus). +The [pipeline handler](../SKILL.md#capture-on-shipment-from-fulfilment-pipelines) must call +`captureByProvider.adyen(authorized.transactionId, Number(authorized.amount), authorized)`: the third argument +carries the currency. `capture` returns `null`, so the handler writes nothing; the `CAPTURE` webhook sets +`state=captured`, and a redelivered stage webhook re-sends the same idempotency key, which never captures twice. A +**partial** capture cancels the rest of the authorisation unless Adyen Support enables multiple partial captures. +Refunds may be partial and repeated, never above the captured amount, and take up to 40 business days to reach the +shopper. For delayed automatic capture, send `captureDelayHours` (or set 1–7 days in the Customer Area) instead of +`manualCapture`, keep `CAPTURE_ON_SHIPMENT` so the order starts `authorized`, do not capture those orders from the +pipeline, and enable the `CAPTURE` event under **Developers → Webhooks → Settings** and on the Standard webhook: its +`CAPTURE` webhook flips the record. ## Mapping ```ts { provider: 'adyen', - method: item.paymentMethod, // visa, mc, amex, vipps, mobilepay, klarna, swish, applepay, googlepay, … - transactionId: item.pspReference, // the payment's PSP reference — capture, cancel and refund use it + method: item.paymentMethod, // visa, mc, amex, applepay, klarna, vipps, mobilepay, swish, ideal, … + transactionId: item.pspReference, // the authorisation's pspReference: capture, refund and cancel take it amount: toMajor(item.amount.value, item.amount.currency), // MAJOR units createdAt: item.eventDate, - meta: [{ key: 'state', value: 'captured' }, // authorized | captured | cancelled - { key: 'checkoutSessionId', value: item.additionalData?.checkoutSessionId ?? '' }], + meta: [{ key: 'state', value: 'authorized' }, // authorized | captured | cancelled + { key: 'cartId', value: cartId }, { key: 'currency', value: 'NOK' }], } ``` -Refund record: `transactionId` = the REFUND webhook's `pspReference`, `amount` = refunded major amount, -`meta: [{ key: 'type', value: 'refund' }, { key: 'paymentPspReference', value: item.originalReference }]`. - -## Gotchas - -- v72 rejects `reference` > 80 chars, `metadata` key > 20 / value > 80, `returnUrl` > 1024 chars, postal - codes > 10 chars, and moved enhanced scheme data from `additionalData` to `enhancedSchemeData`. -- `success`, `live`, `manualCapture` are **strings**. Answer within **10 s** or events pile into the retry queue. -- Initialise Drop-in once, client side, on a ref, not in an iframe. `countryCode` = shipping country. -- Web v6 removed `checkout.create('dropin')` and the default import; `onPaymentCompleted` no longer fires - for failures (`onPaymentFailed` does); `setStatusAutomatically` is gone (`disableFinalAnimation`). -- In the old furniture boilerplates: - - `receivePaymentEvent.ts` + `handleAdyenWebhookRequestPayload` **verify nothing** (the code's own TODO - says so): anyone could POST `AUTHORISATION success=true` and get an order. - - It `return`s inside the `notificationItems` loop (only the first item is handled) and throws on - `success !== 'true'`, so Adyen retries refusals for 30 days. - - `countryCode` is guessed from currency (`NOK→NO, USD→US, else FR`); no `shopperEmail` or `lineItems` - (blocks Klarna); `amount.value: cart.total.gross * 100` is unrounded. - - `adyen.tsx` uses Web v5 (`AdyenCheckout(...)` default import, `checkout.create('dropin')`); furnitut - pins `@adyen/adyen-web@^5.69`. The return page never calls `submitDetails`. - - Orders use `provider: 'custom', custom: { properties }` via the deprecated request handlers. +Refund record: `transactionId` = the `REFUND` webhook's `pspReference`, `amount` = refunded major amount, meta +`type=refund`, `cartId`, `paymentPspReference` ([the payment record](../SKILL.md#the-payment-record)). + +## Provider specifics + +**A payment method chosen before `place`.** If the storefront has its own "Card / Vipps / Klarna" chooser, store the +choice on the cart before `place`; `createAdyenSession` turns it into `allowedPaymentMethods`. Values are Adyen's +[types][pm-types]: `scheme` (cards), `applepay`, `googlepay`, `klarna`, `klarna_account`, `klarna_paynow`, `vipps`, +`mobilepay`, `swish`, `ideal`, `trustly`. + +```ts +await carts.setMeta(cartId, { meta: [{ key: "paymentMethod", value: "vipps" }], merge: true }); // before place +``` + +**Nordic and local methods** run inside Drop-in (redirect, app switch or QR) once added to the merchant account: Vipps +needs `NO`/NOK, MobilePay `DK` or `FI` with DKK/EUR, Swish `SE`/SEK, iDEAL `NL`/EUR. Vipps, MobilePay and Klarna +support separate capture; iDEAL, Swish and Trustly do not, so the webhook records them as captured. + +**Klarna, Riverty, Afterpay** refuse a session without `lineItems`, `shopperName`, `telephoneNumber` (with `+`) and +the addresses: `createAdyenSession` sends them from the customer set on the cart before `place`. + +**3D Secure** runs natively in Drop-in. A strict Content-Security-Policy blocks its challenge: allow it, or send +`authenticationData: { threeDSRequestData: { nativeThreeDS: "disabled" } }` on `/sessions` to use a redirect. + +**Stored cards and recurring** (after payment): `/sessions` with a non-PII `shopperReference`, +`storePaymentMethodMode: "askForConsent"` and `recurringProcessingModel` (`CardOnFile`, `Subscription`, +`UnscheduledCardOnFile`). The token comes in the Recurring tokens life cycle webhook, signed over the **raw body** in an +`hmacsignature` header; charge it with `POST /payments`, `storedPaymentMethodId`, `shopperInteraction: "ContAuth"` +([tokenization][tokenization]). + +## Going further + +- **Hosted Checkout**: `/sessions` with `mode: "hosted"` and a `themeId`, redirect to `url`; webhook, capture and refund + stay the same ([guide][hosted]). **Advanced flow** (`/paymentMethods`, `/payments`, `/payments/details`) for control + between steps ([advanced flow][advanced]). +- **Express checkout** (Apple Pay, Google Pay, PayPal with shipping in the wallet sheet): the shopper picks shipping at + Adyen, so Adyen charges more than the placed cart and the order has no shipping line. Choose shipping in the + storefront before `place` unless you rebuild that flow deliberately. +- **Gift cards and partial payments**: one cart is paid by several `AUTHORISATION`s and closed by `ORDER_CLOSED`; + `createOrderOnce` would flag the second as a duplicate. Create the order on `ORDER_CLOSED` `success=true` instead + ([partial payments][partial]). +- **Changing the amount after the session** (`payable: false`, then `PATCH /sessions/{id}`, v72): with Crystallize, + place the final cart instead. **Authorisation adjustment** for goods shipped after the scheme's validity + ([validity][validity]). +- **Pay by Link** (`POST /paymentLinks`) for call-centre orders, with a placed cart id as `reference`. +- **Webhook hardening**: OAuth 2.0 instead of basic auth, Adyen's IP ranges in an allowlist ([secure webhooks][secure]). +- **Go live**: new API key, client key, webhook and HMAC key; the live URL prefix; a Drop-in `environment` matching the + prefix's region, and every call of a session on that region. **Disputes**: dispute webhooks and the Disputes API. + +## Common mistakes + +- Accepting the webhook without checking basic auth **and** every item's `hmacSignature`: anyone could POST + `AUTHORISATION success=true` and get an order. +- Handling only the first item of `notificationItems` (a `return` inside the loop). +- Answering 403 or another non-2xx to a refused payment (Adyen retries it for 30 days), answering with the created + order as the body, or working past 10 seconds: refusals get a 2xx and no order, everything gets `[accepted]` fast. +- Guessing `countryCode` from the currency (`NOK → NO`, else `FR`): it hides payment methods and breaks Vipps, Swish, + Klarna. Use the delivery address or the market. +- `cart.total.gross * 100` without `Math.round`, and two decimals for every currency (JPY has 0, KWD 3). +- Creating the session even though `place` failed, or a new session on every render or cart change. +- Web v5 code (`import AdyenCheckout from '@adyen/adyen-web'`, `checkout.create('dropin')`, no `countryCode`): v6 uses + named imports, `new Dropin(checkout)` and `onPaymentFailed` for failures. +- A return page that never calls `submitDetails` with `redirectResult`, or that creates the order from `resultCode`. +- `manualCapture: true` as a boolean, or never capturing (authorisations expire); recording iDEAL, Swish or Trustly + payments as "authorized" when they were captured at once. +- Reading `operations` from the `AUTHORISATION` webhook to decide anything: Adyen marks it experimental. [sessions]: https://docs.adyen.com/online-payments/build-your-integration/sessions-flow -[hosted]: https://docs.adyen.com/online-payments/build-your-integration/?platform=Web&integration=Hosted+Checkout [v72]: https://docs.adyen.com/online-payments/upgrade-your-integration/upgrade-to-checkout-api-v72 [web6]: https://docs.adyen.com/online-payments/upgrade-your-integration/upgrade-to-web-v6 -[webhooks]: https://docs.adyen.com/development-resources/webhooks/handle-webhook-events +[handle]: https://docs.adyen.com/development-resources/webhooks/handle-webhook-events [hmac]: https://docs.adyen.com/development-resources/webhooks/secure-webhooks/verify-hmac-signatures +[secure]: https://docs.adyen.com/development-resources/webhooks/secure-webhooks [capture]: https://docs.adyen.com/online-payments/capture [refund]: https://docs.adyen.com/online-payments/refund +[cancel]: https://docs.adyen.com/online-payments/cancel [currency]: https://docs.adyen.com/development-resources/currency-codes -[openapi]: https://github.com/Adyen/adyen-openapi/blob/main/json/CheckoutService-v72.json +[validity]: https://docs.adyen.com/online-payments/adjust-authorisation#validity +[hosted]: https://docs.adyen.com/standard/integration/hosted-checkout +[advanced]: https://docs.adyen.com/online-payments/build-your-integration/advanced-flow +[partial]: https://docs.adyen.com/online-payments/partial-payments +[credentials]: https://docs.adyen.com/development-resources/api-credentials +[live]: https://docs.adyen.com/development-resources/live-endpoints +[test-cards]: https://docs.adyen.com/development-resources/test-cards-and-credentials/test-card-numbers +[test-results]: https://docs.adyen.com/development-resources/testing/result-codes +[pm-types]: https://docs.adyen.com/payment-methods/payment-method-types +[tokenization]: https://docs.adyen.com/online-payments/tokenization diff --git a/use-crystallize/skills/payments/references/dintero.md b/use-crystallize/skills/payments/references/dintero.md index 681dfce..dcc092a 100644 --- a/use-crystallize/skills/payments/references/dintero.md +++ b/use-crystallize/skills/payments/references/dintero.md @@ -1,80 +1,81 @@ # Dintero with Crystallize -> Verification: Written from Dintero's official docs, checked 2026-09-29. Not run end-to-end. -> Official docs: [Dintero Checkout][docs], [API reference][api]. Every docs page is also served as markdown (append -> `.md`), and the index is at [llms.txt](https://docs.dintero.com/llms.txt). - -[docs]: https://docs.dintero.com/docs/checkout/quickstart -[api]: https://docs.dintero.com/api-reference/session/checkout_session_profile_post -[after]: https://docs.dintero.com/docs/checkout/after-payment -[sig]: https://docs.dintero.com/docs/checkout/validating-callbacks -[tm]: https://docs.dintero.com/docs/checkout/transaction-management -[cards]: https://docs.dintero.com/docs/checkout/testdata/dintero-psp/checkout-dintero-cards-testdata +Dintero is a Norwegian payment company whose Dintero Checkout puts cards, Vipps, MobilePay, Swish, Apple Pay, Google +Pay, Klarna, Walley, Two and Kravia invoices behind one session API, for Nordic merchants selling in NOK, SEK, DKK and +EUR to consumers (B2C) and companies (B2B). The recommended integration creates a checkout session from a payment +profile on the server, from the placed cart, shows it with `@dintero/checkout-web-sdk` (embedded, or a redirect to the +hosted page), and creates the order from the signed `callback_url` after re-fetching the transaction. Payments are +authorized at checkout and captured when the goods ship (manual capture by default; auto-capture is optional). + +> Verification: Written from Dintero's official docs, checked 2026-10-06. Not run end-to-end. +> Official docs: [Quickstart][quickstart], [Create session API][api-session], [Handling payment][after], [Validating +> callbacks][sig], [Transaction management][tm], [Web SDK][websdk]. Every docs page is also served as Markdown (append +> `.md`); the index is [llms.txt](https://docs.dintero.com/llms.txt). ## At a glance -| | | -| ------------ | -------------------------------------------------------------------------------------------------------- | -| Markets | Nordics: NO, SE, DK (NOK, SEK, DKK, EUR). **UNVERIFIED:** Dintero publishes no official list; no US | -| Recommended | Checkout session from a **payment profile** (`sessions-profile`), shown by **redirect** or embedded | -| Alternative | Embedded iframe with `@dintero/checkout-web-sdk` (`embed({ container, sid })`) | -| API version | `https://api.dintero.com/v1/accounts/{aid}/payments/…` (the old `checkout.dintero.com/v1` still works) | -| SDKs | `@dintero/node-sdk@1` (typed client). `fetch` shown here. `@dintero/checkout-web-sdk@0.14` | -| Amounts | Integer **minor units**. `items[].amount` is the line total incl. VAT and discounts | -| Capture | Manual by default (or `auto_capture`). Cards hold ~7 days, Klarna 28, Walley 90 ([knowledge base][auth]) | -| Cart id | `order.merchant_reference` (required; ≤35 chars only with Kravia invoice). Echoed on every callback | -| Verification | `Dintero-Signature` HMAC-SHA256 over the callback URL, **plus** a mandatory transaction re-fetch | - -[auth]: https://www.dintero.com/knowledge-base/authorizations - -## Setup - -- **Sign up** at onboarding.dintero.com. Each account has a `T…` (sandbox) and `P…` (live) id on the same hosts. - Backoffice → Settings → API clients → **Checkout client** on the T account (the secret is shown once). -- **Payment methods** come from the payment profile (Backoffice → Settings → Payment profiles; `default` exists). -- **Callback signing:** create the secret once with `POST /v1/admin/signature` ([validating callbacks][sig]). From - then on, callbacks carry `Dintero-Signature`. -- **Reaching localhost:** the `callback_url` must be public HTTPS, and `https://localhost` is rejected, so tunnel - (ngrok, cloudflared). Backoffice shows each transaction's callbacks sent and the responses received. +| | | +| -------------------- | --------------------------------------------------------------------------------------- | +| Markets & currencies | Nordics: NOK, SEK, DKK, EUR; Vipps (NO), Swish (SE), MobilePay (DK) (list unconfirmed) | +| Recommended | `POST …/payments/sessions-profile` (methods from a payment profile), embedded Web SDK | +| Alternative | Redirect to the hosted page: `redirect({ sid })` or the session `url` | +| API version | `api.dintero.com/v1/accounts/{aid}/payments/…`; the old `checkout.dintero.com/v1` works | +| SDKs | Browser `@dintero/checkout-web-sdk@0.14`; server `@dintero/node-sdk@1` (optional) | +| Amount units | Integer **minor** units (`29990` = 299.90 NOK); `items[].amount` = line total incl. VAT | +| Capture | Manual (default). Authorization: cards ~7 days, Vipps 5–30, Klarna 28+, Walley 90 days | +| Cart id | `order.merchant_reference`, on every transaction and callback (≤ 35 chars with Kravia) | +| One session per cart | No idempotency key: find the cart's open session and reuse it | +| Verification | Signed **GET** `callback_url` (`Dintero-Signature`, HMAC-SHA256 of the URL) + re-fetch | + +## Credentials and setup + +- **Sign up** at . The crystallize.com page names three values, all in Backoffice + () → **Settings**: the **client id** and **client secret** (API clients → Create new + API client → **Checkout client**; the secret is shown **once**, [API client][client]) and the **account id**, which + differs between test and production: `T12345678` is the sandbox, `P12345678` is live (Dintero's quickstart creates + new credentials on the `P` account to go live). +- **Profile id**: Settings → Payment profiles; new accounts have `default`. Payment methods, their order and the theme + live on the profile, so enabling a method needs no deploy ([payment profiles][profiles]). +- **Signature secret**, once per account: `POST https://checkout.dintero.com/v1/admin/signature` with a bearer token + returns `signature.secret` ([API][signature-api]); from then on every callback carries `Dintero-Signature`. +- **Test data** ([cards][cards]), any future expiry and CVC: Visa `4000 0000 0000 0002` (no 3DS challenge), + `4000 1000 0000 0000` (challenge), Mastercard `5200 0000 0000 0007`, declined `4100 0000 0000 0076`, capture + time-out `4100 0000 0000 0019`. Klarna, Norway: `customer@email.no`, `+4740123456` ([Klarna test data][klarna-test]). +- **Reaching localhost**: `callback_url` must be public HTTPS (`https://localhost` is refused): use a tunnel as + `PUBLIC_URL`. Backoffice shows each transaction's callbacks and your answers. Callbacks come from `34.241.230.119` + and `34.242.13.162`. ```bash -DINTERO_ACCOUNT_ID=T12345678 +DINTERO_ACCOUNT_ID=T12345678 # P12345678 in production DINTERO_CLIENT_ID=… DINTERO_CLIENT_SECRET=… -DINTERO_CALLBACK_SECRET=… # from POST /v1/admin/signature DINTERO_PROFILE_ID=default -PUBLIC_URL=https://….ngrok.app +DINTERO_SIGNATURE_SECRET=… # signature.secret from POST /v1/admin/signature +PUBLIC_URL=https://shop.example # the tunnel in development: Dintero signs this host ``` -## Create the session +## Create the payment -The session is created from the **placed** cart (Shop `/cart`). `item.price` is the line total -(verified on live carts). Cache the token: it is valid for 4 h. +Call it from the Pay route right after `place` ([SKILL.md](../SKILL.md#lock-the-cart-before-you-charge)). The token +lasts 4 hours: cache it. With `Dintero-Feature-Toggles: strict-session-amounts`, Dintero refuses a session whose lines +do not add up to `order.amount` instead of failing at capture. A session yields at most one transaction, so reusing the +cart's open session keeps two tabs on one payment. Only the older base lists sessions (`search` matches +`merchant_reference`); how soon a new session becomes searchable is unconfirmed, so two clicks in the same instant can still open two +sessions; the duplicate-payment flag in `createOrderOnce` covers that. ```ts // lib/dintero.ts import { createHmac, timingSafeEqual } from "node:crypto"; -import { addPayment, replacePayments, setPaymentStatus } from "@/lib/crystallize-payments"; - -type PlacedCart = { - id: string; - total: { gross: number; currency: string }; - items: { - lineId?: string | null; - name: string; - quantity: number; - variant: { sku: string | null }; - price: { gross: number; taxAmount: number; taxPercent: number }; - }[]; -}; +import { recordPayment, updatePayment, withMeta } from "@/lib/crystallize-payments"; +import type { Payment, PlacedCart } from "@/lib/crystallize-payments"; const AID = process.env.DINTERO_ACCOUNT_ID!; const API = `https://api.dintero.com/v1/accounts/${AID}`; -const minor = (major: number) => Math.round(major * 100); -let token: { value: string; expires: number } | undefined; +const minor = (major: number) => Math.round(major * 100); // NOK, SEK, DKK, EUR: 2 decimals +let token: { value: string; expiresAt: number } | undefined; async function accessToken() { - if (token && token.expires > Date.now() + 60_000) return token.value; + if (token && token.expiresAt - Date.now() > 60_000) return token.value; const basic = btoa(`${process.env.DINTERO_CLIENT_ID}:${process.env.DINTERO_CLIENT_SECRET}`); const res = await fetch(`${API}/auth/token`, { method: "POST", @@ -82,207 +83,346 @@ async function accessToken() { body: JSON.stringify({ grant_type: "client_credentials", audience: API }), // audience = the account URL }); if (!res.ok) throw new Error(`Dintero auth ${res.status}`); - const { access_token, expires_in } = await res.json(); - token = { value: access_token, expires: Date.now() + expires_in * 1000 }; - return access_token as string; + const json = (await res.json()) as { access_token: string; expires_in: number }; + token = { value: json.access_token, expiresAt: Date.now() + json.expires_in * 1000 }; + return token.value; } +/** `path` is under …/payments, or a full URL on the older base. A body makes it a POST. */ export async function dintero(path: string, body?: object, headers: Record = {}): Promise { - const res = await fetch(`${API}/payments${path}`, { + const res = await fetch(path.startsWith("https://") ? path : `${API}/payments${path}`, { method: body ? "POST" : "GET", headers: { Authorization: `Bearer ${await accessToken()}`, "Content-Type": "application/json", ...headers }, body: body ? JSON.stringify(body) : undefined, }); if (!res.ok) throw new Error(`Dintero ${path} ${res.status}: ${await res.text()}`); - return res.json() as Promise; + return (await res.json()) as T; } -export async function createDinteroSession(cart: PlacedCart) { - const items = cart.items.map((item, i) => ({ - id: item.variant.sku ?? `line-${i}`, - line_id: item.lineId ?? String(i + 1), // unique; capture and refund refer to it +export type DinteroTx = { + id: string; + status: string; + amount: number; + merchant_reference: string; + payment_product_type: string; + created_at: string; + items?: { line_id: string; quantity: number; amount: number }[]; + events?: { event: string; success: boolean; amount?: number; event_reference?: string }[]; +}; +type Session = { id: string; transaction_id?: string; expires_at?: string; cancelled_at?: string; order: Order }; +type Order = { amount: number; merchant_reference: string }; +const PAID = ["ON_HOLD", "AUTHORIZED", "CAPTURED", "PARTIALLY_CAPTURED"]; + +/** Lines add up exactly to what place returned (max 100 lines). */ +function dinteroOrder(placed: PlacedCart) { + const items = placed.items.map((item, i) => ({ + id: item.variant?.sku ?? `line-${i + 1}`, // external items (shipping, fees) may have no variant + line_id: item.lineId ?? String(i + 1), // unique; capture and refund name lines by it + type: item.type === "shipping" ? "shipping" : undefined, description: item.name, quantity: item.quantity, - amount: minor(item.price.gross), // line total incl. VAT - vat_amount: minor(item.price.taxAmount), // display only + amount: minor(item.price.gross), // the line total, incl. VAT and discounts + vat_amount: minor(item.price.taxAmount), // "display only", but never 0 on a taxed line vat: item.price.taxPercent, // 25, not 0.25 })); - const base = process.env.PUBLIC_URL; + const amount = minor(placed.total.gross); + items[items.length - 1].amount += amount - items.reduce((s, l) => s + l.amount, 0); // rounding cents + const vat_amount = items.reduce((s, l) => s + l.vat_amount, 0); // order.vat_amount = Σ items.vat_amount + return { amount, vat_amount, currency: placed.total.currency.toUpperCase(), items }; +} + +export async function createDinteroSession( + placed: PlacedCart, + origin: string, +): Promise<{ sid: string } | { paid: true }> { + // No idempotency key on create: reuse the cart's live session (two tabs, double clicks). A click in the + // same instant can still open a second session; the duplicate-payment flag in createOrderOnce covers it. + const order = dinteroOrder(placed); + const search = `https://checkout.dintero.com/v1/sessions?search=${encodeURIComponent(placed.id)}&limit=10`; + for (const s of await dintero(search)) { + if (s.order.merchant_reference !== placed.id || s.cancelled_at) continue; + if (s.transaction_id) { + const tx = await dintero(`/transactions/${s.transaction_id}`); + if (PAID.includes(tx.status)) return { paid: true }; // send the shopper to the return page + } else if (s.order.amount === order.amount && Date.parse(s.expires_at ?? "") > Date.now() + 120_000) { + return { sid: s.id }; // the same session for every tab + } + } + const parties = dinteroParties(placed); // customer, billing address, B2B or B2C: Provider specifics const session = await dintero<{ id: string; url: string }>( "/sessions-profile", { profile_id: process.env.DINTERO_PROFILE_ID ?? "default", url: { - return_url: `${base}/checkout/dintero/return`, - callback_url: `${base}/api/payments/dintero/webhook`, // GET; Dintero appends transaction_id etc. - }, - order: { - amount: minor(cart.total.gross), // must equal Σ items.amount under strict-session-amounts - currency: cart.total.currency.toUpperCase(), - merchant_reference: cart.id, - items, + return_url: `${origin}/checkout/dintero/return?cart=${placed.id}`, // works in another browser + callback_url: `${process.env.PUBLIC_URL}/api/payments/dintero/webhook`, // called with GET }, + customer: parties.customer, + order: { ...order, billing_address: parties.billing, merchant_reference: placed.id }, + configuration: { default_customer_type: parties.type }, }, { "Dintero-Feature-Toggles": "strict-session-amounts" }, ); - return { sid: session.id, url: session.url }; + return { sid: session.id }; } ``` -- Max 100 items. Shipping can be a normal item (as here) or `order.shipping_option`. The latter becomes an item on - the transaction, and you capture it by its `line_id`. - ## Client -Redirect with `location.href = url`, or embed: +Embed the session, or redirect to Dintero's page; both need only the `sid`: -```ts +```tsx +// app/checkout/dintero/checkout.tsx +"use client"; import { embed } from "@dintero/checkout-web-sdk"; -await embed({ container, sid, language: "no" }); // no handlers: the SDK itself redirects to return_url +import { useEffect, useRef } from "react"; + +export function DinteroCheckout({ sid, language }: { sid: string; language: string }) { + const container = useRef(null); + useEffect(() => { + // No onPayment* handlers: the SDK then sends the shopper to return_url itself. + const checkout = embed({ container: container.current!, sid, language }); + return () => void checkout.then((c) => c.destroy()); + }, [sid, language]); + return
; +} +// Redirect instead: import { redirect } from "@dintero/checkout-web-sdk"; redirect({ sid }); ``` -Iframe → SDK events are **not guaranteed** (with Vipps the return URL can open in a new tab). The `return_url` gets -`transaction_id`, `merchant_reference` and maybe `error` (`cancelled`, `authorization`, `failed`, `capture`). It shows -a confirmation and polls the Shop order ([SKILL.md](../SKILL.md#the-payment-step)), and never creates the order. +Iframe events are **not guaranteed**: after paying in the Vipps app the `return_url` can open in a new tab, or in +another browser. So the page takes the cart id from its own `?cart=`; Dintero adds `transaction_id`, +`merchant_reference` and, on failure, `error` (`cancelled`, `authorization`, `failed`, `capture`). The page only reads +([SKILL.md](../SKILL.md#the-return-page)): `ordered` → confirmation; `placed` without `error` → "confirming your +payment…" and refresh; `error` → say so and offer "Try again", which calls the Pay route and gets the same open +session back. ## Webhook -The `callback_url` is a GET with `transaction_id`, `session_id`, `merchant_reference` and `time` in the query -([handling payment][after]). It fires on `AUTHORIZED`, or after `CAPTURED` when auto-capture is on, and fires again -when an `ON_HOLD` transaction resolves. It is retried 20 times on 5xx, timeouts (10 s) and connection errors. **Any 4xx -is a permanent failure**, so answer 5xx for your own errors. - -The signature ([validating callbacks][sig]) is `Dintero-Signature: t=,v0-hmac-sha256=` over -`` `${t}\n${accountId}\n${METHOD}\n${hostname}\n${pathname}\n${sortedQuery}` `` (spaces as `+`). It covers the URL, not -a body. Reject `t` older than 5 minutes, and use the **public** hostname (behind a tunnel `req.url` may differ). -Dintero also requires re-fetching the transaction before you update the order. +Dintero calls `callback_url` with **GET** and the query `transaction_id`, `session_id`, `merchant_reference`, `time` +([handling payment][after]). There is no body to read: the signature covers the timestamp, account id, method, host, +path and **sorted** query of the URL it called ([validating callbacks][sig]), so rebuild that URL on the public host +(behind a proxy `req.url` differs). Then re-fetch the transaction, as Dintero requires, and take the cart id from it. +Retries: 20 times on 1xx/5xx, a 10-second timeout or a connection error; **any 4xx is final**. ```ts // lib/dintero.ts (continued) -export function verifyDintero(req: Request) { - const header = req.headers.get("dintero-signature") ?? ""; - const t = Number(/t=(\d+)/.exec(header)?.[1]); - if (!t || Date.now() / 1000 - t > 300) return false; - const url = new URL(req.url); - url.searchParams.sort(); // URLSearchParams.toString() encodes spaces as '+' - const host = new URL(process.env.PUBLIC_URL!).hostname; - const payload = `${t}\n${AID}\n${req.method}\n${host}\n${url.pathname}\n${url.searchParams.toString()}`; - const mac = createHmac("sha256", process.env.DINTERO_CALLBACK_SECRET!).update(payload, "utf8").digest("hex"); - const [got, want] = [Buffer.from(header), Buffer.from(`t=${t},v0-hmac-sha256=${mac}`)]; +export function verifyDintero(req: Request): boolean { + const header = req.headers.get("dintero-signature") ?? ""; // t=,v0-hmac-sha256= + const t = /t=(\d+)/.exec(header)?.[1]; + const given = /v0-hmac-sha256=([0-9a-f]+)/.exec(header)?.[1]; + if (!t || !given || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // replay window: 5 minutes + const called = new URL(req.url); + const url = new URL(called.pathname + called.search, process.env.PUBLIC_URL); + url.searchParams.sort(); // toString() then encodes spaces as "+", as Dintero does + const payload = [t, AID, req.method, url.hostname, url.pathname, url.searchParams.toString()].join("\n"); + const want = createHmac("sha256", process.env.DINTERO_SIGNATURE_SECRET!).update(payload, "utf8").digest(); + const got = Buffer.from(given, "hex"); return got.length === want.length && timingSafeEqual(got, want); } +``` -// app/api/payments/dintero/webhook/route.ts (helpers stay in lib: Next allows only HTTP-method exports here) +```ts +// app/api/payments/dintero/webhook/route.ts — GET: a POST-only route never receives a callback import { createOrderOnce } from "@/lib/crystallize-payments"; import { dintero, toPayment, verifyDintero, type DinteroTx } from "@/lib/dintero"; export async function GET(req: Request) { if (!verifyDintero(req)) return new Response("bad signature", { status: 401 }); - const q = new URL(req.url).searchParams; + const transactionId = new URL(req.url).searchParams.get("transaction_id"); + if (!transactionId) return new Response("ignored"); try { - const tx = await dintero(`/transactions/${q.get("transaction_id")}`); // mandatory re-fetch - const cartId = tx.merchant_reference; + const tx = await dintero(`/transactions/${encodeURIComponent(transactionId)}`); + const cartId = tx.merchant_reference; // from Dintero, not from the query string if (tx.status === "AUTHORIZED") await createOrderOnce(cartId, "unpaid", toPayment(tx, "authorized")); if (tx.status === "CAPTURED") await createOrderOnce(cartId, "paid", toPayment(tx, "captured")); - return new Response(null, { status: 200 }); // ON_HOLD: a second callback follows; FAILED/DECLINED: no order + return new Response("ok"); // ON_HOLD: a second callback follows. FAILED, DECLINED: no order } catch (error) { console.error(error); - return new Response("retry", { status: 503 }); // never 4xx: Dintero would stop retrying + return new Response("retry", { status: 503 }); // never 4xx for your own failure: Dintero would stop } } ``` -| Transaction status | Crystallize ([SKILL.md](../SKILL.md#paymentstatus)) | -| ------------------------- | --------------------------------------------------------- | -| `AUTHORIZED` | `createOrderOnce(cartId, 'unpaid', …state=authorized)` | -| `CAPTURED` (auto-capture) | `createOrderOnce(cartId, 'paid', …state=captured)` | -| `ON_HOLD` | Nothing yet. Another callback follows (or poll hourly) | -| `FAILED`, `DECLINED` | No order. The shopper retries with a new cart and session | +| Transaction status | Crystallize ([paymentStatus](../SKILL.md#paymentstatus)) | +| ---------------------------------- | ------------------------------------------------------------------------------ | +| `AUTHORIZED` | `createOrderOnce(cartId, 'unpaid', …)`, `state=authorized` | +| `CAPTURED` (auto-capture, Swish) | `createOrderOnce(cartId, 'paid', …)`, `state=captured` (no `AUTHORIZED` first) | +| `ON_HOLD` (manual review) | Nothing yet: another callback comes when it turns `AUTHORIZED` or `FAILED` | +| `FAILED`, `DECLINED` | No order. The cart stays placed; the shopper retries | +| `PARTIALLY_CAPTURED`, `REFUNDED` … | Only after your own calls below, which write Crystallize themselves | -For captures made in Backoffice, subscribe to the `checkout_transaction` webhook (`event-signature`: HMAC-**SHA1** -over the raw body). +A redelivery for a transaction already on the order is a no-op (`recordPayment` dedupes on `transactionId`). A second +paid session for the same cart is recorded with `attention=duplicate-payment`: void it. ## Capture, refund, cancel -The responses are synchronous and return the updated transaction, so write Crystallize right after -([transaction management][tm]). Check the returned status: "captures might fail". No idempotency header is -documented, so re-read the transaction before retrying a capture. +The calls answer with the updated transaction ([transaction management][tm]); `202` means still processing. Dintero +documents **no idempotency key**, so each function re-reads the transaction first, and refunds carry your +`refund_reference`. "Captures might fail": check the returned status. ```ts -const statusOf: Record = { - CAPTURED: "paid", - PARTIALLY_CAPTURED: "partiallyPaid", - REFUNDED: "refunded", - PARTIALLY_REFUNDED: "partiallyRefunded", - PARTIALLY_CAPTURED_REFUNDED: "partiallyRefunded", -}; - -// `captured` = running total captured so far in major units, including this call -export async function captureDintero(txId: string, amount: number, captured: number) { - const tx = await dintero(`/transactions/${txId}/capture`, { amount: minor(amount) }); // + items - if (!statusOf[tx.status]) throw new Error(`Capture not applied: ${tx.status}`); - await replacePayments(tx.merchant_reference, [{ ...toPayment(tx, "captured"), amount: captured }]); - await setPaymentStatus(tx.merchant_reference, statusOf[tx.status]); +// lib/dintero.ts (continued) +const captured = (tx: DinteroTx) => + (tx.events ?? []).filter((e) => e.event === "CAPTURE" && e.success).reduce((s, e) => s + (e.amount ?? 0), 0); + +/** captureByProvider.dintero — the captured amount in major units. Never null: a `202` throws and is retried. */ +export async function capture(transactionId: string, amount: number): Promise { + const before = await dintero(`/transactions/${transactionId}`); + if (before.status === "CAPTURED") return captured(before) / 100; // a retry after a capture that went through + if (before.status !== "AUTHORIZED") throw new Error(`Dintero ${transactionId} is ${before.status}`); + const lines = minor(amount) === before.amount ? before.items : undefined; // Instabank needs the lines + const tx = await dintero(`/transactions/${transactionId}/capture`, { + amount: minor(amount), + items: lines?.map(({ line_id, quantity, amount }) => ({ line_id, quantity, amount })), + }); + if (!tx.status.endsWith("CAPTURED")) throw new Error(`Dintero capture: ${tx.status}`); // 202: retried later + return captured(tx) / 100; } -export async function refundDintero(txId: string, amount: number, reason: string) { - const tx = await dintero(`/transactions/${txId}/refund`, { amount: minor(amount), reason }); - await addPayment(tx.merchant_reference, { +/** A return. `refundId` is yours (return or credit-note id): a retry with it never refunds twice. */ +export async function refund(cartId: string, transactionId: string, amount: number, refundId: string) { + const done = (tx: DinteroTx) => + tx.events?.some((e) => e.event === "REFUND" && e.success && e.event_reference === refundId); + let tx = await dintero(`/transactions/${transactionId}`); + if (!done(tx)) { + const body = { amount: minor(amount), refund_reference: refundId }; + tx = await dintero(`/transactions/${transactionId}/refund`, body); + if (!done(tx)) throw new Error(`Dintero refund ${refundId}: ${tx.status}`); + } + await recordPayment(cartId, { provider: "dintero", - method: "refund", + method: tx.payment_product_type, + transactionId: `${transactionId}:${refundId}`, amount, createdAt: new Date().toISOString(), - transactionId: `${tx.id}:refund:${tx.events?.at(-1)?.id ?? Date.now()}`, // UNVERIFIED: event id field - meta: [{ key: "type", value: "refund" }], + meta: [ + { key: "type", value: "refund" }, + { key: "cartId", value: cartId }, + ], }); - await setPaymentStatus(tx.merchant_reference, statusOf[tx.status] ?? "partiallyRefunded"); } -// Void before capture: POST /transactions/{id}/void → status AUTHORIZATION_VOIDED -// → replacePayments(cartId, [toPayment(tx, 'cancelled')]); stays unpaid; cancelled stage +/** Before capture only (a "Cancelled" stage): releases the reservation. */ +export async function cancel(cartId: string, transactionId: string) { + let tx = await dintero(`/transactions/${transactionId}`); + if (tx.status !== "AUTHORIZATION_VOIDED") tx = await dintero(`/transactions/${transactionId}/void`, {}); + if (tx.status !== "AUTHORIZATION_VOIDED") throw new Error(`Dintero void: ${tx.status}`); + await updatePayment(cartId, transactionId, (p) => withMeta(p, { state: "cancelled" })); +} ``` +The [pipeline handler](../SKILL.md#capture-on-shipment-from-fulfilment-pipelines) calls `capture` before the +authorization lapses ([durations][auth]). Void, never refund, an authorized transaction; to drop lines, capture less. +That a refund event carries `refund_reference` as `event_reference` is read from the API schema (unconfirmed). + ## Mapping ```ts -export type DinteroTx = { - id: string; - status: string; - amount: number; - merchant_reference: string; - created_at: string; - payment_product_type: string; - events?: { id?: string }[]; -}; -export const toPayment = (tx: DinteroTx, state: "authorized" | "captured" | "cancelled") => ({ +// lib/dintero.ts (continued) +export const toPayment = (tx: DinteroTx, state: "authorized" | "captured"): Payment => ({ provider: "dintero", - method: tx.payment_product_type, // dintero_psp.creditcard, vipps, klarna.klarna, collector.invoice … - transactionId: tx.id, // e.g. T12345678.465Uf… — capture, refund and void use it + method: tx.payment_product_type, // dintero_psp.creditcard, vipps, swish.swish, klarna.klarna, two.invoice_b2b … + transactionId: tx.id, // "T12345678.465Uf…": capture, refund and void take it amount: tx.amount / 100, // major units createdAt: tx.created_at, - meta: [{ key: "state", value: state }], + meta: [ + { key: "state", value: state }, + { key: "cartId", value: tx.merchant_reference }, + ], }); ``` -Nothing else is needed later: the items (by `line_id`) can be re-read from the transaction -([record rules](../SKILL.md#the-payment-record)). - -## Gotchas - -- Round every amount; `strict-session-amounts` makes a mismatch fail at creation rather than at capture. -- `method=POST` on the callback URL switches to POST with the transaction in the body. The signature still doesn't - cover the body, so re-fetch anyway. -- Also handle `CAPTURED` in the callback. With auto-capture (set on the session or the payment profile), no - `AUTHORIZED` callback ever comes. -- Test cards ([PSP test data][cards]), any future expiry and CVC: Visa 4000 0000 0000 0002 (no 3DS challenge), - 4000 1000 0000 0000 (challenge), Mastercard 5200 0000 0000 0007, declined 4100 0000 0000 0076. Klarna via - Dintero (NO): `customer@email.no`, +4740123456. -- In the old furniture boilerplates (`furniture-remix/application/src/use-cases/payments/dintero/*`): - - It uses `@crystallize/node-service-api-request-handlers` + `pushOrder` with a `provider: 'custom'` payment and - free-text properties, instead of `createOrderOnce` and a real `provider: 'dintero'` record. - - A new token on every call; `gross * 100` unrounded; no per-item `vat`. Items send `discounts`, **UNVERIFIED** - as still accepted (the spec now has `discount_lines`). - - Only `AUTHORIZED` creates an order, so with auto-capture no order is ever created. There is no signature check - and no idempotency. - - A missing cart throws a 404, a 4xx that Dintero never retries. - - Express shipping is hardcoded (Bring pick-up, 39 NOK, NO only) with stale 2023–2025 ETA dates. +A refund is a second record: `transactionId` = `:`, the refunded `amount`, `meta +type=refund` and `cartId` ([the payment record](../SKILL.md#the-payment-record)). Lines stay on the transaction. + +## Provider specifics + +**B2B and B2C.** One profile serves both. `configuration.default_customer_type` (`b2c` | `b2b`) preselects the type, +and the company the buyer gave your storefront — on the cart **before** place (`setCustomer` with type `organization`, +`companyName`, `taxNumber`, addresses) — prefills the billing address. Two (`two.invoice_b2b`) requires names, +`address_line`, `postal_code`, `country`, `phone_number`, `email`, `business_name` and `organization_number` (9 digits +in Norway, 10 or 12 in Sweden) ([Two via Dintero][two]). + +```ts +// lib/dintero.ts (continued) +function dinteroParties(placed: PlacedCart) { + const c = placed.customer; + const b2b = c?.type === "organization" || !!c?.companyName; + const a = c?.addresses?.find((x) => x.type === "billing") ?? c?.addresses?.[0]; + const phone = (a?.phone ?? c?.phone)?.startsWith("+") ? (a?.phone ?? c?.phone) : undefined; // E.123: +47… + const billing = a && { + first_name: a.firstName ?? c?.firstName, + last_name: a.lastName ?? c?.lastName, + address_line: [a.street, a.streetNumber].filter(Boolean).join(" "), + postal_code: a.postalCode, + postal_place: a.city, + country: a.country, // ISO 3166 alpha-2 + email: a.email ?? c?.email, + phone_number: phone, + ...(b2b ? { business_name: c?.companyName, organization_number: c?.taxNumber } : {}), + }; + return { type: b2b ? "b2b" : "b2c", customer: { email: c?.email, phone_number: phone }, billing }; +} +``` + +**Checkout Express for B2B.** Dintero's company lookup (registries in Norway and Denmark; Walley's own for Walley +B2B, which needs it) runs in Checkout Express ([express][express]): add `express: { shipping_options: [], +shipping_mode: "shipping_not_required", customer_types: ["b2b"] }` — no Dintero-side shipping — and, for Walley, send +only `shipping_address.country` and `organization_number` ([Walley B2B][walley]). The confirmed company is then on the +**transaction**, while the order's customer comes from the cart: compare `organization_number` in the webhook. + +**After payment** (no code): set `merchant_reference_2` to the Core order id for reconciliation +(`PUT /transactions/{id}`); raise a Klarna or Billie authorization before capture +(`POST /transactions/{id}/authorization`, new `amount` and all `items`). A Two or Walley `CAPTURED` means an invoice +was sent, not that money arrived. + +## Going further + +- **Dintero-side shipping** (Express `shipping_options`, `shipping_address_callback_url`, pick-up points) and Express + discount codes — **warning:** Dintero then charges more (or less) than the placed cart, and the Crystallize order + has no shipping line. Choose shipping in the storefront before `place` ([shipping options][shipping-options]). +- **Account-wide webhooks**: subscribe to `checkout_transaction` (Backoffice → Settings → Webhooks, or + `POST /v1/accounts/{aid}/hooks/subscriptions`) to hear about captures, refunds and voids made in Backoffice. + Deliveries carry `event-signature`, HMAC-**SHA1** of the raw body; trust an event's `correction.status` over its + `success` ([checkout webhook][checkout-webhook]). Or add `report_event=CAPTURE` (`REFUND`, `VOID`) to `callback_url`. +- **Auto-capture** (`configuration.auto_capture`, or on the profile): only for goods handed over at once; Dintero + retries it for 48 h, calls back only once `CAPTURED`, and keeps the fee on refunds ([auto-capture][auto-capture]). +- **Cancel the old session** when the shopper goes back and builds a new cart: `POST /sessions/{id}/cancel` (optional: + a paid old session still pays exactly the old placed cart). +- **Recurring payments**: card tokens and merchant-initiated `POST /sessions/pay` ([tokenization][tokenization]). Also: + payment links by SMS or e-mail, split payments for marketplaces, Kravia invoices, in-person terminals. + +## Common mistakes + +- A callback route that only accepts POST: Dintero calls `callback_url` with GET, so no order is ever created. +- Trusting the callback's query, or passing the whole request as the transaction id, instead of re-fetching + `/transactions/{transaction_id}` and reading `merchant_reference` from it; or no `Dintero-Signature` check at all. +- Creating the order only on `AUTHORIZED`: with auto-capture or Swish the one callback says `CAPTURED`. +- Comparing against `AUTHORISED` (British spelling): the API's status is `AUTHORIZED`. +- Answering a 4xx for your own problem (an unknown cart → 404): Dintero never retries a 4xx. +- `gross * 100` without rounding, `vat_amount: 0` on taxed lines, or lines that do not add up to `order.amount`. +- Letting Express checkout pick (hard-coded) shipping: the shipping cost never reaches the Crystallize order. +- Overwriting the order's customer with the transaction's shipping address after payment. +- A new access token on every call (it lasts 4 hours), or a new session on every click. +- A 36-character cart UUID as `merchant_reference` with Kravia enabled: set a shorter `merchant_reference_2`. +- Refunding an authorized transaction (void it), or never capturing: card authorizations lapse after about a week. + +[quickstart]: https://docs.dintero.com/docs/checkout/quickstart +[api-session]: https://docs.dintero.com/api-reference/session/checkout_session_profile_post +[after]: https://docs.dintero.com/docs/checkout/after-payment +[sig]: https://docs.dintero.com/docs/checkout/validating-callbacks +[tm]: https://docs.dintero.com/docs/checkout/transaction-management +[auth]: https://docs.dintero.com/docs/integrations/support/duration-of-authorizations +[websdk]: https://github.com/Dintero/Dintero.Checkout.Web.SDK +[client]: https://docs.dintero.com/docs/checkout/checkout-client +[profiles]: https://docs.dintero.com/docs/checkout/payment-profiles +[signature-api]: https://docs.dintero.com/checkout-api/secrets/admin_signature_post +[cards]: https://docs.dintero.com/docs/checkout/testdata/dintero-psp/checkout-dintero-cards-testdata +[klarna-test]: https://docs.dintero.com/docs/checkout/testdata/other/checkout-klarna-testdata +[two]: https://docs.dintero.com/docs/checkout/two +[express]: https://docs.dintero.com/docs/checkout/express +[walley]: https://docs.dintero.com/docs/checkout/walley-b2b +[shipping-options]: https://docs.dintero.com/docs/checkout/shipping-options +[checkout-webhook]: https://docs.dintero.com/docs/checkout/checkout-webhooks +[auto-capture]: https://docs.dintero.com/docs/integrations/support/auto-capture +[tokenization]: https://docs.dintero.com/docs/checkout/tokenization diff --git a/use-crystallize/skills/payments/references/klarna.md b/use-crystallize/skills/payments/references/klarna.md index de0cddb..55d7b17 100644 --- a/use-crystallize/skills/payments/references/klarna.md +++ b/use-crystallize/skills/payments/references/klarna.md @@ -1,286 +1,618 @@ # Klarna with Crystallize -> Verification: Written from Klarna's official docs, checked 2026-09-29. Not run end-to-end. -> Official docs: [Hosted Payment Page][hpp], [Order Management API][om]. Docs now live under -> `docs.klarna.com/acquirer/klarna/…`; old `klarna-payments/…` URLs redirect or 404. - -[hpp]: https://docs.klarna.com/acquirer/klarna/web-payments/integrate-with-klarna-payments/integrate-via-hpp/ -[om]: https://docs.klarna.com/acquirer/klarna/api/ordermanagement/ -[markets]: https://docs.klarna.com/acquirer/klarna/get-started/data-requirements/puchase-countries-currencies-locales/ -[tax]: https://docs.klarna.com/acquirer/klarna/web-payments/additional-resources/error-handling-and-validations/tax-handling/ - -This covers **Klarna Payments**. Klarna Checkout (KCO v3) was sold and is now **Kustom**, a separate provider that -this skill doesn't cover. OM's `/acknowledge` ("Acknowledge a Kustom checkout order") is not needed here. +Klarna is a Swedish bank whose pay later, pay in parts, financing and pay now (card, bank) options sell in 26 +countries across Europe, North America and Oceania, each in its local currency. The recommended integration is +**Klarna Payments with the Hosted Payment Page (HPP)**: create a Klarna Payments session from the placed cart, +redirect the shopper to Klarna's page, let Klarna place the order (`place_order_mode: PLACE_ORDER`), and confirm it +server-side by re-reading the session from Klarna. The embedded Klarna Payments widget with a server-side +authorization callback is the alternative. Klarna only **authorizes** at checkout: capture with the Order Management +API when the goods ship, within 28 days by default. + +> Verification: Written from Klarna's official docs, checked 2026-10-06. Not run end-to-end. +> Official docs: [Klarna Payments][kp], [Hosted Payment Page][hpp], [HPP status callbacks][status], +> [JavaScript SDK][sdk], [Authorization callback][authcb], [Order Management][om], [Tax handling][tax], +> [Payments API][api], [Order Management API][omapi]. Klarna's docs now live under `docs.klarna.com/acquirer/klarna/`. +> Crystallize page: [Klarna][crystallize]. ## At a glance -| | | -| ------------ | --------------------------------------------------------------------------------------------------------- | -| Markets | 26 countries: EU, UK, CH, NO, US, CA, MX, AU, NZ ([table][markets]); local currency, contract per country | -| Recommended | KP session → **Hosted Payment Page** redirect, `place_order_mode: PLACE_ORDER` | -| Alternative | Embedded KP widget (`x.klarnacdn.net/kp/lib/v1/api.js`) + server-side authorization callback | -| API version | Payments `v1`, HPP `v1`, Order Management `v1` | -| SDKs | No official server SDK (use `fetch` + Basic auth); no client SDK needed for HPP | -| Amounts | Integer **minor units** (`2500` = 25.00) | -| Capture | Manual, on shipment. Authorization lasts 28 days (up to 180 by contract); extendable | -| Cart id | `merchant_reference1` (≤255, shown to the shopper as the order number; the Shop order id is the cart id) | -| Verification | Unsigned. HMAC token in the `status_update` URL, then re-read the HPP session and order from Klarna | - -## Setup - -- **Test account:** docs.klarna.com → Log in → region → **Playground** → Sign up. Then Merchant Portal → Payment - settings → Klarna API keys → Generate: key id = username, secret = password (shown once; playground only). -- **Base URL** (keys are per region): `api.klarna.com`, `api-na.…`, `api-oc.…`; test `api.playground.klarna.com`. -- **Callbacks:** `merchant_urls` must match `^https://`: tunnel localhost (ngrok, cloudflared). Nothing to register. +| Topic | Klarna | +| ------------------------- | --------------------------------------------------------------------------------------------------- | +| Markets & currencies | 26 countries ([list][markets]): Europe, US, CA, MX, AU, NZ; local currency only; one agreement each | +| Recommended integration | Payments session → HPP session → redirect, `place_order_mode: PLACE_ORDER` (Klarna places it) | +| Alternative | Embedded widget (`x.klarnacdn.net/kp/lib/v1/api.js`) + authorization callback; you place it | +| API version | Payments `v1`, HPP `v1`, Order Management `v1`. Klarna Checkout (KCO) is now Kustom: not here | +| SDKs | No server SDK: `fetch` + HTTP Basic. The widget is a CDN script; HPP needs no client code | +| Amount units | Integer **minor** units (`2500` = 25.00); `tax_rate` in basis points (`2500` = 25 %) | +| Capture + auth lifetime | Manual, Order Management. 28 days (up to 180 by agreement); extendable within 180 days | +| Cart id field | `merchant_reference1` (≤ 255 chars; the shopper sees it as the order number): the full cart id | +| One session per cart | No Klarna mechanism (no idempotency key on Payments, references not unique): see below | +| Notification verification | Unsigned callbacks: an HMAC token in each callback URL, then re-read session and order | + +## Credentials and setup + +The crystallize.com page asks for a **username** and a **password**: Klarna's API key ID and secret. Start with a +test (playground) account; a live account comes with the merchant agreement. + +- **Playground account:** on docs.klarna.com, **Log in** → your region → **Playground** → **Sign up**, activate + the e-mail, then open the playground Merchant portal (`portal.playground.klarna.com`). +- **API key:** Merchant portal → **Payment settings** → **Klarna API keys** → **Generate new Klarna API key**, and + download the file: **Key ID** = username, **Secret** = password. The secret is shown once. Playground keys only + work against playground URLs; the live portal issues live keys. +- **Base URL** per region ([API URLs][urls]): Europe `https://api.klarna.com`, North America + `https://api-na.klarna.com`, Oceania `https://api-oc.klarna.com`; playground: `api.playground.klarna.com`, + `api-na.playground.klarna.com`, `api-oc.playground.klarna.com`. Keys are per region. +- **Callbacks:** every `merchant_urls` entry must match `^https://` and be reachable, so tunnel localhost + (cloudflared, ngrok). Nothing is registered in the portal: each URL travels with its session. ```bash -KLARNA_API_URL=https://api.playground.klarna.com -KLARNA_USERNAME=… # API key id -KLARNA_PASSWORD=… # API key secret -KLARNA_CALLBACK_SECRET=… # random 32+ bytes, used to sign callback URLs -PUBLIC_URL=https://….ngrok.app +KLARNA_API_URL=https://api.playground.klarna.com # https://api.klarna.com live (EU) +KLARNA_USERNAME=... # API key ID +KLARNA_PASSWORD=... # API key secret +KLARNA_CALLBACK_SECRET=... # 32+ random bytes: signs the callback URLs +PUBLIC_URL=https://shop.example # the tunnel URL in development ``` -## Create the session +- **Test shoppers** ([sample customers][testdata]): `customer+se@klarna.com` is approved and + `customer+se+denied@klarna.com` declined (use the purchase country's code; the denied flow cannot be tested in every + market, e.g. Sweden and Norway). New accounts take any 6-digit OTP except `999999`. +- **Test payments** ([sample payment data][testpay]): card `4111 1111 1111 1111`, CVC `123`, any future expiry; + `4687 3888 8888 8881` triggers 3-D Secure; direct debit IBAN `DE11 5205 1373 5120 7101 31`; bank transfer "Demo Bank". +- **Debugging:** every error body has a `correlation_id`; find it under **Logs** in the Merchant portal (7 days). + +## Create the payment -A KP session with the **placed** cart (Shop `/cart`), then an HPP session pointing at it. This code assumes -`item.price` is the line total and `variant.price` the unit price (verified on live carts). +Call this from the pay route in [SKILL.md](../SKILL.md#lock-the-cart-before-you-charge) with the **placed** cart. +Klarna needs order lines with tax, and their `total_amount`s must add up to `order_amount` exactly, so the lines are +built from the placed cart and checked against `placed.total.gross`. ```ts // lib/klarna.ts -import { createHmac, timingSafeEqual } from "node:crypto"; -import { addPayment, createOrderOnce, replacePayments, setPaymentStatus } from "@/lib/crystallize-payments"; - -type PlacedCart = { - id: string; - total: { gross: number; currency: string }; - items: { - name: string; - quantity: number; - type?: string | null; - variant: { sku: string | null }; - price: { gross: number; taxPercent: number }; - }[]; -}; +import { createHash, createHmac, timingSafeEqual } from "node:crypto"; +import { + carts, + createOrderOnce, + PLACED_CART, + readOrder, + recordPayment, + updatePayment, + withMeta, +} from "@/lib/crystallize-payments"; +import type { Payment, PlacedCart } from "@/lib/crystallize-payments"; const KLARNA = process.env.KLARNA_API_URL!; -const auth = "Basic " + btoa(`${process.env.KLARNA_USERNAME}:${process.env.KLARNA_PASSWORD}`); -const minor = (major: number) => Math.round(major * 100); +const AUTH = `Basic ${btoa(`${process.env.KLARNA_USERNAME}:${process.env.KLARNA_PASSWORD}`)}`; +export const minor = (major: number) => Math.round(major * 100); // every Klarna currency has 2 decimals -export async function klarna(path: string, init: RequestInit = {}): Promise { +export async function call(path: string, init: RequestInit = {}) { const res = await fetch(KLARNA + path, { ...init, - headers: { Authorization: auth, "Content-Type": "application/json", ...init.headers }, + headers: { Authorization: AUTH, "Content-Type": "application/json", ...init.headers }, }); - if (!res.ok) throw new Error(`Klarna ${path} ${res.status}: ${await res.text()}`); // body has correlation_id - // 201/204 carry no body; OM returns the new ids as Capture-Id / Refund-Id headers - return (res.status === 201 || res.status === 204 ? Object.fromEntries(res.headers) : await res.json()) as T; + if (!res.ok) throw new Error(`Klarna ${init.method ?? "GET"} ${path} ${res.status}: ${await res.text()}`); + return res; } - -export const sign = async (value: string) => - // hex HMAC-SHA256 of the cart id - createHmac("sha256", process.env.KLARNA_CALLBACK_SECRET!).update(value).digest("hex"); - -function orderLine(item: PlacedCart["items"][number]) { - const total = minor(item.price.gross); - const taxRate = Math.round(item.price.taxPercent * 100); // 25% → 2500 - const unit = Math.ceil(total / item.quantity); // unit_price excludes discount - return { - type: item.type === "shipping" ? "shipping_fee" : "physical", - reference: item.variant.sku ?? undefined, - name: item.name, - quantity: item.quantity, - unit_price: unit, - total_discount_amount: unit * item.quantity - total, // ≥ 0 by construction - total_amount: total, - tax_rate: taxRate, - total_tax_amount: total - Math.round((total * 10000) / (10000 + taxRate)), // the ±1 rule, exactly - }; +export const klarna = async (path: string, init?: RequestInit) => (await call(path, init)).json() as Promise; + +// Klarna callbacks are unsigned: an HMAC of (purpose, cart id) in the URL proves the URL is yours +const sign = (purpose: string, cartId: string) => + createHmac("sha256", process.env.KLARNA_CALLBACK_SECRET!).update(`${purpose}:${cartId}`).digest("hex"); +export const signedUrl = (path: string, purpose: string, cartId: string) => + `${process.env.PUBLIC_URL}${path}?cart=${cartId}&token=${sign(purpose, cartId)}`; +export function verifySignedUrl(url: string, purpose: string) { + const q = new URL(url).searchParams; + const cartId = q.get("cart") ?? ""; + const [got, want] = [Buffer.from(q.get("token") ?? ""), Buffer.from(sign(purpose, cartId))]; + return got.length === want.length && timingSafeEqual(got, want) ? cartId : null; } -export async function createKlarnaSession(cart: PlacedCart, market: { country: string; locale: string }) { - const order_lines = cart.items.map(orderLine); - const order_amount = order_lines.reduce((sum, l) => sum + l.total_amount, 0); - if (order_amount !== minor(cart.total.gross)) throw new Error("Lines do not add up to the placed cart total"); +// Deterministic, UUID-shaped Klarna-Idempotency-Key: a retry of the same operation reuses it (Klarna keeps it 24 h) +export const keyFor = (operation: string) => { + const h = createHash("sha256").update(operation).digest("hex"); + return `${h.slice(0, 8)}-${h.slice(8, 12)}-5${h.slice(13, 16)}-a${h.slice(17, 20)}-${h.slice(20, 32)}`; +}; - const kp = await klarna<{ session_id: string }>("/payments/v1/sessions", { - method: "POST", - body: JSON.stringify({ - acquiring_channel: "ECOMMERCE", - intent: "buy", - purchase_country: market.country, // must match the shopper's billing country - purchase_currency: cart.total.currency.toUpperCase(), - locale: market.locale, // e.g. nb-NO; must be a pair Klarna lists for the country - order_amount, - order_tax_amount: order_lines.reduce((sum, l) => sum + l.total_tax_amount, 0), - order_lines, - merchant_reference1: cart.id, - merchant_data: JSON.stringify({ crystallizeCartId: cart.id }), - }), +const LINE_TYPE: Record = { + shipping: "shipping_fee", + fee: "surcharge", + promotion: "discount", + digital: "digital", // the rest: physical +}; + +export function orderLines(placed: PlacedCart) { + const lines = placed.items.map((item) => { + const total = minor(item.price.gross); // the line total, discounts included + const rate = Math.round(item.price.taxPercent * 100); // 25 % → 2500 + const even = Math.ceil(total / item.quantity); + const unit = total >= 0 && item.variant ? Math.max(minor(item.variant.price.gross), even) : even; // list price + return { + type: LINE_TYPE[item.type ?? ""] ?? "physical", + reference: item.variant?.sku ?? item.lineId ?? undefined, + name: item.name, + quantity: item.quantity, + unit_price: unit, + total_discount_amount: unit * item.quantity - total, // never negative + total_amount: total, + tax_rate: rate, + total_tax_amount: total - Math.round((total * 10000) / (10000 + rate)), // Klarna's formula, < 0 if discount + }; }); + const order_amount = minor(placed.total.gross); + const drift = order_amount - lines.reduce((sum, l) => sum + l.total_amount, 0); + if (Math.abs(drift) > lines.length) throw new Error(`Lines miss ${drift} of cart ${placed.id}: cart discount?`); + if (drift !== 0) { + const total_amount = drift; // cents lost to rounding: one untaxed line keeps the sum exact + lines.push({ + type: drift < 0 ? "discount" : "surcharge", + reference: "rounding", + name: "Rounding", + quantity: 1, + unit_price: total_amount, + total_discount_amount: 0, + total_amount, + tax_rate: 0, + total_tax_amount: 0, + }); + } + const order_tax_amount = lines.reduce((sum, l) => sum + l.total_tax_amount, 0); + return { order_amount, order_tax_amount, order_lines: lines }; +} - const [q, base] = [`cart=${cart.id}&sig=${await sign(cart.id)}`, process.env.PUBLIC_URL]; - const hpp = await klarna<{ redirect_url: string }>("/hpp/v1/sessions", { +export type KlarnaMarket = { country: string; locale: string }; // from the Crystallize market, e.g. SE + sv-SE +export type KlarnaCategory = { identifier: string; name: string; asset_urls: { standard: string } }; + +export const createKpSession = (placed: PlacedCart, market: KlarnaMarket, merchant_urls?: object) => + klarna<{ session_id: string; client_token: string; payment_method_categories: KlarnaCategory[] }>( + "/payments/v1/sessions", + { + method: "POST", + body: JSON.stringify({ + acquiring_channel: "ECOMMERCE", + intent: "buy", + purchase_country: market.country, // the shopper's billing country + purchase_currency: placed.total.currency.toUpperCase(), + locale: market.locale, // a pair Klarna lists for that country + ...orderLines(placed), + merchant_reference1: placed.id, // the cart id = the Crystallize order id + merchant_urls, + }), + }, + ); + +/** Recommended: the Hosted Payment Page. Returns where to send the shopper. */ +export async function createKlarnaPayment(placed: PlacedCart, market: KlarnaMarket) { + const kp = await createKpSession(placed, market); + const checkout = `${process.env.PUBLIC_URL}/checkout`; + const hpp = await klarna<{ session_id: string; redirect_url: string }>("/hpp/v1/sessions", { method: "POST", body: JSON.stringify({ payment_session_url: `${KLARNA}/payments/v1/sessions/${kp.session_id}`, merchant_urls: { - success: `${base}/checkout/klarna/return?${q}&sid={{session_id}}`, - cancel: `${base}/checkout`, - back: `${base}/checkout`, - failure: `${base}/checkout?failed=klarna`, - status_update: `${base}/api/payments/klarna/webhook?${q}`, + success: `${checkout}/confirmation?cart=${placed.id}&sid={{session_id}}&order_id={{order_id}}`, + cancel: checkout, + back: checkout, + failure: `${checkout}?payment=failed`, + error: `${checkout}?payment=error`, + status_update: signedUrl("/api/payments/klarna/webhook", "status", placed.id), }, - options: { place_order_mode: "PLACE_ORDER" }, // Klarna places the order, so no 60-minute token race + options: { place_order_mode: "PLACE_ORDER" }, // Klarna places the order, even if the redirect fails }), }); - return { redirectUrl: hpp.redirect_url }; + console.info(`[klarna] cart ${placed.id} → HPP session ${hpp.session_id}`); // for reconciliation, see Webhook + return hpp.redirect_url; } ``` -- US: send no `tax_rate`. Put tax on its own `type: 'sales_tax'` line named "Sales Tax" ([tax handling][tax]). -- The KP session lives 48 h, and the HPP session expires 1 h before it. HPP collects the customer data. +- **One session per cart.** Klarna offers nothing: the Payments API takes no idempotency key and `merchant_reference1` + is not unique, and the placed cart cannot store a session id. Two tabs get two sessions; if both are paid, + `createOrderOnce` records the second with `attention=duplicate-payment` and someone cancels that Klarna order (it + was never captured, so nothing to refund). The widget flow releases the second authorization before it becomes an + order ([Provider specifics](#provider-specifics)). +- **US:** no `tax_rate` or `total_tax_amount` on product lines; send line totals without tax plus one + `type: "sales_tax"` line named "Sales Tax", and `order_tax_amount` = that line's `total_amount` ([tax][tax]). +- **Lifetimes:** a Payments session lives 48 h (or until an order is placed); its HPP session expires 1 h earlier. +- **Limits:** at most 1000 lines; `unit_price` and `total_amount` ≤ 200 000 000; `tax_rate` 0–10000. ## Client -Redirect to `redirectUrl` (Klarna prefers this to the popup widget on mobile). The `success` page shows "Thank you" -and polls the Shop order ([SKILL.md](../SKILL.md#the-payment-step)). Server-side it may also run `verifyKlarna` and -`handleKlarnaSession(sid, cart)`: they trust only the signed cart id, so this covers a lost callback. +The pay route answers with the `redirect_url`; the browser goes there (`location.assign`). Klarna hosts the login, +the method choice and 3-D Secure, and recommends this redirect over the widget's pop-up on mobile browsers. + +- `success` is the return page from [SKILL.md](../SKILL.md#the-return-page). It reads the cart named by `cart` in its + URL (the shopper may come back in another browser, without your cookie): `ordered` → thank you, clear the cart + cookie; still `placed` → "confirming your payment…" and refresh. While waiting it may read the + HPP session (`GET /hpp/v1/sessions/{sid}`) to tell "still confirming" from a `FAILED` or `CANCELLED` session. It + never places or creates anything: the `order_id` in its URL proves nothing. +- `cancel`, `back`, `failure` and `error` land on checkout with the cart still placed. **Pay** again creates a new + session for the same placed cart; changing the cart means a new cart. ## Webhook HPP POSTs `{ event_id, session: { session_id, status, order_id?, klarna_reference? } }` to `status_update` on every -status change: `IN_PROGRESS`, `COMPLETED`, `FAILED`, `CANCELLED`, `BACK`, `TIMEOUT`. There is no signature. It expects -a 2xx within 3 s and otherwise retries up to 3 more times. +status change. Nothing is signed: the route checks the URL's token, **re-reads** the HPP session and the Klarna order, +and checks that the order is this cart's, for this cart's amount. HPP wants a 2xx within 3 seconds and makes at most +4 calls per event, a few seconds apart ([status callbacks][status]). A slower answer only means a retry, which +`createOrderOnce` absorbs. ```ts -// lib/klarna.ts (continued). Keep helpers out of route.ts: Next only allows HTTP-method exports there. -export async function verifyKlarna(req: Request) { - const q = new URL(req.url).searchParams, - cart = q.get("cart") ?? ""; - const [got, want] = [Buffer.from(q.get("sig") ?? ""), Buffer.from(await sign(cart))]; - return got.length === want.length && timingSafeEqual(got, want) ? cart : null; -} +// lib/klarna.ts (continued). Keep helpers here: a Next route file may only export HTTP methods. +export type KlarnaOrder = { + order_id: string; + merchant_reference1: string; + klarna_reference: string; + status: "AUTHORIZED" | "PART_CAPTURED" | "CAPTURED" | "CANCELLED" | "EXPIRED" | "CLOSED"; + fraud_status: "ACCEPTED" | "PENDING" | "REJECTED"; + order_amount: number; + captured_amount: number; + remaining_authorized_amount: number; // minor units + initial_payment_method?: { type: string }; +}; -export async function handleKlarnaSession(hppSessionId: string, cartId: string) { - const s = await klarna<{ status: string; order_id?: string }>(`/hpp/v1/sessions/${hppSessionId}`); // or session_url - if (s.status !== "COMPLETED" || !s.order_id) return null; - const o = await klarna(`/ordermanagement/v1/orders/${s.order_id}`); - if (o.merchant_reference1 !== cartId) throw new Error("Klarna order belongs to another cart"); - return createOrderOnce(cartId, "unpaid", toPayment(o, "authorized")); +export async function handleHppSession(cartId: string, hppSessionId: string) { + const hpp = await klarna<{ status: string; order_id?: string }>(`/hpp/v1/sessions/${hppSessionId}`); + if (hpp.status !== "COMPLETED" || !hpp.order_id) return; // the body was only a hint + const o = await klarna(`/ordermanagement/v1/orders/${hpp.order_id}`); + const placed = (await carts.fetch(cartId, PLACED_CART)) as unknown as PlacedCart; + if (o.merchant_reference1 !== cartId || o.order_amount !== minor(placed.total.gross)) { + throw new Error(`Klarna order ${o.order_id} does not match cart ${cartId}`); // alert a human + } + const meta = { klarnaReference: o.klarna_reference, fraudStatus: o.fraud_status }; + const payment = toPayment(cartId, o.order_id, o.order_amount, o.initial_payment_method?.type, meta); + await createOrderOnce(cartId, "unpaid", payment); } +``` -// app/api/payments/klarna/webhook/route.ts -import { handleKlarnaSession, verifyKlarna } from "@/lib/klarna"; +```ts +// app/api/payments/klarna/webhook/route.ts — HPP status_update +import { handleHppSession, verifySignedUrl } from "@/lib/klarna"; export async function POST(req: Request) { - const cartId = await verifyKlarna(req); - if (!cartId) return new Response("bad signature", { status: 401 }); - const { session } = JSON.parse(await req.text()); + const body = await req.text(); + const cartId = verifySignedUrl(req.url, "status"); + if (!cartId) return new Response("bad token", { status: 401 }); + const { session } = JSON.parse(body) as { event_id: string; session: { session_id: string } }; try { - await handleKlarnaSession(session.session_id, cartId); + await handleHppSession(cartId, session.session_id); return new Response(null, { status: 204 }); } catch (error) { console.error(error); - return new Response("retry", { status: 500 }); // Klarna retries; createOrderOnce dedupes + return new Response("retry", { status: 500 }); // RetryLater and real failures alike } } ``` -| Klarna | Crystallize ([SKILL.md](../SKILL.md#paymentstatus)) | -| ----------------------------------------------- | ----------------------------------------------------------------------------------- | -| HPP `COMPLETED`, order `fraud_status: ACCEPTED` | `createOrderOnce(cartId, 'unpaid', …state=authorized)` | -| `fraud_status: PENDING` | Same, but don't capture until OM `fraud_status` is `ACCEPTED`. Check before capture | -| `FAILED`, `CANCELLED`, `BACK`, `TIMEOUT` | No order. The shopper retries with a new cart and session | -| OM order `EXPIRED` or `CANCELLED` | `replacePayments` with `state=cancelled`; cancelled stage | +| Klarna | Crystallize ([paymentStatus](../SKILL.md#paymentstatus)) | +| ---------------------------------------------------------- | -------------------------------------------------------------------------------- | +| HPP `COMPLETED`, order `fraud_status: ACCEPTED` | `createOrderOnce(cartId, 'unpaid', …)`, `state=authorized` | +| `fraud_status: PENDING` (US/UK, when enabled) | Same; `capture()` refuses until Klarna's `notification` URL reports a decision | +| `IN_PROGRESS`, `WAITING`, `BACK`, `FAILED`, `ERROR` | Nothing: the session is still open and the shopper can retry | +| `CANCELLED`, `DISABLED`, `TIMEOUT` | No order. **Pay** again opens a new session for the same placed cart | +| Authorization expired or cancelled in the portal (no push) | `capture()` fails; `cancel()` below → `state=cancelled`, cancelled stage | +| Widget: authorization callback | [Provider specifics](#provider-specifics): place the Klarna order, then the same | + +If every call fails (your endpoint down for those seconds), Klarna has an order that Crystallize lacks. Re-run +`handleHppSession(cartId, hppSessionId)` from an admin action or a scheduled job with the ids logged by +`createKlarnaPayment`: it is idempotent. Never let the return page do it. ## Capture, refund, cancel -`Klarna-Idempotency-Key` (UUID v4): make one per operation and reuse it on retry. The 201 is the confirmation (don't -poll `getOrder`). Crystallize writes use the [SKILL.md helpers](../SKILL.md#creating-the-order-exactly-once). +Every Order Management POST takes a `Klarna-Idempotency-Key`; Klarna applies an operation once per key for 24 h and +**ignores the body** when it compares, so one key per operation, never one per order. The `201`/`204` is the +confirmation: no need to poll the order afterwards. ```ts -async function klarnaMoney(orderId: string, op: "captures" | "refunds", amount: number, key: string) { - const field = op === "captures" ? "captured_amount" : "refunded_amount"; - return klarna>(`/ordermanagement/v1/orders/${orderId}/${op}`, { - method: "POST", - headers: { "Klarna-Idempotency-Key": key }, - body: JSON.stringify({ [field]: minor(amount) }), // add order_lines (and shipping_info on capture) - }); -} - -// `captured` / `refunded` are the running totals in major units, including this call -export async function captureKlarna(cartId: string, o: KlarnaOrder, amount: number, captured: number, key: string) { - await klarnaMoney(o.order_id, "captures", amount, key); - await replacePayments(cartId, [{ ...toPayment(o, "captured"), amount: captured }]); - await setPaymentStatus(cartId, minor(captured) >= o.order_amount ? "paid" : "partiallyPaid"); +// lib/klarna.ts (continued) — captureByProvider.klarna = capture. Synchronous, so never null; `record` unused. +export async function capture(transactionId: string, amount: number): Promise { + const o = await klarna(`/ordermanagement/v1/orders/${transactionId}`); + if (o.status === "CANCELLED" || o.status === "EXPIRED" || o.fraud_status !== "ACCEPTED") { + throw new Error(`Klarna order ${transactionId} is ${o.status}/${o.fraud_status}: not capturable`); + } + const todo = Math.min(minor(amount) - o.captured_amount, o.remaining_authorized_amount); + if (todo > 0) { + await call(`/ordermanagement/v1/orders/${transactionId}/captures`, { + method: "POST", + headers: { "Klarna-Idempotency-Key": keyFor(`capture:${transactionId}:${o.captured_amount}`) }, + body: JSON.stringify({ captured_amount: todo }), // add order_lines and shipping_info (carrier, tracking) + }); + } + return (o.captured_amount + Math.max(todo, 0)) / 100; // a retry after a capture that went through returns it } -export async function refundKlarna(cartId: string, o: KlarnaOrder, amount: number, refunded: number, key: string) { - const headers = await klarnaMoney(o.order_id, "refunds", amount, key); - await addPayment(cartId, { +/** `requestId` identifies this refund in your system (a return id): retries reuse it. */ +export async function refund(cartId: string, orderId: string, amount: number, requestId: string) { + const res = await call(`/ordermanagement/v1/orders/${orderId}/refunds`, { + method: "POST", + headers: { "Klarna-Idempotency-Key": keyFor(`refund:${requestId}`) }, + body: JSON.stringify({ refunded_amount: minor(amount) }), // order_lines let Klarna pick the right invoice + }); + await recordPayment(cartId, { provider: "klarna", method: "refund", - transactionId: headers["refund-id"], + transactionId: res.headers.get("Refund-Id")!, // 201 Created: the id is a header, the body is empty amount, createdAt: new Date().toISOString(), meta: [ { key: "type", value: "refund" }, - { key: "klarnaOrderId", value: o.order_id }, + { key: "cartId", value: cartId }, + { key: "klarnaOrderId", value: orderId }, ], }); - await setPaymentStatus(cartId, minor(refunded) >= o.order_amount ? "refunded" : "partiallyRefunded"); } -// Cancel (nothing captured): POST …/orders/{id}/cancel, then -// replacePayments(cartId, [toPayment(o, 'cancelled')]); the order stays unpaid → move it to a cancelled stage. -// Also: …/release-remaining-authorization after a partial capture, …/extend-authorization-time. +/** Before any capture: releases the whole authorization. */ +export async function cancel(cartId: string, orderId: string) { + await call(`/ordermanagement/v1/orders/${orderId}/cancel`, { + method: "POST", + headers: { "Klarna-Idempotency-Key": keyFor(`cancel:${orderId}`) }, + }); + await updatePayment(cartId, orderId, (p) => withMeta(p, { state: "cancelled" })); +} ``` +- **Partial capture:** capture what shipped, then `POST …/release-remaining-authorization` (`204`) once nothing more + will ship. `cancel` fails once anything is captured (`CANCEL_NOT_ALLOWED`); refunds need a capture first. +- **Extend:** `POST …/orders/{id}/extend-authorization-time` (`204`) sets the expiry to _today_ + your account's + period (28 days by default) ([extension rules][extend]). Only within 180 days of the purchase, never after + expiry, not for "Pay now" card orders nor US financing. Klarna asks for it only in exceptional delays; made-to-order + shops agree a longer period (up to 180 days) at onboarding instead. +- **Retries:** for `5xx` retry with the same key (Klarna suggests 5 s, 5 min, 5 h); `4xx` and `409` need a human + ([escalation and retry policy][retry]). + ## Mapping +The order's `order_id` is the `transactionId`: every Order Management call takes it. + ```ts -export type KlarnaOrder = { - order_id: string; - order_amount: number; - merchant_reference1: string; - fraud_status: string; - klarna_reference: string; - initial_payment_method?: { type: string }; - created_at: string; -}; -const toPayment = (o: KlarnaOrder, state: "authorized" | "captured" | "cancelled") => ({ +// lib/klarna.ts (continued) +export const toPayment = ( + cartId: string, + orderId: string, + amountMinor: number, + method: string | undefined, + extra: Record, +): Payment => ({ provider: "klarna", - method: o.initial_payment_method?.type?.toLowerCase() ?? "klarna", // invoice, pay_in_x, card, direct_debit… - transactionId: o.order_id, // every Order Management call uses this - amount: o.order_amount / 100, // major units - createdAt: o.created_at, + method: method?.toLowerCase() ?? "klarna", // invoice, pay_in_x, card, direct_debit, … + transactionId: orderId, + amount: amountMinor / 100, // MAJOR units + createdAt: new Date().toISOString(), meta: [ - { key: "state", value: state }, - { key: "fraudStatus", value: o.fraud_status }, - { key: "klarnaReference", value: o.klarna_reference }, - ], // the short id Klarna's customer service uses + { key: "state", value: "authorized" }, + { key: "cartId", value: cartId }, + ...Object.entries(extra).map(([key, value]) => ({ key, value })), // klarnaReference: what support asks for + ], }); ``` -Region and environment come from env, not meta ([record rules](../SKILL.md#the-payment-record)). - -## Gotchas - -- `order_amount` must equal Σ `total_amount` (shipping and discount lines included). Tax is checked per line (±1) - and per order (±the number of lines). Derive it from `total_amount` as above, not from Crystallize's `taxAmount`. -- `unit_price` excludes discounts (max 200000000); max 1000 lines. Country, currency and locale must be a listed pair. -- HPP `status_update` is best effort (4 calls in total). Keep the return-page reconciliation. -- Embedded widget alternative: - - Call `authorize()` directly in the click handler. Any `await` before it blocks the popup. - - Send `Cross-Origin-Opener-Policy: same-origin-allow-popups` (Helmet's `same-origin` breaks it) and Klarna's CSP. - - The authorization callback has a 2 s timeout and at-least-once delivery. Store the token, return 204, place the - order asynchronously, and answer duplicates with 2xx, never 409. -- In the old furniture boilerplates (`furniture-remix/application/src/use-cases/payments/klarna/*`): - - `@crystallize/node-service-api-request-handlers` + `pushOrder`; `gross * 100` unrounded; no tax fields sent. - - `unit_price = (gross / quantity + discount) * 100` adds the whole line discount to each unit (wrong at qty > 1). - - The country comes from the currency, with every non-NOK/USD currency mapped to `FR` and `en-FR`. - - The authorization callback places the Klarna order and pushes the Crystallize order inside the 2 s window, - with no idempotency, on a guessable unsigned `…/klarna/$cartId` route. No capture code: orders expire unpaid. - - `authorize()` runs in the `load()` callback after `await placeCart()`, without a user gesture, so the popup is - blocked. Per-category buttons are outdated; sessions usually return a single `klarna` category. +A refund is its own record: `transactionId` = the `Refund-Id` header, `amount` in major units, `meta` `type=refund`, +`cartId` and `klarnaOrderId` (see `refund()` above). Capture keeps the record and sets `state=captured` with the +captured amount, through the stage handler in [SKILL.md](../SKILL.md#capture-on-shipment-from-fulfilment-pipelines). + +## Provider specifics + +### The embedded widget and its payment method categories + +The session's `payment_method_categories` (`identifier`, `name`, `asset_urls.standard`) drive the choice shown in +your checkout. Today most accounts get one category, `klarna` ("Pay with Klarna"), and the shopper picks the actual +method inside Klarna's pop-up; older or configured accounts still get several (`pay_later`, `pay_over_time`, +`pay_now`, …). Render whatever comes back: one option per category, `load()` the chosen one, `authorize()` it. The +choice is made **after** `place` and never changes the amount, so it is not stored on the cart (and cannot be: the cart +is placed); Klarna reports the method actually used, which becomes the payment record's `method`. + +```ts +// lib/klarna.ts (continued) — the widget alternative +export async function createKlarnaWidgetSession(placed: PlacedCart, market: KlarnaMarket) { + const s = await createKpSession(placed, market, { + authorization: signedUrl("/api/payments/klarna/authorization", "authorization", placed.id), + }); + return { clientToken: s.client_token, categories: s.payment_method_categories }; +} + +export async function placeFromAuthorization(cartId: string, sessionId: string, token: string) { + type KpRead = { merchant_reference1?: string; purchase_country: string; purchase_currency: string; locale: string }; + const session = await klarna(`/payments/v1/sessions/${sessionId}`); // the callback body is unsigned + if (session.merchant_reference1 !== cartId) throw new Error(`Session ${sessionId} is not cart ${cartId}'s`); + // Lines from the PLACED cart, read by id, never the shopper's current cart: Klarna checks them against the session + const placed = (await carts.fetch(cartId, { state: true, ...PLACED_CART })) as unknown as PlacedCart & { + state: string; + }; + if (placed.state === "ordered") { + const order = await readOrder(cartId); + if (order?.payments?.some((p) => p.meta?.klarnaSessionId === sessionId)) return; // a redelivery + await call(`/payments/v1/authorizations/${token}`, { method: "DELETE" }); // another tab paid: never charged + return; + } + const { purchase_country, purchase_currency, locale } = session; + type Placed = { order_id: string; fraud_status: string; authorized_payment_method?: { type: string } }; + const o = await klarna(`/payments/v1/authorizations/${token}/order`, { + // the token lives 60 minutes + method: "POST", + body: JSON.stringify({ + purchase_country, + purchase_currency, + locale, + merchant_reference1: cartId, + ...orderLines(placed), + }), + }); + const meta = { fraudStatus: o.fraud_status, klarnaSessionId: sessionId }; + await createOrderOnce( + cartId, + "unpaid", + toPayment(cartId, o.order_id, minor(placed.total.gross), o.authorized_payment_method?.type, meta), + ).catch((error) => { + throw new Error(`Klarna order ${o.order_id} has no Crystallize order: ${error}`); // alert a human + }); +} +``` + +```ts +// app/api/payments/klarna/authorization/route.ts — merchant_urls.authorization +import { after } from "next/server"; +import { placeFromAuthorization, verifySignedUrl } from "@/lib/klarna"; + +export async function POST(req: Request) { + const body = await req.text(); + const cartId = verifySignedUrl(req.url, "authorization"); + if (!cartId) return new Response("bad token", { status: 401 }); + const { authorization_token, session_id } = JSON.parse(body) as { authorization_token: string; session_id: string }; + // Klarna gives this call 2 s and wants the order placed after the answer: answer first, work in after() + after(() => placeFromAuthorization(cartId, session_id, authorization_token).catch((e) => console.error(e))); + return new Response(null, { status: 204 }); // duplicates get 2xx too: a 409 makes Klarna retry +} +``` + +`after()` (Next.js 15.1+) is not durable: if your platform has a queue, enqueue the token there instead. The callback +is at-least-once and best effort (3 attempts, 2 s connect + 2 s read timeout each). + +```tsx +// app/checkout/klarna-widget.tsx +"use client"; +import { useEffect, useState } from "react"; +import type { KlarnaCategory } from "@/lib/klarna"; + +type Call = (o: object, data: object, cb: (r: { approved?: boolean; show_form: boolean }) => void) => void; +type Sdk = { init(o: { client_token: string }): void; load: Call; authorize: Call }; +declare global { + interface Window { + Klarna?: { Payments: Sdk }; + klarnaAsyncCallback?: () => void; + } +} + +type Props = { clientToken: string; categories: KlarnaCategory[]; billing: object; done: string }; +export function KlarnaWidget(props: Props) { + const [chosen, setChosen] = useState(props.categories[0]?.identifier); + const [hidden, setHidden] = useState([]); + const [ready, setReady] = useState(false); + useEffect(() => { + const init = () => (window.Klarna!.Payments.init({ client_token: props.clientToken }), setReady(true)); + if (window.Klarna) return init(); + window.klarnaAsyncCallback = init; + document.body.append( + Object.assign(document.createElement("script"), { + src: "https://x.klarnacdn.net/kp/lib/v1/api.js", + async: true, + }), + ); + }, [props.clientToken]); + useEffect(() => { + if (!ready || !chosen) return; + window.Klarna!.Payments.load( + { container: "#klarna-payments", payment_method_category: chosen }, + {}, + (r) => r.show_form || setHidden((h) => [...h, chosen]), + ); + }, [ready, chosen]); + // No await between the click and authorize(): the browser only opens Klarna's pop-up on a user gesture + const pay = () => + window.Klarna!.Payments.authorize( + { payment_method_category: chosen }, + { billing_address: props.billing }, + (r) => { + if (r.approved) location.assign(props.done); // the return page; the callback creates the order + else if (!r.show_form) setHidden((h) => [...h, chosen!]); + }, + ); + return ( +
+ {props.categories + .filter((c) => !hidden.includes(c.identifier)) + .map((c) => ( + + ))} +
+ +
+ ); +} +``` + +- Create the session server-side after `place` and pass `clientToken` and `categories` to the page; build `billing` + from the placed cart's `customer` and its `billing` address: `given_name`, `family_name`, `email`, `phone`, + `street_address`, `postal_code`, `city`, `country`. Klarna wants customer data at `authorize()`, not in the + session (GDPR). +- Send `Cross-Origin-Opener-Policy: same-origin-allow-popups` (Helmet's default `same-origin` cuts the pop-up off) and + allow Klarna's hosts in your CSP (`x.klarnacdn.net`, `js.klarna.com`, `*.klarna.com`, `*.klarnaevt.com`). +- `approved: true` in the browser proves nothing; the page only goes to the return page and waits. + +### After payment (prose only) + +- **Pending orders** (US and UK, enabled per account): `fraud_status: PENDING` for up to 24 h; Klarna POSTs + `{ order_id, event_type: FRAUD_RISK_ACCEPTED | _REJECTED | _STOPPED }` to the session's `merchant_urls.notification` + (unconfirmed: Klarna's pending-orders page is unreachable today). It is unsigned: re-read the order; rejected → + `cancel()`. +- **Order Management extras** (paths under `/ordermanagement/v1/orders/{id}`): tracking via `shipping_info` on the + capture or `POST …/captures/{capture_id}/shipping-info`; `…/trigger-send-out` resends the invoice e-mail; + `PATCH …/authorization` changes amount and lines before capture (new risk check); `PATCH …/customer-details` and + `…/merchant-references`; a `type: "return_fee"` line in a refund; the paid due-date extension + (`…/captures/{capture_id}/extend-due-date-options`, pay-later only). +- **Recurring:** `intent: "tokenize"` or `"buy_and_tokenize"`, then + `POST /payments/v1/authorizations/{token}/customer-token` and `POST /customer-token/v1/tokens/{token}/order` with a + `Klarna-Idempotency-Key`. Not covered here. + +## Going further + +- **Klarna Checkout (KCO v3)** — the full checkout with Klarna's address and shipping forms — is now **Kustom**, a + separate provider. Its docs, `/checkout/v3/orders` and the `acknowledge` step do not apply to Klarna Payments. +- **Automatic capture** for digital goods: `place_order_mode: "CAPTURE_ORDER"` on the HPP session (or `auto_capture: + true` when you place the order). Then create the order as `paid` with `state=captured`. +- **HPP options:** `payment_method_category(ies)` to show only some categories (store a storefront choice on the cart + before `place`), `payment_fallback`, branding ([customization][hppcustom]), and distribution by SMS, e-mail or QR code + (`distribution_url`, `qr_code_url`) for telesales and in-store. +- **Conversion boosters:** On-site messaging, Express Checkout and Sign in with Klarna (Klarna Web SDK). Express + Checkout collects the shopper's details at Klarna: keep shipping and the amount decided in your storefront before + `place`, or Klarna charges something the placed cart does not contain. +- **Extra merchant data** (`attachment`) for travel, tickets and marketplaces; the **Mobile SDK** (iOS, Android, React + Native) or HPP in a web view for apps; **settlement** reports in the portal, by API or SFTP. +- The crystallize.com page's example — initiating the payment, handling success, the order confirmation and creating + the order in Crystallize — is this reference's flow. + +## Common mistakes + +- Sending no `tax_rate`, `total_tax_amount` or `order_tax_amount` (or `0`): Klarna rejects the lines or the invoice + shows no VAT. Compute the tax from `total_amount` as above, not from a rounded per-unit value. +- Building `unit_price` as `(gross / quantity + lineDiscount) * 100`: the whole line discount lands on every unit and + the line no longer adds up when quantity > 1. `unit_price` is before discount; the discount is per line. +- `gross * 100` without `Math.round`; leaving out `reference` (the SKU) on lines. +- Deriving the country and locale from the currency (e.g. every non-NOK cart sent as `FR` / `en-FR`). Take both from + the market; `purchase_country` must match the shopper's billing country. +- In the authorization callback, building the Klarna order from the shopper's current cart (cookie, session) instead + of the placed cart named by the callback: the lines no longer match the session (`409`), or the order ships + something else than was paid. +- An unsigned, guessable callback route (`…/klarna/{cartId}`): anyone can make it run. Sign the URL; re-read Klarna. +- Placing the Klarna order and creating the Crystallize order inside the authorization callback's 2 seconds, or + answering a duplicate with `409`: Klarna times out, retries, and duplicates follow. +- Calling `authorize()` after an `await` (placing the cart in the click handler): the pop-up is blocked. Place the cart + and create the session before rendering the widget. +- Never capturing: authorizations expire after 28 days and nothing is paid. +- A random `Klarna-Idempotency-Key` per retry (no protection), or one key per order (Klarna ignores the body, so a + second partial capture silently returns the first). +- Trusting the HPP success redirect or the widget's `approved: true` instead of the re-read session and order. + +[kp]: https://docs.klarna.com/acquirer/klarna/web-payments/integrate-with-klarna-payments/integrate-via-sdk/step-1-initiate-a-payment/ +[sdk]: https://docs.klarna.com/acquirer/klarna/web-payments/additional-resources/klarna-payments-sdk-reference/ +[hpp]: https://docs.klarna.com/acquirer/klarna/web-payments/integrate-with-klarna-payments/integrate-via-hpp/before-you-start/accept-klarna-payments-using-hosted-payment-page/ +[status]: https://docs.klarna.com/acquirer/klarna/web-payments/integrate-with-klarna-payments/integrate-via-hpp/api-documentation/status-callbacks/ +[hppcustom]: https://docs.klarna.com/acquirer/klarna/web-payments/integrate-with-klarna-payments/integrate-via-hpp/before-you-start/customization/ +[authcb]: https://docs.klarna.com/acquirer/klarna/web-payments/integrate-with-klarna-payments/other-actions/authorization-callback/ +[om]: https://docs.klarna.com/acquirer/klarna/after-payments/order-management/manage-orders-with-the-api/ +[extend]: https://docs.klarna.com/acquirer/klarna/web-payments/additional-resources/use-cases/extended-authorization-expiration/ +[retry]: https://docs.klarna.com/acquirer/klarna/get-started/integration-resilience/escalation-and-retry-policy/ +[tax]: https://docs.klarna.com/acquirer/klarna/web-payments/additional-resources/error-handling-and-validations/tax-handling/ +[markets]: https://docs.klarna.com/acquirer/klarna/get-started/data-requirements/puchase-countries-currencies-locales/ +[urls]: https://docs.klarna.com/acquirer/klarna/get-started/integration-resilience/api-urls/ +[api]: https://docs.klarna.com/acquirer/klarna/api/payments/ +[omapi]: https://docs.klarna.com/acquirer/klarna/api/ordermanagement/ +[testdata]: https://docs.klarna.com/acquirer/klarna/resources/developer-tools/sample-data/sample-customer-data/ +[testpay]: https://docs.klarna.com/acquirer/klarna/resources/developer-tools/sample-data/sample-payment-data/ +[crystallize]: https://crystallize.com/docs/developer/integrations/payment-gateways/klarna diff --git a/use-crystallize/skills/payments/references/mollie.md b/use-crystallize/skills/payments/references/mollie.md new file mode 100644 index 0000000..6f9093a --- /dev/null +++ b/use-crystallize/skills/payments/references/mollie.md @@ -0,0 +1,489 @@ +# Mollie with Crystallize + +Mollie is a Dutch payment service provider for merchants incorporated in the EEA, the UK and Switzerland: cards, +Apple Pay, Google Pay, PayPal and local methods such as iDEAL, Bancontact, Klarna, Billie, Riverty, in3, TWINT, Vipps, +MobilePay and SEPA bank transfer, in EUR and 27 other currencies depending on the method. Integrate it by creating a +payment with the Payments API (`POST /v2/payments`) from the placed cart and redirecting the shopper to the hosted +Mollie Checkout (`_links.checkout.href`). The webhook receives only an unsigned payment id, so it fetches the payment +from Mollie before creating the order. Capture is automatic (`paid`) by default. With `captureMode: "manual"`, cards, +Klarna, Billie, PayPal, Vipps and MobilePay stop at `authorized` and are captured on shipment; Riverty and Billink +always work that way. + +> Verification: Written from Mollie's official docs, checked 2026-10-06. Not run end-to-end. +> Official docs: [Accepting payments][accepting], [Create payment][create], [Webhooks][webhooks], +> [API idempotency][idempotency], [Payment status][status], [Place a hold][hold], [Refunds][refunds], +> [Testing][testing], [Multicurrency][currencies], [Authentication][auth]. Every page is also served as Markdown +> (append `.md`); index: [llms.txt][llms]. + +## At a glance + +| Topic | Mollie | +| ------------------------- | ---------------------------------------------------------------------------------------- | +| Markets & currencies | Merchants in the [EEA, UK, Switzerland][countries]; EUR + 27 currencies, per method | +| Recommended integration | Payments API `POST /v2/payments`, redirect to Mollie Checkout (`_links.checkout.href`) | +| Alternative | Mollie Components card form (`cardToken`). The Orders API is no longer recommended | +| API version | `v2`, `https://api.mollie.com/v2` for test and live: the key's prefix picks the mode | +| SDKs | `fetch` here. Node: [`mollie-api-typescript`][sdk] 1.12; legacy `@mollie/api-client` 4.6 | +| Amount units | `{ currency, value }`, `value` a string in **major** units: `"10.00"`, JPY/ISK `"1000"` | +| Capture | Automatic → `paid`; `captureMode: "manual"` → `authorized` (Riverty, Billink: always) | +| Authorization lifetime | Visa/Amex 7 d, Mastercard 30 d, Klarna/Billie/Billink 28 d, Riverty 30 d, PayPal 29 d | +| Cart id field | `metadata: { cartId }` (about 1 kB of JSON); `description` ≤ 255 chars | +| One session per cart | `Idempotency-Key` derived from the cart id — Mollie caches it for **1 hour** only | +| Notification verification | Unsigned: `webhookUrl` receives `id=tr_…` → `GET /v2/payments/{id}` with your key | + +The legacy [`@mollie/api-client`][legacy-sdk] goes maintenance-only: its README says migrate by 17 November 2026, the +new SDK's README says 10 February 2027. MobilePay authorizations last 14 days, Vipps 180; every authorized payment +carries `captureBefore`, which cannot be extended. + +## Credentials and setup + +The crystallize.com page names one credential, the **apiKey**: + +1. Sign up at [my.mollie.com](https://my.mollie.com) and create a **website profile** (your web store). Keys, payment + methods and checkout branding belong to it. +2. **API key**: Developers → API keys. Each profile has a **Test API key** (`test_…`) and a **Live API key** + (`live_…`); build with the test key first. A key is shown once, at creation. Server only. +3. **Payment methods**: activate at least one, or checkout has nothing to offer. The crystallize.com page says + Settings → Website profiles → Payment methods; today's Web app has it under Organization settings → Profiles → your + web store → the method → Activate ([troubleshooting][troubleshoot]). +4. **Currency**: Mollie accepts ISO 4217 codes only. A Crystallize price variant's currency can be named anything (€, + Euro, EURO, EUR): name it `EUR`, since `placed.total.currency` goes to Mollie unchanged. Address countries are ISO + 3166-1 alpha-2 (`NL`). + +```bash +MOLLIE_API_KEY=test_... # live_... in production +MOLLIE_CAPTURE_MODE=manual # authorize now, capture on shipment; omit to capture at once +PUBLIC_BASE_URL=https://shop.example # redirect and webhook URLs; your tunnel URL in development +``` + +- **Test mode** swaps the hosted pages for a test checkout where you pick the outcome (paid, authorized, failed, + canceled, expired), with real webhooks. **EUR only.** Cards: Visa `4543 4740 0224 9996`, Mastercard + `2223 0000 1047 9399`, Amex `3782 822463 10005`, any expiry and CVV; amounts €1,001.00 to €1,011.00 forced to + `failed` give a chosen `failureReason`. A paid test payment's `_links.changePaymentState` creates a refund or + chargeback. +- **Localhost**: a `localhost` `webhookUrl` is refused ("The webhook location is invalid"). Run ngrok or cloudflared + and point `PUBLIC_BASE_URL` at the tunnel. + +## Create the payment + +Call it from the "Pay" route right after `place` ([SKILL.md](../SKILL.md#lock-the-cart-before-you-charge)). The body +comes from the placed cart only, so every call for one cart sends the same bytes: Mollie answers a reused +`Idempotency-Key` carrying a **different** body with `400`. + +```ts +// lib/mollie.ts +import { type PlacedCart } from "@/lib/crystallize-payments"; + +type Money = { currency: string; value: string }; +export type MollieRefund = { id: string; amount: Money; createdAt: string; status?: string }; // re_… or chb_… +export type MolliePayment = { + id: string; // tr_… + mode: "live" | "test"; + status: "open" | "pending" | "authorized" | "paid" | "canceled" | "expired" | "failed"; + amount: Money; + amountCaptured?: Money; + method: string | null; // ideal, creditcard, klarna, bancontact, banktransfer, … + metadata: { cartId?: string } | null; + createdAt: string; + captureBefore?: string; + _links: { checkout?: { href: string } }; + _embedded?: { refunds?: MollieRefund[]; chargebacks?: MollieRefund[] }; +}; + +type Call = { method?: string; body?: unknown; idempotencyKey?: string }; +async function mollieFetch(path: string, call: Call = {}, attempt = 0): Promise<{ json: any; replayed: boolean }> { + const res = await fetch(`https://api.mollie.com/v2${path}`, { + method: call.method ?? (call.body ? "POST" : "GET"), + headers: { + Authorization: `Bearer ${process.env.MOLLIE_API_KEY}`, + "Content-Type": "application/json", + ...(call.idempotencyKey ? { "Idempotency-Key": call.idempotencyKey } : {}), + }, + body: call.body ? JSON.stringify(call.body) : undefined, + }); + if (res.status === 409 && attempt < 3) { + // this Idempotency-Key is still being processed (two tabs at once): wait, then get the replay + await new Promise((r) => setTimeout(r, 500 * (attempt + 1))); + return mollieFetch(path, call, attempt + 1); + } + const text = await res.text(); // release-authorization answers 202 without a body + if (!res.ok) throw Object.assign(new Error(`Mollie ${path} ${res.status}: ${text}`), { status: res.status }); + return { json: text ? JSON.parse(text) : undefined, replayed: res.headers.get("idempotent-replayed") === "true" }; +} +export const mollie = async (path: string, call?: Call) => (await mollieFetch(path, call)).json as T; + +// A string in major units with the currency's decimals; JPY and ISK have none. +const decimals = (currency: string) => (currency === "JPY" || currency === "ISK" ? 0 : 2); +export const minor = (major: number, currency: string) => Math.round(major * 10 ** decimals(currency)); +export const money = (minorUnits: number, currency: string): Money => ({ + currency, + value: (minorUnits / 10 ** decimals(currency)).toFixed(decimals(currency)), +}); + +/** Every tab and every click gets the same live payment for this cart (within Mollie's one-hour key cache). */ +export async function createMolliePayment(placed: PlacedCart): Promise { + const body = await paymentBody(placed); + let key = `cart-${placed.id}`; + for (let attempt = 0; attempt < 5; attempt++) { + const { json, replayed } = await mollieFetch("/payments", { body, idempotencyKey: key }); + // A replay is the response cached at creation, still "open": read the payment as it is now. + const payment: MolliePayment = replayed ? await mollie(`/payments/${json.id}`) : json; + if (!["canceled", "expired", "failed"].includes(payment.status)) return payment; + key = `cart-${placed.id}-after-${payment.id}`; // dead: every tab derives the same next key + } + throw new Error(`cart ${placed.id}: five dead Mollie payments in a row`); +} + +async function paymentBody(placed: PlacedCart) { + const currency = placed.total.currency; // ISO 4217: "EUR", never "€" + const base = process.env.PUBLIC_BASE_URL; // fixed: a varying Host header would change the body + const customer = placed.customer; // customer and addresses were set with setCustomer before place + const address = (type: string) => { + const a = customer?.addresses?.find((x) => x.type === type); + if (!a) return undefined; + return clean({ + givenName: a.firstName ?? customer?.firstName, + familyName: a.lastName ?? customer?.lastName, + organizationName: customer?.companyName, // required for Billie (B2B) + streetAndNumber: [a.street, a.streetNumber].filter(Boolean).join(" "), + streetAdditional: a.street2, + postalCode: a.postalCode, + city: a.city, + country: a.country, // ISO 3166-1 alpha-2 + email: a.email ?? customer?.email, + }); + }; + return { + amount: money(minor(placed.total.gross, currency), currency), // the PLACED total, never the browser's + description: `Order ${placed.id}`, // the order id is the cart id + redirectUrl: `${base}/checkout/mollie/return?cart=${placed.id}`, + cancelUrl: `${base}/checkout?payment=cancelled`, + webhookUrl: `${base}/api/payments/mollie/webhook`, + metadata: { cartId: placed.id }, + locale: placed.meta?.locale, // e.g. "nl_NL", put on the cart before place + method: placed.meta?.mollieMethod, // optional preselection, see Provider specifics + captureMode: process.env.MOLLIE_CAPTURE_MODE === "manual" ? "manual" : undefined, + billingAddress: address("billing"), + shippingAddress: address("delivery"), + lines: mollieLines(placed), + }; +} +const clean = (o: Record) => + Object.fromEntries(Object.entries(o).filter(([, v]) => v != null && v !== "")); + +// Required for Klarna, Billie, in3, Riverty, Billink and vouchers; Mollie recommends them on every payment. +function mollieLines(placed: PlacedCart) { + const cur = placed.total.currency; + let sum = 0; + const lines: Record[] = placed.items.map((item) => { + const total = minor(item.price.gross, cur); // line total, VAT and discounts included + const rate = item.price.taxPercent ?? 0; + let [quantity, unit] = [ + item.quantity, + minor(item.variant?.price.gross ?? item.price.gross / item.quantity, cur), + ]; + if (unit * quantity < total) [quantity, unit] = [1, total]; // never a negative discount + sum += total; + return { + type: item.type === "shipping" ? "shipping_fee" : item.type === "digital" ? "digital" : "physical", + description: quantity === item.quantity ? item.name : `${item.quantity} × ${item.name}`, + quantity, + sku: item.variant?.sku?.slice(0, 64) || undefined, + unitPrice: money(unit, cur), // VAT included + ...(unit * quantity > total ? { discountAmount: money(unit * quantity - total, cur) } : {}), + totalAmount: money(total, cur), // = unitPrice × quantity − discountAmount, exactly + vatRate: rate.toFixed(2), // a string: "21.00" + vatAmount: money(Math.round((total * rate) / (100 + rate)), cur), // Mollie checks this formula + }; + }); + const rest = minor(placed.total.gross, cur) - sum; // Σ totalAmount must equal amount + const adjust = { + description: "Adjustment", + quantity: 1, + unitPrice: money(rest, cur), + totalAmount: money(rest, cur), + }; + if (rest) lines.push({ type: rest < 0 ? "discount" : "surcharge", ...adjust }); + return lines; +} +``` + +**After the hour.** Mollie forgets an `Idempotency-Key` after one hour, and [`GET /v2/payments`][list-payments] has no +filter on `metadata` (it only pages through the profile's payments, newest first). So the one-hour window is the +guarantee: a click after it creates a second payment while the first may still be `open`. If both get paid, +`createOrderOnce` records the second with `attention=duplicate-payment` for someone to refund +([SKILL.md](../SKILL.md#one-payment-per-cart)). Keys are tied to the API key, so rotating it resets them too. + +## Client + +Redirect the browser to the checkout URL with a **GET** (Mollie warns that POSTing to it breaks some methods): + +```ts +// app/api/checkout/pay/route.ts — continues SKILL.md's route after `place` +const payment = await createMolliePayment(placed); +const url = + payment._links.checkout?.href ?? // open: Mollie Checkout + `${process.env.PUBLIC_BASE_URL}/checkout/mollie/return?cart=${placed.id}`; // already paid, authorized or pending +return Response.redirect(url, 303); // called with fetch(): return { url } and window.location.assign(url) instead +``` + +- Mollie sends the shopper to `redirectUrl` whatever happened, without a status. The return page only reads the cart + ([SKILL.md](../SKILL.md#the-return-page)) and shows nothing personal: anyone holding the URL can open it. +- Without a preselected `method`, a failed attempt returns the shopper to Mollie Checkout to retry, and the payment + stays `open`. Cancelling there leads to `cancelUrl`: offer "Pay again" (the same route; the canceled payment is dead, + so the next key creates a fresh one) or "Change cart" (a new cart, as SKILL.md says). +- SEPA bank transfer: the shopper returns **before** paying, and the payment stays `open` for 12 (+2) days. After a + few seconds of "confirming", say the order is confirmed when the transfer arrives. + +## Webhook + +Mollie POSTs `id=tr_…` (form-encoded) to the payment's `webhookUrl` on `pending`, `authorized`, `paid`, `canceled`, +`expired` and `failed` (never on `open`), when a refund reaches `processing`, `refunded` or `failed`, and on a +chargeback. Nothing is signed: the id is a hint, and the payment you `GET` with your own key is the proof. + +```ts +// app/api/payments/mollie/webhook/route.ts +import { createOrderOnce, readOrder, recordPayment, updatePayment, withMeta } from "@/lib/crystallize-payments"; +import { mollie, molliePayment, mollieRefund, type MolliePayment } from "@/lib/mollie"; + +export async function POST(req: Request) { + const id = new URLSearchParams(await req.text()).get("id") ?? ""; + if (!/^tr_\w+$/.test(id)) return new Response("ignored"); // 200 even for junk: tell a prober nothing + try { + const p = await mollie(`/payments/${id}?embed=refunds,chargebacks`).catch((error) => { + if (error.status === 404) return null; // not yours, or from the other mode + throw error; + }); + const cartId = p?.metadata?.cartId; + if (!p || !cartId) return new Response("ignored"); + if (p.status === "paid" || p.status === "authorized") { + await createOrderOnce(cartId, p.status === "paid" ? "paid" : "unpaid", molliePayment(p)); + } + await syncOrder(cartId, p); + return new Response("ok"); + } catch (error) { + console.error(error); + return new Response("retry", { status: 500 }); // Mollie retries: 10 attempts over 26 hours + } +} + +/** Later changes to an existing order: a capture or release made anywhere, expiry, refunds, chargebacks. */ +async function syncOrder(cartId: string, p: MolliePayment) { + const order = await readOrder(cartId); + if (!order) return; // pending, or dead before paying: no order, nothing to do + const state = (id: string) => order.payments?.find((r) => r.transactionId === id)?.meta?.state; + if (state(p.id) === "authorized") { + const captured = Number(p.amountCaptured?.value ?? (p.status === "paid" ? p.amount.value : 0)); + if (captured > 0) await updatePayment(cartId, p.id, (r) => withMeta(r, { state: "captured" }, captured)); + else if (p.status === "canceled" || p.status === "expired") { + await updatePayment(cartId, p.id, (r) => withMeta(r, { state: "cancelled" })); // released or lapsed + } + } + for (const r of [...(p._embedded?.refunds ?? []), ...(p._embedded?.chargebacks ?? [])]) { + const known = state(r.id); + const status = r.status ?? "chargeback"; + if (!known && status !== "failed" && status !== "canceled") await recordPayment(cartId, mollieRefund(r, p.id)); + else if (known && known !== status) await updatePayment(cartId, r.id, (x) => withMeta(x, { state: status })); + } +} +``` + +| Mollie payment `status` (from your `GET`) | Crystallize ([paymentStatus](../SKILL.md#paymentstatus)) | +| ----------------------------------------- | ------------------------------------------------------------------------ | +| `paid` | `createOrderOnce(cartId, 'paid', …)`, `state=captured` | +| `authorized` (manual capture) | `createOrderOnce(cartId, 'unpaid', …)`, `state=authorized` | +| `paid` after `authorized` | `updatePayment` → `state=captured`, amount = `amountCaptured` | +| `open`, `pending` | Nothing: Mollie calls again when it settles | +| `failed`, `canceled`, `expired`, no order | Nothing; the cart stays placed and the next "Pay" makes a fresh payment | +| `canceled`, `expired` after `authorized` | `updatePayment` → `state=cancelled`; move the order to a cancelled stage | +| a refund in `_embedded.refunds` | `recordPayment`, `transactionId` = `re_…`, `meta type=refund` | +| a chargeback in `_embedded.chargebacks` | `recordPayment`, `transactionId` = `chb_…`, `meta type=refund` | + +Answer within **15 seconds**; later counts as failed. Retries follow after 1, 2, 4, 8, 16 and 29 minutes, then 1, 2 +and 22 hours. A `301`/`302` turns the POST into a GET and loses the `id`: keep the route clear of trailing-slash, +locale and auth redirects. Don't allowlist Mollie's IPs (they change); your `GET` already authenticates the data. + +## Capture, refund, cancel + +```ts +// lib/mollie.ts (continued). `capture` is captureByProvider.mollie in SKILL.md's pipeline-stage handler. +import { recordPayment, updatePayment, withMeta } from "@/lib/crystallize-payments"; + +export async function capture(transactionId: string, amount: number): Promise { + const p = await mollie(`/payments/${transactionId}`); + if (p.status === "paid") return Number(p.amountCaptured?.value ?? p.amount.value); // captured already + if (p.status !== "authorized") throw new Error(`Mollie ${transactionId} is ${p.status}: nothing to capture`); + const cur = p.amount.currency; + const [wanted, full] = [minor(amount, cur), minor(Number(p.amount.value), cur)]; + if (wanted < full && ["riverty", "billink"].includes(p.method ?? "")) throw new Error(`${p.method}: full only`); + const c = await mollie<{ id: string; status: string; amount?: Money }>(`/payments/${transactionId}/captures`, { + body: wanted < full ? { amount: money(wanted, cur) } : {}, // no amount = the whole authorization + idempotencyKey: `capture-${transactionId}-${wanted}`, + }); + if (c.status === "failed") throw new Error(`Mollie capture ${c.id} failed`); + return null; // pending → succeeded is asynchronous: the webhook's "paid after authorized" row records it +} + +/** `refundId` is yours (a return number…): it makes the Idempotency-Key, so a retry never refunds twice. */ +export async function refund(cartId: string, paymentId: string, amount: number, currency: string, refundId: string) { + const value = money(minor(amount, currency), currency); + const r = await mollie(`/payments/${paymentId}/refunds`, { + body: { amount: value, description: `Refund ${refundId}`, metadata: { cartId } }, + idempotencyKey: `refund-${refundId}`, + }); + await recordPayment(cartId, mollieRefund(r, paymentId)); // the webhook then only updates its state +} + +/** Releases what is still authorized: always the whole remainder. */ +export async function cancel(cartId: string, transactionId: string) { + const path = `/payments/${transactionId}/release-authorization`; + await mollie(path, { body: {}, idempotencyKey: `release-${transactionId}` }); // 202, asynchronous + await updatePayment(cartId, transactionId, (p) => withMeta(p, { state: "cancelled" })); +} +``` + +- A full capture moves the payment to `paid` and calls the webhook. Partial: Klarna and Billie keep the rest + authorized for later captures; cards release it (unless Mollie enables multicapture for you); Riverty and Billink + capture in full only. An `authorized` payment cannot be refunded (release it); a captured one cannot be released. +- Refunds run about two hours later and wait as `queued` when your Mollie balance is short. Mollie rejects a second + refund of the same amount on a payment within an hour; the idempotency key makes a timed-out call safe to repeat in + that hour. After it, check `GET /v2/payments/{id}/refunds` before trying again. + +## Mapping + +```ts +// lib/mollie.ts (continued) +import type { Payment } from "@/lib/crystallize-payments"; + +export const molliePayment = (p: MolliePayment): Payment => ({ + provider: "mollie", + method: p.method ?? undefined, // ideal, creditcard, klarna, … + transactionId: p.id, // tr_… — capture, refund and release need it + amount: Number(p.amount.value), // already major units + createdAt: p.createdAt, + meta: [ + { key: "state", value: p.status === "paid" ? "captured" : "authorized" }, + { key: "cartId", value: p.metadata!.cartId! }, + ...(p.captureBefore ? [{ key: "captureBefore", value: p.captureBefore }] : []), // capture by then + { key: "mode", value: p.mode }, // "test" must never reach a production order + ], +}); + +/** Refunds (re_…) and chargebacks (chb_…) alike: money going back, never a second order. */ +export const mollieRefund = (r: MollieRefund, paymentId: string): Payment => ({ + provider: "mollie", + method: r.id.startsWith("chb_") ? "chargeback" : "refund", + transactionId: r.id, + amount: Number(r.amount.value), + createdAt: r.createdAt, + meta: [ + { key: "type", value: "refund" }, + { key: "state", value: r.status ?? "chargeback" }, // queued | pending | processing | refunded | … + { key: "paymentId", value: paymentId }, + ], +}); +``` + +Mollie spells its status `canceled`; the record's `state` uses SKILL.md's `cancelled`. + +## Provider specifics + +**Choosing a method in your own checkout.** `GET /v2/methods` lists what the profile accepts for an amount, sorted +and translated for a locale; `billingCountry` tells whether Klarna is offered. Store the choice on the cart **before** +`place`; `paymentBody` sends it as `method`, and the shopper skips Mollie's method screen. + +```ts +// lib/mollie.ts (continued) +export async function mollieMethods(gross: number, currency: string, locale: string, billingCountry: string) { + const q = new URLSearchParams({ locale, billingCountry, "amount[currency]": currency }); + q.set("amount[value]", money(minor(gross, currency), currency).value); + type Methods = { _embedded: { methods: { id: string; description: string; image: { size2x: string } }[] } }; + return (await mollie(`/methods?${q}`))._embedded.methods; +} +// shopper picks `id` → await carts.setMeta(cartId, { meta: [{ key: "mollieMethod", value: id }], merge: true }); +``` + +With a single method, a failed or canceled attempt returns the shopper to your site instead of Mollie Checkout; the +next "Pay" makes a fresh payment. + +**Buy now, pay later.** Klarna, Billie, in3, Riverty and Billink need `lines` and a `billingAddress` with +`givenName`, `familyName`, `streetAndNumber`, `postalCode`, `city`, `country` and `email` (Billie also +`organizationName`), so the customer and addresses must be on the cart before `place`. On the Payments API, Klarna +and Billie are **captured at once** (`paid`) unless `captureMode` is `manual`; Riverty and Billink require `manual`. +in3 is NL only (EUR 50 to 5,000); Riverty is NL, BE, DE, AT in EUR; [Klarna][klarna] takes EUR, DKK, SEK, NOK, CHF, +GBP, PLN, CZK, RON or HUF depending on the country. + +**Bank transfer** stays `open` up to 12 (+2) days; with `billingAddress.email` set, Mollie emails the instructions. +The key lasts an hour, so a "Pay" days later makes a second payment: disable `banktransfer` if that is a problem. + +**After payment** (prose only): capture before `captureBefore`, or the payment turns `expired` (`paid` if partly +captured). A refund can be cancelled while `queued` or `pending` (`DELETE /v2/payments/{id}/refunds/{refundId}`). A +chargeback shows in `amountChargedBack` and, if reversed, gets `reversedAt`. Paysafecard and gift cards cannot be +refunded. + +## Going further + +- **[Mollie Components][components]**, an embedded card form: load `https://js.mollie.com/v1/mollie.js`, call + `Mollie(profileId, { locale, testmode })` and `createComponent('card')`; `createToken()` returns a `cardToken` + (valid 1 hour) that the server sends with `method: 'creditcard'`, then redirects to `_links.checkout` for 3-D + Secure. Each attempt has a new token, so key on cart id + token. The wider Components checkout and the Sessions API + are in private beta. +- **[Next-gen webhooks][webhooks-new]**: organization-level subscriptions signed with + `X-Mollie-Signature: sha256=`; Mollie still recommends `webhookUrl` for payments. +- **Recurring**: create a Mollie customer once per Crystallize customer (`POST /v2/customers`), take a first payment + with `customerId` and `sequenceType: 'first'`, then read its mandate (`GET /v2/customers/{id}/mandates`; valid once + that payment is paid). Store the customer and mandate ids on the Crystallize subscription contract. Charge with + `sequenceType: 'recurring'` + `mandateId` (always with an Idempotency-Key), or `POST /v2/customers/{id}/subscriptions` + (`amount`, `interval`, `description` unique per customer, `startDate` as `YYYY-MM-DD`, `mandateId`, `webhookUrl`). + See [Recurring payments][recurring]. +- **Single-click cards**: the same `customerId` on later payments lets Mollie Checkout offer saved cards. +- **QR codes**: `?include=details.qrCode` on create, for iDEAL, Bancontact and bank transfer. +- **Cancel an open payment**: `DELETE /v2/payments/{id}` while `isCancelable`, e.g. when the shopper starts a new cart. +- **Digital goods VAT**: `restrictPaymentMethodsToCountry: 'NL'` keeps methods to the customer's country. +- **Express Component** (private beta) collects the address during payment. That address, and any shipping cost, is + then **not** on the placed cart: Mollie charges more than the cart and the order lacks the shipping line. Choose + shipping in the storefront before `place`. +- **Orders API** integrations: shipments become captures, order cancel becomes release-authorization, order lines + become payment `lines` ([migration guide][migrate-orders]). +- Advanced access tokens and OAuth must send `profileId` (and `testmode: true` to test); an API key sends neither. + +## Common mistakes + +- Creating the order when the shopper clicks "Pay": every abandoned payment leaves an unpaid order. Create it from the + webhook once the payment is `paid` or `authorized`. +- Creating a Mollie customer on every checkout: create one per Crystallize customer, for recurring or saved cards. +- Updating the order whatever status the fetched payment has: `open`, `pending`, `failed`, `canceled` and `expired` + create nothing. +- Placeholder names and addresses in `billingAddress`, or tax sent as `0`: take both from the cart. +- `gross.toFixed(2)` for every currency (JPY and ISK have no decimals), and unrounded `gross * 100` in line maths. +- Taking the redirect to `redirectUrl` as proof of payment, or looking for a signature on the classic webhook. +- Answering `4xx` for unknown ids, or letting middleware redirect the webhook route. +- Changing the create body under the same `Idempotency-Key` (locale from the request, origin from `Host`, a + timestamp) → `400`; trusting a replayed response's `status`; counting on the key after an hour. +- `vatRate` as a number, a `vatAmount` that is not `totalAmount × rate / (100 + rate)`, lines that do not sum to + `amount`, or negative `physical` / `shipping_fee` lines. +- A price variant currency named `€` or `Euro`, or country names instead of ISO alpha-2 codes. +- Expecting Klarna or Billie to stop at `authorized` without `captureMode: 'manual'`, or calling the Orders API. +- A subscription `startDate` with a time in it (it is `YYYY-MM-DD`), or before the first payment is paid. + +[accepting]: https://docs.mollie.com/docs/accepting-payments +[create]: https://docs.mollie.com/reference/create-payment +[webhooks]: https://docs.mollie.com/reference/webhooks +[webhooks-new]: https://docs.mollie.com/reference/webhooks-new +[idempotency]: https://docs.mollie.com/reference/api-idempotency +[status]: https://docs.mollie.com/docs/handling-payment-status +[hold]: https://docs.mollie.com/docs/place-a-hold-for-a-payment +[refunds]: https://docs.mollie.com/docs/refunds +[testing]: https://docs.mollie.com/reference/testing +[currencies]: https://docs.mollie.com/docs/multicurrency +[auth]: https://docs.mollie.com/reference/authentication +[llms]: https://docs.mollie.com/llms.txt +[countries]: https://help.mollie.com/hc/en-us/articles/115002116105-Can-I-use-Mollie-s-services-in-my-country +[sdk]: https://github.com/mollie/mollie-api-typescript +[legacy-sdk]: https://github.com/mollie/mollie-api-node +[troubleshoot]: https://docs.mollie.com/docs/troubleshoot-common-issues +[list-payments]: https://docs.mollie.com/reference/list-payments +[klarna]: https://docs.mollie.com/docs/klarna +[components]: https://docs.mollie.com/docs/mollie-components +[recurring]: https://docs.mollie.com/docs/recurring-payments +[migrate-orders]: https://docs.mollie.com/docs/migrating-from-orders-to-payments diff --git a/use-crystallize/skills/payments/references/montonio.md b/use-crystallize/skills/payments/references/montonio.md index 62d4500..d60fec6 100644 --- a/use-crystallize/skills/payments/references/montonio.md +++ b/use-crystallize/skills/payments/references/montonio.md @@ -1,290 +1,428 @@ # Montonio with Crystallize -> Verification: Written from Montonio's official docs, checked 2026-09-29. Not run end-to-end. -> Official docs: [Payments overview][overview], [API reference][reference], [Create and validate an -> Order][orders], [Webhooks][webhooks], [Refunds][refunds], [Payment methods][methods], -> [Embedded cards][embedded]. Montonio's docs ask agents to start from -> (the whole site inlined: ). +Montonio is an Estonian payment and shipping platform for merchants in Estonia, Latvia, Lithuania, Finland and Poland: +bank payments from every major Baltic, Finnish and Polish bank, plus cards, Apple Pay, Google Pay, MobilePay, BLIK and +financing, in EUR and PLN only, with parcel-machine and courier shipping from the same account. The recommended +integration lets the shopper pick a bank (and a parcel machine) in your checkout, creates a Stargate order from the +placed cart with the bank preselected, redirects to its `paymentUrl`, and creates the Crystallize order from the signed +`orderToken` webhook. There is no capture: a `PAID` order is final, and money goes back only through refunds. + +> Verification: Written from Montonio's official docs, checked 2026-10-06. Not run end-to-end. +> Official docs: [Payments overview][overview], [API reference][reference], [Create and validate an Order][orders], +> [Display payment methods][methods], [Webhooks][webhooks], [Refunds][refunds], [Shipping v2][shipping]. Montonio asks +> agents to start from (all of it inlined: `/llms-full.txt`). ## At a glance -| Topic | Montonio | -| ----------------------- | ---------------------------------------------------------------------------------------- | -| Markets & currencies | Estonia, Latvia, Lithuania, Finland, Poland. **EUR and PLN only** | -| Recommended integration | Stargate: `POST /orders`, redirect to `paymentUrl` (all methods) | -| Alternative | Embedded cards: `POST /sessions` + `@montonio/montonio-js` `MontonioCheckout` | -| API version | "Stargate" (unversioned), `https://stargate.montonio.com/api`. Payments V1 is deprecated | -| Server SDK | None — `jsonwebtoken@9` for HS256 JWTs + `fetch` | -| Client SDK | `@montonio/montonio-js@1` (embedded cards only); redirect needs none | -| Amount units | **Major** units, 2 decimals (`grandTotal: 99.99`), like Crystallize | -| Capture | None: `PENDING → PAID`. `AUTHORIZED` only as opt-in (bank, enterprise) | -| Cart id | `merchantReference` (unique per store; reuse on an unpaid order replaces it) | -| Webhook verification | Body `{ orderToken }` / `{ refundToken }`: HS256 JWT signed with your Secret Key | - -Methods (`payment.method`): `paymentInitiation` (bank, EUR/PLN), `cardPayments`, `applePay`, -`googlePay` (EUR/PLN), `mobilePay` (EUR), `blik` (PLN), `bnpl` and `hirePurchase` (EUR, EE/LV/LT). - -## Setup - -- Register at montonio.com. In the Partner System () go to **Stores** → - your store → **API Keys**. Sandbox keys are available at once; production keys after approval. - Generating new keys invalidates the old ones. -- Sandbox API: `https://sandbox-stargate.montonio.com/api`. Test cards (embedded cards guide): - `5577 0000 5577 0004` success, `5454 5454 5454 5454` success with 3DS, both `03/30`, CVC `737`; a - wrong CVC fails. Force the redirect 3DS flow with `billingAddress.email = redirect-3ds-test@montonio.com`. - The sandbox bank list comes from `GET /stores/payment-methods`; test-bank behaviour is not documented. -- No webhook registration: each order carries its own `notificationUrl`, and refund webhooks go to the - same URL. Webhooks come from `35.156.245.42` and `35.156.159.169` (User-Agent `MontonioWebhooks/1.0`) — - allowlist them in a WAF or Cloudflare. Localhost: ngrok or webhook.site. +| | | +| -------------------- | ------------------------------------------------------------------------------------------ | +| Markets & currencies | Estonia, Latvia, Lithuania, Finland, Poland. **EUR and PLN only** (no NOK, SEK, DKK) | +| Recommended | Stargate `POST /orders` with the bank preselected, then redirect to `paymentUrl` | +| Alternative | Embedded cards (`POST /sessions` + `@montonio/montonio-js`), payment links | +| API version | Stargate (unversioned) `https://stargate.montonio.com/api`; Shipping `…/api/v2` | +| SDKs | No server SDK: `jsonwebtoken@9` + `fetch`. Browser `@montonio/montonio-js@1` (cards only) | +| Amount units | **Major** units, 2 decimals (`grandTotal: 99.99`), like Crystallize — never × 100 | +| Capture | None: `PENDING → PAID` is final. Opt-in `AUTHORIZED` is a bank still settling, not a hold | +| Cart id | `merchantReference` (required, unique per store), echoed in every order token | +| One session per cart | Montonio enforces it: re-posting a `merchantReference` replaces the unpaid order | +| Verification | Webhook body `{ orderToken }` / `{ refundToken }`: an HS256 JWT signed with the Secret Key | + +## Credentials and setup + +- **Access Key** (identifies your calls) and **Secret Key** (signs every request and verifies every webhook; keep it on + the server), as the crystallize.com page names them. Register at montonio.com, then in the Partner System + () open **Stores** → your store → **API Keys** tab. Sandbox keys work at once, + production keys after Montonio approves the business; generating new keys invalidates the old ones. +- **Shipping** uses the same store keys (both APIs point to the same [API keys][keys]). Activate carriers in the + Partner System; in sandbox, switch on test mode and activate carriers with dummy credentials — carrier calls and + labels are mocked ([sandbox][ship-sandbox]). +- **Test data** (sandbox): cards `5577 0000 5577 0004` (success) and `5454 5454 5454 5454` (3DS), `03/30`, CVC `737`; + `billingAddress.email = redirect-3ds-test@montonio.com` forces the redirect 3DS flow. BLIK `777 123` succeeds. The + sandbox bank list comes from `GET /stores/payment-methods`; how its test banks behave is not documented (unconfirmed). +- **Webhooks** need no registration: each order carries its `notificationUrl`, and refund webhooks go to the same URL. + They come from `35.156.245.42` and `35.156.159.169` with User-Agent `MontonioWebhooks/1.0` — allowlist them in a + WAF or Cloudflare. Localhost: ngrok, or webhook.site to inspect payloads. ```bash -MONTONIO_API_URL=https://sandbox-stargate.montonio.com/api # prod: https://stargate.montonio.com/api -MONTONIO_ACCESS_KEY=... -MONTONIO_SECRET_KEY=... -MONTONIO_NOTIFICATION_URL=https://shop.example/api/payments/montonio/webhook +MONTONIO_ACCESS_KEY=… +MONTONIO_SECRET_KEY=… +MONTONIO_API_URL=https://sandbox-stargate.montonio.com/api # prod: https://stargate.montonio.com/api +MONTONIO_SHIPPING_URL=https://sandbox-shipping.montonio.com/api/v2 # prod: https://shipping.montonio.com/api/v2 +PUBLIC_URL=https://shop.example # the tunnel in development ``` -## Create the session +## Create the payment -Call after `place` ([SKILL.md](../SKILL.md#the-payment-step)). Auth is a JWT signed HS256 with the Secret -Key and carrying `accessKey`. For POST, the **payload itself** is the JWT, sent as `{ data }`; for GET, it -goes in `Authorization: Bearer`. Let the shopper pick the method (and bank) in your checkout **before** -creating the order. +Authentication is a JWT signed HS256 with the Secret Key and carrying `accessKey`. Stargate takes it as +`Authorization: Bearer` on GET, and on POST the **payload itself** is the JWT, sent as `{ data }` (10-minute `exp`). +Shipping v2 takes a Bearer JWT on every call and plain JSON bodies. The bank and parcel machine were stored on the cart +before place ([Provider specifics](#provider-specifics)); the Pay route calls this after `place` +([SKILL.md](../SKILL.md#lock-the-cart-before-you-charge)). ```ts +// lib/montonio.ts import jwt from "jsonwebtoken"; +import type { Payment, PlacedCart } from "@/lib/crystallize-payments"; -type PlacedCart = { - id: string; - total: { gross: number; net: number; currency: string }; - items: { name: string; quantity: number; price: { gross: number } }[]; // line totals, major units -}; - +const ACCESS = process.env.MONTONIO_ACCESS_KEY!; +const SECRET = process.env.MONTONIO_SECRET_KEY!; +const round2 = (major: number) => Math.round(major * 100) / 100; // major units, 2 decimals const sign = (payload: object, expiresIn: "10m" | "1h") => - jwt.sign({ accessKey: process.env.MONTONIO_ACCESS_KEY, ...payload }, process.env.MONTONIO_SECRET_KEY!, { - algorithm: "HS256", - expiresIn, - }); + jwt.sign({ ...payload, accessKey: ACCESS }, SECRET, { algorithm: "HS256", expiresIn }); // adds iat +/** Stargate. 401 STORE_NOT_FOUND = wrong access key or environment, 403 = wrong secret key. */ export async function montonio(path: string, payload?: object): Promise { const init: RequestInit = payload ? { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ data: sign(payload, "10m") }), // POST: the payload IS the JWT + body: JSON.stringify({ data: sign(payload, "10m") }), } : { headers: { Authorization: `Bearer ${sign({}, "1h")}` } }; const res = await fetch(`${process.env.MONTONIO_API_URL}${path}`, init); - // 401 STORE_NOT_FOUND = wrong access key, 403 INVALID_TOKEN = wrong secret if (!res.ok) throw new Error(`Montonio ${path} ${res.status}: ${await res.text()}`); - return res.json() as Promise; + return (await res.json()) as T; +} + +/** Shipping v2. */ +export async function shipping(path: string, body?: object): Promise { + const res = await fetch(`${process.env.MONTONIO_SHIPPING_URL}${path}`, { + method: body ? "POST" : "GET", + headers: { Authorization: `Bearer ${sign({}, "1h")}`, "Content-Type": "application/json" }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!res.ok) throw new Error(`Montonio shipping ${path} ${res.status}: ${await res.text()}`); + return (await res.json()) as T; } -// GET /stores/payment-methods → paymentMethods.paymentInitiation.setup[country].paymentMethods[] (banks); -// cache it for a few hours and render your own selector (or Montonio's bank widget). +type Options = { origin: string; locale: string; letShopperPickBank?: boolean }; -export async function createMontonioSession( - cart: PlacedCart, - origin: string, - choice: { method: string; bank?: string; country?: string }, - customer: { firstName: string; lastName: string; email: string; country: string }, -) { - const grandTotal = Math.round(cart.total.gross * 100) / 100; // major units, 2 decimals +export async function createMontonioOrder(placed: PlacedCart, { origin, locale, letShopperPickBank }: Options) { + const m = placed.meta ?? {}; + const c = placed.customer; + const a = c?.addresses?.find((x) => x.type === "billing") ?? c?.addresses?.[0]; + const grandTotal = round2(placed.total.gross); + const method = m.montonioMethod || "paymentInitiation"; const order = await montonio<{ uuid: string; paymentUrl: string }>("/orders", { - merchantReference: cart.id, // placed cart can't change, so re-POSTing on retry is safe - returnUrl: `${origin}/checkout/montonio/return`, // gets ?order-token= - notificationUrl: process.env.MONTONIO_NOTIFICATION_URL, - currency: cart.total.currency, // EUR | PLN + merchantReference: placed.id, // unique per store: a second POST replaces the unpaid order, never adds one + returnUrl: `${origin}/checkout/montonio/return?cart=${placed.id}`, // + &order-token=, paid or cancelled + notificationUrl: `${process.env.PUBLIC_URL}/api/payments/montonio/webhook`, + currency: placed.total.currency, // EUR or PLN grandTotal, - locale: "en", // de en et fi lt lv pl ru - billingAddress: customer, - lineItems: cart.items.map((i) => ({ + locale, // de, en, et, fi, lt, lv, pl, ru + billingAddress: { + firstName: c?.firstName, + lastName: c?.lastName, + email: c?.email, + addressLine1: a?.street, + locality: a?.city, + postalCode: a?.postalCode, + country: a?.country, + }, + // unit price incl. tax; Montonio does not enforce that the lines add up to grandTotal + lineItems: placed.items.map((i) => ({ name: i.name, quantity: i.quantity, - finalPrice: Math.round((i.price.gross / i.quantity) * 100) / 100, // unit price incl. tax + finalPrice: round2(i.price.gross / i.quantity), })), payment: { - method: choice.method, // e.g. 'paymentInitiation' + method, // paymentInitiation, cardPayments, applePay, googlePay, mobilePay, blik, bnpl, hirePurchase amount: grandTotal, // must equal grandTotal - currency: cart.total.currency, - methodOptions: choice.bank - ? { preferredProvider: choice.bank, preferredCountry: choice.country } // bank code, e.g. LHVBEE22 - : undefined, + currency: placed.total.currency, + methodOptions: + method === "paymentInitiation" + ? { + preferredProvider: letShopperPickBank ? undefined : m.montonioBank || undefined, // LHVBEE22 + preferredCountry: m.montonioBankCountry || undefined, // the list the shopper saw + } + : undefined, }, - expiresIn: 30, // minutes until ABANDONED (5 … 44640) + expiresIn: 30, // minutes until ABANDONED (5 to 44 640) }); - return { url: order.paymentUrl, montonioOrderUuid: order.uuid }; + return { url: order.paymentUrl }; } +// app/api/checkout/pay/route.ts, after place: return Response.json(await createMontonioOrder(placed, opts)); ``` -Re-using a `merchantReference` for an unpaid order **replaces** that order, amount included, and -returns a new URL. For a paid order it errors. Montonio: "If you change the amount … create a new order". +Re-posting a `merchantReference` replaces an **unpaid** order and returns a new `paymentUrl`; a **paid** one answers an +error ([merchantReference][mref]). The placed cart cannot change, so the amount never does. ## Client -Redirect to `paymentUrl` (a server `redirect()` from the Server Action). For a bank payment with -`preferredProvider`, Montonio skips its own bank picker and sends the shopper straight to the bank. The -shopper comes back to `returnUrl?order-token=` after success **or** cancel. Verify that token -server-side to render the result (`paymentStatus`), then poll for the order. The page never creates the -order; the webhook does. - -Embedded cards (optional): on the server call `POST /sessions` once per checkout load; on the client -use `new MontonioCheckout({ sessionUuid, environment: 'sandbox' | 'production', onSuccess, onError })`, -`.initialize('#container')`, then `validateOrReject()`. Then create the order with `sessionUuid` and -call `submitPayment()`. Call `destroy()` before re-initialising (React re-renders). If -`cardPayments.processor === 'stripe'`, the store is still on the legacy embedded flow. +Redirect to `paymentUrl` (`location.href = url`, or `redirect()` from a Server Action). With `preferredProvider` the +shopper goes straight to their bank. They come back to the `returnUrl` with `order-token=` added, after success +**or** cancellation — maybe in another browser, so the page takes the cart id from its own `?cart=`. It only reads +([SKILL.md](../SKILL.md#the-return-page)), verifying the token on the server (`verifyMontonio` below) just to pick the +message: `PAID` → "thank you" while the cart turns `ordered`; `AUTHORIZED` → "your bank is processing the payment"; +anything else → "payment not completed" and "Try again", which calls the Pay route with `letShopperPickBank: true` +(same order, new URL, Montonio's own bank list). It never creates the order. ## Webhook +Montonio POSTs `{ "orderToken": "" }` when an order's `paymentStatus` changes and `{ "refundToken": "" }` +when a refund's does, to the same `notificationUrl` ([webhooks][webhooks]). The signature is inside the JWT, so the +body only has to be read and parsed; pin the algorithm and check `accessKey`. Use the camelCase fields: the snake_case +copies (`payment_status`, …) are being removed. Anything but `200`/`201` is retried 13 times over 48 hours. + ```ts -export async function verifyMontonio(req: Request) { - const body = JSON.parse(await req.text()) as { orderToken?: string; refundToken?: string }; - const token = body.orderToken ?? body.refundToken; - if (!token) throw new Error("no token"); - const claims = jwt.verify(token, process.env.MONTONIO_SECRET_KEY!, { algorithms: ["HS256"] }) as any; - if (claims.accessKey !== process.env.MONTONIO_ACCESS_KEY) throw new Error("other store"); - return { kind: body.orderToken ? ("order" as const) : ("refund" as const), claims }; +// lib/montonio.ts (continued) +export type Claims = Record; + +export function verifyMontonio(token: unknown): Claims { + if (typeof token !== "string") throw new Error("no token"); + const claims = jwt.verify(token, SECRET, { algorithms: ["HS256"] }) as Claims; // signature and exp + if (claims.accessKey !== ACCESS) throw new Error("token for another store"); + return claims; } ``` ```ts // app/api/payments/montonio/webhook/route.ts -import { addPayment, createOrderOnce, replacePayments, setPaymentStatus } from "@/lib/crystallize-payments"; +import { createOrderOnce, readCart, recordPayment, updatePayment, withMeta } from "@/lib/crystallize-payments"; +import { montonio, montonioPayment, verifyMontonio, type Claims } from "@/lib/montonio"; export async function POST(req: Request) { - let event: Awaited>; + const raw = await req.text(); + let body: { orderToken?: string; refundToken?: string }; + let c: Claims; try { - event = await verifyMontonio(req); + body = JSON.parse(raw); + c = verifyMontonio(body.orderToken ?? body.refundToken); } catch { - return new Response("invalid token", { status: 401 }); + return Response.json({ error: "invalid token" }, { status: 401 }); } - const c = event.claims; try { - if (event.kind === "order" && c.paymentStatus === "PAID") { - const order = await createOrderOnce(c.merchantReference, "paid", montonioPayment(c)); - if ("paymentStatus" in order && order.paymentStatus === "unpaid") { - // it existed as AUTHORIZED: the bank has now settled it - await replacePayments(c.merchantReference, [montonioPayment(c)]); - await setPaymentStatus(c.merchantReference, "paid"); + if (body.orderToken && c.paymentStatus === "PAID") { + await createOrderOnce(c.merchantReference, "paid", montonioPayment(c)); + } else if (body.orderToken && c.paymentStatus === "VOIDED") { + // the bank rejected the payment; after PAID (rare, Montonio also e-mails you) the order must not ship + if ((await readCart(c.merchantReference))?.state === "ordered") { + await updatePayment(c.merchantReference, c.uuid, (p) => withMeta(p, { state: "cancelled" })); + console.error(`[montonio] ${c.merchantReference} VOIDED after PAID: stop fulfilment`); } - } else if (event.kind === "order" && c.paymentStatus === "AUTHORIZED") { - await createOrderOnce(c.merchantReference, "unpaid", montonioPayment(c, "authorized")); - } else if (event.kind === "order" && c.paymentStatus === "VOIDED") { - await replacePayments(c.merchantReference, [montonioPayment(c, "cancelled")]); - await setPaymentStatus(c.merchantReference, "unpaid"); // and stop fulfilment - } else if (event.kind === "refund" && c.refundStatus === "SUCCESSFUL") { - // the refund token has no merchantReference: read the Montonio order (dedupe on c.refundUuid) - const order = await montonio(`/orders/${c.orderUuid}`); - await addPayment(order.merchantReference, { + } else if (body.refundToken && c.refundStatus === "SUCCESSFUL") { + // the refund token carries no merchantReference: read it from the Montonio order + const order = await montonio<{ merchantReference: string; paymentMethodType: string }>( + `/orders/${c.orderUuid}`, + ); + await recordPayment(order.merchantReference, { provider: "montonio", - method: "refund", + method: order.paymentMethodType, transactionId: c.refundUuid, - amount: c.refundAmount, // major units + amount: Number(c.refundAmount), // major units createdAt: new Date(c.iat * 1000).toISOString(), meta: [ { key: "type", value: "refund" }, - { key: "state", value: "refunded" }, + { key: "cartId", value: order.merchantReference }, ], }); - await setPaymentStatus( - order.merchantReference, - order.paymentStatus === "REFUNDED" ? "refunded" : "partiallyRefunded", - ); + } else if (body.refundToken && c.refundStatus === "REJECTED") { + console.error(`[montonio] refund ${c.refundUuid} rejected: ${c.refundStatusDescription}`); // a human acts } - return Response.json({ ok: true }); // 200 or 201 - } catch { + return Response.json({ ok: true }); // PENDING, AUTHORIZED, ABANDONED: nothing yet + } catch (error) { + console.error(error); return Response.json({ error: "retry" }, { status: 500 }); } } ``` -The signature is inside the JWT, so the raw body only has to be valid JSON. Pin `algorithms`. Use -`paymentStatus` and `merchantReference`; the snake_case duplicates (`payment_status`, …) are being -removed. A failure (not 200/201) is retried over 48 hours ("13 times"). Deduplicate on the order `uuid` + -status, or the refund `uuid`. - -| Token status | Crystallize ([paymentStatus](../SKILL.md#paymentstatus)) | -| ------------------------------ | --------------------------------------------------------------------------------- | -| order `PAID` | `createOrderOnce(cartId, 'paid', …)`, `state=captured` | -| order `AUTHORIZED` (opt-in) | `createOrderOnce(cartId, 'unpaid', …)`, `state=authorized` | -| `PAID` after `AUTHORIZED` | `replacePayments` `state=captured`; `setPaymentStatus` `paid` | -| order `VOIDED` (bank rejected) | `replacePayments` `state=cancelled`; `setPaymentStatus` `unpaid`; stop fulfilment | -| refund `SUCCESSFUL` | `addPayment` `type=refund`; `setPaymentStatus` `partiallyRefunded`/`refunded` | -| refund `REJECTED` | Alert a human (`refundStatusDescription`) | -| order `PENDING`, `ABANDONED` | No order (`ABANDONED` after `expiresIn`, 30 min) | +| Token status | Crystallize ([paymentStatus](../SKILL.md#paymentstatus)) | +| -------------------------------- | --------------------------------------------------------------------------- | +| order `PAID` | `createOrderOnce(cartId, 'paid', …)`, `state=captured` | +| order `AUTHORIZED` (opt-in) | Nothing yet: the bank is still settling; `PAID` or `VOIDED` follows | +| order `VOIDED` | After `PAID`: `updatePayment` → `state=cancelled`, stop. Else no order | +| order `PENDING`, `ABANDONED` | No order (`ABANDONED` after `expiresIn`; on by default since 2023-08-29) | +| order `REFUNDED`, `PARTIALLY_…` | Nothing: the refund token records it | +| refund `SUCCESSFUL` / `REJECTED` | `recordPayment` (`type=refund`) / alert a human (`refundStatusDescription`) | + +Redeliveries are harmless (`createOrderOnce` and `recordPayment` dedupe on the UUIDs). Whether late retries carry a +fresh token is unconfirmed: if they fail on `exp`, verify with `ignoreExpiration: true` — the handlers are idempotent. ## Capture, refund, cancel -There is no capture or void: bank payments, cards and wallets settle at `PAID`. Refunds: +There is nothing to capture or void: bank payments, cards and wallets settle at `PAID`. Refunds go through +`POST /refunds`, and Crystallize is written only when the `refundToken` says `SUCCESSFUL` (a refund can wait in +`PENDING`, e.g. on insufficient settlement funds). Cancelling a paid order means refunding it in full +(`availableForRefund` from `GET /orders/{uuid}`). ```ts -import { randomUUID } from "node:crypto"; - -// optional pre-check: GET /orders/{uuid} → availableForRefund, isRefundableType -await montonio(`/refunds`, { - orderUuid: transactionId, // the Montonio order uuid from the payment record - amount: 25.0, // major units, ≥ 0.05 EUR, total ≤ grandTotal - idempotencyKey: randomUUID(), // required; store it and reuse it only to retry the same refund - iat: Math.floor(Date.now() / 1000), // required for refunds -}); +// lib/montonio.ts (continued) +/** captureByProvider.montonio — no-op: PAID is final, the whole amount is already captured. */ +export const capture = async (_orderUuid: string, amount: number): Promise => amount; + +/** `refundId` is yours (a return or credit-note id); Montonio refuses a second refund with the same key. */ +export async function refund(orderUuid: string, amount: number, refundId: string) { + try { + await montonio("/refunds", { orderUuid, amount: round2(amount), idempotencyKey: `refund-${refundId}` }); + } catch (error) { + if (!String(error).includes("same idempotency key")) throw error; // else: a retry of a refund already made + } +} ``` -Write nothing to Crystallize here: the refund is only done when the `refundToken` webhook says -`SUCCESSFUL` (it can sit in `PENDING` and retry), and the route above then calls `addPayment` and -`setPaymentStatus` ([helpers](../SKILL.md#creating-the-order-exactly-once)). +Refund rules ([refunds][refunds]): to the original payer only, at least 0.05 €, in total at most `grandTotal`, and only +once the money reached the Montonio settlement account (about one business day). Bank-payment refunds are EUR only and +must be enabled in the Partner System; cards, wallets, BLIK and financing are refundable by default. ## Mapping ```ts -import type { Payment } from "@/lib/crystallize-payments"; - -function montonioPayment(c: any, state: "captured" | "authorized" | "cancelled" = "captured"): Payment { - return { - provider: "montonio", - method: c.paymentMethod, // 'paymentInitiation' | 'cardPayments' | 'applePay' | 'mobilePay' | … - transactionId: c.uuid, // Montonio order uuid — refunds and GET /orders/{uuid} use it - amount: Number(c.grandTotal), // already major units - createdAt: new Date(c.iat * 1000).toISOString(), - meta: [ - { key: "state", value: state }, - { key: "type", value: "payment" }, - ...(c.paymentProviderName ? [{ key: "bank", value: c.paymentProviderName }] : []), - ], +// lib/montonio.ts (continued) +export const montonioPayment = (c: Claims): Payment => ({ + provider: "montonio", + method: c.paymentMethod, // paymentInitiation, cardPayments, applePay, googlePay, mobilePay, blik, bnpl … + transactionId: c.uuid, // the Montonio order UUID: refunds, GET /orders/{uuid} and shipments take it + amount: Number(c.grandTotal), // already major units + createdAt: new Date(c.iat * 1000).toISOString(), + meta: [ + { key: "state", value: "captured" }, + { key: "cartId", value: c.merchantReference }, + ...(c.paymentProviderName ? [{ key: "bank", value: String(c.paymentProviderName) }] : []), + ], +}); +``` + +A refund is a second record, from the webhook: `transactionId` = refund UUID, `amount` = `refundAmount`, `meta +type=refund` and `cartId` ([the payment record](../SKILL.md#the-payment-record)). + +## Provider specifics + +**Bank picker.** Montonio wants the method, and for bank payments the bank, chosen in your checkout before the order +exists ([methods][methods]). `GET /stores/payment-methods` lists the enabled methods, and the banks per country under +`paymentInitiation.setup[country].paymentMethods` (`code`, `name`, `logoUrl`, `supportedCurrencies`, `uiPosition`). +Revolut, N26 and Wise share one code across countries: send the country whose list the shopper saw as +`preferredCountry`. Montonio's bank widget lives in its legacy SDK, retired in 2026: render the list yourself. + +**Pickup points.** Shipping v2 lists the carriers per destination country (`GET /shipping-methods`) and their pickup +points (`GET /shipping-methods/pickup-points?carrierCode=&countryCode=&type=`, types `parcelMachine`, `parcelShop`, +`postOffice`) ([shipping methods][ship-methods]). The chosen point is **required before Pay** and goes on the cart with +the bank, next to the shipping line the storefront added as an external item. + +```ts +// lib/montonio.ts (continued) +export type Bank = { code: string; name: string; logoUrl: string; supportedCurrencies: string[]; uiPosition?: number }; +export type PickupPoint = { id: string; name: string; streetAddress: string; locality: string; carrierCode: string }; +type StoreMethods = { paymentMethods: { paymentInitiation?: { setup: Record } } }; + +const cache = new Map(); +async function cached(key: string, ttl: number, load: () => Promise): Promise { + const hit = cache.get(key); + if (hit && Date.now() - hit.at < ttl) return hit.value as T; + const value = await load(); + cache.set(key, { at: Date.now(), value }); + return value; +} + +export async function listBanks(country: string, currency: string): Promise { + const store = await cached("methods", 3_600_000, () => montonio("/stores/payment-methods")); + const banks = store.paymentMethods.paymentInitiation?.setup[country]?.paymentMethods ?? []; + return banks + .filter((b) => b.supportedCurrencies.includes(currency)) + .sort((x, y) => (x.uiPosition ?? 99) - (y.uiPosition ?? 99)); +} + +export async function listPickupPoints(carrier: string, country: string): Promise { + const q = new URLSearchParams({ carrierCode: carrier, countryCode: country, type: "parcelMachine" }); + const load = () => shipping<{ pickupPoints: PickupPoint[] }>(`/shipping-methods/pickup-points?${q}`); + return cached(`points:${q}`, 86_400_000, async () => (await load()).pickupPoints); +} +``` + +```ts +// app/api/checkout/montonio/choice/route.ts — the bank and the parcel machine go on the cart BEFORE place +import { carts, PLACED_CART, type PlacedCart } from "@/lib/crystallize-payments"; +import { listBanks, listPickupPoints } from "@/lib/montonio"; + +type Choice = { bankCountry: string; bank: string; shipTo: string; carrier: string; pickupPoint: string }; + +export async function POST(req: Request) { + const cartId = getCartIdFromCookie(req); // your session handling + const choice = (await req.json()) as Choice; + const cart = (await carts.fetch(cartId, { state: true, ...PLACED_CART })) as unknown as PlacedCart & { + state: string; + }; + if (cart.state !== "cart") return Response.json({ error: "placed" }, { status: 409 }); // the write would be lost + const banks = await listBanks(choice.bankCountry, cart.total.currency); + const bank = banks.find((b) => b.code === choice.bank)?.code ?? ""; + const point = (await listPickupPoints(choice.carrier, choice.shipTo)).find((p) => p.id === choice.pickupPoint); + const meta = { + montonioMethod: "paymentInitiation", + montonioBank: bank, + montonioBankCountry: choice.bankCountry, + montonioCarrier: choice.carrier, + montonioPickupPoint: point?.id ?? "", + montonioPickupPointName: point ? `${point.name}, ${point.streetAddress}, ${point.locality}` : "", }; + await carts.setMeta(cartId, { meta: Object.entries(meta).map(([key, value]) => ({ key, value })), merge: true }); + return Response.json({ banks, ready: !!point }); // ready: Pay may be enabled } +// app/api/checkout/pay/route.ts, before place: no parcel machine, no Pay +// if (!cart.meta?.montonioPickupPoint) return Response.json({ error: "choose a parcel machine" }, { status: 400 }); ``` -## Gotchas - -- Amounts are **major** units, unlike most providers. Do not multiply by 100; round to 2 decimals. -- EUR and PLN only. There is no NOK, SEK or DKK — Montonio is not an option for NO/SE/DK shoppers. -- Refunds only work once the money has reached the Montonio settlement account (≈ 1 business day). - Bank-payment refunds are EUR-only and must be enabled in the Partner System. Minimum 0.05 €. -- `VOIDED` can follow `PAID` for bank payments (rare; Montonio also emails the merchant), so allow a - paid order to become unpaid. `ABANDONED` exists by default only for stores created after 2023-08-29. -- `preferredCountry` must match the bank list you showed (matters for Revolut, N26, Wise). -- The JWT `exp` for an order token must be short (docs: 10 minutes). The order's own lifetime is - `expiresIn`. -- The legacy UMD SDK and the old embedded (Stripe) card flow are being retired in 2026. -- Shipping is out of scope here. Montonio's Shipping API **v2** (`https://shipping.montonio.com/api/v2`, - same keys, JWT-signed webhooks) replaced the old `api.shipping.montonio.com` endpoints. Create shipments - after `PAID` from a pipeline step, not inside the payment webhook. -- In the old furniture boilerplates: - - Payments use the deprecated **V1** flow: a `payment_token` JWT appended to `payments.montonio.com`, - with snake_case fields (`merchant_reference`, `preselected_aspsp`) and no API call. - - The webhook read `payment_token` from the **query string** and checked `status === 'finalized'` - and `payment_uuid` / `customer_iban`. Stargate POSTs `{ orderToken }` with `paymentStatus: 'PAID'`. - - No `accessKey` check, no algorithm pinning, no idempotency: every notification called `pushOrder`, - so a later refund notification would create a second order. - - The bank list came from the old `/pis/v2/merchants/payment_methods`. The UI was hard-wired to `EE` - and Omniva. - - Shipments and labels (old shipping API) were created synchronously inside the payment webhook with - errors swallowed (`.catch(console.log)`), and used a hard-coded dummy sender and phone number. +The checkout shows the `banks` this route returns as a logo grid under a country selector (`EE`, `LV`, `LT`, `FI`, +`PL`), and the pickup points (a GET route around `listPickupPoints`) as a searchable `