Skip to content

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:

PathUsed ForHandled By
NACHA / M&T BankNon-California casesFlask — generates NACHA file, uploads via SFTP to M&T Bank
Huntington BankCalifornia Intended ParentsFastAPI — 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:

  1. State is CA — the IP’s state must match HUNTINGTON_STATES = ["CA", "CALIFORNIA"]
  2. Country is US — the IP’s country must be in the US_COUNTRY_VARIATIONS list

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 account
  • WT — wire transfer (domestic or international)
  • CHK — physical check
  • AT — account transfer (internal)

Banking details (all encrypted at rest — see Encryption):

  • Routing number (ABA 9-digit)
  • Account number
  • Account type (CHECKING or SAVINGS)
  • 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 = NULL
Admin reviews →
Admin approves → form_approved = True
Party updates existing form →
Changes stored in form_updates (pending queue), NOT applied to main fields yet
Admin reviews update →
Admin approves → form_updates applied to main fields, cleared

This 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: MasterACHForm in seedtrust_flask/seedtrust/models/ach.py FastAPI 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
end

Step 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 DISBURSEMENT or SCHEDULED PAYMENT
  • amount > 0
  • bank_account_type matches 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 CodePPD

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_sftp rejects any further SFTP attempt. However, track_nacha_download does not check whether the batch was already SFTP-uploaded — a previously uploaded batch can still be downloaded. A row-level lock in upload_nacha_to_sftp prevents 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 created

The 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 cleared
  • CLEARED — CIP passed, account fully active
  • ERROR — 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 next
HuntingtonBatch updated with final status →
Admin polls / streams batch progress

Unlike 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 flight
  • COMPLETED — all processed (may include failures)
  • PARTIAL_SUCCESS — mix of successes and failures
  • FAILED — 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 approves
PENDING_SECOND_APPROVAL
↓ Second Admin approves
PENDING_TRANSFER (ready for batch processing)
↓ Batch processed
IN_PROCESS → SENT_TO_BANK → FINALIZED

Hold & 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:

  1. IP provides name, banking details (or uses their ACH form on file), and amount
  2. System creates an ECheckAuthorization record — this record is immutable after creation (enforced by a SQLAlchemy event listener)
  3. A LedgerTransaction of type E-CHECK FUNDING is created with status = PAID
  4. 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: ECheckAuthorization in banking.py, e-check view in general_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:

  1. Receive the notification from M&T externally
  2. Manually update the affected LedgerTransaction to RETURN status
  3. Adjust Case.acct_balance if 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.):

  1. Admin places it on hold — banking_status = ON_HOLD, approvals reset
  2. Admin updates the transaction fields (update-transaction-fields endpoint)
  3. Admin releases the hold — returns to PENDING_FIRST_APPROVAL
  4. 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.

WhatModelHow to Access
Routing numberMasterACHFormform.routing_num (decrypts on read)
Account numberMasterACHFormform.account_num (decrypts on read)
SSN / Tax IDMasterACHFormform.ssn_tid (decrypts on read)
IBANMasterACHFormform.iban_num (decrypts on read)
SWIFT codeMasterACHFormform.swift_num (decrypts on read)
E-Check routingECheckAuthorizationauth.display_routing_number()
E-Check accountECheckAuthorizationauth.display_account_number()
Company TINAdminCompanycompany.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

  1. 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
  2. Two different Admins must approvefirst_approver_id and second_approver_id cannot be the same person
  3. ECheckAuthorization records are immutable — they cannot be updated after creation, by design
  4. NACHA effective date is always the next US business day — weekends and federal holidays are skipped automatically
  5. Routing number validation is enforced (9-digit checksum) unless the routing-number-verification-disabled feature flag is set
  6. Huntington batch failures are per-transaction — one failure does not cancel the rest of the batch
  7. CIP must be CLEARED before any Huntington payment can be processed
  8. 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. The payable_json field on LedgerTransaction is 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.