From 5c8af8bee5d99c320e0f619e54d81144ca0e7322 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Wed, 13 May 2026 09:10:40 +0530 Subject: [PATCH] =?UTF-8?q?plans:=20differentiate=20yearly=20discount=20?= =?UTF-8?q?=E2=80=94=20hobby=20'save=201=20month',=20pro/team=20'2=20month?= =?UTF-8?q?s=20free'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hobby Annual is now $99/yr (= $9 x 11 = 8.3% off, "save 1 month"). Pro Annual stays $490/yr (= $49 x 10 = 17% off, "2 months free"). Team Annual stays $1990/yr (= $199 x 10 = 17% off, "2 months free"). Strategic intent: when a hobby user sees their annual savings is small but Pro Annual saves "2 months free / $98", the differential nudges them to tier-skip into Pro Annual rather than just upgrade frequency. Tests: - Split TestYearlyIsTwoMonthsFree into TestProAnnualIsTwoMonthsFree (pro+team only, 10/12 ratio) + TestHobbyAnnualIsOneMonthFree (hobby only, 11/12 ratio). - Renamed TestYearlyIsExactlyMonthlyTimesTen to TestProTeamYearlyIsMonthlyTimesTen and added TestHobbyYearlyIsMonthlyTimesEleven for the new x11 lock. - Added TestTierDiscountDifferentiation locking the strategic intent: pro_yearly_ratio < hobby_yearly_ratio (and same for team). --- plans/plans.go | 11 ++++-- plans/plans_test.go | 90 +++++++++++++++++++++++++++++++++++++-------- 2 files changed, 82 insertions(+), 19 deletions(-) diff --git a/plans/plans.go b/plans/plans.go index c473a14..929aac0 100644 --- a/plans/plans.go +++ b/plans/plans.go @@ -472,15 +472,18 @@ plans: custom_domains: false sla: false # hobby_yearly mirrors hobby exactly — same limits + features. Only the - # billing period and price differ ($90/yr = $9 x 10 — "2 months free" vs - # $9 x 12). The "2 months free" framing (Athenic-style) beats percentage-off - # by ~3.4x in conversion per PRICING-BEST-PRACTICES-2026-05-13.md. + # billing period and price differ ($99/yr = $9 x 11 — "save 1 month" vs + # $9 x 12). Hobby Annual gets a smaller discount than Pro/Team Annual + # (which keep "2 months free" = $X x 10) so the savings differential + # nudges hobbyists to tier-skip into Pro Annual rather than just + # upgrading their billing frequency. Locked by + # TestTierDiscountDifferentiation in plans_test.go. # The webhook upgrades teams to the "hobby" tier regardless of which # cycle the user paid on; this variant exists only so the checkout # handler can pick the right Razorpay plan_id at subscribe time. hobby_yearly: display_name: "Hobby (yearly)" - price_monthly_cents: 9000 + price_monthly_cents: 9900 billing_period: "yearly" trial_days: 14 limits: diff --git a/plans/plans_test.go b/plans/plans_test.go index 4664c69..928b951 100644 --- a/plans/plans_test.go +++ b/plans/plans_test.go @@ -177,18 +177,19 @@ func TestYearlyPrices_DiscountedVsMonthlyTimesTwelve(t *testing.T) { } } -// TestYearlyIsTwoMonthsFree locks the yearly-pricing contract: -// (yearly / 12) / monthly must equal 10/12 ≈ 0.8333 within a small tolerance -// for each of hobby/pro/team. This is the mathematical expression of "2 -// months free" — pay 10 months, get 12. The framing beats percentage-off by -// ~3.4x in conversion per PRICING-BEST-PRACTICES-2026-05-13.md (Athenic). -// Future price changes that accidentally drift the discount (e.g. forgetting -// to re-derive the yearly cents from the new monthly) will fail this test. -func TestYearlyIsTwoMonthsFree(t *testing.T) { +// TestProAnnualIsTwoMonthsFree locks the Pro/Team yearly-pricing contract: +// (yearly / 12) / monthly must equal 10/12 ≈ 0.8333 within a small tolerance. +// This is the mathematical expression of "2 months free" — pay 10 months, +// get 12. The framing beats percentage-off by ~3.4x in conversion per +// PRICING-BEST-PRACTICES-2026-05-13.md (Athenic). Hobby is *intentionally* +// excluded: it gets a smaller "save 1 month" discount (see +// TestHobbyAnnualIsOneMonthFree) so the savings differential nudges +// hobbyists to tier-skip into Pro Annual rather than just upgrade frequency. +func TestProAnnualIsTwoMonthsFree(t *testing.T) { r := plans.Default() const tolerance = 0.01 const twoMonthsFreeRatio = 10.0 / 12.0 // ≈ 0.8333 - for _, base := range []string{"hobby", "pro", "team"} { + for _, base := range []string{"pro", "team"} { monthly := float64(r.Get(base).PriceMonthly) yearly := float64(r.Get(base + "_yearly").PriceMonthly) require.Greater(t, monthly, 0.0, "%s monthly price must be > 0", base) @@ -199,13 +200,33 @@ func TestYearlyIsTwoMonthsFree(t *testing.T) { } } -// TestYearlyIsExactlyMonthlyTimesTen is the strict integer-cents lock for -// the "2 months free" pricing model: yearly_price_cents == monthly_price_cents * 10 -// exactly. This makes the "2 months free" claim provable to the cent and -// keeps the Razorpay plan_id <-> dashboard display values in lockstep. -func TestYearlyIsExactlyMonthlyTimesTen(t *testing.T) { +// TestHobbyAnnualIsOneMonthFree locks the Hobby-specific "save 1 month" +// contract: (yearly / 12) / monthly must equal 11/12 ≈ 0.9167. Hobby +// Annual is deliberately a weaker discount than Pro/Team Annual so the +// savings differential nudges hobbyists to tier-skip into Pro Annual +// (which saves "2 months free / $98") rather than just upgrade frequency. +func TestHobbyAnnualIsOneMonthFree(t *testing.T) { r := plans.Default() - for _, base := range []string{"hobby", "pro", "team"} { + const tolerance = 0.01 + const oneMonthFreeRatio = 11.0 / 12.0 // ≈ 0.9167 + monthly := float64(r.Get("hobby").PriceMonthly) + yearly := float64(r.Get("hobby_yearly").PriceMonthly) + require.Greater(t, monthly, 0.0, "hobby monthly price must be > 0") + ratio := (yearly / 12.0) / monthly + assert.InDelta(t, oneMonthFreeRatio, ratio, tolerance, + "hobby_yearly effective monthly / hobby monthly must be 11/12 ≈ 0.9167 (save 1 month); got %.4f (yearly=%d, monthly=%d)", + ratio, int(yearly), int(monthly)) +} + +// TestProTeamYearlyIsMonthlyTimesTen is the strict integer-cents lock for +// the Pro/Team "2 months free" pricing model: yearly_price_cents == +// monthly_price_cents * 10 exactly. This makes the "2 months free" claim +// provable to the cent and keeps Razorpay plan_id <-> dashboard display +// values in lockstep. Hobby has its own x11 lock (see +// TestHobbyYearlyIsMonthlyTimesEleven). +func TestProTeamYearlyIsMonthlyTimesTen(t *testing.T) { + r := plans.Default() + for _, base := range []string{"pro", "team"} { monthly := r.Get(base).PriceMonthly yearly := r.Get(base + "_yearly").PriceMonthly require.Greater(t, monthly, 0, "%s monthly price must be > 0", base) @@ -215,6 +236,45 @@ func TestYearlyIsExactlyMonthlyTimesTen(t *testing.T) { } } +// TestHobbyYearlyIsMonthlyTimesEleven is the strict integer-cents lock for +// the Hobby "save 1 month" pricing model: hobby_yearly == hobby monthly * 11 +// exactly. Differentiated from Pro/Team (which use x10) so Hobby Annual +// looks deliberately weaker, nudging tier-skip to Pro Annual. +func TestHobbyYearlyIsMonthlyTimesEleven(t *testing.T) { + r := plans.Default() + monthly := r.Get("hobby").PriceMonthly + yearly := r.Get("hobby_yearly").PriceMonthly + require.Greater(t, monthly, 0, "hobby monthly price must be > 0") + assert.Equal(t, monthly*11, yearly, + "hobby_yearly (%d cents) must equal hobby monthly (%d cents) * 11 = %d cents", + yearly, monthly, monthly*11) +} + +// TestTierDiscountDifferentiation locks the strategic intent: Pro Annual +// must be a *strictly better* discount than Hobby Annual so the savings +// differential nudges hobbyists to tier-skip rather than just upgrade +// frequency. Expressed as: pro_yearly_ratio < hobby_yearly_ratio where +// ratio = (yearly / 12) / monthly. Lower ratio = better discount. If +// someone "fixes" Hobby to also be 10/12, this test fails — the +// differentiation is the product directive, not an accident. +func TestTierDiscountDifferentiation(t *testing.T) { + r := plans.Default() + ratio := func(base string) float64 { + monthly := float64(r.Get(base).PriceMonthly) + yearly := float64(r.Get(base + "_yearly").PriceMonthly) + return (yearly / 12.0) / monthly + } + hobbyRatio := ratio("hobby") + proRatio := ratio("pro") + teamRatio := ratio("team") + assert.Less(t, proRatio, hobbyRatio, + "pro_yearly ratio (%.4f) must be strictly < hobby_yearly ratio (%.4f) so Pro Annual is the obviously-best value", + proRatio, hobbyRatio) + assert.Less(t, teamRatio, hobbyRatio, + "team_yearly ratio (%.4f) must be strictly < hobby_yearly ratio (%.4f) so Team Annual is the obviously-best value", + teamRatio, hobbyRatio) +} + func TestValidatePromotion_ValidCode_ReturnsPromotion(t *testing.T) { yaml := ` plans: