A unified interface abstracts payment providers behind a single contract. Stripe is
the live provider. The factory can route by cost and fall back when a provider is
unhealthy once a second provider is configured.
Every payment gateway implements a unified abstract interface. Business logic asks the
factory for a gateway and never names a provider. One provider is live today, so
every payment goes through Stripe.
Abstract Gateway Interface
The interface defines the contract: create_payment, verify_account,
process_refund, and more. Each provider implements the same methods,
so a provider can change without touching business logic.
Cost-Aware Routing
When more than one provider is configured, the factory can compare
percentage and flat fees for the payment amount. It has one provider
to choose from today.
Fallback Chains
The factory checks provider health before it returns a gateway and
can fall back to a configured second provider. The chain applies
once a second provider is configured.
ProviderCustomer Abstraction
Model decouples users from provider-specific IDs. Multi-tenant HOA context
with unique constraint on [user, provider, hoa]. Verification status
tracked per provider independently.
Connected Balance and Vendor Payouts
Resident payments land in the association's own connected balance.
A vendor payment is a payout from that balance to the vendor's bank,
by routing and account number. The vendor needs no processor account.
The vendor's bank is attached at its first payment, paid by stored
identifier, and removed after the payout settles plus seven days.
Mandate-Based Compliance
ACH mandate collection with validation. Mandate metadata storage.
Automatic reuse when valid mandate exists. Status verification
before every payment attempt.
No Vendor Lock-In: The abstract interface means a new payment
provider can be added by implementing the gateway contract. Business logic does
not change.
Click twice, get charged once. This isn't UI protection—it's enforced at the API layer,
webhook layer, and database layer. Every payment operation is protected against
duplication through defense in depth.
Layer 1: Payment UUID Keys
Every Payment model has a unique UUID4 field (editable=False).
Idempotency key format: payment_{id}_{uuid}.
Sent with every payment creation request. Provider returns
cached result on retry—no duplicate charge.
Layer 2: Webhook Deduplication
WebhookEvent model with unique_together constraint on [provider, event_id].
Database-level enforcement—same event cannot insert twice.
Status tracking: received→processing→processed/failed/ignored.
Layer 3: Transaction ID Check
Each Payment stores provider_transaction_id. Query-before-retry pattern:
if transaction ID already exists in database, reject the duplicate attempt.
Belt and suspenders protection at the application layer.
Why Three Layers? Network failures cause retries at every level—
user double-clicks, API timeouts, webhook redelivery. Each layer catches duplicates
that slip past the others. Fiduciary protection requires defense in depth.
Resident payments are checked against velocity limits before they are processed.
Vendor payments are governed by the bill's approval and the controls beside it.
Vendor Payments
- A bill's approval binds the amount, the vendor, and the fund, and on the transfer rail the bank, and a payment spends it once
- A fresh authenticator code within ten minutes before a vendor is paid
- One pinned payout bank; a vendor's bank can never be it
- Automatic payouts pause if the processor's default bank is not the pin
- A changed vendor bank waits three business days; a second board member can end the wait by phone with a fresh code and the bank PIN
- On the processor rail, no single vendor payment, and no 24 hours of them together, may exceed the association's transfer limit, which is its buffer target ($5,000 where none is set)
- An email to the whole board on every vendor payment sent by transfer and every top-up
- A bounced payout reverses its entry and reopens the bill
Velocity Limits on Resident Payments
- 2 payments per minute, 5 per hour, 10 per day
- $2,000 per hour, $10,000 per day, $50,000 per week
- $5,000 single payment cap
- 3 different units per hour
- A risk score on each resident payment; a score of 50 or more is flagged and alerts staff
Not all failures are equal. NSF requires days between retries. Network errors
are retried within minutes. The retry service reads the failure code and
applies a type-specific schedule.
NSF / Insufficient Funds
Re-presented after 48 hours, then 120 hours later, at most twice.
Then escalated to support. The resident is notified on each retry.
Waiting for funds to arrive avoids wasteful rapid retries.
Card Declined
Retried after 1 hour, 1 day, and 3 days. Then a new payment method
is requested. An expired card is retried once, after one day.
Network / Processing Errors
Processing errors are retried after 15 minutes, 1 hour, and 6 hours.
Network errors are retried after 2, 8, and 30 minutes. Then escalated
to support.
Never retried: a closed, invalid, or unauthorized account is
not re-presented. An unrecognized failure code is never retried automatically;
it waits for a person.
Unified webhook endpoint handles multiple providers with signature verification,
rate limiting, and replay attack prevention.
Signature Verification
- HMAC timing-safe comparison (hmac.compare_digest)
- Provider-specific signature format support
- Dual-secret support (platform + Connect webhooks)
- Replay attack prevention (5-minute timestamp window)
- Automatic webhook type detection from payload
Rate Limiting
- Per-provider rate limits: 100, 60, and 50 requests per minute
- 512KB max payload size
- Unified endpoint routes to correct handler
- Fallback secret verification chain
- Detailed logging for multi-account debugging
When a payment succeeds, its receipt posts to the ledger automatically.
No manual posting. No end-of-day batch. Every receipt passes the same
enforcement choke point as any other entry.
Payment.status → SUCCEEDED (webhook confirms)
↓
Receipt handler waits for the processor's confirmed fee
↓
Find the linked invoice
↓
Create the receipt with the payment's unique reference
↓
Entries posted, accounts resolved by role: DR the cash account where the money landed
(the processor sub-account for a processor-settled payment), DR bank charges for the fee,
CR assessments receivable
↓
The receipt is applied to the invoice
↓
Invoice balance updated automatically
Duplicate Prevention: A second receipt for the same payment intent
is refused. Combined with webhook idempotency, a payment posts once.
Reconciliation runs nightly and compares database records against provider data.
Each discrepancy is recorded with what differs and why.
Step 1: Payment Reconciliation
Compare provider PaymentIntents against Payment records.
Detect: missing_in_provider, missing_in_db, amount_mismatch,
status_mismatch, duplicate entries.
Step 2: Refund Reconciliation
Match provider refunds to Refund model records.
Track partial vs. full refunds. Verify amounts,
timing, and linked original payment.
Step 3: Payout Reconciliation
Match the processor's payouts to the association's bank to expected
settlements. Each payout is tracked with status and timing.
Step 4: Balance Check
Read each association's own processor balance. A negative available
balance raises an alert. An available balance below the board-set
payout floor raises a high-severity alert.
Step 5: Sub-Account Comparison
The ledger's processor sub-account is compared with the provider's
available, pending, and reserved balances plus payouts in flight.
An alert fires when the books claim more than the provider holds.
Alert Generation
Automatic ReconciliationAlert for discrepancies.
ReconciliationItem stores both DB and provider values.
Email reports. Resolution status tracking.
Instant Verification
- Financial Connections API integration
- Instant auth for immediate access
- Real-time balance and ownership confirmation
- Verification tokens stored securely
Micro-Deposit Fallback
- Micro-deposit flow for unsupported institutions
- Verification status tracking per provider
- Account metadata storage (provider_metadata JSON)
- Verification history kept per provider
Rate limits sit at each entry point. Each is a fixed window, counted in the cache.
Webhook Limits
Each provider has its own per-minute limit, and a payload over 512 KB is
rejected before processing.
Sign-In Lockout
Five failed sign-ins lock an account for 30 minutes, tracked by IP address.
Every authentication event is logged.
Crawler Throttling
Aggressive crawlers are limited per minute by agent and host.
Verified search crawlers are recognized.