Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1783,6 +1783,8 @@
"confirm_downgrade": "Downgrade to {tier}? You'll get account credit for the unused portion of your current plan.",
"confirm_purchase_body": "Buy {item} for {amount, number} {currency}?",
"confirm_purchase_title": "Confirm Purchase",
"confirm_subscribe_over_grant": "Start a paid {tier} subscription now? Your free access ends immediately: the {days, plural, one {# day left on it is} other {# days left on it are}} not refunded and not added to your paid time.",
"confirm_subscribe_over_grant_no_days": "Start a paid {tier} subscription now? Your free access ends immediately, and the time left on it is not refunded and not added to your paid time.",
"confirm_tier_change_steam": "Subscribe to {tier} now? Your new tier starts immediately at full price, and the rest of your current month is not refunded.",
"confirm_upgrade": "Upgrade to {tier}? You'll be charged the prorated difference now.",
"cosmetics": "Cosmetics",
Expand Down Expand Up @@ -1830,6 +1832,7 @@
"purchase_tribe_button": "Purchase",
"steam_overlay_cancelled": "Purchase cancelled. You haven't been charged.",
"steam_overlay_waiting": "Approve the purchase in the Steam overlay to continue.",
"subscribe_heading": "Start Subscription",
"subscribed": "Subscribed",
"subscription_purchase_success": "Subscription activated!",
"subscriptions": "Subscriptions",
Expand Down
141 changes: 136 additions & 5 deletions src/client/Cosmetics.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { assetUrl } from "src/core/AssetUrls";
import { UserMeResponse } from "../core/ApiSchemas";
import {
isGrantedSubscription,
UserMeResponse,
UserSubscription,
} from "../core/ApiSchemas";
import {
ColorPalette,
CosmeticPack,
Expand Down Expand Up @@ -261,6 +265,84 @@ function debtMessage(debt: number): string {
return translateText("store.pack_debt", { debt: String(debt) });
}

/**
* Whole days left on a granted subscription, or null when there is no end date
* to count to.
*
* A Steam ownership grant is a fixed free month, so `currentPeriodEnd` is set
* and the number is real. An admin comp is open-ended (`currentPeriodEnd`
* null) and there is nothing to count — the caller uses the no-days copy
* rather than inventing a figure. A date already in the past returns null for
* the same reason: "0 days" reads as a bug, and a row the sweeper has not got
* to yet is not worth quoting.
*
* Rounded UP, so the figure never claims they forfeit LESS than they do — the
* safe side for a warning about something irreversible. It also gets the case
* that would look most like a bug right: two hours into a 30-day grant, floor
* would say "29 days".
*/
function grantedDaysRemaining(sub: UserSubscription): number | null {
const end = sub.currentPeriodEnd;
if (!end) return null;
const ms = end.getTime() - Date.now();
if (!Number.isFinite(ms) || ms <= 0) return null;
return Math.ceil(ms / 86_400_000);
}

/**
* A granted player starting a PAID subscription — any tier, including the one
* their grant already gives them.
*
* The confirm is not the tier-change copy. That copy promises Stripe proration
* ("charged the prorated difference", "credit for the unused portion"), and
* none of it is true here: infra expires every granted row in the same
* transaction as the paid insert (`expireGrantsForPaidReplacement`,
* SteamAgreements.ts, confirmed 12 Sept 2026), so the paid period starts at
* settle and the unused free days are simply gone — no credit, no extension,
* no stacking. The string says so, and names the days when we know them,
* because "not carried over" badly understates 28 of them.
*
* The purchase itself is the ordinary first-purchase flow: same startPurchase,
* same success string, same profile refresh. Only the confirm differs.
*/
async function purchaseOverGrant(
sub: Subscription,
currentSub: UserSubscription,
): Promise<void> {
const targetName = translateCosmetic("subscriptions", sub.name);
const days = grantedDaysRemaining(currentSub);
// Both keys passed as literals so the en.json sync test can see them.
const confirmed = await showInGameConfirm(
days === null
? translateText("store.confirm_subscribe_over_grant_no_days", {
tier: targetName,
})
: translateText("store.confirm_subscribe_over_grant", {
tier: targetName,
days,
}),
{
heading: translateText("store.subscribe_heading"),
variant: "warning",
},
);
if (!confirmed) return;

const outcome = await startPurchase({
kind: "subscription_tier",
tierName: sub.name,
});
if (outcome.outcome === "completed") await broadcastFreshUserMe();
if (outcome.outcome === "error" && outcome.refetchCatalog) {
invalidateCosmetics();
}
const message = purchaseOutcomeMessage(
outcome,
"store.subscription_purchase_success",
);
if (message !== null) await showInGameAlert(message);
}

export async function purchaseCosmetic(
resolved: ResolvedCosmetic,
method: PaymentMethod,
Expand All @@ -276,6 +358,29 @@ export async function purchaseCosmetic(
userMe === false ? null : (userMe.player.subscription ?? null);

if (currentSub) {
// OPE-440, and BEFORE every branch below it, including the
// already-subscribed one. A grant is not a purchase: nobody is being
// billed, so there is no agreement to reprice and `change-tier` answers
// 400 "Cannot change tier of a granted subscription" for every tier —
// which is why a granted player currently cannot pay us at all. Their
// tier selection is a FIRST purchase, and /payments/checkout admits
// them on both rails (its exclusivity gate filters
// `isNotNull(subscriptions.provider)`, so a granted row is never an
// incumbent).
//
// Including the tier they already hold: buying that same tier is the
// likeliest conversion of the whole cohort, and `already_subscribed`
// would refuse the one click we most want.
//
// No rail check like the Steam ones below, because a grant HAS no rail:
// `provider` is null, so there is no account fact to disagree with the
// device, and startPurchase's paymentsProvider() is the only answer
// there is — Steam inside the shell, Stripe on the web. Both are
// admitted.
if (isGrantedSubscription(currentSub)) {
return purchaseOverGrant(sub, currentSub);
}

if (currentSub.tier === sub.name) {
await showInGameAlert(translateText("store.already_subscribed"));
return;
Expand Down Expand Up @@ -1169,16 +1274,42 @@ export function resolveCosmetics(
// Subscriptions
const flares =
userMeResponse === false ? [] : (userMeResponse.player.flares ?? []);
const currentSubTier =
const currentSub =
userMeResponse === false
? null
: (userMeResponse.player.subscription?.tier ?? null);
: (userMeResponse.player.subscription ?? null);
const currentSubTier = currentSub?.tier ?? null;
// OPE-440. A grant is free access nobody is billing, not something the
// player bought, and the tier it confers is the one they are likeliest to
// buy. Calling it "owned" is what rendered a dead "Subscribed" box where
// the buy button goes, so a granted player had no way to pay us for the
// tier they were already enjoying. `owned` is read by the store and nowhere
// else, so this changes the buy affordance and nothing about what the grant
// currently entitles them to.
//
// A flare is untouched by this: it is a permanent unlock, not a month, and
// there is genuinely nothing to sell someone who holds one.
const grantIsCurrent = isGrantedSubscription(currentSub);
for (const [subKey, sub] of Object.entries(cosmetics.subscriptions ?? {})) {
const key = `subscription:${subKey}`;
const isCurrent = subKey === currentSubTier || flares.includes(key);
// A listing with no Stripe `product` block cannot render a price, so it
// falls to "blocked" — and the subscriptions tab lists only purchasable
// and owned, so a blocked tier is not shown at all. (Currency packs hit
// this and were fixed by never gating on `product`; subscriptions still
// do. OPE-441 is the real fix.)
const canBeSold = Boolean(sub.product);
// ...which is why the grant demotion below is conditional on it. Taking
// "owned" away from a tier we then cannot sell would make the card
// VANISH from the store, and a card that disappears is a worse failure
// than the dead "Subscribed" box this change exists to remove. Not
// reachable today — every live tier carries a product — and this is not
// the PR to introduce it.
const isCurrentTier = subKey === currentSubTier;
const demoteGrant = grantIsCurrent && isCurrentTier && canBeSold;
const isCurrent = flares.includes(key) || (isCurrentTier && !demoteGrant);
const rel: ResolvedCosmetic["relationship"] = isCurrent
? "owned"
: sub.product
: canBeSold
? "purchasable"
: "blocked";
result.push({
Expand Down
14 changes: 10 additions & 4 deletions src/client/Store.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { UserMeResponse } from "../core/ApiSchemas";
import { isGrantedSubscription, UserMeResponse } from "../core/ApiSchemas";
import { CosmeticPack, Cosmetics, Product } from "../core/CosmeticSchemas";
import { BaseModal } from "./components/BaseModal";
import "./components/CosmeticCard";
Expand Down Expand Up @@ -564,9 +564,15 @@ export class StoreModal extends BaseModal {
}

private renderSubscriptionGrid(): TemplateResult {
const userHasSubscription =
this.userMeResponse !== false &&
this.userMeResponse.player.subscription !== null;
// Drives the "Switch" label on the other tiers' buy buttons. A granted
// player is deliberately NOT counted (OPE-440): they have nothing to
// switch from — nobody is billing them — so every tier, theirs included,
// is a first purchase and reads as a plain price.
const sub =
this.userMeResponse === false
? null
: this.userMeResponse.player.subscription;
const userHasSubscription = sub !== null && !isGrantedSubscription(sub);
return this.renderBrowser(this.visibleGroups, {
emptyTranslationKey: "store.no_subscriptions",
userHasSubscription,
Expand Down
18 changes: 5 additions & 13 deletions src/client/components/SubscriptionPanel.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { html, LitElement, nothing, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { UserSubscription } from "../../core/ApiSchemas";
import { isGrantedSubscription, UserSubscription } from "../../core/ApiSchemas";
import { Subscription } from "../../core/CosmeticSchemas";
import {
cancelSubscription,
Expand Down Expand Up @@ -119,20 +119,12 @@ export class SubscriptionPanel extends LitElement {
* therefore must not offer Cancel (nor Manage or Change Tier, which have no
* billing to reach) and must not claim the month renews.
*
* `=== null`, deliberately, and never `!this.sub.provider`:
*
* null — granted. Hide the destructive controls.
* undefined — the field is absent because the server predates it. We
* CANNOT tell a grant from a Stripe subscription, so keep
* today's behaviour; hiding Cancel on this path would take the
* one control a paying subscriber actually needs.
*
* A truthiness test is true for both and would do the wrong thing on the
* second — which is the whole hazard, because `provider` is on `main` but not
* yet on staging, so `undefined` is the live case until the next deploy.
* The `=== null` rule itself, and why `undefined` must NOT be treated as a
* grant, live on `isGrantedSubscription` — the store asks the same question
* (OPE-440) and the two must not drift.
*/
private isGranted(): boolean {
return this.sub.provider === null;
return isGrantedSubscription(this.sub);
}

/** Billed by Steam: managed on the Steam account page, on every surface. */
Expand Down
34 changes: 34 additions & 0 deletions src/core/ApiSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,40 @@ export type UserSubscription = NonNullable<
NonNullable<UserMeResponse["player"]["subscription"]>
>;

/**
* Is this subscription a GRANT — free access nobody is billing — rather than
* something the player bought?
*
* The one definition, shared by every surface that has to tell the two apart
* (OPE-314 hid the account panel's destructive controls on it; OPE-440 turned
* the store's dead "Subscribed" tile back into a buy action). It applies the
* exact three-state rule documented on `provider` above, so a caller cannot
* re-derive a fourth:
*
* null — granted.
* "stripe" / "steam" (or any future rail) — paid.
* undefined — the server predates the field, so we CANNOT tell. Falls back
* to the PAID behaviour, which is the safe side on every
* caller: it keeps Cancel in front of a paying subscriber, and
* it never sends one to a second checkout.
*
* Spelled out rather than `!sub.provider`, which is true for `undefined` too
* and so collapses the two states that must not collapse.
*
* The explicit null/undefined guard is for the THIRD case — no subscription at
* all. `sub?.provider === null` would in fact answer this predicate correctly
* (`undefined === null` is false), but it answers by accident: it returns the
* same `false` for "pays us" and "has nothing", and those have separate
* branches in every caller. The guard names the case instead of relying on two
* unrelated states landing on one value.
*/
export function isGrantedSubscription(
sub: UserSubscription | null | undefined,
): boolean {
if (sub === null || sub === undefined) return false;
return sub.provider === null;
}

// PUT /users/@me/username success payload. `username` is the resolved display
// form (safe for optimistic UI). The suffix is re-rolled on every rename and
// the response carries the fresh 30-day cooldown.
Expand Down
Loading
Loading