Skip to content

billing: a plan row states its reload bound and the reload it switches on - #6

Merged
zeekay merged 2 commits into
mainfrom
plan-reloads
Nov 2, 2026
Merged

zeekay merged 2 commits into
mainfrom
plan-reloads

Conversation

@zeekay

@zeekay zeekay commented Nov 2, 2026 •

Copy link
Copy Markdown
Member

A plan row now states its own auto-reload behaviour, so a plan can switch auto-reload on when it is bought and can lift the daily reload bound. Both are data on the row, edited with the plan.

What changed

plan.Limits (models/plan), stored in the row's envelope, compared by the seed, and never served by GET /v1/billing/plans (Capacity stays capacities only):

  • reloadsPerDay (*int): the most automatic reloads a day for an org the plan serves. Absent uses the package bound ReloadsPerDay (3, unchanged for every plan that says nothing). 0 means unbounded.
  • reload (*plan.Reload{thresholdCents, amountCents}): the auto-recharge rule that buying the plan with a card switches on.

Sweep (api/billing/autorecharge.go). The sweep reads the bound from the authority row the org's own account is served from (servedSubscription → servedAs → plan row by slug). An unbounded plan skips the daily check; the day's count is still kept. A bounded plan's refusal names its own bound. The rule's perDay reports the org's real bound.

Card sale (api/billing/subscribe_card.go). Once a card has paid the first period, a sale to the org's own account on a plan with a reload switches the org's rule on. It goes through the same write as PUT /v1/billing/recharge, so the card-on-file check and amount bounds still apply. A rule the org already switched on is left as it stands; one switched off, or no rule at all, is set to the plan's figures. If the rule cannot be written, the failure is logged and the sale stands.

Separate commit (models/plan YearlyTotal). Only a stated year is billed annually: AnnualTotal, else the legacy rung's stated year. A row stating neither answers 0, so a yearly purchase is refused with plan %q is not sold annually. PriceAnnual is a per-month price tag and is never multiplied into a charge.

Data, not code

No row in the embed changes. An Ultra row would carry "limits": {"reloadsPerDay": 0, "reload": {"thresholdCents": 1000, "amountCents": 5000}}, written through the admin plan op.

Tests

  • New: api/billing/reload_test.go and models/plan/reload_test.go, covering an unbounded plan reloading past 3 a day, a plan's bound of 1 being kept and named, the default for no bound and for no plan, the public catalog carrying neither field, buying a plan with a reload switching the rule on, buying without one storing no rule, the org's own switched-on rule being kept and a switched-off one being set, and the envelope round-trip.
  • New: api/billing/monthly_test.go.
  • TestPlanAtInterval now passes in full. Already failing on origin/main and not touched here: TestSubscribeTrial_NoChargeCardVaultedTrialing.

Rollout

Cloud picks this up with a commerce tag and a go.mod pin.

https://claude.ai/code/session_016DKtyuzxU43Gsc6Pm9vKTF

…s on

plan.Limits gains reloadsPerDay (the most automatic reloads a day for an
org the plan serves; absent is the sweep's ReloadsPerDay, 0 is unbounded)
and reload {thresholdCents, amountCents} (the auto-recharge rule buying the
plan with a card switches on). Both persist in the row's envelope, are
compared by the seed, and stay off the public catalog's Capacity.

The sweep reads the bound from the authority row the org's own account is
served from; an unbounded plan skips the daily check and keeps the count.
The rule's perDay reports that bound. A card sale to the org's own account
on a plan with a reload switches the rule on through the same write the
endpoint uses (card on file, amount bounds), leaving a rule the org
switched on as it stands; a rule that cannot be written is logged and the
sale stands.

Claude-Session: https://claude.ai/code/session_016DKtyuzxU43Gsc6Pm9vKTF
YearlyTotal answers AnnualTotal, else the legacy rung's stated year, and 0
for a row stating neither, so planAtInterval refuses a yearly purchase of
such a row with "plan %q is not sold annually". PriceAnnual is a per-month
price tag and is never multiplied into a charge.

Claude-Session: https://claude.ai/code/session_016DKtyuzxU43Gsc6Pm9vKTF
@zeekay
zeekay merged commit af5b52d into main Nov 2, 2026
5 of 6 checks passed
@zeekay
zeekay deleted the plan-reloads branch November 2, 2026 06:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant