From af1c12441512e08a15adf5088ee5ae53c40382b4 Mon Sep 17 00:00:00 2001 From: Rahul Agarwal Date: Sat, 9 May 2026 23:25:47 +0530 Subject: [PATCH 1/2] added demo for ach payments --- src/controllers/payments.ts | 44 ++++ src/routes.ts | 39 +++ src/static/ach-payments/ach-payments.js | 225 +++++++++++++++++ src/static/ach-payments/index.html | 313 ++++++++++++++++++++++++ src/static/index.html | 19 ++ src/static/shared/utils.js | 35 ++- 6 files changed, 667 insertions(+), 8 deletions(-) create mode 100644 src/static/ach-payments/ach-payments.js create mode 100644 src/static/ach-payments/index.html diff --git a/src/controllers/payments.ts b/src/controllers/payments.ts index 405235f..54e05bb 100644 --- a/src/controllers/payments.ts +++ b/src/controllers/payments.ts @@ -490,6 +490,50 @@ export const createBraintreePurchase = async (req: Request, res: Response): Prom } }; +// Run a purchase against an ACH (bank_account) payment method token using +// the Spreedly Test gateway. Mirrors createSimplePurchase but is dedicated to +// ACH so it can be wired and documented independently for the demo flow. +export const createAchPurchase = async (req: Request, res: Response): Promise => { + const gateway_key = config.spreedlyGatewayToken; + + const { payment_method_token, amount, currency_code = 'USD' } = req.body; + + if (!payment_method_token || !amount) { + res.status(400).json({ error: 'payment_method_token and amount are required' }); + return; + } + + const body = { + transaction: { + payment_method_token, + amount, + currency_code, + }, + }; + + try { + const response = await axios.post( + `${config.spreedlyUrl}/v1/gateways/${gateway_key}/purchase.json`, + body, + { + headers: { + Authorization: getAuthorizationHeader(), + 'Content-Type': 'application/json', + }, + } + ); + + const transaction = response.data?.transaction; + res.json({ + success: transaction?.succeeded || false, + transaction, + }); + } catch (error) { + const apiError = error as AxiosError; + res.status(apiError.response?.status || 500).json(apiError.response?.data); + } +}; + // Confirm a Braintree/Stripe-apm transaction with the nonce from PayPal/Venmo export const confirmTransaction = async (req: Request, res: Response): Promise => { const transaction_token = req.params.transactionToken || ''; diff --git a/src/routes.ts b/src/routes.ts index 00930fb..2b19892 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -17,6 +17,7 @@ import { createPurchase, createBraintreePurchase, confirmTransaction, + createAchPurchase, } from './controllers/payments'; const router = Router(); @@ -575,4 +576,42 @@ router.post('/braintree-purchase', createBraintreePurchase); */ router.post('/transactions/:transactionToken/confirm', confirmTransaction); +/** + * @swagger + * /api/v1/ach-purchase: + * post: + * description: Create a purchase transaction against an ACH (bank_account) payment method using the Spreedly Test gateway + * tags: [ACH Payments] + * produces: + * - application/json + * parameters: + * - name: body + * description: Purchase details + * in: body + * required: true + * schema: + * type: object + * required: + * - payment_method_token + * - amount + * properties: + * payment_method_token: + * type: string + * description: Token of the bank_account payment method + * amount: + * type: number + * description: Transaction amount in cents + * currency_code: + * type: string + * description: ISO 4217 currency code (default USD) + * responses: + * 200: + * description: ACH purchase created successfully + * 400: + * description: Missing required parameters + * 500: + * description: Error creating purchase + */ +router.post('/ach-purchase', createAchPurchase); + export default router; diff --git a/src/static/ach-payments/ach-payments.js b/src/static/ach-payments/ach-payments.js new file mode 100644 index 0000000..c598a20 --- /dev/null +++ b/src/static/ach-payments/ach-payments.js @@ -0,0 +1,225 @@ +/** + * ACH Payments Flow — Spreedly Web SDK Demo + * + * 1. Load SDK and fetch auth params from backend + * 2. Initialize the SDK with auth params + * 3. User fills in bank-account details + * 4. Call setupACHPayment(config) → submitACHPayment() + * 5. Listen for achTokenGenerated → POST to /api/v1/ach-purchase + * 6. Render success/failure + * + * Note: ACH does NOT use hosted-fields or express-checkout iframes. The + * merchant collects the values in their own form and passes them to the + * SDK via the public API. We still load the SpreedlyHostedFields class + * here because it is the entry point that exposes setupACHPayment. + */ + +let sdk = null; + +const elements = { + loadingState: () => document.getElementById('loading-state'), + paymentSection: () => document.getElementById('payment-section'), + resultSection: () => document.getElementById('result-section'), + achForm: () => document.getElementById('ach-form'), + submitBtn: () => document.getElementById('submit-btn'), + toggleAccountVisibility: () => document.getElementById('toggle-account-visibility'), + accountInput: () => document.getElementById('ach-account'), + resultTitle: () => document.getElementById('result-title'), + resultDetails: () => document.getElementById('result-details'), + resultIconSuccess: () => document.getElementById('result-icon-success'), + resultIconError: () => document.getElementById('result-icon-error'), +}; + +document.addEventListener('DOMContentLoaded', init); + +async function init() { + setupAccountVisibilityToggle(); + + try { + await loadSDKAsync(); + await fetchAuthParamsAndInitSDK(); + setupSubmitHandler(); + hideLoading(); + elements.paymentSection().classList.remove('hidden'); + } catch (error) { + console.error('Failed to initialize ACH demo:', error); + showError('Failed to initialize. Please refresh the page.'); + } +} + +function loadSDKAsync() { + return new Promise((resolve, reject) => { + SpreedlyUtils.loadSDKScript(error => (error ? reject(error) : resolve())); + }); +} + +async function fetchAuthParamsAndInitSDK() { + const authParams = await SpreedlyUtils.fetchAuthParams(); + + const authConfig = { + environment_key: authParams.environmentKey, + nonce: authParams.nonce, + timestamp: authParams.timestamp, + certificate_token: authParams.certificateToken, + signature: authParams.signature, + }; + + // Either SDK class works for ACH since the methods live on the shared + // SpreedlyWebSDK base. Pick based on the ?sdk= query param for parity + // with the other demo flows. + const sdkType = SpreedlyUtils.getSDKType(); + if (sdkType === 'express-checkout') { + sdk = new SpreedlyExpressCheckout(authConfig); + } else { + sdk = new SpreedlyHostedFields(authConfig); + } + + sdk.on('achTokenGenerated', async ({ token, last4 }) => { + console.log('ACH payment method created:', { token, last4 }); + await runPurchase(token, last4); + }); + + sdk.on('achPaymentError', error => { + console.error('ACH payment error:', error); + renderError(error?.message || 'Failed to create ACH payment method.'); + setSubmitting(false); + }); + + elements.submitBtn().disabled = false; +} + +function setupSubmitHandler() { + const form = elements.achForm(); + form.addEventListener('submit', e => { + e.preventDefault(); + handleSubmit(); + }); +} + +function setupAccountVisibilityToggle() { + const button = elements.toggleAccountVisibility(); + const input = elements.accountInput(); + if (!button || !input) return; + button.addEventListener('click', () => { + const showing = input.type === 'text'; + input.type = showing ? 'password' : 'text'; + button.textContent = showing ? 'Show' : 'Hide'; + }); +} + +function handleSubmit() { + if (!sdk) { + renderError('SDK not initialized.'); + return; + } + + const formData = SpreedlyUtils.getFormData('ach-form'); + + // Build the config exactly as the SDK expects (camelCase). + const config = { + bankRoutingNumber: (formData.bankRoutingNumber || '').trim(), + bankAccountNumber: (formData.bankAccountNumber || '').trim(), + firstName: (formData.firstName || '').trim(), + lastName: (formData.lastName || '').trim(), + bankName: (formData.bankName || '').trim() || undefined, + bankAccountType: formData.bankAccountType, + bankAccountHolderType: formData.bankAccountHolderType, + }; + + setSubmitting(true); + + try { + sdk.setupACHPayment(config); + sdk.submitACHPayment(); + console.log('Waiting for achTokenGenerated event...'); + } catch (error) { + console.error('ACH submit failed:', error); + renderError(error.message || 'Failed to set up ACH payment.'); + setSubmitting(false); + } +} + +async function runPurchase(paymentMethodToken, last4) { + try { + // Fixed $10 USD for the demo. + const amount = 1000; + const currency = 'USD'; + + const result = await SpreedlyUtils.createAchPurchase(paymentMethodToken, amount, currency); + if (result?.success && result?.transaction) { + renderSuccess({ + paymentMethodToken, + last4, + transactionToken: result.transaction.token, + amount, + currency, + }); + } else { + renderError( + result?.transaction?.response?.message || + result?.transaction?.message || + 'Gateway purchase did not succeed.' + ); + } + } catch (error) { + console.error('Purchase request failed:', error); + const message = error?.response?.data?.error || error?.message || 'Purchase request failed.'; + renderError(message); + } finally { + setSubmitting(false); + } +} + +function setSubmitting(submitting) { + const btn = elements.submitBtn(); + btn.disabled = submitting; + btn.textContent = submitting + ? 'Processing...' + : 'Create payment method & run purchase'; +} + +function renderSuccess({ paymentMethodToken, last4, transactionToken, amount, currency }) { + hideLoading(); + elements.paymentSection().classList.add('hidden'); + elements.resultSection().classList.remove('hidden'); + elements.resultIconSuccess().classList.remove('hidden'); + elements.resultIconError().classList.add('hidden'); + elements.resultTitle().textContent = 'Purchase succeeded'; + + const escape = SpreedlyUtils.escapeHtml; + elements.resultDetails().innerHTML = ` +
Payment method token${escape(paymentMethodToken)}
+ ${last4 ? `
Account•••• ${escape(last4)}
` : ''} +
Transaction token${escape(transactionToken)}
+
Amount${SpreedlyUtils.formatCurrency(amount / 100, currency)}
+ `; +} + +function renderError(message) { + hideLoading(); + elements.paymentSection().classList.remove('hidden'); + elements.resultSection().classList.remove('hidden'); + elements.resultIconSuccess().classList.add('hidden'); + elements.resultIconError().classList.remove('hidden'); + elements.resultTitle().textContent = 'Something went wrong'; + elements.resultDetails().innerHTML = `
Error${SpreedlyUtils.escapeHtml(message)}
`; + SpreedlyUtils.showStatus('status-message', message, 'error'); +} + +window.resetAchFlow = function () { + if (sdk && typeof sdk.clearACHPayment === 'function') { + sdk.clearACHPayment(); + } + window.location.href = window.location.pathname + window.location.search; +}; + +function hideLoading() { + const loading = elements.loadingState(); + if (loading) loading.classList.add('hidden'); +} + +function showError(message) { + hideLoading(); + elements.paymentSection().classList.remove('hidden'); + SpreedlyUtils.showStatus('status-message', message, 'error'); +} diff --git a/src/static/ach-payments/index.html b/src/static/ach-payments/index.html new file mode 100644 index 0000000..a9c50df --- /dev/null +++ b/src/static/ach-payments/index.html @@ -0,0 +1,313 @@ + + + + + + ACH Payments | Spreedly Web SDK Demo + + + + + + + + +
+
+
+ + ACH Payments +
+
+ +
+
+
+ +
+ + + + + Back to flows + + +

ACH Bank Account Payment

+

+ Tokenize a US/Canadian bank account using the SDK's setupACHPayment / + submitACHPayment methods, then run a purchase against the Spreedly Test gateway. +

+ + +
+
+ + Loading auth parameters... +
+
+ + + + + + +
+ + +
+
+
+ + + + About ACH Payments +
+

+ ACH payments tokenize a US or Canadian bank account. The SDK posts the bank + details directly to Spreedly Core; the merchant page never sees the raw account + number outside this form. After the SDK emits achTokenGenerated, + the merchant backend runs a purchase on a gateway that supports ACH. +

+
+ +
+
+ + + + Flow Steps +
+
    +
  1. Load SDK and initialize with auth params
  2. +
  3. Collect bank-account details in your own form
  4. +
  5. Call setupACHPayment(config)
  6. +
  7. Call submitACHPayment()
  8. +
  9. Listen for achTokenGenerated → token + last4
  10. +
  11. Send token to backend → run gateway purchase
  12. +
+
+
+
+
+
+ +
+ +
+
+ + + + + diff --git a/src/static/index.html b/src/static/index.html index 8e2dc87..23f8301 100644 --- a/src/static/index.html +++ b/src/static/index.html @@ -164,6 +164,25 @@

Purchase with 3DS Gateway Specific

+ + +
+ + + +
+
+

ACH Bank Payments

+

Tokenize a US/Canadian bank account with setupACHPayment

+ ACH +
+
+ + + +
+
+
diff --git a/src/static/shared/utils.js b/src/static/shared/utils.js index 8b56924..bedc6c2 100644 --- a/src/static/shared/utils.js +++ b/src/static/shared/utils.js @@ -10,17 +10,17 @@ function getSDKType() { function getSDKScriptUrl() { const sdkType = getSDKType(); // uncomment this to use local sdk - // if(window.location.hostname === 'localhost') { - // if (sdkType === 'express-checkout') { - // return 'http://localhost:5173/express-checkout.js'; - // } - // return 'http://localhost:5000/index.js'; - // } + if(window.location.hostname === 'localhost') { + if (sdkType === 'express-checkout') { + return 'http://localhost:5173/express-checkout.js'; + } + return 'http://localhost:5000/index.js'; + } if (sdkType === 'express-checkout') { - return 'https://core-test.spreedly.com/checkout/elements/rc/express-checkout.js'; + return 'https://core.spreedly.com/checkout/elements/1.0.1/express-checkout.js'; } - return 'https://core-test.spreedly.com/checkout/sdk/rc/index.js'; + return 'https://core.spreedly.com/checkout/sdk/1.0.1/index.js'; } function getSDKDisplayName() { @@ -218,6 +218,22 @@ async function createOffsitePurchase(paymentMethodToken, amount, redirectUrl, ca } } +// ACH Payments — runs a server-side gateway purchase against an ACH +// (bank_account) payment method token using the configured Spreedly Test gateway. +async function createAchPurchase(paymentMethodToken, amount, currencyCode = 'USD') { + try { + const response = await axios.post(`${LOCAL_API_URL}/ach-purchase`, { + payment_method_token: paymentMethodToken, + amount, + currency_code: currencyCode, + }); + return response.data; + } catch (error) { + console.error('Error creating ACH purchase:', error); + throw error; + } +} + async function getTransactionStatus(transactionToken) { try { const response = await axios.get(`${API_BASE_URL}/transactions/${transactionToken}`); @@ -262,6 +278,9 @@ window.SpreedlyUtils = { // Offsite Payments createOffsitePurchase, getTransactionStatus, + + // ACH Payments + createAchPurchase, // UI helpers showStatus, From 95e8f316786da11cd3a05a0aa8f6574905e08ec4 Mon Sep 17 00:00:00 2001 From: Rahul Agarwal Date: Mon, 11 May 2026 12:37:34 +0530 Subject: [PATCH 2/2] Added integration guide for ACH payments --- docs/ach-payments/INTEGRATION_GUIDE.md | 188 +++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 docs/ach-payments/INTEGRATION_GUIDE.md diff --git a/docs/ach-payments/INTEGRATION_GUIDE.md b/docs/ach-payments/INTEGRATION_GUIDE.md new file mode 100644 index 0000000..bfacc33 --- /dev/null +++ b/docs/ach-payments/INTEGRATION_GUIDE.md @@ -0,0 +1,188 @@ +# ACH Payments Integration Guide + +This guide covers integrating ACH (US/Canadian bank account) payments using +the Spreedly Web SDK. ACH lets customers pay directly from a checking or +savings account via the ACH network. + +## Overview + +The SDK exposes a small, API-only surface for ACH: + +1. The merchant collects bank-account details in their own UI (the SDK does + not render any input fields for ACH — there are no hosted fields or + express-checkout iframes for this flow). +2. The merchant calls `setupACHPayment(config)` with the collected values. +3. The merchant calls `submitACHPayment()` — the SDK posts directly to + Spreedly Core and emits `achTokenGenerated` with the resulting payment + method token. +4. The merchant's backend runs a purchase against an ACH-capable gateway + using the token. + +``` +┌────────────┐ setupACHPayment ┌─────────────────┐ +│ Merchant │ ─────────────────▶ │ │ +│ Frontend │ submitACHPayment │ Spreedly SDK │ +│ │ ─────────────────▶ │ │ +└────────────┘ └────────┬────────┘ + ▲ │ POST /v1/payment_methods + │ achTokenGenerated ▼ + │ { token, last4 } ┌──────────────┐ + └────────────────────────────│ Spreedly Core│ + └──────────────┘ + ▲ + │ POST /v1/gateways/{gw}/purchase + │ + ┌────────────────┐ + │ Merchant │ + │ Backend │ + └────────────────┘ +``` + +## Prerequisites + +- Spreedly account with an [ACH-capable gateway](https://developer.spreedly.com/docs/ach-payments#ach-gateways) configured +- Spreedly environment key and API credentials +- Auth params (nonce, timestamp, signature, certificate_token) generated by + your backend + +--- + +## Step 1: Load and initialize the SDK + +```html + +``` + +```javascript +const sdk = new SpreedlyHostedFields({ + environment_key: 'your_environment_key', + certificate_token: 'your_certificate_token', + nonce: 'generated_nonce', + timestamp: 'generated_timestamp', + signature: 'generated_signature', +}); +``` + +> Both `SpreedlyHostedFields` and `SpreedlyExpressCheckout` expose the ACH +> methods — pick whichever class your app already loads. Hosted fields and +> express-checkout iframes are NOT mounted just to use ACH; the methods live +> on the shared base class. + +## Step 2: Set up event listeners + +```javascript +sdk.on('achTokenGenerated', ({ token, last4 }) => { + // POST `token` to your backend to run the gateway purchase +}); + +sdk.on('achPaymentError', (error) => { + console.error('ACH error:', error.message); +}); +``` + +## Step 3: Collect bank-account details and submit + +```javascript +sdk.setupACHPayment({ + bankRoutingNumber: '021000021', + bankAccountNumber: '9876543210', + fullName: 'Bob Smith', // OR firstName + lastName + bankAccountType: 'checking', // 'checking' | 'savings' + bankAccountHolderType: 'personal', // 'personal' | 'business' +}); + +sdk.submitACHPayment(); +``` + +`setupACHPayment` validates only that required fields are present at the +SDK boundary. Routing-number and account-number formatting (US ABA, Canadian +electronic routing) is validated by Spreedly Core; invalid values surface +via the `achPaymentError` event. + +## Step 4: Run the purchase from your backend + +After `achTokenGenerated` fires, send the token to your backend and run a +purchase against an ACH-capable gateway: + +```http +POST https://core.spreedly.com/v1/gateways/{ach_gateway_token}/purchase.json +Authorization: Basic base64(environment_key:access_secret) +Content-Type: application/json + +{ + "transaction": { + "payment_method_token": "...", + "amount": 1000, + "currency_code": "USD" + } +} +``` + +> **Important:** Run the purchase from your backend, not from the browser. +> Your access secret must never be exposed client-side. + +--- + +## API reference + +### `setupACHPayment(config: ACHPaymentConfig): void` + +Stores bank-account details that will be tokenized when `submitACHPayment()` +is called. Throws if any required field is missing. + +#### `ACHPaymentConfig` + +| Field | Type | Required | Notes | +| ------------------------ | -------------------------- | -------- | --------------------------------------------------------- | +| `bankRoutingNumber` | `string` | yes | 9-digit US ABA or Canadian electronic routing number | +| `bankAccountNumber` | `string` | yes | Bank account number | +| `fullName` | `string` | † | Either `fullName` OR (`firstName` AND `lastName`) | +| `firstName` | `string` | † | See above | +| `lastName` | `string` | † | See above | +| `bankName` | `string` | no | Display name of the bank | +| `bankAccountType` | `'checking' \| 'savings'` | no | | +| `bankAccountHolderType` | `'personal' \| 'business'` | no | | +| `email` | `string` | no | | +| `phoneNumber` | `string` | no | | +| `address1` … `country` | `string` | no | Optional billing address; some gateways require it | +| `metadata` | `Record` | no | Forwarded to Spreedly | +| `retained` | `boolean` | no | Set `true` to create a retained payment method | + +> Spreedly only supports US (`US`) and Canadian (`CA`) bank accounts. +> Other country codes will be rejected by Spreedly Core. + +### `submitACHPayment(): void` + +Submits the configured payload to Spreedly. Emits `achTokenGenerated` on +success, `achPaymentError` on failure. Throws synchronously if +`setupACHPayment()` was not called first. + +### `clearACHPayment(): void` + +Clears the stored ACH configuration. Useful if you want to reset state +between attempts. + +### Events + +| Event | Payload | Notes | +| -------------------- | -------------------------------- | ---------------------------------- | +| `achTokenGenerated` | `{ token: string, last4?: string }` | Spreedly returns the last 4 digits as `account_number_display_digits`; the SDK exposes them as `last4` | +| `achPaymentError` | `{ message: string, error?: any }` | | + +--- + +## Security notes + +- Account and routing numbers never leave the merchant page until they are + posted directly to Spreedly Core. The SDK does not log them. +- The SDK's success log includes only the masked `last4` returned by + Spreedly, never the full account number. +- The merchant page's CSP must allow `connect-src https://core.spreedly.com` + (the standard SDK CSP already does). + +--- + +## Sample app + +A working end-to-end demo lives at `web-sdk-sample-app/src/static/ach-payments/` +and exercises the Spreedly Test gateway via `POST /api/v1/ach-purchase`.