Skip to content

Subscription upgrade/downgrade + tier management#3927

Merged
evanpelle merged 1 commit into
mainfrom
subs3
May 15, 2026
Merged

Subscription upgrade/downgrade + tier management#3927
evanpelle merged 1 commit into
mainfrom
subs3

Conversation

@evanpelle
Copy link
Copy Markdown
Collaborator

@evanpelle evanpelle commented May 15, 2026

Summary

  • Tier upgrade/downgrade in the Store. The Subscriptions tab now shows all tiers including the user's current one. Other tiers swap "Subscribe" → "Switch" when the user already has a sub, and clicking them calls the new POST /subscriptions/@me/change-tier endpoint with a direction-aware confirm (upgrade charges prorated diff now, downgrade gives account credit).
  • Owned-tier card renders a Current Plan badge in place of the purchase button. Resolution logic in resolveCosmetics now reads userMeResponse.player.subscription.tier (with flare fallback) and marks that tier as owned.
  • AccountModal's <subscription-panel> reworked into a proper two-column layout:
    • Left: tier name, $X.XX/mo price, description, daily Pu/Caps amounts.
    • Right: status badge (Active / Renews date / Cancels date), [Manage] [Change Tier] button row, [Cancel] centered underneath. When cancelAtPeriodEnd === true, the row collapses to a single [Reactivate] button (opens the Stripe portal).
  • New <o-button size="xs"> variant (py-2 px-3 text-xs) for the compact panel buttons.
  • Store dollar-purchase price label now supports an optional suffix (/mo for subs only) via a priceSuffix prop plumbed through CosmeticContainerPurchaseButton.
  • Api.ts gains changeSubscriptionTier(tierName) with the same 401-handling pattern as the existing subscription helpers.
Screenshot 2026-05-14 at 7 09 20 PM Screenshot 2026-05-14 at 7 09 33 PM Screenshot 2026-05-14 at 7 09 55 PM

Discord

evan

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 15, 2026

Review Change Stack

Walkthrough

Adds client-side subscription tier-change support: a new API helper, purchase flow to confirm and call tier changes, owned-subscription detection, and button/ container props to show switch/subscribe labels and monthly price suffixes.

Changes

Subscription Tier Changes

Layer / File(s) Summary
PurchaseButton: label & suffix
src/client/components/PurchaseButton.ts
Adds DEFAULT_DOLLAR_LABEL_KEY and public dollarLabelKey / priceSuffix properties; uses them when rendering the dollar button and price suffix.
Cosmetics: purchase orchestration & ownership
src/client/Cosmetics.ts
Imports changeSubscriptionTier; purchaseCosmetic blocks same-tier actions, distinguishes upgrades vs downgrades by price, prompts confirmation, calls the API and reloads on success; resolveCosmetics marks subscriptions as owned when matching the user's active player.subscription.tier.
sequenceDiagram
  participant User
  participant Store as Store UI
  participant CosmeticButton
  participant Cosmetics as Cosmetics.ts
  participant Api as API Client
  participant Backend

  User->>Store: Open subscription grid
  Store->>CosmeticButton: Render with userHasSubscription flag
  User->>CosmeticButton: Click "Switch/Subscribe"
  CosmeticButton->>Cosmetics: purchaseCosmetic(subscription)
  Cosmetics->>Cosmetics: read current tier, compare price
  Cosmetics->>User: show upgrade/downgrade confirmation
  User->>Cosmetics: confirm
  Cosmetics->>Api: changeSubscriptionTier(newTier)
  Api->>Backend: POST /subscriptions/@me/change-tier
  Backend->>Api: 200 OK
  Api->>Cosmetics: success
  Cosmetics->>Store: refresh user state
  Store->>User: updated subscription shown
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • openfrontio/OpenFrontIO#3918: Prior subscription management changes touching Api.ts and SubscriptionPanel.ts that this PR extends with change-tier/reactivate and owned-state handling.

Poem

🎁 Tiers shift softly, badges light the way,
Click to switch plans, confirm, and stay,
Prices show "/mo", labels snug and neat,
Backend nods, the UI refreshes sweet,
Subscriptions dance—new plans are complete.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main feature: subscription tier management with upgrade/downgrade capability, matching the primary changes across the changeset.
Description check ✅ Passed The description is well-detailed and directly related to the changeset, covering all major changes: tier management UI, endpoint implementation, badge rendering, panel layout redesign, and component prop additions.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/client/components/baseComponents/Button.ts`:
- Around line 59-60: The "xs" size case in Button.ts returns classes that are
taller than "sm" (case "xs" currently returns "py-2 px-3 text-xs"), so update
the "xs" case in the size switch (the case labeled "xs" in the function that
computes size classes) to use smaller vertical padding than "sm" (e.g., change
to a smaller padding set such as py-1 and smaller horizontal padding like px-2
while keeping text-xs) so the xs button renders smaller than sm.

In `@src/client/components/SubscriptionPanel.ts`:
- Around line 128-131: SubscriptionPanel currently renders cosmetic.description
directly; wrap that value in the translateText() call (i.e. replace direct
rendering of cosmetic.description with translateText(cosmetic.description) or a
translation key derived from the cosmetic item) so all UI text goes through
i18n, and add the matching entry/key to resources/lang/en.json (e.g.
"subscription.cosmetic.<id>.description" or the exact string key you use) so
translations load correctly; update any unit/preview that asserts the rendered
text if present.

In `@src/client/Cosmetics.ts`:
- Around line 58-67: The code currently treats a missing currentCosmetic as an
upgrade (isUpgrade true) which can display incorrect billing text; change the
logic so isUpgrade is only computed when currentCosmetic exists (use
currentCosmetic !== null && currentCosmetic !== undefined), otherwise set a
neutral state (e.g., isUpgrade = null or a separate flag like directionUnknown)
and choose a neutral confirmKey (add a new translation key like
"store.confirm_change" or similar) instead of "store.confirm_upgrade" or
"store.confirm_downgrade"; update the confirmKey assignment to select
upgrade/downgrade only when isUpgrade is boolean and fall back to the neutral
key when currentCosmetic is missing, referencing fetchCosmetics(),
currentCosmetic, isUpgrade, and confirmKey in your changes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6d2b1ab0-8016-4ff2-81ad-587cdaf4a4db

📥 Commits

Reviewing files that changed from the base of the PR and between bcc453e and fe34ffe.

📒 Files selected for processing (9)
  • resources/lang/en.json
  • src/client/Api.ts
  • src/client/Cosmetics.ts
  • src/client/Store.ts
  • src/client/components/CosmeticButton.ts
  • src/client/components/CosmeticContainer.ts
  • src/client/components/PurchaseButton.ts
  • src/client/components/SubscriptionPanel.ts
  • src/client/components/baseComponents/Button.ts

Comment thread src/client/components/baseComponents/Button.ts Outdated
Comment thread src/client/components/SubscriptionPanel.ts
Comment thread src/client/Cosmetics.ts
Comment on lines +58 to +67
const currentCosmetic =
(await fetchCosmetics())?.subscriptions?.[currentSub.tier] ?? null;
const isUpgrade =
currentCosmetic !== undefined && currentCosmetic !== null
? sub.priceMonthly > currentCosmetic.priceMonthly
: true;
const targetName = translateCosmetic("subscriptions", sub.name);
const confirmKey = isUpgrade
? "store.confirm_upgrade"
: "store.confirm_downgrade";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid defaulting unknown tier comparisons to “upgrade” messaging.

On Line 60–67, when the current tier is missing from cosmetics.subscriptions, isUpgrade defaults to true, so downgrade paths can show upgrade billing text. Use a neutral fallback confirm message when direction is unknown.

💡 Suggested fix
-      const isUpgrade =
-        currentCosmetic !== undefined && currentCosmetic !== null
-          ? sub.priceMonthly > currentCosmetic.priceMonthly
-          : true;
+      const isUpgrade =
+        currentCosmetic !== undefined && currentCosmetic !== null
+          ? sub.priceMonthly > currentCosmetic.priceMonthly
+          : null;
       const targetName = translateCosmetic("subscriptions", sub.name);
-      const confirmKey = isUpgrade
-        ? "store.confirm_upgrade"
-        : "store.confirm_downgrade";
+      const confirmKey =
+        isUpgrade === null
+          ? "store.confirm_change_tier"
+          : isUpgrade
+            ? "store.confirm_upgrade"
+            : "store.confirm_downgrade";

Add a neutral translation key:

  "store": {
+   "confirm_change_tier": "Switch to {tier}?",
    "confirm_upgrade": "Upgrade to {tier}? You'll be charged the prorated difference now.",
    "confirm_downgrade": "Downgrade to {tier}? You'll get account credit for the unused portion of your current plan.",
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const currentCosmetic =
(await fetchCosmetics())?.subscriptions?.[currentSub.tier] ?? null;
const isUpgrade =
currentCosmetic !== undefined && currentCosmetic !== null
? sub.priceMonthly > currentCosmetic.priceMonthly
: true;
const targetName = translateCosmetic("subscriptions", sub.name);
const confirmKey = isUpgrade
? "store.confirm_upgrade"
: "store.confirm_downgrade";
const currentCosmetic =
(await fetchCosmetics())?.subscriptions?.[currentSub.tier] ?? null;
const isUpgrade =
currentCosmetic !== undefined && currentCosmetic !== null
? sub.priceMonthly > currentCosmetic.priceMonthly
: null;
const targetName = translateCosmetic("subscriptions", sub.name);
const confirmKey =
isUpgrade === null
? "store.confirm_change_tier"
: isUpgrade
? "store.confirm_upgrade"
: "store.confirm_downgrade";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/Cosmetics.ts` around lines 58 - 67, The code currently treats a
missing currentCosmetic as an upgrade (isUpgrade true) which can display
incorrect billing text; change the logic so isUpgrade is only computed when
currentCosmetic exists (use currentCosmetic !== null && currentCosmetic !==
undefined), otherwise set a neutral state (e.g., isUpgrade = null or a separate
flag like directionUnknown) and choose a neutral confirmKey (add a new
translation key like "store.confirm_change" or similar) instead of
"store.confirm_upgrade" or "store.confirm_downgrade"; update the confirmKey
assignment to select upgrade/downgrade only when isUpgrade is boolean and fall
back to the neutral key when currentCosmetic is missing, referencing
fetchCosmetics(), currentCosmetic, isUpgrade, and confirmKey in your changes.

@github-project-automation github-project-automation Bot moved this from Triage to Development in OpenFront Release Management May 15, 2026
@evanpelle evanpelle changed the title subs Subscription upgrade/downgrade + tier management May 15, 2026
@evanpelle evanpelle marked this pull request as ready for review May 15, 2026 02:10
@evanpelle evanpelle requested a review from a team as a code owner May 15, 2026 02:10
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
src/client/Cosmetics.ts (1)

56-69: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use a neutral confirm message when the current tier cannot be resolved.

If fetchCosmetics() fails or the current tier is missing from cosmetics.subscriptions, isUpgrade falls back to true, so downgrade flows can still show the prorated-upgrade copy.

Proposed change
       const currentCosmetic =
         (await fetchCosmetics())?.subscriptions?.[currentSub.tier] ?? null;
       const isUpgrade =
         currentCosmetic !== null
           ? sub.priceMonthly > currentCosmetic.priceMonthly
-          : true;
+          : null;
       const targetName = translateCosmetic("subscriptions", sub.name);
-      const confirmKey = isUpgrade
-        ? "store.confirm_upgrade"
-        : "store.confirm_downgrade";
+      const confirmKey =
+        isUpgrade === null
+          ? "store.confirm_change_tier"
+          : isUpgrade
+            ? "store.confirm_upgrade"
+            : "store.confirm_downgrade";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/Cosmetics.ts` around lines 56 - 69, The confirm dialog assumes an
upgrade when currentCosmetic is null, causing downgrade flows to show upgrade
copy; change the logic in the block that calls fetchCosmetics() (referencing
currentCosmetic, isUpgrade, currentSub.tier, sub.priceMonthly) to detect the
unresolved case and use a neutral translation key (e.g., "store.confirm_change")
instead of defaulting to the upgrade key; update the confirmKey selection (used
in translateText/translateCosmetic and assigned to confirmed) to choose
"store.confirm_upgrade" or "store.confirm_downgrade" only when currentCosmetic
is present, otherwise choose the neutral key.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/client/components/PurchaseButton.ts`:
- Around line 199-201: The priceSuffix property on the PurchaseButton component
is currently raw display text and must be passed/used as a translation key (or
as a fully translated template) so it goes through translateText() and
resources/lang/en.json; change the `@property` priceSuffix: string to represent a
translation key (e.g., "priceSuffixKey") or document that callers must pass a
translated string, then update the render logic in PurchaseButton (where
priceSuffix is interpolated) to call translateText(this.priceSuffix) before
rendering and add the corresponding key/value to resources/lang/en.json; apply
the same change to the other similar prop references noted around the 239-242
area so all user-visible suffixes go through translateText().

In `@src/client/Cosmetics.ts`:
- Around line 395-402: The ownership logic can mark both the actual subscription
and a flare as owned when flares lag; update the isCurrent calculation in the
loop over cosmetics.subscriptions so that subscription ownership uses
player.subscription.tier as the sole source of truth (currentSubTier) when it is
non-null, and only fall back to checking flares.includes(key) if currentSubTier
is null/undefined; adjust the evaluation that sets isCurrent (referencing
currentSubTier, flares, subKey, and key) so flares are considered only when
userMeResponse.player.subscription?.tier is not present.

---

Duplicate comments:
In `@src/client/Cosmetics.ts`:
- Around line 56-69: The confirm dialog assumes an upgrade when currentCosmetic
is null, causing downgrade flows to show upgrade copy; change the logic in the
block that calls fetchCosmetics() (referencing currentCosmetic, isUpgrade,
currentSub.tier, sub.priceMonthly) to detect the unresolved case and use a
neutral translation key (e.g., "store.confirm_change") instead of defaulting to
the upgrade key; update the confirmKey selection (used in
translateText/translateCosmetic and assigned to confirmed) to choose
"store.confirm_upgrade" or "store.confirm_downgrade" only when currentCosmetic
is present, otherwise choose the neutral key.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b4dbb685-2160-470a-bbd5-a457b476c516

📥 Commits

Reviewing files that changed from the base of the PR and between fe34ffe and c61b5da.

📒 Files selected for processing (9)
  • resources/lang/en.json
  • src/client/Api.ts
  • src/client/Cosmetics.ts
  • src/client/Store.ts
  • src/client/components/CosmeticButton.ts
  • src/client/components/CosmeticContainer.ts
  • src/client/components/PurchaseButton.ts
  • src/client/components/SubscriptionPanel.ts
  • src/client/components/baseComponents/Button.ts
✅ Files skipped from review due to trivial changes (1)
  • resources/lang/en.json
🚧 Files skipped from review as they are similar to previous changes (6)
  • src/client/components/CosmeticButton.ts
  • src/client/components/CosmeticContainer.ts
  • src/client/components/baseComponents/Button.ts
  • src/client/Store.ts
  • src/client/components/SubscriptionPanel.ts
  • src/client/Api.ts

Comment thread src/client/components/PurchaseButton.ts
Comment thread src/client/Cosmetics.ts
Comment on lines +395 to +402
const currentSubTier =
userMeResponse === false
? null
: (userMeResponse.player.subscription?.tier ?? null);
for (const [subKey, sub] of Object.entries(cosmetics.subscriptions ?? {})) {
const key = `subscription:${subKey}`;
const rel = flares.includes(key)
const isCurrent = subKey === currentSubTier || flares.includes(key);
const rel: ResolvedCosmetic["relationship"] = isCurrent
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use flare ownership only as a fallback.

subKey === currentSubTier || flares.includes(key) can mark two plans as "owned" if the flare list lags behind a tier change. When player.subscription.tier exists, it should be the only source of truth.

Proposed change
-    const isCurrent = subKey === currentSubTier || flares.includes(key);
+    const isCurrent =
+      currentSubTier !== null ? subKey === currentSubTier : flares.includes(key);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const currentSubTier =
userMeResponse === false
? null
: (userMeResponse.player.subscription?.tier ?? null);
for (const [subKey, sub] of Object.entries(cosmetics.subscriptions ?? {})) {
const key = `subscription:${subKey}`;
const rel = flares.includes(key)
const isCurrent = subKey === currentSubTier || flares.includes(key);
const rel: ResolvedCosmetic["relationship"] = isCurrent
const currentSubTier =
userMeResponse === false
? null
: (userMeResponse.player.subscription?.tier ?? null);
for (const [subKey, sub] of Object.entries(cosmetics.subscriptions ?? {})) {
const key = `subscription:${subKey}`;
const isCurrent =
currentSubTier !== null ? subKey === currentSubTier : flares.includes(key);
const rel: ResolvedCosmetic["relationship"] = isCurrent
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/Cosmetics.ts` around lines 395 - 402, The ownership logic can mark
both the actual subscription and a flare as owned when flares lag; update the
isCurrent calculation in the loop over cosmetics.subscriptions so that
subscription ownership uses player.subscription.tier as the sole source of truth
(currentSubTier) when it is non-null, and only fall back to checking
flares.includes(key) if currentSubTier is null/undefined; adjust the evaluation
that sets isCurrent (referencing currentSubTier, flares, subKey, and key) so
flares are considered only when userMeResponse.player.subscription?.tier is not
present.

@evanpelle evanpelle added this to the v32 milestone May 15, 2026
@evanpelle evanpelle merged commit ca565ea into main May 15, 2026
13 of 16 checks passed
@evanpelle evanpelle deleted the subs3 branch May 15, 2026 19:01
@github-project-automation github-project-automation Bot moved this from Development to Complete in OpenFront Release Management May 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Complete

Development

Successfully merging this pull request may close these issues.

1 participant