finances app
Dues, invoicing, payments, refunds, and per-chapter ledger.
Models (17)
OrgDuesSettings— per-org dues config:billing_mode(bill_chapter|bill_member),officers_see_amounts(D13), and the payment-plan policy (D8):payment_plans_enabled(off by default),plan_requesters,plan_max_installments,plan_max_duration_days,plan_min_first_payment_cents,auto_approve_within_limits(off by default) andplan_regional_approvalChapterBillingOverride— per-chapter billing-mode override, effective from a givenorganizations.Term, so an org can pilot per-member billing on one chapter (D4)DuesRate— amount per member type per termChapterInvoice— bills either a chapter or an individual member, discriminated bypayer_type, with a nullablememberFK.chapterstays NOT NULL on every row including member invoices — RLS readschapter_iddirectly, and it records where the member was billed (D12), so it never changes on transferInvoiceLineItem— itemized line, immutable after creationOrgPaymentProcessor— per-org Stripe / Braintree / Square / PayPal config (encrypted creds)PaymentRecord— completed payment;processor_payment_idisuniqueand is the idempotency keyPaymentAttempt— log of all attempts including pending and failedInvoiceDiscount— voidable admin discount with a required reasonChapterAccountCredit— immutable, payer-scoped ledger entry. Balance is always derived, never stored:Σissued − Σapplied − Σrefunded, adjusted for reversals.payer_type+ nullablemembermean a chapter's credit can never pay a member's invoice, nor one member's another's (D10)CreditRefundIntent— durable record committed before a gateway refund call, so a crash or timeout cannot cause the nightly sweep to refund the same member twiceInvoiceExportJob— tracks an async XLSX exportPaymentPlanRequest— a request to pay one invoice in instalments (D8). Modelled onBigLittleRequest:requesteris distinct frommember, so an officer can submit on a member's behalf without that conferring approval authority (D6). Carries a requiredjustificationplusreviewed_by/reviewed_at/review_notes.PENDING_STATUSESdrives the server-side?status=pendingfilter. A partial unique constraint (one_pending_payment_plan_request_per_invoice) allows only one open request per invoicePaymentPlan— the approved schedule. A schedule only — it carries no balance authority whatsoever. A partial unique constraint (one_active_payment_plan_per_invoice) allows only one active plan per invoicePaymentPlanInstallment— one dated, priced row of a plan, ordered bysequence. v1 instalments are reconciled manually; nothing auto-collects themChapterRemittance— one gateway charge covering many member invoices (D7). Holds the single realprocessor_payment_id(unique, nullable so a draft has none, and aCheckConstraintrefuses''— the empty string is not exempt from a unique index and would make the idempotency key vacuous).total_centsis server-computed from the allocations, never accepted from the client. Fourreconciliation_*columns record a charge that landed but could not be allocatedChapterRemittanceAllocation— the treasurer's explicit declaration: one row per covered invoice, carrying that member's share and, once settled, a OneToOne to the childPaymentRecord. Carries nomemberFK: the payer isinvoice.member, and a thirdPROTECTFK toMembershipthatmember_detach.pydid not know about would make account erasure raiseProtectedError
Key endpoints
| URL | Purpose |
|---|---|
GET/POST /api/billing/remittances/ | Officer: list / create a chapter remittance |
GET /api/billing/remittances/<id>/ | Officer: detail with its allocations |
GET /api/billing/remittances/<id>/payment-config/ | Officer: hosted gateway config; metadata carries remittance_id |
POST /api/billing/remittances/<id>/pay/ | Officer: charge it. 409 whenever the gateway charged and no member was credited — whether settlement raised or returned having flagged the row needs_reconciliation (a stale allocation, or R-S4-5's amount mismatch). Do not retry. Deliberately not a 400: every 400 here means "nothing happened, fix the request" |
POST /api/billing/remittances/<id>/resolve/ | Officer: re-allocate a flagged remittance against the charge already recorded on it. Makes no gateway call — the money was taken; what failed was attribution. Body is the allocation shape minus chapter (the remittance names it) plus resolution_note. The allocated total must equal reconciliation_amount_cents, never the stale total_cents — the two differ precisely in the amount-mismatch case that is one of the two ways a row gets here |
GET /api/organizations/<id>/dues-settings/ | View dues config |
PATCH /api/organizations/<id>/dues-settings/ | Update dues config |
POST /api/organizations/<id>/payment-processors/ | Connect a processor |
GET /api/organizations/<id>/invoices/ | All invoices in org |
GET /api/chapters/<id>/invoices/ | Chapter invoices |
POST /api/invoices/<id>/pay/ | Initiate payment (returns Stripe session URL) |
POST /api/invoices/<id>/adjust/ | Add credit / fee / waiver |
POST /api/invoices/<id>/refund/ | Refund a paid invoice |
POST /api/hq/dues/<org_id>/invoices/<id>/regenerate/ | Void one recurring dues invoice and reissue it from current live member counts |
GET /api/hq/dues/<org_id>/chapters/<chapter_id>/credit/ | Available account credit + ledger history. ?member=<uuid> scopes to one member; omitted means the chapter's OWN credit |
POST /api/hq/dues/<org_id>/chapters/<chapter_id>/credit/refund/ | Refund unused account credit to the original payment method. ?member= refunds that member's, against their own payments |
GET/POST /api/hq/dues/<org_id>/billing-overrides/ | Per-chapter billing-mode overrides (D4) |
GET /api/billing/me/ledger/ | The requesting member's own balance-first ledger (D2). ?membership= when they hold several |
GET /api/billing/me/invoices/ | The member's own invoices (paginated) |
GET/POST /api/hq/dues/<org_id>/plan-requests/ | Admin review queue (D8). Sorted by freeze expiry, not age; rows carry freeze_expires_at, freeze_seconds_remaining, is_in_policy, is_frozen and the granted plan. ?status=pending filters server-side |
POST /api/hq/dues/<org_id>/plan-requests/<id>/review/ | Approve or refuse one request |
POST /api/hq/dues/<org_id>/plan-requests/bulk-approve-in-policy/ | "Approve all in policy" bulk action |
GET/POST /api/billing/me/plan-requests/ | The member's own plan requests |
GET /api/billing/me/plan-requests/<id>/ | One of the member's own requests |
GET /api/billing/me/payment-plans/ | The member's own approved schedules. Read-only — a plan has no member-writable state |
POST /api/billing/plan-requests/ | Officer submits on a member's behalf. The 201 echo's invoice_balance_cents is null when officers_see_amounts is off (D13) |
GET /api/billing/roster/ | Officer collection roster — one row per member, aggregated, delinquent-first |
GET /api/billing/roster/<membership_id>/ledger/ | Officer's view of one member's ledger — discounts de-itemized (D13) |
GET /api/chapters/<id>/finances/ledger/ | Chapter running balance |
POST /api/finances/webhooks/stripe/ | Stripe webhook receiver |
Permissions
IsNationalAdmin— dues settings, payment processor config, refunds beyond cap, org-wide reportsIsChapterOfficer(treasurer) — chapter invoice management, refunds within cap- Payment plan review (D8) is
IsNationalAdmin, widened to regional admins whenOrgDuesSettings.plan_regional_approvalis on — a config toggle over the existing permission classes, not a new class
Background tasks
generate_chapter_invoices(term_id)— fans out invoice creation per active member for the given termsend_invoice_reminders— daily; sends "due in N days" notificationsmark_invoices_overdue— daily; flipsopen→overduepast due dateprocess_failed_payments— retries cards per org config
External integrations
- Stripe (most common) — Payment Intents API, Webhooks for async events
- Braintree, Square, PayPal — alternate processors per
OrgPaymentProcessor.provider
Signals
post_saveonPaidInvoice— createsChapterLedgerentry to update running balance
Notable patterns
Status-based dues
DuesRate(organization=, status=, amount=) overrides the org default. Common: undergrad pays $400/year, alumni pay $50/year. Loaded via org.dues_settings.get_rate_for_member(member).
Per-officer refund cap
OrgDuesSettings.refund_cap_per_officer (default $50). Officer-initiated refunds above this route to the org admin queue (returns 202 + creates an approval request).
Webhook reconciliation
/api/finances/webhooks/stripe/ validates the signature + processes:
payment_intent.succeeded→ marks invoice paidpayment_intent.payment_failed→ records failed transactioncharge.refunded→ updates refund status
Failed webhook deliveries: Stripe retries; idempotency key prevents double-processing.
Encrypted processor credentials
OrgPaymentProcessor stores client_id / secret / API key as EncryptedTextField. Decrypted at request time, never logged.
Invoice regeneration + chapter account credit
services/regeneration.py:regenerate_chapter_invoice() voids a single recurring dues ChapterInvoice and reissues it from services/member_counts.py:compute_chapter_invoice_lines() (shared with the scheduled generator so counts are computed identically). Payments, in-flight PaymentAttempts, and active discounts are re-linked (FK updated) onto the replacement, not recreated. If the carried-forward payments/discounts exceed the new (lower) total, the excess is recorded via services/credit.py:issue_credit() instead of a negative balance — ChapterInvoice.balance_due_cents is a PositiveIntegerField and can never go negative. services/balance.py:recompute_balance_and_status() folds applied ChapterAccountCredit entries into its balance calculation alongside payments and discounts. services/credit.py:apply_available_credit() auto-applies any available credit at invoice creation time (both generate_chapter_invoices and ad hoc charge creation call it); refund_chapter_credit() instead converts unused credit into a real processor refund via PaymentGateway.refund_payment() (implemented for Stripe; other gateways raise NotImplementedError).
Chapter ledger vs invoice list
The ledger is a derived view (sum of charges + payments). For accuracy, the source of truth is ChapterInvoice + PaymentTransaction. The ledger is materialized for fast reads.
Two balances that must not be conflated
A chapter's own balance (ad hoc charges it owes HQ) is a different number from the sum of its members' balances. In bill_member mode a chapter's own balance is frequently $0 while its members collectively owe thousands, so GET /api/billing/balance/ returns them as separately labelled figures (total_balance_cents vs member_balance_cents). Any queryset meaning "the chapter's own invoices" must constrain payer_type — filtering on chapter_id alone silently includes every member's personal dues.
Officer financial visibility (D13)
OrgDuesSettings.officers_see_amounts (default on) controls whether chapter officers see member balance amounts or only status buckets. Independently of that toggle, a discount's category and notes are never shown to an officer — build_ledger(..., itemize_discounts=False) emits it as a bare adjustment carrying the amount but no reason, so the running balance still reconciles while the reason stays private. Notification recipients follow payer_type, so a member's payment or overdue notice goes to the member, not to their peers.
Payment plans are a schedule, never a balance (D9)
A PaymentPlan changes what is owed by nobody. balance.py remains the sole authority on balance_due_cents, derived from PaymentRecord rows, and is deliberately untouched by this feature. The integration point is the overdue task, not balance math.
While a plan request is pending and in policy, accrual is frozen: mark_invoices_overdue suppresses the open → overdue transition and its dunning notice. The freeze is anchored to the invoice's own due date, not to when the request was filed, so filing late buys no more time than filing early. Accrual resumes once the window lapses even if the request is still pending, so an unreviewed request cannot suppress dunning forever. An out-of-policy request earns no freeze (R-S3-5) — otherwise a member submits 99 instalments with nothing down and buys the freeze for free until an admin gets to it.
services/payment_plan_freeze.py is the single source of truth for that clock, read by both the admin queue's countdown and the overdue task, so what an admin sees and what the task enforces cannot drift apart. Because the protection expires on a clock rather than ageing, the review queue sorts by freeze expiry, not request age — the request about to lose its protection is not always the oldest.
Note the implementation detail in that module: mark_invoices_overdue filters due_date < today, so the spec's "due date + 24h" is implemented as FREEZE_DAYS_AFTER_DUE_DATE = 2. Read literally as midnight(due_date) + 24h, the freeze would expire before the first task run could ever observe it and would suppress nothing (R-S3-1).
One gateway charge, many member payments (D7)
PaymentRecord.processor_payment_id is unique=True and is the idempotency
key, ranked pending < failed < succeeded so an out-of-order webhook cannot
clobber a settled payment. One charge therefore cannot directly produce N
PaymentRecord rows.
So ChapterRemittance holds the single real id and each covered invoice gets a
child record under the synthetic key {remittance_id}:{invoice_id}.
Resolving a flagged remittance
services/remittance.resolve_remittance is the only path that clears a
needs_reconciliation dead end, and it is a re-allocation, not a
flag-clear. Clearing the four columns would leave the charge unattributed
forever while the members who paid stayed marked unpaid — the precise failure
D7 exists to prevent.
It replaces the stale allocations, rewrites total_cents, and then calls the
same settle_remittance the gateway path calls, under the charge id already
on the row. Two consequences worth knowing:
- The four
reconciliation_*columns are preserved, via an explicitclear_reconciliation_on_settle=Falsethreaded through to_record_charge_on_parent. A resolved row is thereforesettledand still carriesreconciliation_flagged_at;resolved_atis the only thing that distinguishes a fixed anomaly from a live one, so any consumer comparing them must compare the timestamps, not merely test thatresolved_atis set. - A row can only be resolved if money was actually captured. Every
_flag_for_reconciliationbranch is gated on aSUCCEEDEDdelivery, because all four gateways report the full intent amount on a non-succeeded delivery — so an ungated flag wrote reconciliation columns from a decline that were indistinguishable from a real capture, and resolving one fabricated member payments and refundable credit for money that never moved. Settlement callsrecord_paymentrather than writing rows itself, inheriting both the uniqueness and the ranking;services/balance.pyandservices/payment_recording.pyare untouched by the feature.
Three consequences worth knowing before changing anything here:
- The excess must be measured before
record_paymentruns. It recomputes the balance and floors it at zero, so an over-allocation measured afterwards reads as the whole payment and credits the member far too much. - A child payment is not refundable to its member.
refund_member_creditskips rows with aremittance_allocation, because the synthetic key means nothing to the gateway — and because the money came off the chapter's card, so refunding it to a member would move money between payers. - Allocation is explicit everywhere. No code path may compute a share the treasurer did not type, in the API or the UI.
Ledger conservation on regeneration
ChapterAccountCredit supports a REVERSED entry carrying a reverses FK, contributing the exact negation of its target's effect — reversing an issued row claws back, reversing an applied row un-applies. Regeneration therefore un-applies, reprices, settles the difference, then re-applies, so a downward reprice cannot destroy surplus credit and an upward one cannot mint it. Overpayment credit derives from payments only: a discount may reduce a balance to zero but never produces refundable credit.
End of a member's billing relationship
A chapter's credit carries forward indefinitely; a member's cannot. On reaching a terminal membership status (alumni, active_alumni, lifetime_alumni, disaffiliated — inactive is excluded as reversible), outstanding debt is settled from credit first and only the remainder is refunded. Account deletion voids non-paid invoices, snapshots the payer's name onto every invoice including paid ones, releases the PROTECT FK, and lets deletion proceed; paid and partially-paid invoices keep their financial history.
Code paths
- Models:
backend/apps/finances/models.py - Views:
backend/apps/finances/views.py - Payment-plan freeze clock:
backend/apps/finances/services/payment_plan_freeze.py - Stripe wrapper:
backend/apps/finances/providers/stripe.py - Webhook handler:
backend/apps/finances/views.py:StripeWebhookView