Skip to main content

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_status and refunded_amount back to the original donation (in addition to payment_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.
  • handleChargeRefunded is 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-webhook now handles refund.failed to 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 = refund for 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 donorId continues 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 both summary 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 what create-stripe-refund can 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 (shared auth.json strings + createStripeRefund). See 01-DONORS-CRM.md.
  • Data: fetchDonorDonations() return type includes stripePaymentIntentId.

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_idorganizations.name, legal/footer = parent org when parent_organization_id exists, same as receipts). Body includes refund amount, refund date and original donation amount/date (resolved via stripe_payment_intent_id without -refund suffix), purpose, tax disclaimer, Donor Portal CTA. Donor salutation uses donors.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_logs row with email_type: donation_refund_notice and metadata.refund_donation_id. Only accepts rows with donation_type = refund. Skips placeholder/@noemail addresses 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_id with 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 stores refund_status (partial / full) and cumulative refunded_amount alongside payment_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 sortBy values as before (SortableTableHead + cycleSortPair). Narrow viewports use a labeled Sort by Select (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_org payment-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-management prefetch entry in prefetchMap.ts with 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-link edge function had if (donor_id && !donor_email) condition for DB lookup. Frontend always sends both donor_id AND donor_email, so the lookup was skipped and stripe_customer_id was 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 to if (donor_id) — always does DB lookup when donor_id is present (matching create-setup-intent and get-stripe-donor-details pattern). Also picks up stripe_customer_id in the email-only fallback branch.
  • P1: Generic "Edge Function returned a non-2xx status code" errorscreateStripePortalLink, createSetupIntent, and getStripeDonorDetails in stripe.ts were not extracting structured error bodies from FunctionsHttpError. 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 use FunctionsHttpError + error.context.json() pattern (matching users.ts convention).
  • P2: Placeholder emails shown raw in table and drawer — Donors with @noemail.alignmint.app placeholder emails showed the raw placeholder string. Fixed: table and drawer now use getDisplayEmail() — placeholder emails show "No Email" badge instead.
  • P2: Zustand §49 compliance — Consolidated 4 individual useAppStore selectors into single useShallow subscription.
  • P2: Removed unused importsPaginatedDonorsResult, DonorStats, useCallback.

Files Modified

  • supabase/functions/create-stripe-portal-link/index.ts — DB lookup condition fix + email fallback picks up stripe_customer_id
  • src/lib/db/stripe.ts — FunctionsHttpError extraction on 3 functions + import added
  • src/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" bugopenDonorProfile() previously opened the Sheet then immediately closed it if fetchDonorById() 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 (EmptyState component) 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 canWrite permission. Process Refund requires both isParentOrg and canWrite. 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.json locale files: table.noEmail, drawer.errorTitle, drawer.fetchError.
  • State additions: drawerLoading, drawerError for proper loading/error UX in the profile drawer.

Files Modified

  • src/features/donors/components/DonorPaymentManagement.tsx — Drawer architecture, permission gating, i18n
  • All 6 auth.json locale files — 3 new keys each

Embedded Payment Method Update (March 2026)

  • New "Update Payment Method" action in the actions dropdown — opens an embedded Stripe PaymentElement form via UpdatePaymentMethodDialog, 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 SetupIntent API
  • Flow: Admin clicks "Update Payment Method" → dialog opens → calls create-setup-intent edge function → renders StripeProvider + PaymentElement → donor/admin enters card → stripe.confirmSetup() → webhook setup_intent.succeeded saves to payment_methods table + 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.updatePaymentMethod key added to all 6 auth.json locale files

Files Modified

  • src/features/donors/components/DonorPaymentManagement.tsx — Import UpdatePaymentMethodDialog, added updateCardDonor state, dropdown item, dialog render
  • src/features/donors/components/UpdatePaymentMethodDialog.tsx — New shared component (also used by DonorPortal)
  • All 6 auth.json locale files

Table & Data Fixes (February 11, 2026)

  • Fixed empty table bug — Removed blockAdminView: true which 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, AlertCircle imports.
  • Fixed `useEffect` dependency array — Added missing safeSelectedEntity and isParentOrg dependencies.
  • 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_donated for filtered donors)

Column Name Fix

  • Fixed column name from donor_type to donation_type throughout 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 information
  • donor_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 filter
  • statusFilter - Status filter
  • entityFilter - Organization filter
  • selectedDonor - Donor being managed
  • showDetailsDialog - 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 - Icons
  • sonner - 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

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

Ready to get started?Start Plus Trial