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 withOrganizationFoundationAdmin— promotes a User to foundation admin/editor roleFoundationPaymentProcessor— separate Stripe processor config, one-to-one withFoundationConfig(foundation funds go to a different connected account than dues)Fund— donation designation (e.g. "General Fund", "Scholarship Fund"); goal + denormalized running totalCampaign— fundraising drive; FK toFund, goal, dates, public slug, hero image, suggested amounts, visibility (internal/public)Donor— CRM record; lifetime giving total/count, contact info, optional link to aUserDonation— completed (or pending/failed/refunded) donation; amount, fee,refunded_cents(partial-refund signal — see below), recurring-pledge link, anonymous flagRecurringPledge— 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 aBulletin(sent/failed/bounced)TaxReceipt— IRS-compliant receipt (per-donation or annual summary); PDF URL, receipt numberStripeWebhookEvent— Stripe webhook idempotency ledger; not org-scoped (nullablefoundationFK)
Key endpoints
| URL | Purpose |
|---|---|
GET/POST/PUT/PATCH /api/foundation/config/ | Foundation settings (singleton) |
POST /api/foundation/campaigns/ | Create campaign |
GET /donate/:foundationId/:slug | Public 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 managementIsFoundationEditor— read + create donationsAllowAny— public donate page
Background tasks
auto_close_expired_campaigns— daily at 1 AM; closes campaigns past end_dategenerate_annual_tax_receipts— January 15 each year; fans outgenerate_annual_tax_receipts_for_foundation→generate_and_email_annual_receiptper donorsend_per_donation_receipt— per-donation; emails a receipt immediately after a completed donationsend_bulletin— sends aBulletinto 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, verbatim — create_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 bygenerate_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.
Donor search
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
Related
- Foundation (org admin)
- Foundation module (user-facing)
- finances app — separate processor for dues