Payment Systems Architecture: Designing Secure Multi-Currency Transaction Platforms
The Stakes of Payment Architecture
Payment systems don’t get second chances. A bug that double-charges customers destroys trust instantly. A security vulnerability that exposes card data triggers regulatory action and reputational damage that persists for years. A failure during peak traffic turns revenue into refund costs and support tickets.
The difference between payment systems that work and those that fail isn’t primarily technical sophistication. It’s architectural discipline—designing systems that are correct by construction, that fail safely when they do fail, and that provide the visibility needed to detect and respond to problems quickly.
This post examines the architectural patterns that enable payment systems to process transactions reliably at scale, across multiple currencies and payment methods, while meeting the security and compliance requirements that define the payments industry.
Core Architectural Principles
Principle 1: Exactly-Once Processing
The cardinal rule of payment systems: process each transaction exactly once. Never double-charge. Never fail to charge after confirming success.
Idempotency Keys Every payment request includes a client-generated idempotency key:
POST /payments
Idempotency-Key: pay_req_abc123def456
{
"amount": 10000,
"currency": "AUD",
"source": "card_xyz789"
}
The system guarantees:
- Same idempotency key always returns same result
- If request succeeded previously, return cached success
- If request failed previously, return cached failure
- If request is in progress, wait and return that result
Implementation:
def process_payment(request: PaymentRequest) -> PaymentResult:
# Check for existing result
existing = idempotency_store.get(request.idempotency_key)
if existing:
return existing.result
# Acquire lock to prevent concurrent processing
lock = idempotency_store.lock(request.idempotency_key)
if not lock.acquired:
# Another process is handling this request
return wait_for_result(request.idempotency_key)
try:
# Process payment
result = execute_payment(request)
# Store result for idempotency
idempotency_store.set(
request.idempotency_key,
result,
ttl=24*3600 # Keep for 24 hours
)
return result
finally:
lock.release()
Principle 2: Transactional Integrity
Payment operations must be atomic. Either the entire operation succeeds or nothing changes.
Saga Pattern for Distributed Transactions:
A multi-step payment flow (authorize, capture, notify) uses compensating transactions:

class PaymentSaga:
def execute(self, payment: Payment):
steps = [
(self.reserve_inventory, self.release_inventory),
(self.authorize_card, self.void_authorization),
(self.capture_payment, self.refund_payment),
(self.update_ledger, self.reverse_ledger),
(self.send_confirmation, None), # No compensation needed
]
completed_steps = []
try:
for (action, compensation) in steps:
action(payment)
if compensation:
completed_steps.append((compensation, payment))
except Exception as e:
# Compensate in reverse order
for (compensation, data) in reversed(completed_steps):
try:
compensation(data)
except Exception as comp_error:
# Log and alert on compensation failure
self.alert_compensation_failure(comp_error)
raise PaymentFailedException(e)
Principle 3: Audit Everything
Every action in a payment system must be traceable:
def authorize_payment(payment: Payment, context: RequestContext):
event = PaymentEvent(
event_id=generate_uuid(),
event_type="AUTHORIZATION_ATTEMPT",
payment_id=payment.id,
timestamp=datetime.utcnow(),
actor=context.user_id,
ip_address=context.ip_address,
request_data=redact_sensitive(payment.to_dict()),
request_id=context.request_id,
)
audit_log.write(event)
try:
result = payment_processor.authorize(payment)
success_event = PaymentEvent(
event_type="AUTHORIZATION_SUCCESS",
payment_id=payment.id,
result_code=result.code,
processor_reference=result.reference,
# ... other fields
)
audit_log.write(success_event)
return result
except ProcessorException as e:
failure_event = PaymentEvent(
event_type="AUTHORIZATION_FAILURE",
payment_id=payment.id,
error_code=e.code,
error_message=e.message,
)
audit_log.write(failure_event)
raise
Multi-Currency Architecture
Currency Representation
Never use floating point for currency. Ever.
Integer Minor Units: Store amounts as integers in the smallest currency unit:
class Money:
def __init__(self, amount_minor: int, currency: str):
self.amount_minor = amount_minor # Cents, pence, etc.
self.currency = currency
@classmethod
def from_decimal(cls, amount: Decimal, currency: str):
minor_units = CURRENCY_MINOR_UNITS.get(currency, 2)
amount_minor = int(amount * (10 ** minor_units))
return cls(amount_minor, currency)
def to_decimal(self) -> Decimal:
minor_units = CURRENCY_MINOR_UNITS.get(self.currency, 2)
return Decimal(self.amount_minor) / (10 ** minor_units)
Currency Metadata: Handle currency-specific rules:
CURRENCY_CONFIG = {
'AUD': {'minor_units': 2, 'symbol': '$', 'rounding': 'HALF_UP'},
'JPY': {'minor_units': 0, 'symbol': '¥', 'rounding': 'HALF_UP'},
'KWD': {'minor_units': 3, 'symbol': 'د.ك', 'rounding': 'HALF_UP'},
'BTC': {'minor_units': 8, 'symbol': '₿', 'rounding': 'DOWN'},
}
Exchange Rates
Rate Management:
class ExchangeRateService:
def get_rate(self, from_currency: str, to_currency: str,
rate_type: str = 'mid') -> Decimal:
"""
Get exchange rate with appropriate spread.
rate_type: 'mid', 'buy', 'sell'
"""
mid_rate = self.rate_provider.get_rate(from_currency, to_currency)
if rate_type == 'mid':
return mid_rate
elif rate_type == 'buy':
return mid_rate * (1 - self.spread)
elif rate_type == 'sell':
return mid_rate * (1 + self.spread)

def convert(self, amount: Money, to_currency: str,
rate_type: str = 'mid') -> Money:
if amount.currency == to_currency:
return amount
rate = self.get_rate(amount.currency, to_currency, rate_type)
converted_amount = amount.amount_minor * rate
# Apply rounding rules for target currency
rounded = self.apply_rounding(converted_amount, to_currency)
return Money(rounded, to_currency)
Rate Snapshots: Lock rates at transaction time:
class Transaction:
def __init__(self, amount: Money, settlement_currency: str):
self.amount = amount
self.settlement_currency = settlement_currency
# Capture rate at transaction creation
self.exchange_rate = exchange_service.get_rate(
amount.currency, settlement_currency, 'sell'
)
self.settlement_amount = exchange_service.convert(
amount, settlement_currency, rate=self.exchange_rate
)
Multi-Currency Settlement
Settlement Account Structure:
[Customer Transaction: AUD 100]
|
v
[Merchant Settlement Account: AUD]
|
v (if different currency needed)
[FX Conversion at locked rate]
|
v
[Bank Account: Target Currency]
Reconciliation:
class SettlementReconciliation:
def reconcile_period(self, merchant_id: str, period: DateRange):
# Sum expected settlements
expected = self.calculate_expected_settlement(merchant_id, period)
# Get actual bank transactions
actual = self.get_bank_transactions(merchant_id, period)
# Match transactions
matched, unmatched_expected, unmatched_actual = self.match(
expected, actual
)
if unmatched_expected or unmatched_actual:
self.raise_reconciliation_exception(
matched, unmatched_expected, unmatched_actual
)
return ReconciliationResult(matched=matched, status='SUCCESS')
Payment Method Abstraction
Unified Payment Interface
Abstract different payment methods behind a common interface:
class PaymentMethod(Protocol):
def authorize(self, payment: Payment) -> AuthorizationResult:
"""Reserve funds without capturing."""
...
def capture(self, authorization: Authorization,
amount: Money = None) -> CaptureResult:
"""Capture previously authorized funds."""
...
def charge(self, payment: Payment) -> ChargeResult:
"""Authorize and capture in single step."""
...
def refund(self, transaction: Transaction,
amount: Money = None) -> RefundResult:
"""Refund captured funds."""
...
def void(self, authorization: Authorization) -> VoidResult:
"""Cancel authorization without capturing."""
...
Implementation for Cards:
class CardPaymentMethod:
def __init__(self, processor: CardProcessor, tokenizer: Tokenizer):
self.processor = processor
self.tokenizer = tokenizer
def authorize(self, payment: Payment) -> AuthorizationResult:
# Get card details from secure token
card_data = self.tokenizer.detokenize(payment.card_token)
# Call processor
result = self.processor.authorize(
card_data=card_data,
amount=payment.amount,
merchant=payment.merchant_id,
metadata={
'order_id': payment.order_id,
'customer_id': payment.customer_id,
}
)
return AuthorizationResult(
success=result.approved,
authorization_code=result.auth_code,
processor_reference=result.transaction_id,
decline_reason=result.decline_reason if not result.approved else None,
)
Implementation for Bank Transfers:
class BankTransferPaymentMethod:
def authorize(self, payment: Payment) -> AuthorizationResult:
# Bank transfers don't support authorization
# Return immediate authorization that will be verified on capture
return AuthorizationResult(
success=True,
authorization_code=generate_reference(),
requires_async_capture=True,
)
def capture(self, authorization: Authorization,
amount: Money = None) -> CaptureResult:
# Initiate bank transfer
transfer = self.bank_service.initiate_transfer(
from_account=authorization.payment.source_account,
to_account=self.merchant_account,
amount=amount or authorization.amount,
reference=authorization.authorization_code,
)
# Transfer is asynchronous
return CaptureResult(
success=True,
status='PENDING',
expected_completion=transfer.estimated_arrival,
)
Payment Routing
Route payments to optimal processor:
class PaymentRouter:
def route(self, payment: Payment) -> PaymentMethod:
"""Select best payment method/processor based on rules."""
candidates = self.get_eligible_processors(payment)
if not candidates:
raise NoEligibleProcessorError(payment)
# Score candidates
scored = []
for candidate in candidates:
score = self.calculate_score(candidate, payment)
scored.append((candidate, score))
# Select best
scored.sort(key=lambda x: x[1], reverse=True)
return scored[0][0]
def calculate_score(self, processor, payment) -> float:
score = 0
# Cost optimization
fee = processor.calculate_fee(payment)
score -= fee * 100 # Lower fees = higher score
# Success rate (historical)
success_rate = self.get_success_rate(
processor, payment.card_bin, payment.currency
)
score += success_rate * 50
# Latency
avg_latency = self.get_avg_latency(processor)
score -= avg_latency * 0.1
return score
Security Architecture
Tokenization
Never store raw card data. Replace with tokens:
[Card: 4111 1111 1111 1111] --> [Token: tok_abc123xyz]
Tokenization Flow:
class TokenizationService:
def tokenize(self, card_data: CardData) -> str:
# Validate card data
self.validate(card_data)
# Generate token
token = self.generate_token()
# Store encrypted card data in secure vault
encrypted = self.vault.encrypt(card_data)
self.vault.store(token, encrypted)
return token
def detokenize(self, token: str) -> CardData:
# Retrieve from vault
encrypted = self.vault.retrieve(token)
# Decrypt (only within PCI-compliant environment)
card_data = self.vault.decrypt(encrypted)
# Log access for audit
self.audit_log.log_detokenization(token)
return card_data
PCI DSS Compliance
Payment Card Industry Data Security Standard requires:
Network Segmentation:
[Internet] --> [WAF] --> [API Gateway]
|
v
[Application Tier]
|
v
[Card Data Environment]
(Isolated network segment)
Data Protection:
- Encryption in transit (TLS 1.2+)
- Encryption at rest (AES-256)
- Key management (HSM for production keys)
- Access logging
Access Control:
class PaymentAccessControl:
def check_access(self, user: User, operation: str, resource: str) -> bool:
# Role-based access
if operation == 'view_card_data':
return user.has_role('card_data_viewer') and \
user.has_completed_pci_training()
if operation == 'process_payment':
return user.has_role('payment_processor') and \
user.is_authenticated_mfa()
return False
Fraud Detection
Multi-layer fraud prevention:
Rule-Based Screening:
class FraudRules:
rules = [
# Velocity rules
('max_transactions_per_hour', 10),
('max_amount_per_day', Money(5000, 'AUD')),
# Geographic rules
('block_high_risk_countries', ['XX', 'YY']),
# Pattern rules
('max_declined_ratio_24h', 0.5),
]
def screen(self, payment: Payment) -> FraudScreenResult:
triggered_rules = []
for rule_name, threshold in self.rules:
if self.evaluate_rule(rule_name, threshold, payment):
triggered_rules.append(rule_name)
if triggered_rules:
return FraudScreenResult(
status='FLAGGED',
triggered_rules=triggered_rules,
recommended_action=self.get_action(triggered_rules)
)
return FraudScreenResult(status='PASSED')
ML-Based Scoring:
class MLFraudScorer:
def score(self, payment: Payment) -> float:
features = self.extract_features(payment)
# Features: transaction amount, time of day, device fingerprint,
# customer history, card BIN, shipping/billing match, etc.
score = self.model.predict_proba(features)[0]
return score # 0.0 = legitimate, 1.0 = fraudulent
3D Secure Integration:
class ThreeDSecure:
def authenticate(self, payment: Payment) -> AuthenticationResult:
# Check if 3DS is required/supported
if not self.should_authenticate(payment):
return AuthenticationResult(status='NOT_REQUIRED')
# Initiate authentication
challenge = self.initiate_authentication(payment)
if challenge.status == 'FRICTIONLESS':
# Silently authenticated
return AuthenticationResult(
status='AUTHENTICATED',
eci=challenge.eci,
authentication_value=challenge.cavv,
)
# Return challenge for customer completion
return AuthenticationResult(
status='CHALLENGE_REQUIRED',
challenge_url=challenge.url,
transaction_id=challenge.transaction_id,
)
Reliability Patterns
Circuit Breakers
Protect against processor failures:
class ProcessorCircuitBreaker:
def __init__(self, processor: PaymentProcessor,
failure_threshold: int = 5,
recovery_timeout: int = 30):
self.processor = processor
self.failure_threshold = failure_threshold
self.recovery_timeout = recovery_timeout
self.failures = 0
self.state = 'CLOSED'
self.last_failure_time = None
def process(self, payment: Payment) -> Result:
if self.state == 'OPEN':
if self.should_attempt_recovery():
self.state = 'HALF_OPEN'
else:
raise CircuitOpenError()
try:
result = self.processor.process(payment)
self.on_success()
return result
except ProcessorException as e:
self.on_failure()
raise
def on_failure(self):
self.failures += 1
self.last_failure_time = time.time()
if self.failures >= self.failure_threshold:
self.state = 'OPEN'
def on_success(self):
self.failures = 0
self.state = 'CLOSED'
Retry with Backoff
Handle transient failures:
class RetryablePaymentProcessor:
def process_with_retry(self, payment: Payment,
max_retries: int = 3) -> Result:
last_error = None
for attempt in range(max_retries):
try:
return self.processor.process(payment)
except TransientError as e:
last_error = e
wait_time = self.calculate_backoff(attempt)
time.sleep(wait_time)
except PermanentError:
# Don't retry permanent failures
raise
raise MaxRetriesExceeded(last_error)
def calculate_backoff(self, attempt: int) -> float:
# Exponential backoff with jitter
base_delay = 0.5
max_delay = 30
delay = min(base_delay * (2 ** attempt), max_delay)
jitter = random.uniform(0, delay * 0.1)
return delay + jitter
Dead Letter Queues
Handle unprocessable transactions:
class PaymentQueue:
def process_message(self, message: PaymentMessage):
try:
result = self.payment_service.process(message.payment)
self.acknowledge(message)
except TransientError:
# Requeue for retry
self.requeue(message, delay=30)
except PermanentError as e:
# Move to dead letter queue
self.dead_letter_queue.send(
message,
error=str(e),
timestamp=datetime.utcnow()
)
self.acknowledge(message)
def process_dead_letters(self):
"""Manual review and resolution of failed payments."""
for message in self.dead_letter_queue.receive():
# Log for investigation
self.alert_operations(message)
# These require manual intervention
Reconciliation and Reporting
Daily Reconciliation
class DailyReconciliation:
def reconcile(self, date: date) -> ReconciliationReport:
# Get all transactions for date
transactions = self.get_transactions(date)
# Get processor settlement files
processor_data = self.get_processor_settlements(date)
# Get bank statements
bank_data = self.get_bank_transactions(date)
# Match transactions
matched = []
discrepancies = []
for tx in transactions:
processor_match = self.find_processor_match(tx, processor_data)
bank_match = self.find_bank_match(tx, bank_data)
if processor_match and bank_match:
if self.amounts_match(tx, processor_match, bank_match):
matched.append(tx)
else:
discrepancies.append(
Discrepancy(tx, processor_match, bank_match, 'AMOUNT_MISMATCH')
)
else:
discrepancies.append(
Discrepancy(tx, processor_match, bank_match, 'MISSING_MATCH')
)
return ReconciliationReport(
date=date,
total_transactions=len(transactions),
matched=len(matched),
discrepancies=discrepancies,
)
Financial Reporting
class FinancialReporting:
def generate_settlement_report(self, merchant_id: str,
period: DateRange) -> SettlementReport:
transactions = self.get_transactions(merchant_id, period)
gross_amount = sum(tx.amount for tx in transactions
if tx.type == 'CAPTURE')
refunds = sum(tx.amount for tx in transactions
if tx.type == 'REFUND')
chargebacks = sum(tx.amount for tx in transactions
if tx.type == 'CHARGEBACK')
fees = sum(tx.processing_fee for tx in transactions)
net_amount = gross_amount - refunds - chargebacks - fees
return SettlementReport(
merchant_id=merchant_id,
period=period,
gross_amount=gross_amount,
refunds=refunds,
chargebacks=chargebacks,
fees=fees,
net_amount=net_amount,
transactions=transactions,
)
Operational Excellence
Monitoring
Key Metrics:
metrics:
# Volume
- transactions_per_second
- transaction_value_per_hour
# Success
- authorization_success_rate
- capture_success_rate
- decline_rate_by_reason
# Latency
- authorization_latency_p50
- authorization_latency_p99
# Fraud
- fraud_detection_rate
- false_positive_rate
# Financial
- net_settlement_amount
- chargeback_rate
- refund_rate
Alerting:
alerts:
- name: authorization_success_rate_low
condition: success_rate < 0.95
duration: 5m
severity: critical
- name: latency_high
condition: p99_latency > 2000ms
duration: 3m
severity: warning
- name: fraud_rate_spike
condition: fraud_rate > 2x_baseline
duration: 15m
severity: critical
Incident Response
Payment incidents require specific runbooks:
# Runbook: Payment Processing Failure
## Symptoms
- Elevated error rate on /payments endpoint
- Customer complaints about failed transactions
- Alerts from monitoring
## Immediate Actions
1. Check processor status page
2. Verify network connectivity to processor
3. Check circuit breaker status
4. Review recent deployments
## Escalation
- > 1% error rate: Page on-call engineer
- > 5% error rate: Page payments lead
- > 10% error rate: Incident commander + executive notification
## Recovery
1. If processor issue: Enable failover processor
2. If code issue: Rollback deployment
3. If infrastructure: Engage cloud provider support
## Post-Incident
- Customer communication if > 100 affected
- Reconciliation of failed transactions
- Post-mortem within 24 hours
Building Payment Systems
Payment systems are unforgiving. They demand:
- Perfect correctness (no double charging, no missed transactions)
- Extreme reliability (99.99%+ uptime)
- Robust security (PCI compliance, fraud prevention)
- Complete auditability (every action traced)
The architectural patterns in this post enable those requirements. But patterns alone aren’t sufficient. Payment systems require:
- Deep domain expertise in payment flows and regulations
- Rigorous testing including chaos engineering
- Comprehensive monitoring and alerting
- Practiced incident response
- Regular security assessments
The organisations that build successful payment platforms invest in all of these. They treat payment architecture as a strategic capability requiring sustained investment, not a one-time project.
That investment pays dividends: payment systems that process billions in transactions reliably, enabling the business to grow without payment infrastructure becoming a constraint.
Ash Ganda advises enterprise technology leaders on fintech architecture, security systems, and digital transformation strategy. Connect on LinkedIn for ongoing insights.