Software Archetypes

The Payment Pattern

Based on "Enterprise Patterns and MDA" by Jim Arlow & Ila Neustadt

Problem

Orders need payment tracking, but it's more complex than a boolean flag:

  • Multiple payment methods — Card, bank transfer, cash, digital wallets
  • Partial payments — Customer pays in installments
  • Refunds — Returning money for returns/cancellations
  • Gateway integration — External transaction IDs, webhook handling
  • Payment lifecycle — Pending → Processing → Completed → Refunded
  • Audit requirements — Full history for accounting and compliance

Concept

Payment is a financial transaction entity — money moving IN (payment) or OUT (refund). It has its own lifecycle independent of the Order, stores gateway references, and provides a complete audit trail.

Key insight: A Refund is also a Payment, just in the opposite direction.

Structure

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

Attributes

Payment

Attribute Type Required Description
id UUID Yes Unique identifier
reference String Yes Human-readable reference
order Order Yes Order being paid
customer Customer Yes Who is paying
amount Money Yes Payment amount
currency String Yes ISO currency code
method PaymentMethod Yes How payment was made
status PaymentStatus Yes Current payment state
isRefund Boolean Yes Is this a refund (money out)?
originalPayment Payment No For refunds: the original payment
gatewayName String No Payment gateway used
gatewayTransactionId String No External transaction ID
gatewayResponse JSON No Raw gateway response
failureReason String No Why payment failed
cardLast4 String No Last 4 digits (masked card)
cardBrand String No Visa, Mastercard, etc.
createdAt Timestamp Yes When payment was initiated
completedAt Timestamp No When payment was completed

Behaviors

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

Examples

Paying for an order by card

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

Partial 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"

State Machine

create

markProcessing()

cancel()

complete()

fail()

partial refund completed

full refund completed

remaining refunded

Pending

Processing

Cancelled

Completed

Failed

PartiallyRefunded

Refunded

Status Meanings

Status Description Can Transition To
Pending Awaiting action (redirect to gateway) Processing, Cancelled
Processing Sent to gateway, awaiting response Completed, Failed
Completed Money received successfully PartiallyRefunded, Refunded
Failed Gateway rejected (card declined, etc.) —
Cancelled Cancelled before completion —
PartiallyRefunded Some money returned to customer Refunded
Refunded All money returned to customer —

Invariants

  1. Positive amount — Payment amount must be > 0
  2. Matching currency — Payment currency must match order currency
  3. Refund within limit — Refund cannot exceed original payment
  4. No double refund — Cannot refund more than refundable amount
  5. Refund has original — Refund payment must reference original payment
  6. Card info only for cards — cardLast4/cardBrand only when method is Card
  7. Valid state transitions — Only allowed transitions per state machine

Error Handling

Operation Precondition Violated Error
markProcessing() Status not Pending PreconditionError: can only process pending payments
complete(txId) Status not Pending/Processing PreconditionError: cannot complete payment in status Failed
fail(reason) Status not Pending/Processing PreconditionError: cannot fail already completed payment
cancel() Status not Pending PreconditionError: can only cancel pending payments
createRefund(original, amount, ref) Original not refundable PreconditionError: payment in status Failed cannot be refunded
createRefund(original, amount, ref) amount <= 0 PreconditionError: refund amount must be positive
createRefund(original, amount, ref) Amount > refundable PreconditionError: refund 500.00 exceeds refundable amount 449.00
setCardInfo(last4, brand) Method not Card PreconditionError: card info only for card payments

Idempotency: complete() on an already-completed payment is a no-op (see Gateway Integration section). This prevents double-processing from duplicate webhooks.

SQL Schema

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;

Common Queries

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;

Gateway Integration Pattern

// 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

Design Considerations

Money in Cents

// 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"

Refunds as Payments

Modeling refunds as Payment entities (with isRefund = true) rather than separate entity:

Approach Pros Cons
Refund as Payment Unified handling, same gateway Need isRefund flag
Separate Refund Cleaner type distinction Duplicated code, two tables

Recommendation: Refund as Payment with self-reference to original.

Card Data Security (PCI)

Never store:

  • Full card number
  • CVV/CVC
  • Full expiry date

Safe to store:

  • Last 4 digits
  • Card brand (Visa, MC)
  • Cardholder name (optional)

Gateway handles actual card data. You only store reference and masked info.

Idempotency

Gateway webhooks can arrive multiple times. Handle idempotently:

function handlePaymentSuccess(payment: Payment):
    if payment.status == PaymentStatus.Completed:
        return  // Already processed, skip
    
    payment.complete(transactionId)
  • Order — What is being paid for
  • Party Role (Customer) — Who is paying
  • Shipment — Triggered after payment confirmed
  • Invoice — Formal payment request document