Core Entities
PaymentIntent: intent_id (idempotency key), amount_cents, currency, customer_id, payment_method_id, status (CREATED, PROCESSING, SUCCEEDED, FAILED, CANCELLED), created_at, metadata. PaymentMethod: method_id, customer_id, type (CARD, BANK, WALLET), last_four, card_brand, billing_address, is_default, tokenized_card_id (from card vault). Refund: refund_id, payment_intent_id, amount_cents, reason, status (PENDING, SUCCEEDED, FAILED), created_at. FraudSignal: signal_id, payment_intent_id, signal_type, score, action (ALLOW, REVIEW, BLOCK).
Card Processing Flow
1. Customer submits card details -> client-side tokenization (Stripe.js / Braintree SDK) — raw card data never touches your server. 2. Your server receives the token and creates a PaymentIntent. 3. Submit to card network via acquirer: Authorization request (Reserve funds on card). 4. Card network routes to issuer. 5. Issuer approves/declines (based on funds, fraud rules). 6. Authorization response: approved with authorization_code, or declined with reason_code. 7. On approval: Capture (move funds from card to merchant). Authorization and capture can be separate (authorize-capture flow for pre-authorization) or combined (charge immediately). 8. Settlement: acquirer batches and settles with card network daily.
Idempotency
Payment operations must be idempotent — a client retry (network timeout) should not double-charge. Implementation: the client generates an idempotency_key (UUID) for each payment attempt. Server: SELECT * FROM payment_intents WHERE idempotency_key = X. If found: return the existing result (same response as the original). If not found: process and store with the key. The SELECT + INSERT must be atomic: use INSERT … ON CONFLICT (idempotency_key) DO NOTHING and check if the row was inserted. Idempotency keys expire after 24 hours. Same key, different amount: reject (key collision with conflicting parameters — return error).
Refund Processing
class RefundService:
def create_refund(self, intent_id: str, amount_cents: int,
reason: str) -> Refund:
intent = self.db.get_intent(intent_id)
if intent.status != PaymentStatus.SUCCEEDED:
raise ValueError("Can only refund succeeded payments")
already_refunded = self.db.sum_refunds(intent_id)
if already_refunded + amount_cents > intent.amount_cents:
raise ValueError("Refund exceeds original charge")
refund = Refund(intent_id=intent_id, amount_cents=amount_cents,
reason=reason, status=RefundStatus.PENDING)
self.db.insert(refund)
result = self.acquirer.refund(
intent.acquirer_transaction_id, amount_cents
)
status = (RefundStatus.SUCCEEDED
if result.success else RefundStatus.FAILED)
self.db.update_refund(refund.id, status)
return refund
Fraud Detection
Rule-based signals: velocity checks (more than 3 failed attempts in 10 minutes from same IP/card), high-risk countries, mismatched billing address vs IP geolocation, unusual purchase amount vs customer history. ML signals: model trained on historical fraud data returns a fraud score [0, 1]. Actions based on score: score 0.7: decline. Card velocity: track failed authorization attempts per card_number per hour in Redis (INCR card:{hashed_number}:failures:hour EXPIRE 3600). Decline the card if failures > 3 in any hour window.
Card Vault
Never store raw card numbers (PAN) in your database — this requires PCI DSS compliance at the highest level. Use a card vault (Stripe, Braintree, or self-hosted like Basis Theory). The vault stores the raw PAN and returns a token. You store the token. For recurring charges: submit the token to the vault; it retrieves the PAN and processes the charge. This limits your PCI scope dramatically (SAQ A vs SAQ D). Network tokens: Visa and Mastercard issue network tokens that replace the PAN — immune to merchant database breaches since the token is useless without the merchant-specific key.
Webhook Delivery
Payment outcomes (success, failure, dispute) are delivered asynchronously via webhooks. Your system must handle: duplicate webhook deliveries (same event sent twice), out-of-order delivery, and processing failures. Implement an idempotent webhook handler: store processed webhook event_ids; skip duplicates. Use FOR UPDATE SKIP LOCKED for concurrent webhook processors. Sign webhooks with HMAC (Stripe uses X-Stripe-Signature) and verify signatures before processing. Retry failed webhook processing with exponential backoff.
Asked at: Stripe Interview Guide
Asked at: Coinbase Interview Guide
Asked at: Shopify Interview Guide
Asked at: Airbnb Interview Guide