Skip to main content

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) and plan_regional_approval
  • ChapterBillingOverride — per-chapter billing-mode override, effective from a given organizations.Term, so an org can pilot per-member billing on one chapter (D4)
  • DuesRate — amount per member type per term
  • ChapterInvoice — bills either a chapter or an individual member, discriminated by payer_type, with a nullable member FK. chapter stays NOT NULL on every row including member invoices — RLS reads chapter_id directly, and it records where the member was billed (D12), so it never changes on transfer
  • InvoiceLineItem — itemized line, immutable after creation
  • OrgPaymentProcessor — per-org Stripe / Braintree / Square / PayPal config (encrypted creds)
  • PaymentRecord — completed payment; processor_payment_id is unique and is the idempotency key
  • PaymentAttempt — log of all attempts including pending and failed
  • InvoiceDiscount — voidable admin discount with a required reason
  • ChapterAccountCredit — immutable, payer-scoped ledger entry. Balance is always derived, never stored: Σissued − Σapplied − Σrefunded, adjusted for reversals. payer_type + nullable member mean 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 twice
  • InvoiceExportJob — tracks an async XLSX export
  • PaymentPlanRequest — a request to pay one invoice in instalments (D8). Modelled on BigLittleRequest: requester is distinct from member, so an officer can submit on a member's behalf without that conferring approval authority (D6). Carries a required justification plus reviewed_by / reviewed_at / review_notes. PENDING_STATUSES drives the server-side ?status=pending filter. A partial unique constraint (one_pending_payment_plan_request_per_invoice) allows only one open request per invoice
  • PaymentPlan — 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 invoice
  • PaymentPlanInstallment — one dated, priced row of a plan, ordered by sequence. v1 instalments are reconciled manually; nothing auto-collects them
  • ChapterRemittanceone gateway charge covering many member invoices (D7). Holds the single real processor_payment_id (unique, nullable so a draft has none, and a CheckConstraint refuses '' — the empty string is not exempt from a unique index and would make the idempotency key vacuous). total_cents is server-computed from the allocations, never accepted from the client. Four reconciliation_* columns record a charge that landed but could not be allocated
  • ChapterRemittanceAllocation — the treasurer's explicit declaration: one row per covered invoice, carrying that member's share and, once settled, a OneToOne to the child PaymentRecord. Carries no member FK: the payer is invoice.member, and a third PROTECT FK to Membership that member_detach.py did not know about would make account erasure raise ProtectedError

Key endpoints

URLPurpose
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 reports
  • IsChapterOfficer (treasurer) — chapter invoice management, refunds within cap
  • Payment plan review (D8) is IsNationalAdmin, widened to regional admins when OrgDuesSettings.plan_regional_approval is 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 term
  • send_invoice_reminders — daily; sends "due in N days" notifications
  • mark_invoices_overdue — daily; flips openoverdue past due date
  • process_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_save on PaidInvoice — creates ChapterLedger entry 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 paid
  • payment_intent.payment_failed → records failed transaction
  • charge.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 openoverdue 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 explicit clear_reconciliation_on_settle=False threaded through to _record_charge_on_parent. A resolved row is therefore settled and still carries reconciliation_flagged_at; resolved_at is the only thing that distinguishes a fixed anomaly from a live one, so any consumer comparing them must compare the timestamps, not merely test that resolved_at is set.
  • A row can only be resolved if money was actually captured. Every _flag_for_reconciliation branch is gated on a SUCCEEDED delivery, 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 calls record_payment rather than writing rows itself, inheriting both the uniqueness and the ranking; services/balance.py and services/payment_recording.py are untouched by the feature.

Three consequences worth knowing before changing anything here:

  • The excess must be measured before record_payment runs. 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_credit skips rows with a remittance_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, disaffiliatedinactive 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