Skip to main content

Donors CRM

Donors CRM

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

Recent Updates (April 21, 2026)

Other Revenue card on donor profile

  • What: New DonorOtherRevenueCard renders below donation history when a donor has any active deposit_items linked via donor_id with donation_id IS NULL on finalized deposits (deposited / cleared only). In child-fund views it matches the current CRM entity scope; parent / all views aggregate across visible orgs. The card sums non-donation revenue (sales, program income) and breaks down by revenue account.
  • Critical separation: Lifetime giving total in the donor header does not include this revenue. Year-end tax receipts continue to query donations only. The card is informational/CRM context, not a donation aggregate.
  • Self-hiding: card does not render at all when the donor has zero non-donation revenue items, so 99% of donor profiles look identical to today.
  • Intake: Check + Regular Deposit Manager use the same payer DonorSearch control; when the selected revenue account is classified as non-donation (isDonationRevenueAccount in src/lib/db/deposit-revenue-account.ts), the line does not create a donations row—optional donor link is stored on deposit_items.donor_id only. Donation-revenue lines still run duplicate checks, findOrCreateDonorByName when needed, and createDonation as before.
  • Migration: supabase/migrations/20260422054121_deposit_items_donor_id_fk.sql adds nullable deposit_items.donor_id with ON DELETE SET NULL.

Recent Updates (April 12, 2026)

Deposit-linked donation void behavior (Apr 12, 2026)

  • Standalone deposits: If the donation came from a posted deposit with exactly one active deposit_item, staff can now void it directly from donor-profile donation flows and donation-context JE drawer flows.
  • Posted multi-item deposits: Donation-context JE drawer void now routes through softDeleteDonation() instead of the generic JE batch-void hook, so voiding one donation no longer silently voids the entire deposit batch.
  • Scope: Donation-level partial void works for check deposits and for newer regular deposits that stored per-item posting context on deposit_items. Ambiguous historical multi-item regular deposits still require whole-batch void.
  • Banner / errors: The deposit warning copy now reflects the new rule set, and ambiguous historical regular deposits raise a direct error instead of guessing at a partial reversal.

Edit Donation DAF sponsor now resolves from the donation's org

  • Issue: In donor profile Edit Donation, existing DAF gifts could show a checked DAF box but an empty DAF Sponsor select, even though the donation already had daf_sponsor_id / daf_sponsor_name.
  • Root Cause: The edit dialog loaded DAF sponsor options from the current Donors CRM view entity (safeSelectedEntity) instead of the donation row's own organizationId. Cross-org donations and parent-org/all-entity views could therefore load a sponsor list that did not contain the saved sponsor id.
  • Fix: DonorsCRM.tsx now resolves a dedicated edit DAF entity id from editingDonationRow.organizationId and fetches sponsor options for that donation entity while the edit dialog is open. This matches the existing DonationsManager edit-dialog pattern.
  • Impact: Opening Edit Donation on a DAF gift now shows the saved sponsor correctly, and users can change or keep it without the select appearing blank.

Recent Updates (April 8, 2026)

Header Notes button removed (redundant with Notes tab)

  • Removed the profile card Notes action button from DonorsCRM.tsx. Add and manage notes only from the Notes pill tab (NotesCard); avoids duplicating the add-note entry point next to Send/Resend login link, Edit, Link Household, and Merge.

Recent Updates (April 7, 2026)

Donor type derivation + Stripe CTA behavior (parent org)

  • Donor profile type badges are now treated as a history-derived summary, not a standalone manual truth source. The intended donor summary rules are:
  • one-time when completed history contains only non-recurring gifts
  • recurring when completed history contains only recurring gifts
  • both when completed history contains a mix of recurring and non-recurring gifts
  • The profile badge label now preserves Both Donor correctly anywhere getDonationTypeBadge() receives custom labels.
  • The profile Recurring billing (Stripe) card is no longer shown just because donors.donation_type says recurring/both. Parent-org users now see:
  • Stripe recurring detail card only when Stripe returns at least one valid subscription (active, trialing, or past_due)
  • A compact Open Donor Payment Management CTA in the same slot when no valid subscription exists
  • The CTA deep-links through Zustand navigation context with the current donorId, so Donor Payment Management opens already scoped to that donor.

Stripe recurring billing on donor profile (parent org)

  • Donor profiles load `get-stripe-donor-details` (same edge function as Donor Payment Management) for parent-org users and show next charge, billing day (monthly, from billing_cycle_anchor / period end), and subscription status when a primary valid subscription exists.

Donor portal login link (send / resend) + redirect contract

  • Profile action Send login link (primary, UserPlus) / Resend login link (outline, Mail) for any donor with a real email and canWrite; label/variant follow hasPortalAccess (donor_users.auth_user_id present).
  • sendDonorMagicLink() in src/lib/db/stripe.ts passes `window.location.origin` by default so send-donor-magic-link builds emailRedirectTo as {origin}/donor-portal exactly once (avoids /donor-portal/donor-portal).
  • i18n: donors.portalInvite.create / resend strings updated across locale donors.json files.

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

Recent Updates (April 5, 2026)

Profile pill tabs + edit donation / void UX

  • Donor profile sections use Donations / Notes / Communications PillTabs (donation history, NotesCard, CommunicationStatusCard). Add and edit notes from the Notes tab only.
  • Edit Donation: DAF checkbox ↔ method list stay in sync; void uses the same dialog footer pattern as Donations Manager. updateDonation() normalizes DAF columns the same way as createDonation() when any DAF-related field is saved, and the database now enforces the same invariant for direct writes too (see Developer Playbook).

Recent Updates (March 25, 2026)

Donor profile Merge dialog mount (P0)

  • Issue: Merge on the donor profile appeared to do nothing — setMergeDialogOpen(true) ran while MergeProfileDialog was only rendered in the list view branch; the profile view uses an early return, so the dialog was never mounted.
  • Fix: Render MergeProfileDialog inside the profile branch (same props as before). Removed the unreachable copy from the list branch. Clear mergeDialogOpen in handleBackToList so stale open state does not reopen the dialog later.
  • Docs: See 19-MERGE-PROFILE-DIALOG.md — “Mounting rules”.

Recent Updates (March 23, 2026)

Donor Profile Action Strip Consolidation + Login/Deactivate Behavior (P1)

  • Issue: Profile actions had duplicate household entry points (Link Household + New Household), inconsistent login CTA state when opening a donor from list rows, and a Deactivate action that product wanted removed from this surface.
  • Fix:
  • Replaced split household buttons with one profile action that opens a unified household dialog with pill tabs (Link Existing / Create New)
  • Removed duplicate household dialog mounts from DonorsCRM and kept one dialog source
  • Hydrated donor profile on row click via fetchDonorById() so hasPortalAccess is authoritative in profile state
  • At that time, the login CTA was narrowed to Create Login only when the donor had a real email, canWrite, and no portal access
  • Removed optimistic hasPortalAccess=true local flip after invite send; profile now rehydrates from backend truth
  • Removed Deactivate from profile action strip; backend deactivate/reactivate functions remain available for existing workflows
  • Applied canWrite gating to Notes/Edit/Household profile actions for consistency with write-access rules
  • Files Changed: DonorsCRM.tsx, HouseholdActionDialog.tsx

Recent Updates (March 17, 2026)

Donor Profile Donation History Recurring Badge Mismatch (P0)

  • Issue: Donor Management list showed a donor (e.g., Stephanie Pelletier) as "Recurring" but clicking into the donor profile showed all individual donations badged as "One-Time". The profile donation type badges were inconsistent with the list view and Donations Manager.
  • Root Cause: fetchDonorDonations() in src/lib/db/donations.ts computed effectiveType using only the donation row's own is_recurring and donation_type fields. Every other donation-fetching function (fetchDonationsPage, get_donations_page RPC, fetchDonorsPageFallback, dashboard) uses hybrid logic that also checks the donor record's donors.donation_type. Historical Aplos-imported donations were bulk-imported with donation_type='one-time' and is_recurring=false regardless of the donor's actual recurring status, so the donor-level field was the only reliable source.
  • Fix (code): fetchDonorDonations() now joins donors!donor_id(donation_type) and includes donorIsRecurring in the effectiveType calculation, matching the hybrid pattern used everywhere else.
  • Fix (data): DB migration backfill_recurring_flag_on_aplos_imported_donations updated 272 Aplos-imported donation rows across 8 donors with donors.donation_type='recurring' — set is_recurring=true and donation_type='recurring' on non-Stripe rows. Donors with donation_type='both' were excluded (their one-time rows may be legitimately one-time).
  • Impact: Donor profile donation history badges now match the Donor Management list, Donations Manager, and dashboard views.
  • Files Changed: src/lib/db/donations.ts
  • Documentation: Added "Hybrid recurring logic (mandatory)" rule to DEVELOPER-PLAYBOOK §13

Recent Updates (March 11, 2026)

Send Login Link / Resend Login Link Button on Donor Profile (P0 — User Request)

> Historical context: this rollout introduced resend behavior. Current profile behavior is documented in the April 7, 2026 update above.

  • Issue: Karyn King (Awakenings) reported she could not send a donor portal login link to an existing donor (Kelly Majewski) from the donor profile page. The VolunteersCRM has a "Create Login" / "Resend Login Link" button on volunteer profiles, but no equivalent existed on donor profiles.
  • Root Cause: The DonorsCRM profile action buttons only had Notes, Edit, Household, Merge, Reactivate, and Deactivate. The only path to send a donor magic link was during initial donor creation via the "Create Login Account" toggle in the Add Donor dialog.
  • Fix:
  • Added donor portal invite controls to donor profile action buttons (mirrors VolunteersCRM pattern)
  • Initial labels were "Create Login" (primary, UserPlus) and "Resend Login Link" (outline, Mail); current labels are "Send login link" and "Resend login link"
  • Gated on canWrite permission + donor must have a real email (not @noemail.alignmint.app placeholder)
  • Calls sendDonorMagicLink() from src/lib/db/stripe.tssend-donor-magic-link edge function
  • The original rollout optimistically updated hasPortalAccess on success; current behavior re-fetches profile state from backend truth instead
  • Added hasPortalAccess field to DonorProfile type — populated via lightweight donor_users.auth_user_id check in fetchDonorById()
  • i18n: Added portalInvite.* keys (create, resend, sending, sent, failed) + deactivateConfirm.* keys to locale donors.json files; labels were revised again in April 2026
  • Also fixed: Hardcoded English strings in ConfirmDeactivateDialog (§21), mojibake Ã—× character in tag remove button
  • Files Changed: DonorsCRM.tsx, src/types/data.ts, src/lib/db/donors.ts, 6 locale donors.json files

Recent Updates (March 6, 2026)

Donor Profile Donation History Hidden-Org Fix (P0)

  • Issue: Parent-org admins could open a donor profile, see correct lifetime-giving stat tiles, but the Donation History table showed "No donation history available".
  • Root Cause: fetchDonorDonations() in src/lib/db/donations.ts excluded hiddenOrgIds whenever getOrgId(entityId) returned null for admin view. That hidden-org exclusion is correct for aggregate admin list/report views, but wrong for a donor profile already opened by ID. Hidden-org donors (for example Acacia) therefore showed pre-aggregated donor totals from the donors table while all underlying donation rows were filtered out from the profile history query.
  • Fix: Removed the hidden-org exclusion from fetchDonorDonations() for admin/specific-donor profile views. Fund-scoped views still filter by organization_id, but admin donor profiles now show all donations the user can access for that donor.
  • Impact: Hidden-org donors no longer appear internally inconsistent in Donors CRM. Lifetime totals and Donation History now match on the donor profile.
  • Files Changed: src/lib/db/donations.ts

Robust Donor List Search (P1)

  • Issue: Donor list search was still effectively name/email-oriented, so searches by phone, address, donor totals, or donation dates were easy to miss.
  • Fix: fetchDonorsPaginated() and fetchDonorsSummaryForOrg() now use shared search helpers from src/lib/searchUtils.ts plus a donor-specific matcher in src/lib/db/donors.ts.
  • Search now matches: donor name, spouse first name, email, phone, address fields, tags, notes, total giving amount, and first/last donation dates.
  • Architecture: This is a shared matcher core with a donor-specific adapter, not a single universal search field list shared with fund accounting.
  • Files Changed: src/lib/searchUtils.ts, src/lib/db/donors.ts

Recent Updates (April 5, 2026)

Stripe refund from donor profile Edit Donation (P1)

  • Issue: Parent org users could process refunds only from Donor Payment Management row actions, not from the donor profile Edit Donation dialog (parity with void + payment-management UX).
  • Fix:
  • `Process Refund` outline button in the Edit Donation footer when isParentOrg && canWrite and the row is eligible: not a refund row, has stripe_payment_intent_id, within the same 90-day window as payment management.
  • Opens a second dialog matching Donor Payment Management refund flow: donation picker (up to 10 recent Stripe-backed gifts), amount (CurrencyInput), Stripe reason (duplicate / fraudulent / requested_by_customer), createStripeRefund edge function.
  • `fetchDonorDonations()` now returns stripePaymentIntentId so the UI can gate refunds without guessing; Donor Payment Management refund picker also filters to donations with a Stripe intent (skips check/cash-only rows).
  • Files Changed: src/lib/db/donations.ts, src/features/donors/components/DonorsCRM.tsx, src/features/donors/components/DonorPaymentManagement.tsx
  • Donor email: When Stripe confirms the refund (charge.refunded), stripe-webhook calls `send-donation-refund-notice` so the donor gets the same style of transactional mail as receipts (see playbook + documentation/backend/SMS-EMAIL-INFRASTRUCTURE.md).

Recent Updates (March 4, 2026)

Edit Donation from Donor Profile (P1 — User Request)

  • Issue: Vic Woodward (Marriage Mosaic) reported he could not change the payment method on donations from the donor profile. The Method badge in the Donation History table was display-only, the "Edit" button edited the donor record (not individual donations), and clicking a donation row opened the JE ledger drawer with no donation-level fields.
  • Root Cause: No UI existed to edit individual donation details (amount, date, type, method, purpose, tax-deductible) from the donor profile view. The only edit path was via Donors Hub → Donation Management (DonationsManager.tsx), which has per-row edit buttons.
  • Fix:
  • Added Actions column to Donation History table (gated on canWrite permission)
  • Each donation row now has a Pencil icon button that opens an Edit Donation dialog
  • Dialog fields: Amount (CurrencyInput), Date (DatePicker), Type (Select), Method (Select), Purpose (Select), Tax Deductible (Checkbox)
  • Void Donation section (gated on canWriteAccounting) with expandable confirmation + reason field
  • Row click → JE drawer behavior preserved (e.stopPropagation() on Pencil button)
  • Calls updateDonation() / softDeleteDonation() from src/lib/db/donations.ts (backend already supported all fields)
  • Invalidates donorDonations, donors, donorsCRM, donationsPaginated queries on save/void
  • Files Changed: src/features/donors/components/DonorsCRM.tsx

§28 Compliance: CurrencyInput for Recurring Amount (P1)

  • Issue: Edit Donor dialog used <Input type="number"> for the Recurring Amount field, violating §28 (Specialized Input Components).
  • Fix: Replaced with <CurrencyInput> component.
  • Files Changed: src/features/donors/components/DonorsCRM.tsx

§21 Compliance: Hardcoded English Strings (P2)

  • Issue: Several UI strings were hardcoded in English instead of using t() i18n calls.
  • Fixed strings: "Reactivate", "Deactivate", "Tags" label, breadcrumb labels ("Donors", "Donor Management"), results summary ("Showing X of Y donors"), "N/A" fallback, "Inactive" badge, "General Fund" fallback in donation history.
  • New i18n keys added: donorManagement, notAvailable, inactive, reactivate, deactivate — added to all 6 locale files (en, es, fr, de, zh, th).
  • Files Changed: src/features/donors/components/DonorsCRM.tsx, src/i18n/locales/{en,es,fr,de,zh,th}/donors.json

Recent Fixes (February 25, 2026)

Giving Overview Missing Cross-Org Donors (P0)

  • Issue: Maribeth reported Christine Gregory's donation doesn't appear on the Giving Overview for Church Without Walls. The Giving Overview showed 0 donors and $0.00 when searching for "gregory".
  • Root Cause: fetchDonorGivingGrid() in src/lib/db/donations.ts had a double-filter bug. Step 0 correctly found donor IDs via donations.organization_id (donations table). But Step 1 then filtered the donors table with BOTH .in('id', activeDonorIds) AND .eq('organization_id', orgId). Christine Gregory's donor record lives in Love With Actions (organization_id = fc56a535), but she has 49 donations totaling $30,000 to Church Without Walls. The second filter dropped her because her donors.organization_id didn't match CWW.
  • Scope: 171 donors with 1,811 cross-org donations totaling $744,498.30 were invisible in Giving Overview when viewing the org they donated to.
  • Fix: Removed the donors.organization_id filter from Step 1. The activeDonorIds from Step 0 already correctly scopes donors via the donations table — matching the pattern used by fetchDonorsPaginated and fetchDonorIdsForOrg. The hidden-org exclusion for admin view is preserved.
  • Not a DAF issue: The payment method (DAF vs credit card) is irrelevant — the bug affected all cross-org donors regardless of payment method.
  • Console error ("Cannot edit a reconciled entry"): This is working as intended. The reconciliation guard correctly blocks editing JE lines that are reconciled. Maribeth's older Christine Gregory donations (pre-December 2025) have reconciled JE lines.
  • Files Changed: src/lib/db/donations.ts (fetchDonorGivingGrid)

Production note (Mar 2026): The Giving Overview screen calls `get_giving_grid_page` via fetchGivingGridPage(). Any future scoping or filter changes must be applied to that RPC (and kept in sync with fetchDonorGivingGrid if the legacy helper is retained).

Donor type filter (Mar 30, 2026): CRM list filters use exclusive donors.donation_type values (Recurring = recurring only, not both). `get_donors_page` and fetchDonorsPage must stay aligned with Giving Overview; see DEVELOPER-PLAYBOOK.md and 00-DONOR-HUB.md Recent Changes.

Date Filter Persistence (P0)

  • Issue: Users reported that the date period filter (e.g., "Month to Date") resets to "All Time" every time they navigate away and return to the Donors page.
  • Root Cause: dateFilterPeriod, startDate, and endDate were managed with plain useState('all') — no persistence mechanism.
  • Fix: Date filter period is now persisted to localStorage per entity via key alignmint-donors-date-filter-{entity}. On mount, the component reads the saved period and computes startDate/endDate from it. A useEffect re-syncs when the entity changes (user switches org). pendingDonorFilter from dashboard navigation still overrides the persisted value.
  • Same fix applied to `DonationsManager.tsx` (key: alignmint-donations-date-filter-{entity}).
  • §22 Compliance: Fixed toISOString().split('T')[0]toLocalDateString() in export filenames.
  • Files Changed: src/features/donors/components/DonorsCRM.tsx, src/features/donations/components/DonationsManager.tsx, documentation/frontend/DEVELOPER-PLAYBOOK.md

Recent Fixes (February 16, 2026)

Summary Tiles + Export Now Respect All Active Filters (P0)

  • Issue: On the Donors page, changing filters (especially date period) updated the donor count but left total donation amount unchanged, creating a mismatch between table and summary tiles.
  • Root Cause: Summary totals were pulled from separate all-time helper queries (fetchTotalDonationsSum, fetchRecurringDonorsCount) that did not receive the active list filters (search, donation type, date period).
  • Fix: fetchDonorsSummaryForOrg() now accepts filter options and computes summary metrics using the same filter inputs as fetchDonorsPaginated():
  • search
  • donationType
  • startDate / endDate
  • scoped donor IDs for specific-org views
  • admin-block guard for non-parent-org users
  • Additional UX Fixes:
  • Clear Filters now resets search text, donation type, and date period back to All Time.
  • Export Donors now applies the same active filters (including date period), so exports match on-screen results.
  • Empty-state messaging now treats date period as an active filter.
  • Impact: Summary tiles, table totals, and exports now stay in sync under all active DonorsCRM filters.
  • Files Changed: src/features/donors/components/DonorsCRM.tsx, src/lib/db/donors.ts

Parent Org "All Donors" Search Missing Some Donors (P0)

  • Issue: Parent org users reported donors appearing in a specific fund view (e.g., Cornerstone) but not appearing when searching from the parent org "all donors" view (InFocus Ministries).
  • Root Cause: Multiple donor data helpers in src/lib/db/donors.ts queried large donations datasets without explicit row limits. Supabase/PostgREST applies a default 1000-row cap when no range/limit is provided, which silently truncated donor ID sets used for filtering.
  • Fix: Added explicit .limit(50000) caps to high-volume donor/donation queries and aligned donor ID derivation with active donations (.eq('status', 'active')) in the donor query helpers. Extended the same fix to dashboard metrics, chart data, global search, celebrations, and DAF summary queries.
  • Impact: Parent org and fund views now use complete donor ID sets (up to 50k rows per query), so donor search/filter results, dashboard metrics, and chart data are consistent across entity scopes.
  • Files Changed: src/lib/db/donors.ts, src/lib/db/dashboard.ts, src/lib/db/donations.ts, src/lib/db.ts

Donor Search Not Finding Existing Donors (P0)

1. Queries donations table to find donor IDs who have donated to the selected org 2. Also includes donors whose organization_id matches (catches newly created donors with no donations yet) 3. Deduplicates results before returning 4. Admin view (no org filter) continues to search all donors

  • Issue: When viewing a specific nonprofit (e.g., "Church Without Walls"), searching for donors by name in the donation entry dialog returned "No donor or prospect found" — even though the donor existed and appeared in the DonorsCRM table. Attempting to create a new donor was then blocked with "A donor with this email already exists."
  • Root Cause: searchDonorsAndProspects() in db.ts filtered donors by donors.organization_id (the org that *created* the donor record). But fetchDonorsPaginated() uses a donations-based lookup (finds donors who have *donated to* the org via the donations table). These two strategies were misaligned — a donor created under Org A who donated to Org B would appear in Org B's CRM table but not in the search.
  • Fix: searchDonorsAndProspects() now uses the same donations-based strategy as fetchDonorsPaginated():
  • Files Changed: src/lib/db.ts (searchDonorsAndProspects)

Edit Dialog Losing Structured Address Data (P1)

  • Issue: When opening the Edit Donor dialog, city, state, and zipCode were always set to empty strings. The DonorProfile type only carried a computed address display string (e.g., "Dallas, TX"), so structured address data was lost on every edit.
  • Fix:
  • Added addressLine1, city, state, zipCode optional fields to DonorProfile interface in types/data.ts
  • fetchDonorsPaginated() and fetchDonorById() now populate these structured fields from the database
  • Added zip_code to the fetchDonorsPaginated select query
  • openEditDialog() in DonorsCRM.tsx now uses donor.addressLine1, donor.city, donor.state, donor.zipCode instead of empty strings
  • Files Changed: src/types/data.ts, src/lib/db/donors.ts, src/features/donors/components/DonorsCRM.tsx

Improved Duplicate Donor Error Message (P2)

  • Issue: When checkEmailExists blocked new donor creation, the error message ("A donor with this email already exists.") gave no guidance on how to find the existing donor.
  • Fix: Error message now reads: "A donor with this email already exists. Try searching for them in the donor list or switch to the All Nonprofits view."
  • Files Changed: src/features/donors/components/DonorsCRM.tsx, src/components/shared/AddDonorDialog.tsx

Recent Updates (February 1, 2026)

Spouse First Name Field

  • New Field: Added spouse_first_name column to donors table for better data integrity
  • Use Case: Many donor records contain both husband and wife names (e.g., "John and Jane Smith"). This field allows tracking the spouse's first name separately for cleaner data and personalized communications.
  • UI Updates:
  • Add Donor dialog: New "Spouse First Name" field with hint text
  • Edit Donor dialog: New "Spouse First Name" field with hint text
  • Donor exports: New "Spouse First Name" column included
  • Donor Portal: Welcome message and contribution statements now display joint names (e.g., "Welcome back, John & Jane!")
  • Database: New spouse_first_name VARCHAR(100) column with index for non-null values

Recent Updates (January 23, 2026)

Date Filtering Fix

  • Issue: Date filtering on the Donors page was incorrectly filtering by last_donation_date on the donors table. This caused recurring donors to be excluded when filtering by past periods (e.g., "Last Year") because recurring donors have recent last_donation_date values.
  • Root Cause: The donors table stores global aggregates (last_donation_date, total_donated, etc.) across ALL organizations, not per-fund. Date filtering on this field doesn't make semantic sense for donors.
  • Fix: Date filtering now queries the donations table to find donors who donated during the selected period, not donors whose last donation was during that period.
  • Technical Details:
  • fetchDonorIdsForOrg() now accepts optional startDate and endDate parameters
  • When date filter is applied, the function queries donations.donation_date instead of donors.last_donation_date
  • This correctly returns donors who made donations to the specific org during the selected period
  • For admin view with date filter, a separate query fetches donor IDs from the donations table
  • Impact: Recurring donors now correctly appear when filtering by any time period, as long as they made a donation during that period.

Recent Updates (December 30, 2025)

Card Expiration Column

  • Card Exp column added to donor list table
  • Data source: payment_methods table (populated by Stripe webhook)
  • Note: Only shows data for donors who have made Stripe payments. Aplos-imported donors will show "—" since their payment data was processed externally.
  • Stripe webhook updated to INSERT payment methods on payment_intent.succeeded events

Recent Fixes (December 22, 2025)

Bug Fixes

  • Recurring Donors Count: Fixed fetchDonors function selecting wrong column (donor_type instead of donation_type), causing recurring donors count to always show 0
  • Assign Donation Stats: assignDonorToDonation now updates donor stats (total_donated, donation_count, first_donation_date, last_donation_date) when linking a donation to a donor
  • Donation History: Added SQL migration script (scripts/LINK-DONATIONS-TO-DONORS.sql) to link unassigned donations and recalculate donor stats

Data Model Clarification

  • `donor_type` column: Entity type (individual, organization, foundation, corporate, government)
  • `donation_type` column: Giving pattern (one-time, recurring, both)

Overview

The Donors CRM is a comprehensive donor management system that allows users to view, search, filter, and manage donor information. It provides both a list view of all donors and detailed individual donor profiles with complete donation history.

UI Features

List View

  • Back Button: Returns to Donor Hub (standard ghost button pattern)
  • Summary Tiles: Three metric cards above search bar:
  • Total Donations: Sum of donations for donors matching all active list filters (search, type, date period)
  • Total Donors: Count of donors matching all active list filters
  • Recurring Donors: Count of filtered donors whose donation_type is recurring or both
  • Pagination Controls: Displayed at both top and bottom of table
  • Rows per page selector (100, 250, 500)
  • Page navigation (Previous/Next buttons)
  • Current page indicator
  • Search: Search donors by name, spouse name, email, phone, address, tags, notes, total giving amount, and first/last donation dates
  • Sort Options:
  • Name (A-Z, Z-A)
  • Total donations (High to Low, Low to High)
  • Last donation date (Newest, Oldest)
  • Filters:
  • Donation type (All, One-time, Recurring, Both, Event Attended)
  • Date Period Filter: Month, YTD, Last Year, Q1-Q4, All Time
  • Filters donors who made a donation during the selected period (queries donations table)
  • NOT the same as filtering by last_donation_date (which would exclude active recurring donors)
  • Clear Filters: Resets search, donation type, and date period together
  • Actions:
  • Add new donor
  • View donor profile
  • Edit donor (from profile)
  • Export donors (CSV, Excel, PDF) using current active list filters

Profile View

  • Donor Information Card:
  • Name, email, phone, address (all with click-to-copy functionality)
  • Avatar with initials
  • Donor type badge
  • Tags display (if any)
  • Action buttons (right side, stacked vertically on desktop):
  • Send login link / Resend login link (if donor has real email + canWrite) — primary button when no portal access, outline when donor_users.auth_user_id is linked; sends magic link via sendDonorMagicLink() (edge function appends /donor-portal to the app origin passed from the client)
  • Edit (canWrite) - Edit donor information
  • Link Household (canWrite, if no household) - Opens unified household dialog with pill tabs for Link Existing / New Household flows
  • Merge (canManageDonors) - Opens merge dialog
  • Reactivate (canManageDonors, inactive donors only) - Restores donor to active state
  • Household Section:
  • If donor is in a household: Shows HouseholdCard with household name, total giving, and member list
  • Household members can be clicked to navigate to their profile
  • Primary contact can be set from within the household card
  • Profile pill tabs (shared PillTabs, see documentation/pages/components/26-PILL-TABS.md): Order DonationsNotesCommunications. Default tab is Donations; tab resets when the selected donor changes, when opening a donor from dashboard deep-link or global search, and when picking a row from the list. Donations holds the donation history table (filters/sort unchanged). Notes holds NotesCard (add-note and full note management; there is no duplicate notes shortcut on the profile header card). Communications holds read-only CommunicationStatusCard (marketing email + SMS consent; fetch runs only while this tab is active); if the donor has no email and no phone, a short empty state explains that contact info is required.
  • Donation Summary:
  • Total lifetime donations
  • Number of donations
  • Average donation amount
  • First donation date
  • Last donation date
  • Donation type (One-time, Recurring, Both)
  • Donation History Table:
  • Date, amount, type, fund, purpose, payment method
  • Edit Donation: Per-row Pencil icon (gated on canWrite) opens Edit Donation dialog to change amount, date, type, method, purpose, tax-deductible status. Method and This is a DAF gift stay in sync (including a DAF option in the method list); save matches Donations Manager semantics (method: daf when DAF, cleared sponsors when not). Payment methods include PayPal for parity with Donations Manager. Process Refund (gated on isParentOrg && canWrite, Stripe-backed gift, last 90 days) opens the same refund flow as Donor Payment Management. Void Donation (canWriteAccounting) uses the shared dialog footer pattern (destructive void + Cancel/Save row, confirm panel above).
  • Ledger Drawer: Click any donation row to open a Sheet showing the full journal entry (all debit/credit lines, fund breakdown, entry number). Uses donations.journal_entry_line_id FK → fetchJournalEntryByLineId() for instant lookup.
  • Donor Insights:
  • Donation frequency
  • Preferred payment method
  • Preferred causes/campaigns
  • **Communication History:
  • Emails sent
  • Thank you notes
  • Receipts

Data Requirements

Donor List Data

  • id (uuid) - Unique identifier
  • first_name (string) - Donor first name
  • last_name (string) - Donor last name
  • spouse_first_name (string, nullable) - Spouse's first name for joint giving
  • email (string) - Email address
  • phone (string, nullable) - Phone number
  • address (object, nullable) - Full address
  • donor_type (string) - 'individual', 'organization', 'foundation'
  • status (string) - 'active', 'inactive', 'lapsed'
  • total_donated (decimal) - Lifetime donation total
  • donation_count (integer) - Number of donations
  • first_donation_date (date) - Date of first donation
  • last_donation_date (date) - Date of last donation
  • donation_type (string) - 'one-time', 'recurring', 'both'
  • tags (array) - Donor tags/categories
  • created_at (datetime) - When donor was added

Donor Profile Data

All of the above, plus:

  • donation_history (array) - Complete donation records
  • communication_history (array) - Email/message history
  • notes (text) - Internal notes about donor
  • preferred_payment_method (string) - Most used payment method
  • preferred_causes (array) - Most donated to causes

Data Mutations

  • Create Donor: Add new donor to system
  • Update Donor: Edit donor information
  • Delete Donor: Soft delete (mark as inactive)
  • Add Note: Add internal note to donor profile
  • Send Message: Send email to donor
  • Process Refund (Stripe): From donor profile Edit Donation (parent org + canWrite) or from Donor Payment Management; requires create-stripe-refund / Stripe payment intent on the gift

Request/Response Schemas

Donor Schema

interface Donor {
  id: string;
  organization_id: string;
  first_name: string;
  last_name: string;
  email: string;
  phone?: string;
  address?: {
    line1: string;
    line2?: string;
    city: string;
    state: string;
    zip: string;
    country: string;
  };
  donor_type: 'individual' | 'organization' | 'foundation';
  status: 'active' | 'inactive' | 'lapsed';
  total_donated: number;
  donation_count: number;
  average_donation?: number;
  first_donation_date?: string;
  last_donation_date?: string;
  donation_type: 'one-time' | 'recurring' | 'both';
  tags: string[];
  notes?: string;
  preferred_payment_method?: string;
  preferred_causes?: string[];
  created_at: string;
  updated_at: string;
}

interface DonationHistoryItem {
  id: string;
  amount: number;
  donation_date: string;
  donation_type: 'one-time' | 'recurring' | 'pledge';
  payment_method: string;
  payment_status: 'completed' | 'pending' | 'failed' | 'refunded';
  fund_name?: string;
  campaign_name?: string;
  designation?: string;
  transaction_id?: string;
  refunded_at?: string;
  refund_reason?: string;
}

interface CommunicationHistoryItem {
  id: string;
  type: 'email' | 'phone' | 'mail' | 'in_person';
  subject?: string;
  message?: string;
  sent_at: string;
  status: 'sent' | 'delivered' | 'opened' | 'failed';
}

Authentication & Authorization

Required Permissions

  • donors:read - View donor list and profiles
  • donors:write - Create and update donors
  • donors:delete - Delete donors
  • donations:refund - Process refunds

User Type & Position Access

  • Parent Org (`parent_org`): Full access to donor data across all orgs (subject to position/access level)
  • Fund User (`fund_user`): Access to donors in assigned orgs (subject to position/access level)
  • Positions: Directors and assistants typically have Donor Hub access; custom positions must enable Donor Hub permissions
  • Read-only access: View-only donor access
  • Volunteer/Donor: No access to Donor Hub

Business Logic & Validations

Frontend Validations

  • Email must be valid format
  • Phone must be valid format (if provided)
  • At least one of email or phone required
  • First name and last name required
  • Organization must be selected (if user has access to multiple)
  • Refund amount cannot exceed original donation amount

Backend Validations (Rails)

  • Email uniqueness within organization
  • Valid email format
  • Valid phone format
  • Donor type must be one of allowed values
  • Status must be one of allowed values
  • Total donated must be non-negative
  • Donation count must be non-negative
  • Tags must be array of strings

Business Rules

  • Donors are organization-specific (multi-tenant isolation)
  • Deleting a donor is soft delete (status = 'inactive')
  • Donor's total_donated and donation_count are calculated fields, updated via database triggers or background jobs
  • Refunds create negative donation entries
  • Lapsed donors: No donation in past 12 months
  • Major donors: Total donated > $10,000 (configurable)

Parent Org Admin View

  • When viewing as parent org (admin view), ALL donors across all organizations are displayed
  • Uses isAdminView() helper from entityMapping.ts to dynamically detect admin views
  • Detection is based on organization type = 'parent_org' in the database, NOT hardcoded entity names
  • Admin view does NOT filter by organization - shows complete donor database
  • Specific nonprofit views filter donors by those who have donated to that organization
  • Search and filters work identically in both views, just with different base datasets
  • Production-ready: Works for any parent org, not just InFocus Ministries

State Management

Local State

  • view - 'list' or 'profile'
  • searchQuery - Search input value
  • sortBy - Current sort option
  • donationTypeFilter - Current filter
  • dateFilterPeriod - Persisted to localStorage per entity (alignmint-donors-date-filter-{entity})
  • addDonorOpen - Dialog state
  • Edit-donation and profile-refund dialog state — see DonorsCRM.tsx (editDonationOpen, editingDonationRow, profileRefundDialogOpen, profileRefundForm, etc.)
  • newDonor - Form state for adding donor

Global State (AppContext)

  • selectedEntity - Current organization
  • selectedDonor - Currently selected donor (for profile view)
  • setSelectedDonor - Function to set selected donor

Dependencies

Internal Dependencies

  • AppContext - Global state
  • mockData.ts - TO BE REMOVED - Currently provides donor data
  • UI components (Card, Button, Table, Dialog, etc.)

External Libraries

  • lucide-react - Icons
  • sonner - Toast notifications

Error Handling

Error Scenarios

1. Network Error: Show error toast "Unable to load donors. Please check your connection.", allow retry 2. Validation Error: Show field-level errors inline 3. Permission Error: Show toast "You don't have permission to perform this action" 4. Not Found: Show "Donor not found" message 5. Refund Failed: Show toast "Refund failed: [reason]" 6. Email Send Failed: Show toast "Failed to send email to donor"

Loading States

  • Initial load: Show skeleton table with 10 rows
  • Pagination: Show loading spinner in table
  • Profile load: Show skeleton cards
  • Form submission: Disable submit button, show spinner
  • Refund processing: Disable refund button, show spinner

Mock Data to Remove

  • mockData.ts - getDonorProfile() function
  • mockData.ts - getAllDonors() function
  • mockData.ts - DonorProfile interface (move to types file)

Migration Notes

Phase 1: Data Layer Integration

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

Phase 2: Profile Integration

1. Replace getDonorProfile() with API call 2. Fetch donation history from API 3. Fetch communication history from API

Phase 3: Mutations

1. Implement create donor API call 2. Implement update donor API call 3. Implement delete donor API call 4. Refund API: Implemented — Stripe refunds via create-stripe-refund from Donor Payment Management and donor profile Edit Donation (parent org + canWrite). 5. Implement send message API call

Phase 4: Real-time Updates

1. Consider WebSocket for real-time donation updates 2. Or implement polling for new donations 3. Update donor totals when new donations come in

Donor Portal Login Account Creation

When creating a new donor with "Create Login Account" enabled:

Flow

1. Donor record is created in donors table 2. donor_users record is created with status: 'invited' 3. send-donor-magic-link Edge Function is called with:

4. Edge Function creates auth user (if not exists) and sends magic link email 5. Donor clicks link → redirected to /donor-portal

  • email - Donor's email address
  • donorName - Full name (first + last)
  • organizationId - Organization UUID
  • organizationName - Organization display name (optional)

Database Functions

// Create donor with optional login account
createDonor({
  first_name: string;
  last_name: string;
  email: string;
  entityId: string;
  createLoginAccount?: boolean;  // Triggers magic link flow
  organizationName?: string;     // Used in email template
})

// Send magic link (called internally by createDonor)
sendDonorMagicLink(
  email: string,
  donorName: string,
  organizationId: string,
  organizationName?: string,
  redirectUrl?: string
)

Edge Function: send-donor-magic-link

  • Creates auth user if not exists
  • Links user to organization via organization_users table
  • Sends OTP email via Supabase Auth
  • Redirects to /donor-portal on success

Related Documentation

Marketing communications (profile)

Read-only Marketing communications card (CommunicationStatusCard): Email row reflects email_unsubscribes (unsubscribe link, complaints, hard bounces, etc.); Text row reflects sms_consent (STOP). Channels are independent. Staff cannot record opt-outs from the CRM; the same component is used on contacts, volunteers, and prospects.


Synced from IFMmvp-Frontend documentation: pages/donor-hub/01-DONORS-CRM.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