Przejdź do głównej zawartości

Pre-selekcja instrumentów płatności

Klient wybiera konkretny instrument płatności na storefront (BLIK kod, mBank, Apple Pay) i ląduje BEZPOŚREDNIO na ekranie wybranego instrumentu na hosted gateway page — zamiast default landing z listą wszystkich metod. Branżowy standard skraca funnel o 5-15% na single-instrument checkout flows.

Use case

Storefront ma w panelu wyboru metody płatności tile per instrument:

┌──────────────────────────────────────┐
│ BLIK │
│ ┌────────────┐ ┌────────────┐ │
│ │ 🟦 BLIK kod│ │ 📱 BLIK │ │
│ │ │ │ Mobile │ │
│ └────────────┘ └────────────┘ │
│ │
│ Karty │
│ ┌────────────┐ ┌────────────┐ │
│ │ 💳 Karta │ │ 🍎 Apple │ │
│ │ │ │ Pay │ │
│ └────────────┘ └────────────┘ │
│ │
│ Banki │
│ ┌────────────┐ ┌────────────┐ │
│ │ 🟦 mBank │ │ 🟠 ING │ │
│ │ │ │ │ │
│ └────────────┘ └────────────┘ │
└──────────────────────────────────────┘

Klient klika "BLIK kod" → wybrany instrument zostaje utrwalony na koszyku (Cart.selectedPaymentInstrument = 'blik') i przeniesiony na zamówienie przy cartCompletepaymentCreate → bramka przekierowuje bezpośrednio na ekran wpisywania kodu BLIK (bez listy 6 metod do wyboru). Conversion improvement mierzalny w analytics.

Gdzie żyje wybrany instrument

Storefront API NIE wystawia queryowalnego pola Order.paymentInstrumentCode. Wybór instrumentu jest intencją przedpłatniczą utrwaloną na koszyku w polu Cart.selectedPaymentInstrument (String). Backend kopiuje ją na zamówienie przy cartComplete, a paymentCreate używa jej do deep-linku bramki. Z perspektywy storefrontu jedyne queryowalne pole to Cart.selectedPaymentInstrument.

SDK API

Renderowanie instrument tiles

Listę metod płatności (z zagnieżdżonymi instrumentami) pobierasz przez CartClient.getAvailablePaymentMethods(). W warstwie React dostęp do klienta dajesz przez useStorefrontClient():

import { useQuery } from '@tanstack/react-query';
import { CartClient } from '@doswiftly/storefront-sdk';
import { useStorefrontClient } from '@doswiftly/storefront-sdk/react';

const client = useStorefrontClient();

const { data } = useQuery({
queryKey: ['availablePaymentMethods'],
queryFn: () => new CartClient(client).getAvailablePaymentMethods(),
});

// data = { methods, defaultMethod } (raw payload z backendu)
const blik = data?.methods.find((m) => m.type === 'BLIK');
// blik.instruments = [{ provider, code, displayName, type, displayHint, brandImage, enabled }, ...]

Kształt operacji availablePaymentMethods:

AvailablePaymentMethodsqueryPayment Methods
query AvailablePaymentMethods

Returns the active payment methods for the shop, sorted by the merchant-configured display position. Shop-level — does NOT vary by cart amount or currency. Each method exposes `type` (`CARD`, `BANK_TRANSFER`, `BLIK`, `PAYPAL`, `APPLE_PAY`, `GOOGLE_PAY`, `CASH_ON_DELIVERY`, `OTHER`), provider, icon, description, and supported currencies. Use to render the payment step of checkout.

GraphQL operation
query AvailablePaymentMethods {
availablePaymentMethods {
...AvailablePaymentMethods
}
}
Uses fragments: AvailablePaymentMethods

PaymentMethod.instruments field jest nullable — null gdy żaden provider nie expose'uje granular data dla tej method, [] gdy live data ale post-filter empty (cross-provider leak prevention), array gdy gateway expose'uje instrumenty.

Każdy PaymentInstrument carries:

FieldTypeSemantyka
providerPaymentProvider (enum)PAYU / PRZELEWY24 / STRIPE / etc. (UPPERCASE)
codeString! (opaque)Gateway-specific identyfikator ('blik', 'mb', '154')
displayNameString!User-facing name (z gateway response lub backend enriched per known instruments)
typePaymentInstrumentType (enum)Sub-kategoria — BANK / BLIK_CODE / CARD_BRAND / OTHER / WALLET
displayHintPaymentInstrumentDisplayHint (enum)UX hint — BRANDED_TILE / DROPDOWN_OPTION / PROMINENT_BUTTON / RADIO_OPTION
brandImageImage (nullable)Opcjonalny obrazek marki — selekcja brandImage { url(transform: { maxWidth: 64 }) altText }. null gdy bramka nie wystawia logotypu (np. kod BLIK)
enabledBoolean!Gateway-side disable flag — gray-out tile, NIE hide

Pre-selekcja instrumentu

import { useCartManager } from '@doswiftly/storefront-sdk/react';

const { selectPaymentMethod } = useCartManager();

// Klient kliknął BLIK kod tile (cartId dorzuca hook — nie podajesz go sam)
await selectPaymentMethod({
methodType: 'BLIK',
preferredProvider: 'PAYU', // PaymentProvider enum (UPPERCASE) — wymagany gdy preferredInstrument set
preferredInstrument: 'blik', // gateway-specific code (PaymentInstrument.code)
});

// Backend utrwala wybór na koszyku — pole Cart.selectedPaymentInstrument
// round-trip w cart query.

Kształt mutacji cartSelectPaymentMethod:

CartSelectPaymentMethodmutationCart Completion Mutations
mutation CartSelectPaymentMethod($input: CartSelectPaymentMethodInput!)

Selects a payment method on the cart by category (`methodType` — BLIK, CARD, BANK_TRANSFER, ...). Optional `preferredProvider` overrides the merchant priority when the buyer explicitly picks a gateway from `PaymentMethod.providersAvailable`. The backend resolves the gateway routing from `MerchantPaymentConfig` at `cartComplete`; the selection persisted here is the category. Errors: `PAYMENT_METHOD_REQUIRED`, `CART_NOT_FOUND`.

Variables

NameTypeDefaultRequired
$inputCartSelectPaymentMethodInput!Yes
GraphQL operation
mutation CartSelectPaymentMethod($input: CartSelectPaymentMethodInput!) {
cartSelectPaymentMethod(input: $input) {
cart {
...Cart
}
userErrors {
...UserError
}
warnings {
...CartWarning
}
}
}
Uses fragments: Cart, CartWarning, UserError

Backend cross-checkuje preferredInstrument przeciw live capabilities PRZED utrwaleniem — jeśli instrument NIE jest w aktualnych instrumentach providera (gateway disable, merchant config update), zwracany jest userError, a stan koszyka NIE jest zmieniany.

import { useCartManager } from '@doswiftly/storefront-sdk/react';

const { createPayment } = useCartManager();

try {
// createPayment przyjmuje OBIEKT PaymentCreateInput, nie goły orderId
const session = await createPayment({
orderId, // z complete().order.id
returnUrl: 'https://sklep.pl/checkout/confirm',
cancelUrl: 'https://sklep.pl/checkout/payment',
});
// Rozgałęź na session.flow — patrz koszyk → "Inicjacja płatności"
if (session.flow === 'ONLINE_REDIRECT' && session.redirectUrl) {
window.location.href = session.redirectUrl; // bezpośrednio na ekran BLIK
}
} catch (err) {
// Patrz error handling poniżej
}

Kształt mutacji paymentCreate:

PaymentCreatemutationCart Completion Mutations
mutation PaymentCreate($input: PaymentCreateInput!)

Initiates a payment session for an order created by `cartComplete` — call this when `order.canCreatePayment` is `true` (orders with an offline payment method like cash-on-delivery skip this step). The session charges the order's **outstanding balance** (total minus settled captures plus settled refunds), not necessarily the full total — a gift-card-covered part or an earlier partial payment is never charged twice; pending (unfinished) sessions do not reduce the balance. `orderId` is required; `returnUrl` / `cancelUrl` are optional and, when supplied, must point to a verified domain of the shop (open-redirect protected). Branch on the returned `payment.flow`: `ONLINE_REDIRECT` → redirect to `payment.redirectUrl`, `ONLINE_EMBEDDED` → render a widget with `payment.clientSecret`, `INSTANT_DIRECT` → already settled, read `payment.status`. Public, but ownership-checked — an authenticated customer cannot pay for another customer's order. **Idempotent** — calling it again for the same order returns the existing still-valid session **as long as its amount still equals the outstanding balance**; when the balance changed in the meantime (e.g. the shop edited the order items), a fresh session on the current balance is created instead (safe to retry either way). Rate limit: 5 requests/minute. `userErrors[].code`: `ORDER_NOT_FOUND`, `ORDER_ALREADY_PAID`, `ORDER_NOT_PAYABLE`, `PAYMENT_PROVIDER_NOT_CONFIGURED`, `RETURN_URL_INVALID`, `INVALID_ID_FORMAT`, `PAYMENT_FAILED`, `INSTRUMENT_PRESELECTION_FAILED`. `warnings[]` array zawiera `INSTRUMENT_CLEARED_FOR_RETRY` post-auto-clear instrument-specific failure (storefront retry hint: next attempt uses gateway default landing).

Variables

NameTypeDefaultRequired
$inputPaymentCreateInput!Yes
GraphQL operation
mutation PaymentCreate($input: PaymentCreateInput!) {
paymentCreate(input: $input) {
payment {
...PaymentSession
}
userErrors {
...UserError
}
warnings {
...PaymentWarning
}
}
}

Error handling — distinct retry semantics

Backend rozróżnia 2 ścieżki failure:

const paymentInput = { orderId, returnUrl, cancelUrl };

try {
const session = await createPayment(paymentInput);
if (session.flow === 'ONLINE_REDIRECT' && session.redirectUrl) {
window.location.href = session.redirectUrl;
}
} catch (err) {
if (err.userErrors?.[0]?.code === 'INSTRUMENT_PRESELECTION_FAILED') {
// Instrument-specific issue — backend auto-wyczyścił wybrany instrument na zamówieniu.
// Kolejny paymentCreate trafi na gateway default landing.
// err.warnings[] zawiera entry { code, message, retryHint }
showRetryUI({
title: 'Instrument płatności niedostępny',
message: err.warnings?.[0]?.message ?? err.message,
onRetry: () => createPayment(paymentInput), // instrument już wyczyszczony, default landing
});
} else if (err.userErrors?.[0]?.code === 'PAYMENT_FAILED') {
// Generic gateway outage (credentials, network, 5xx, circuit breaker)
// Wybrany instrument POZOSTAJE — retry z tym samym instrumentem
showOutageUI({
message: err.message,
onRetry: () => createPayment(paymentInput), // retry same instrument
});
} else if (err.userErrors?.[0]?.code === 'ORDER_ALREADY_PAID') {
// Idempotency edge — order opłacony w międzyczasie (webhook race)
redirectToOrderConfirmation();
}
}

warnings[] array w PaymentCreatePayload jest non-blocking sygnałem (split z userErrors[] per common e-commerce convention). Backend emit'uje warning entry post-auto-clear żeby storefront mógł:

  • Wyświetlić context-aware UI message ("instrument zostal wyczyszczony, kolejna próba bramki pokaże wszystkie dostępne metody").
  • Dispatchować analytics event distinct od generic gateway failure.
  • Decydować retry button placement / progress bar / accordion reset.

displayHint enum dispatching

Każdy instrument carries displayHint semantic UX hint — storefront-dev branchuje rendering per hint:

function InstrumentTile({ instrument, selected, onClick }: InstrumentTileProps) {
return (
<button
onClick={onClick}
aria-pressed={selected}
aria-label={instrument.displayName}
className={cn('instrument-tile', selected && 'selected')}
>
{/* Per-displayHint rendering — storefront decides UX */}
{instrument.displayHint === 'PROMINENT_BUTTON' && (
<span>{instrument.displayName}<br/><small>Duże CTA</small></span>
)}
{instrument.displayHint === 'BRANDED_TILE' && (
<span>{instrument.displayName}<br/><small>Kafelek z logo marki</small></span>
)}
{instrument.displayHint === 'RADIO_OPTION' && (
<span>{instrument.displayName}<br/><small>Wiersz radio</small></span>
)}
{instrument.displayHint === 'DROPDOWN_OPTION' && (
<span>{instrument.displayName}</span>
)}
</button>
);
}

Wartości PaymentInstrumentDisplayHint: BRANDED_TILE | DROPDOWN_OPTION | PROMINENT_BUTTON | RADIO_OPTION.

Pre-built headless components

SDK eksportuje gotowe headless React components — eliminacja boilerplate'a + accessibility-by-default. Components nie mają własnego stylingu — pass className per part (button, icon, label).

<PaymentInstrumentTile>

Single instrument button z ARIA role="radio", aria-checked, data-instrument-code, data-display-hint. PROMINENT_BUTTON displayHint ukrywa brand image (BLIK code entry). Props instrumentu to Pick z PaymentInstrument: code, displayName, displayHint, enabled + opcjonalny brandImage.

import { PaymentInstrumentTile } from '@doswiftly/storefront-sdk/react';

<PaymentInstrumentTile
instrument={{
code: 'blik',
displayName: 'BLIK',
displayHint: 'PROMINENT_BUTTON',
enabled: true,
}}
selected={selectedCode === 'blik'}
onSelect={() => setSelectedCode('blik')}
className="rounded border p-3 data-[selected=true]:border-blue-500"
labelClassName="font-semibold"
/>

displayHint jest emitowany jako data-display-hint attribute — stylizuj przez CSS attribute selectors:

button[data-display-hint='PROMINENT_BUTTON'] { /* duże CTA */ }
button[data-display-hint='BRANDED_TILE'] { /* brand-led, image + label */ }
button[data-display-hint='RADIO_OPTION'] { /* radio-style row */ }
button[data-display-hint='DROPDOWN_OPTION'] { /* text-only */ }

<PaymentInstrumentSection>

Radio-group container z keyboard nav (ArrowDown/Right/Up/Left wrap, Home/End jump) per WAI-ARIA radiogroup pattern. Renderuje jeden tile per instrument w order otrzymanym z backendu (zero own resort — backend zwraca instrumenty w ustalonym porządku: BLIK + portfele, potem banki alfabetycznie, potem reszta).

import { PaymentInstrumentSection, useCartManager } from '@doswiftly/storefront-sdk/react';
import { useState } from 'react';

function CheckoutPaymentStep({ method }: { method: PaymentMethod }) {
const [instrumentCode, setInstrumentCode] = useState<string | undefined>(undefined);
const { selectPaymentMethod } = useCartManager();

return (
<PaymentInstrumentSection
method={method}
selectedInstrumentCode={instrumentCode}
onSelectInstrument={(code) => {
setInstrumentCode(code);
selectPaymentMethod({
methodType: method.type,
preferredProvider: method.preferredProvider ?? undefined,
preferredInstrument: code,
});
}}
sectionClassName="grid grid-cols-2 gap-2"
tileClassName="rounded border p-3 hover:bg-gray-50 data-[selected=true]:border-blue-500"
labelClassName="font-semibold"
ariaLabel="Wybierz instrument płatności"
/>
);
}

Komponent zwraca null gdy instruments[] puste / missing — bezpieczne na method bez instrument-level data (soft providery jak cash on delivery).

Cart re-validation — PAYMENT_SELECTION_STALE

Backend re-walidaje selekcję płatności przy KAŻDYM Cart query względem live gateway capabilities (PayU /paymethods, P24 /payment/methods). Gdy method lub instrument znikło z gateway pomiędzy cartSelectPaymentMethod a query, SDK dostaje signal w Cart.warnings[]:

const { cart } = useCart(cartId);

const staleWarning = cart?.warnings?.find((w) => w.code === 'PAYMENT_SELECTION_STALE');

if (staleWarning) {
// Backend signaluje że klient wybrał metodę która znikła z gateway —
// pokaż dialog "wybierz inną metodę" pre-checkout (zamiast czekać na rejection
// przy paymentCreate). `target` (field path) rozróżnia czy method czy instrument:
if (staleWarning.target === 'selectedPaymentMethod') {
// cart.selectedPaymentMethod === null (downgrade do null)
// cart.selectedPaymentInstrument === null
showRePromptDialog({
title: 'Metoda płatności niedostępna',
message: staleWarning.message,
onSelectAgain: () => router.push('/checkout/payment'),
});
} else if (staleWarning.target === 'selectedPaymentInstrument') {
// cart.selectedPaymentMethod NADAL set (method-level OK)
// cart.selectedPaymentInstrument === null (instrument-level downgrade)
showRePromptDialog({
title: 'Wybrany instrument niedostępny',
message: staleWarning.message,
onSelectAgain: () => /* re-show instrument tiles for current method */,
});
}
}

CartWarning.target to ścieżka pola (String!) — wartość 'selectedPaymentMethod' lub 'selectedPaymentInstrument'. CartWarning.code to stabilny enum (branch na nim, NIE na message — przetłumaczony, locale-dependent).

Read-only signal — backend NIE clear'uje persistencji automatycznie. Storefront wywołuje cartClearPaymentSelection lub świeży cartSelectPaymentMethod(...) żeby zapisać re-selekcję.

Graceful degradation — gdy gateway live capability call zawiedzie (timeout, network outage), Cart zwraca existing selection bez warning. UX continuity nad freshness — cartComplete time-of-payment validation jest finalnym safety net (auto-clear + INSTRUMENT_PRESELECTION_FAILED).

Browser data dla 3DS flows

getBrowserDataForPayment() helper — collects PSD2/3DS2 browser context (userAgent, language, screen dims, color depth, timezone, javaEnabled fallback) w shape matchującym EMVCo 3DS2 BrowserData specification. Forward-looking utility — PaymentCreateInput na razie nie konsumuje browser data (przyjmuje tylko orderId + returnUrl? + cancelUrl?). Helper służy do wcześniejszego zbierania kontekstu po stronie przeglądarki; stanie się mandatory dopiero w przyszłych przepływach PSD2/SCA (card-on-file challenge, BLIK confirmation z risk scoring).

import { getBrowserDataForPayment, BrowserDataNotAvailableError } from '@doswiftly/storefront-sdk/react';

function handleCheckoutSubmit() {
try {
const browserData = getBrowserDataForPayment();
// browserData zebrane z przeglądarki — przekażesz je do bramki gdy
// PaymentCreateInput zacznie je przyjmować (przyszłe przepływy 3DS2).
await createPayment({ orderId, returnUrl, cancelUrl });
} catch (err) {
if (err instanceof BrowserDataNotAvailableError) {
// SSR / no DOM — pomiń zbieranie browser data
await createPayment({ orderId, returnUrl, cancelUrl });
return;
}
throw err;
}
}

Browser-only — throw BrowserDataNotAvailableError w SSR (Server Component, Route Handler). Wywołuj w event handler lub useEffect.

Explicit deselect — cartClearPaymentSelection

Accordion UI "wróć do wyboru metody" — atomic NULL na wszystkich payment selection fields w jednym round-trip:

const { clearPaymentSelection } = useCartManager();

// Klient kliknął "wróć do wyboru metody" — cartId dorzuca hook, nie podajesz go sam
await clearPaymentSelection();
// Cart.selectedPaymentMethod === null
// Cart.selectedPaymentInstrument === null

Kształt mutacji cartClearPaymentSelection:

CartClearPaymentSelectionmutationCart Completion Mutations
mutation CartClearPaymentSelection($input: CartClearPaymentSelectionInput!)

Clears all payment selection state on the cart in a single atomic operation. Use for accordion "back to method picker" flows w storefront UI. Idempotent — calling twice yields the same end state. Cart MUSI być `ACTIVE` (CONVERTED carts reject z `ALREADY_COMPLETED`). Rate limit: 30 requests/minute per IP+shop.

Variables

NameTypeDefaultRequired
$inputCartClearPaymentSelectionInput!Yes
GraphQL operation
mutation CartClearPaymentSelection($input: CartClearPaymentSelectionInput!) {
cartClearPaymentSelection(input: $input) {
cart {
...Cart
}
userErrors {
...UserError
}
warnings {
...CartWarning
}
}
}
Uses fragments: Cart, CartWarning, UserError

Idempotent — wielokrotne wywołanie = same final state. Cart MUSI być ACTIVE — CONVERTED carts reject z userErrors[0].code === 'ALREADY_COMPLETED'. Operacja nie ma dedykowanego limitu per operację — objęta jest wyłącznie ogólnym budżetem zapytań koszyka.

Migracja z wcześniejszego API

Wcześniejsze integracje storefrontu używały innego nazewnictwa płatności (3-warstwowy refaktor: provider / method / instrument):

  • cartSelectPaymentMethod({ preferredProviderCode })renamed do preferredProvider (clean break, NIE backward-compat alias).
  • cartSelectPaymentMethod({ preferredInstrumentCode })renamed do preferredInstrument.
  • PaymentMethod.provider === 'payu' string compare → typed enum PaymentProvider.PAYU (UPPERCASE). Enum ProviderCode przemianowany na PaymentProvider.
  • Typ PaymentMethodInstrument przemianowany na PaymentInstrument; pola instrumentCodecode, providerCodeprovider, brandImageUrl (String) → brandImage (Image — selekcja brandImage { url(transform: { maxWidth: 64 }) altText }).
  • Cart.selectedPaymentInstrumentCoderenamed do Cart.selectedPaymentInstrument (String).

Wartości enumów (po refaktorze):

  • PaymentProvider: PAYU | PRZELEWY24 | STRIPE | CASH_ON_DELIVERY | BANK_TRANSFER | MANUAL_PAYMENT | GIFT_CARD | TEST_GATEWAY.
  • PaymentMethodType: BANK_TRANSFER | BLIK | CARD | CASH_ON_DELIVERY | INSTALLMENT | OTHER | WALLET.
  • PaymentInstrumentType: BANK | BLIK_CODE | CARD_BRAND | OTHER | WALLET.
  • PaymentInstrumentDisplayHint: BRANDED_TILE | DROPDOWN_OPTION | PROMINENT_BUTTON | RADIO_OPTION.

Pełen migration guide w CHANGELOG @doswiftly/storefront-sdk.

Pozostałe elementy systemu pre-selekcji:

  • PaymentErrorCode.INSTRUMENT_PRESELECTION_FAILED distinct value.
  • PaymentCreatePayload.warnings: [PaymentWarning!]! field.
  • cartClearPaymentSelection mutation.
  • Cart.warnings: [CartWarning!]! field z runtime stale check (PAYMENT_SELECTION_STALE code, target = 'selectedPaymentMethod' lub 'selectedPaymentInstrument').
  • Cart.selectedPaymentMethod re-walidaje przy każdym query — null gdy method stale.
  • Cart.selectedPaymentInstrument re-walidaje przy każdym query — null gdy instrument stale (method preserved).
  • CartWarningCode.PAYMENT_SELECTION_STALE enum value.
  • <PaymentInstrumentTile> + <PaymentInstrumentSection> pre-built headless React components.
  • getBrowserDataForPayment() helper + BrowserDataNotAvailableError + PaymentBrowserData type dla PSD2/3DS2 flows.

Migration checklist:

  • Zmień preferredProviderCodepreferredProvider i preferredInstrumentCodepreferredInstrument w wywołaniach cartSelectPaymentMethod.
  • Zmień selekcję brandImageUrlbrandImage { url(transform: { maxWidth: 64 }) altText } (jeśli używasz custom GraphQL).
  • Zmień Cart.selectedPaymentInstrumentCodeCart.selectedPaymentInstrument w fragmentach koszyka.
  • Zmień string compare provider === 'payu' na typed enum PaymentProvider.PAYU (UPPERCASE).
  • Add warnings[] selection w paymentCreate query (jeśli używasz custom GraphQL bez SDK helpers).
  • Add warnings { message code target } selection w cart query (jeśli custom GraphQL).
  • Branch UI dispatch na userErrors[0].code === 'INSTRUMENT_PRESELECTION_FAILED' distinct od PAYMENT_FAILED.
  • Branch UI na cart.warnings[].code === 'PAYMENT_SELECTION_STALE' żeby pokazać pre-checkout re-prompt.
  • (Optional) Replace accordion-reset hack z cartClearPaymentSelection() — clearer API, idempotent.
  • (Optional) Replace custom instrument picker z <PaymentInstrumentSection> — saves boilerplate, full ARIA + keyboard nav.
  • (Forward-looking) Adopt getBrowserDataForPayment() w checkout submit handlers gdy 3DS flow staje się relevant.

Powiązane