#512·sphere

Plan duration is a hardcoded 30-day constant the plan model cannot express

Author: KruGoLCreated Sep 7, 2026Updated Sep 7, 2026
Labelsbug

Limits and expiry ARE visible today — Settings → Subscription shows the plan name, a "Renews / expires: " line and two usage bars reading used / limit for the per-day and per-minute ceilings. What is not visible is that the 30-day term behind all of it is a constant, not a property of the plan, and the copy asserts things the data model cannot express.

This matters if plans ever stop being time-based and become limit-only: the wallet would then describe them wrongly in three places, and the gateway could not represent them at all.

The term is hardcoded on both sides

Client. planFeatures() emits the bullet '30-day subscription' for every plan with priceCents > 0. It is a constant in a ternary — PlanInfo carries planId, name, requestsPerMinute, requestsPerDay, priceCents, fiatCurrency, and no duration at all. The string reaches the user on the plan cards (PlanCard.tsx:73) and twice in PlanScreen.tsx (:976, :985).

Server. pricing_plans has no duration column either — after V1/V4/V6/V23 its shape is id, name, requests_per_minute, requests_per_day, price, price_cents. The term lives in ApiKeyUtils.PAYMENT_VALIDITY_DAYS = 30 and is applied identically to every plan through getExpiryStartingFrom() at fulfilment; the legacy flow's proration divides by the same constant. A per-plan term is not expressible; the only escapes are the admin override updateActiveUntil(id, instant) and the free tier's NULL active_until.

So a limit-only paid plan is representable today ONLY as active_until = NULL — which is exactly the shape the UI reads as "free".

Three places that would then lie

  1. SubscriptionModal.tsx:102'Never expires — free plan' is rendered whenever activeUntil is null, regardless of the plan. A paid limit-only plan would print its real name on the line above and be called free on the line below.
  2. SubscriptionModal.tsx:101'Renews / expires: …'. Nothing renews: the subscription is not recurring, there is no auto-charge, it simply expires. The word is already wrong today.
  3. planFeatures.ts — the '30-day subscription' bullet, on every paid card, whatever the plan's real term.

isPlanSelectable() also rests on the assumption: it blocks re-buying the CURRENT plan while active because "the gateway resets the window to now+30d with no time carry-over". With no window, that rationale disappears.

Bonus: a dead widget already ships

SubscriptionModal.tsx:125 renders <ResetRow resetAt={null} …> — the value is hardcoded. msUntil(null) is null, so the row always reads "Daily limit resets in — continuously". The comment says it is "kept for when the gateway adds a reset time". Today it is a countdown label with nothing to count, next to a limiter (bucket4j) that refills continuously.

Suggested shape

  • Derive the term from data instead of a constant: an explicit duration on the plan (null = no expiry), so the card bullet and the Settings line can both be generated rather than asserted. Needs the gateway half — filed as item 8 of unicitynetwork/aggregator-subscription#83.
  • Until then, at least stop equating "no expiry" with "free": key that copy on the plan's price, not on the presence of a date.
  • Either wire ResetRow to a real reset time or drop it.

Related

  • unicitynetwork/aggregator-subscription#83 — item 8, the server-side half
  • #509 — the billing surface, which would have to state a plan's term
  • #510 — the downgrade that silently burns the remainder of the 30-day period