fix(billing): honour annual checkouts, and let Business be sold - #20
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two billing defects found while wiring Teams up for sale.
Annual was unbuyable from every surface
CheckoutRequestderivesDeserializewithoutdeny_unknown_fields, sobilling_periodwas silently dropped,intervalstayedNone, andyearlyevaluated 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_YEARLYandLS_VARIANT_TEAMS_YEARLYwere dead configuration as a result.wants_yearly()now readsinterval, falls back tobilling_period, and accepts"annual"alongside"yearly". An explicitintervalstill wins when both are present, so no existing caller changes behaviour.Business could not be sold, and would have charged silently
create_checkoutrejected"business"with 400, andtier_from_variant_idresolved 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_tierexisted 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
Six new tests, including one asserting
billing_period: "annual"is honoured — the assertion whose absence let annual go unbuyable.Deployment notes
LS_VARIANT_BUSINESS_MONTHLYis set; no variant exists yet.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.