Donation Flow
A donation in Miranda flows from a public donation page through Stripe Connect to a completed transaction record, with automatic contact creation, donor metadata updates, activity logging, and receipt delivery.
Two donation pathways
Section titled “Two donation pathways”Miranda supports donations from two surfaces:
1. Public donation page (anonymous donors)
Section titled “1. Public donation page (anonymous donors)”Used by donors visiting the foundation’s subdomain donate page or the Blade-rendered form at the donate path.
Donor visits page → Fills form → Stripe.js collects card → DonationService::processOneTime() → Creates PaymentIntent with destination charge → Finds or creates Contact by email → Records Donation → Updates donor relationship metadata → Dispatches SendDonationReceipt job2. Donor portal (authenticated donors)
Section titled “2. Donor portal (authenticated donors)”Used by logged-in donors on miranda.gives, consuming the /api/donor/donations endpoint.
Donor authenticates → Selects foundation and amount → DonationService::processDonorDonation() → Ensures platform Stripe Customer exists → Creates PaymentIntent with destination charge → Finds or creates Contact at the foundation → Records Donation (initially Pending) → Updates DonorFoundationLink stats → Updates DonorProfile aggregate stats → Returns client_secret for frontend confirmationDonationService methods
Section titled “DonationService methods”processOneTime()
Section titled “processOneTime()”Processes a one-time donation from a public page:
public function processOneTime( DonationPage $page, array $donorData, // ['email', 'first_name', 'last_name'] int $amountCents, bool $coverFees,): Donation;processDonorDonation()
Section titled “processDonorDonation()”Processes a donation from the authenticated donor portal:
public function processDonorDonation( DonorProfile $donor, Foundation $foundation, float $amount, ?string $paymentMethodId = null, bool $savePaymentMethod = false, bool $donorCoversFees = false, ?string $donationPageId = null,): array; // Returns {donation, client_secret, status}Fee calculation
Section titled “Fee calculation”Platform fees are tiered by the foundation’s plan:
| Plan | Transaction fee |
|---|---|
| Starter | 2.0% |
| Growth | 1.5% |
| Pro | 1.0% |
// StripeConnectService::calculatePlatformFee()$plan = $foundation->settings['plan'] ?? 'starter';$feeRate = config("plans.{$plan}.transaction_fee");$platformFee = (int) round($amountInCents * $feeRate);Donor covers fees
Section titled “Donor covers fees”When coverFees is true:
- Public page flow: The charge amount increases by the platform fee
- Donor portal flow: The charge increases by both Stripe processing fees (2.9% + 30c) and the platform fee
Contact matching
Section titled “Contact matching”The findOrCreateContact() method handles contact resolution:
- If the donor provides an email, search for an existing contact in the foundation
- If found, return the existing contact
- If not found, create a new contact with
first_contact_atset to now - If neither email nor first name is provided, return null (anonymous donation)
Post-donation side effects
Section titled “Post-donation side effects”After a donation is recorded, these actions happen automatically:
1. Donor relationship metadata update
Section titled “1. Donor relationship metadata update”The contact’s donor relationship metadata is updated:
$metadata['lifetime_giving'] += $amount;$metadata['gift_count'] += 1;$metadata['last_gift_at'] = now();2. Activity logging
Section titled “2. Activity logging”An activity is recorded on the contact’s timeline:
Type: donatedDescription: "Donated $50.00 via Annual Fund"Metadata: {donation_id, amount, donation_page}3. Receipt email
Section titled “3. Receipt email”If the contact has an email, a SendDonationReceipt job is dispatched to send a donation receipt via the email service.
4. Donor-foundation link (portal donations only)
Section titled “4. Donor-foundation link (portal donations only)”For donor portal donations, a DonorFoundationLink record tracks the relationship between the DonorProfile and the Foundation:
gift_countis incrementedtotal_givenis incrementedlast_gift_atandfirst_gift_atare updated
Donation model
Section titled “Donation model”| Field | Type | Description |
|---|---|---|
id | UUID | Primary key |
foundation_id | UUID | Owning foundation |
contact_id | UUID? | Linked contact (null for anonymous) |
donation_page_id | UUID? | Page the donation came through |
stripe_payment_intent_id | string? | Stripe PaymentIntent ID |
amount | integer | Amount in cents |
platform_fee | integer | Platform fee in cents |
currency | string | ISO 4217 code (default USD) |
type | DonationType | one_time or recurring |
status | DonationStatus | pending, succeeded, failed, refunded |
donor_covered_fees | boolean | Whether the donor covered fees |
recurring_interval | RecurringInterval? | monthly, quarterly, annual (for recurring) |
stripe_subscription_id | string? | Stripe subscription ID (for recurring) |
metadata | JSON? | Arbitrary metadata |
donated_at | datetime? | When the donation was made |
Webhook handling
Section titled “Webhook handling”Stripe webhooks are received at POST /stripe/webhook (and the alias POST /strp/wbhk). The WebhookController processes events to update donation statuses, handle failed payments, and reconcile records.