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
DonorOtherRevenueCardrenders below donation history when a donor has any activedeposit_itemslinked viadonor_idwithdonation_id IS NULLon finalized deposits (deposited/clearedonly). In child-fund views it matches the current CRM entity scope; parent /allviews 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
donationsonly. 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 (
isDonationRevenueAccountinsrc/lib/db/deposit-revenue-account.ts), the line does not create adonationsrow—optional donor link is stored ondeposit_items.donor_idonly. Donation-revenue lines still run duplicate checks,findOrCreateDonorByNamewhen needed, andcreateDonationas before. - Migration:
supabase/migrations/20260422054121_deposit_items_donor_id_fk.sqladds nullabledeposit_items.donor_idwithON 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 haddaf_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 ownorganizationId. 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.tsxnow resolves a dedicated edit DAF entity id fromeditingDonationRow.organizationIdand fetches sponsor options for that donation entity while the edit dialog is open. This matches the existingDonationsManageredit-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-timewhen completed history contains only non-recurring giftsrecurringwhen completed history contains only recurring giftsbothwhen 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_typesays recurring/both. Parent-org users now see: - Stripe recurring detail card only when Stripe returns at least one valid subscription (
active,trialing, orpast_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 andcanWrite; label/variant followhasPortalAccess(donor_users.auth_user_idpresent). sendDonorMagicLink()insrc/lib/db/stripe.tspasses `window.location.origin` by default sosend-donor-magic-linkbuildsemailRedirectToas{origin}/donor-portalexactly once (avoids/donor-portal/donor-portal).- i18n:
donors.portalInvite.create/resendstrings updated across localedonors.jsonfiles.
> 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 ascreateDonation()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 whileMergeProfileDialogwas only rendered in the list view branch; the profile view uses an earlyreturn, so the dialog was never mounted. - Fix: Render
MergeProfileDialoginside the profile branch (same props as before). Removed the unreachable copy from the list branch. ClearmergeDialogOpeninhandleBackToListso 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
DonorsCRMand kept one dialog source - Hydrated donor profile on row click via
fetchDonorById()sohasPortalAccessis 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=truelocal 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
canWritegating 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()insrc/lib/db/donations.tscomputedeffectiveTypeusing only the donation row's ownis_recurringanddonation_typefields. Every other donation-fetching function (fetchDonationsPage,get_donations_pageRPC,fetchDonorsPageFallback, dashboard) uses hybrid logic that also checks the donor record'sdonors.donation_type. Historical Aplos-imported donations were bulk-imported withdonation_type='one-time'andis_recurring=falseregardless of the donor's actual recurring status, so the donor-level field was the only reliable source. - Fix (code):
fetchDonorDonations()now joinsdonors!donor_id(donation_type)and includesdonorIsRecurringin theeffectiveTypecalculation, matching the hybrid pattern used everywhere else. - Fix (data): DB migration
backfill_recurring_flag_on_aplos_imported_donationsupdated 272 Aplos-imported donation rows across 8 donors withdonors.donation_type='recurring'— setis_recurring=trueanddonation_type='recurring'on non-Stripe rows. Donors withdonation_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
canWritepermission + donor must have a real email (not@noemail.alignmint.appplaceholder) - Calls
sendDonorMagicLink()fromsrc/lib/db/stripe.ts→send-donor-magic-linkedge function - The original rollout optimistically updated
hasPortalAccesson success; current behavior re-fetches profile state from backend truth instead - Added
hasPortalAccessfield toDonorProfiletype — populated via lightweightdonor_users.auth_user_idcheck infetchDonorById() - i18n: Added
portalInvite.*keys (create, resend, sending, sent, failed) +deactivateConfirm.*keys to localedonors.jsonfiles; 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 localedonors.jsonfiles
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()insrc/lib/db/donations.tsexcludedhiddenOrgIdswhenevergetOrgId(entityId)returnednullfor 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 thedonorstable 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 byorganization_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()andfetchDonorsSummaryForOrg()now use shared search helpers fromsrc/lib/searchUtils.tsplus a donor-specific matcher insrc/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 && canWriteand the row is eligible: not a refund row, hasstripe_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),createStripeRefundedge function. - `fetchDonorDonations()` now returns
stripePaymentIntentIdso 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-webhookcalls `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
canWritepermission) - 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()fromsrc/lib/db/donations.ts(backend already supported all fields) - Invalidates
donorDonations,donors,donorsCRM,donationsPaginatedqueries 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()insrc/lib/db/donations.tshad a double-filter bug. Step 0 correctly found donor IDs viadonations.organization_id(donations table). But Step 1 then filtered thedonorstable 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 herdonors.organization_iddidn'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_idfilter from Step 1. TheactiveDonorIdsfrom Step 0 already correctly scopes donors via the donations table — matching the pattern used byfetchDonorsPaginatedandfetchDonorIdsForOrg. 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, andendDatewere managed with plainuseState('all')— no persistence mechanism. - Fix: Date filter period is now persisted to
localStorageper entity via keyalignmint-donors-date-filter-{entity}. On mount, the component reads the saved period and computesstartDate/endDatefrom it. AuseEffectre-syncs when the entity changes (user switches org).pendingDonorFilterfrom 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 asfetchDonorsPaginated(): searchdonationTypestartDate/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.tsqueried largedonationsdatasets 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()indb.tsfiltered donors bydonors.organization_id(the org that *created* the donor record). ButfetchDonorsPaginated()uses a donations-based lookup (finds donors who have *donated to* the org via thedonationstable). 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 asfetchDonorsPaginated(): - Files Changed:
src/lib/db.ts(searchDonorsAndProspects)
Edit Dialog Losing Structured Address Data (P1)
- Issue: When opening the Edit Donor dialog,
city,state, andzipCodewere always set to empty strings. TheDonorProfiletype only carried a computedaddressdisplay string (e.g.,"Dallas, TX"), so structured address data was lost on every edit. - Fix:
- Added
addressLine1,city,state,zipCodeoptional fields toDonorProfileinterface intypes/data.ts fetchDonorsPaginated()andfetchDonorById()now populate these structured fields from the database- Added
zip_codeto thefetchDonorsPaginatedselect query openEditDialog()inDonorsCRM.tsxnow usesdonor.addressLine1,donor.city,donor.state,donor.zipCodeinstead 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
checkEmailExistsblocked 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_namecolumn 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_dateon thedonorstable. This caused recurring donors to be excluded when filtering by past periods (e.g., "Last Year") because recurring donors have recentlast_donation_datevalues. - Root Cause: The
donorstable 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
donationstable to find donors who donated during the selected period, not donors whose last donation was during that period. - Technical Details:
fetchDonorIdsForOrg()now accepts optionalstartDateandendDateparameters- When date filter is applied, the function queries
donations.donation_dateinstead ofdonors.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_methodstable (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.succeededevents
Recent Fixes (December 22, 2025)
Bug Fixes
- Recurring Donors Count: Fixed
fetchDonorsfunction selecting wrong column (donor_typeinstead ofdonation_type), causing recurring donors count to always show 0 - Assign Donation Stats:
assignDonorToDonationnow 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_typeis 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
donationstable) - 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 whendonor_users.auth_user_idis linked; sends magic link viasendDonorMagicLink()(edge function appends/donor-portalto 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
HouseholdCardwith 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, seedocumentation/pages/components/26-PILL-TABS.md): Order Donations → Notes → Communications. 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 holdsNotesCard(add-note and full note management; there is no duplicate notes shortcut on the profile header card). Communications holds read-onlyCommunicationStatusCard(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: dafwhen DAF, cleared sponsors when not). Payment methods include PayPal for parity with Donations Manager. Process Refund (gated onisParentOrg && 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_idFK →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; requirescreate-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 profilesdonors:write- Create and update donorsdonors:delete- Delete donorsdonations: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 fromentityMapping.tsto 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 valuesortBy- Current sort optiondonationTypeFilter- Current filterdateFilterPeriod- 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 organizationselectedDonor- Currently selected donor (for profile view)setSelectedDonor- Function to set selected donor
Dependencies
Internal Dependencies
AppContext- Global statemockData.ts- TO BE REMOVED - Currently provides donor data- UI components (Card, Button, Table, Dialog, etc.)
External Libraries
lucide-react- Iconssonner- 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()functionmockData.ts-getAllDonors()functionmockData.ts-DonorProfileinterface (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 addressdonorName- Full name (first + last)organizationId- Organization UUIDorganizationName- 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_userstable - Sends OTP email via Supabase Auth
- Redirects to
/donor-portalon success
Related Documentation
- 02-DONATIONS-MANAGER.md - Related donation management
- 04-DONOR-PORTAL.md - Public-facing donor portal
- 01-DATA-SCHEMA.md - Historical donor data model
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