DEV3 Technologies home

Recurring donations with Stripe subscriptions

Start a monthly gift as a Stripe Checkout subscription with a custom amount, and record each renewal from the invoice-paid webhook branch.

LaravelPaymentsstripesubscriptionrecurringdonations

Code

<?php

namespace App\Fundraising;

use App\Models\Campaign;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;

class MonthlyGiving
{
    /** Returns the hosted Checkout URL for a monthly gift of any amount. */
    public function start(Campaign $campaign, int $cents, string $currency): string
    {
        $session = Http::asForm()->withToken(config('services.stripe.secret'))
            ->baseUrl('https://api.stripe.com/v1')
            ->post('/checkout/sessions', [
                'mode' => 'subscription',
                'success_url' => route('campaigns.thanks', $campaign),
                'cancel_url' => route('campaigns.show', $campaign),
                'line_items[0][quantity]' => 1,
                'line_items[0][price_data][currency]' => strtolower($currency),
                'line_items[0][price_data][unit_amount]' => $cents,
                'line_items[0][price_data][recurring][interval]' => 'month',
                'line_items[0][price_data][product_data][name]' => "Monthly gift to {$campaign->title}",
                // Copied onto the subscription and every invoice it produces.
                'subscription_data[metadata][campaign_id]' => $campaign->id,
            ])->throw()->json();

        return $session['url'];
    }

    /** Webhook branch for `invoice.paid`: first payment and every renewal. */
    public function onInvoicePaid(array $invoice): void
    {
        $campaignId = $invoice['subscription_details']['metadata']['campaign_id'] ?? null;

        if (! $campaignId || ($invoice['amount_paid'] ?? 0) <= 0) {
            return;
        }

        // Unique invoice id: a redelivered event inserts nothing.
        DB::table('donations')->insertOrIgnore([
            'campaign_id' => $campaignId,
            'amount_cents' => $invoice['amount_paid'],
            'currency' => $invoice['currency'],
            'provider_invoice_id' => $invoice['id'],
            'recurring' => true,
            'created_at' => now(),
        ]);
    }
}

How it works

What it solves

Donors choose their own monthly amount, so a fixed catalogue of Stripe prices does not fit. You still want Stripe to handle the card, the schedule and the retries. This class starts a subscription with an inline price for any amount and records each charge when Stripe reports it.

How it works

start() creates a Checkout session in subscription mode. The price is described inline with price_data, including a recurring[interval] of month, so no price object needs to exist beforehand. The campaign and donor are copied into subscription_data[metadata], which Stripe attaches to the subscription and to every invoice it later produces.

onInvoicePaid() is the one webhook branch you need for renewals. Stripe sends invoice.paid for the first payment and every renewal. The method reads the metadata from the subscription details, then inserts a donation row keyed by the invoice id. A unique index on that id makes a redelivered event harmless via insertOrIgnore.

Call onInvoicePaid from your webhook controller, after the signature has been verified, when the event type matches.

Adapting it

Add a yearly interval option, link the donation to the monthly gift row to show a supporter history, and cancel with the subscription id through the Stripe API from the donor's account page.

Pitfalls

  • Minimum amounts differ per currency; validate before calling Stripe.
  • Handle invoice.payment_failed separately so donors are asked to update their card.

How to use it

  1. Add a unique provider_invoice_id column to your donations table.
  2. Put services.stripe.secret in config and copy the class.
  3. Start a gift: redirect()->away(app(MonthlyGiving::class)->start($campaign, 2500, 'usd')).
  4. In your verified webhook controller: if ($event['type'] === 'invoice.paid') { app(MonthlyGiving::class)->onInvoicePaid($event['data']['object']); }.
  5. Use test mode and the Stripe CLI to replay invoice.paid.

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets