Archetypy Programowania

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

originalPayment (for refunds)

Payment

+id: UUID

+reference: String

+order: Order

+amount: Money

+method: PaymentMethod

+status: PaymentStatus

+isRefund: Boolean

+originalPayment: Payment

+complete() : : void

+refund(amount) : : Payment

«enumeration»

PaymentMethod

Card

BankTransfer

Cash

PayPal

Blik

Invoice

«enumeration»

PaymentStatus

Pending

Processing

Completed

Failed

Cancelled

Refunded

PartiallyRefunded

Order

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 refund

Przykł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()  // true

Częś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

create

markProcessing()

cancel()

complete()

fail()

partial refund completed

full refund completed

remaining refunded

Pending

Processing

Cancelled

Completed

Failed

PartiallyRefunded

Refunded

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

  1. Dodatnia kwota — Kwota Payment musi być > 0
  2. Zgodna waluta — Waluta Payment musi odpowiadać walucie Order
  3. Refund w ramach limitu — Refund nie może przekroczyć oryginalnej Payment
  4. Brak podwójnego Refund — Nie można zwrócić więcej niż kwota dostępna do Refund
  5. Refund ma oryginał — Payment będąca Refund musi wskazywać na oryginalną Payment
  6. Dane karty tylko dla kart — cardLast4/cardBrand tylko gdy metoda to Card
  7. 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 200

Zagadnienia 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)
  • Order — Za co się płaci
  • Party Role (Customer) — Kto płaci
  • Shipment — Uruchamiana po potwierdzeniu Payment
  • Faktura — Formalny dokument wezwania do zapłaty