Skip to main content

Donations Manager

Donations Manager

Component File: src/features/donations/components/DonationsManager.tsx Route / navigation: Path /donors, Zustand donorTool = donations. See 00-DONOR-HUB.md. Access Level: Parent Org and Fund Users with Donor Hub access (position-based) Last Updated: April 13, 2026

> Data Layer: This component uses Supabase client functions in src/lib/db.ts (e.g., fetchDonations(), createDonation(), updateDonation(), softDeleteDonation(), checkForDuplicateDonation()). There is no internal REST API; use Supabase clients, RPC, and edge functions.

Recent Updates (April 13, 2026)

DAF sponsor enforcement hardening

  • Manual Record mode: Add Donation now hard-blocks save when DAF gift is on and no sponsor is selected (create path).
  • Edit Donation dialog: Save now hard-blocks when DAF is on and sponsor is missing (update path).
  • DB helper guards: createDonation() and updateDonation() now throw explicit errors when a non-interfund DAF gift has no daf_sponsor_id.
  • Schema enforcement + legacy backfill: migration 20260615120000_enforce_daf_sponsor_and_memtx_deposit_requirements.sql backfills unresolved historical DAF gifts to an org-scoped placeholder sponsor (Unknown DAF (Needs Review)) and adds chk_donations_daf_sponsor_required.
  • Reporting impact: Schedule B and DAF summary flows no longer accept new sponsorless DAF gift writes.

Duplicate guard donor lookup contract fix

  • Problem: checkForDuplicateDonation() was selecting donors!left(name), but donors are stored as first_name + last_name in schema.
  • Fix: Query now selects donors!left(first_name,last_name) and composes donor_name from those fields.
  • Impact: same-day duplicate warning now reliably includes donor names instead of silently degrading when the join shape is invalid.

Add Donation integrity hardening

  • Purpose reset parity: In Record Donation mode, changing nonprofit now clears purposeId (matching Process mode), so a purpose from Fund A cannot be submitted under Fund B.
  • Stale purpose self-heal: If a previously selected purpose is no longer valid for the selected nonprofit, the form now auto-recovers to that fund's General purpose (or blank if none).
  • Notes persistence: Manual Record Donation now writes newDonation.notes to donations.notes through createDonation().
  • No placeholder purpose writes: The UI no longer submits a synthetic fallback purpose value; createDonation() resolves the organization default purpose when no explicit purpose is chosen.
  • Post-commit sync is non-fatal: syncDonorDonationTypeFromHistory() failures no longer surface as a failed add-donation action after the donation row has already committed.

Recent Updates (April 12, 2026)

Deposit-linked donation void behavior

  • Standalone deposits: If a posted deposit has exactly one active deposit_item, the linked donation now behaves like a direct donation entry for edit/void guard purposes.
  • Posted multi-item deposits: Donation-level void now supports check deposits and newer regular deposits that stored per-item posting context on deposit_items, using an additive reversing JE rather than voiding the original batch lines.
  • Historical regular deposits: Older multi-item regular deposits without stored posting context remain whole-batch only for safe accounting; the UI now surfaces an explicit ambiguity error instead of guessing.
  • Donation-context JE drawer: Donation-linked JE drawer void no longer routes through the generic batch-void path for one-donation actions.

Recent Updates (April 7, 2026)

Fund-scoped unassigned gifts + assign permission

  • Visibility: Gifts with no linked donor (donations.donor_id null) appear in the receiving fund when donations.organization_id matches the scoped fund (single-fund entity switcher or parent user viewing one child fund). Parent aggregate (isAdminView) behavior is unchanged. Server: `get_donations_page` and `get_donation_table_stats` include unassigned rows/counts when p_org_id is set (supabase/migrations/20260510120000_fund_scope_show_unassigned_donations.sql, supersedes the hide rule in 20260506120000_hide_unassigned_donations_fund_scope.sql).
  • Client list query: Donations Manager passes through the assignment filter to the RPC for all scopes (no more forced `assigned`-only fetch in single-fund view).
  • Who can assign: `canAssignDonations` in src/lib/permissions.ts (exposed on usePermissions). `canManageDonations` implies assign when true (parent org and non-enterprise fund users with read_write). If that is false, assign is allowed only when `tier === 'enterprise'` and `fund_user` + `director` + `read_write`. Bookkeeper/assistant and `read_only` directors on enterprise see unassigned rows but cannot assign.
  • Unassigned tile vs list: The summary tile’s `unassignedCount` (RPC) counts only `payment_status = 'completed'` gifts with no donor, matching historical stats semantics; the paginated list still includes refunded / partially_refunded rows, so counts can differ slightly from row counts when those states appear.
  • Assign dialog: Add New donor and prospect rows are hidden when `canManageDonors` is false (enterprise fund directors can assign to existing donors only). i18n: donations.donationErrors.assignNotPermitted.

Recent Updates (April 13, 2026)

Shared donor picker adoption (Add Donation modal)

  • Record + Process donor fields: Add Donation now uses shared `DonorSearch` (same component used in accounting deposit flows) instead of local Popover + Command implementations.
  • Fund scope: Search context remains based on `newDonation.entityId` / selected receiving fund.
  • Metadata: Selection now captures donor linkage (donorId, donorSource) and known email via shared picker callback details.
  • Assign/Edit flows unchanged: Standalone Assign dialog and Edit dialog donor assignment continue using shared AssignDonorSearchItems behavior.

Recent Updates (April 6, 2026)

Tax-deductible defaults (donations.is_taxable)

  • Rule: Gifts default to tax-deductible (is_taxable = true) unless the staff explicitly unchecks tax-deductible, the gift is a DAF (is_daf_gift), or the payment is not a charitable donation (e.g. Stripe transaction_type of service/product/fee, or source=event_registration in metadata — event revenue is separate from donations).
  • Implementation: resolveDonationIsTaxable / isTaxDeductibleFromDonationRow in src/lib/donationDafNormalization.ts; Stripe webhook uses supabase/functions/_shared/donation-tax-deductible.ts; DB backfill + trigger supabase/migrations/20260406183000_donation_tax_deductible_daf_backfill.sql.

Assign Donor flow (donors + prospects; fund-scoped visibility — see April 7, 2026)

  • Assign Donor + Edit assign block: Search uses `searchDonorsAndProspects` (same API as record donation), scoped to the gift's receiving fund when known. Results show donors and prospects with shared `AssignDonorSearchItems` cmdk rows (donor vs prospect Badge parity with record mode). Selecting a prospect runs `convertProspectToDonor(..., { targetOrganizationId: donation.organization_id })` then `updateDonation(donationId, { donorId })` (replaces raw assignDonorToDonation so voided donations and shared update paths stay consistent). Users without `canManageDonors` see donors only in the standalone Assign dialog.
  • Add New Donor from assign: `AddDonorDialog` receives `defaultEntityId` derived from the gift’s organization_id (via fetchDonationsPageorganizationId on list rows) so staff on parent view create donors under the receiving fund, not the parent slug. Dialog syncs entityId when it opens with a non-empty defaultEntityId.
  • Record / Process Donation search: donor/prospect search now follows `newDonation.entityId` (the selected receiving fund) instead of the parent aggregate view, so prospect conversion and donor creation stay aligned to the donation's fund.
  • Journal entries: Donor-only updates do not run updateDonation accounting sync (needsAccountingSync is false when only donorId changes). The DB trigger `donations_try_journal_line_link` still runs on donor_id updates and may attach `journal_entry_line_id` when it was null. Existing JEs are not rewritten for name/description when a donor is linked later.

Recent Updates (March 27, 2026)

Recent Updates (April 5, 2026)

DAF / payment_method consistency (edit + insert)

  • Edit dialog open: If payment_method is daf but is_daf_gift was false (legacy rows), the form treats the row as a DAF gift and restores sponsor fields. The payment method select is disabled while DAF is on and shows a non-daf placeholder (bank-transfer) so the list never needs a daf option (Donors CRM edit uses a DAF item in the method list instead—both UIs save the same way).
  • Edit save: daf_sponsor_* are cleared when DAF is unchecked; method is coerced so daf is never saved without isDafGift. `updateDonation()` applies the same column alignment as createDonation() whenever any DAF-related field is updated (safety net for partial callers).
  • App + DB safety net: createDonation() / updateDonation() normalize non-interfund rows in src/lib/db/donations.ts, and supabase/migrations/20260505123000_normalize_donation_daf_invariants.sql adds a one-time backfill for legacy payment_method='daf' rows plus a DB trigger/check constraint so direct writes stay aligned too. Interfund transfers are unchanged.
  • DAF checkbox: Toggling DAF on sets the form method to bank-transfer for display; a ref stores the prior method so toggling off restores unsaved method changes.

Edit Donation footer — void control

  • Void donation uses variant="destructive" size="sm" in the footer row (left), with Cancel / Save on the right; confirm UI stays in DialogFooter above that row (see Developer Playbook § Drawer Footer / donation dialog note).

Assigned / Unassigned donation filter now scopes the query set

  • Issue: The Donations Manager assignment filter was only applied client-side after get_donations_page had already returned one page of rows. That produced empty table states such as "Showing 0 of 20141" when page 1 contained no matching rows even though later pages did.
  • Fix:
  • get_donations_page now accepts p_donor_assignment so assignment filtering happens server-side before count, sort, and pagination.
  • Assignment semantics are now based on donations.donor_id, not the rendered donor label. A donation linked to a donor record is treated as Assigned even if that donor currently renders as Anonymous because first/last name is blank.
  • Donations Manager now uses a shared donor-display helper so linked anonymous donors render as a normal assigned row instead of showing the Unassigned badge / assign action.
  • Files changed: supabase/migrations/20260505120000_get_donations_page_donor_assignment_filter.sql, supabase/migrations/20260505121000_get_donations_page_assignment_by_donor_id.sql, src/lib/donationAssignment.ts, src/features/donations/components/DonationsManager.tsx, src/lib/db/donations.ts

Recent Updates (April 2, 2026)

Standardized duplicate guard with deposit flows

  • Manual Record Donation now uses the same shared guard logic as Check/Regular Deposit via src/lib/donationEntryGuards.ts.
  • High-confidence duplicate deposit matches are now hard-blocked (no override), instead of warning-only confirmation.
  • Lower-confidence same-day/same-amount collisions still use the confirmation dialog.
  • Files changed: DonationsManager.tsx, donationEntryGuards.ts

Stripe checkout errors (Process mode redirect path)

  • `createCheckoutSession` now throws `PaymentInitError` with server codes (`connect_not_ready`, `charges_not_enabled`, etc.) like `createPaymentIntent`. Donations Manager shows `getPaymentInitErrorMessage` in the checkout failure toast. Backend routing matches `resolvePaymentTargetForOrg` (see `STRIPE-SETUP-GUIDE.md`).

Recent Updates (March 22, 2026)

Donation List table — Safari column overlap (P2)

  • Issue: On Safari (reported macOS), the desktop Donation List table (table-fixed) could compress columns so ID and Donor text appeared merged or overlapping; Amount sat too close to the donor column.
  • Fix: Desktop table uses min-w-[1080px] inside the horizontal scroll wrapper so layout does not over-compress. Wider min width on Donor / Amount / Status headers; whitespace-nowrap on ID, amount, date, type, method, and status cells; donor cell uses min-w-0 max-w-[240px] with existing truncate for names.
  • Files changed: src/features/donations/components/DonationsManager.tsx

Recent Updates (March 20, 2026)

Record-mode donor linkage guard + cmdk parity (P0)

  • Issue: Staff could type a donor name in Record Donation mode and submit without actually linking a donor record, creating rows that appeared as Unassigned later. Add Donation donor pickers also diverged from the cmdk pattern documented in the Developer Playbook.
  • Fix:
  • Record guard: Record Donation now blocks submit when donor text exists but no linked donor ID is selected. Users must choose a donor from search results, use Add New, or clear the donor field.
  • UX parity: Record mode now shows the same donor guidance hints already used in Process mode (selectDonorHint / searchDonorHint).
  • cmdk parity: Added shouldFilter={false} to both Add Donation donor Command dropdowns (Process + Record) so API-driven search behavior matches the Assign/Edit donor popovers and Playbook §20.
  • Assigning anonymous/unassigned rows: Existing Assign Donor actions (table row + Edit Donation dialog) remain the supported path to link unassigned/anonymous donations to a donor record after entry.
  • Files changed: src/features/donations/components/DonationsManager.tsx, src/i18n/locales/*/donations.json

Edit Donation dialog — donor assignment + footer layout (P1)

  • Issue: The donations table Edit Donation dialog felt unbalanced: void used a full-width destructive button between the form and the footer, and there was no way to assign or change the donor from edit (only the separate Assign Donor flow from the row / unassigned badge).
  • Fix:
  • Donor block (gated on canManageDonations): shows current donor or “Unassigned”; same API-driven donor/prospect search pattern as Assign Donor (shouldFilter={false}, unique CommandItem values, shared AssignDonorSearchItems); Assign Donor applies via `updateDonation(id, { donorId })` (prospects converted first) for the open donation (handleAssignDonorFromEdit). Uses a dedicated popover open flag (editDonorAssignPopoverOpen) so the edit dialog does not share popover state with the standalone Assign dialog or Add Donation donor pickers.
  • Void: Void donation is variant="destructive" size="sm" in the footer row, left-aligned; Cancel / Save stay right-aligned. Confirm step is a compact bordered panel above that row (reason textarea + cancel / confirm void). Dialog content uses max-h-[90vh] overflow-y-auto for long forms.
  • Files changed: src/features/donations/components/DonationsManager.tsx

Recent Updates (March 13, 2026)

Assign Donor Dialog Fix (P0)

1. `cmdk` duplicate `value` collision — Two donor records named "Bethany Community Church" existed. CommandItem used value={donor.name} (not unique). cmdk v1.x deduplicates items by value, collapsing both into one non-functional item. 2. Missing `shouldFilter={false}` — The Command component had no CommandInput, but cmdk's built-in filter was still active (default shouldFilter: true), interfering with the external API-driven search. 3. Dual-purpose state variableselectedDonorForAssignment was used as both the search input value AND the selected donor display name. After selecting a donor, the debounce timer fired again with the full name, triggering a new search and causing re-render/popover timing issues.

  • Issue: Mike Labrum (Deeper Walk) reported that the "Assign Donor" dialog allowed searching for donors but clicking a result did not select them. "Bethany Community Church" was searchable but not selectable.
  • Root cause (3 compounding bugs):
  • Fix:
  • Separated search term (assignSearchTerm) from display name (selectedDonorForAssignment), matching the working Add Donation flow pattern.
  • Added shouldFilter={false} to <Command> since search is API-driven.
  • Changed CommandItem value to {donor.name}-{donor.id} (unique, prevents deduplication).
  • Fixed Popover open condition: assignSearchTerm.length >= 2 instead of !!selectedDonorForAssignment.
  • Added __none__ clear selection item for UX parity with Add Donation.
  • Added email display below donor names to help disambiguate duplicate names.
  • Replaced mojibake character with <XCircle> Lucide icon on the clear button.
  • Added disabled={!selectedDonorIdForAssignment} to the Assign button.
  • Added onFocus handler to re-open results when clicking back into the input.
  • Pattern rule: When using cmdk Command with API-driven search (no CommandInput), ALWAYS: (a) set shouldFilter={false}, (b) use unique value props containing IDs, (c) keep search term and display name in separate state variables.
  • Files changed: src/features/donations/components/DonationsManager.tsx

Recent Updates (March 8, 2026)

Donation Void Standardization

  • Void reason is now required in both donation management entry points.
  • softDeleteDonation() now rejects blank reasons instead of treating them as optional audit data.
  • Donation notes are preserved on void and now append VOIDED: ... instead of overwriting the prior note history.
  • Direct donation voids still reverse the linked JE first; deposit-driven donation voids still use skipJeVoid = true to avoid double-voiding.

Recent Updates (March 6, 2026)

Robust Donation List Search (P1)

  • Issue: Donation list search mainly matched donor name and purpose, so amount/date searches and other donation metadata were easy to miss.
  • Fix: fetchDonationsPaginated() now uses shared search helpers from src/lib/searchUtils.ts plus a donation-specific matcher in src/lib/db/donations.ts.
  • Search now matches: donation amount, donation date, donor name, donation ID, fund name, purpose, effective donation type, payment method, and payment status.
  • Architecture: This reuses the same shared matcher core as other richer search surfaces, but keeps a donation-specific field adapter instead of forcing one universal search function across donors and fund accounting.
  • Files Changed: src/lib/searchUtils.ts, src/lib/db/donations.ts

Donor + Prospect Entry Search (P1)

  • Issue: The add-donation donor picker still behaved like a narrow name-only search, which made it harder to find existing donors or prospects by email, phone, spouse name, or source.
  • Fix: searchDonorsAndProspects() in src/lib/db.ts now uses the same shared multi-word search core for donor/prospect entry search.
  • Search now matches: donor first/last name, spouse first name, email, phone; plus prospect name, email, phone, and source.
  • Files Changed: src/lib/db.ts, src/lib/searchUtils.ts

Recent Updates (February 28, 2026)

Donation Void & Safety Audit

  • Void Donation UI: Added inline void section to the Edit Donation dialog, gated behind canWriteAccounting permission. Includes reason field and two-step confirmation. Calls softDeleteDonation().
  • `softDeleteDonation()` now auto-reverses JE lines: If the donation has a linked journal_entry_line_id, the function automatically calls voidJournalEntry() to create reversing entries. New skipJeVoid parameter for callers that already handle JE voiding (deposit batch flows, voidJournalEntry cascade).
  • Duplicate detection: handleAddDonation() in Record mode now calls checkForDuplicateDonation(orgId, amount, date) before inserting. Shows a warning dialog with matching donor names if duplicates are found.
  • Improved JE failure error UX: If createDonation() succeeds but JE creation fails, the catch block now shows a specific 8-second toast explaining the donation exists but won't appear on financial reports, instead of a generic "Record failed" message.
  • Files changed: src/lib/db/donations.ts, src/lib/db/deposits.ts, src/lib/db/journal-entries.ts, src/features/donations/components/DonationsManager.tsx, 6 locale files (donations.json)
  • DB fix: Voided duplicate Terry Benware $82.62 donation (God's Grace, 2026-02-03) created via deposit batch duplicating a Stripe webhook donation. Corrected deposit batch JE from $134.37 → $51.75.

Previous Updates (February 20, 2026)

Donation → Journal Entry FK Linkage

  • Schema change: Added journal_entry_line_id UUID FK column on donationsjournal_entry_lines(id) with partial index. Links each donation directly to its debit-side JE line.
  • Postgres (Mar–Apr 2026): Trigger `donations_try_journal_line_link` attempts heuristic journal_entry_line_id for completed non-Stripe-rail donations (donation_try_link_journal_entry). Rows with any of stripe_payment_intent_id, stripe_session_id, stripe_charge_id, or stripe_invoice_id set are skipped so Stripe webhook paths own the FK. See DEVELOPER-PLAYBOOK §45.
  • Backfill: 19,390 / 19,432 active donations linked (99.8%) using three strategies:
  • A) Stripe PI → reference_id
  • B) org/fund + date + amount on asset-type debit lines
  • C) org/fund + date on revenue credit lines (Aplos aggregate entries)
  • `createDonation()` now captures the debit line ID from createJournalEntry() return and saves it back to the donation.
  • `fetchJournalEntryForDonation()` uses FK as primary path (single query, instant). Falls back to Strategy A/B/C matching for the 42 unlinked donations, and auto-backfills the FK on first hit.
  • Void tracking: Added voided_by (UUID → users) and voided_at (timestamp) columns to donations.
  • Files changed: src/lib/db/donations.ts, documentation/frontend/DEVELOPER-PLAYBOOK.md §16
  • Migration: backend/migrations/20260220_donation_journal_entry_line_fk.sql

Previous Updates (February 12, 2026)

Bug Fix: Donation Journal Entry Creation Failure

  • Root cause: createDonation() in src/lib/db/donations.ts silently failed to create journal entries for child org donations. The getEntityId(parentOrgUUID) call returned null for the parent org UUID, which was unsafely cast via as EntityId. The resulting createJournalEntry() call threw inside a try/catch that swallowed the error with console.warn.
  • Impact: ~19,557 donations existed without corresponding journal_entry_lines, making them invisible on the Income Statement by Fund report. Reported by Maribeth Carlton for Jenny Torgerson (Deeper Walk), Anita Seivert (Breathe Pray Worship), and Kelly Majewski (Awakenings).
  • Frontend fix (`src/lib/db/donations.ts`):
  • Line 697: Replaced getEntityId(journalOrgId) as EntityId with getEntityId(journalOrgId) || journalOrgId — falls back to raw UUID when slug mapping unavailable
  • Changed catch block from silent console.warn to throw so JE failures surface to the user
  • Database migration (`backfill_donation_journal_entries`):
  • Created 39,114 journal entry lines (19,557 balanced pairs: debit Cash 1000, credit Revenue 4000) for all completed donations missing JEs
  • Uses parent org's accounts (InFocus Ministries) since child orgs don't have their own
  • Sets organization_id and fund_id to the donation's org (matching existing pattern)
  • Database migration (`backfill_null_purpose_ids`):
  • Fixed NULL purpose_id on donations by inferring from the donor's most common purpose for the same organization
  • Updated corresponding JE lines with the inferred purpose

Previous Updates (January 31, 2026)

Automatic Receipt Emails for Manual Donations

  • Manual donations now trigger receipt emails - When recording a donation via "Add Donation" (Record Donation mode), a donation receipt email is automatically sent to the donor
  • Receipt is only sent if the donation has an associated donor with an email address
  • Uses the same send-donation-receipt edge function as Stripe payments

Stripe card refunds vs void (accounting + donor email)

  • Stripe refunds (Donor Payment Management, donor profile refund dialog, Donations Manager where applicable, or Stripe Dashboard) go through Stripe → `charge.refunded` in `stripe-webhook`. That path inserts a `donation_type: refund` row, updates the original gift’s `payment_status`, and appends new journal_entry_lines (DR donations revenue, CR stripe receivable) for the refunded amount — it does not void or edit the original payment JE lines (audit trail). Donors receive transactional `send-donation-refund-notice` (same fund / fiscal-sponsor branding as receipts). See DEVELOPER-PLAYBOOK.md §13 (*Stripe refunds*).
  • Void donation (softDeleteDonation, often check/cash or corrections) follows separate rules and may reverse linked JEs via the void pipeline — do not conflate with Stripe refund.

Previous Updates (January 27, 2026)

Date-Filtered Stats

  • Total Amount stat card now reflects the selected time period filter
  • When a date filter is selected (Month, YTD, Q1-Q4, Last Year), the Total Amount shows donations for that period only
  • The stat card label displays the selected period (e.g., "Total Amount (Month)")
  • Recurring count and Unassigned count also respect the date filter

Filter Bar Reordering

  • Timeframe dropdown moved to the right of Filter button for better UX
  • New order: Search → Sort → Filter → Timeframe → Page Size

Overview

The Donations Manager is a comprehensive donation tracking and management system. It displays all donations across the organization with advanced filtering, sorting, and search capabilities. Users can add new donations, assign unassigned donations to donors, process refunds, and send receipts.

UI Features

Main Features

  • Search: Search by donation amount, donation date, donor name, donation ID, fund, purpose, type, payment method, or payment status
  • Sort Options:
  • Date (Newest, Oldest)
  • Amount (High to Low, Low to High)
  • Donor name (A-Z, Z-A)
  • Filters:
  • Status (All, Completed, Pending, Failed, Refunded)
  • Type (All, One-time, Recurring, Event)
  • Assignment (All, Assigned to donor, Unassigned)
  • Actions:
  • Add new donation
  • Assign donation to donor
  • Add new donor (from donation flow)
  • View donor profile (click donor name)
  • Send receipt
  • Process refund

Donation Table Columns

  • Status badge (Completed, Pending, Failed, Refunded)
  • Donor name (clickable to view profile)
  • Amount
  • Date
  • Type (One-time, Recurring, Event)
  • Payment method
  • Cause/Campaign
  • Actions dropdown

Add Donation Dialog

Two modes are available:

#### Record Donation Mode For manually recording donations received outside the platform:

  • Donor selection (searchable dropdown across donors and prospects by name, spouse name, email, phone, or prospect source, or "Add New Donor")
  • Amount
  • Date
  • Type (One-time, Recurring)
  • Payment method (Credit Card, Check, Cash, ACH, Wire)
  • Purpose (from Purpose Manager - fund-specific + global purposes; organization default is used when none is selected)
  • Notes (persisted to donations.notes)
  • Receipt sent checkbox
  • Tax deductible checkbox

Accounting Behavior: Manual donations now automatically create journal entry lines for GL posting using purpose-based account defaults (typically DR `default_checking` for non-Stripe/manual methods or DR `stripe_receivable` for Stripe-backed flows, CR `donations_revenue`). The posting code resolves account IDs through the account-default mapping system / get_default_account_id RPC with parent-org fallback; it does not hard-code a single cash/revenue account UUID or account code. Deposit batch flows still post a single batch journal entry and skip per-donation GL entries. Deposit intake (Check Deposit + Regular Deposit) uses that same effective defaulting model for donations_revenue: a fund can rely on its own override or inherit the parent mapping, but the app still refuses to fall back to the first revenue account in the chart when no default resolves.

Process Payment Mode

For processing live payments via Stripe (`create-payment-intent` embedded flow). Charge destination follows `resolvePaymentTargetForOrg`: Connect on the selected org’s ancestor chain, or `legacy_direct` platform charges when the org is under `DIRECT_STRIPE_PAYOUT_ORG_ID`. If neither applies, the UI shows a payment-init error (`connect_not_ready` / `charges_not_enabled` via `getPaymentInitErrorMessage`).

Redirect checkout (Process flow alternate): When the flow uses `createCheckoutSession`, the same resolver and error codes apply.

  • Step 1 - Donation Details:
  • Entity/Organization selection (pre-selected based on current filter, overridable by parent org)
  • Donor selection (required - searchable dropdown or "Add New Donor")
  • Amount selection (suggested amounts: $25, $50, $100, $250, $500, $1000 or custom)
  • Cover processing fee option (2.9% + $0.30)
  • Recurring donation toggle with frequency selection (weekly, monthly, quarterly, annually)
  • Total amount display with fee breakdown
  • Step 2 - Payment:
  • Stripe PaymentElement for secure card entry
  • Submit payment button
  • Cancel option to return to Step 1

Note: Donor is required for Process Payment mode to ensure proper donation tracking and receipt generation.

Assign Donor Dialog

  • Search and select existing donor (API-driven search via searchDonorsAndProspects, donors only)
  • Results show donor name + email for disambiguation of duplicate names
  • Clear selection item at top of dropdown
  • Or create new donor inline via "Add New" button
  • Assign button disabled until a donor is selected
  • Uses shouldFilter={false} on Command + unique value props (name-id) — see pattern rule in March 13 2026 update

Edit Donation Dialog (table pencil)

  • Opens from the per-row edit control when the user has donation management permission (canManageDonations).
  • Fields: Amount, date, type, payment method, purpose (when purposes loaded), tax-deductible, DAF gift + sponsor (same patterns as add/edit elsewhere).
  • Donor: Dedicated section to view current donor and assign or change the linked donor (March 20, 2026) — same search UX as Assign Donor; does not require closing the dialog to open the standalone Assign dialog.
  • Void: Requires accounting write permission (canWriteAccounting); two-step flow with required reason; destructive actions aligned with footer buttons (March 20, 2026 layout).

Data Requirements

Donation List Data

  • id (uuid) - Unique identifier
  • donor_id (uuid, nullable) - Linked donor
  • donor_name (string, nullable) - Donor name (if assigned)
  • amount (decimal) - Donation amount
  • currency (string) - Currency code (default: USD)
  • donation_date (date) - Date of donation
  • donation_type (string) - 'one-time', 'recurring', 'pledge'
  • payment_method (string) - 'credit_card', 'check', 'cash', 'ach', 'wire'
  • payment_status (string) - 'completed', 'pending', 'failed', 'refunded'
  • transaction_id (string, nullable) - External payment processor ID
  • fund_id (uuid, nullable) - Designated fund
  • fund_name (string, nullable) - Fund name
  • campaign_id (uuid, nullable) - Associated campaign
  • campaign_name (string, nullable) - Campaign name
  • designation (string, nullable) - Specific purpose/cause
  • is_anonymous (boolean) - Anonymous donation flag
  • is_recurring (boolean) - Recurring donation flag
  • recurring_frequency (string, nullable) - 'weekly', 'monthly', 'quarterly', 'yearly'
  • check_number (string, nullable) - Check number if applicable
  • deposited_at (datetime, nullable) - When deposited
  • receipt_sent (boolean) - Receipt sent flag
  • receipt_sent_at (datetime, nullable) - When receipt was sent
  • notes (text, nullable) - Internal notes
  • created_at (datetime) - When record was created
  • updated_at (datetime) - When record was updated

Data Mutations

  • Create Donation: Add new donation record
  • Update Donation: Edit donation details
  • Delete Donation: Remove donation (soft delete)
  • Assign Donor: Link donation to existing or new donor
  • Send Receipt: Email receipt to donor
  • Process Refund: Refund a donation

Request/Response Schemas

Donation Schema

interface Donation {
  id: string;
  organization_id: string;
  donor_id?: string;
  donor_name?: string;
  amount: number;
  currency: string;
  donation_date: string;
  donation_type: 'one-time' | 'recurring' | 'pledge';
  payment_method: 'credit_card' | 'check' | 'cash' | 'ach' | 'wire';
  payment_status: 'completed' | 'pending' | 'failed' | 'refunded';
  transaction_id?: string;
  fund_id?: string;
  fund_name?: string;
  campaign_id?: string;
  campaign_name?: string;
  designation?: string;
  is_anonymous: boolean;
  is_recurring: boolean;
  recurring_frequency?: 'weekly' | 'monthly' | 'quarterly' | 'yearly';
  recurring_day?: number;
  recurring_end_date?: string;
  check_number?: string;
  deposited_at?: string;
  receipt_sent: boolean;
  receipt_sent_at?: string;
  notes?: string;
  refund_amount?: number;
  refund_reason?: string;
  refunded_at?: string;
  created_at: string;
  updated_at: string;
}

interface RecurringDonation {
  id: string;
  donor_id: string;
  donor_name: string;
  amount: number;
  recurring_frequency: string;
  recurring_day: number;
  next_charge_date: string;
  recurring_end_date?: string;
  status: 'active' | 'cancelled';
  created_at: string;
}

Authentication & Authorization

Required Permissions

  • donations:read - View donations
  • donations:write - Create and update donations
  • donations:delete - Delete donations
  • donations:refund - Process refunds
  • donations:send_receipt - Send receipts

Role-Based Access

  • Admin: Full access to all donation operations
  • Manager: Can view, create, update, refund, send receipts
  • Staff: Can view, create, update; cannot delete or refund
  • Volunteer: No access to donations manager

Business Logic & Validations

Frontend Validations

  • Amount must be greater than 0
  • Date cannot be in the future
  • Donor required (either existing or new)
  • Payment method required
  • Organization must be selected
  • DAF gifts require a selected DAF sponsor
  • Refund amount cannot exceed original donation amount
  • Email required for sending receipt

Backend Validations (Rails)

  • Amount must be positive decimal
  • Valid donation date
  • Valid payment method
  • Valid payment status
  • Valid donation type
  • Organization exists and user has access
  • Donor exists (if donor_id provided)
  • Non-interfund DAF gifts require daf_sponsor_id (createDonation/updateDonation guards + chk_donations_daf_sponsor_required)
  • Fund exists (if fund_id provided)
  • Campaign exists (if campaign_id provided)
  • Cannot refund already refunded donation
  • Cannot refund more than original amount

Business Rules

  • Donations are organization-specific (multi-tenant)
  • Unassigned donations can be assigned to donors later
  • Refunds create negative entries or update status to 'refunded'
  • Recurring donations automatically create new donation records
  • Receipt emails use organization's email templates
  • Anonymous donations hide donor information in public reports
  • Deposited donations cannot be deleted (only voided)
  • Total donated amount on donor profile updates automatically

State Management

Local State

  • searchQuery - Search input
  • sortBy - Current sort option
  • statusFilter - Status filter
  • typeFilter - Type filter
  • assignmentFilter - Assignment filter
  • addDonationOpen - Add dialog state
  • assignDonorOpen - Assign dialog state
  • addNewDonorOpen - New donor dialog state
  • selectedDonationId - Selected donation for actions
  • newDonation - Form state for new donation
  • newDonor - Form state for new donor

Global State (AppContext)

  • selectedEntity - Current organization
  • setSelectedDonor - Navigate to donor profile
  • setDonorTool - Switch to donors view

Dependencies

Internal Dependencies

  • AppContext - Global state
  • mockData.ts - TO BE REMOVED - getAllDonationRecords(), getAllDonors()
  • UI components (Card, Button, Table, Dialog, etc.)

External Libraries

  • lucide-react - Icons
  • sonner - Toast notifications

Error Handling

Error Scenarios

1. Network Error: Show toast "Unable to load donations. Please try again.", retry button 2. Validation Error: Show inline field errors 3. Permission Error: Show toast "You don't have permission to perform this action" 4. Not Found: Show "Donation not found" message 5. Refund Failed: Show toast "Refund failed: [reason from API]" 6. Receipt Send Failed: Show toast "Failed to send receipt. Please try again." 7. Assign Donor Failed: Show toast "Failed to assign donor"

Loading States

  • Initial load: Skeleton table with 10 rows
  • Pagination: Loading spinner overlay on table
  • Form submission: Disable submit button, show spinner
  • Refund processing: Disable button, show spinner
  • Receipt sending: Disable button, show spinner

Mock Data to Remove

  • mockData.ts - getAllDonationRecords() function
  • mockData.ts - getAllDonors() function (for donor selection)
  • mockData.ts - DonationRecord interface (move to types)

Migration Notes

Phase 1: Data Layer Integration

1. Create/expand Supabase helpers in src/lib/db.ts (or scoped donations modules) 2. Create src/types/donation.ts with TypeScript interfaces 3. Replace getAllDonationRecords() with API call 4. Add loading and error states

Phase 2: CRUD Operations

1. Implement create donation flow 2. Implement update donation flow 3. Implement delete donation flow 4. Test all operations

Phase 3: Advanced Features

1. Implement assign donor flow 2. Implement send receipt flow 3. Implement refund flow 4. Implement recurring donation management

Phase 4: Integration with Donors

1. Ensure clicking donor name navigates to donor profile 2. Ensure donor selection dropdown loads from API 3. Test create new donor from donation flow

Related Documentation


Synced from IFMmvp-Frontend documentation: pages/donor-hub/02-DONATIONS-MANAGER.md

Ready to Get Started?

See how Alignmint can simplify your nonprofit's operations. Schedule a free demo with our team and we'll walk you through everything.

Questions? Email us at steven@getalignmint.org

Ready to get started?Start Plus Trial