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¶
- Customer Consistency: All payment intents for a team use same
stripe_customer_id - Amount Precision: Frontend and Stripe both agree on amount (cents)
- Payment Method Ownership: Payment method belongs only to one billing account
- Status Sync: Payment intent status must be confirmed before transaction recording
- 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)
Related Documentation¶
- Billing Domain - Balance and transaction recording
- Payment System Guide - Detailed Stripe integration
- Auto-Refill Logic - Balance-triggered charging