ACH Processing

The ach-handler Lambda consumes the site-payments Kinesis stream and processes ACH settlement callbacks for subscription collection attempts. It is the mechanism by which subscription statuses transition to COMPLETED, ERROR, or REFUNDED after an ACH debit is submitted by the payments service.

Kinesis Event Filtering

The ach-handler uses a Kinesis filter so that only subscription-related payment events are delivered. The filter pattern is applied at the event source mapping level in Terraform:

{
  "data": {
    "type": [
      "SUBSCRIPTION_COMPLETED",
      "SUBSCRIPTION_RETURNED",
      "SUBSCRIPTION_REFUNDED",
      "SUBSCRIPTION_CHARGED_BACK"
    ]
  }
}

Events for other payment types (e.g. float disbursements) pass through the same site-payments stream but are dropped by the filter before the Lambda is invoked. Only the four types above reach ProcessPaymentEvent.

Kinesis Event Type payment.Status Description

SUBSCRIPTION_COMPLETED

COMPLETED

ACH debit for a subscription collection has settled successfully.

SUBSCRIPTION_RETURNED

FAILED

ACH debit was returned by the bank (e.g. insufficient funds, authorization revoked). A return code is present on the payment record.

SUBSCRIPTION_REFUNDED

REFUNDED

A previously collected subscription payment was refunded.

SUBSCRIPTION_CHARGED_BACK

CHARGED_BACK

A chargeback was filed against a subscription debit. The user is banned.

ACH Callback Flow

After the Kinesis filter delivers a record, the handler decodes the payment event and dispatches on payment.Status.

COMPLETED

SUBSCRIPTION_COMPLETED received
        │
        ▼
Look up user via User Service
        │
        ├── Not found ──► log warning, skip (no-op)
        │
        ▼
Look up active account from Transaction Service
  (resolves institution ID for Datadog metric tags)
        │
        ▼
Look up subscription by payment.SubscriptionId
        │
        ├── Not found ──► log warning, skip (no-op)
        │
        ▼
Update subscription record in DynamoDB
  billing_status  ──► COMPLETED
  updated_event   ──► "payment-completed"
  completion_date ──► now (UTC)
  last_run_date   ──► now (UTC)
  usio_error      ──► "" (cleared)
  mask            ──► payment.MaskedAccountNumber
        │
        ▼
Emit Segment event (topic: "subscription-ach-digital-receipt")
  receipt payload includes: frequency, amount, start_date, end_date,
  payment_type ("ACH"), mask, membership_tier, days_past_due,
  billing_group, billing_week, firebase_install_id
        │
        ▼
Emit AppsFlyer event ("ad_subscription_payment_completed")
  payload: revenue, currency ("USD"), membership_tier,
  payment_type ("ACH"), subscription_date, process
        │
        ▼
Emit Datadog metric (subscriptions.collections.attempt)
  tags: method:ACH, status:completed, institution, process,
        initial_date, payment_submit_date, payment_completed_date

FAILED (ACH Return)

SUBSCRIPTION_RETURNED received
        │
        ▼
Look up user via User Service
        │
        ├── Not found ──► log warning, skip (no-op)
        │
        ▼
Look up subscription by payment.SubscriptionId
        │
        ├── Not found ──► log warning, skip (no-op)
        │
        ▼
Update subscription record in DynamoDB
  billing_status  ──► ERROR
  updated_event   ──► "payment-failed"
  last_run_date   ──► now (UTC)
  completion_date ──► zero value (cleared)
  usio_error      ──► payment.ReturnCode (e.g. "R01", "R05")
        │
        ▼
Is return code bannable?
        │
        ├── Yes ──► Ban user via User Service
        │           Add note via Admin Service
        │           ("User was banned for returning a payment")
        │
        ▼
Emit Iterable event ("user_subscription_payment_failed")
  payload: frequency, amount, membership_tier, due_date,
  payment_type ("ACH"), mask, reason ("ACH_FAILED"),
  billing_group, billing_week
        │
        ▼
Emit AppsFlyer event ("ad_subscription_payment_failed")
  payload: membership_tier, payment_type ("ACH"),
  subscription_date, reason ("ACH_FAILED"), return_code, process
        │
        ▼
Emit Datadog metric (subscriptions.collections.attempt)
  tags: method:ACH, status:returned, institution, process,
        initial_date, payment_submit_date, payment_completed_date,
        return_code, return_info

REFUNDED

SUBSCRIPTION_REFUNDED received
        │
        ▼
Look up subscription by payment.SubscriptionId
        │
        ├── Not found ──► log warning, skip (no-op)
        │
        ▼
Update subscription record in DynamoDB
  billing_status  ──► REFUNDED
  updated_event   ──► "payment-refunded"
  last_run_date   ──► now (UTC)
        │
        ▼
Emit Datadog metric (subscriptions.collections.attempt)
  tags: method:ACH, status:refunded, institution, process,
        initial_date, payment_submit_date, payment_completed_date
No user-facing notifications (Segment, Iterable, AppsFlyer) are sent for the REFUNDED path. The status update is the only side effect.

CHARGED_BACK

SUBSCRIPTION_CHARGED_BACK received
        │
        ▼
Look up subscription by payment.SubscriptionId
        │
        ├── Not found ──► log warning, skip (no-op)
        │
        ▼
Update subscription record in DynamoDB
  billing_status  ──► ERROR
  updated_event   ──► "CHARGED_BACK"
  last_run_date   ──► now (UTC)
        │
        ▼
Ban user via User Service
  (user ID taken from subscription.UserID — no separate User Service lookup)
  description: "user banned for returned payment or chargeback"
  source:       system
        │
        ▼
Add note via Admin Service
  ("User was banned for returning a payment")
        │
        ▼
Emit Datadog metric (subscriptions.collections.attempt)
  tags: method:ACH, status:chargeback, institution, process,
        initial_date, payment_submit_date, payment_completed_date,
        return_code, return_info
The chargeback path does not send Iterable or AppsFlyer notifications.

Status Transition Table

Kinesis Event Type payment.Status billing_status Transition Notes

SUBSCRIPTION_COMPLETED

COMPLETED

ACHSENTCOMPLETED

Segment receipt event and AppsFlyer completion event emitted. completion_date and last_run_date set to now.

SUBSCRIPTION_RETURNED

FAILED

ACHSENTERROR

usio_error set to ACH return code. Iterable and AppsFlyer failure events emitted. User banned if return code is in the bannable set.

SUBSCRIPTION_REFUNDED

REFUNDED

COMPLETEDREFUNDED

No notifications emitted. Status-only update.

SUBSCRIPTION_CHARGED_BACK

CHARGED_BACK

ACHSENTERROR

User banned unconditionally. Admin note added. No user-facing notifications.

Notification Emission

Channel Event Name Triggered By Purpose

Segment

subscription-ach-digital-receipt

SUBSCRIPTION_COMPLETED

Digital receipt sent to the user confirming ACH collection. Payload includes amount, payment type, account mask, days past due, and billing group.

Iterable

user_subscription_payment_failed

SUBSCRIPTION_RETURNED

In-app or email notification informing the user their subscription payment failed via ACH. Reason is ACH_FAILED.

AppsFlyer

ad_subscription_payment_completed

SUBSCRIPTION_COMPLETED

Mobile attribution event for a successful ACH collection. Includes revenue, currency, membership tier, payment type, and process.

AppsFlyer

ad_subscription_payment_failed

SUBSCRIPTION_RETURNED

Mobile attribution event for a failed ACH collection. Includes membership tier, payment type, reason, and ACH return code.

AppsFlyer events are dispatched per mobile platform (iOS and Android clients are configured separately). If the user has no appsflyer_id set, the event is silently dropped.

Bannable ACH Return Codes

When an ACH debit is returned with one of the following codes, the user is automatically banned via the User Service. Any CHARGED_BACK event also triggers a ban unconditionally.

Return Code JPMorgan Alias Meaning

R05

AG03

Unauthorized debit to consumer account using corporate SEC code

R07

Authorization revoked by customer

R08

DS02

Payment stopped

R10

AG01

Customer advises not authorized

R11

Check truncation entry returned

R29

AG01

Corporate customer advises not authorized

R51

Item is ineligible, notice not provided, signature not genuine, or item altered

Non-bannable return codes (e.g. R01 — insufficient funds, R02 — account closed) set billing_status to ERROR and record the return code in usio_error without triggering a ban. The subscription re-enters the normal retry cycle via the collections-job.

Infrastructure

The ach-handler Lambda is configured with:

  • Trigger: Kinesis stream site-payments with the four-type filter above

  • Batch size: controlled by var.kinesis_stream_batch_size

  • Parallelization factor: controlled by var.user_kinesis_stream_parallelization_factor

  • Bisect on error: enabled — a batch with one failing record is split to isolate it

  • Partial batch reporting: ReportBatchItemFailures — successfully processed records are not retried

  • Timeout: 840 seconds

  • DynamoDB access: billing-activity table (GetItem, PutItem, UpdateItem, Query, BatchWriteItem) and billing-activity-history table

  • Secrets: Segment write key, Iterable API key, AppsFlyer S2S key (loaded from Secrets Manager at cold start)