Skip to content

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.

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 job

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 confirmation

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;

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}

Platform fees are tiered by the foundation’s plan:

PlanTransaction fee
Starter2.0%
Growth1.5%
Pro1.0%
// StripeConnectService::calculatePlatformFee()
$plan = $foundation->settings['plan'] ?? 'starter';
$feeRate = config("plans.{$plan}.transaction_fee");
$platformFee = (int) round($amountInCents * $feeRate);

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

The findOrCreateContact() method handles contact resolution:

  1. If the donor provides an email, search for an existing contact in the foundation
  2. If found, return the existing contact
  3. If not found, create a new contact with first_contact_at set to now
  4. If neither email nor first name is provided, return null (anonymous donation)

After a donation is recorded, these actions happen automatically:

The contact’s donor relationship metadata is updated:

$metadata['lifetime_giving'] += $amount;
$metadata['gift_count'] += 1;
$metadata['last_gift_at'] = now();

An activity is recorded on the contact’s timeline:

Type: donated
Description: "Donated $50.00 via Annual Fund"
Metadata: {donation_id, amount, donation_page}

If the contact has an email, a SendDonationReceipt job is dispatched to send a donation receipt via the email service.

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_count is incremented
  • total_given is incremented
  • last_gift_at and first_gift_at are updated
FieldTypeDescription
idUUIDPrimary key
foundation_idUUIDOwning foundation
contact_idUUID?Linked contact (null for anonymous)
donation_page_idUUID?Page the donation came through
stripe_payment_intent_idstring?Stripe PaymentIntent ID
amountintegerAmount in cents
platform_feeintegerPlatform fee in cents
currencystringISO 4217 code (default USD)
typeDonationTypeone_time or recurring
statusDonationStatuspending, succeeded, failed, refunded
donor_covered_feesbooleanWhether the donor covered fees
recurring_intervalRecurringInterval?monthly, quarterly, annual (for recurring)
stripe_subscription_idstring?Stripe subscription ID (for recurring)
metadataJSON?Arbitrary metadata
donated_atdatetime?When the donation was made

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.