From 4df84cf30033241395418344d0da7fd44e527dc4 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Wed, 13 May 2026 07:58:17 +0530 Subject: [PATCH] plans: yearly discount 17% -> 10% (hobby $97.20 / pro $529.20 / team $2149.20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2 shipped the yearly variants at ~17% off monthly. User feedback: 17% is too steep a give-up on annual revenue; standardize on 10% off across all three tiers to keep yearly attractive without leaving margin on the table. New prices (annual amount in cents, stored in price_monthly_cents per the existing schema): hobby_yearly: 9000 -> 9720 ($90.00 -> $97.20) pro_yearly: 49000 -> 52920 ($490.00 -> $529.20) team_yearly: 199000 -> 214920 ($1990.00 -> $2149.20) Each new price = (monthly * 12 * 0.9), giving an effective monthly rate of $8.10 / $44.10 / $179.10 respectively. Tests: - existing TestYearlyVariants_MirrorMonthlyLimits still passes (limits + features unchanged) - existing TestYearlyPrices_DiscountedVsMonthlyTimesTwelve still passes - new TestYearlyDiscountIsExactly10Percent locks the contract: (yearly / 12) / monthly == 0.9 +/- 0.01 for hobby/pro/team. Future price changes that drift the discount fail loudly. Operator action required (not automatable from this PR): the existing RAZORPAY_PLAN_ID_HOBBY_YEARLY / _PRO_YEARLY / _TEAM_YEARLY env vars still point at the OLD prices in the Razorpay dashboard. Operator must EITHER edit the 3 existing yearly plans in Razorpay to the new prices ($97.20, $529.20, $2149.20) OR create 3 new plans + rotate the env vars in the k8s secret. Until then, checkout will charge the old amounts even though the dashboard quotes the new ones. Dashboard impact: none — the "Save $X/yr" badge reads PriceMonthly from the registry, so it auto-updates once this lands. Co-Authored-By: Claude Opus 4.7 (1M context) --- plans/plans.go | 12 ++++++------ plans/plans_test.go | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/plans/plans.go b/plans/plans.go index 68b3365..b0f0d8b 100644 --- a/plans/plans.go +++ b/plans/plans.go @@ -472,13 +472,13 @@ plans: custom_domains: false sla: false # hobby_yearly mirrors hobby exactly — same limits + features. Only the - # billing period and price differ ($90/yr ≈ $7.50/mo, ~17% off vs $9 x 12). + # billing period and price differ ($97.20/yr ≈ $8.10/mo, 10% off vs $9 x 12). # 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: 9720 billing_period: "yearly" trial_days: 14 limits: @@ -525,10 +525,10 @@ plans: alerts: true custom_domains: true sla: false - # pro_yearly mirrors pro exactly. $490/yr ≈ $40.83/mo (~17% off $49 x 12). + # pro_yearly mirrors pro exactly. $529.20/yr ≈ $44.10/mo (10% off $49 x 12). pro_yearly: display_name: "Pro (yearly)" - price_monthly_cents: 49000 + price_monthly_cents: 52920 billing_period: "yearly" trial_days: 0 limits: @@ -575,10 +575,10 @@ plans: alerts: true custom_domains: true sla: true - # team_yearly mirrors team exactly. $1990/yr ≈ $165.83/mo (~17% off $199 x 12). + # team_yearly mirrors team exactly. $2149.20/yr ≈ $179.10/mo (10% off $199 x 12). team_yearly: display_name: "Team (yearly)" - price_monthly_cents: 199000 + price_monthly_cents: 214920 billing_period: "yearly" trial_days: 0 limits: diff --git a/plans/plans_test.go b/plans/plans_test.go index 24adbab..a19cd57 100644 --- a/plans/plans_test.go +++ b/plans/plans_test.go @@ -177,6 +177,25 @@ func TestYearlyPrices_DiscountedVsMonthlyTimesTwelve(t *testing.T) { } } +// TestYearlyDiscountIsExactly10Percent locks the yearly-discount contract: +// (yearly / 12) / monthly must equal 0.9 within a small tolerance for each +// of hobby/pro/team. 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 TestYearlyDiscountIsExactly10Percent(t *testing.T) { + r := plans.Default() + const tolerance = 0.01 + for _, base := range []string{"hobby", "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) + ratio := (yearly / 12.0) / monthly + assert.InDelta(t, 0.9, ratio, tolerance, + "%s_yearly effective monthly / %s monthly must be 0.9 (10%% off); got %.4f (yearly=%d, monthly=%d)", + base, base, ratio, int(yearly), int(monthly)) + } +} + func TestValidatePromotion_ValidCode_ReturnsPromotion(t *testing.T) { yaml := ` plans: