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 |
|---|---|---|
|
|
ACH debit for a subscription collection has settled successfully. |
|
|
ACH debit was returned by the bank (e.g. insufficient funds, authorization revoked). A return code is present on the payment record. |
|
|
A previously collected subscription payment was refunded. |
|
|
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 |
|---|---|---|---|
|
|
|
Segment receipt event and AppsFlyer completion event emitted. |
|
|
|
|
|
|
|
No notifications emitted. Status-only update. |
|
|
|
User banned unconditionally. Admin note added. No user-facing notifications. |
Notification Emission
| Channel | Event Name | Triggered By | Purpose |
|---|---|---|---|
Segment |
|
|
Digital receipt sent to the user confirming ACH collection. Payload includes amount, payment type, account mask, days past due, and billing group. |
Iterable |
|
|
In-app or email notification informing the user their subscription payment failed via ACH. Reason is |
AppsFlyer |
|
|
Mobile attribution event for a successful ACH collection. Includes revenue, currency, membership tier, payment type, and process. |
AppsFlyer |
|
|
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 |
|---|---|---|
|
|
Unauthorized debit to consumer account using corporate SEC code |
|
— |
Authorization revoked by customer |
|
|
Payment stopped |
|
|
Customer advises not authorized |
|
— |
Check truncation entry returned |
|
|
Corporate customer advises not authorized |
|
— |
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-paymentswith 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-activitytable (GetItem, PutItem, UpdateItem, Query, BatchWriteItem) andbilling-activity-historytable -
Secrets: Segment write key, Iterable API key, AppsFlyer S2S key (loaded from Secrets Manager at cold start)
Related Pages
-
Subscription Lifecycle — Full billing_status state machine and transition descriptions
-
Collections Engine — How ACH debits are submitted before the callback arrives
-
DynamoDB Tables — billing-activity table schema and access patterns
-
Event Flows — Published events and consumed streams overview
-
Notifications — Three-day pre-billing notifier and other notification channels