Ana içeriğe git

Giving Architecture

ChurchApps runs donations on a gateway-rail model: the church keeps its own Stripe (or PayPal, Kingdom Funding, or Paystack) account, and B1 never sits in the money path as a platform processor. Card data is tokenized in the browser and never reaches a ChurchApps server. This page maps the whole stack — the client-side provider registry in @churchapps/apphelper, the GivingApi gateway abstraction, the donation data model, and how gateway webhooks reconcile back into the database.

Overview​

┌─────────────────────────────┐                   ┌───────────────────────────────────────┐
│ B1App / B1Admin (browser) │ │ Payment gateway │
│ │ │ (Stripe / PayPal / KF / Paystack) │
│ @churchapps/apphelper │ │ │
│ ┌───────────────────────┐ │ card entry in the │ Stripe Elements · KF tokenizer · │
│ │ Payment provider │──┼──────────────────▶│ PayPal Hosted Fields │
│ │ registry │ │◀── token / nonce ─│ (card never reaches a B1 server) │
│ │ getPaymentProvider() │ │ └──────────▲────────────────┬───────────┘
│ │ Stripe · PayPal · KF │ │ │ │
│ └──────────┬────────────┘ │ │ │
└─────────────┼───────────────┘ │ │
│ POST /giving/donate/charge | /subscribe │ │
│ { token, amount, funds, person } │ │
▼ charge / subscribe│ │ signed webhook
┌─────────────────────────────────────────────┐ (secret key) │ │ event
│ GivingApi — /giving module │──────────────┘ │
│ DonateController → GatewayService │ │
│ → GatewayFactory → IGatewayProvider │◀──────────────────────────────┘
│ donations · funds · subscriptions · … │ POST /giving/donate/webhook/:provider
└─────────────────────┬───────────────────────┘
│ save donations + fundDonations — dedup via eventLogs / transactionId
▼
MySQL (giving schema)

Three principles hold across the stack:

  1. The gateway holds the card. Every provider's entry widget tokenizes in the browser; the API only ever receives a token, nonce, or order id.
  2. One abstraction, many providers. The browser resolves a PaymentProvider from a registry; the server resolves an IGatewayProvider from a factory. Both key off the same normalized provider name stored on the gateway record.
  3. Webhooks are the source of truth for settlement. A charge response is recorded optimistically, but the gateway's signed webhook is what confirms (or creates) the completed donation, with idempotency guards on both sides.

Client-side: the payment provider registry (@churchapps/apphelper)​

The registry lives in Packages/apphelper/src/donations/providers/, with each provider's widgets and helpers under its own subfolder (providers/stripe/, providers/paypal/, providers/kingdomfunding/, providers/paystack/) — nothing outside providers/ branches on a provider name. A PaymentProvider (see providers/types.ts) bundles everything a host app needs for one gateway: a descriptor (admin labels, supported currencies, fee fields, default fee rates, dashboard/signup URLs), a capabilities flag set (saved cards, ACH, recurring, inline new-card entry, implicit save-on-tokenize), the React widgets for member entry (MemberWrapper/MemberEntry), guest giving (GuestForm), saved-method editing (MethodEditForm), and form-question payments (FormPayment), plus buildChargeRequest(ctx, token) — the one place the charge payload shape differs per provider. Each provider's MemberWrapper loads its own SDK from the gateway record's public key, so host apps never import a gateway SDK (B1App and B1Admin have no @stripe/* dependency). pickDefaultGateway(gateways, capability?) centralizes which of a church's gateways a surface should use.

providers/registry.ts holds the built-ins. They are referenced by value, not registered through a module side-effect, so a bundler's tree-shaking can never drop the registration:

for (const p of [StripeProvider, KingdomFundingProvider, PayPalProvider, PaystackProvider]) builtins.set(p.key, p);
FunctionPurpose
getPaymentProvider(name)Resolve by normalized name; falls back to Stripe so a misconfigured provider never hard-crashes the donor form
registerPaymentProvider(p)Register an extra provider at runtime (for a host app's custom gateway)
listPaymentProviders()Enumerate built-ins + custom — used to build the admin gateway dropdown
hasPaymentProvider(name)Membership check

Built-in client providers: Stripe, PayPal, Kingdom Funding, Paystack. B1App and B1Admin only read the registry (getPaymentProvider, listPaymentProviders); neither calls registerPaymentProvider — registration stays inside apphelper.

Each provider tokenizes differently, but all keep the card out of B1:

ProviderEntry widgetToken returned to API
StripeStripe Elements CardElement → stripe.createPaymentMethod(...); the guest form also mounts an ExpressCheckoutElement (Apple Pay / Google Pay, one-time gifts) whose onConfirm resolves to the same pm_… idpayment-method id (pm_…); bank via /paymentmethods/ach-setup-intent — Financial Connections us_bank_account for USD gateways, Canadian PAD acss_debit (hosted mandate modal, mandate default_for invoices/subscriptions, one-off charges pass the mandate id) for CAD gateways
Kingdom FundingHosted tokenizer form keyed by the gateway public keysingle-use nonce
PayPalPayPal Hosted Fields (card, recurring) plus PayPal Smart Buttons with Venmo funding (one-time); both share one SDK load and the server order built via /donate/client-token + /donate/create-ordercaptured order id
PaystackPaystack Inline popup (js.paystack.co/v2/inline.js) — the popup itself takes the payment (card, mobile money, bank transfer, USSD)paid transaction reference; saved methods are Paystack AUTH_… authorization codes

Stripe's finalizeResult runs 3-D Secure / SCA in the browser (providers/stripe/stripe3DS.ts → stripe.confirmCardPayment) before the donation is considered complete; the shared form just calls provider.finalizeResult(result) with no knowledge of what it does.

Server-side: the gateway abstraction (GivingApi)​

The /giving module (Api/src/modules/giving) exposes the REST surface; the gateway plumbing lives in Api/src/shared/helpers. DonateController never talks to a gateway SDK directly — it goes through GatewayService, which resolves the right IGatewayProvider from GatewayFactory and hands it a decrypted GatewayConfig.

DonateController ─▶ GatewayService ─▶ GatewayFactory.getProvider(name) ─▶ IGatewayProvider
│ getGatewayConfig() decrypts privateKey / webhookKey
▼
StripeGatewayProvider · PayPalGatewayProvider · KingdomFundingGatewayProvider · PaystackGatewayProvider · …

IGatewayProvider (shared/helpers/gateways/IGatewayProvider.ts) is the contract every gateway implements — webhook lifecycle (createWebhookEndpoint, verifyWebhookSignature, classifyWebhookEvent), payment (prepareCharge, processCharge, prepareSubscription, createSubscription, finalizeSubscription, cancelSubscription), fees (calculateFees), saved-method handling (listNormalizedPaymentMethods, buildAttachOptions, buildLocalMethodRecord, deletePaymentMethod, verifyMethodOwnership, ownsPaymentMethodId), and optional extras (customers, orders, SetupIntents, event replay, retryFailedPayment for a failed subscription invoice, registerPaymentMethodDomain for Apple Pay domain verification). A provider that omits an optional hook is reported as unsupported for that action and the UI hides the control. Each provider class declares its own capabilities matrix (supported currencies, ACH, refunds, subscription requirements, transaction limits) — GatewayService.getProviderCapabilities(provider) just reads it — and flags like logsDonationsImmediately drive controller behavior without any provider-name conditionals in the controllers.

Server providers registered in GatewayFactory:

ProviderAvailability
StripeAlways on
PayPalAlways on
Kingdom FundingAlways on
PaystackAlways on (Nigeria, Ghana, South Africa, Kenya, Côte d'Ivoire merchants; currencies NGN/GHS/ZAR/KES/XOF/USD)
SquareOpt-in via the ENABLE_SQUARE environment flag
ePayMintsOpt-in via the ENABLE_EPAYMINTS environment flag

Paystack differs from the others in that money moves before GivingApi is involved: the popup charges the donor, processCharge is a GET /transaction/verify/:reference whose paid amount and currency must match the donation being recorded (a reference already on file is never logged twice), and the first gift of a recurring schedule is logged from finalizeSubscription (verify → POST /plan → POST /subscription with start_date one interval out). Webhooks are signed with the secret key itself (x-paystack-signature, HMAC-SHA512 over the raw body) and Paystack has no webhook-management API, so the admin screen shows the URL for the church to paste into its dashboard. Renewal charge.success events carry no fund split; the provider recovers it from the donor's local subscriptions/subscriptionFunds rows. Only card authorizations are reusable — mobile money gifts are one-time only, so createSubscription refuses them. The demo data seeds a second church (Accra Community Church, CHU00000002) on a Paystack test-mode GHS gateway so the Paystack Playwright suite runs beside Grace's Stripe one.

Custom providers can be registered at runtime when ENABLE_CUSTOM_GATEWAY_PROVIDERS is set; AbstractExperimentalGatewayProvider is the base class for those. Provider names are matched case-insensitively.

Gateway configuration & secrets​

An admin saves gateway credentials via POST /giving/gateways (GatewayController). On save the controller encrypts the private and webhook keys with EncryptionHelper before persisting, then — on any non-localhost host — deletes the church's existing webhook and provisions a fresh one pointed at /giving/donate/webhook/{provider}?churchId=…. A church keeps one row per provider: saving a gateway replaces only the existing row for that same provider. Public reads (GET /giving/gateways/churchId/:churchId, /configured/:churchId) return public keys only.

Data model​

The giving schema (Api/src/modules/giving/db/DatabaseTypes.ts, models in models/) is a MySQL schema accessed through Kysely:

TableRole
gatewaysPer-church provider config: provider, publicKey, encrypted privateKey/webhookKey, productId, payFees, currency, settings, environment
fundsGiving designations (name, taxDeductible, productId)
donationBatchesGrouping for entry/reporting (name, batchDate)
donationsOne gift: batchId, personId, donationDate, amount, currency, method, status (pending/complete/failed/refunded; statements, totals, dashboards and donation reports count only complete or null), transactionId
fundDonationsAllocation of a donation across one or more funds (donationId, fundId, amount)
subscriptionsRecurring gift; id is the gateway's subscription id, linked to personId, customerId, gatewayId
subscriptionFundsFund split for a recurring gift
customersLinks a personId to its gateway customer id, per provider
gatewayPaymentMethodsSaved cards/banks: customerId, externalId, methodType, displayName, metadata
eventLogsWebhook/event audit trail and dedup key (provider, providerId, eventType, status, resolved)
campaigns / pledgesPledge campaigns tied to a fund, and each person's pledged amount

A donation is split across funds through fundDonations — the donation carries the total, each fundDonation carries a slice. donations.currency and gateways.currency carry the ISO currency; each provider advertises its supportedCurrencies, and amounts are formatted with CurrencyHelper.formatCurrencyWithLocale.

End-to-end flows​

Member one-time and recurring (B1App)​

The authenticated donate screen (B1App/src/app/[sdSlug]/mobile/components/screens/DonatePage.tsx) composes three apphelper components: MultiGatewayDonationForm, PaymentMethods, and RecurringDonations. B1App does the surrounding data-loading — GET /donations/my, /gateways, /paymentmethods/personid/:id, /customers/:id/subscriptions — and passes the gateway list through; the resolved provider loads its own SDK from the gateway's public key. The charge itself happens inside apphelper: the resolved provider tokenizes the (new or saved) method, then posts to /giving/donate/charge for a one-time gift or /giving/donate/subscribe for a recurring one. Both endpoints attribute a signed-in donor to their own personId (only donations.edit holders may attribute to someone else) and reject fund splits that add up to more than the charged amount. Recurring gifts create a subscriptions row plus subscriptionFunds and hand the schedule to the gateway (Stripe Subscriptions, PayPal Billing Plans, or a KF recurring schedule).

Guest / anonymous giving​

The public donate page (B1App/src/app/[sdSlug]/(public)/[pageSlug]/components/DonatePage.tsx) and the "give now" panel render NonAuthDonationWrapper from @churchapps/apphelper/website, which injects reCAPTCHA and the gateway's Elements context around the provider's GuestForm. Guests get no login, no saved methods, and no history. The flow fetches GET /giving/funds/churchId/:id and GET /giving/donate/gateways/:churchId (public keys only), verifies the visitor with POST /giving/donate/captcha-verify, tokenizes in the browser, and posts to /giving/donate/charge (or /subscribe). Guest ACH uses the anonymous POST /giving/paymentmethods/ach-setup-intent-anon.

Three guest-form options ride on the same charge call. ?fundId= and ?amount= on the donate URL preselect the fund split (read by each provider's guest form on mount, routed through the normal fund-change handler so totals and fees update). anonymous: true makes DonateController.charge discard any person the client sent and log the gift with personId = null; the guest form skips /people/loadOrCreate and the customer/vault step, and the three immediate-log providers stop resolving a person from the gateway customer. Apple Pay needs the page's domain registered with Stripe, so a Stripe guest form posts once per session to the public, rate-limited POST /giving/donate/register-domain, which only accepts a domain that belongs to the church (<subDomain>.b1.church, a row in the content module's domains table, or a local host) before calling Stripe's payment-method-domains API.

Admin recording and Stripe import (B1Admin)​

The B1Admin donations section (B1Admin/src/donations/) is where finance teams work. Batch entry (components/BulkDonationEntry.tsx) records cash/check/in-kind gifts by posting /giving/donations then /giving/funddonations — no gateway involved. Funds, batches, campaigns, and statements each map to their /giving/* CRUD routes. The member-style donate panel (B1Admin/src/donationComponents/) reuses the same apphelper components as B1App.

Reporting and accounting hand-offs are client-side or report-runner work, not gateway work: the batch page's QuickBooks export builds a journal-entry CSV from the batch's donations + fundDonations (debit Undeposited Funds, one credit per fund), the Lapsed Givers tab runs Api/reports/lapsedGivers.json through the generic report runner with person names resolved by ReportOutput, and country receipt formats (Canada / Australia / New Zealand) are church settings in the membership key/value store rendered by GivingStatementDocument and duplicated in the B1App print page.

Stripe import (B1Admin/src/donations/StripeImportPage.tsx) backfills gifts made outside B1: it calls POST /giving/donate/replay-stripe-events with dryRun: true for a preview, then dryRun: false to import. The server lists Stripe events for the date range and skips anything already recorded — matched first by eventLogs provider id, then by DonationRepo.findMatchingDonation (amount + date + person) so a re-run never double-imports.

Webhooks and reconciliation​

Settled payments and subscription state changes arrive at POST /giving/donate/webhook/:provider?churchId=… (DonateController.webhook). Processing is deliberately idempotent:

  1. Verify — GatewayService.verifyWebhook delegates to the provider's signature check; a failed signature returns 401. Events that don't need processing short-circuit with 200.
  2. Dedup the event — EventLogRepo.loadByProviderId skips a webhook already recorded in eventLogs.
  3. Dedup the donation — before creating anything, DonationRepo.loadByTransactionId is checked against every candidate id the payload might carry. This absorbs duplicate deliveries, multi-stage ACH events (pending → settled), and the case where /donate/charge already logged the gift optimistically.
  4. Apply — the provider's classifyWebhookEvent(eventType) says what the event means (donation pending/complete, cancel-subscription, or ignore); completed payments create a complete donation (or promote an existing pending or failed one), ACH-style events land as pending until settlement, a failed subscription invoice (Stripe invoice.payment_failed) creates a failed donation keyed on the invoice id, and cancellation events delete the local subscriptions row. The controller never inspects provider-specific event names.

Failed recurring gifts and dunning​

A failed donation is the unit of work for recovery. GET /giving/donations/failed lists them with the newest gateway failure message from eventLogs and a canRetry flag from the gateway's capabilities; POST /giving/donate/retry/:donationId calls the provider's retryFailedPayment (Stripe pays the open invoice), and the resulting webhook promotes the row to complete through the normal dedup path. Dunning emails go to the donor from the webhook handler on day 0, then from DunningHelper.run in the midnight timer (wired in both lambda/timer-handler.ts and RailwayCron.ts) at 3 and 7 days; each send is recorded in eventLogs as provider: "dunning", providerId: "<donationId>:<day>", so a rerun never emails twice. Stripe webhook endpoints created before this feature do not subscribe to invoice.payment_failed; re-saving the gateway provisions a fresh endpoint with the event.

Providers with logsDonationsImmediately (PayPal, Kingdom Funding, Paystack) have their charges logged from the /charge response (no webhook round-trip required for the happy path), while Stripe relies on payment_intent.succeeded / invoice.paid and ACH payment_intent.processing. Fee handling (POST /giving/donate/fee, the payFees gateway flag, and each provider's calculateFees) computes the "cover the fees" gross-up on the donor side — B1 takes no platform cut, so no application fee is ever added.

bilgi

The charge and webhook paths write the same donations / fundDonations rows. The transactionId is the join key that keeps an optimistic charge log and its later webhook from producing two donations for one gift.

  • Giving Endpoints — full REST surface for donations, funds, batches, gateways, subscriptions, payment methods, and webhooks
  • AppHelper — the npm package that ships the payment provider registry and donation components
  • Module Structure — how the GivingApi module is organized server-side