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
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 refundExamples
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() // truePartial 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
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
- Positive amount — Payment amount must be > 0
- Matching currency — Payment currency must match order currency
- Refund within limit — Refund cannot exceed original payment
- No double refund — Cannot refund more than refundable amount
- Refund has original — Refund payment must reference original payment
- Card info only for cards — cardLast4/cardBrand only when method is Card
- 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 200Design 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)Related Patterns
- Order — What is being paid for
- Party Role (Customer) — Who is paying
- Shipment — Triggered after payment confirmed
- Invoice — Formal payment request document