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 nameservice_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 settingsis_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 disbursementapply_payment()--- Allocates a payment across fees, interest, and principal per the program's allocation ordercalculate_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 paymentcapture()--- Capture an authorized paymentvoid()--- Void an authorizationrefund()--- 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 URLget_download_url()--- Generate a presigned download URLdelete()--- 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:
- A master encryption key is stored in the
PROVIDER_ENCRYPTION_KEYenvironment variable - Per-tenant keys are derived via HKDF (master key + tenant UUID as salt)
- The
configJSONB field onProviderConfigis transparently encrypted/decrypted at the model level - 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:
- Check for a
ProviderAssignmentmatching the lending program + service type - Fall back to the tenant's default
ProviderConfigfor that service type - Return
Noneif 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¶
- Architecture Overview --- Where providers fit in the system
- Layered Architecture --- The provider layer in the 5-layer model
- Cross-Module Communication --- How services interact with providers