Wzorce integracji bramek płatności
Przyjmowanie pieniędzy to część e-commerce, której nie możesz sobie pozwolić zepsuć. Deep-dive o poprawnej integracji bramki płatności — tokenizacja, klucze idempotencji, webhooki jako źródło prawdy i modelowanie płatności jako maszyny stanów.
Ze wszystkiego kodu w aplikacji e-commerce integracja płatności to część, gdzie błędy kosztują prawdziwe pieniądze i erodują jedyną rzecz, bez której sklep nie może działać: zaufanie. Jest też pełna nieoczywistych pułapek, które wychodzą dopiero na produkcji, przy współbieżności, gdy wywołanie sieciowe przekracza czas w najgorszym momencie. Zintegrowawszy Stripe, Braintree i inne, oto wzorce, które czynią kod płatności poprawnym i odpornym — i błędy, które czynią go groźnym.
Reguła pierwsza: nigdy nie dotykaj danych karty
Pierwszą i najważniejszą regułą jest to, czego twój kod nie może robić. Nie chcesz surowych numerów kart nigdzie blisko swoich serwerów, bo w chwili, gdy dotkną twojej infrastruktury, wpadasz w pełny zakres zgodności PCI-DSS — ogromnego, kosztownego ciężaru — i stajesz się celem. Odpowiedzią jest tokenizacja: dane karty idą bezpośrednio z przeglądarki klienta do bramki (przez JavaScript bramki, np. Stripe Elements), która zwraca token reprezentujący kartę. Twój serwer widzi tylko ten nieprzejrzysty token, nigdy numer:
// w przeglądarce — dane karty idą do Stripe, nigdy do twojego serwera
stripe.createToken(cardElement).then(result => {
form.append(hiddenInput("stripe_token", result.token.id));
form.submit(); // twój serwer otrzymuje token, nie numer karty
});
# serwer — obciąż używając tokenu; nigdy nie widziałeś karty
Stripe::Charge.create(
amount: order.total_cents,
currency: order.currency,
source: params[:stripe_token],
)
Ten jeden wybór architektoniczny — dane karty nigdy do ciebie nie docierają — spuszcza cię z „pełnego audytu PCI” do najlżejszej warstwy zgodności i usuwa najgorszą klasę ryzyka wycieku. Traktuj to jako nienegocjowalne.
Reguła druga: klucze idempotencji, albo podwójnie obciążysz
Oto awaria, która niszczy naiwny kod płatności, i ma ten sam kształt, co przy budowie bota tradingowego: wysyłasz żądanie obciążenia, sieć przekracza czas, a ty nie wiesz, czy obciążenie się powiodło. Ponów ślepo, a możesz obciążyć klienta dwukrotnie. Obroną jest klucz idempotencji — unikalny klucz, który generujesz per próbę obciążenia i wysyłasz z żądaniem. Bramka używa go, by zagwarantować, że operacja dzieje się najwyżej raz; ponowienie z tym samym kluczem zwraca oryginalny wynik zamiast obciążać ponownie:
Stripe::Charge.create(
{ amount: order.total_cents, currency: order.currency, source: token },
{ idempotency_key: "order-#{order.id}-charge" }
)
Każda nowoczesna bramka to wspiera i musisz tego używać. Obciążenie bez klucza idempotencji to podwójne obciążenie czekające na niestabilne połączenie. Wyprowadź klucz z czegoś stabilnego o intencji (zamówienia), by ta sama logiczna płatność zawsze niosła ten sam klucz.
Reguła trzecia: webhooki są źródłem prawdy, nie odpowiedź HTTP
To wzorzec oddzielający odporne integracje od kruchych. Synchroniczna odpowiedź na twoje żądanie obciążenia nie jest niezawodnym zapisem tego, co się stało. Żądanie może przekroczyć czas po tym, jak obciążenie się powiodło; płatność może być autoryzowana teraz, a przechwycona później; przelew bankowy albo przepływ 3D-Secure kończy się asynchronicznie, minuty po tym, jak klient odszedł. Jeśli aktualizujesz zamówienie tylko na podstawie natychmiastowej odpowiedzi API, przegapisz zmiany stanu i rozsynchronizujesz się z bramką.
Odporny projekt traktuje webhooki bramki jako autorytatywny strumień zdarzeń. Bramka woła twój endpoint, gdy płatność faktycznie się powiedzie, zawiedzie, zostanie zwrócona albo zakwestionowana, a te zdarzenia napędzają stan twojego zamówienia:
def stripe_webhook
event = verify_signature(request) # ZAWSZE weryfikuj podpis
case event.type
when "charge.succeeded" then mark_order_paid(event.data.object)
when "charge.failed" then mark_order_failed(event.data.object)
when "charge.refunded" then mark_order_refunded(event.data.object)
when "charge.dispute.created" then flag_dispute(event.data.object)
end
head :ok
end
Trzy dyscypliny czynią webhooki bezpiecznymi: weryfikuj podpis (endpoint jest publiczny; niezweryfikowany webhook to atakujący mówiący ci, że zamówienie jest opłacone), czyń handlery idempotentnymi (bramki ponawiają webhooki i mogą dostarczyć to samo zdarzenie więcej niż raz) i potwierdzaj szybko, potem rób ciężką pracę w zadaniu w tle (wolna odpowiedź webhooka wyzwala więcej ponowień). Synchroniczna odpowiedź to wskazówka dla natychmiastowego doświadczenia użytkownika; webhook to prawda, którą zapisują twoje dane.
Modeluj płatność jako maszynę stanów
Płatność to nie boolean; to cykl życia, a modelowanie jej jako takiego zapobiega klasie błędów.
Obciążenie przechodzi przez stany — pending → authorized → captured → refunded, z gałęziami do
failed i disputed, a twoje zamówienie powinno jawnie śledzić, w którym stanie jest, z
dozwolonymi tylko legalnymi przejściami:
class Payment < ApplicationRecord
# pending -> authorized -> captured -> refunded
# \-> failed \-> disputed
state_machine :status, initial: :pending do
event(:authorize) { transition pending: :authorized }
event(:capture) { transition authorized: :captured }
event(:refund) { transition captured: :refunded }
event(:fail) { transition [:pending, :authorized] => :failed }
end
end
Jawne stany czynią handlery webhooków prostymi (każde zdarzenie wyzwala przejście), czynią nielegalne sytuacje niemożliwymi do reprezentowania („captured, potem z powrotem do pending” nie może się zdarzyć) i dają ci uczciwy ślad audytowy tego, co się stało. Zmuszają cię też do myślenia o asynchronicznej, wielokrokowej naturze płatności, zamiast udawać, że obciążenie to jedno atomowe boolowskie zdarzenie.
Przechowuj referencje, nigdy instrumenty; obsługuj porażkę jako normalność
Dwie kolejne istotne rzeczy. Przechowuj referencje bramki, nie instrumenty płatnicze — trzymaj ID obciążenia, token klienta, ostatnie cztery cyfry do wyświetlenia, ale nigdy karty. I traktuj porażkę jako normalną ścieżkę, nie wyjątek: karty są odrzucane nieustannie, z powodów, których nie widzisz (brak środków, reguły antyfraudowe, wygasłe karty). Dobra integracja pokazuje jasny, odzyskiwalny komunikat („twoja karta została odrzucona, spróbuj innej”) i zostawia zamówienie w stanie, z którego klient może ponowić — a nie błąd 500 i zamówienie w zawieszeniu. Nieszczęśliwa ścieżka jest częstą ścieżką w płatnościach; projektuj na nią świadomie.
Werdykt
Integracja płatności to miejsce, gdzie troska liczy się najbardziej, bo porażki mierzy się w pieniądzach i zaufaniu. Pięć wzorców czyni ją poprawną: trzymaj dane karty poza serwerami przez tokenizację (ryzyko PCI i wycieku); używaj kluczy idempotencji na każdym obciążeniu, by timeout nigdy nie stał się podwójnym obciążeniem; traktuj webhooki — podpisane, idempotentne, szybko potwierdzane — jako źródło prawdy zamiast synchronicznej odpowiedzi; modeluj płatność jako jawną maszynę stanów, by nielegalne stany nie mogły wystąpić, a asynchroniczny cykl życia był uczciwy; i obsługuj odrzucenia jako normalną, odzyskiwalną ścieżkę. Złap to dobrze, a twój przepływ płatności jest odporny na awarie sieci i współbieżność, które się zdarzą. Pomiń je, a zbudowałeś, prędzej czy później, sposób na obciążenie klienta dwukrotnie w najgorszym możliwym momencie.