Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@ node_modules
vendor
mix-manifest.json
.claude/settings.local.json
build/
/build/
resources/dist/hot
emails/build_local
63 changes: 63 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,69 @@

All notable changes to `donation-checkout` will be documented in this file.

## 3.0.0 - 2026-06-17

### Control Panel

- **Donations Dashboard** accessible under Tools > Donations. Sortable, searchable listing of all single and recurring donations with status badges (Active, Paused, Cancelled, Succeeded, Refunded). Click any donor's name to view their full profile.
- **Donor Profile** showing a donor's complete history split into recurring and single donation tables, with metadata display.
- **Subscription Actions** with confirmation dialogs: pause, resume, and cancel subscriptions directly from the CP.
- **Refund Payments** with confirmation dialog from the CP.
- **Dashboard Widget** showing total raised, active subscriptions count, and 5 most recent donations. Add it via CP > Dashboard > Widgets.
- **Permissions**: `view donations` (see the listing), `cancel donations` (pause/resume/cancel subscriptions), `refund donations` (issue refunds). Configured under Users > Roles.

### Gift Aid (UK)

- **Gift Aid Toggle** on the donation form. Enable it from the CP under Globals > Donation Messages > Settings. Renders as a styled toggle switch with a customisable label (default: "Boost your donation by 25% with Gift Aid").
- **Billing Address Collection** toggle in the same Settings tab. When enabled, Stripe Checkout requires the donor's full billing address (needed for HMRC Gift Aid claims).
- Gift Aid declaration is stored as `gift_aid: yes/no` in Stripe session metadata, visible in both the CP and Stripe Dashboard.

### Thank You Page

- Customisable thank you page at `/donation-checkout/thank-you`. Different headings, messages, and CTA buttons for single vs recurring donations.
- Edit the copy from the CP under Globals > Donation Messages (Single Donations and Recurring Donations tabs).
- Also available as a `{{ donation:thank_you }}` tag pair for building custom thank you templates.
- Success URLs in config now accept relative paths (e.g. `/donation-checkout/thank-you?session_id={CHECKOUT_SESSION_ID}`) and are resolved to absolute URLs automatically.

### Donor Emails

- **Subscription Paused Email** thanking the donor for their support when their recurring donation is paused.
- **Subscription Resumed Email** confirming their donation has been reactivated.
- **Magic Link Email** with a time-limited signed link to access the donor portal.
- Emails are sent from both CP actions and Stripe webhooks, with duplicate prevention (60-second cache lock).

### Donor Portal

- **Magic Link Authentication**: donors enter their email on the frontend to receive a signed login link (no password needed). The `{{ donation:magic_link_form }}` tag renders the form, and automatically shows a "Manage your donations" link instead when the donor is already logged in.
- **Self-Service Portal** at `/donation-checkout/portal` showing the donor's recurring and single donations with status badges, dates, and amounts.
- **Self-Cancellation**: donors can cancel their own subscriptions from the portal (configurable via Settings toggle in the CP).

### Stripe Webhooks

- Webhook endpoint at `POST /donation-checkout/webhook/stripe` with HMAC signature verification.
- Handles 6 events: `checkout.session.completed`, `customer.subscription.updated`, `customer.subscription.deleted`, `charge.refunded`, `invoice.payment_failed`, `invoice.payment_succeeded`.
- Clears CP caches and dispatches Laravel events (`DonationCompleted`, `SubscriptionPaused`, `SubscriptionResumed`, `SubscriptionCancelled`, `SubscriptionUpdated`, `DonationRefunded`, `RecurringPaymentFailed`, `RecurringPaymentSucceeded`) so your application can react to donation lifecycle changes.
- **Setup Command**: `php artisan donation-checkout:setup-webhook` registers the endpoint in Stripe via the API and writes `STRIPE_WEBHOOK_SECRET` to your `.env` file automatically.

### Custom Metadata Fields

- Configure additional form fields (text inputs, checkboxes) in `config/donation-checkout.php` under `custom_fields`. They are rendered on the form, validated, and passed to Stripe session metadata.
- Checkbox fields render as styled toggle switches.
- Metadata is visible in the CP donations listing and donor profile.

### CP Settings (Global Set)

- All key settings are now configurable from the CP under Globals > Donation Messages > Settings: billing address collection, Gift Aid (enable/disable + label), donor portal, and donor self-cancellation.
- Settings take priority over config file values.

### Technical

- CP views built with Vue/Inertia using Statamic's `@ui` component library (Listing, Badge, Header, ConfirmationModal, Heading, DropdownItem).
- Each CP action is its own invokable controller with standard REST verbs: `DELETE` (cancel), `PUT` (pause, resume), `POST` (refund).
- Refund status detected via `latest_charge.refunded` on Stripe PaymentIntents.
- Per-customer Stripe API response caching (2 minutes) with automatic invalidation on actions and webhooks.
- 62 tests covering actions, controllers, webhooks, thank you page, magic link flow, and portal authentication.

## v2.1.4 - 2026-05-27

### Fixed
Expand Down
120 changes: 98 additions & 22 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Project Overview

This is a Statamic addon that integrates Stripe Checkout for handling variable single and recurring donations. It uses the Stripe PHP SDK and extends Statamic's AddonServiceProvider.
This is a Statamic addon that integrates Stripe Checkout for handling variable single and recurring donations. It includes a CP dashboard for managing donations, a donor portal with magic link authentication, Stripe webhook integration, and configurable Gift Aid support. It uses the Stripe PHP SDK and extends Statamic's AddonServiceProvider.

## Commands

Expand All @@ -17,52 +17,128 @@ composer test-coverage

# Format code with Laravel Pint
composer format

# Build JS assets for CP
npm run build

# Register Stripe webhook endpoint
php artisan donation-checkout:setup-webhook
```

## Architecture

### Entry Point
- `ServiceProvider.php` extends `Statamic\Providers\AddonServiceProvider` - registers routes, tags, views, and auto-publishes config on installation
- `ServiceProvider.php` extends `Statamic\Providers\AddonServiceProvider` and registers routes (web + CP), tags, views, widgets, Vite assets, nav items, permissions, artisan commands, and auto-installs the `donation_messages` Global Set.

### Statamic Tags
`src/Tags/Donation.php` provides template tags:
- `{{ donation:form }}` - Complete donation form with Alpine.js integration
- `{{ donation:scripts }}` - Alpine.js component (place before closing `</body>`)
- `{{ donation:stripe_key }}` - Stripe publishable key
- `{{ donation:endpoint }}` - Donation API endpoint URL
- `{{ donation:currency }}` - Configured currency code
- `{{ donation:form }}` renders the donation form with vanilla JS. Supports custom metadata fields (Gift Aid, etc.) configured via the CP Global Set.
- `{{ donation:scripts }}` outputs the vanilla JS handler (place before closing `</body>`)
- `{{ donation:styles }}` outputs default CSS (toggle switches, form fields, magic link form, portal link)
- `{{ donation:stripe_key }}` outputs the Stripe publishable key
- `{{ donation:endpoint }}` outputs the donation API endpoint URL
- `{{ donation:currency }}` outputs the configured currency code
- `{{ donation:thank_you }}` tag pair returning session details + global set messages for thank you pages
- `{{ donation:magic_link_form }}` renders magic link email form (hidden when logged in, shows portal link instead)
- `{{ donation:portal }}` tag pair returning donation/subscription data for authenticated donors
- `{{ donation:portal_cancel_url }}` returns cancel endpoint URL for use in forms

Form tag parameters: `amounts`, `default`, `frequency`, `currency_symbol`, `button_text`

### Views
`resources/views/` - Publishable Blade templates:
- `form.blade.php` - Donation form markup
- `scripts.blade.php` - Alpine.js donation handler
`resources/views/` (publishable Blade templates):
- `form.blade.php` with custom field support (toggle switches for checkboxes)
- `scripts.blade.php` with custom field collection in JS submission
- `styles.blade.php` with default CSS including toggle switch, magic link form, portal link styles
- `thank-you.blade.php` for the thank you page
- `portal.blade.php` for the donor self-service portal
- `emails/magic-link.blade.php`, `emails/subscription-paused.blade.php`, `emails/subscription-resumed.blade.php`
- `widgets/donation-stats.blade.php` for the CP dashboard widget

### CP (Vue/Inertia)
`resources/js/` contains Vue components registered as Inertia pages via `Statamic.$inertia.register()`:
- `pages/DonationsIndex.vue` uses `<ui-listing>` with client-side sort/search, `<ui-badge>` for status, `<ui-confirmation-modal>` for actions
- `pages/DonorProfile.vue` shows donor detail with separate subscription/donation listings

Built with Vite, compiled assets in `resources/dist/build/`. Uses Statamic's globally registered `ui-*` components. Vue externals plugin maps `vue` to `window.Vue`.

CP controllers extend `Statamic\Http\Controllers\CP\CpController` and return `Inertia::render()`. Each action (cancel, pause, resume, refund) has its own invokable controller using standard REST verbs:
- `DELETE /subscriptions/{id}` (cancel)
- `PUT /subscriptions/{id}/pause` (pause)
- `PUT /subscriptions/{id}/resume` (resume)
- `POST /payments/{id}/refund` (refund)

### Request Flow
1. POST to `/donation-checkout/start` hits `StartDonationController` (invokable)
2. `DonationRequest` validates: amount, email, first_name, last_name, frequency (single|recurring)
2. `DonationRequest` validates: amount, email, first_name, last_name, frequency (single|recurring), plus dynamic custom fields from config
3. `UserService` finds or creates Statamic user by email
4. `PaymentService` creates Stripe customer if needed, then creates appropriate Checkout Session
5. Returns Stripe Checkout Session (frontend redirects to `session.url`)
4. `CreateStripeCustomer` creates Stripe customer if needed
5. `CreateSingleDonation` or `CreateRecurringDonation` creates Checkout Session with metadata and optional billing address collection
6. Returns Stripe Checkout Session URL (frontend redirects)

### Actions
All follow the invokable pattern with constructor-promoted `private readonly StripeClient $stripe`:
- `CreateStripeCustomer`, `CreateSingleDonation`, `CreateRecurringDonation` (donation flow)
- `CancelSubscription`, `PauseSubscription`, `ResumeSubscription` (subscription management)
- `RefundPayment` (refunds)
- `ListDonations` (with `latest_charge` expansion for refund status), `ListSubscriptions` (pagination support)
- `RetrieveCheckoutSession` (with `line_items` expansion)
- `SendMagicLink` (generates temporary signed URL, dispatches mail)

### Services
- `PaymentService` - Stripe API wrapper: customer CRUD, single donations (payment mode), recurring donations (subscription mode using price plan multiplier)
- `UserService` - Statamic User facade wrapper: find by email, create with random password, update with stripe_customer_id
- `UserService` wraps Statamic User facade: `findByEmail`, `findByStripeCustomerId`, `createUser`, `updateUser`

### Concerns (Traits)
- `ClearsStripeCache` clears per-customer and widget caches
- `ResolvesPaymentStatus` derives effective status from `latest_charge.refunded`
- `SendsDonorNotifications` sends pause/resume emails with 60-second cache lock to prevent duplicates

### Settings
`src/Support/Settings.php` reads from the `donation_messages` Global Set first, falling back to config. Gracefully handles missing Statamic bindings (tests). Methods: `collectBillingAddress()`, `giftAidEnabled()`, `giftAidLabel()`, `donorPortalEnabled()`, `donorCanCancel()`, `customFields()`.

### Webhook
- `POST /donation-checkout/webhook/stripe` registered directly in ServiceProvider (outside web middleware group, no CSRF)
- `VerifyStripeWebhook` middleware verifies signatures via `Stripe\Webhook::constructEvent()`
- `StripeWebhookController` handles 6 event types, clears caches, dispatches Laravel events, sends notification emails

### Events
All in `src/Events/` with `public readonly` constructor-promoted properties:
- `DonationCompleted` (stripeCustomerId, sessionId, mode, amountInCents, currency)
- `SubscriptionPaused`, `SubscriptionResumed`, `SubscriptionCancelled` (stripeCustomerId, subscriptionId)
- `SubscriptionUpdated` (stripeCustomerId, subscriptionId, data)
- `DonationRefunded` (stripeCustomerId, chargeId, amountRefundedInCents)
- `RecurringPaymentFailed` (stripeCustomerId, invoiceId, subscriptionId)
- `RecurringPaymentSucceeded` (stripeCustomerId, invoiceId, amountInCents)

### Configuration
Published to `config/donation-checkout.php`:
- Stripe keys (via env vars `STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY`)
- `stripe_price_plan_id` - Required for recurring donations (create £1/month product in Stripe, use price ID)
- Success/cancel URLs for both donation types
- Stripe keys (`STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY`, `STRIPE_WEBHOOK_SECRET`)
- `stripe_price_plan_id` for recurring donations (quantity x £1 price plan)
- Success/cancel URLs (relative paths auto-resolved to absolute at runtime)
- Currency (default: gbp)
- `magic_link_expiry_hours`, `donor_portal_enabled`, `donor_can_cancel_subscriptions`
- `collect_billing_address` for Gift Aid/HMRC compliance
- `custom_fields` array for additional metadata fields

Settings tab in the `donation_messages` Global Set provides CP control over: billing address collection, Gift Aid toggle + label, donor portal, donor cancellation.

### Route
Single route: `POST /donation-checkout/start` - CSRF disabled for API usage
### Routes
- **Web**: `POST /start`, `GET /thank-you`, `POST /magic-link`, `GET /magic-link/verify` (signed), `GET /portal` (auth), `POST /portal/subscriptions/{id}/cancel` (auth)
- **Webhook**: `POST /webhook/stripe` (registered in ServiceProvider, VerifyStripeWebhook middleware)
- **CP**: `GET /`, `GET /donor/{email}`, `DELETE /subscriptions/{id}`, `PUT /subscriptions/{id}/pause`, `PUT /subscriptions/{id}/resume`, `POST /payments/{id}/refund`

## Key Implementation Details

- Recurring donations use quantity-based pricing: amount becomes quantity × £1 price plan
- Recurring donations use quantity-based pricing: amount becomes quantity x £1 price plan
- Users are created with random 16-char passwords (donation flow doesn't require login)
- `stripe_customer_id` stored on Statamic user for reuse
- Uses `ray()` debugging calls throughout (Spatie Ray)
- Stripe is source of truth (no local database). CP listing caches Stripe responses for 2 minutes
- CP views use Vue/Inertia with Statamic's `@ui` component library (Listing, Badge, Header, ConfirmationModal, etc.)
- Webhook route bypasses CSRF by being registered outside the web middleware group
- Settings are read from Global Set first, falling back to config, with graceful handling when Statamic bindings are unavailable
- Gift Aid checkbox renders as a styled toggle switch in the donation form
- Name resolution falls back from first_name + last_name to name to email
- Refund status derived from `latest_charge.refunded` on PaymentIntent (Stripe keeps status as `succeeded` after refund)

# currentDate
Today's date is 2026-03-18.
Loading
Loading