Skip to content

Provider Pattern

Modules with multiple backends use a typing.Protocol-based provider pattern for extensibility without tight coupling. This allows swapping implementations (e.g., payment processors, email services, document storage) per tenant without changing business logic.

How It Works

1. Protocol Definition

Each parent app defines a Protocol class in providers/__init__.py specifying the interface that all implementations must satisfy:

from typing import Protocol

class PaymentProcessorProtocol(Protocol):
    async def authorize(self, amount: Decimal, ...) -> AuthResult: ...
    async def capture(self, transaction_id: str) -> CaptureResult: ...
    async def void(self, transaction_id: str) -> VoidResult: ...
    async def refund(self, transaction_id: str, amount: Decimal) -> RefundResult: ...

2. Implementations

Providers are either subapps with their own models and migrations (e.g., payments/authorizenet/) or stateless modules (e.g., a compliance rule engine):

# apps/payments/authorizenet/provider.py
class AuthorizeNetProvider:
    async def authorize(self, amount: Decimal, ...) -> AuthResult:
        # Call Authorize.Net API
        ...

3. Registry

A ProviderRegistry maps string keys to provider classes. Each protocol has its own registry instance.

4. Per-Tenant Configuration

The ProviderConfig model (in apps.providers) stores configuration and encrypted credentials per tenant:

  • label --- Human-readable name
  • service_type --- Which protocol this config is for (e.g., "payment_processor")
  • provider_key --- Which implementation to use (e.g., "authorizenet", "stripe")
  • config --- JSONB field with encrypted credentials and settings
  • is_active --- Whether this provider is currently enabled

5. Per-Program Assignment

The ProviderAssignment model maps providers to specific lending programs:

  • A lending program can use a different payment processor than the tenant default
  • Resolution chain: program-level override → tenant default → None

Defined Protocols

Protocol Module Implementations
LendingProgramProtocol programs Installment Loan, Lease, Line of Credit
PaymentProcessorProtocol payments Authorize.Net, Stripe
DocumentStorageProtocol documents S3, Local filesystem
EmailProviderProtocol communications SendGrid, SMTP
SMSProviderProtocol communications Twilio
LetterProviderProtocol communications Lob
ComplianceRuleProtocol compliance 10 built-in rule engines
ReportGeneratorProtocol reporting 14 built-in report generators

Lending Program Protocol

The most complex protocol --- defines how a class of loans behaves:

  • generate_schedule() --- Creates the amortization schedule on disbursement
  • apply_payment() --- Allocates a payment across fees, interest, and principal per the program's allocation order
  • calculate_payoff() --- Computes total payoff amount including penalties

Three built-in implementations handle installment loans, leases, and lines of credit, each with different lifecycle states, amortization logic, and payment allocation rules.

Payment Processor Protocol

Handles external payment processing:

  • authorize() --- Authorize a payment
  • capture() --- Capture an authorized payment
  • void() --- Void an authorization
  • refund() --- Refund a captured payment

Two implementations: Authorize.Net (python-authorizenet AsyncClient) and Stripe (stripe SDK).

Document Storage Protocol

Handles document persistence:

  • get_upload_url() --- Generate a presigned upload URL
  • get_download_url() --- Generate a presigned download URL
  • delete() --- Remove a stored document

Two implementations: S3 (production) and Local filesystem (development).

Communication Protocols

Three protocols for multi-channel messaging:

  • EmailProviderProtocol --- send_email() with HTML/text body, attachments (SendGrid, SMTP)
  • SMSProviderProtocol --- send_sms() with text body (Twilio)
  • LetterProviderProtocol --- send_letter() with PDF attachment (Lob)

Credential Security

Provider credentials are encrypted at rest using Fernet symmetric encryption:

  1. A master encryption key is stored in the PROVIDER_ENCRYPTION_KEY environment variable
  2. Per-tenant keys are derived via HKDF (master key + tenant UUID as salt)
  3. The config JSONB field on ProviderConfig is transparently encrypted/decrypted at the model level
  4. API responses mask secrets (e.g., sk_...***1234) --- secrets are write-only through the API

Warning

Never log or expose provider credentials. The API layer masks all secret values in responses. Credentials can only be written, not read back.

Resolution Chain

When a service needs a provider, the resolution chain is:

  1. Check for a ProviderAssignment matching the lending program + service type
  2. Fall back to the tenant's default ProviderConfig for that service type
  3. Return None if no provider is configured (service handles gracefully)
# In a service function
provider_config = await resolve_provider_config(
    service_type="payment_processor",
    program=loan.program,
)
if provider_config is None:
    raise ValueError("No payment processor configured")

provider = registry.get(provider_config.provider_key)
result = await provider.authorize(amount=payment.amount, ...)

See Also