Subscription Lifecycle

A subscription is a recurring monthly billing record tied to a FloatMe user’s membership. Every subscription progresses through a set of statuses from the moment it is created until it is either collected, waived, or cancelled. This page covers the end-to-end lifecycle: activation, collection, manual payment, upgrade/downgrade, ban, and reactivation.

Subscription Statuses

Status Transitions

SCHEDULED   ──[Collections worker: pinless debit success]──────────► COMPLETED
SCHEDULED   ──[Collections worker: ACH submitted]──────────────────► ACHSENT
SCHEDULED   ──[Collections worker: balance check fails / error]────► ERROR
SCHEDULED   ──[Manual pay: pinless success]─────────────────────────► COMPLETED
SCHEDULED   ──[Manual pay: pinless fails]───────────────────────────► SCHEDULED (status unchanged)
SCHEDULED   ──[Pause event from memberships]────────────────────────► PAUSED
SCHEDULED   ──[Ban: POST /ban]─────────────────────────────────────► CANCELLED
SCHEDULED   ──[User not active at collection time]─────────────────► CANCELLED
SCHEDULED   ──[Employee waiver in collections worker]──────────────► WAIVED

ACHSENT     ──[ACH settles: COMPLETED event on payments Kinesis]───► COMPLETED
ACHSENT     ──[ACH returned: FAILED event on payments Kinesis]─────► ERROR
ACHSENT     ──[Chargeback: CHARGED_BACK event on payments Kinesis]─► ERROR  (+ ban user)
ACHSENT     ──[Refund: REFUNDED event on payments Kinesis]─────────► REFUNDED

ERROR       ──[Retry worker: ACH submitted]─────────────────────────► ACHSENT
ERROR       ──[Retry worker: ACH limit exceeded / blocklisted]──────► (skip, stays ERROR)
ERROR       ──[Webhook balance worker: pinless success]─────────────► COMPLETED
ERROR       ──[Webhook balance worker: pinless fails]────────────────► ERROR (status unchanged)
ERROR       ──[Ban: POST /ban]──────────────────────────────────────► CANCELLED
ERROR       ──[Batch worker: user not active]───────────────────────► INACTIVE

PAUSED      ──[Collections pause worker: skip cycle]────────────────► PAUSED_SKIPPED
              (next PAUSED record scheduled; if last pause month → SCHEDULED)

PAUSED_SKIPPED (terminal for that record — a new PAUSED or SCHEDULED record is created)

COMPLETED   (terminal)

CANCELLED   (terminal)

WAIVED      (terminal — employee-only; next SCHEDULED record is created)

INACTIVE    (terminal — user account not active at time of collection)

STALE       (computed read-only view: returned by GET /subscriptions/current when the
             most recent record is very old and no non-stale statuses are found)

Status Reference

Status Description

SCHEDULED

Initial status assigned when a subscription is first created (on activation or after a previous cycle completes). The subscription is awaiting its billing date.

ACHSENT

An ACH debit has been submitted to the payment processor. The subscription remains in this state until the ACH Handler Lambda receives a settlement event from the site-payments Kinesis stream.

COMPLETED

The subscription has been successfully collected. Set by the collections worker on a successful pinless debit, by the ACH Handler on a successful ACH settlement, or by the manual pay endpoint.

ERROR

A collection attempt was made but failed — insufficient balance, a returned ACH, a payment provider error, or a chargeback. The subscription will be retried by the scheduled retry worker or by the webhook balance worker on a qualifying balance event.

WAIVED

The subscription was waived for a FloatMe employee. The collections worker detects the IsEmployee tag on the user and skips billing, writing WAIVED and scheduling the next subscription.

CANCELLED

The subscription was cancelled because the user’s membership was cancelled (via the memberships Kinesis consumer), because the user was banned, or because the user was found to be inactive at collection time. No further collection attempts are made.

PAUSED

The user’s membership is paused. The subscription record carries a pause_duration_months counter. On each billing cycle the collections pause worker skips the charge, writes PAUSED_SKIPPED for the current record, and creates the next PAUSED record (decrementing the counter). When the counter reaches 0 the next record is SCHEDULED with updated_event = pause-pending-resume.

PAUSED_SKIPPED

Terminal status for an individual record within a pause window. Written by the pause worker to record that a billing cycle was skipped. The next record (PAUSED or SCHEDULED) is created atomically.

INACTIVE

Set when the batch worker detects that the user account is not ACTIVE at the time a collection is attempted. The subscription is not retried.

REFUNDED

The subscription payment was refunded after successful ACH collection. Set by the ACH Handler on a SUBSCRIPTION_REFUNDED event from the site-payments Kinesis stream. Terminal status.

STALE

A computed, read-only status returned by GET /{user_id}/subscriptions/current. Never written to DynamoDB. Indicates that all recent subscription records are older than the configured OldSubscriptionDays threshold, or that no non-stale status (SCHEDULED, PAUSED, PAUSED_SKIPPED, CANCELLED) is present.

Activation

When a new user joins FloatMe and enables their membership, the mobile app calls POST /{user_id}/subscriptions/activate. This is a lightweight idempotent endpoint — if the user already has a SCHEDULED record it returns 200 OK without creating a duplicate.

POST /{user_id}/subscriptions/activate
        │
        ▼
List all subscriptions for user (DynamoDB)
        │
        ├── Any record with status SCHEDULED? ──Yes──► 200 OK (already active)
        │
        │ No
        ▼
Calculate next billing date
  Friday billing enabled? ──Yes──► next Friday ≥ today + 6 days (grace period)
                          ──No───► today + 9 days (seven-day trial window)
        │
        ▼
Create subscription record (DynamoDB)
  billing_status:  SCHEDULED
  billing_amount:  $4.99 (AmountLite)
  billing_date:    next billing date
  billing_period:  MM/YYYY of next billing date
  term:            MONTHLY
  subscription_id: new UUID
  created_date:    now
        │
        ▼
Return 200 OK
Activation does not charge the user immediately. The first charge occurs when the scheduled billing date arrives and the collections-job picks up the SCHEDULED record.

Renewal

Every time a subscription record is processed, the collections worker writes the next record for that user (GetNextSubscription). The price of that next record is resolved from the GrowthBook global.tiers.config feature using the tier and version on the user’s membership record in the user-service, not the tier name carried on the previous subscription row.

Subscription processed (COMPLETED, WAIVED, PAUSED_SKIPPED, ...)
        │
        ▼
Get membership from user-service (tier + tier_version)
        │
        ├── error / not found ──────────────┐
        │                                   │
        ▼                                   │
Resolve tier + version in global.tiers.config
        │                                   │
        ├── tier missing ───────────────────┤
        ├── version missing ────────────────┤
        ├── monthly price <= 0 ─────────────┤
        │                                   │
        │ resolved                          │ fallback
        ▼                                   ▼
billing_amount:    tier.Price.Monthly   billing_amount:    previous billing_amount
receipt_tier_name: <tier>:<version>     receipt_tier_name: previous receipt_tier_name

Both outcomes are logged: a fallback logs a warning naming the tier and version that failed to resolve, and a successful lookup that changes the price logs the previous and next amount.

Because the price is resolved on every renewal, editing the monthly price of a tier version in GrowthBook reprices every subscriber pinned to that version at their next billing cycle. To leave existing subscribers untouched, add a new version to the tier instead of editing an existing one.

A user is only repriced onto a version they are actually on — the pinned tier_version from the membership record is used, never the tier’s current_version. If the pinned version has been removed from the config, the previous amount is carried forward rather than silently moving the user to another version.

Renewal Price Override

A price increase is announced to users before it can be put in the tiers config: the config change would reprice everyone at their next renewal, including users who have not been notified yet and users whose next renewal falls before the announced date. The subscriptions.collection.increase.temp feature bridges that gap. While it is enabled, a monthly renewal produced by the flow above is repriced to the flag’s amount if it is due on or after the flag’s due_date, its tier is targeted, and it does not already carry that amount.

{
  "amount": "6.99",
  "due_date": "9/1/2026",
  "target_tiers": [
    { "tier": "base", "version": "v2" }
  ]
}

due_date may be written as M/D/YYYY or YYYY-MM-DD. amount must parse as a finite, strictly positive number — a missing, zero, negative, non-numeric, NaN or Inf amount is rejected as unusable and leaves the renewal priced from the tier. target_tiers is the whitelist of tiers to reprice:

  • An empty or absent list matches every tier and version. It widens which renewals are targeted, not which are eligible — the monthly, due_date and already-repriced checks below still apply.

  • A target with a version reprices only that version of that tier.

  • A target with an empty version reprices every version of that tier, including renewals whose version is unknown — which is how users are targeted whose membership record predates tier versions.

The tier and version matched against the list are the ones the renewal was priced from: the membership record’s when it could be read, the previous subscription’s otherwise. Both are matched case-insensitively. A renewal that fell back to the previous amount is still eligible — the override replaces whatever amount the flow produced, tier price or fallback.

The override is skipped for yearly terms, since one amount cannot price a yearly renewal, and it is skipped when the flag is off, its payload has no usable amount or due_date, or the renewal is due before the due_date. A renewal already carrying the override amount is left alone, since there is nothing to change; that check does not depend on target_tiers, so an empty list does not reprice it either. Each repriced renewal logs the tier price it replaced and emits subscriptions.collections.renewal.price_override tagged with the tier and version.

The mechanism is permanent; only its enablement is temporary, which is what the temp in the flag key refers to. For the next increase, point amount and due_date at it, list the tiers to reprice, and enable the flag. Once the tiers config carries the new price, disable the flag — leaving it on would keep overriding the config.
The override only reaches renewals. Records already scheduled past the due_date when the flag is enabled keep the amount they were written with, and reactivations are priced straight from the tiers config, so neither is repriced by the flag.

Upgrade / Downgrade

Upgrade

POST /{user_id}/subscriptions/upgrade changes the amount and tier of the user’s next scheduled subscription. The existing SCHEDULED record is overwritten in-place (same DynamoDB key); is_pending_downgrade is cleared.

POST /{user_id}/subscriptions/upgrade
  Body: { "upgrade_tier": "plus", "upgrade_tier_version": "v1" }
        │
        ▼
Get recent subscriptions for user (DynamoDB)
        │
        ├── No SCHEDULED record found? ──Yes──► 404 Not Found
        │
        │ SCHEDULED record found → nextSub
        ▼
Validate tier + version against GrowthBook (global.tiers.config)
        │
        ├── Unknown tier ──────────────► 400 Bad Request
        ├── Unknown version ───────────► 400 Bad Request
        │
        │ Valid tier
        ▼
Update nextSub in-place:
  billing_amount:       tier.Price.Monthly
  is_pending_downgrade: false
  term:                 MONTHLY
  receipt_tier_name:    "tier:version"
  last_run_date:        now
        │
        ▼
Save updated record (DynamoDB — overwrites existing SCHEDULED record)
        │
        ▼
Return 201 Created (updated subscription)
The new amount takes effect at the next billing cycle. If the current billing cycle is already in-flight (ACHSENT or COMPLETED), the upgrade applies to the record that follows it.

Downgrade

POST /{user_id}/subscriptions/downgrade works identically to upgrade but sets is_pending_downgrade = true on the record and accepts PAUSED records in addition to SCHEDULED ones (for users in a pause window).

POST /{user_id}/subscriptions/downgrade
  Body: { "downgrade_tier": "base", "downgrade_tier_version": "v0" }
        │
        ▼
Get recent subscriptions for user (DynamoDB)
        │
        ├── No SCHEDULED or PAUSED record found? ──Yes──► 404 Not Found
        │
        │ SCHEDULED or PAUSED record found → nextSub
        ▼
Validate tier + version against GrowthBook (global.tiers.config)
        │
        ├── Unknown tier or version ──► 400 Bad Request
        │
        │ Valid
        ▼
Update nextSub in-place:
  billing_amount:       tier.Price.Monthly
  is_pending_downgrade: true
  term:                 MONTHLY
  receipt_tier_name:    "tier:version"
  last_run_date:        now
        │
        ▼
Save updated record (DynamoDB)
        │
        ▼
Return 201 Created (updated subscription)
When is_pending_downgrade = true, the collections worker calls FinalizeDowngradeMembershipTierWithResponse on the User Service at the start of the next billing cycle before charging the user, officially completing the tier change on the membership side.

Manual Pay

A user can manually pay a due subscription via POST /{user_id}/subscriptions/{subscription_id}/pay. Only subscriptions with status SCHEDULED or ERROR that are not older than OldSubscriptionDays are eligible.

POST /{user_id}/subscriptions/{subscription_id}/pay
        │
        ▼
Get user from User Service
        │
        ├── User status != ACTIVE? ──Yes──► 409 Conflict
        │
        │ Active
        ▼
Get subscription record by ID (DynamoDB)
        │
        ├── Status not in [SCHEDULED, ERROR]? ──Yes──► 409 Conflict
        ├── Subscription too old? ───────────────Yes──► 409 Conflict
        │
        │ Eligible
        ▼
Acquire distributed lock (DynamoDB)
        │
        ▼
Get debit card for user (Payments Service)
        │
        ├── Card not valid? ──Yes──► error → 409 Conflict
        │
        │ Valid card
        ▼
Submit pinless debit (Payments Service)
        │
        ├── Payment response = COMPLETED
        │       │
        │       ├── Status was SCHEDULED?
        │       │     └── Schedule next SCHEDULED subscription (DynamoDB)
        │       │
        │       ├── Update subscription status → COMPLETED (process: MANUAL_REPAYMENT)
        │       ├── Send receipt (Segment via DebitReceiptTopic)
        │       ├── Send AppsFlyer ad_subscription_payment_completed event
        │       └── Trigger underwriting recalculation (Underwriting Service)
        │
        ├── Payment response != COMPLETED (failed)
        │       │
        │       ├── Update subscription status → unchanged (error recorded in usio_error)
        │       └── Send AppsFlyer ad_subscription_payment_failed event
        │
        ▼
Return 201 Created (success) or 409 Conflict (payment failed)
The manual pay endpoint uses pinless debit only — it never submits an ACH. If the pinless attempt fails, the subscription status remains as it was (SCHEDULED or ERROR); the usio_error field is updated with the failure message.

Ban

POST /{user_id}/subscriptions/ban is called by other internal services (typically the ACH Handler or the User Service) to cancel all pending subscription records when a user is banned. Only records with status SCHEDULED or ERROR are affected; records in terminal states are left unchanged.

POST /{user_id}/subscriptions/ban
        │
        ▼
List all subscriptions for user (DynamoDB)
        │
        ▼
For each subscription:
        │
        ├── Status = SCHEDULED or ERROR?
        │     │
        │     └── Update record:
        │           billing_status: CANCELLED
        │           updated_event:  "user_banned"
        │           last_run_date:  now
        │
        └── Any other status → skip (no change)
        │
        ▼
Save all updated records (DynamoDB — overwrites with Add, creates history entries)
        │
        ├── No records needed updating? ──Yes──► 200 OK (warn logged)
        │
        ▼
Return 200 OK
The ACH Handler also calls the User Service ban endpoint directly when it processes a CHARGED_BACK or bannable FAILED payment event (e.g. ACH return codes R05, R07, R08, R10, R11, R29). In that path the subscription status is set to ERROR first by the ACH Handler, and the downstream ban call then cancels the record.

Reactivate

POST /{user_id}/subscriptions/reactivate is called when a previously cancelled or lapsed user resumes their FloatMe membership. It immediately charges the user for the current month and creates a new SCHEDULED record for the next billing cycle.

POST /{user_id}/subscriptions/reactivate
  Body: { "tier": "base", "version": "v0" }   (optional; defaults to base:v0)
        │
        ▼
Get membership tier config from GrowthBook (global.tiers.config)
        │
        ├── Error getting config ─────────────► 500 Internal Server Error
        │
        ▼
Parse and validate request body
        │
        ├── tier XOR version provided ────────► 400 Bad Request
        │
        ▼
Validate tier + version against GrowthBook config
        │
        ├── Unknown tier or version ──────────► 400 Bad Request
        │
        ▼
List all subscriptions for user (DynamoDB)
        │
        ├── SCHEDULED record already exists? ──Yes──► 200 OK (already active)
        │
        │ No active subscription
        ▼
Get debit card for user (Payments Service)
        │
        ├── Card is invalid? ─────────────────► 409 Conflict
        │
        │ Valid card
        ▼
Submit pinless debit for current month (Payments Service)
  amount: tier.Price.Monthly
  new subscription_id: UUID
        │
        ├── Payment provider error ───────────► 500 Internal Server Error
        ├── Payment returned IsFailed ────────► 402 Payment Required
        │
        │ Payment success: returns confirmationID + masked card number
        ▼
Write COMPLETED subscription record (DynamoDB)
  billing_status:  COMPLETED
  billing_amount:  tier.Price.Monthly
  billing_date:    now
  updated_event:   "user-reactivated"
  term:            MONTHLY
        │
        ▼
Write next SCHEDULED subscription record (DynamoDB)
  billing_status:  SCHEDULED
  billing_date:    first billing date ~1 month from now
                   (next Friday if Friday billing enabled)
  billing_amount:  tier.Price.Monthly
  term:            MONTHLY
        │
        ▼
Return 201 Created
  { subscription: <new SCHEDULED record>, mask: <masked card number> }
Reactivation charges the user synchronously before returning. If the DynamoDB write for the COMPLETED record fails after a successful charge, the payment has moved but no subscription record exists — the same race condition noted in float-service disbursement. The SCHEDULED record write happens only after the COMPLETED record is saved.
  • Collections — Scheduled, retry, pause, webhook, and ACH collection flows

  • ACH Processing — ACH settlement callbacks and status transitions from the payments Kinesis stream

  • Memberships — Kinesis consumer that drives PAUSE, UNPAUSE, CANCEL, and other membership lifecycle events

  • API Endpoints — Full endpoint reference including request/response shapes

  • DynamoDB Schema — billing-activity table attributes, GSIs, and access patterns