Donor Payment Management
Donor Payment Management
Component File: src/features/donors/components/DonorPaymentManagement.tsx Route / navigation: Path /administration, Zustand administrationTool = donor-payment-management. See 00-ADMINISTRATION-HUB.md. Access Level: Parent Org Admin Only Last Updated: April 23, 2026 Status: ✅ Implemented
✨ Recent Updates
Refund posting hardening + donor aggregate alignment (April 23, 2026)
- Refund webhook posting now writes
refund_statusandrefunded_amountback to the original donation (in addition topayment_status). - Refund JEs now use unique per-refund references (
stripe_refund:{charge_id}:{refund_payment_intent_id}) so partial refunds for one charge do not share a single reference key. handleChargeRefundedis now rollback-safe: if refund status or JE persistence fails, the webhook removes the inserted refund donation and restores original donation refund fields before rethrowing.stripe-webhooknow handlesrefund.failedto reconcile original donation refund metadata from Stripe's current charge refund total.- Donor stats trigger now keeps lifetime dollars net of refunds, but ignores
donation_type = refundfor gift count + first/last gift dates.
Donors CRM jump-off + donor-type alignment (April 7, 2026)
- Donors CRM can now open Donor Payment Management directly from the donor profile Stripe card using
navigateTo('administration', 'donor-payment-management', { donorId }). - The incoming
donorIdcontinues to isolate the table/drawer flow inside Donor Payment Management, so payment admins land on the exact donor rather than re-searching manually. - Drawer donor-type badges now preserve the
bothsummary state explicitly (One-Time Donor,Recurring Donor,Both Donor) instead of falling through to the recurring label. - This tool remains the parent-org jump-off when a donor has Stripe history or profile data but no valid recurring subscription to show on the CRM profile.
Refund picker: Stripe-only rows + Donors CRM parity (April 5, 2026)
- Refund donation list now includes only gifts that have
stripe_payment_intent_id(matches whatcreate-stripe-refundcan act on). Check/cash/DAF-only rows no longer appear as false-positive choices. - Donors CRM: The donor profile Edit Donation dialog exposes the same Process Refund flow for parent org users with
canWrite(sharedauth.jsonstrings +createStripeRefund). See 01-DONORS-CRM.md. - Data:
fetchDonorDonations()return type includesstripePaymentIntentId.
Donor refund email (April 5, 2026)
- After Stripe confirms a refund (
charge.refunded), `stripe-webhook` invokes `send-donation-refund-notice` (server-to-server, same pattern as `send-donation-receipt`). Donors receive a transactional email (organic receipt theme, fund display name =donations.organization_id→organizations.name, legal/footer = parent org whenparent_organization_idexists, same as receipts). Body includes refund amount, refund date and original donation amount/date (resolved viastripe_payment_intent_idwithout-refundsuffix), purpose, tax disclaimer, Donor Portal CTA. Donor salutation usesdonors.first_name+last_name. Calendar dates use `_shared/donation-email-dates.ts` (YYYY-MM-DD+ noon anchor) so US mail does not show the wrong day. - Idempotency:
email_logsrow withemail_type: donation_refund_noticeandmetadata.refund_donation_id. Only accepts rows withdonation_type = refund. Skips placeholder/@noemailaddresses and demo orgs. - Ledger: Refunds do not void original JE lines. `handleChargeRefunded` posts new lines for the incremental refund (DR `donations_revenue` / CR `stripe_receivable`) and resolves the actual
account_ids through the account-default mapping system (get_default_account_idwith parent-org fallback), not hard-coded account UUIDs/codes. Refund JE rows use unique references (stripe_refund:{charge_id}:{refund_payment_intent_id}) so partial refunds on the same charge do not collide. The original donation now storesrefund_status(partial/full) and cumulativerefunded_amountalongsidepayment_status. If the original gift already recognized Stripe processing expense at charge time, full refunds also append DR `stripe_receivable` / CR `bank_fees` for the stored actual fee; partial refunds do not yet reverse Stripe fee expense proportionally — see `DEVELOPER-PLAYBOOK.md` §13 (*Stripe refunds*). - Deploy: After changes under
supabase/functions/_shared/, run `npm run deploy:list-edge-functions` — importers (`send-donation-refund-notice`, `send-donation-receipt`, etc.) plus `stripe-webhook` when touched.
Table sort: header clicks + mobile Select (April 5, 2026)
- UX: Removed the toolbar Sort dropdown. Donor, Last donation, and Total donated column headers are clickable and cycle the same
sortByvalues as before (SortableTableHead+cycleSortPair). Narrow viewports use a labeled Sort bySelect(sm:hidden) so all sort modes stay reachable without header taps. - Files:
DonorPaymentManagement.tsx,shared/SortableTableHead.tsx(shared helper)
Notification Isolation + Header Tile Performance (March 23, 2026)
- Notification deep-link isolation (P0) —
parent_orgpayment-failure navigation now opens Donor Payment Management with the donor isolated in the table query (filterDonorIds) instead of only opening the drawer profile. - Navigation context alignment (P1) — Deep-link now uses
navigateTo(..., { donorId })and is consumed/cleared in Donor Payment Management, aligned with playbook cross-component linking rules. - Header tile performance (P1) —
fetchDonorStats()now uses a single RPC (get_donor_payment_stats) with a legacy fallback path to avoid a sequential multi-query waterfall. - Header tile correctness (P1) — Recurring count includes
donation_type IN ('recurring', 'both'); One-Time tile remains one-time-only (donation_type = 'one-time'). - Stats freshness after mutations (P2) — donor stats query is invalidated/refetched after donor edit, subscription actions, refund actions, and payment-method updates.
- Tile loading UX (P2) — Added loading skeletons and reduced count-up duration to improve perceived responsiveness.
- Admin prefetch compliance (P2) — Added
donor-payment-managementprefetch entry inprefetchMap.tswith query-key parity to component defaults.
Stripe Portal Link Fix + Error Handling + Playbook Compliance (March 13, 2026)
- P0: "Manage in Stripe" 404 for donors with `stripe_customer_id` —
create-stripe-portal-linkedge function hadif (donor_id && !donor_email)condition for DB lookup. Frontend always sends bothdonor_idANDdonor_email, so the lookup was skipped andstripe_customer_idwas never read. The function fell back to Stripe email search only, which returned 404 when Stripe customer email didn't match exactly. Fixed: condition changed toif (donor_id)— always does DB lookup when donor_id is present (matchingcreate-setup-intentandget-stripe-donor-detailspattern). Also picks upstripe_customer_idin the email-only fallback branch. - P1: Generic "Edge Function returned a non-2xx status code" errors —
createStripePortalLink,createSetupIntent, andgetStripeDonorDetailsinstripe.tswere not extracting structured error bodies fromFunctionsHttpError. Users saw opaque generic messages instead of the actual error (e.g., "This donor has no payment history in Stripe"). Fixed: all three functions now useFunctionsHttpError+error.context.json()pattern (matchingusers.tsconvention). - P2: Placeholder emails shown raw in table and drawer — Donors with
@noemail.alignmint.appplaceholder emails showed the raw placeholder string. Fixed: table and drawer now usegetDisplayEmail()— placeholder emails show "No Email" badge instead. - P2: Zustand §49 compliance — Consolidated 4 individual
useAppStoreselectors into singleuseShallowsubscription. - P2: Removed unused imports —
PaginatedDonorsResult,DonorStats,useCallback.
Files Modified
supabase/functions/create-stripe-portal-link/index.ts— DB lookup condition fix + email fallback picks up stripe_customer_idsrc/lib/db/stripe.ts— FunctionsHttpError extraction on 3 functions + import addedsrc/features/donors/components/DonorPaymentManagement.tsx— getDisplayEmail, useShallow consolidation, unused import cleanup
Edge Function Deployed
create-stripe-portal-link— redeployed to Supabase
Drawer Architecture + Permission + i18n Fixes (March 6, 2026)
- P0: Drawer "not opening" bug —
openDonorProfile()previously opened the Sheet then immediately closed it iffetchDonorById()returned null. This made the drawer appear to "never open" for users whose auth context couldn't resolve the donor. Now: drawer stays open with a proper loading skeleton → error state (EmptyStatecomponent) inside the drawer. Stale profile data is cleared before each new open. - P0: `create-setup-intent` edge function deployed — The "Update Payment Method" embedded Stripe form was broken in production because the edge function was never deployed. Now deployed (v1,
verify_jwt: true). - P1: Permission gating — Write actions (Edit Donation Settings, Update Payment Method) now gated behind
canWritepermission. Process Refund requires bothisParentOrgandcanWrite. Read-only users see only "Manage in Stripe" in the dropdown. - P2: i18n sweep — Fixed hardcoded English strings: "No Email" badge, frequency labels (Weekly/Monthly/etc.), drawer error title/description. 3 new keys added to all 6
auth.jsonlocale files:table.noEmail,drawer.errorTitle,drawer.fetchError. - State additions:
drawerLoading,drawerErrorfor proper loading/error UX in the profile drawer.
Files Modified
src/features/donors/components/DonorPaymentManagement.tsx— Drawer architecture, permission gating, i18n- All 6
auth.jsonlocale files — 3 new keys each
Embedded Payment Method Update (March 2026)
- New "Update Payment Method" action in the actions dropdown — opens an embedded Stripe
PaymentElementform viaUpdatePaymentMethodDialog, allowing admins to update a donor's card on file without leaving the app or opening the Stripe hosted portal - Replaces the previously removed "Update Payment Method" menu (which was removed in Jan 2026 for PCI compliance) — the new embedded flow is PCI compliant via Stripe's
SetupIntentAPI - Flow: Admin clicks "Update Payment Method" → dialog opens → calls
create-setup-intentedge function → rendersStripeProvider+PaymentElement→ donor/admin enters card →stripe.confirmSetup()→ webhooksetup_intent.succeededsaves topayment_methodstable + sets default on Stripe customer + updates active subscriptions - On success: Refetches donor list and refreshes Stripe details in the profile drawer if open
- i18n:
admin.donorPaymentPage.actions.updatePaymentMethodkey added to all 6auth.jsonlocale files
Files Modified
src/features/donors/components/DonorPaymentManagement.tsx— ImportUpdatePaymentMethodDialog, addedupdateCardDonorstate, dropdown item, dialog rendersrc/features/donors/components/UpdatePaymentMethodDialog.tsx— New shared component (also used by DonorPortal)- All 6
auth.jsonlocale files
Table & Data Fixes (February 11, 2026)
- Fixed empty table bug — Removed
blockAdminView: truewhich blocked parent org admins from seeing any donors. Summary cards showed correct counts but table showed 0 rows. - Removed `donationCount > 0` client-side filter — Donors with payment methods but no donations yet (e.g., new recurring setup) were incorrectly hidden.
- Replaced Stripe-dependent columns with DB-available data — Payment Method, Frequency, Amount, and Next Billing columns were always empty (Stripe data only loads on-demand per donor click). Replaced with Email, Last Donation, # Gifts columns that are populated from the database.
- Table column widths follow styling guide tier system — Name (flex), Email (2xl/200px), Status (md/100px), Last Donation (md/100px), # Gifts (sm/80px), Entity (lg/130px), Total Donated (lg/130px), Actions (xs/40px).
- Added `keepPreviousData` — Table no longer disappears during pagination/filter changes.
- Added `isFetching` opacity dimming — Background refetches dim the table content instead of showing a loading spinner.
- Replaced inline skeleton with `SkeletonTable` — Follows styling guide table loading standards.
- Added `stagger-fade-in-blur` animation to summary cards grid.
- Added no-Stripe indicator in edit dialog — Shows info banner when donor has no active Stripe subscription.
- Consolidated lucide-react imports — Removed unused
XCircle,AlertCircleimports. - Fixed `useEffect` dependency array — Added missing
safeSelectedEntityandisParentOrgdependencies. - Stripe details remain on-demand in profile drawer — Full payment method, subscription, and billing info loads when clicking a donor row.
Parent Org Improvements (January 31, 2026)
- Entity column now shows donor's actual organization - Previously showed selected entity, now correctly shows which fund/nonprofit the donor belongs to
- Organization shown in donor profile drawer - Parent org admins can now see which organization a donor belongs to when viewing their profile
- Removed non-functional "Update Payment Method" menu - Users should use "Manage in Stripe" instead for PCI compliance
- Improved refund dialog UX - Now shows dropdown of recent donations instead of requiring manual transaction ID entry
- Documentation terminology updated - Changed legacy terminology to "Parent Org" throughout
Previous Updates (December 2025)
Renamed Tile
- Tile renamed from "Donor Management" to "Donor Payment Management" in Administration Hub
- Updated description to clarify focus on payment methods, subscriptions, and refunds
Database-Wide Statistics
- Stats now query entire database - not just current page of paginated data
- New `fetchDonorStats()` function - fetches aggregate counts from database
- Accurate counts for:
- Total donors
- Recurring donors (uses
donation_type IN ('recurring', 'both')) - One-time-only donors (uses
donation_type = 'one-time') - Total donated (sum of all
total_donatedfor filtered donors)
Column Name Fix
- Fixed column name from
donor_typetodonation_typethroughout codebase - Affects:
fetchDonorsPaginated(),fetchDonorStats(), donor type filtering
Entity Filtering
- Properly filters by selected nonprofit using
organization_id - Stats update when switching between nonprofits
---
Overview
The Donor Payment Management component provides comprehensive management of donor payment methods and recurring donation subscriptions. It displays all donors with saved payment methods, allows viewing and updating payment information, managing recurring donations (pause, resume, cancel), and tracking subscription status. This is essential for maintaining healthy recurring donation relationships and ensuring payment method currency.
UI Features
Main Features
- Search & Filter:
- Search by name or email
- Filter by status (all, active recurring, cancelled, one-time only, payment failed)
- Entity filter (for parent org admins)
- Donors Table:
- Name (flex column, always visible)
- Email (hidden below md breakpoint)
- Status badge (always visible)
- Last Donation date (hidden below lg)
- # Gifts count (hidden below xl)
- Entity/Organization (hidden below xl)
- Total Donated (always visible)
- Actions dropdown (always visible)
- Stripe details available on-demand via profile drawer
- Payment Method Details:
- Card brand and last 4 digits
- Expiry date
- Bank name (for ACH)
- Default payment method indicator
- Recurring Subscription Management:
- Pause subscription
- Resume subscription
- Cancel subscription
- Update payment method
- Change amount/frequency
- Actions Menu:
- View details
- Update payment method
- Pause/Resume recurring
- Cancel subscription
- Send receipt
Table Layout
Donor Payment Management
[Search...] [Sort ▼] [Filter ▼] [100 rows ▼]
Showing 100 of 2,553 donors (Page 1 of 26) [Sort: Name A-Z]
Donor Name | Email | Status | Last Donation | # Gifts | Entity | Total Donated | ⋮
-----------------|--------------------|----------|---------------|---------|------------|---------------|---
Robert Thompson | robert@email.com | Active | 01/15/26 | 12 | Awakenings | $1,200.00 | [⋮]
Susan Chen | susan@email.com | One-Time | 12/20/25 | 2 | Bloom | $500.00 | [⋮]
Michael Williams | michael@email.com | Active | 02/01/26 | 8 | Bonfire | $2,400.00 | [⋮]Column Visibility by Breakpoint:
| Column | Width | Breakpoint |
|--------|-------|------------|
| Donor Name | flex | Always |
| Email | 200px | md (768px+) |
| Status | 100px | Always |
| Last Donation | 100px | lg (1024px+) |
| # Gifts | 80px | xl (1280px+) |
| Entity | 130px | xl (1280px+) |
| Total Donated | 130px | Always |
| Actions | 40px | Always |
Actions Dropdown
⋮
├─ Edit Donation Settings
├─ Manage in Stripe (opens Customer Portal)
└─ Process Refund (Parent Org only)Recurring Subscription Card
┌─────────────────────────────────────────┐
│ Recurring Donation Details │
├─────────────────────────────────────────┤
│ Donor: Robert Thompson │
│ Email: robert@email.com │
│ │
│ Amount: $100 │
│ Frequency: Monthly │
│ Next Billing: November 15, 2025 │
│ Status: ● Active │
│ │
│ Payment Method: │
│ Visa •••• 4242 (Exp: 12/26) │
│ │
│ [Update Payment] [Pause] [Cancel] │
└─────────────────────────────────────────┘Data Requirements
Donor Payment
- id (uuid) - Donor identifier
- name (string) - Full name
- email (string) - Email address
- phone (string) - Phone number
- entity_id (uuid) - Organization
- payment_methods (array) - Saved payment methods
- is_recurring (boolean) - Has recurring donation
- recurring_amount (decimal, nullable) - Recurring amount
- recurring_frequency (enum, nullable) - Frequency
- next_billing_date (date, nullable) - Next charge date
- subscription_status (enum) - Status
- total_donated (decimal) - Lifetime total
- last_donation_date (date) - Most recent donation
- start_date (date) - First donation date
Payment Method
- id (uuid) - Payment method ID
- type (enum) - 'card' or 'bank'
- last4 (string) - Last 4 digits
- brand (string, nullable) - Card brand (Visa, MC, etc.)
- bank_name (string, nullable) - Bank name
- expiry_month (string, nullable) - MM
- expiry_year (string, nullable) - YYYY
- is_default (boolean) - Default payment method
Request/Response Schemas
interface DonorPayment {
id: string;
name: string;
email: string;
phone: string;
entity_id: string;
entity_name: string;
payment_methods: PaymentMethod[];
is_recurring: boolean;
recurring_amount?: number;
recurring_frequency?: 'weekly' | 'bi-weekly' | 'monthly' | 'quarterly' | 'annually';
next_billing_date?: string;
subscription_status: 'active' | 'cancelled' | 'paused' | 'none';
total_donated: number;
last_donation_date: string;
start_date: string;
}
interface PaymentMethod {
id: string;
type: 'card' | 'bank';
last4: string;
brand?: string;
bank_name?: string;
expiry_month?: string;
expiry_year?: string;
is_default: boolean;
}Authentication & Authorization
Required Permissions
donor_payments:read- View payment informationdonor_payments:manage- Manage subscriptions and payment methods
Role-Based Access
- Parent Org (`parent_org`): View all donor payments across nonprofits (subject to position/access level)
- Fund User (`fund_user`): View their organization's donor payments only (subject to position/access level)
- Positions: Directors and assistants typically have donor access; custom positions must enable donor permissions
- Donor/Volunteer: No access
Business Logic & Validations
Frontend Validations
- Search query minimum 2 characters
- Valid filter selections
- Confirmation required for cancellation
Backend Validations (Rails)
- Valid organization access
- Valid subscription status
- Payment method belongs to donor
- Cannot resume cancelled subscription
Business Rules
- Paused subscriptions can be resumed
- Cancelled subscriptions cannot be resumed
- Payment method updates apply to next billing
- Failed payments retry automatically (3 attempts)
- Email notifications for status changes
- Donors notified before billing
- Grace period for failed payments (7 days)
State Management
Local State
searchQuery- Search filterstatusFilter- Status filterentityFilter- Organization filterselectedDonor- Donor being managedshowDetailsDialog- Dialog visibility
Global State (AppContext)
selectedEntity- Current organization
Dependencies
Internal Dependencies
AppContext- Global state- Mock data - TO BE REMOVED -
generateMockDonors() - UI components (Table, Dialog, Dropdown, etc.)
External Libraries
lucide-react- Iconssonner- Toast notifications
Error Handling
Error Scenarios
1. Network Error: Show toast "Unable to load donor payments" 2. Pause Failed: Show toast "Failed to pause subscription" 3. Cancel Failed: Show toast "Failed to cancel subscription" 4. Update Failed: Show toast "Failed to update payment method" 5. Permission Error: Show toast "You don't have permission"
Loading States
- Initial load: Skeleton table
- Search: Instant filtering
- Actions: Button loading states
- Status updates: Optimistic UI updates
Mock Data to Remove
DonorPaymentManagement.tsx-generateMockDonors()function- Move interfaces to
src/types/donor-payments.ts
Migration Notes
Phase 1: Data Layer Integration
1. Create/expand Supabase + Stripe-backed helpers for donor payment data and actions 2. Implement list endpoint 3. Test filtering and search 4. Add pagination
Phase 2: Subscription Management
1. Implement pause/resume/cancel 2. Add payment method updates 3. Test subscription workflows 4. Add email notifications
Phase 3: Payment Processing
1. Integrate with Stripe Billing 2. Implement retry logic 3. Add dunning management 4. Test failed payment scenarios
Stripe Integration ✅ IMPLEMENTED
This component integrates with Stripe via Edge Functions for payment management.
Edge Functions Used
| Function | Purpose | Access |
|----------|---------|--------|
| `create-stripe-portal-link` | Open Stripe Customer Portal | All authenticated |
| `manage-stripe-subscription` | Cancel, pause, update subscriptions | **Parent Org Admin Only** |
| `create-stripe-refund` | Issue refunds | **Parent Org Admin Only** |
| `get-stripe-donor-details` | View complete Stripe history | **Parent Org Admin Only** |
"Manage in Stripe" Button
Opens Stripe Customer Portal for the selected donor:
const openStripePortal = async (donor: DonorPayment) => {
const result = await createStripePortalLink(donor.id, donor.email);
if (result.url) {
window.open(result.url, '_blank');
}
};Parent Org Admin Actions
Parent org admins can perform these actions directly without opening Stripe:
| Action | Edge Function | Parameters |
|--------|---------------|------------|
| Cancel immediately | `manage-stripe-subscription` | `{ action: "cancel", donor_id }` || Cancel at period end | `manage-stripe-subscription` | `{ action: "cancel_at_period_end", donor_id }` || Change amount | `manage-stripe-subscription` | `{ action: "update_amount", donor_id, new_amount }` || Pause subscription | `manage-stripe-subscription` | `{ action: "pause", donor_id }` || Resume subscription | `manage-stripe-subscription` | `{ action: "resume", donor_id }` || Change billing day (monthly only) | `manage-stripe-subscription` | `{ action: "update_billing_anchor", donor_id, billing_anchor_day }` (1–28 or 31 = last day of month; parent org only) || Issue refund | `create-stripe-refund` | `{ donation_id, amount, reason }` |---
Related Documentation
- 01-DONORS-CRM.md
- 02-DONATIONS-MANAGER.md
- 04-DONOR-PORTAL.md - Donor self-service
- backend/EDGE-FUNCTIONS.md - Edge Functions reference
- STRIPE-SETUP-GUIDE.md - Stripe configuration
- 01-DATA-SCHEMA.md
Additional Notes
Subscription Statuses
- Active: Billing normally
- Paused: Temporarily stopped
- Cancelled: Permanently ended
- Payment Failed: Retry in progress
- None: No recurring donation
Payment Method Management
- Donors can have multiple payment methods
- One marked as default
- Default used for recurring donations
- Can update without interrupting subscription
- Old methods retained for history
Failed Payment Handling
1. Attempt 1: Immediate retry 2. Attempt 2: 3 days later 3. Attempt 3: 7 days later 4. Final: Subscription paused, donor notified
Email Notifications
- Upcoming billing reminder (3 days before)
- Payment successful
- Payment failed
- Subscription paused
- Subscription cancelled
- Payment method expiring soon
Compliance
- PCI DSS compliant (no card storage)
- GDPR compliant data handling
- Secure payment tokenization
- Audit trail for all changes
Synced from IFMmvp-Frontend documentation: pages/administration/05-DONOR-PAYMENT-MANAGEMENT.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