Как написать платёжный плагин
English version: plugin-authoring.md
Плагин — единственное место, которое знает, как разговаривает конкретный платёжный провайдер. Всё остальное — движок, форма, экран аутентификации, страницы результата — написано один раз и переиспользуется всеми интеграциями.
Контракт — это четыре метода. Основная работа — решить, как выразить то, что имеет в виду ваш провайдер.
Форма плагина
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 | номер, срок, код | собственная форма чекаута |
token | id сохранённой ранее карты | ваш API, в прошлый визит |
hosted_session | id начатой вами сессии | ваш 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 «для логов».
Докажите
Каждый плагин проходит один и тот же набор:
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, кошельки и банковский эквайринг и какое действие под это собрать. Дальше — гайды по регионам: Европа, Америка, Азия.
Регистрация
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), который никто не скачает, пока провайдера не выберут. Регистрация заодно проверяет, что у хоста есть раннеры под все действия, которые вы можете вернуть: отсутствие раннера станет ошибкой на старте, а не сюрпризом посреди платежа.