From 08aa75f33f0712a72c13f812cc54adf78589f9df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Tue, 6 Oct 2026 14:23:54 -0700 Subject: [PATCH 1/6] feat(skills): rework payments around the placed-cart lock, add Mollie and Two - SKILL.md: the common flow from the crystallize.com docs, why the cart is placed before charging (the two-tab attack), one payment per cart, the webhook skeleton, a read-only return page, shared Shop API helpers (/lock, createOrderOnce, Core customer, recordPayment, updatePayment), paymentStatus set once at creation, and capture/cancel from fulfilment pipeline stages. - Every provider reference re-verified against current provider docs (2026-10-06), with the crystallize.com page content folded in and provider-specific features kept as plain API calls. - New references: Mollie (Payments API) and Two (B2B invoice). - No dependency on the deprecated request-handler library or old boilerplates. --- use-crystallize/skills/payments/SKILL.md | 807 ++++++++++++++---- .../skills/payments/references/adyen.md | 663 +++++++++----- .../skills/payments/references/dintero.md | 510 +++++++---- .../skills/payments/references/klarna.md | 736 +++++++++++----- .../skills/payments/references/mollie.md | 489 +++++++++++ .../skills/payments/references/montonio.md | 530 +++++++----- .../skills/payments/references/qliro.md | 676 ++++++++++----- .../skills/payments/references/quickpay.md | 506 ++++++----- .../skills/payments/references/razorpay.md | 550 +++++++----- .../skills/payments/references/stripe.md | 517 ++++++----- .../skills/payments/references/two.md | 470 ++++++++++ .../payments/references/vipps-mobilepay.md | 621 ++++++++------ 12 files changed, 5037 insertions(+), 2038 deletions(-) create mode 100644 use-crystallize/skills/payments/references/mollie.md create mode 100644 use-crystallize/skills/payments/references/two.md diff --git a/use-crystallize/skills/payments/SKILL.md b/use-crystallize/skills/payments/SKILL.md index 7c2d1c8..e167243 100644 --- a/use-crystallize/skills/payments/SKILL.md +++ b/use-crystallize/skills/payments/SKILL.md @@ -1,250 +1,683 @@ --- 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 order with a payment provider — Stripe, Adyen, Klarna, Qliro, Dintero, + Vipps MobilePay, Mollie, Montonio, QuickPay, Two or Razorpay. Covers the whole payment step of a Shop + API checkout: locking (placing) the cart before charging, creating the provider session or payment + intent from the placed cart, redirect or embedded checkout, verifying webhooks and callbacks, creating + exactly one order per cart, recording payments, and capture, refund and cancellation driven by + fulfilment pipelines. 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, stop duplicate or + unpaid orders, prevent a shopper from changing the cart after paying (two tabs, back button), capture + on shipment, or refund an order. Trigger on "payment", "payment provider", "payment gateway", "PSP", + "checkout payment", "webhook", "callback", "capture", "refund", "authorize", "payment intent", + "place cart", "lock the cart", "double payment", "duplicate order", "paymentStatus", "addPayments", + "setPayments", "createFromCart", "Stripe", "Adyen", "Klarna", "Qliro", "Dintero", "Vipps", "MobilePay", + "Mollie", "Montonio", "QuickPay", "Two", "Tillit", "Razorpay", "Checkout Session", "PaymentIntent", + "Drop-in", "Hosted Payment Page", "ePayment", "pickup point", "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 → lock → 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 + +The lock 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`**: takes the Shop API lock for the cart, creates the Core customer if needed, calls + `createFromCart` once, and waits until the cart shows `ordered`. +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. Cart reads and `place` use the official +`@crystallize/js-api-client`; the Shop API `/order` and `/lock` endpoints have no helper there, so +`shop()` calls them with a Shop API token that has the `cart`, `order` and `lock` scopes. ```ts // lib/crystallize-payments.ts +import { createCartManager, createClient } from "@crystallize/js-api-client"; + +const tenant = process.env.CRYSTALLIZE_TENANT_IDENTIFIER!; +const accessTokenId = process.env.CRYSTALLIZE_ACCESS_TOKEN_ID!; +const accessTokenSecret = process.env.CRYSTALLIZE_ACCESS_TOKEN_SECRET!; + +export const api = createClient({ tenantIdentifier: tenant, accessTokenId, accessTokenSecret }); +export const carts = createCartManager(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, +}; +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: { + identifier: true, + type: true, + email: true, + firstName: true, + lastName: true, + phone: true, + companyName: true, + taxNumber: true, + addresses: ADDRESS, + }, + meta: true, +}; +type Address = { type: "delivery" | "billing" | "other" } & Partial< + Record, string | 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 {} + +let token: { value: string; expiresAt: number } | undefined; +async function shopToken() { + if (token && token.expiresAt - Date.now() > 5 * 60_000) return token.value; + const res = await fetch(`https://shop-api.crystallize.com/@${tenant}/auth/token`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Crystallize-Access-Token-Id": accessTokenId, + "X-Crystallize-Access-Token-Secret": accessTokenSecret, + }, + body: JSON.stringify({ scopes: ["cart", "order", "lock"], expiresIn: 3600 }), + }); + const json = (await res.json()) as { success: boolean; token?: string; error?: string }; + if (!json.success || !json.token) throw new Error(`Shop API token: ${json.error}`); + token = { value: json.token, expiresAt: Date.now() + 3600_000 }; + return token.value; +} + +export async function shop(endpoint: "cart" | "order" | "lock", query: string, variables = {}): Promise { + const res = await fetch(`https://shop-api.crystallize.com/@${tenant}/${endpoint}`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${await shopToken()}` }, + body: JSON.stringify({ query, variables }), + }); + const json = (await res.json()) as { data?: T; errors?: { message: string }[] }; + if (!res.ok || json.errors?.length || !json.data) { + throw new Error(`Shop API /${endpoint}: ${JSON.stringify(json.errors ?? res.status)}`); + } + return json.data; +} + +type CartCustomer = { + identifier: string | null; + isGuest: boolean; + type: "individual" | "organization" | null; + firstName: string | null; + lastName: string | null; + email: string | null; + phone: string | null; + companyName: string | null; + taxNumber: string | null; + addresses: Record[] | null; +}; +type CartState = { id: string; state: "cart" | "placed" | "ordered" | "abandoned"; customer: CartCustomer | null }; + +export async function readCart(id: string) { + const { cart } = await shop<{ cart: CartState | null }>( + "cart", + `query($id: UUID) { cart(id: $id) { id state customer { identifier isGuest type firstName lastName email + phone companyName taxNumber addresses { type firstName lastName street street2 streetNumber postalCode + city state country phone email } } } }`, + { id }, + ); + return cart; +} + 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; + }[] + | null; }; - export async function readOrder(id: string) { - const data = await shop<{ order: OrderRead | null }>( + const { order } = await shop<{ order: OrderRead | null }>( "order", - `query($id: UUID!) { order(id: $id) { id coreId paymentStatus payments { provider transactionId amount } } }`, + `query($id: UUID!) { order(id: $id) { id coreId payments { provider method transactionId amount createdAt meta } } }`, { id }, - ).catch(() => ({ order: null })); - return data.order; + ).catch(() => ({ order: null })); // not readable yet right after createFromCart + return order; } -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 - +/** Serialise everything that writes one cart's order: concurrent webhooks, capture vs refund. */ +export async function withCartLock(cartId: string, work: () => Promise): Promise { + const key = `order:${cartId}`; + const { acquire } = await shop<{ acquire: boolean | null }>( + "lock", + `mutation($key: String!) { acquire(key: $key, ttl: 60) }`, + { key }, + ); + if (!acquire) throw new RetryLater(`cart ${cartId} is being processed`); 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 shop("lock", `mutation($key: String!) { release(key: $key) }`, { 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 }, - ); +/** 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" }] +) { + return withCartLock(cartId, async () => { + 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 shop( + "order", + `mutation($id: UUID!, $input: OrderFromCartInput) { createFromCart(id: $id, input: $input) { id } }`, + { id: cartId, input: { type: "standard", paymentStatus, payments: [payment], pipelines } }, + ); + // createFromCart answers before it moves the cart to `ordered`: keep the lock until it has. + 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 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) })), +}); + +/** Refunds: append a record unless this transactionId is already on the order. */ +export const recordPayment = (orderId: string, payment: Payment) => + withCartLock(orderId, () => addRecord(orderId, payment, false)); + +async function addRecord(orderId: string, payment: Payment, isAnotherCharge: boolean) { 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; + 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 shop( "order", - `mutation($id: UUID!, $payments: [OrderPaymentInput!]!) { - addPayments(id: $id, payments: $payments) { id } }`, - { id: orderId, payments: [payment] }, + `mutation($id: UUID!, $p: [OrderPaymentInput!]!) { addPayments(id: $id, payments: $p) { id } }`, + { id: orderId, p: [record] }, ); } -// 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 } }, +/** 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) { + return withCartLock(orderId, async () => { + 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 shop( + "order", + `mutation($id: UUID!, $p: [OrderPaymentInput!]!) { setPayments(id: $id, payments: $p) { id } }`, + { id: orderId, p: payments }, + ); + }); +} + +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 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 lookup = `query($identifier: String!) { customer(identifier: $identifier) { ... on Customer { identifier } } }`; + const found = await api.nextPimApi<{ customer: { identifier?: string } | null }>(lookup, { + identifier: customer.identifier, + }); + if (found.customer?.identifier) return; + const { createCustomer } = await api.nextPimApi<{ + createCustomer: { identifier?: string; errorName?: string; message?: string }; + }>( + `mutation($input: CreateCustomerInput!) { createCustomer(input: $input) { + ... on Customer { identifier } ... on BasicError { errorName message } } }`, + { + input: { + identifier: customer.identifier, + type: customer.type ?? (customer.companyName ? "organization" : "individual"), + firstName: customer.firstName, + lastName: customer.lastName, + email: customer.email ?? customer.identifier, + phone: customer.phone, + companyName: customer.companyName, + taxNumber: customer.taxNumber, + addresses: customer.addresses?.map( + ({ + type, + firstName, + lastName, + street, + street2, + streetNumber, + postalCode, + city, + state, + country, + phone, + email, + }) => ({ + type, + firstName, + lastName, + street, + street2, + streetNumber, + postalCode, + city, + state, + country, + phone, + email, + }), + ), + }, + }, ); + if (createCustomer.errorName) { + const again = await api.nextPimApi<{ customer: { identifier?: string } | null }>(lookup, { + identifier: customer.identifier, + }); + if (!again.customer?.identifier) throw new Error(`createCustomer: ${createCustomer.message}`); // a race is fine + } +} +``` + +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 +} +``` + +## 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) + ], } ``` -`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. +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. -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. +### 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 + } +} +``` + +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 +685,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..492013b 100644 --- a/use-crystallize/skills/payments/references/dintero.md +++ b/use-crystallize/skills/payments/references/dintero.md @@ -1,80 +1,80 @@ # 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: under the cart lock, 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 keep the cart lock around it. ```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, withCartLock, 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 +82,343 @@ 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 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, + 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) { + return withCartLock(placed.id, async (): Promise<{ sid: string } | { paid: true }> => { + 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: `${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 }; + { "Dintero-Feature-Toggles": "strict-session-amounts" }, + ); + return { sid: session.id }; + }); // a concurrent click gets RetryLater: answer 409 and let the button retry } ``` -- 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 `