Event Flows

The Subscription Service participates in the FloatMe event-driven architecture in four ways: it publishes subscription lifecycle events to a Kinesis stream via a DynamoDB Stream feeder, it consumes scheduled EventBridge rules to trigger collection and notification runs, it reacts to real-time income and balance signals via SQS webhook workers, and it consumes Kinesis streams from the User Service and Payments Service for membership and ACH callbacks.

Overview

                     Subscription Service
                              │
      ┌───────────────────────┼─────────────────────────┐
      │                       │                         │
      ▼                       ▼                         ▼
 Publishes               Consumes                  Consumes
 Kinesis events      scheduled EventBridge      SQS queues and
 (outbound)          rules (inbound)            Kinesis streams
      │                       │                     (inbound)
      ▼                       ▼                         │
{env}-subscriptions   collections-job             ┌─────┴──────┐
Kinesis stream        notifier-scheduler          │            │
  subscription-       invoked on schedule    Webhook      Kinesis
  updated events      to queue work          Workers      Consumers
  consumed by         to SQS                (income,     (memberships,
  downstream                                balance)     ach-handler)
  services

Events Published

subscription-updated

Published to the site-subscriptions Kinesis stream by the kinesis-feeder Lambda. The feeder is triggered by a DynamoDB Stream on the billing-activity table and forwards every INSERT or MODIFY change to Kinesis as a wrapped FMSDK event. REMOVE events are silently skipped.

Field Value

Kinesis stream

site-subscriptions

Event source

SubscriptionService

Event version

V1

Event type

subscription-updated (default) or the value stored in the updated_event DynamoDB attribute when set (e.g. PENDING_CANCELLATION)

Payload

Full SubscriptionResponse object derived from the DynamoDB new image: subscription_id, user_id, subscription_date, subscription_status, subscription_amount, tier_name, process, transaction_id, last_run_date, completion_date, updated_event, and related fields

Trigger

Every INSERT or MODIFY record on the billing-activity DynamoDB Stream

The event type is overridden when a membership lifecycle event (such as CANCEL) writes a specific updated_event value into the DynamoDB record before the feeder sees the stream record. For example, a CANCEL membership event sets updated_event = "PENDING_CANCELLATION" and the feeder emits that as the Kinesis event type instead of the default.

Downstream consumers (e.g. analytics pipelines, other services) subscribe to this Kinesis stream independently. The Subscription Service does not manage those subscriptions.

Consumed Kinesis Streams

site-user-service-users (Memberships)

Consumed by the memberships Lambda. The stream carries FMSDK-wrapped membership lifecycle events published by the User Service. The processor unmarshals each record into a Membership struct and routes on event_type.

Event Type Action

CANCEL

Locks the subscription; updates SCHEDULED and PAUSED subscriptions to carry updated_event = "PENDING_CANCELLATION".

SUB_PAUSED

Applies pause logic — advances the subscription date by the pause duration months.

RETRACT

Retracts a previously applied membership change.

UNPAUSE

Unpauses the subscription and schedules the next billing date.

UNPAUSE_CHARGE

Unpauses and immediately triggers a charge attempt.

CLOSEACCOUNT

Handles account closure — cancels any open subscriptions.

Any event type not in the list above is logged as unsupported and the record is skipped.

site-payments (ACH Handler)

Consumed by the ach-handler Lambda. The stream carries payment outcome events from the Payments Service. The handler filters for subscription-related ACH debit results and updates billing-activity accordingly, then emits notifications via Segment, Iterable, and AppsFlyer.

See ACH Processing for the full event-type-to-status mapping.

Scheduled Collections

Three CloudWatch EventBridge rules trigger the collections-job Lambda on weekdays. Each rule injects a process field into the event detail that tells the job which collection run to perform. The job queries DynamoDB for qualifying subscriptions and enqueues them to the appropriate SQS queue for the collections-worker Lambda to process.

EventBridge Rules

Rule Schedule (UTC) Injected Detail Effect

subscription-collections-scheduled

8:00 AM Mon–Fri

{"process": "scheduled"}

Queries for subscriptions in SCHEDULED status whose billing date falls on or before today. Enqueues to site-subscription-service-collections-scheduled. Enabled in prod only.

subscription-collections-retry

7:00 AM Mon–Fri

{"process": "retry"}

Queries for subscriptions in ERROR status that are past due. Enqueues to site-subscription-service-collections-retry. Enabled in prod only.

subscription-collections-pause

10:00 PM Mon–Fri UTC (16:00 CST / 17:00 CDT)

{"process": "pause"}

Queries for subscriptions in PAUSED status that are ready for collection. Enqueues to site-subscription-service-collections-pause. Always enabled.

Scheduler → Worker Flow

The collections-job Lambda supports two trigger types — CloudWatch (initial schedule fire) and SQS (pagination self-invocation when results exceed one page). The pattern is identical for all three process types.

EventBridge rule fires (scheduled / retry / pause)
        │
        ▼
collections-job Lambda invoked via CloudWatch event
  detail.process = "scheduled" | "retry" | "pause"
        │
        ▼
Query DynamoDB (billing-activity) for qualifying subscriptions
  scheduled: status=SCHEDULED, subscription_date <= today
  retry:     status=ERROR, subscription_date passed
  pause:     status=PAUSED, ready for collection
        │
        ▼
For each page of results (up to PAGES_PER_INVOKE):
  Enqueue subscriptions to the process-specific SQS queue
        │              │
        │              └─ If more pages remain:
        │                   Enqueue pagination message to
        │                   the process-specific page queue
        │                   (collections-job re-invoked via SQS)
        ▼
collections-worker Lambda dequeues from:
  {env}-subscription-service-collections-scheduled
  {env}-subscription-service-collections-retry
  {env}-subscription-service-collections-pause
        │
        ▼
Attempt collection for each subscription
(see xref:collections.adoc[Collections] for decision logic)

Webhook Worker (Income Detection)

The webhook-worker Lambda reacts to income detection events from the Insight Service. When income is deposited for a user, the worker checks whether that user has a subscription in ERROR status and, if so, attempts immediate collection via pinless debit or ACH.

Event Path

Insight Service detects income transaction for a user
  (filter: detail-type=income_txn, source=insight-service.income,
   amount < -$75.00 i.e. < -7500 cents)
        │
        ▼
EventBridge rule: {env}-subscription-service-income-detected
        │
        ▼
SQS: {env}-subscription-service-income-event-tap
  (visibility timeout: 900s, max receive count: 1, DLQ present)
        │
        ▼
webhook-worker Lambda invoked (batch size 10, max concurrency 5)
  Event payload: CloudWatch envelope wrapping {user_id, amount}
        │
        ▼
GrowthBook flag subscriptions.webhook.balance.authoritative?
  true  ──► skip (balance worker is authoritative for this user)
  false ──► proceed with income-triggered collection
        │
        ▼
Acquire DynamoDB distributed lock for user
        │
        ▼
Query DynamoDB for subscriptions in ERROR status
  (within the last 2 months)
        │
        ├── no collectable subs ──► release lock, return
        │
        ▼
Get user from User Service
  inactive user ──► set subs to INACTIVE, release lock, return
        │
        ▼
For each collectable subscription:
  Debit card valid? ──► attempt pinless debit
  Debit card invalid? ──► run ACH checks:
    income event amount < ACH_INCOME_EVENT_THRESHOLD (-$100.00)?
      ──► skip (income not high enough)
    ACH attempts in last month >= ACH_ATTEMPTS_PER_MONTH (3)?
      ──► skip (too many ACH attempts)
    balance < ACH_BALANCE_THRESHOLD ($200.00)?
      ──► skip (balance too low)
    user blocklisted by returned payments?
      ──► skip
    ──► attempt ACH debit
        │
        ▼
Update subscription status in DynamoDB
  COMPLETED (pinless success) | ACHSENT (ACH sent) | ERROR (failure)
Emit Datadog metric: sub_collect

GrowthBook Flags

Flag Effect

subscriptions.webhook.balance.authoritative

Routes a user to the balance webhook worker path. When true, webhook-worker skips the user entirely; when false (default), webhook-worker handles the user.

subscriptions.payments.provider

When set to "usio", overrides the default payment provider for ACH submissions to use the USIO provider.

The income threshold filter on the EventBridge rule (amount < -$75.00) is separate from the per-user ACH income check at collection time (ACH_INCOME_EVENT_THRESHOLD = -$100.00). Both must pass for an ACH collection to proceed.

Balance Webhook Worker

The webhook-balance-worker Lambda reacts to account balance update events from the Transaction Service. It applies a richer set of balance and collection rules before attempting collection, making it suitable for users whose income detection path has been migrated to balance-based triggering.

Event Path

Transaction Service publishes account balance update
  (filter: detail-type=new_account, source=txn-service.feeder,
   is_main=true,
   available >= 0 OR current >= 0 OR calc_available >= 0)
        │
        ▼
EventBridge rule: {env}-subscription-service-balance-detected
        │
        ▼
SQS: {env}-subscription-service-balance-event-tap
  (visibility timeout: 900s, max receive count: 1, DLQ present)
        │
        ▼
webhook-balance-worker Lambda invoked (batch size 10, max concurrency 5)
  Event payload: CloudWatch envelope wrapping txn-service Account object
        │
        ▼
GrowthBook flag subscriptions.webhook.balance.authoritative?
  false ──► skip (user is on the income webhook path)
  true  ──► proceed with balance-triggered collection
        │
        ▼
Acquire DynamoDB distributed lock for user
        │
        ▼
Populate collection state from account update:
  - available balance, current balance, calc_available balance
  - institution ID
  - user status (short-circuit if inactive)
  - subscriptions in ERROR status (within last 2 months)
  - subscription ACH history per subscription
  - debit card validity
  - blocklist status
        │
        ├── no collectable subs ──► release lock, return
        │
        ▼
For each collectable subscription, evaluate collection rules:
  ruleUserIsActive
  ruleDefaultPinlessIfNotSet
  ruleAchIfInvalidDebitCard
  rulePinlessIfBlocklisted
  ruleDontRetryAchIfBlocklisted
  ruleHasValidDebitCard
  rulePinlessBalanceCheck   (threshold from GB flag)
  ruleAchBalanceCheck       (threshold from GB flag)
  ruleACHLimitNotExceeded
        │
        ├── rules fail ──► emit sub_check metric with reason, skip
        │
        ▼
Attempt collection (pinless debit or ACH)
  error code 51 (NSF) + achIf51 flag enabled?
    ──► fallback to ACH attempt
        │
        ▼
Update subscription status in DynamoDB
Emit notifications: Segment, AppsFlyer (iOS + Android)
Emit Datadog metric: sub_collect

GrowthBook Flags

Flag Effect

subscriptions.webhook.balance.authoritative

Routes a user to the balance worker path. When true, balance-worker proceeds; when false (default), balance-worker skips the user.

subscriptions.webhook.balance.threshold

Pinless debit balance threshold (in cents) for standard-tier subscriptions. Defaults to MaxFloat64 (no effective cap) when not set.

subscriptions.webhook.balance.threshold_premium

Pinless debit balance threshold (in cents) for premium-tier subscriptions. Defaults to MaxFloat64 when not set.

subscriptions.webhook.balance.ach_threshold

ACH balance threshold (in cents). Defaults to MaxFloat64 when not set.

subscriptions.webhook.balance.use_calculated_balance

When true, uses the calc_available balance field from the account update rather than the available field for threshold comparisons.

subscriptions.webhook.balance.ach_if_51

When true, a pinless debit that returns error code 51 (NSF) will automatically retry the same subscription using ACH.

subscriptions.payments.provider

When set to "usio", routes ACH payments to the USIO provider instead of the default JPM provider.

Notifier

The three-day pre-billing notifier runs as two cooperating Lambdas: notifier-scheduler and notifier-worker.

Flow

EventBridge rule fires (daily, 12:00 UTC — prod only)
  rule: pre_subscription_notifier
  detail: {"status": "SCHEDULED"}
        │
        ▼
notifier-scheduler Lambda invoked via CloudWatch
  Reads target date = today + 4 calendar days
        │
        ▼
Query DynamoDB (billing-activity) via GSI
  status = SCHEDULED, subscription_date in [target_date, target_date+1)
  Up to PAGES_PER_INVOKE pages per invocation
        │
        ▼
For each page of results:
  Wrap each subscription in an FMSDK event
    event_type = "three_day_notification"
  Batch-send to SQS:
    {env}-subscription-service-pre-subscription-notifier-worker
        │
        │  If more pages remain:
        └─► Enqueue pagination cursor to:
              {env}-subscription-service-pre-subscription-notifier-scheduler
              (notifier-scheduler re-invoked via SQS to continue paging)
        │
        ▼
notifier-worker Lambda invoked (max concurrency 5)
  Dequeues from pre-subscription-notifier-worker
        │
        ▼
For each subscription:
  Skip if updated_event = PENDING_CANCELLATION
  Get user — skip if inactive
  Skip non-test-domain emails in test environments
        │
        ▼
Send Iterable track event
  event_name: "three_day_notification"
  properties: membership_tier, billing_date, amount, account_number
        │
        ▼
Prenote user (GrowthBook-gated: subscriptions.prenotes defaults true)
  User < 30 days old:
    submit prenote to payments service
  User >= 30 days old:
    require a JPM COMPLETED payment in last 40 days
    require active Plaid item
    if eligible: submit prenote to payments service
      provider = "usio" (GB flag subscriptions.payments.provider)?
        ──► SubmitUsioPrenote
      default:
        ──► SubmitJpmPrenote

GrowthBook Flags

Flag Effect

subscriptions.prenotes

Master switch for prenote submissions in the notifier worker. Defaults to true. When false, prenotes are skipped for all users regardless of eligibility.

subscriptions.payments.provider

When set to "usio", routes prenote requests to the USIO provider. Default routes to JPM.

  • Collections — Full decision logic for scheduled, retry, pause, and webhook collection paths

  • ACH Processing — Kinesis-based ACH payment outcome callbacks and status transitions

  • Memberships — Membership Kinesis consumer and state transitions

  • Notifications — Three-day notifier scheduler and worker in detail

  • Architecture — System context diagram showing all inbound and outbound event paths

  • Infrastructure — SQS queues, EventBridge rules, and Lambda configuration