Membership Upgrades

An active user moves to a higher tier mid-cycle by paying the prorated difference until their next billing date. Error bodies are the ErrorResponse schema (error_code 8 with an error_string) in the API Specification; the M… names below identify the branch that produced them.

Tier Configuration

Tiers come from the GrowthBook feature global.tiers.config (Feature Flags), a map of lower-case tier name to configuration:

{
  "plus": {
    "current_version": "v1",
    "versions": [
      { "version_name": "v1", "display_name": "Plus", "price": { "monthly": 9.99 } }
    ]
  }
}

Requests name the tier in lower case (plus); membership records store it upper-cased (PLUS) with the version_name in force at the time. The BASE tier costs $4.99 per month and is the baseline of every proration.

Proration

GET /{user_id}/user/membership/upgrade/proration?upgrade_tier=<tier> returns the exact charge for an upgrade so the app can show it before confirming.

  1. Resolve the tier and its current version (400 M8_INVALID_TIER / M9_TIER_VERSION_NOT_FOUND).

  2. Fetch the next billing date from the Subscription Service (500 M7_BILLING_DETAILS_FETCH_FAILED).

  3. days = billing date minus today, both truncated to midnight UTC. days ⇐ 0 yields a charge of 0. days > 65 yields the error sentinel (400 M10_PRORATION_CALCULATION_FAILED): the subscription was pushed far into the future by MX and cannot be upgraded through this flow.

  4. cents = round_half_even((monthly_price - 4.99) / 30 * days, 2), computed in decimal arithmetic.

The response carries the amount in dollars and in cents (proration_amount_int), the tier, the billing date and days_until_billing. Metric user.membership.upgrade.proration.

Source: pkg/api/membership_tiers.go (CalculateProrationAmountAt)

Upgrade

POST /{user_id}/user/membership/upgrade with upgrade_tier and the proration amount (upgrade_amount_int in cents, preferred; otherwise upgrade_amount in dollars).

  1. Load the tier configuration (500 M2_CONFIG_FETCH_FAILED).

  2. The user must exist (404 M3_USER_NOT_FOUND) and be ACTIVE (403 M4_USER_NOT_ACTIVE); a membership must exist (404 M5_MEMBERSHIP_NOT_FOUND).

  3. A membership pending a downgrade whose current tier equals upgrade_tier takes the retraction path instead.

  4. Fetch the primary debit card from the Payments Service (500 M6_DEBIT_CARD_NOT_FOUND) and the billing details from the Subscription Service (500 M7_BILLING_DETAILS_FETCH_FAILED).

  5. Resolve the tier version (400 M8 / M9), compute the proration (400 M10 when negative) and require the request amount to equal it (400 M11_PRORATION_AMOUNT_MISMATCH).

  6. Charge the card through the Payments Service with a card override for the primary card, a new prorated_subscription_id and bypass_retry_limit: true (Retry-Limit Bypass): a call failure is 500 M12_PAYMENT_SUBMISSION_FAILED, a failed payment is 402 M13_PAYMENT_DECLINED.

  7. Move the subscription to the tier and version in the Subscription Service. On an error or a non-201 the charge is refunded: 500 M17_UPGRADE_FAILED_REFUND_ISSUED when the refund succeeds, 500 M16_REFUND_FAILED when it does not (the user was charged and nothing was upgraded).

  8. Write the membership record: <TIER> / MONTHLY at the version, event MEMBERSHIP_UPGRADE, the caller-derived source, the confirmation id (500 M18_MEMBERSHIP_RECORD_CREATE_FAILED — the charge and subscription change stand and need reconciliation).

  9. Write the prorated subscription row and send the Iterable receipt subscription-prorated-digital-receipt; both are best effort.

  10. 201 with confirmation_id and the new membership. Metric user.membership.upgrade with from_tier: and to_tier:.

Upgrading Back During a Pending Downgrade

When the current membership has is_pending_downgrade set and the requested tier is the tier the user still holds, the upgrade cancels the downgrade instead of charging: the Subscription Service upgrade is called with an empty confirmation id, and the membership is re-written with event MEMBERSHIP_RETRACT, is_pending_downgrade false and downgrade_date cleared. The response is 201 with an empty confirmation_id. The check reads the durable is_pending_downgrade flag, so a pause and unpause between the downgrade and the retraction does not hide it.

Retry-Limit Bypass

The Payments Service enforces a pinless-debit retry limit on automated collections and refuses blocked submissions with status: FAILED and a block_reason. The upgrade charge is member-initiated and is submitted with bypass_retry_limit: true, so a FAILED response is always a processor decline and maps to M13_PAYMENT_DECLINED; any block_reason that does appear is logged with the decline. Bypassed attempts are still recorded in the Payments Service’s retry-limit ledger, so a declined upgrade charge consumes decline budget for automated float and subscription collections on the same card and amount.