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 |
|---|---|---|
|
Activation, renewal scheduling, upgrade/downgrade, unpause, reactivation |
Awaiting its billing date. The only status the scheduled collections run picks up. |
|
Collections worker, retry worker |
ACH debit submitted; awaiting a settlement event on the |
|
Collections worker (pinless), ACH Handler (settlement), manual pay, reactivation |
Collected. Terminal; the next |
|
Collections worker, ACH Handler (return/chargeback) |
A collection attempt failed. Picked up by the retry run and the webhook balance worker. |
|
Collections worker |
Billing skipped for a FloatMe employee ( |
|
Collections run (on |
No further collection attempts. Terminal. |
|
Memberships Lambda ( |
Membership paused; carries |
|
Pause collections run |
Terminal for one record: this cycle’s charge was skipped and the next |
|
Batch worker |
User account was not |
|
ACH Handler (refund event; constant in |
Payment refunded after a successful collection ( |
|
Never written — computed by |
Returned when the most recent records are older than |
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.
-
List the user’s subscriptions; any
SCHEDULEDrecord →200 OK. -
Compute the first billing date: next Friday at least 6 days out when
subscriptions.friday_billing_for_allis enabled (GrowthBook Flags), otherwise today + 9 days. -
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.99with an emptytier_nameand emitsubscriptions.pricing.base_tier.fallback(cause:config_unavailableorcause:unpriced_tier); a fallback record is repriced from the config at its first renewal. -
Write the record:
billing_status: SCHEDULED,billing_amount,billing_date,billing_period(MM/YYYY),term: MONTHLY,tier_name: base:<version>, newsubscription_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 |
Repriced to |
On or after |
Held at the previous record’s amount for subscriptions due before |
On or after |
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 |
|---|---|---|
|
|
|
|
|
Same, but |
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).
-
Eligibility: user
ACTIVE, record statusSCHEDULEDorERROR, record not older thanOldSubscriptionDays— otherwise409. -
Acquire the per-user billing lock (dynamo.adoc#locks); fetch the debit card (invalid card →
409). -
Submit the pinless debit with
bypass_retry_limit, so the Payments Service never blocks it (Retry-Limit Blocks). -
On success: status →
COMPLETED(processMANUAL_REPAYMENT); when the record wasSCHEDULED, the nextSCHEDULEDrecord is written; a receipt is sent (Segment) andad_subscription_payment_completed(AppsFlyer); an underwriting recalculation is triggered — the service’s only Underwriting Service call. Returns201. -
On failure: status unchanged, the failure message is recorded in
usio_error,ad_subscription_payment_failedis emitted, and the endpoint returns409.
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.
-
Read
global.tiers.config(error →500); validate the optional body —tierandversionmust be provided together (defaultbase:v0); unknown tier or version →400. -
Idempotency: an existing
SCHEDULEDrecord →200 OK. -
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 carriesbypass_retry_limitand is never blocked. -
Write a
COMPLETEDrecord (billing_date: now,updated_event: user-reactivated,term: MONTHLY, processREACTIVATION), then the nextSCHEDULEDrecord (~1 month out, Friday-billing aware) priced at the tier’s monthly amount and subject to the price override. -
Clear any pending Array account cleanup for the user, through entitlements-service.
-
Return
201with the newSCHEDULEDrecord 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.