Subscription Lifecycle

A subscription is a recurring monthly billing record tied to a FloatMe user’s membership. This page is the canonical reference for subscription statuses and every lifecycle operation the API Lambda performs: activation, renewal pricing, upgrade/downgrade, manual pay, ban, and reactivation. Automated collection is covered in Collections; membership-event-driven transitions in Membership Events; ACH settlement transitions in ACH Processing.

Statuses

The canonical billing_status reference. Each subscription record carries exactly one status; a user’s billing history is a chain of records, each ending in a terminal status while a successor record is created for the next cycle.

Status Set by Meaning

SCHEDULED

Activation, renewal scheduling, upgrade/downgrade, unpause, reactivation

Awaiting its billing date. The only status the scheduled collections run picks up.

ACHSENT

Collections worker, retry worker

ACH debit submitted; awaiting a settlement event on the site-payments Kinesis stream.

COMPLETED

Collections worker (pinless), ACH Handler (settlement), manual pay, reactivation

Collected. Terminal; the next SCHEDULED record is written alongside it.

ERROR

Collections worker, ACH Handler (return/chargeback)

A collection attempt failed. Picked up by the retry run and the webhook balance worker.

WAIVED

Collections worker

Billing skipped for a FloatMe employee (IsEmployee tag). Terminal; next record is scheduled.

CANCELLED

Collections run (on PENDING_CANCELLATION), ban endpoint, memberships Lambda (CLOSEACCOUNT, surplus unpause records), batch worker

No further collection attempts. Terminal.

PAUSED

Memberships Lambda (SUB_PAUSED), pause collections run

Membership paused; carries pause_duration_months (-1 = indefinite). The pause run skips the charge each cycle.

PAUSED_SKIPPED

Pause collections run

Terminal for one record: this cycle’s charge was skipped and the next PAUSED (or SCHEDULED, when the counter runs out) record was created.

INACTIVE

Batch worker

User account was not ACTIVE at collection time. Terminal; not retried.

REFUNDED

ACH Handler (refund event; constant in pkg/payments/achhandler.go)

Payment refunded after a successful collection (updated_event: payment-refunded). Terminal.

STALE

Never written — computed by GET /{user_id}/subscriptions/current

Returned when the most recent records are older than OldSubscriptionDays and no non-stale status is present.

Source: pkg/dynamo/subscriptions/subscriptions.go (all values except REFUNDED, defined in pkg/payments/achhandler.go).

Activation

POST /{user_id}/subscriptions/activate is called by the user-service during its own activation flow (never by the mobile app). Idempotent: an existing SCHEDULED record returns 200 OK without creating a duplicate. No charge happens at activation — the first charge occurs when the scheduled collections run picks up the record on its billing date.

  1. List the user’s subscriptions; any SCHEDULED record → 200 OK.

  2. Compute the first billing date: next Friday at least 6 days out when subscriptions.friday_billing_for_all is enabled (GrowthBook Flags), otherwise today + 9 days.

  3. Price the base tier from global.tiers.config (current version). When the config is unreadable or does not price the base tier, fall back to $4.99 with an empty tier_name and emit subscriptions.pricing.base_tier.fallback (cause:config_unavailable or cause:unpriced_tier); a fallback record is repriced from the config at its first renewal.

  4. Write the record: billing_status: SCHEDULED, billing_amount, billing_date, billing_period (MM/YYYY), term: MONTHLY, tier_name: base:<version>, new subscription_id.

Renewal

Whenever a record is processed (COMPLETED, WAIVED, PAUSED_SKIPPED, …), the collections worker writes the next record (GetNextSubscription). Its price is resolved from global.tiers.config using the tier and pinned tier_version on the user’s membership record in the user-service — never the tier’s current_version. A LITE membership tier is normalized to base version v0 before this lookup — provided the previous record is still billing the base tier’s $4.99 — since the tiers config and price override target_tiers no longer carry the old LITE id. When the membership cannot be read, or the pinned tier/version is missing from the config, or its monthly price is not positive, the previous record’s billing_amount and receipt_tier_name carry forward.

The price is re-resolved on every renewal, so editing a tier version’s monthly price in GrowthBook reprices every subscriber pinned to that version at their next cycle. To leave existing subscribers untouched, add a new version instead of editing one.

Price override

While a price increase is being announced, the subscriptions.collection.increase.temp complex flag (GrowthBook Flags) overrides the amount of monthly subscriptions written by renewal and reactivation for targeted tiers, due on or after the flag’s due_date:

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

amount must be a finite positive number and both dates (M/D/YYYY or YYYY-MM-DD) are required; an unusable payload leaves every subscription priced from the tier. target_tiers: an empty list matches every tier and version; a target with a version matches only that version; a target with an empty version matches every version of that tier, including renewals whose version is unknown. Matching is case-insensitive against the tier/version the renewal was priced from.

The outcome depends on when the user’s subscription relationship began — the later of date_joined and the membership’s reactivation_date:

Relationship began Outcome

Before notified_date

Repriced to amount.

On or after notified_date, before due_date

Held at the previous record’s amount for subscriptions due before due_date + one month; priced from the tiers config after that.

On or after due_date

Priced from the tiers config.

Unknown

Priced from the tiers config.

Skipped for yearly terms, untargeted tiers, subscriptions due before due_date, and renewals already carrying the override amount. Every change logs the replaced price and emits subscriptions.collections.renewal.price_override (tier, version, path:renewal\|reactivation, outcome:override\|carry_forward).

Keep the flag enabled until the end of the month after due_date — records priced after the config change rely on the hold. Records already written before the flag was enabled keep their amount; the override runs only at record creation.

Upgrade / Downgrade

Both endpoints reprice the user’s next record in place (same DynamoDB key) after validating the tier and version against global.tiers.config (unknown tier or version → 400; no matching record → 404). The new amount takes effect at the next billing cycle. The memberships Lambda does not process UPGRADE/DOWNGRADE Kinesis events — they error as unsupported (memberships.adoc#handlers); these endpoints are the only path.

Endpoint Record matched Fields set

POST /{user_id}/subscriptions/upgrade

SCHEDULED

billing_amount (tier monthly price), receipt_tier_name (tier:version), term: MONTHLY, is_pending_downgrade: false

POST /{user_id}/subscriptions/downgrade

SCHEDULED or PAUSED

Same, but is_pending_downgrade: true

When is_pending_downgrade is set, the collections worker calls FinalizeDowngradeMembershipTier on the User Service at the start of the next billing cycle, before charging, completing the tier change on the membership side.

Manual Pay

POST /{user_id}/subscriptions/{subscription_id}/pay collects a due subscription immediately, by pinless debit only (never ACH).

  1. Eligibility: user ACTIVE, record status SCHEDULED or ERROR, record not older than OldSubscriptionDays — otherwise 409.

  2. Acquire the per-user billing lock (dynamo.adoc#locks); fetch the debit card (invalid card → 409).

  3. Submit the pinless debit with bypass_retry_limit, so the Payments Service never blocks it (Retry-Limit Blocks).

  4. On success: status → COMPLETED (process MANUAL_REPAYMENT); when the record was SCHEDULED, the next SCHEDULED record is written; a receipt is sent (Segment) and ad_subscription_payment_completed (AppsFlyer); an underwriting recalculation is triggered — the service’s only Underwriting Service call. Returns 201.

  5. On failure: status unchanged, the failure message is recorded in usio_error, ad_subscription_payment_failed is emitted, and the endpoint returns 409.

Ban

POST /{user_id}/subscriptions/ban is called by internal services (ACH Handler, User Service) when a user is banned. Every record in SCHEDULED or ERROR is set to CANCELLED with updated_event: user_banned; records in other statuses are untouched. Returns 200 even when nothing needed updating. The ACH Handler triggers this path on chargebacks and bannable ACH return codes (ach_processing.adoc#bannable-return-codes).

Reactivation

POST /{user_id}/subscriptions/reactivate resumes a cancelled or lapsed membership: it charges the user synchronously for the current month and schedules the next cycle.

  1. Read global.tiers.config (error → 500); validate the optional body — tier and version must be provided together (default base:v0); unknown tier or version → 400.

  2. Idempotency: an existing SCHEDULED record → 200 OK.

  3. Fetch the debit card (invalid → 409) and submit a pinless debit for the tier’s monthly price (provider error → 500; declined → 402). Like manual pay, the submit carries bypass_retry_limit and is never blocked.

  4. Write a COMPLETED record (billing_date: now, updated_event: user-reactivated, term: MONTHLY, process REACTIVATION), then the next SCHEDULED record (~1 month out, Friday-billing aware) priced at the tier’s monthly amount and subject to the price override.

  5. Clear any pending Array account cleanup for the user, through entitlements-service.

  6. Return 201 with the new SCHEDULED record and the masked card number.

Cancelling schedules the user’s Array account for deletion after a grace period, and nothing else cancels that schedule, so reactivation clears it. The call is best effort: the payment has already settled by that point, so a failure emits subscriptions.reactivation.array_cleanup with outcome:not_cleared and the reactivation still returns 201. It requires ENTITLEMENTS_SERVICE_URL and ENTITLEMENTS_SERVICE_REGION.