Skip to content
Merged
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
188 changes: 188 additions & 0 deletions docs/ach-payments/INTEGRATION_GUIDE.md
Original file line number Diff line number Diff line change
@@ -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
<script src="https://core.spreedly.com/checkout/sdk/{version}/index.js"></script>
```

```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<string, string>` | 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`.
22 changes: 0 additions & 22 deletions docs/migration-guide/MIGRATION_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ library to the new Checkout Web SDK (`SpreedlyHostedFields` /
- ✅ — direct equivalent
- ⚠️ — equivalent exists but the signature, event name, or payload changed
- 🆕 — new in Checkout Web SDK (no legacy counterpart)
- ❌ TODO — exists in legacy, not yet in Checkout Web SDK

---

Expand Down Expand Up @@ -274,8 +273,6 @@ are marked ⚠️.
| `'recache'` `(token, pm)` | `'recacheSuccess'` `(response)` | ⚠️ | Renamed; payload is the recache response object `{ message, token, payment_method }`. |
| _(none)_ | `'cvvExpired'` (subset of `'error'`) | 🆕 | New SDK clears CVV after PCI DSS 3.2.3 TTL (3 minutes) and emits an `error` with `{ message: 'CVV expired after 3 minutes', reason: 'PCI DSS 3.2.3 TTL compliance' }`. |
| `'3ds:status'` `(event)` (single dispatcher; switch on `event.action`) | Typed callbacks on the `SpreedlyThreeDSLifecycle` constructor | ⚠️ | See [3DS](#3ds--global--forter) — replaced by `callbacks: { onChallenge, onSuccess, onError, onDeviceFingerprint?, onTriggerCompletion? }`. |
| `'fraud:token'` | _(none — Spreedly Fraud client not ported)_ | ❌ TODO | |
| _none_ | `'close'` (Hosted Fields after `destroy()`, and Express Checkout) | 🆕 | |
| _none_ | `'offsiteTokenGenerated'` / `'offsitePaymentError'` | 🆕 | See [Offsite payments](#offsite-payments--paypal--redirect-style). |
| _none_ | `'achTokenGenerated'` / `'achPaymentError'` | 🆕 | See [ACH payments](#ach-payments-). |

Expand Down Expand Up @@ -577,22 +574,3 @@ sdk.expressCheckout({

Reference: `web-sdk-sample-app/src/static/tokenize/tokenize.js?sdk=express-checkout`,
`checkout-web-sdk/docs/tokenization/express-checkout/INTEGRATION_GUIDE.md`

---

## Not yet migrated

The following legacy iFrame APIs do **not** have an equivalent in Checkout Web
SDK today. Track each as a TODO when planning your migration.

### Field-level controls
- `Spreedly.setValue('number' \| 'cvv', value)` — intentionally not migrated;
setting card values from the parent page would break PCI scope.

### Events
- `'numberSet'` / `'cvvSet'` / `'sourceSet'` — mirror legacy `setValue` / `source`
flows that aren't supported.
- `'fraud:token'` — see below.

### Other
- `Spreedly.Fraud` (built-in fraud client).
44 changes: 44 additions & 0 deletions src/controllers/payments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
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<void> => {
const transaction_token = req.params.transactionToken || '';
Expand Down
39 changes: 39 additions & 0 deletions src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
createPurchase,
createBraintreePurchase,
confirmTransaction,
createAchPurchase,
} from './controllers/payments';

const router = Router();
Expand Down Expand Up @@ -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;
Loading
Loading