From a786b5a92aa7a960ffab00a7853bb31a665e7dc1 Mon Sep 17 00:00:00 2001 From: Jeremy Zongker Date: Mon, 22 Jun 2026 19:13:16 -0500 Subject: [PATCH] Switched to NMI --- .changeset/kingdom-funding-nmi-collectjs.md | 7 + apphelper/public/locales/en.json | 2 + .../KingdomFundingNonAuthDonationInner.tsx | 117 +++----- .../components/KingdomFundingTokenForm.tsx | 278 ++++++++++-------- .../donations/components/PaymentMethods.tsx | 1 + apphelper/src/donations/helpers/Locale.ts | 2 + .../providers/KingdomFundingProvider.tsx | 81 ++++- 7 files changed, 276 insertions(+), 212 deletions(-) create mode 100644 .changeset/kingdom-funding-nmi-collectjs.md diff --git a/.changeset/kingdom-funding-nmi-collectjs.md b/.changeset/kingdom-funding-nmi-collectjs.md new file mode 100644 index 0000000..bc25e79 --- /dev/null +++ b/.changeset/kingdom-funding-nmi-collectjs.md @@ -0,0 +1,7 @@ +--- +"@churchapps/apphelper": minor +--- + +Kingdom Funding donations now tokenize via **NMI Collect.js** (replacing the Accept Blue hosted iframe) and support **ACH/bank** in addition to card. The member and guest donation forms gain a card/bank toggle, both routed through the single-use NMI `payment_token`; raw bank numbers no longer reach the backend. Saved methods are sent as `paymentMethodId`/`customerId` (the NMI customer vault id). The Kingdom Funding provider capabilities now expose `savedBank` and `guestAch`. + +Requires the matching Api change (the `kingdomfunding` gateway provider retargeted to NMI). No public API of the donations module changed for normal consumers — `MultiGatewayDonationForm`/`NonAuthDonation` pick up the updated provider automatically. diff --git a/apphelper/public/locales/en.json b/apphelper/public/locales/en.json index 893fd6c..59c2cde 100644 --- a/apphelper/public/locales/en.json +++ b/apphelper/public/locales/en.json @@ -147,6 +147,8 @@ "errorProcessingDonation": "Error processing donation", "enterBankDetails": "Enter your bank account details", "enterCardDetails": "Enter card details", + "payWithCard": "Card", + "payWithBank": "Bank (ACH)", "gatewayConfigMissing": "Payment form not available. Gateway configuration missing.", "paymentProvider": "Payment Provider", "memo": "Memo (optional)", diff --git a/apphelper/src/donations/components/KingdomFundingNonAuthDonationInner.tsx b/apphelper/src/donations/components/KingdomFundingNonAuthDonationInner.tsx index 9e8fa8c..fc04e22 100644 --- a/apphelper/src/donations/components/KingdomFundingNonAuthDonationInner.tsx +++ b/apphelper/src/donations/components/KingdomFundingNonAuthDonationInner.tsx @@ -14,11 +14,6 @@ import { import type { PaperProps } from "@mui/material/Paper"; import { KingdomFundingTokenForm, KingdomFundingTokenFormHandle } from "./KingdomFundingTokenForm"; -// Kingdom Funding ACH is hidden in the UI pending hosted ACH tokenization support -// from the gateway. Flip to true once tokenization no longer requires raw routing/ -// account numbers to flow through our backend. -const KF_ACH_ENABLED = false; - interface Props { churchId: string; mainContainerCssProps?: PaperProps; @@ -52,12 +47,10 @@ export const KingdomFundingNonAuthDonationInner: React.FC = ({ mainContai const captchaRef = useRef(null); const kfTokenRef = useRef(null); - // Bank-specific fields - const [routingNumber, setRoutingNumber] = useState(""); - const [accountNumber, setAccountNumber] = useState(""); - const [accountType, setAccountType] = useState<"checking" | "savings">("checking"); - - const paymentType = KF_ACH_ENABLED ? (props.paymentType || "card") : "card"; + const [payMethod, setPayMethod] = useState<"card" | "ach">( + props.paymentType === "bank" ? "ach" : "card" + ); + const paymentType: "card" | "bank" = payMethod === "ach" ? "bank" : "card"; const getUrlParam = (param: string) => { if (typeof window === "undefined") return null; @@ -124,10 +117,6 @@ export const KingdomFundingNonAuthDonationInner: React.FC = ({ mainContai if (result.length === 0) { if (!email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) result.push(Locale.label("donation.donationForm.validate.validEmail")); } - if (paymentType === "bank") { - if (!routingNumber || routingNumber.length < 9) result.push(Locale.label("donation.kingdomFunding.validate.routingNumber")); - if (!accountNumber || accountNumber.length < 4) result.push(Locale.label("donation.kingdomFunding.validate.accountNumber")); - } setErrors(result); return result.length === 0; }; @@ -184,24 +173,18 @@ export const KingdomFundingNonAuthDonationInner: React.FC = ({ mainContai }; try { + const tokenResult = await kfTokenRef.current?.getNonce(); + if (!tokenResult?.nonce) { + setErrors([Locale.label("donation.kingdomFunding.failedToProcessCard")]); + setProcessing(false); + return; + } + basePayload.type = paymentType; + basePayload.id = tokenResult.nonce; if (paymentType === "bank") { - // Bank/ACH charge - send routing/account details to backend - basePayload.type = "bank"; - basePayload.name = "bank account xxxxx" + accountNumber.toString().substring(accountNumber.length - 3); - basePayload.routing_number = routingNumber; - basePayload.account_number = accountNumber; - basePayload.account_type = accountType; - basePayload.sec_code = "WEB"; + basePayload.name = "Bank account ****" + (tokenResult.accountLast4 || ""); + basePayload.accountLast4 = tokenResult.accountLast4; } else { - // Card charge - get nonce from tokenization form - const tokenResult = await kfTokenRef.current?.getNonce(); - if (!tokenResult?.nonce) { - setErrors([Locale.label("donation.kingdomFunding.failedToProcessCard")]); - setProcessing(false); - return; - } - basePayload.type = "card"; - basePayload.id = tokenResult.nonce; basePayload.cardBrand = tokenResult.cardType; basePayload.cardLast4 = tokenResult.last4; basePayload.expiry_month = tokenResult.expiryMonth; @@ -243,8 +226,6 @@ export const KingdomFundingNonAuthDonationInner: React.FC = ({ mainContai case "startDate": setStartDate(val); break; case "interval": setInterval(val); break; case "notes": setNotes(val); break; - case "routingNumber": setRoutingNumber(val.replace(/\D/g, "")); break; - case "accountNumber": setAccountNumber(val.replace(/\D/g, "")); break; } }; @@ -341,53 +322,39 @@ export const KingdomFundingNonAuthDonationInner: React.FC = ({ mainContai - {/* Payment input */} - {KF_ACH_ENABLED && paymentType === "bank" ? ( - - - - {Locale.label("donation.kingdomFunding.enterBankDetails")} - - - - - - - - - - - {Locale.label("donation.bankForm.accountType")} - - + {Locale.label("donation.kingdomFunding.payWithCard")} + + + + + - - ) : gateway?.publicKey ? ( -
+ + {paymentType === "bank" + ? Locale.label("donation.kingdomFunding.enterBankDetails") + : Locale.label("donation.kingdomFunding.enterCardDetails")} +
diff --git a/apphelper/src/donations/components/KingdomFundingTokenForm.tsx b/apphelper/src/donations/components/KingdomFundingTokenForm.tsx index 20a68ff..ff39a9f 100644 --- a/apphelper/src/donations/components/KingdomFundingTokenForm.tsx +++ b/apphelper/src/donations/components/KingdomFundingTokenForm.tsx @@ -6,11 +6,15 @@ import { Locale } from "../helpers"; export interface KingdomFundingTokenResult { nonce: string; + token: string; + paymentType: "card" | "ach"; last4: string; cardType: string; expiryMonth: string; expiryYear: string; maskedCard: string; + accountLast4?: string; + accountName?: string; } export interface KingdomFundingTokenFormHandle { @@ -19,158 +23,172 @@ export interface KingdomFundingTokenFormHandle { interface Props { tokenizationKey: string; + paymentMethod?: "card" | "ach"; sandbox?: boolean; } -// CSS strings for each CardFormStyles field to match MUI outlined TextField -// accept.blue labelType options: "floating" | "static-left" | "static-top" | "hidden" -const inputBase = "font-family: 'Roboto','Helvetica','Arial',sans-serif; font-size: 1rem; color: rgba(0,0,0,0.87); border: 1px solid rgba(0,0,0,0.23); border-radius: 4px; padding: 16.5px 14px; background: #fff; outline: none; box-sizing: border-box; transition: border-color 200ms cubic-bezier(0.0,0,0.2,1);"; -const tokenFormStyles = { - container: "display: flex; flex-wrap: wrap; gap: 12px; width: 100%; align-items: flex-end;", - card: inputBase + " flex: 1 0 100%; width: 100%;", - expiryContainer: "display: flex; align-items: flex-end; gap: 4px;", - expiryMonth: inputBase + " width: 64px; text-align: center;", - expirySeparator: "font-size: 1.25rem; color: rgba(0,0,0,0.4); padding: 16.5px 0; line-height: 1;", - expiryYear: inputBase + " width: 64px; text-align: center;", - cvv2: inputBase + " width: 90px;", - labels: "font-family: 'Roboto','Helvetica','Arial',sans-serif; font-size: 0.75rem; color: rgba(0,0,0,0.6);", - labelType: "floating" as const +const COLLECT_JS_URL = "https://secure.nmi.com/token/Collect.js"; + +const customCss = { + "font-family": "'Roboto','Helvetica','Arial',sans-serif", + "font-size": "16px", + color: "rgba(0,0,0,0.87)" +}; +const invalidCss = { color: "#d32f2f" }; +const focusCss = { color: "rgba(0,0,0,0.87)" }; +const placeholderCss = { color: "rgba(0,0,0,0.5)" }; + +const fieldBoxSx = { + border: "1px solid rgba(0,0,0,0.23)", + borderRadius: "4px", + padding: "0 14px", + height: "56px", + display: "flex", + alignItems: "center", + background: "#fff", + "& iframe": { width: "100%", height: "100%", border: "none" } }; export const KingdomFundingTokenForm = forwardRef( - ({ tokenizationKey, sandbox = false }, ref) => { - const containerRef = useRef(null); - const hostedTokenizationRef = useRef(null); + ({ tokenizationKey, paymentMethod = "card", sandbox: _sandbox = false }, ref) => { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const initCalledRef = useRef(false); - - const destroyTokenization = useCallback(() => { - if (hostedTokenizationRef.current) { - try { - // destroy() returns a Promise that often rejects with undefined when the - // iframe was never fully initialized (e.g. unmount during loading). - // Swallow both sync throws and the async rejection so it doesn't surface - // as an Unhandled Promise Rejection in the console. - const result = hostedTokenizationRef.current.destroy(); - if (result && typeof result.catch === "function") { - result.catch(() => { /* ignore */ }); - } - } catch (_e) { /* ignore */ } - hostedTokenizationRef.current = null; - } - // Clear any leftover iframes in the container - if (containerRef.current) { - containerRef.current.innerHTML = ""; - } + const configuredRef = useRef(false); + type PendingRequest = { resolve: (r: KingdomFundingTokenResult) => void; reject: (e: Error) => void; timer: ReturnType }; + const pendingRef = useRef(null); + + const settlePending = useCallback((fn: "resolve" | "reject", value: any) => { + const p = pendingRef.current; + if (!p) return; + clearTimeout(p.timer); + pendingRef.current = null; + if (fn === "resolve") p.resolve(value); + else p.reject(value); }, []); - const initTokenization = useCallback(() => { - if (initCalledRef.current) return; - - try { - const HostedTokenization = (window as any).HostedTokenization; - if (!HostedTokenization) { - setError(Locale.label("donation.kingdomFunding.paymentFormNotAvailable")); - setLoading(false); - return; - } - - // Clean up any previous instance first - destroyTokenization(); + const configureCollectJs = useCallback(() => { + const CollectJS = (window as any).CollectJS; + if (!CollectJS) { + setError(Locale.label("donation.kingdomFunding.paymentFormNotAvailable")); + setLoading(false); + return; + } - const containerId = "kf-token-container"; - if (containerRef.current) { - containerRef.current.id = containerId; - } + const fields = + paymentMethod === "ach" + ? { + checkaccount: { selector: "#kf-checkaccount", placeholder: "Account Number" }, + checkaba: { selector: "#kf-checkaba", placeholder: "Routing Number" }, + checkname: { selector: "#kf-checkname", placeholder: "Name on Account" } + } + : { + ccnumber: { selector: "#kf-ccnumber", placeholder: "0000 0000 0000 0000" }, + ccexp: { selector: "#kf-ccexp", placeholder: "MM / YY" }, + cvv: { selector: "#kf-cvv", placeholder: "CVV" } + }; - initCalledRef.current = true; - hostedTokenizationRef.current = new HostedTokenization(tokenizationKey, { - target: `#${containerId}`, - styles: tokenFormStyles + try { + CollectJS.configure({ + variant: "inline", + tokenizationKey, + customCss, + invalidCss, + focusCss, + placeholderCss, + fields, + fieldsAvailableCallback: () => setLoading(false), + validationCallback: (_field: string, valid: boolean, message: string) => { + if (!valid && pendingRef.current) { + settlePending("reject", new Error(message || Locale.label("donation.kingdomFunding.failedToTokenizeCard"))); + } + }, + timeoutCallback: () => { + settlePending("reject", new Error(Locale.label("donation.kingdomFunding.failedToTokenizeCard"))); + }, + callback: (response: any) => { + const card = response.card || {}; + const check = response.check || {}; + const exp: string = card.exp || ""; + const isAch = !!response.check && !response.card; + settlePending("resolve", { + nonce: response.token, + token: response.token, + paymentType: isAch ? "ach" : "card", + last4: (card.number || "").replace(/[^0-9]/g, "").slice(-4), + cardType: card.type || "", + expiryMonth: exp ? exp.slice(0, 2) : "", + expiryYear: exp ? "20" + exp.slice(2, 4) : "", + maskedCard: card.number || "", + accountLast4: (check.account || "").replace(/[^0-9]/g, "").slice(-4), + accountName: check.name || "" + } as KingdomFundingTokenResult); + } }); - - setLoading(false); - } catch (_e) { + configuredRef.current = true; + } catch { setError(Locale.label("donation.kingdomFunding.failedToInitPaymentForm")); setLoading(false); } - }, [tokenizationKey, destroyTokenization]); + }, [tokenizationKey, paymentMethod, settlePending]); useEffect(() => { - initCalledRef.current = false; - if (!tokenizationKey) { setError(Locale.label("donation.kingdomFunding.missingTokenizationKey")); setLoading(false); return; } - - const scriptUrl = sandbox - ? "https://tokenization.sandbox.accept.blue/tokenization/v0.3" - : "https://tokenization.accept.blue/tokenization/v0.3"; - - // Check if script already loaded - if ((window as any).HostedTokenization) { - initTokenization(); - return () => { destroyTokenization(); initCalledRef.current = false; }; - } - - const existingScript = document.querySelector(`script[src="${scriptUrl}"]`); - if (existingScript) { - const onLoad = () => { initTokenization(); }; - if ((window as any).HostedTokenization) { - initTokenization(); + setError(null); + setLoading(true); + configuredRef.current = false; + + const onScriptReady = () => configureCollectJs(); + + if ((window as any).CollectJS) { + onScriptReady(); + } else { + let script = document.querySelector(`script[src="${COLLECT_JS_URL}"]`); + if (!script) { + script = document.createElement("script"); + script.src = COLLECT_JS_URL; + script.async = true; + script.setAttribute("data-tokenization-key", tokenizationKey); + script.setAttribute("data-variant", "inline"); + script.onload = onScriptReady; + script.onerror = () => { + setError(Locale.label("donation.kingdomFunding.failedToLoadPaymentForm")); + setLoading(false); + }; + document.head.appendChild(script); } else { - existingScript.addEventListener("load", onLoad); + script.addEventListener("load", onScriptReady); } - return () => { - existingScript.removeEventListener("load", onLoad); - destroyTokenization(); - initCalledRef.current = false; - }; } - const script = document.createElement("script"); - script.src = scriptUrl; - script.async = true; - script.onload = () => { initTokenization(); }; - script.onerror = () => { - setError(Locale.label("donation.kingdomFunding.failedToLoadPaymentForm")); - setLoading(false); - }; - document.head.appendChild(script); - return () => { - destroyTokenization(); - initCalledRef.current = false; + if (pendingRef.current) { + settlePending("reject", new Error(Locale.label("donation.kingdomFunding.paymentFormNotInitialized"))); + } }; - }, [tokenizationKey, sandbox, initTokenization, destroyTokenization]); + }, [tokenizationKey, paymentMethod, configureCollectJs, settlePending]); useImperativeHandle(ref, () => ({ - getNonce: async (): Promise => { - if (!hostedTokenizationRef.current) { - throw new Error(Locale.label("donation.kingdomFunding.paymentFormNotInitialized")); - } - - const result = await hostedTokenizationRef.current.getNonceToken(); - if (!result?.nonce) { - throw new Error(result?.error || Locale.label("donation.kingdomFunding.failedToTokenizeCard")); - } - - // Normalize expiry year to 4-digit (accept.blue API requires 2020-9999) - let expYear = result.expiryYear ? Number(result.expiryYear) : 0; - if (expYear > 0 && expYear < 100) expYear += 2000; - - return { - nonce: result.nonce, - last4: result.last4 || "", - cardType: result.cardType || "", - expiryMonth: result.expiryMonth ? String(result.expiryMonth) : "", - expiryYear: expYear ? String(expYear) : "", - maskedCard: result.maskedCard || "" - }; - } + getNonce: (): Promise => + new Promise((resolve, reject) => { + const CollectJS = (window as any).CollectJS; + if (!CollectJS || !configuredRef.current) { + reject(new Error(Locale.label("donation.kingdomFunding.paymentFormNotInitialized"))); + return; + } + const timer = setTimeout(() => { + settlePending("reject", new Error(Locale.label("donation.kingdomFunding.failedToTokenizeCard"))); + }, 20000); + pendingRef.current = { resolve, reject, timer }; + try { + CollectJS.startPaymentRequest(); + } catch (e: any) { + settlePending("reject", new Error(e?.message || Locale.label("donation.kingdomFunding.failedToTokenizeCard"))); + } + }) })); if (error) { @@ -185,7 +203,23 @@ export const KingdomFundingTokenForm = forwardRef{Locale.label("donation.kingdomFunding.loadingPaymentForm")} )} -
+ {paymentMethod === "ach" ? ( + + + + + + + + ) : ( + + + + + + + + )} ); } diff --git a/apphelper/src/donations/components/PaymentMethods.tsx b/apphelper/src/donations/components/PaymentMethods.tsx index 0cb9526..ab08fde 100644 --- a/apphelper/src/donations/components/PaymentMethods.tsx +++ b/apphelper/src/donations/components/PaymentMethods.tsx @@ -196,6 +196,7 @@ export const PaymentMethods: React.FC = (props) => { name: props.person?.name?.display || "", provider: provider?.key, id: token.id, + type: token.type, cardBrand: token.brand, cardLast4: token.last4, expiry_month: token.expMonth, diff --git a/apphelper/src/donations/helpers/Locale.ts b/apphelper/src/donations/helpers/Locale.ts index e9c1f70..e77ff20 100644 --- a/apphelper/src/donations/helpers/Locale.ts +++ b/apphelper/src/donations/helpers/Locale.ts @@ -168,6 +168,8 @@ export class Locale { "errorProcessingDonation": "Error processing donation", "enterBankDetails": "Enter your bank account details", "enterCardDetails": "Enter card details", + "payWithCard": "Card", + "payWithBank": "Bank (ACH)", "gatewayConfigMissing": "Payment form not available. Gateway configuration missing.", "paymentProvider": "Payment Provider", "memo": "Memo (optional)", diff --git a/apphelper/src/donations/providers/KingdomFundingProvider.tsx b/apphelper/src/donations/providers/KingdomFundingProvider.tsx index c40be98..f7c1fe7 100644 --- a/apphelper/src/donations/providers/KingdomFundingProvider.tsx +++ b/apphelper/src/donations/providers/KingdomFundingProvider.tsx @@ -1,31 +1,66 @@ "use client"; -import React, { forwardRef, useImperativeHandle, useRef } from "react"; +import React, { forwardRef, useImperativeHandle, useRef, useState } from "react"; +import { Button, Grid } from "@mui/material"; import { KingdomFundingTokenForm, KingdomFundingTokenFormHandle } from "../components/KingdomFundingTokenForm"; import { KingdomFundingNonAuthDonationInner } from "../components/KingdomFundingNonAuthDonationInner"; -import { buildSavedMethodBody } from "./StripeProvider"; +import { Locale } from "../helpers"; import type { PaymentProvider, GuestFormProps, PaymentToken, ChargeRequest, - MemberEntryHandle, MemberEntryProps + ChargeContext, MemberEntryHandle, MemberEntryProps } from "./types"; -// Inline card entry for member donations — wraps the accept.blue hosted iframe -// and normalizes its nonce into the uniform PaymentToken. const KingdomFundingMemberEntry = forwardRef(({ gateway }, ref) => { const kfRef = useRef(null); + const [payMethod, setPayMethod] = useState<"card" | "ach">("card"); + useImperativeHandle(ref, () => ({ tokenize: async (): Promise => { if (!kfRef.current) throw new Error("Card form not ready. Please wait and try again."); const r = await kfRef.current.getNonce(); - return { id: r.nonce, type: "card", brand: r.cardType, last4: r.last4, expMonth: r.expiryMonth, expYear: r.expiryYear }; + const type: "card" | "bank" = r.paymentType === "ach" ? "bank" : "card"; + return { + id: r.nonce, + type, + brand: r.cardType || undefined, + last4: r.last4 || r.accountLast4 || undefined, + expMonth: r.expiryMonth || undefined, + expYear: r.expiryYear || undefined + }; } })); + return ( - + <> + + + + + + + + + + ); }); KingdomFundingMemberEntry.displayName = "KingdomFundingMemberEntry"; @@ -37,10 +72,26 @@ const KingdomFundingGuestForm: React.FC = (props) => ( showHeader={false} recaptchaSiteKey={props.recaptchaSiteKey} churchLogo={props?.churchLogo} - paymentType="card" /> ); +function buildKfSavedBody(ctx: ChargeContext, token: PaymentToken, providerKey: string) { + return { + paymentMethodId: token.id, + customerId: token.customerId || ctx.customerId, + type: token.type, + provider: providerKey, + gatewayId: ctx.gatewayId, + person: ctx.person, + amount: ctx.amount, + funds: ctx.funds, + billing_cycle_anchor: ctx.billingCycleAnchor, + interval: ctx.interval, + notes: ctx.notes, + church: ctx.church + }; +} + export const KingdomFundingProvider: PaymentProvider = { key: "kingdomfunding", descriptor: { @@ -73,13 +124,13 @@ export const KingdomFundingProvider: PaymentProvider = { return "https://kingdomfunding.org/begin-registration/?" + params.map(([k, v]) => k + "=" + encodeURIComponent(v || "")).join("&"); } }, - capabilities: { savedCard: true, savedBank: false, guestAch: false, memberNewCard: true, recurring: true, editRecurring: false }, + capabilities: { savedCard: true, savedBank: true, guestAch: true, memberNewCard: true, recurring: true, editRecurring: false }, MemberEntry: KingdomFundingMemberEntry, buildChargeRequest: (ctx, token): ChargeRequest => { const endpoint = ctx.recurring ? "/donate/subscribe" : "/donate/charge"; - if (token.saved) return { endpoint, body: buildSavedMethodBody(ctx, token, "kingdomfunding") }; + if (token.saved) return { endpoint, body: buildKfSavedBody(ctx, token, "kingdomfunding") }; const body: any = { provider: "kingdomfunding", gatewayId: ctx.gatewayId, @@ -90,7 +141,7 @@ export const KingdomFundingProvider: PaymentProvider = { notes: ctx.notes || "", church: ctx.church, saveCard: ctx.saveCard, - type: "card", + type: token.type, id: token.id, cardBrand: token.brand, cardLast4: token.last4,