Payment Pattern (Wzorzec płatności)
Based on "Enterprise Patterns and MDA" by Jim Arlow & Ila Neustadt
Problem
Order wymagają śledzenia Payment, ale to coś więcej niż flaga boolean:
- Wiele metod Payment — Karta, przelew bankowy, gotówka, portfele cyfrowe
- Payment częściowe — Customer płaci w ratach
- Refund — Zwracanie pieniędzy za zwroty/anulowania
- Integracja z bramką Payment — Zewnętrzne identyfikatory transakcji, obsługa webhooków
- Cykl życia Payment — Oczekująca → W trakcie → Zakończona → Zwrócona
- Wymagania audytowe — Pełna historia dla księgowości i zgodności z przepisami
Koncepcja
Payment to encja transakcji finansowej — pieniądze wpływające (Payment) lub wypływające (Refund). Ma własny cykl życia niezależny od Order, przechowuje referencje do bramki Payment i zapewnia pełną ścieżkę audytu.
Kluczowa obserwacja: Refund to również Payment, tylko w odwrotnym kierunku.
Struktura
Atrybuty
Payment
| Atrybut | Typ | Wymagane | Opis |
|---|---|---|---|
| id | UUID | Tak | Unikalny identyfikator |
| reference | String | Tak | Czytelna referencja |
| order | Order | Tak | Opłacane Order |
| customer | Customer | Tak | Kto płaci |
| amount | Money | Tak | Kwota Payment |
| currency | String | Tak | Kod waluty ISO |
| method | PaymentMethod | Tak | Sposób dokonania Payment |
| status | PaymentStatus | Tak | Bieżący stan Payment |
| isRefund | Boolean | Tak | Czy to Refund (pieniądze wychodzą)? |
| originalPayment | Payment | Nie | Dla Refund: oryginalna Payment |
| gatewayName | String | Nie | Użyta bramka Payment |
| gatewayTransactionId | String | Nie | Zewnętrzny identyfikator transakcji |
| gatewayResponse | JSON | Nie | Surowa odpowiedź bramki |
| failureReason | String | Nie | Przyczyna niepowodzenia Payment |
| cardLast4 | String | Nie | Ostatnie 4 cyfry (zamaskowana karta) |
| cardBrand | String | Nie | Visa, Mastercard itp. |
| createdAt | Timestamp | Tak | Kiedy Payment została zainicjowana |
| completedAt | Timestamp | Nie | Kiedy Payment została zakończona |
Zachowania
Entity Payment:
// === Status checks ===
function isSuccessful(): Boolean
return status in [PaymentStatus.Completed, PaymentStatus.PartiallyRefunded]
function isFinal(): Boolean
// No more changes expected (but Completed can still be refunded!)
return status in [PaymentStatus.Failed, PaymentStatus.Cancelled, PaymentStatus.Refunded]
function canBeRefunded(): Boolean
return status in [PaymentStatus.Completed, PaymentStatus.PartiallyRefunded]
and not isRefund // Can't refund a refund
// === State transitions ===
function markProcessing():
require status == PaymentStatus.Pending
status = PaymentStatus.Processing
emit PaymentProcessing(this)
function complete(gatewayTransactionId: String):
require status in [PaymentStatus.Pending, PaymentStatus.Processing]
status = PaymentStatus.Completed
completedAt = now()
this.gatewayTransactionId = gatewayTransactionId
// If this is a refund, update original payment status
if isRefund and originalPayment is not None:
originalPayment.updateRefundStatus()
emit PaymentCompleted(this)
function fail(reason: String):
require status in [PaymentStatus.Pending, PaymentStatus.Processing]
status = PaymentStatus.Failed
failureReason = reason
emit PaymentFailed(this, reason)
function cancel():
require status == PaymentStatus.Pending
status = PaymentStatus.Cancelled
emit PaymentCancelled(this)
// === Refund handling ===
function getRefundedAmount(): Money
total = Money(0, currency)
for refund in getRefunds():
if refund.isSuccessful():
total = total + refund.amount
return total
function getRefundableAmount(): Money
return amount - getRefundedAmount()
function updateRefundStatus():
// Called when a refund completes
refundable = getRefundableAmount()
if refundable <= 0:
status = PaymentStatus.Refunded
else:
status = PaymentStatus.PartiallyRefunded
// === Factory for refunds ===
static function createRefund(
originalPayment: Payment,
refundAmount: Money,
reference: String
): Payment
require originalPayment.canBeRefunded()
require refundAmount > 0
require refundAmount <= originalPayment.getRefundableAmount()
refund = new Payment(
reference: reference,
order: originalPayment.order,
customer: originalPayment.customer,
amount: refundAmount,
currency: originalPayment.currency,
method: originalPayment.method,
status: PaymentStatus.Pending,
isRefund: true,
originalPayment: originalPayment
)
return refund
// === Gateway integration ===
function setGatewayInfo(gateway: String, transactionId: String, response: JSON):
gatewayName = gateway
gatewayTransactionId = transactionId
gatewayResponse = response
function setCardInfo(last4: String, brand: String):
require method == PaymentMethod.Card
cardLast4 = last4
cardBrand = brand
// === Display ===
function getFormattedAmount(): String
prefix = isRefund ? "-" : ""
return prefix + formatMoney(amount, currency)
// Example: "-500,00 PLN" for refundPrzykłady
Opłacanie Order kartą
order = findOrder("ORD-2026-000042")
payment = new Payment(
reference: "PAY-2026-000089",
order: order,
customer: order.customer,
amount: Money(909633, "PLN"), // 9096.33 PLN
currency: "PLN",
method: PaymentMethod.Card,
status: PaymentStatus.Pending,
isRefund: false
)
// Gateway processes the payment
payment.markProcessing()
// ... gateway webhook arrives ...
payment.complete("gw_txn_abc123xyz")
payment.setCardInfo("4242", "Visa")
// emits PaymentCompleted(payment)
payment.isSuccessful() // trueCzęściowy Refund
// Customer returns one item (mouse, 449.00 PLN)
refund = Payment.createRefund(
originalPayment: payment,
refundAmount: Money(44900, "PLN"),
reference: "REF-2026-000012"
)
// refund.isRefund == true
// refund.originalPayment == payment
refund.complete("gw_ref_def456")
// payment.status == PartiallyRefunded
// payment.getRefundedAmount() == 44900
// payment.getRefundableAmount() == 864733
refund.getFormattedAmount() // "-449.00 PLN"Maszyna stanów
Znaczenie statusów
| Status | Opis | Możliwe przejścia do |
|---|---|---|
| Pending | Oczekuje na akcję (przekierowanie do bramki Payment) | Processing, Cancelled |
| Processing | Wysłane do bramki, oczekiwanie na odpowiedź | Completed, Failed |
| Completed | Pieniądze otrzymane pomyślnie | PartiallyRefunded, Refunded |
| Failed | Bramka odrzuciła (karta odrzucona itp.) | — |
| Cancelled | Anulowane przed zakończeniem | — |
| PartiallyRefunded | Część pieniędzy zwrócona Customer | Refunded |
| Refunded | Wszystkie pieniądze zwrócone Customer | — |
Niezmienniki
- Dodatnia kwota — Kwota Payment musi być > 0
- Zgodna waluta — Waluta Payment musi odpowiadać walucie Order
- Refund w ramach limitu — Refund nie może przekroczyć oryginalnej Payment
- Brak podwójnego Refund — Nie można zwrócić więcej niż kwota dostępna do Refund
- Refund ma oryginał — Payment będąca Refund musi wskazywać na oryginalną Payment
- Dane karty tylko dla kart — cardLast4/cardBrand tylko gdy metoda to Card
- Prawidłowe przejścia stanów — Tylko dozwolone przejścia zgodnie z maszyną stanów
Obsługa błędów
| Operacja | Naruszony warunek wstępny | Błąd |
|---|---|---|
markProcessing() |
Status nie jest Pending | PreconditionError: can only process pending payments |
complete(txId) |
Status nie jest Pending/Processing | PreconditionError: cannot complete payment in status Failed |
fail(reason) |
Status nie jest Pending/Processing | PreconditionError: cannot fail already completed payment |
cancel() |
Status nie jest Pending | PreconditionError: can only cancel pending payments |
createRefund(original, amount, ref) |
Oryginał nie podlega zwrotowi | PreconditionError: payment in status Failed cannot be refunded |
createRefund(original, amount, ref) |
amount <= 0 |
PreconditionError: refund amount must be positive |
createRefund(original, amount, ref) |
Kwota > dostępna do zwrotu | PreconditionError: refund 500.00 exceeds refundable amount 449.00 |
setCardInfo(last4, brand) |
Metoda nie jest Card | PreconditionError: card info only for card payments |
Idempotentność: complete() na już zakończonej Payment jest operacją bez efektu (patrz sekcja Wzorzec integracji z bramką Payment). Zapobiega to podwójnemu przetwarzaniu z powodu zduplikowanych webhooków.
Schemat SQL
sqlCREATE TABLE payment (
id UUID PRIMARY KEY,
reference VARCHAR(50) NOT NULL UNIQUE,
order_id UUID NOT NULL REFERENCES orders(id),
customer_id UUID NOT NULL REFERENCES party_role(id),
-- Amount (in smallest currency unit)
amount_cents INTEGER NOT NULL,
currency CHAR(3) NOT NULL,
-- Method and status
method VARCHAR(30) NOT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'Pending',
-- Refund tracking
is_refund BOOLEAN NOT NULL DEFAULT FALSE,
original_payment_id UUID REFERENCES payment(id),
-- Gateway information
gateway_name VARCHAR(50),
gateway_transaction_id VARCHAR(255),
gateway_response JSONB,
failure_reason TEXT,
-- Card details (masked)
card_last4 CHAR(4),
card_brand VARCHAR(20),
-- Timestamps
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Constraints
CONSTRAINT chk_positive_amount CHECK (amount_cents > 0),
CONSTRAINT chk_payment_status CHECK (
status IN ('Pending', 'Processing', 'Completed', 'Failed',
'Cancelled', 'Refunded', 'PartiallyRefunded')
),
CONSTRAINT chk_payment_method CHECK (
method IN ('Card', 'BankTransfer', 'Blik', 'PayPal', 'Cash', 'Invoice')
),
CONSTRAINT chk_refund_has_original CHECK (
(is_refund = FALSE) OR (original_payment_id IS NOT NULL)
)
);
-- Indexes
CREATE INDEX idx_payment_order ON payment(order_id);
CREATE INDEX idx_payment_customer ON payment(customer_id);
CREATE INDEX idx_payment_status ON payment(status);
CREATE INDEX idx_payment_gateway_tx ON payment(gateway_transaction_id)
WHERE gateway_transaction_id IS NOT NULL;
CREATE INDEX idx_payment_original ON payment(original_payment_id)
WHERE original_payment_id IS NOT NULL;Typowe zapytania
sql-- Get all payments for an order (including refunds)
SELECT
p.*,
CASE WHEN p.is_refund THEN -p.amount_cents ELSE p.amount_cents END as signed_amount
FROM payment p
WHERE p.order_id = :orderId
ORDER BY p.created_at;
-- Calculate order payment status
SELECT
o.id,
o.grand_total_cents,
COALESCE(SUM(CASE WHEN p.status = 'Completed' AND NOT p.is_refund
THEN p.amount_cents ELSE 0 END), 0) as total_paid,
COALESCE(SUM(CASE WHEN p.status = 'Completed' AND p.is_refund
THEN p.amount_cents ELSE 0 END), 0) as total_refunded
FROM orders o
LEFT JOIN payment p ON p.order_id = o.id
WHERE o.id = :orderId
GROUP BY o.id;
-- Find payment by gateway transaction ID (for webhooks)
SELECT * FROM payment
WHERE gateway_transaction_id = :transactionId
AND gateway_name = :gatewayName;
-- Payments awaiting confirmation (stuck in Processing)
SELECT * FROM payment
WHERE status = 'Processing'
AND created_at < CURRENT_TIMESTAMP - INTERVAL '1 hour';
-- Refund summary for original payment
SELECT
p.id as original_id,
p.amount_cents as original_amount,
COALESCE(SUM(r.amount_cents), 0) as refunded_amount,
p.amount_cents - COALESCE(SUM(r.amount_cents), 0) as refundable_amount
FROM payment p
LEFT JOIN payment r ON r.original_payment_id = p.id AND r.status = 'Completed'
WHERE p.id = :paymentId AND NOT p.is_refund
GROUP BY p.id;Wzorzec integracji z bramką Payment
// Webhook handler pseudocode
function handleGatewayWebhook(gatewayName: String, payload: JSON):
transactionId = payload.transactionId
// Find the payment
payment = findPayment(gatewayName, transactionId)
if payment is None:
log("Unknown transaction: " + transactionId)
return HTTP 404
// Process based on event type
match payload.event:
case "payment.success":
payment.complete(transactionId)
if payload.cardLast4:
payment.setCardInfo(payload.cardLast4, payload.cardBrand)
case "payment.failed":
payment.fail(payload.failureReason)
case "refund.success":
payment.complete(transactionId) // Refund is also a payment
// Store raw response for debugging
payment.setGatewayInfo(gatewayName, transactionId, payload)
save(payment)
return HTTP 200Zagadnienia projektowe
Kwoty w groszach
// ALWAYS store money as integers in smallest unit
// Bad (floating point):
amount = 19.99 // Rounding errors!
// Good (cents/grosze):
amount_cents = 1999
// Display:
function formatMoney(cents: Integer, currency: String): String
return (cents / 100).toFixed(2) + " " + currency
// "19.99 PLN"Refund jako Payment
Modelowanie Refund jako encji Payment (z isRefund = true) zamiast osobnej encji:
| Podejście | Zalety | Wady |
|---|---|---|
| Refund jako Payment | Zunifikowana obsługa, ta sama bramka | Potrzebna flaga isRefund |
| Osobna encja Refund | Czystsza separacja typów | Zduplikowany kod, dwie tabele |
Zalecenie: Refund jako Payment z samoreferencją do oryginału.
Bezpieczeństwo danych kart (PCI)
Nigdy nie przechowuj:
- Pełnego numeru karty
- CVV/CVC
- Pełnej daty ważności
Można bezpiecznie przechowywać:
- Ostatnie 4 cyfry
- Markę karty (Visa, MC)
- Imię i nazwisko posiadacza karty (opcjonalnie)
Bramka Payment obsługuje faktyczne dane karty. Przechowujesz jedynie referencję i zamaskowane informacje.
Idempotentność
Webhooki bramki Payment mogą przychodzić wielokrotnie. Obsługuj je idempotentnie:
function handlePaymentSuccess(payment: Payment):
if payment.status == PaymentStatus.Completed:
return // Already processed, skip
payment.complete(transactionId)Related Patterns
- Order — Za co się płaci
- Party Role (Customer) — Kto płaci
- Shipment — Uruchamiana po potwierdzeniu Payment
- Faktura — Formalny dokument wezwania do zapłaty