Skip to content

Instantly share code, notes, and snippets.

@MrOnlineCoder
Last active February 19, 2024 20:40
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save MrOnlineCoder/3ad700e7e992c3c18150b084a2e56380 to your computer and use it in GitHub Desktop.
Save MrOnlineCoder/3ad700e7e992c3c18150b084a2e56380 to your computer and use it in GitHub Desktop.
💰 Інфраструктурні рецепти, частина 3: Прийом платежів і інтеграція з платіжними системами

💰 Інфраструктурні рецепти, частина 3: Прийом платежів і інтеграція з платіжними системами

Цей допис доступний як пост в Telegram: https://t.me/middle_outloud_ua/70

🧐 Розглянемо ситуацію, де після довгих місяців старань і неперевної роботи, Ви нарешті створили свій супер-продукт, і хочете почати заробляти на ньому - тобто, отримувати кошти за надання послуг своїм клієнтам. Це може бути як постійна підписка на якомусь медіа-сервісі, поповнення віртуального балансу, або просто банальна оплата замовлення в онлайн-магазині.

❔В цьому дописі намагатимусь надати відповідь на питання технічної реалізації даного аспекту, і опишу можливі підводні камені, на які інколи натикався сам.

Зауважу - надалі йде лонгрід, оскільки я розпочинаю з самих азів, бо хочу, щоб цей допис був повноцінним "мануалом" про те, як підключити оплату до свого додатку чи сайту. А ще тут не буде нічого про оплату через криптовалюту, бо це окрема тема 😉

☝️Ліричний відступ: навіть якщо ваш продукт представляє з себе якийсь софт, або тим паче веб-додаток, це все одно не зобовʼязує вас реалізовувати автономний прийом платежів на сайті або в додатку. Ви все ще можете приймати оплату за свої послуги звичайним методом банківського переказу по виставленому рахунку, а після отримання коштів - видавати потрібну послугу користувачу - будь-то продовження терміну дії аккаунту в системі, або нарахування якоїсь внутрішньої валюти. Такий метод наприклад властивий нішевим SaaS B2B-рішенням, і все ще досить популярний на європейських і американських ринках. В той же час, особисто вважаю, що якщо ви себе позиціонуєте як модерновий продукт, відсутність можливості онлайн оплати є моветоном, або я б навіть сказав "red flag"-ом. Найкращий варіант для SaaS - це надання обох варіантів, і клієнт сам обирає спосіб у який йому простіше оплачувати послуги.

А тепер до справи.

🤓 Перш за все, розпочну з самих азів і відповім на питання - "а як це так працює, що користувач вводить дані своєї карточки на сайті, і через хвилину, з його карточки списуються кошти, а на сайті мені активовується та чи інша послуга, або замовлення потрапляє на пакування?"

💡Відповідь - платіжні системи, вони же payment gateways, payment providers. Простими словами, це сервіси, які надають певний API для автономного прийому платежів на сайті і/або мобільному додатку. При цьому, Ви не забиваєте собі голову питаннями про процесинг платежів або інтеграцією з банком клієнту - це вся тяжка праця лежить на плечах платіжної системи, а вам надається уніфікований API і навіть частково фронт-енд.

Платіжних систем існує багато, відрізняються вони тарифами (більшість з них стягує певну комісію з кожного проведеного платежу), підтримуваними API/фічами, країнами, де вони працюють тощо.

Вибір платіжної системи залежить від потреб вашого бізнесу, ваших можливостей і вимог, проте я наведу приклади деяких відомих, з якими сам мав справу, а саме:

🇺🇦 В Україні - Liqpay (від Приватбанку), Wayforpay.

🌍 В світі (US/EU) - звичайно, Stripe - один з лідерів на ринку. Paddle.

🇨🇿 PayU - Чехія.

Stripe звичайно має найбільше функціоналу, проте вимагає юридичної особи в US або Європі.

🤓 Liqpay та Wayforpay надають плюс-мінус однаковий функціонал, проте перший мені особисто візуально подобається більше, хоч там і вимагається наявність рахунку в Приватбанку.

Думаю, що кожна платіжна система сьогодення, дозволяє приймати платежі з карт Visa/Mastercard з майже будь-якої країни світу, і відповідно це покриває 99.9% аудиторії споживачів.

👀 В свою чергу, більшість платіжних систем працюють по майже однаковому принципу, тому конкретний вибір буде мати мінімальний вплив на актуальність надалі описаної технічної реалізації.

❗️Але перед тим, як її розписувати, по класиці, закцентую увагу на важливості правильного розділення ролей/відповідальності в контексті бізнес-логіки. По суті своїй, прийом платежів в кодовій базі складається з двох частин:

  1. Бізнес логіки, яка надає користувачу послугу, за яку він заплатив. Ця частина абсолютно незалежна від платіжної системи, яку Ви обрали і не має містити жодних посилань на неї. Зазвичай, це функція/сервіс (application/domain layer), яка отримує на вхід дані про оплату - хто, коли і скільки - і відповідно корегує стейт системи. Наприклад, це може бути метод AccountService.extendAccountPeriod(accountId, days), або EconomyService.topupBalance(balanceId, amount). Надалі ці методи буде викликати безпосередня інтеграція з платіжною системою (пункт 2)
  2. Інфраструктурна інтеграція з платіжною системою. Тут вже мова йде про роботу API, формування і прийом HTTP запитів, тощо. Вона викликає метод бізнес логіки (попередній пункт) після (не)успішного проведення платежу.

Тобто, стрілочка залежностей з "чистої архітектури", свій напрямок не змінює:

PaymentGatewayProvider --- depends on ---> EconomyService

А тепер ще один важливий дисклеймер, вже навіть з трьома знаками оклику:

❗️❗️❗️При реалізації оплати в вашій системі і інтеграції з платіжною системою мова йтиме про реальні кошти і фінанси, від яких залежить дохід і фінансове становище або вашої компанії, або клієнта. В залежності від масштабів, помилки в цьому флоу, можуть коштувати вам нервових клітин, репутації, фінансів або навіть роботи в цілому. На вас, як на команді розробки, лежить відповідальність як в коректній реалізації, так і в тестуванні цього модуля в продукті. Юніт тести, е2е тести, мануал тести, лінтинг, код ревʼю, вбудовані safeguard-и - це все і інше по можливості має бути додано і використовуватись для мінімізації вище описаних "казусів".

🤯 Наведу приклад з свого досвіду, де ці застереження і бест-практіси були проігноровані і в продакшн пішов код з тривіальним багом - return був не в тому місці, де треба. Оплата на проекті проводилась на рахунок іншого бізнесу, якому через наш сайт звичайний споживач здійснював оплату. Відповідно, оплата проводилась в дві стадії - спочатку з рахунку споживача списувалась сума, а потім через деякий час або після деяких операцій в системі кошти переказувались на рахунок бізнесу - себто ми виступали таким собі посередником. От той проклятий "return" якраз був в коді, який виконував виплату бізнесу в другій стадії, який в результаті невдалого деплою взагалі не виконувався. І от тут основний прикол - якщо не завершити двох-стадійну оплату в платіжній системі, вона через умовні 14 днів автоматично виконає повернення коштів споживачу. По милості долі, на 10 день бізнес нам повідомив про дивну затримку зарахування коштів на його рахунок, що і в результаті дослідження викрило цю проблему. З новими сивими волосинами на голові, був написаний хот-фікс і скрипт, який завершив всі незакінчені двох-стадійні виплати, яких виявлось на декілька десятків тисяч гривень. Хеппі енд, після якого була проведена робота над помилками, яку вам сподіваюсь не потрібно буде робити.

🚀 Додам ще короткий порядок кроків в контексту продукту, а саме порядок підключення платіжної системи, який знову ж таки, в загальному всюди однаковий:

  1. Обираєте бажаний payment gateway
  2. Реєструєте свою торгову точку (мерчанта), ввівши дані про свою компанію, юридичну адресу, рахунок і т.д. Більшість платіжок дозволяють вам створювати декілька мерчантів в межах одного аккаунту.
  3. Ваш буде видано реквізити API для тестового середовища (sandbox/test environment). В ньому не відбувається реальне списання коштів, проте з технічної точки зору це зазвичай повна копія "бойового" (live) середовища, тому ви з легкістю можете протестувати на ньому свою інтеграцію.
  4. Ви розробляєте інтеграцію і активно її тестуєте.
  5. Після цього відправляєте запит на активацію вашого мерчанту і надання live API ключів, які будуть працювати у вас продакшені. Зазвичай це ручний процес, і відповідальна особа з сторони мерчанта буде перевіряти ваш сайт чи додаток. Окрім працюючого флоу оплати, вони зазвичай попросять у вас посилання на політику повернення товарів/послуг, оферту, політику конфіденційності чи інші подібні юридичні документи, тому будьте готові до цього.
  6. Коли ваш мерчант активований, Ви можете починати прийом оплати в продакшені.

В особистому кабінеті платіжної системи також можна бачити окрім різної аналітики, ще список всіх вдалих і невдалих транзакцій.

Тепер розглянемо флоу проведення окремого платежу. В більшості випадків, він не залежить від обраного середовища - будь-то test чи live.

  1. Користувач натискає "Оплатити" / "Купити" / "Поповнити" або іншу подібну кнопку
  2. Ваша система згідно певного алгоритму, описаного в документації платіжної системи, генерує посилання або HTML-форму (яка доречі може бути наперед вбудована в кнопку оплати), яка в результаті перекине користувача на сайт платіжки.
  3. Платіжна система матиме певну форму оплати (checkout), на якій користувач вводить дані від своєї кредитної картки. Часто на тій же формі доступні і інші методи оплати по типу Google Pay / Apple Pay.
  4. Після відправки форми з даними картки, платіжна система сама під капотом буде намагатись провести оплату і списання коштів через комунікацію з банком платника (ще це називають банком-емітента картки - той банк, що видав картку). При необхідності, платіжна система також може запросити користувача пройти 3DS перевірку (це коли Вам банк присилає підтвердження або SMS з кодом для конкретного платежу, щоб впевнитись, що це саме ви робите платіж).
  5. Платіжка перекидує користувача назад на ваш сайт. Адреса, на яку саме необхідно редіректнути користувача, налаштовується в мерчанті, при цьому зазвичай є так званий success_url, на який користувача закине в результаті успішної оплати, а є ще failure_url - URL для невдалої оплати. Цією кінцевою сторінкою може бути як окрема сторінка в стилі "Ваша оплата пройшла успішно", так і наприклад сторінка з умовним поточним балансом або тарифом користувача, яка вже на момент відкриття буде з оновленими даними.
  6. Протягом процесу оплати або щонайменше на його кінці після успішного або неуспішного проведення платежу, гейтвей відправляє на ваш сервер HTTP запит (він же Webhook) з усіма даними про платіж, включно з його поточним статусом. Зазвичай цей URL називається server_url і налаштовується в особистому кабінеті мерчанта.
  7. Ви отримуєте відповідно вебхук з статусом оплати. Якщо він успішний - проводите необхідні операції в своїй системі, викликаючи код з бізнес логіки, як описано було вище.

☝️ Саме завядки останнім 2 пунктам, ваша система отримує інформацію про проведення платежів і може прийняти рішення, як реагувати на ту чи іншу подію.

🚚 Яка інформація може бути передана в пункті 2 для генерації форми на оплату? Набір параметрів зазвичай схожий до наступного:

  • ідентифікатор вашого мерчанту
  • тип оплати (одноразове списання, двохстадійна оплата, регулярний платіж)
  • сума
  • валюта
  • ваш внутрішній ідентифікатор платежу (order_id) і/або додаткові метадані (metadata) - це може бути наприклад номер замовлення в вашій системі
  • опис товару або навіть список товарів, які купуються
  • для регулярних платежів - дата списання, періодичність
  • деякі системи - доступні методи оплати
  • більшість систем дозволяють перезаписувати success_url / failure_url / server_url для кожного окремого платежу, за бажанням

Давайте представимо, що є деяка платіжна система SkyrimPay, яку ми підключили до свого додатку. І розглянемо приклад форми на оплату, яку ми показуємо юзеру:

<form action="https://skyrimpay.com/api/v1/checkout" method="POST">
<input type="hidden" name="merchant_id" value="123456789"/>
<input type="hidden" name="amount" value="100"/>
<input type="hidden" name="currency" value="USD"/>
<input type="hidden" name="order_id" value="672626"/>
<input type="hidden" name="description" value="1x Dragonscale Sword"/>
<button type="submit">
 Buy
</button>
</form>

При її відправці, SkyrimPay створить платіж на своїй стороні в статусі а-ля "pending" і покаже користувачу сторінку з оплатою.

🔓 Проте досвідчений читач має задатись питанням - а що в цьому випадку заважатиме відредагувати форму, наприклад в DevTools, і підмінити значення суми з 100 долларів на 1 цент, і фактично отримати товар задаром?

Так ми плавно підійшли до суті приватного ключа і підпису.

❗️ Платіжна система при рестрації мерчанту видає пару API ключів - один публічний, і один приватний.

Публічний ключ зазвичай і є ідентифікатором мерчанту, і не є секретною інформацією. За його допомогою платіжна система може зрозуміти, в контексті якого мерчанту відбувається транзакція.

Приватний ключ є секретним і нікому не має розголошуватись. Зазвичай він з себе представляє довгу стрічку випадкових символів, наприклад: secret_live_h6KhDFOyZzAGkbAtxp300RhfWEGfm8LM

І ось саме ця пара ключів використовується для формування підпису (signature) для всіх ваших запитів в комунікації між вашою системою і платіжною системою. Цей підпис додається як параметр до всіх запитів, включно з наприклад формою вище, і будується в результаті застосування криптографічних алгоритмів (зазвичай хешування) і залежить від тіла (параметрів) запиту. Наприклад, підпис може формуватись так:

SHA256(merchant_id + amount + currency + order_id + private_key)

Де "+" це конкатенація стрічок, а private_key - ваш приватний ключ. Оскільки застосовується функція хешування (в цьому випадку SHA256) - будь-яка зміна аргументу (а відповідно будь-якого параметру) призводить до суттєвої зміни результату хеш-функції. Ну і нагадаю, знайти початкове значення (аргумент), маючи лише кінцеве значення хеш-функції дуже складно або взагалі нереально.

Принцип використання підпису простий, як двері:

  1. При відправці запиту ви додаєте підпис, сформований на основі параметрів вашого запиту і секретного ключа (який знаєте тільки ви і сама платіжна система)
  2. Платіжна система перед виконанням будь-яких операцій, формує по ідентичному алгоритму підпис самотужки на основі переданих параметрів в запиті, приватного ключа який їй відомий і порівнює його з підписом, який передали Ви. Якщо вони відрізняються - запит вважається невалідним і повертається помилка.

Таким чином, завдяки криптографії, платіжна система може впевнитись на 100% в тому, що запит прийшов саме від вас (мерчанта), а не від третьої особи.

Оновлена наша форма буде виглядати тепер так:

<form action="https://skyrimpay.com/api/v1/checkout" method="POST">
<input type="hidden" name="merchant_id" value="123456789"/>
<input type="hidden" name="amount" value="100"/>
<input type="hidden" name="currency" value="USD"/>
<input type="hidden" name="order_id" value="672626"/>
<input type="hidden" name="description" value="1x Dragonscale Sword"/>
<input type="hidden" name="signature" value=""
<button type="submit">
 Buy
</button>
</form>

❗️ Даний алгоритм може працювати в обидві сторони - платіжна система також формує підпис при відправці на ваш сервер вебхука з статусом платежу, і ви так само повинні сформувати свій варіант підпису і порівняти його з наданим.

🧐 Окремо розпишу - а що вводити в якості order_id?

order_id, external_order_id, metadata - це все назви параметрів, які ви встановлюєте в довільне значення при формуванні форми на оплату. Цей параметр платіжна система без-змін (as-id) буде відправляти вам на кожен вебхук про статус платежу, який був створений з даним значенням. Саме цей параметр і виступає звʼязуючим звеном, по якому ваша система може зрозуміти - а що саме оплатив користувач - оскільки матиме можливість "замапити" ідентифікатор від платіжки, з певною внутрішньою сутністю (баланс, замовлення, рахунок тощо).

Більшість гейтвеїв йдуть простим шляхом і надають лише 1 параметр, той же order_id, з певним лімітом на кількість символів (64/255). В цьому випадку він повинен бути унікальним в межах вашого мерчанту.

Такі монстри як Stripe - надають можливість прописувати цілі JSON-обʼєкти в якості параметра metadata. Тоді унікальність зазвичай не обовʼязкова.

Але що там вказувати? Вказувати потрібно той мінімум інформації, який дозволить вашому додатку при отриманні вебхуку суто з цим ідентифікатором/метаданими, зрозуміти, яку бізнес операцію потрібно провести в системі. Наведу приклади, по яким буде зрозуміліше:

  1. Базовий приклад - оплачувати в додатку можна тільки одну послугу/сутність - замовлення в інтернет магазині, або виставлений рахунок на оплату. Тоді сам номер (ідентифікатор замовлення) може бути значенням order_id.
  2. В вашому додатку можна оплачувати декілька різних сутностей, які є кардинально різними. Наприклад, на одному сайті є і покупка товарів, а є оплата бронювання певної послуги, як от сайт кінотеатру, де можна як наперед забронювати білет, так і просто придбати атрибутику якогось фільму. Тоді ви можете сформувати order_id з декількох частин, як от order:${shop_order_id} і ticket:${cinema_ticket_id}. Тоді, обробник вашого вебхуку, розбиває order_id по сепаратору (:), і дивлячись на першу частину, розуміє, про який саме обʼєкт йтиме мова:
const orderId = request.body.orderId;

const [entityType, entityId] = orderId.split(':')

if (!entityType || !entityId) throw new Error('Invalid orderId');

if (entityType === PaymentEntityTypes.ORDER) {
  await ShopService.payOrder(entityId); //this is order ID
} else if (entityType === PaymentEntityTypes.TICKET) {
  await TicketService.bookTicket(entityId); //this is ticket ID
}
  1. Одна й та сама сутність, але оплату якої можна здійснювати декілька разів. Наприклад, віртуальний баланс користувача на ігровому сайті. Кожне поповнення буде посилатись на один й той же баланс/аккаунт. Але в цьому випадку руйнується правило про унікальність ідентифікатора. Вирішити це можна, додаванням змінної, унікальної частини - класичними варіантами є додавання поточної дати в Unix epoch, або псевдовипадкового набору символів:
    • balance:5472872:1708362029491 (банальна конкатенація Date.now())
    • balance:5472872:abd6ee978f0d (додавання crypto.randomBytes(5).toString('hex')) Переваги першого методу - з ідентифікатора платежу можна визначити дату його створення, таймстемп може тільки збільшуватись. Мінус - не гарантується унікальність в межах декількох міллісекунд (похибки годинника сервера). Другий метод при достатній кількості символів гарантує більшу унікальність, проте менш читабельний.
  2. Якщо ви не можете умістити всі необхідні дані в лімітований параметр ідентифікатора order_id, або він стає занадто великим - створіть окремий внутрішній ідентифікатор, всередині вашої системи пропишіть всі необхідні дані до нього, і тільки його самого вказуйте в API запити до платіжки. Це може бути як банальний UUID, всі необхідні дані до якого покладені в Redis, так і окрема сутність/таблиця в БД "PaymentAttempt"

🤓 Особисто я, надаю перевагу (3) методу з складеним ідентифікатором з типом і таймстемпом. Зазвичай ймовірність, що користувач одночасно в одну й ту самі мілісекунду натисне "оплатити" настільки мала, що нею можна знехтувати. При цьому навіть, якщо на момент реалізації оплати в мене в системі є лише одна послуга/тип товару - я все одно додаю її тип в ідентифікатор платежу, оскільки це дозволить тоді без суттєвих змін в коді обробника вебхуку, додати потенційно нові оплати.

Загальну схему взаємодії з платіжкою розглянути. Тепер безпосередньо декілька рекомендацій, в яких і сховані нюанси, з якими можна зіткнутись неочікувано, якщо не знати:

💡 Розпочну з очевидної - ЗАВЖДИ перевіряйте підпис/сигнатуру, яка приходить у вебхуках від платіжної системи. Не потрібно видумовувати секретні ендпоінти по типу myapp.com/api/payments/webhook_9o167239dgh9129d9vb12d і вказувати їх як server_url - це не є гарантією безпеки. Підпис фактично унікальний для кожного запиту, тому є більш надійним. Також покрийте цю частину коду тестами. Деякі платіжні системи також можуть надавати список їх IP-адрес, які ви можете додати в whitelist для вебхук ендпоінту.

В цей же час:

НІКОЛИ не надавайте послугу користувачу, на основі того, що він опинився на сторінці success_url, на яку платіжка переадресовує його в разі успішної оплати! Це тільки косметично-візуальне підтвердження для користувача - для вас "пруфом" успішної оплати має бути тільки валідний, підписаний вебхук від серверів гейтвея з відповідним статусом!

💡Деякі платіжні системи мають опубліковані SDK для роботи з їх API, проте частою є ситуація коли ці SDK застарілі або використовують неоновлені залежності.

Перевіряйте цей момент під час розробки, не полінуйтесь переглянути трішки код цих SDK. Якщо вони застарілі - напишіть свій. Там немає нічого складного, просто пару HTTP запитів і метод, який формує підпис, алгоритми для якого зазвичай всі вже реалізовані в стандартному модулі crypto. Прикладом тому є SDK від Liqpay, який не оновлювали декілька років, і там все ще використовується архаїчний пакет request і синтаксис ES5 🤒. Stripe, же, навпаки, є прикладом не тільки зручного і актуального SDK, а ще й власника буквально шикарної документації, яка стала натхненням для багатьох інших сервісів в плані оформлення.

💡Приділіть увагу тому, яка політика відправки і обробки вебхуків у обраної Вами платіжної системи.

Більшість з них вважають вебхук доставленим, якщо отримали від вашого серверу відповідь 200 ОК. Деякі (Stripe/Wayforpay) - вимагають ще додатково в тілі відповіді сформувати ще один підпис. Якщо вебхук не доставлений - система намагатиметься надіслати вам цей вебхук ще раз декілька разів протягом деякого періоду.

Відповідно, врахуйте, це при написанні коду для вебхук-ендпоінту і перевірте, чи буде поведінка такого ендпоінту в різних ситуаціях, підходити під Вашу бізнес логіку. Ви можете одразу після отримання запиту, відправляти 200 ОК (підтверджувати успішну обробку вебхука), але при цьому будь-які помилки, які виникли в результаті його обробки, хендлити на своїй стороні. Можна ж навпаки, при виникненні помилки в обробнику - повертати non-200 код, з надією, що наступна спроба від платіжки увінчається успіхом. Іншими словами, є різниця між:

app.post('/webhook', async (req,res) => {
	await handlePaymentWebhook(req.body);
	res.status(200).json({
		ok: true
	})
});

і

app.post('/webhook', async (req,res) => {
	res.status(200).json({
		ok: true
	})
	
	try {
		await handlePaymentWebhook(req.body);
	} catch(err) {
		//log errr
	}
});

(в другому варіанті платіжна система вважатиме всі спроби доставки вебхука успішними, навіть якщо всередині вашої системи провести транзакцію не вдалось)

❗️ До того ж, загальною рекомендацією (яку надає навіть сам Stripe - https://docs.stripe.com/webhooks#acknowledge-events-immediately) є повернення успішного статусу (200 ОК) настільки швидко, наскільки це можливо, до виконання будь-яких комплексних логік з вашої сторони, себто варіант коду (2).

💡Знайте, що деякі гейтвеї можуть відправляти Вам один і той же вебхук повторно декілька разів, навіть якщо Ви успішно його приймаєте, без видимих на те причин (наприклад Liqpay).

В цьому випадку це не має повпливати на стейт вашої системи - повторний вебхук не має спричинити повторне поповнення віртуального балансу користувача, або повторну оплату замовлення, і відповідно має бути no-op подією.

Як цього уникнути:

  1. Якщо мова йде про конкретну сутність, наприклад оплату замовлення - це має бути реалізовано навіть на рівні домену - якщо замовлення вже було оплачене хоч раз - спроби оплатити його заново має викинути помилку. При першій успішній оплаті зберігайте інформацію про сплату в самому замовленні (вистачить умовного таймстемпу paymentCompletedAt), і перевіряйте це поле перед проведенням оплати. Це до речі базова річ, і має працювати в незалежності від джерела оплати - будь-то вебхук платіжки, чи ваш менеджер вручну натиснув кнопку "оплатити" на закешованій сторінці адмін-панелі.
  2. Якщо платіж не привяʼзаний до конкретної сутності - створіть її. У випадку з поповненням балансу - створіть окрему сутність "ПоповненняБалансу", і зберігайте там order_id / webhook_id (якщо він є). При повторному вебхуку, перевірте - чи не оброблявся цей вебхук повторно до цього.
  3. Гібридний/костильний варіант - зберігайте ідентифікатори платежів, які ви уже успішно обробили в якомусь сховищі по типу Redis, з часом зберігання декілька днів/тижнів/місяців. Костильний по тій причині, що з цим не дуже зручно працювати і дані розмазуються між декількома сховищами.

💡Все, що повʼязане з оплатою - логуйте, логуйте і ще раз логуйте

Прийшов вебхук - залогували. Оплатили замовлення - залогували. Прийшов вебхук з невірним підписом - залогували. Не вдалось згенерувати форму для оплати - що зробили? залогували.

Я думаю причини робити це - зрозумілі.

Ще кращим рішенням, яке я застосовую особисто, це зберігання кожного вебхуку в БД, і надалі їх вивід у зручній таблиці десь у внутрішній адмін-панелі. Це дуже допомагає при дебагінгу різних проблем. Достатньо при цьому буквально зберігати дату і тіло запиту, але зазвичай схема у моїй таблиці схожа на таку:

CREATE TABLE payment_webhooks (
  id uuid primary key,
  order_id text,
  status text,
  received_at timestamp,
  request_body json
);

Тобто ще окремими колонками я дублюю деяку корисну для себе інформацію по типу статусу і ідентифікатора замовлення (але без створення зовнішнього ключа на таблицю замовлень, ця архівна інформація має зберігатись навіть якщо саме замовлення було видалене!)

Записи в цю таблицю вставляються в незалежності від успішності обробки вебхука.

Не зайвим також буде надсилання сповіщень в Slack/Telegram/Discord у ваш внутрішній чат при отриманні платежу - це буде зручно як Вам, так і менджерам/продактам/сейзлам/т.д.

💡Тестуйте проведення оплати в sandbox/test середовищі, до якого вам надає доступ платіжна система

Для тестування вебхуків, використовуйте сервіс по типу ngrok для перехоплення вебхуків на своїй локальній машині.

Stripe також в цій ситуації позитивно виділяється - і надає CLI тул для прямої інтеграції з вебхуками під час розробки: https://docs.stripe.com/webhooks#test-webhook

Платіжні системи також надають певний набір тестових карток, які в тестовому середовищі можуть одразу завершити платіж тим чи іншим статусом (наприклад, для Liqpay карта 4242 4242 4242 4242 автоматично проводить успішну оплату, деталі дивіться в документації вашого гейтвея). Протестуйте не тільки успішну, а й не успішну оплату по різним причинам - недостатньо коштів, невірна картка, помилка 3DS і т.д.

Приклади: https://www.liqpay.ua/en/documentation/api/sandbox https://docs.stripe.com/testing

💡Більшість платіжних систем надають окрім статусу платежу, також маску картки користувача. Ви можете її використати для покращення UX

Тобто, в вебхуку про успішний платіж також може прийти card_mask = "Visa 51***3678". Ви можете зберегти цю інформацію для подальшого відображення на фронтенді, в якості підтвердження того, що оплата пройшла і з якої саме картки.

💡Пропрацюйте сценарій, коли виконується повернення платежу

Особисті кабінети мерчантів дозволяють здійснювати повернення платежу з тих чи інших причин, і як автоматично через API, так і вручну. Рано чи пізно, ви можете зіткнутись з ситуацією коли по домовленості з клієнтом, вам потрібно буде повернути суму платежу і скасувати проведення оплати. Ваш додаток має бути технічно готовий до цього, і при необхідності - відмінити надання послуги при отримані вебхука з відповідним статусом, що сигналізує повернення платежу. Це може бути: деактивація акаунту, відміна замовлення, обнулення балансу тощо. Без цього вам прийдеться вручну виправляти стейт системи в тій же БД, що є трудомістким і незручним.

Приклади: https://www.liqpay.ua/en/documentation/api/aquiring/refund/doc https://docs.stripe.com/refunds

З рекомендаціями здається все, перейдемо до не менш наповненого розділу - відповіді на часті запитання.

Але користувач зараз вводить дані про картку на сайті платіжної системи! Більшість сервісів (Figma, Google, YouTube, Amazon) - просять ввести дані картки прямо на сайті. Як таке зробити?

👉 Те, що дані вводяться на стороні платіжної системи (на їх фронтенді) - зроблено не просто так. Різного роду регулятори, включно з Visa / Mastercard не дозволять будь-кому приймати реквізити кредитної картки, і тому вимагають від компаній, які таке здійснюють, пройти так звану PCI DSS сертифікацію, яка не є тривіально простим ділом і вимагає як зусиль, так і певних ресурсів. Платіжні системи цю сертифікацію вже пройшли, і тому виступають зручним посередником для простих "смертних" для організації прийому платежів на сайті. Враховуючи те, що налаштування сторінки чекауту в сучасних платіжках є досить зручним, для 90% проектів немає сенсу забивати собі голову цією процедурою, і краще сфокусуватись на реалізації свого продукту, а не формочки для оплати. Користувачу до того ж не особо принципово, де саме вводити дані про свою картку.

Деталі: https://www.liqpay.ua/en/documentation/api/aquiring/pay

Як організувати оплату в мобільному додатку?

👉Варіанта в цілому є 3:

  1. Платіжна система сама надає нативний SDK на Java/Swift з віджетами для оплати. Тоді використовуйте його, щоправда, це рідкість.
  2. Платіжна система надає API для проведення Google Pay / Apple Pay оплат. Ці методи по ідеї є стандартними для мобільних платформ, тому їх наявність є досить вичерпною. Зауважу, що в деяких платіжних системах (той же Liqpay), Вам необхідно окремо написати в підтримку для активації цієї функції, і ще потрібно буде верифікувати домен/iOS сертифікат. Самі методи API зазвичай не особо відрізняються від стандартних, проте в них ще додатково треба передавати пейлоуд від клієнта Apple Pay / Google Pay. Зверніться до документації вашої платіжки для детальнішої інформації.
  3. Відкривати попап/окремий екран з WebView, в якому буде відкрита форма на оплату, згенерована вашим сервером.

Links: https://www.liqpay.ua/en/documentation/api/aquiring/applepay/doc https://docs.stripe.com/apple-pay

Моя платіжна система не надає можливості здійснити оплату через HTML форму, а просто генерує посилання на оплату по API. Як бути?

👉 Флоу деяких платіжок побудований не в форматі "клієнт відправляє форму, згенеровану сервером", а "сервер запитує посилання на оплату у платіжки, на яке перекидує клієнта". В цьому випадку, ви просто повертаєте це посилання клієнту, і він здійснює редірект:

//server
app.post(`/payOrder/:orderId`, async (req,res) => {
  const paymentLink = await PaymentService.generatePaymentLinkForOrder(req.params.orderId);
  res.json({
    paymentLink //== https://skyrimpay.com/checkout/912673681269783187623
  });
})

//client
const response = await fetch('/payOrder/'+orderId);
const json = await response.json();

window.location.href = json.paymentLink;

Мобільний додаток в свою чергу просто відкриє посилання в WebView.

Моя бізнес модель полягає в тому, що я є посередником між бізнесом і споживачем, і надаю площадку для проведення оплати від споживача до бізнесу. Наприклад маркетплейс як Prom - оплата здійснюється на рахунок продавця, а не prom-а, але при цьому prom отримує вебхуки про успішну оплату. Як таке реалізувати?

👉 Ви можете акумулювати всі кошти на своєму рахунку, а потім через API здійснювати періодичні виплати на потрібні IBAN рахунки, які буде вказувати бізнес у себе десь в налаштуваннях на вашій платформі. Але це створює додаткові ризики, і може заплутати вашу бухгалтерію, а також зацікавити певних осіб до різкого росту вашого обороту ;)

Тому найкращим рішенням буде, щоб бізнес зареєстрував мерчанта в тій чи іншій платіжній системі (юридичну особу він вказує свою, а сайт - ваш, або піддомен на якому буде працювати його магазин). Далі залежить від послуг, що надає гейтвей.

Деякі платіжки з коробки підтримують таку модель - в Stripe це називається Stripe Connect (https://stripe.com/connect). Тоді ваш мерчант по суті отримує тільки вебхуки, в саме яких Ви зацікавлені.

Якщо подібної послуги система Вам не надає, Ви можете скористатись так званим "розщепленням платежів", де сума платежу розділяється між декількома мерчантами, і тоді вказуєте єдиного отримувача на повну суму платежу. Тут до речі, зазвичай можна ще вказати певну комісію, яку ви берете за надання послуги.

Фінальний варіант, якщо все вище не підійшло або не спрацювало - бізнес буде вводити публічний/приватний ключ від свого мерчанта у вас на сайті, і при генерації форми/посилання на оплату, ви будете використовувати динамічно ключі того мерчанту / бізнесу, для якого створюється замовлення на платформі. Це вимагає додаткових мір безпеки (по типу шифрування цих ключів), докладає на вас відповідальність за збереження цих даних в секреті і вимагає певний кредит довіри, проте це можливо, особливо, якщо є спосіб обмежити API ключ платіжки в можливих діях, і/або коли бізнес створив нового мерчанта спеціально і тільки для вашої платформи, зменшивши потенційний простір для зловмисних дій в разі втрати ключів.

🤯 В той же час, зауважу що гіганти по типу Prom/OLX і подібних можуть мати або окремі контракти з платіжними системами, або навіть мати свій процесинг платежів, деталі реалізації яких мені невідомі.

В мене бізнес-модель - щомісячна підписка. Як таке реалізувати?

👉 Дивіться документацію до вашого гейтвея, більшість з них дозволяють це зробити, хоча з деякими обмеженнями :) Додам лише пару підказок:

  • Технічно реалізація майже не змінюється, ви просто при генерації форми на оплату, вказуєте дату початку регулярного платежу, його періодичність. Користувач на сторінці оплати вводить дані про картку, з якої платіжна система буде здійснювати списання автоматично по заданим параметрам, і надсилати вам відповідний вебхук.
  • Зверніть увагу - більшість платіжних систем, без додаткових договорів не дозволяють вам змінювати суму регулярного платежу після його оформлення, з міркувань безпеки! Для зміни суми, необхідно його скасовувати і оформляти заново. Або підписувати договір з платіжним провайдером, і вносити певну суму в якості страхувального фонду (в Україні з мого досвіду це біля тисячі долларів в гривневому еквіваленті)
  • Зберігайте в БД не кількість днів, що залишилась в аккаунті, а просто кінцеву дату аккаунту або тарифу, на який оформлена підписка. При отриманні чергового регулярного платежу - збільшуйте дату на умовні 30 днів або інший потрібний період. Вся інша внутрішня бізнес логіка при необхідності перевіряє, чи знаходиться ця дата в майбутньому (аккаунт активний / тариф проплачений).
  • Прочитайте або дізнайтесь, як платіжна система буде обробляти невдалий регулярний платіж. Деякі, такі як Liqpay, будуть намагатись провести платіж ще декілька разів. Деякі - не будуть, і чекатимуть наступного місяця, інші - можуть взагалі одразу відмінити її.
  • Врахуйте сценарій, коли у привʼязаної картки користувача вийде строк дії. Це буде окремим типом помилки у відповідному вебхуку, і можливо вам окремо необхідно буде повідомити це клієнта шляхом відправки електронного листа.
  • При успішній чи неуспішній спробі провести регулярний платіж - надсилайте користувачу на пошту, або квитанцію про оплату, або повідомлення про неуспішний платіж і прохання оновити платіжні дані чи поповнити картку.
  • Не забудьте про сценарій повернення коштів - це часта ситуація, як мінімум в B2B продуктах, коли відбувається чергове списання коштів, і це слугує нагадуванню клієнту, про те, що, як виявляється, він передумав користуватись вашими послугами і в цей момент відміняє підписку. В цьому випадку, якщо дозволяє політика компанії, часто клієнт намагається домовитись про повернення коштів з останнього платежу.
  • Не забудьте обовʼязково показати чітко і зрозуміло кнопку/флоу для скасування підписки. На мою думку, якщо ця кнопка прихована десь в дебрях налаштуваннях - це жахливий UX, і від такого продукту треба тікати подалі. Скасування підписки має відбуватись в пару інтуїтивно зрозумілих кліків, якщо того забажає юзер.

Links: https://www.liqpay.ua/en/documentation/api/aquiring/subscribe/doc https://docs.stripe.com/subscriptions

Ціни на послуги в моєму продукті вимірюються в долларах, але компанія в Україні. Я змушений приймати платежі в гривні, і конвертувати ціну самотужки?

👉 Ні, зазвичай при формуванні чекауту, Ви можете вказати його валюту (параметр currency), а "золоту трійцю" - USD, EUR, UAH підтримують фактично всі українські провайдери. В цьому випадку закордонні клієнти зможуть платити в долларах, а на ваш рахунок кошти будуть надходити в гривні, після конвертації, яка автоматично буде здійснюватись на стороні платіжної системи.

Що таке двохстадійна оплата, про яку був написаний кейс з життя, і коли вона потрібна?

👉 Двохстадійна оплата, як і підказує назва, складається з двох етапів:

  1. Списання коштів з картки клієнта
  2. Зарахування коштів на кінцевий рахунок мерчанта

Приклад - https://www.liqpay.ua/en/documentation/api/aquiring/hold/doc

Вона корисна, я думаю в очевидних сценаріях - наприклад прийняли оплату за замовлення, але ще наперед не знаємо чи є товар на складі або у продавця. В такому випадку ми спочатку приймаємо кошти від користувача, а після того як дізнались чи може замовлення бути виконане - підтверджуємо його і завершуємо цикл платежу. Зазвичай підтвердження або скасування двохстадійної оплати - це просто ще один API запит на сервер платіжного провайдера, з указанням ідентифікатора платежу - будь-то order_id, чи внутрішній ідентифікатор платежу від платіжки. Що саме потрібно для цього - читайте в документації. Зрозуміло, що для завершення двохстадійної оплати, вам потрібно мати оригінальний order_id. Якщо при його генерації додається таймстемп/рандомні символи - вам потрібно його десь зберігати на час оплати, наприклад прямо в даних замовлення.

Як вже було сказано раніше, платіжні системи автоматично скасовують двохстадійну оплату, якщо вона не була завершена з вашої сторони, через певний період (від 7 до 30 днів). Додайте механізми, які б гарантували або хоча б нагадували, що неочікуваного скасування двохстадійки не буде.

🫡 Ну от тепер нарешті, думаю, виклав все, що зміг згадати в контексті платіжних систем і реалізації онлайн оплати, що "надибав" за останні 5 років. Дякую, якщо дійшли до кінця, і буду вдячний за коментарі.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment