Skip to content

Как написать платёжный плагин ​

English version: plugin-authoring.md

Плагин — единственное место, которое знает, как разговаривает конкретный платёжный провайдер. Всё остальное — движок, форма, экран аутентификации, страницы результата — написано один раз и переиспользуется всеми интеграциями.

Контракт — это четыре метода. Основная работа — решить, как выразить то, что имеет в виду ваш провайдер.

Форма плагина ​

ts
import type { PaymentProvider, ProviderContext, PaymentProviderInstance } from '@checkout-kit/core'
import { createHttpClient } from '@checkout-kit/core/http'

export interface AcmeConfig {
  readonly baseUrl: string
  readonly apiKey: string
}

// Объявляем конфиг системе типов хоста, чтобы `defineProvider({ id: 'acme', ... })`
// проверялся, хотя код плагина хост не импортирует.
declare module '@checkout-kit/core' {
  interface ProviderConfigRegistry {
    acme: AcmeConfig
  }
}

export const acmeProvider: PaymentProvider<AcmeConfig> = {
  id: 'acme',
  displayName: 'Acme Payments',
  capabilities: {
    instruments: ['card'],
    actions: ['redirect'],
    surfaces: ['iframe', 'top'],
    authentication: ['none', '3ds2'],
    session: 'lazy',
    cancel: true,
    poll: false,
    idempotency: 'header',
  },
  create: (ctx: ProviderContext<AcmeConfig>): PaymentProviderInstance => {
    const http = createHttpClient({ baseUrl: ctx.config.baseUrl, fetch: ctx.fetch })
    return {
      createIntent: async (input, opts) => {
        /* ... */
      },
      confirm: async (intentId, instrument, opts) => {
        /* ... */
      },
      resume: async (intentId, evidence, opts) => {
        /* ... */
      },
      getIntent: async (intentId, opts) => {
        /* ... */
      },
      cancel: async (intentId, opts) => {
        /* ... */
      },
    }
  },
}

export default acmeProvider

Экспортируйте провайдер ещё и как экспорт по умолчанию: хост регистрирует его как load: () => import('@checkout-kit/provider-acme'), и реестр сам достаёт экспорт по умолчанию.

Четыре глагола ​

createIntent начинает платёж и возвращает то, что домен называет PaymentIntent. Если вашему API нужно два round-trip'а, чтобы его получить — зарегистрировать заказ, потом прочитать его обратно, — делайте оба здесь. Движку всё равно, во сколько запросов обходится глагол.

confirm предъявляет инструмент. Либо сразу завершает платёж, либо отвечает requires_action с тем, что должно произойти дальше.

Инструмент бывает пяти видов, и capabilities.instruments должен перечислять ровно те, что вы принимаете: по нему чекаут строит форму, и контрактный набор проверяет обе стороны.

ВидЧто несётКто собирает
cardномер, срок, кодсобственная форма чекаута
tokenid сохранённой ранее картываш API, в прошлый визит
hosted_sessionid начатой вами сессииваш API
walletданные из SDK кошелькакошелёк, если он у хоста
noneничегосовсем другое место

Вид, который вы не принимаете, отклоняйте с кодом unsupported_instrument. Это единственный код ошибки, который читают движок и контрактный набор: любой другой означает «не подошёл конкретный инструмент», а это другое.

Сохранённую карту стоит назвать отдельно — на ней легко ошибиться: это token, в браузере лежат id и последние четыре цифры, а дальше платёж идёт ровно как с набранной картой — его могут отклонить, и банк по-прежнему может попросить аутентификацию.

resume продолжает, когда действие завершилось, и получает сырое evidence от раннера. Здесь живёт смысл вашего протокола. transStatus со значением Y, orderStatus 6, блоб PaRes, токен кошелька — всё это читается тут и больше нигде. Поэтому банк с 3-D Secure 1 и процессинг с 3-D Secure 2 приводят к одному и тому же экрану.

getIntent перечитывает платёж. Движок вызывает его всякий раз, когда одного evidence недостаточно, — то есть почти всегда.

cancel необязателен; объявляйте capabilities.cancel, только если реализовали его.

Действия: как попросить следующий шаг ​

Верните requires_action с PaymentAction, и движок найдёт для него раннер. Пяти видов хватает на все интеграции в этом репозитории:

ВидКогда использоватьКакое evidence вернётся
redirectпокупателю нужно уйти — во фрейм или во всё окноpost_message или return_url
collect_fieldsваши поля показываются внутри страницы оплатыpost_message
sdk_handoffплатёж ведёт ваш скриптsdk_callback
displayпоказать QR или код — платят в другом приложенииpoll
pollпоказывать нечего, ответ придёт позжеpoll

Два поля делают больше, чем кажется:

  • surface — предпочтение, а не требование. Хост может выполнить то же действие во фрейме или во всём окне, и раннер вернёт подходящее evidence.
  • returnUrlField называет поле для абсолютного URL возврата (TermUrl, returnUrl — как принято у вашего API). Не собирайте этот URL сами: базу знает только хост, и собранный вручную URL ломается на деплое из подпути.

Ставьте completion.correlationField, если провайдер возвращает идентификатор транзакции под своим именем (challengeId, MD). Это то, что не даёт устаревшему сообщению от прошлой попытки завершить текущий платёж.

display — платёж завершается там, где мы не видим ​

PIX, UPI, BLIK, PromptPay, Konbini: покупателю показывают код, а платит он в другом приложении. Из браузера этого не увидеть, поэтому completion здесь всегда { via: 'poll', intervalMs, timeoutMs }.

Движок делает два дела сразу: запускает раннер, который показывает код и ждёт, и с вашим интервалом спрашивает getIntent. Кто закончил первым — останавливает другого: покупатель может уйти, а истёкший код перестаёт опрашиваться, а не опрашивается вечно. Если выиграл опрос, приходит evidence { via: 'poll' }, и ваш resume перечитывает платёж и говорит, чем всё кончилось.

value заполняйте всегда: покупатель, который читает страницу с того же телефона, не может отсканировать собственный экран. imageUrl — если провайдер сам рисует QR (своего кодировщика QR в наборе нет), deeplink — если провайдер его даёт.

Правила, которые типами не выразить ​

confirm и resume никогда не бросают. Сбой сети, кривой ответ, неизвестный статус — всё возвращается как { status: 'error' }. Эти два метода сообщают, что случилось с деньгами, и брошенное исключение лишает вызывающего возможности это сказать. createIntent, getIntent и cancel могут реджектить — движок сам превратит это в результат-ошибку.

Evidence — подсказка, а не истина. То, что пришло после перенаправления или через postMessage, говорит, где побывал браузер. Ушли ли деньги, знает только ваш API — спросите его. Плагин платёжной страницы игнорирует status=success в URL возврата и перечитывает заказ. Есть e2e-тест, который возвращается с сообщением «успех», ничего не заплатив, и попадает на страницу отказа.

Возвращайте сообщение эмитента, а не своё. Покупатель может зачитать вашу строку оператору банка. «Your card was declined.» — это слова эмитента; «Unexpected status declined» — баг, который однажды уехал в прод.

Отклонённая карта — не ошибка. Одни API сообщают отказ успешным вызовом со статусом, другие — HTTP-ошибкой. И то и другое маппится в declined. error — только когда сломались вы или сеть и никто не знает, что стало с платежом.

Capabilities — для валидации и текстов. Хост по ним проверяет наличие нужных раннеров и решает, показывать ли форму карты. Не давайте чекауту решать по ним, что требуется платежу: это решает эмитент в момент транзакции, и единственный надёжный сигнал — действие, которое вы вернули.

Не возвращайте данные инструмента. Ничто из возвращаемого не должно содержать номер карты — в том числе в поле detail «для логов».

Докажите ​

Каждый плагин проходит один и тот же набор:

ts
import { describeProviderContract } from '@checkout-kit/conformance'
import { acmeHandlers } from './test-backend'
import { SCENARIO_CARDS, declineMessage } from '@checkout-kit/testing'
import { acmeProvider } from './provider'

describeProviderContract({
  provider: acmeProvider,
  config: { baseUrl: 'http://acme.test', apiKey: 'test' },
  handlers: acmeHandlers,
  declineMessage: declineMessage(),
  instrumentFor: (testCase) => card(SCENARIO_CARDS[testCase]),
  evidenceFor: (action, outcome) => ({
    via: 'post_message',
    actionId: action.id,
    origin: 'https://acs.test',
    data: { transStatus: outcome === 'pass' ? 'Y' : 'N' },
  }),
})

Пятнадцать тестов. Они проверяют правила выше, а не вашу реализацию: отказ несёт сообщение эмитента; evidence для действия, которого вы не выдавали, ничего не подтверждает; повторный resume отвечает одинаково дважды, а не бросает; авария превращается в результат, а не в исключение; за объявленной capability есть метод; номер карты не возвращается наружу.

Если тест падает, а утверждение кажется неверным для вашего провайдера — скажите. Набор описывает то, на что вправе рассчитывать движок и UI, и интеграция, которая этого не даёт, — та, которой они не смогут безопасно пользоваться.

Разобранные примеры ​

Настоящие провайдеры на нашем контракте показывают, что возвращают Stripe, Adyen, PayPal, кошельки и банковский эквайринг и какое действие под это собрать. Дальше — гайды по регионам: Европа, Америка, Азия.

Регистрация ​

ts
import { defineProvider } from '@checkout-kit/core'
import type { AcmeConfig } from '@checkout-kit/provider-acme'

defineProvider({
  id: 'acme',
  config: { baseUrl: '/api/acme', apiKey } satisfies AcmeConfig,
  load: () => import('@checkout-kit/provider-acme'),
})

Импорт типа стирается при сборке. Динамический импорт кладёт код плагина в отдельный файл сборки (chunk), который никто не скачает, пока провайдера не выберут. Регистрация заодно проверяет, что у хоста есть раннеры под все действия, которые вы можете вернуть: отсутствие раннера станет ошибкой на старте, а не сюрпризом посреди платежа.

Apache-2.0 · Headless-движок, адаптеры провайдеров и запускаемые примеры.