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
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.
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.
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
Limits and expiry ARE visible today — Settings → Subscription shows the plan
name, a "Renews / expires: " line and two usage bars reading
used / limitfor the per-day and per-minute ceilings. What is not visible isthat 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 everyplan with
priceCents > 0. It is a constant in a ternary —PlanInfocarriesplanId,name,requestsPerMinute,requestsPerDay,priceCents,fiatCurrency, and no duration at all. The string reaches the user on the plancards (
PlanCard.tsx:73) and twice inPlanScreen.tsx(:976, :985).Server.
pricing_planshas no duration column either — after V1/V4/V6/V23its shape is
id,name,requests_per_minute,requests_per_day,price,price_cents. The term lives inApiKeyUtils.PAYMENT_VALIDITY_DAYS = 30and isapplied identically to every plan through
getExpiryStartingFrom()atfulfilment; 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 NULLactive_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
SubscriptionModal.tsx:102—'Never expires — free plan'is renderedwhenever
activeUntilis null, regardless of the plan. A paid limit-onlyplan would print its real name on the line above and be called free on the
line below.
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.
planFeatures.ts— the'30-day subscription'bullet, on every paidcard, whatever the plan's real term.
isPlanSelectable()also rests on the assumption: it blocks re-buying theCURRENT 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:125renders<ResetRow resetAt={null} …>— the valueis hardcoded.
msUntil(null)is null, so the row always reads "Daily limitresets 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
plan (
null= no expiry), so the card bullet and the Settings line can bothbe generated rather than asserted. Needs the gateway half — filed as item 8
of unicitynetwork/aggregator-subscription#83.
the plan's price, not on the presence of a date.
ResetRowto a real reset time or drop it.Related