Skip to main content

foundation app

Fundraising arm of the platform. Separate payment processor from dues; tax-exempt donation receipts; public donate pages.

Models (12)

  • FoundationConfig — per-org foundation settings (name, legal name, EIN, mailing address, receipt template overrides); one-to-one with Organization
  • FoundationAdmin — promotes a User to foundation admin/editor role
  • FoundationPaymentProcessor — separate Stripe processor config, one-to-one with FoundationConfig (foundation funds go to a different connected account than dues)
  • Fund — donation designation (e.g. "General Fund", "Scholarship Fund"); goal + denormalized running total
  • Campaign — fundraising drive; FK to Fund, goal, dates, public slug, hero image, suggested amounts, visibility (internal/public)
  • Donor — CRM record; lifetime giving total/count, contact info, optional link to a User
  • Donation — completed (or pending/failed/refunded) donation; amount, fee, refunded_cents (partial-refund signal — see below), recurring-pledge link, anonymous flag
  • RecurringPledge — Stripe Subscription-backed recurring donation; interval (monthly/quarterly/annually), status (active/past_due/paused/cancelled)
  • Bulletin — foundation email communication to donors (draft/scheduled/sending/sent), audience targeting (all donors, recurring donors, campaign donors, org members)
  • BulletinRecipient — per-recipient delivery tracking for a Bulletin (sent/failed/bounced)
  • TaxReceipt — IRS-compliant receipt (per-donation or annual summary); PDF URL, receipt number
  • StripeWebhookEvent — Stripe webhook idempotency ledger; not org-scoped (nullable foundation FK)

Key endpoints

URLPurpose
GET/POST/PUT/PATCH /api/foundation/config/Foundation settings (singleton)
POST /api/foundation/campaigns/Create campaign
GET /donate/:foundationId/:slugPublic donate page (no auth)
GET /api/foundation/public/campaigns/<foundation_id>/<slug>/Public campaign payload (no auth) — includes publishable_key, payment_provider, fee_rate, fee_fixed_cents
POST /api/foundation/public/campaigns/<foundation_id>/<slug>/donate/Submit donation (public, no auth)
GET /api/foundation/donations/Donations list (admin, read-only)
GET /api/foundation/donors/Donor CRM
POST /api/foundation/donations/<id>/refund/Refund
GET /api/foundation/donations/export/Export donations (CSV)

Permissions

  • IsFoundationAdmin — campaign + donor CRM management
  • IsFoundationEditor — read + create donations
  • AllowAny — public donate page

Background tasks

  • auto_close_expired_campaigns — daily at 1 AM; closes campaigns past end_date
  • generate_annual_tax_receipts — January 15 each year; fans out generate_annual_tax_receipts_for_foundationgenerate_and_email_annual_receipt per donor
  • send_per_donation_receipt — per-donation; emails a receipt immediately after a completed donation
  • send_bulletin — sends a Bulletin to its resolved donor audience

External integrations

  • Stripe (separate connected account from dues)

Notable patterns

Public donate URL

/donate/<foundation_id>/<campaign_slug>/ — fully public, no auth. Renders campaign page with progress bar, suggested amounts, donor info form.

After payment success → redirects to /donation-success/?donation_id=<uuid>. Receipt emailed asynchronously.

Cover-the-fees gross-up (the client is authoritative)

The server charges the amount_cents the client sends, verbatimcreate_donation computes fee_cents/net_cents off whatever arrives. So when a donor opts to cover fees, the number donate.tsx computes is the number the donor pays, and it has to be the true gross-up:

charge - (charge * rate + fixed) = base
charge = ceil((base + fixed) / (1 - rate))

Rounded up, so the residual cent lands with the foundation rather than the processor. Adding fee(base) on top instead leaves the foundation short, because fee(base) never covers fee(base + fee) (#686).

services/fee_calculation.py stays the canonical calculator — calculate_total_with_fee is the reference implementation and the server still computes fees for its own records. What the client no longer does is invent the inputs: get_fee_schedule(processor=None) returns {rate, fixed_cents}, PublicCampaignSerializer serves them as fee_rate (a decimal string, e.g. "0.029") and fee_fixed_cents, and frontend/src/lib/donation-fees.ts rebuilds an exact fraction from the rate's digits so its integer arithmetic lands on the same cent Python's Decimal does.

rate is a string rather than a float deliberately — round-tripping 0.029 through binary floating point is exactly the drift this is meant to prevent.

get_fee_schedule takes a processor argument it currently ignores, mirroring calculate_processing_fee. The schedule is global today (FoundationPaymentProcessor has no rate columns — only fee_pass_through); that argument is the seam a per-processor rate would arrive through.

Partial refunds

There is deliberately no PARTIALLY_REFUNDED value in Donation.Status — adding one would break the DonationStatus union on the frontend, the status tabs, and the export's status validation. A partial refund is carried by refunded_cents instead:

refunded_cents > 0 && status == "completed" -> partially refunded
refunded_cents >= amount_cents -> fully refunded (status == "refunded")

The UI derives this in one place, frontend/src/lib/donation-status.ts. Note the platform only issues full refunds; partial refunds originate in the Stripe dashboard and arrive via the charge.refunded webhook.

Tax-year timezone

FoundationConfig.timezone (default "UTC") decides which tax year a donation is receipted in, via services/tax_year.py (tax_year_bounds / filter_by_tax_year / tax_year_of) and the annual receipt task. It is not a display preference. FoundationConfigWriteSerializer.validate_timezone rejects anything ZoneInfo cannot resolve, and FoundationConfig.tzinfo falls back to UTC rather than raising, since the column is free text.

Recurring donations

Donor opts into is_recurring + a recurring_interval (monthly/quarterly/annually) at donation-submission time; this creates a RecurringPledge (there's no campaign-level "allow recurring" toggle). Stripe Subscriptions handle the recurring charge; each subsequent charge creates a new Donation row linked back to the RecurringPledge.

Donor matching

Anonymous donors get a Donor row (without name displayed publicly). Returning donors are matched by email — lifetime giving accumulates correctly.

Tax receipts

Two flavors:

  • Per-donation — emailed at giving time via the send_per_donation_receipt(donation_id) task; PDF generated by generate_per_donation_receipt()
  • Year-end summary — emailed every January; aggregates all prior-year donations per donor

Both use a configurable template (HTML / Markdown), with substitutions for amount, date, donor name, EIN.

DonorViewSet supports ?search= (matches email, first_name, last_name); there is no tag/segment field on Donor — donor targeting for bulletins is done via the Bulletin.audience choice (all donors / recurring donors / campaign donors / org members), not per-donor tags.

Donor bulletins

Bulletin is an admin-composed email (subject + HTML/plaintext body) sent to a targeted donor audience (all donors, recurring donors, a specific campaign's donors, or all org members) via the send_bulletin Celery task. BulletinRecipient tracks per-recipient delivery status (sent/failed/bounced). This is a donor-communication tool, not a public progress update on the donate page.

Code paths

  • Models: backend/apps/foundation/models.py
  • Views: backend/apps/foundation/views.py
  • Permissions: backend/apps/foundation/permissions.py
  • Tasks: backend/apps/foundation/tasks.py
  • Receipt generator: backend/apps/foundation/services/receipt_generation.py