Skip to content

Set Up Recurring Donations#133

Open
aaronashby wants to merge 11 commits into
mainfrom
recurring-donations
Open

Set Up Recurring Donations#133
aaronashby wants to merge 11 commits into
mainfrom
recurring-donations

Conversation

@aaronashby

@aaronashby aaronashby commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Description

Implements fully functional recurring donations via Stripe Subscriptions, replacing the previous mocked stub. Recurring selections now create a real Stripe Subscription (billed on the donor's chosen interval) while keeping the on-site card-confirmation UX identical to one-time gifts. Also fixes a pre-existing bug where the "cover fees" toggle was shown to donors but never actually charged.

Changes Made

  • Backend changes
  • Frontend changes
  • Database schema changes
  • Configuration updates
  • Other — test fixes for pre-existing breakage

Recurring subscriptions

  • Rewrote PaymentsService.createSubscription to create a Stripe customer (find-or-create by email), resolve/create the donation product, and create a subscription with payment_behavior: 'default_incomplete'. Returns the first invoice's PaymentIntent id + client secret (derived from latest_invoice.confirmation_secret, since invoice.payment_intent no longer exists on our Stripe API version), so the frontend confirms it exactly like a one-time payment.
  • New POST /payments/subscription endpoint + response DTO/mapper.
  • Frontend routes recurring donations through the new endpoint; one-time donations are unchanged. The Stripe customer/subscription ids are threaded into the donation record.
  • New nullable stripeSubscriptionId / stripeCustomerId columns on donations (migration also backfills the entity-only feeAmount column that shipped without one).
  • Webhook now handles invoice.paid / invoice.payment_failed: each subscription_cycle renewal is recorded as its own succeeded/failed donation row (matched via stripeSubscriptionId, idempotent on the renewal PaymentIntent id) so renewals count toward the goal.

Fee coverage fix

  • The "cover fees" toggle only updated Step 2's local state, so the fee was displayed but never charged, and formData.coverFees was dead. Made formData the single source of truth: DonationSummary is now controlled, and a shared calculateChargeAmount helper (gross-up formula, rounded to cents) drives the Step 2 total, the Stripe charge (one-time and recurring), and the recorded amount.

Testing & Verification

  • Unit tests pass
  • Manual testing completed
  • No breaking changes

Verification Steps:

  • Backend: 179 unit tests pass (npx jest --config apps/backend/jest.config.ts --testPathIgnorePatterns=e2e). Rewrote the createSubscription service tests (customer reuse/creation, product resolution, all 5 interval mappings, confirmation_secret parsing); added controller tests for the subscription endpoint and the invoice.paid/invoice.payment_failed renewal branches; added donations.service tests for Stripe-id persistence and recordRenewalCharge (idempotency, template cloning). Also fixed pre-existing spec breakage: EmailsService was missing from the donations-service test module, Donation fixtures lacked the new/feeAmount columns, and DonationForm renders needed a MemoryRouter wrapper.
  • Frontend: both projects typecheck clean; rewrote the stale DonationSummary spec (now 7 passing) covering the gross-up math, cent-rounding, and the controlled toggle.
  • Manual (in progress, handed off): end-to-end recurring donation with a test card, confirming the donation row goes pending→succeeded with the Stripe ids populated, an active subscription appears in Stripe, and renewals (via a Stripe test clock) create new succeeded rows that increment the goal.

Screenshots (if relevant)

Future Improvements/Notes

  • Config for deploy: set invoice.paid + invoice.payment_failed on the Stripe webhook endpoint, and optionally set STRIPE_DONATION_PRODUCT_ID in the ECS task def (leave blank to auto-create on first use — it logs the id). Migrations auto-run on startup (migrationsRun: true), so no manual RDS step.
  • Fee rate: DONATION_FEE_RATE / DONATION_FIXED_FEE are hardcoded to Stripe's standard US card pricing (2.9% + $0.30); update if the new Stripe account has different (e.g. nonprofit) rates.
  • Renewal fee capture is best-effort and per-charge; the recorded feeAmount reflects Stripe's actual cut, which differs slightly from the client-side estimate.
  • Out of scope: subscription management UI (view/cancel/update) — the stored stripeSubscriptionId is the hook for it later.
  • donations.e2e-spec.ts requires a live Postgres; it passes with the DB up but fails locally without one (environmental).

Related Issues

Closes #

Add nullable stripeSubscriptionId and stripeCustomerId to the Donation
entity so recurring donations can be linked to their Stripe subscription
(for renewals and future management). Includes a migration that also adds
the previously entity-only feeAmount column (guarded with IF NOT EXISTS),
and registers the migration in data-source.ts (migrations are statically
imported for Webpack bundling).
Accept and persist stripeSubscriptionId/stripeCustomerId on donation
creation, and add recordRenewalCharge() which writes each successful (or
failed) subscription renewal as its own donation row. Renewals are matched
to the original donation via stripeSubscriptionId, are idempotent on the
renewal PaymentIntent id, and — being succeeded rows — count toward the
goal total automatically.
Replace the createSubscription stub with a full flow: find-or-create the
Stripe customer, resolve/create the donation product, create the
subscription with inline price_data and payment_behavior
'default_incomplete', and return the first PaymentIntent's id + client
secret (derived from latest_invoice.confirmation_secret) so the frontend
confirms it exactly like a one-time payment. Adds POST /payments/subscription
and its response DTO/mapper, and webhook handling for invoice.paid /
invoice.payment_failed that records subscription-cycle renewals.

Co-authored resolution helper getPaymentIntentIdForInvoice reads the
expanded invoice.payments list since invoice.payment_intent no longer
exists on this Stripe API version.
Add apiClient.createSubscription and branch the checkout flow: recurring
donations call the subscription endpoint, one-time donations keep using the
payment intent endpoint. Both return { id, clientSecret }, so the existing
confirmCardPayment flow is unchanged. The subscription's customer and
subscription ids are threaded into createDonation so they persist on the
donation row.
Rewrite the createSubscription service tests for the new signature (customer
reuse/creation, product resolution, interval mapping, confirmation_secret
parsing) and add controller tests for the subscription endpoint and the
invoice.paid / invoice.payment_failed renewal branches. Add donations-service
tests for Stripe-id persistence and recordRenewalCharge (idempotency,
template cloning).

Also fix breakage that predated this work: provide EmailsService in the
donations-service spec, backfill the new nullable columns in Donation test
fixtures, and wrap DonationForm renders in MemoryRouter (the form now uses
useSearchParams).
The "cover fees" toggle was cosmetic: it updated only Step 2's local state,
so the fee was shown but never charged, and formData.coverFees was dead.
Make formData the single source of truth — DonationSummary is now controlled
(coverFees + onCoverFeesChange), and a shared calculateChargeAmount helper
(gross-up: (base + fixed) / (1 - rate), rounded to cents) drives the Step 2
total, the Stripe charge for both one-time and recurring donations, and the
recorded donation amount. Rewrite the stale DonationSummary spec to cover the
helpers and the controlled toggle.
@aaronashby aaronashby requested a review from thaninbew July 2, 2026 06:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants