ACH Processing & Banking
This module handles every path money takes out of — or into — a SeedTrust escrow account. It is the most operationally sensitive area of the codebase. A misconfiguration here can cause a rejected payment file, a failed wire, or money sent to the wrong account. Read carefully before touching anything here.
Two Payment Paths
Every case uses one of two banking paths depending on where the Intended Parent is located:
| Path | Used For | Handled By |
|---|---|---|
| NACHA / M&T Bank | Non-California cases | Flask — generates NACHA file, uploads via SFTP to M&T Bank |
| Huntington Bank | California Intended Parents | FastAPI — calls Huntington API directly |
A case’s path is set by Case.bank_account_type (HUNTINGTON or M_T). This determines which batch processing flow is used for outgoing disbursements.
How the Routing Decision Is Made
The should_use_huntington(state, country) function determines the path at ACH form submission time. Both conditions must be true for Huntington to be used:
- State is CA — the IP’s state must match
HUNTINGTON_STATES = ["CA", "CALIFORNIA"] - Country is US — the IP’s country must be in the
US_COUNTRY_VARIATIONSlist
A third condition can disable Huntington routing globally: the PostHog feature flag huntington-flag, when its variant equals huntington-flag-new-cases-off (or when the flag is off entirely), prevents new cases from being routed to Huntington regardless of state. Automatic assignment only updates active cases with no ledger transactions and no disbursement requests.
Code:
seedtrustapi/src/seedtrust/modules/banking/huntington/; see also ADR 002 “NACHA” and “M&T” are used interchangeably in day-to-day operations. NACHA is the file format; M&T is the bank that receives it. Both terms refer to the same payment path for non-California cases.
ACH Forms — Collecting Banking Details
Before anyone can receive a payment, they must submit an ACH form with their banking details. This applies to surrogates, vendors, agencies, and in some configurations Intended Parents.
What an ACH Form Contains
An ACH form (MasterACHForm) stores:
Payment method — how the payment will be sent:
ACH— direct deposit to a US bank accountWT— wire transfer (domestic or international)CHK— physical checkAT— account transfer (internal)
Banking details (all encrypted at rest — see Encryption):
- Routing number (ABA 9-digit)
- Account number
- Account type (
CHECKINGorSAVINGS) - For international: IBAN, SWIFT code, transit number
Identity fields (plaintext):
- Name, address, date of birth
- Bank name and address
ACH transaction type:
PPD— Prearranged Payment and Deposit (person-to-person)CCD— Corporate Credit or Debit (business-to-business)
ACH Form Approval Workflow
A submitted ACH form must be reviewed and approved by an Admin before it can be used for payment.
Party submits banking details → form_approved = False, form_updates = NULLAdmin reviews → Admin approves → form_approved = TrueParty updates existing form → Changes stored in form_updates (pending queue), NOT applied to main fields yetAdmin reviews update → Admin approves → form_updates applied to main fields, clearedThis means the routing/account number used for payment is always the last admin-approved version, not the latest submission. If a surrogate updates their banking details and the Admin hasn’t approved it yet, the old details are used.
Code:
MasterACHForminseedtrust_flask/seedtrust/models/ach.pyFastAPI endpoints:seedtrustapi/src/seedtrust/modules/ach_form/route.py
NACHA / M&T Flow (Non-California Cases)
This is the standard payment path. It generates a NACHA-format file and delivers it to M&T Bank via SFTP.
End-to-End Flow
sequenceDiagram participant Admin participant Flask participant S3 participant SFTP as M&T SFTP
Admin->>Flask: Select approved transactions for batch Flask->>Flask: Validate transactions (routing, balance, bank type) Flask->>Flask: Generate NACHA file (carta-ach library) Flask->>S3: Upload NACHA file Flask->>Admin: Return batch ID + file path
alt Admin downloads Admin->>Flask: Download batch Flask->>Flask: Set downloaded = now(), lock batch else SFTP upload Admin->>Flask: Trigger SFTP upload Flask->>SFTP: Upload NACHA file Flask->>Flask: Set sftp_upload_status = SUCCESS/FAILED endStep 1 — Transaction Selection
An Admin selects which approved, unpaid transactions to include in a batch. The system filters eligible transactions:
status = PAID(approved and ready for ACH)nacha_sent = False(not already in a batch)payable_batch_id = NULL(not in a payables batch)- Type is
DISBURSEMENTorSCHEDULED PAYMENT amount > 0bank_account_typematches the batch target
Step 2 — NACHA File Generation
The NACHA file is built using the carta-ach library. The format is extremely strict — column positions, record lengths, and batch balancing rules are non-negotiable. A single formatting error rejects the entire file at the bank.
Key values used in the file header:
- Immediate destination — company bank routing number
- Immediate origin — company TIN (decrypted at generation time)
- Effective entry date — next US business day (skips weekends and federal holidays)
- Entry description —
"ESCROWPAYMENTS"(truncated to 10 characters) - Standard Entry Class Code —
PPD
Each transaction entry includes: routing number, account number, amount, payee name, and an optional memo (the nacha_memo field on the transaction).
Step 3 — File Delivery
The generated file is uploaded to S3 first, then delivered to M&T Bank via one of two mutually exclusive paths:
Download: Admin downloads the file manually and submits it to the bank through another channel.
SFTP upload: The system connects to M&T Bank’s SFTP server and uploads the file directly. Credentials come from environment variables (BANK_SFTP_HOST, BANK_SFTP_USERNAME, BANK_SFTP_PASSWORD).
SFTP upload is blocked after download, but not vice versa. Once a batch is downloaded,
upload_nacha_to_sftprejects any further SFTP attempt. However,track_nacha_downloaddoes not check whether the batch was already SFTP-uploaded — a previously uploaded batch can still be downloaded. A row-level lock inupload_nacha_to_sftpprevents the download path from closing the batch concurrently during an upload, but does not enforce post-upload exclusivity. Code:seedtrust_flask/seedtrust/views/admin/banking.py
Huntington Flow (California Cases)
Huntington cases use a direct API integration instead of file-based ACH. The flow has two phases: provisioning (one-time account setup) and batch processing (per-payment transfers).
Provisioning — One-Time Account Setup
Before a Huntington case can receive payments, the Intended Parent must be provisioned in the Huntington system. This creates a managed escrow account linked to the IP.
Step 1: Person Creation Admin submits IP details → Huntington API creates person record → person_guid stored on IntendedParent
Step 2: Wallet Creation Happens automatically during person creation → wallet_guid stored on IntendedParent
Step 3: Account Creation Huntington creates a managed account (DDA) → account_guid stored on HuntingtonAccount → provisioning_status: PENDING → PERSON_CREATED → WALLET_CREATED → COMPLETED
Step 4: Instrument Creation Links the IP's external bank account to their Huntington account → HuntingtonAccountInstrument createdThe provisioning_status property on HuntingtonAccount reflects how far through this flow the account is. Payments cannot be processed until status is COMPLETED.
Auto-provision from ACH Form: If the IP already has an approved ACH form on file, provisioning can be triggered automatically using those banking details.
Code:
seedtrustapi/src/seedtrust/modules/banking/huntington/service/provisioning.py
CIP — Regulatory Compliance Requirement
Before a Huntington account is fully activated, the Intended Parent must pass CIP (Customer Identification Program) verification — a Huntington-enforced regulatory requirement. The IP’s person_status reflects this:
PENDING— CIP not yet clearedCLEARED— CIP passed, account fully activeERROR— CIP failed or errored
Payments cannot proceed until person_status = CLEARED.
Batch Processing — Sending Payments
Once provisioned, disbursements are sent via the Huntington API:
Admin selects transactions for Huntington batch →System creates HuntingtonBatch (status: PROCESSING) →For each transaction: 1. Load case's Huntington account 2. Load IP's external bank instrument (create if new) 3. Call Huntington API: ACCOUNTTOBANK transfer 4. On success: transaction.banking_status = IN_PROCESS 5. On failure: transaction.banking_status = FAILED, continue to nextHuntingtonBatch updated with final status →Admin polls / streams batch progressUnlike NACHA, individual transaction failures do not halt the batch — the system skips failed transactions and continues processing the rest.
Batch status values:
PROCESSING— transfers in flightCOMPLETED— all processed (may include failures)PARTIAL_SUCCESS— mix of successes and failuresFAILED— all failed
Code:
seedtrustapi/src/seedtrust/modules/banking/huntington/service/batches.py
Dual Approval Workflow
When dual approval is enabled (controlled by a feature flag), all transactions on the banking dashboard — regardless of payment path (NACHA/M&T or Huntington) — require two separate Admin approvals before being released for payment. The two approvals must be from different Admins.
PENDING_FIRST_APPROVAL ↓ First Admin approvesPENDING_SECOND_APPROVAL ↓ Second Admin approvesPENDING_TRANSFER (ready for batch processing) ↓ Batch processedIN_PROCESS → SENT_TO_BANK → FINALIZEDHold & Release:
Any transaction can be placed on hold (ON_HOLD) at any point before finalization. Placing a hold resets both approvals. The Admin must capture a hold reason. The transaction can be released back to PENDING_FIRST_APPROVAL after corrections.
Code:
seedtrustapi/src/seedtrust/modules/banking/route.py
Incoming Funds — E-Check and Credit Card
E-Check Funding
E-checks allow Intended Parents to fund their escrow account via ACH debit (pulling money from their bank account rather than wiring it).
Flow:
- IP provides name, banking details (or uses their ACH form on file), and amount
- System creates an
ECheckAuthorizationrecord — this record is immutable after creation (enforced by a SQLAlchemy event listener) - A
LedgerTransactionof typeE-CHECK FUNDINGis created withstatus = PAID - Banking details are encrypted using
ACH_KEY
The immutability is a compliance requirement — e-check authorizations must be retained for 2 years and cannot be altered.
Code:
ECheckAuthorizationinbanking.py, e-check view ingeneral_frontend.py
Alacriti Credit Card Funding
For IPs who prefer to fund via credit card, SeedTrust integrates with Alacriti as the card processor. This creates an AlacritiPayment record linked to a LedgerTransaction.
Error Handling & Recovery
NACHA Return Entries
When M&T Bank rejects a payment (invalid account, closed account, insufficient funds at the recipient bank), it sends back a return NACHA file. This is a manual process — SeedTrust has no automated return file ingestion. When M&T sends a return, an admin must:
- Receive the notification from M&T externally
- Manually update the affected
LedgerTransactiontoRETURNstatus - Adjust
Case.acct_balanceif the funds have been returned to the source account
The RETURN status on LedgerTransaction represents a reversed payment.
Failed NACHA Batches
If SFTP upload fails, sftp_upload_status = "FAILED" and sftp_error_message is set. Admins can:
- Retry the SFTP upload (re-attempts delivery from the S3 file)
- Mark the batch as failed, which resets all linked transactions to
banking_status = FAILED
Failed Huntington Transactions
Individual transaction failures within a Huntington batch are tracked per transaction. Admins can:
- Retry failed transactions — the system creates a new batch containing only the failed transactions and reprocesses them
- Place individual transactions on hold for manual review and data correction
Transaction On Hold
When a transaction needs corrections (wrong account number, invalid routing, etc.):
- Admin places it on hold —
banking_status = ON_HOLD, approvals reset - Admin updates the transaction fields (
update-transaction-fieldsendpoint) - Admin releases the hold — returns to
PENDING_FIRST_APPROVAL - Normal two-approval flow resumes
Encryption
All banking account details are encrypted at rest using Fernet symmetric encryption (ACH_KEY environment variable). The raw encrypted bytes are stored in _storage columns; decrypted values are accessed via Python property synonyms.
| What | Model | How to Access |
|---|---|---|
| Routing number | MasterACHForm | form.routing_num (decrypts on read) |
| Account number | MasterACHForm | form.account_num (decrypts on read) |
| SSN / Tax ID | MasterACHForm | form.ssn_tid (decrypts on read) |
| IBAN | MasterACHForm | form.iban_num (decrypts on read) |
| SWIFT code | MasterACHForm | form.swift_num (decrypts on read) |
| E-Check routing | ECheckAuthorization | auth.display_routing_number() |
| E-Check account | ECheckAuthorization | auth.display_account_number() |
| Company TIN | AdminCompany | company.decrypt_string() |
Never log decrypted values. The redaction middleware in FastAPI and Flask strips known patterns, but the safest practice is to never pass raw account or routing numbers to a logging call.
Audit Trail
Every banking action is logged in BankingActionLog with:
- Who performed it (
admin_id) - What action (
BankingActionTypeEnum) - What entity (batch or transaction)
- Metadata (counts, amounts, delivery paths, error messages)
Key actions logged: batch creation, first/second approval, SFTP upload, download, transaction holds/releases, Huntington person/account creation.
Critical Business Rules
- NACHA download and SFTP upload are asymmetrically enforced — once a batch is downloaded, SFTP upload is blocked; a previously SFTP-uploaded batch can still be downloaded
- Two different Admins must approve —
first_approver_idandsecond_approver_idcannot be the same person - ECheckAuthorization records are immutable — they cannot be updated after creation, by design
- NACHA effective date is always the next US business day — weekends and federal holidays are skipped automatically
- Routing number validation is enforced (9-digit checksum) unless the
routing-number-verification-disabledfeature flag is set - Huntington batch failures are per-transaction — one failure does not cancel the rest of the batch
- CIP must be
CLEAREDbefore any Huntington payment can be processed - ACH form must be admin-approved before banking details can be used for payment — pending updates are not used
Gotchas for Developers
ACH_KEY must be set or nothing works. Both Flask and FastAPI will start without it, but any operation that reads or writes banking details will fail. Flask logs a warning at startup; FastAPI will throw a decryption error at runtime.
The routing_num property decrypts on every access. Calling form.routing_num in a loop is expensive. Decrypt once, store in a local variable.
NACHA file format has no tolerance for errors. The carta-ach library handles formatting, but inputs must be sanitized first — bank names and company names are truncated and stripped of special characters before being written to the file. A company name with a slash or ampersand can corrupt a field boundary.
Never hardcode the NACHA effective entry date. The system computes the next US business day at file generation time. If you’re testing, be aware the date in the file will be tomorrow (or next Monday if today is Friday).
Huntington sandbox vs production credentials are completely different. Always verify which environment the HUNTINGTON_* config variables point to before testing payment flows.
form_updates is not the current form. When a surrogate updates their banking details, the new values sit in form_updates (encrypted JSON) until an Admin approves them. If you read form.routing_num, you get the last approved value — not the pending one. Always check form.needs_update first if you need to know whether a pending change exists.
Open Questions
1. Huntington webhook event inventory
Huntington sends inbound webhooks that update transfer and CIP status. The HuntingtonWebhookEvent model records them, but the full list of event types and their effect on LedgerTransaction statuses has not been catalogued.
Note on
payable_json/ integrated payables: The “integrated payables” flow is a dead feature that was never fully removed. Thepayable_jsonfield onLedgerTransactionis still present in the database and may have data, but the integrated payables workflow is no longer active. Ignore references to it in the code.