Skip to content

Payments Domain Implementation

Version: 1.0.0 Last Updated: 2026-02-11 Status: Stable

Overview

The Payments domain manages payment method lifecycle (saving, updating, deleting cards) and payment intent orchestration across multiple gateways (Stripe, PayPal). It abstracts provider-specific complexity from billing operations.

Key Responsibilities: - Create and manage payment intents (one-time and setup) - Save and manage payment methods securely - Handle payment confirmation and webhooks - Provide gateway-agnostic payment interfaces - Support multiple currencies and payment types


Architecture

graph TB
    PM["PaymentMethod<br/>(Saved Cards)<br/>- external_id<br/>- brand<br/>- last_four"]
    PI["PaymentIntent<br/>(One-time)<br/>- client_secret<br/>- amount<br/>- status"]
    SI["SetupIntent<br/>(Save Card)<br/>- client_secret<br/>- payment_method"]
    STRIPE["Stripe API<br/>- PaymentIntent<br/>- SetupIntent<br/>- Webhooks"]
    PAYPAL["PayPal API<br/>- Orders API<br/>- Subscriptions API"]

    PM -->|tokenized by| STRIPE
    PM -->|or| PAYPAL

    PI -->|created via| STRIPE
    SI -->|created via| STRIPE

    STRIPE -->|confirms| PI
    PI -->|triggers| WEBHOOK["Webhook Handler<br/>payment_intent.succeeded"]
    WEBHOOK -->|creates| TRANSACTION["BillingTransaction"]

    style PM fill:#f59e0b,color:#fff
    style PI fill:#ec4899,color:#fff
    style SI fill:#06b6d4,color:#fff
    style STRIPE fill:#3b82f6,color:#fff
    style PAYPAL fill:#0ea5e9,color:#fff

Data Model

No new tables—Payments uses BillingPaymentMethod and BillingTransaction from Billing domain.

Domain-specific objects:

PaymentIntent (In-Memory DTOs)

Represents a Stripe Payment Intent during checkout flow.

@dataclass
class PaymentIntent:
    """Stripe PaymentIntent wrapper."""
    payment_intent_id: str          # pi_1234abcd
    client_secret: str              # pi_1234abcd_secret_xyz
    amount_cents: int               # 5000 ($50.00)
    currency: str = "usd"
    status: str                     # "requires_payment_method" | "succeeded"
    payment_method_id: str | None   # pm_1234abcd (if saved)
    created_at: datetime

    def is_confirmed(self) -> bool:
        return self.status == "succeeded"

SetupIntent (In-Memory DTOs)

Represents a Stripe Setup Intent for saving cards without charging.

@dataclass
class SetupIntent:
    """Stripe SetupIntent for saving payment methods."""
    setup_intent_id: str            # seti_1234abcd
    client_secret: str              # seti_1234abcd_secret_xyz
    status: str                     # "requires_payment_method" | "succeeded"
    payment_method_id: str | None   # pm_1234abcd
    created_at: datetime

    def is_setup(self) -> bool:
        return self.status == "succeeded"

PaymentRequest

API request DTOs.

class StripePaymentRequest(BaseModel):
    """Create a Stripe Payment Intent."""
    amount: int                     # Cents
    payment_method_id: str | None   # Optional pre-saved card
    description: str | None         # e.g., "Balance refill"

class PaymentConfirmRequest(BaseModel):
    """Confirm payment and update balance."""
    payment_intent_id: str
    amount: Decimal                 # Amount charged

class SetupIntentRequest(BaseModel):
    """Create a Setup Intent to save a card."""
    payment_method_id: str          # Temporary pm_xxx from Stripe.js

class PaymentMethodRequest(BaseModel):
    """Save or update payment method."""
    external_method_id: str         # pm_1234abcd
    is_default: bool = False
    payment_metadata: dict | None

Business Logic

Core Operations

1. Create Payment Intent

Operation: create_payment_intent(
    team_id, amount_cents, payment_method_id=None, description=None
)
Precondition:
  - Team exists
  - amount_cents > 0
  - Stripe API key configured
Postcondition: PaymentIntent created
Returns: PaymentIntent with client_secret
Side Effects: Stripe API call (POST /v1/payment_intents)
Idempotent: No (creates new intent each time)

Stripe Call:

stripe.PaymentIntent.create(
    amount=amount_cents,
    currency="usd",
    customer=stripe_customer_id,
    payment_method=payment_method_id,
    off_session=False,  # Customer present
    confirm=False,      # Require frontend confirmation
    metadata={"team_id": team_id, "type": "balance_refill"}
)

Frontend receives client_secret → uses Stripe.js to confirm with card.

2. Confirm Payment

Operation: confirm_payment(
    team_id, payment_intent_id, amount
)
Precondition:
  - PaymentIntent status = "succeeded"
  - BillingAccount exists
  - Amount matches intent
Postcondition:
  - BillingTransaction recorded
  - Balance updated
  - Invoice generated async
Returns: PaymentResult (balance, transaction_id)
Side Effects:
  - writes BillingTransaction
  - updates BillingAccount.balance
  - queues RabbitMQ job for invoice
Error: If intent not confirmed by Stripe, reject

Safety Checks: - Verify payment_intent.status == "succeeded" - Verify payment_intent.amount == amount * 100 - Verify payment_intent.customer == billing_account.stripe_customer_id - Check idempotency: if transaction exists for this payment_intent_id, return existing

3. Create Setup Intent

Operation: create_setup_intent(team_id)
Precondition: Team exists, Stripe configured
Postcondition: SetupIntent created
Returns: SetupIntent with client_secret
Side Effects: Stripe API call

Used for saving a card without immediate charge. Frontend uses client_secret with Stripe.js.

4. Save Payment Method

Operation: save_payment_method(
    team_id, external_method_id,
    card_brand, card_last_four,
    card_exp_month, card_exp_year,
    is_default=False
)
Precondition:
  - BillingAccount exists
  - external_method_id exists in Stripe
Postcondition: BillingPaymentMethod inserted
Returns: BillingPaymentMethod object
Side Effects: May update is_default on existing methods
Idempotent: No (will create duplicate if called twice with same card)

Called after successful SetupIntent confirmation.

5. Delete Payment Method

Operation: delete_payment_method(team_id, payment_method_id)
Precondition:
  - PaymentMethod exists and belongs to team
  - IsDefault methods need replacement
Postcondition: PaymentMethod deleted from Stripe & DB
Returns: Boolean (success)
Side Effects:
  - Detaches from Stripe (stripe.PaymentMethod.detach)
  - Removes from BillingPaymentMethod
Error: Cannot delete last default method without replacement

6. Set Default Payment Method

Operation: set_default_payment_method(team_id, payment_method_id)
Precondition: PaymentMethod exists and belongs to team
Postcondition: is_default flag updated
Side Effects: May unset is_default on previous default

Used by auto-refill setup and payment flows.

7. Trigger Off-Session Charge (Auto-Refill)

Operation: charge_payment_method(
    team_id, payment_method_id, amount_cents,
    description="Auto-refill"
)
Precondition:
  - PaymentMethod exists and belongs to team
  - amount_cents > 0
Postcondition:
  - PaymentIntent created with off_session=True
  - Stripe charges immediately
  - BillingTransaction recorded if succeeded
Returns: PaymentResult
Side Effects:
  - Network call to Stripe
  - Database writes
Error Handling:
  - Stripe declines: Record FAILED transaction, send owner email
  - Network error: Retry with exponential backoff
Idempotent: No (will charge multiple times if retried naively)

Stripe Call:

stripe.PaymentIntent.create(
    amount=amount_cents,
    currency="usd",
    customer=stripe_customer_id,
    payment_method=payment_method_id,
    off_session=True,        # No customer present (server-initiated)
    confirm=True,            # Charge immediately
    metadata={"team_id": team_id, "type": "auto_refill"}
)


State Machine

Payment Intent Lifecycle:

stateDiagram-v2
    [*] --> RequiresPaymentMethod: Create intent
    RequiresPaymentMethod --> RequiresAction: Payment method set
    RequiresAction --> Succeeded: Client confirms (Stripe.js)
    RequiresAction --> RequiresPaymentMethod: Confirmation failed
    Succeeded --> [*]: Confirm on backend
    Succeeded --> Canceled: timeout (30 min)
    Canceled --> [*]
    note right of RequiresAction
        Stripe waits for client-side confirmation
        with CardElement, Apple Pay, or Google Pay
    end note

Setup Intent Lifecycle:

stateDiagram-v2
    [*] --> RequiresPaymentMethod: Create setup intent
    RequiresPaymentMethod --> RequiresAction: Payment method set
    RequiresAction --> Succeeded: Client confirms
    RequiresAction --> RequiresPaymentMethod: Confirmation failed
    Succeeded --> [*]: Save payment method
    Succeeded --> Canceled: timeout
    Canceled --> [*]

Off-Session Charge Sequence:

sequenceDiagram
    Backend->>Stripe: POST /payment_intents (off_session=true, confirm=true)
    Stripe-->>Backend: PaymentIntent (status=succeeded/processing)
    Backend->>Database: Record COMPLETED BillingTransaction
    Backend->>RabbitMQ: Enqueue "invoice.generate" job
    RabbitMQ->>Worker: Process job async
    Worker->>S3: Generate & upload PDF
    Worker->>Email: Send receipt

Invariants

  1. Customer Consistency: All payment intents for a team use same stripe_customer_id
  2. Amount Precision: Frontend and Stripe both agree on amount (cents)
  3. Payment Method Ownership: Payment method belongs only to one billing account
  4. Status Sync: Payment intent status must be confirmed before transaction recording
  5. Default Method Exists: At least one saved method must be marked default if auto-refill enabled

API Endpoints

One-Time Payments

Endpoint Method Permission Purpose
/api/v1/billing/stripe/payment-intent POST billing.edit Create payment intent
/api/v1/billing/stripe/confirm-payment POST billing.edit Confirm & record transaction
/api/v1/billing/paypal/order POST billing.edit Create PayPal order

Setup (Save Card)

Endpoint Method Permission Purpose
/api/v1/billing/stripe/setup-intent POST billing.edit Create setup intent
/api/v1/billing/payment-methods POST billing.edit Save payment method

Payment Method Management

Endpoint Method Permission Purpose
/api/v1/billing/payment-methods GET billing.view List saved cards
/api/v1/billing/payment-methods/{id} DELETE billing.edit Delete card
/api/v1/billing/payment-methods/{id}/default PUT billing.edit Set as default

External Integrations

Stripe

API Methods Used:

Method Purpose When
stripe.PaymentIntent.create() Create one-time payment intent User initiates refill
stripe.PaymentIntent.confirm() (via client SDK) Customer approves payment
stripe.SetupIntent.create() Create intent for saving card User saves card
stripe.PaymentMethod.detach() Remove payment method User deletes card
stripe.Webhook.construct_event() Verify webhook signature Webhook received

Webhook Events Handled:

Event Action Idempotency Key
payment_intent.succeeded Record COMPLETED transaction, generate invoice payment_intent.id
payment_intent.payment_failed Record FAILED transaction, notify owner payment_intent.id
customer.subscription.created (Future) Record subscription subscription.id

Webhook Security:

event = stripe.Webhook.construct_event(
    payload,
    stripe_signature_header,
    webhook_secret_key  # From config
)
# Raises SignatureVerificationError if tampering detected

PayPal (Future)

Planned Integration Points: - Orders API v2 for PayPal guest checkout - Subscriptions API v2 for recurring auto-refill - Webhook handling for completion events

Current Support: Foundation laid, not active


Caching Strategy

Redis Keys

# Payment methods list (10 minutes)
f"payments:{team_id}:methods"  JSON array

# Default payment method (5 minutes)
f"payments:{team_id}:default_method"  JSON

# Recent payment intents (1 minute)
f"payments:{team_id}:recent_intents"  JSON array

Invalidation Triggers

  • On payment method create/delete
  • On payment confirmation
  • On default method change

Error Handling

Error Cause Response Recovery
Invalid Card Card declined 402 Payment Required User retries with different card
Insufficient Funds Card limit exceeded 402 Payment Required User tries different card
Expired Setup Intent 30+ min elapsed 400 Bad Request User creates new intent
Stripe API Error Network/rate limit 503 Service Unavailable Retry with backoff
Webhook Signature Invalid Replay/tampering 401 Unauthorized Discard, log, alert
Missing Payment Intent DB corruption 500 Internal Error Admin investigation

Concurrency & Idempotency

Payment Confirmation Idempotency

If same payment_intent_id confirmed twice:

# First call
if transaction_exists(payment_intent_id):
    return existing_transaction_result  # Return cached result

# New transaction
transaction = await record_transaction(payment_intent_id, ...)
return transaction

Stripe's idempotency: Use Idempotency-Key header on intent creation.

Off-Session Charge Prevention

Use per-team distributed lock:

async with redis_lock(f"payment:{team_id}:charge_lock", ttl=5):
    # Check if charge in progress
    if redis.get(f"payment:{team_id}:charging"):
        raise ConflictError("Charge already in progress")

    redis.set(f"payment:{team_id}:charging", "true", ex=5)
    await charge_payment_method(...)

Testing

Unit Tests

  • PaymentIntent status transitions
  • SetupIntent confirmation flow
  • Payment method CRUD
  • Error responses for declined cards

Integration Tests

  • Full one-time payment flow (test Stripe keys)
  • Setup intent → save card → confirm
  • Off-session charge (auto-refill simulation)
  • Webhook signature verification
  • Idempotency key reuse

E2E Tests

  • User refills via Stripe card
  • User saves card → auto-refill triggers
  • PayPal flow (mocked until integration)