Skip to content

fix(billing): honour annual checkouts, and let Business be sold - #20

Merged
kipavy merged 1 commit into
mainfrom
fix/billing-interval-and-business
Sep 12, 2026
Merged

fix(billing): honour annual checkouts, and let Business be sold#20
kipavy merged 1 commit into
mainfrom
fix/billing-interval-and-business

Conversation

@kipavy

@kipavy kipavy commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Two billing defects found while wiring Teams up for sale.

Annual was unbuyable from every surface

server expects   interval: Option<String>   compared against "yearly"
portal sends     billing_period             value "annual"
desktop sends    nothing

CheckoutRequest derives Deserialize without deny_unknown_fields, so billing_period was silently dropped, interval stayed None, and yearly evaluated false. Every checkout ever made resolved the monthly variant, including for customers who selected annual — which is the price the pricing page leads with ($7/mo and $15/user/mo "billed annually"). A customer choosing annual would be billed at the monthly rate.

LS_VARIANT_PRO_YEARLY and LS_VARIANT_TEAMS_YEARLY were dead configuration as a result.

wants_yearly() now reads interval, falls back to billing_period, and accepts "annual" alongside "yearly". An explicit interval still wins when both are present, so no existing caller changes behaviour.

Business could not be sold, and would have charged silently

create_checkout rejected "business" with 400, and tier_from_variant_id resolved only Pro and Teams variants. Had a Business product been created in LemonSqueezy, a purchase would have succeeded, the webhook would have logged "variant is not configured; skipping tier update", and the customer would have been charged with no tier granted.

Both paths now cover Business via LS_VARIANT_BUSINESS_MONTHLY / _YEARLY. Business resolves monthly-only — a yearly request returns the same variant rather than failing — matching a pricing card that advertises no annual discount. The code is inert until those variables are set.

A deliberate invariant is being retired

tier_from_variant_id_never_grants_business_tier existed specifically to stop a variant being wired to Business, its comment describing that as a mistake to guard against. Making Business self-serve removes it by design.

Worth noting: that test would have stayed green either way, because it only ever exercised Pro and Teams variants. It is rewritten as business_tier_is_granted_only_by_its_own_configured_variant, locking the half that still holds — no pro, teams, or unknown variant may reach Business, and only the configured Business variant may.

Testing

cargo test billing    20 passed, 0 failed

Six new tests, including one asserting billing_period: "annual" is honoured — the assertion whose absence let annual go unbuyable.

Deployment notes

  • Business stays unsellable until LS_VARIANT_BUSINESS_MONTHLY is set; no variant exists yet.
  • Business intentionally has no seat minimum; Teams keeps its 3-seat floor and 422. A one-seat Business at $30 undercuts three-seat Teams at $54, so that may want revisiting as a pricing decision.
  • Separately, the configured Teams variant IDs (1563603 / 1563598) do not match the store's actual variants (2120136 / 2120147). That is an environment fix, not a code fix, and it is required before the now-public "Get Teams" button can complete a purchase.

Annual was unbuyable from every surface. The portal sends
`billing_period: "annual"` and the desktop client sends nothing, but the
handler only read `interval == "yearly"`. CheckoutRequest derives Deserialize
without deny_unknown_fields, so billing_period was silently dropped, yearly
evaluated false, and every checkout resolved the monthly variant — including
for customers who picked annual, which is the price the pricing page leads
with. wants_yearly() now reads interval, falls back to billing_period, and
accepts "annual" alongside "yearly"; an explicit interval still wins.

LS_VARIANT_PRO_YEARLY and LS_VARIANT_TEAMS_YEARLY were therefore dead
configuration. They are now reachable.

Business could not be sold at all: create_checkout rejected the plan with 400,
and tier_from_variant_id resolved only pro and teams, so a Business purchase
would have been charged while the webhook logged "variant is not configured;
skipping tier update" and granted nothing. Both now cover business via
LS_VARIANT_BUSINESS_MONTHLY/_YEARLY, and the plan resolves monthly-only — a
yearly request returns the same variant rather than failing, matching a
pricing card that advertises no annual discount.

This deliberately retires the invariant that variant mapping can never grant
business. The test locking it kept passing only because it never set the
business variables, so it is rewritten to lock the half that still holds: no
pro, teams, or unknown variant may reach business, and only the configured
business variant may.

Business intentionally has no seat minimum; Teams keeps its 3-seat floor.
@kipavy
kipavy merged commit 4e3a770 into main Sep 12, 2026
2 checks passed
@kipavy
kipavy deleted the fix/billing-interval-and-business branch September 12, 2026 21:59
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