Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,15 @@ type Config struct {
RazorpayKeyID string // RAZORPAY_KEY_ID — API key ID (used server-side)
RazorpayKeySecret string // RAZORPAY_KEY_SECRET — API key secret
RazorpayWebhookSecret string // RAZORPAY_WEBHOOK_SECRET — webhook signature verification
RazorpayPlanIDHobby string // RAZORPAY_PLAN_ID_HOBBY — plan_id for hobby tier
RazorpayPlanIDPro string // RAZORPAY_PLAN_ID_PRO — plan_id for pro tier
RazorpayPlanIDTeam string // RAZORPAY_PLAN_ID_TEAM — plan_id for team tier
RazorpayPlanIDHobby string // RAZORPAY_PLAN_ID_HOBBY — plan_id for hobby tier (monthly)
RazorpayPlanIDPro string // RAZORPAY_PLAN_ID_PRO — plan_id for pro tier (monthly)
RazorpayPlanIDTeam string // RAZORPAY_PLAN_ID_TEAM — plan_id for team tier (monthly)
// Yearly billing variants. When unset, the corresponding yearly checkout
// returns 503 billing_not_configured so partial rollout (monthly already
// live, yearly plans not yet created in Razorpay dashboard) is safe.
RazorpayPlanIDHobbyYearly string // RAZORPAY_PLAN_ID_HOBBY_YEARLY — plan_id for hobby tier (yearly)
RazorpayPlanIDProYearly string // RAZORPAY_PLAN_ID_PRO_YEARLY — plan_id for pro tier (yearly)
RazorpayPlanIDTeamYearly string // RAZORPAY_PLAN_ID_TEAM_YEARLY — plan_id for team tier (yearly)
ResendAPIKey string
GitHubClientID string
GitHubClientSecret string
Expand Down Expand Up @@ -127,9 +133,12 @@ func Load() *Config {
RazorpayKeyID: os.Getenv("RAZORPAY_KEY_ID"),
RazorpayKeySecret: os.Getenv("RAZORPAY_KEY_SECRET"),
RazorpayWebhookSecret: os.Getenv("RAZORPAY_WEBHOOK_SECRET"),
RazorpayPlanIDHobby: os.Getenv("RAZORPAY_PLAN_ID_HOBBY"),
RazorpayPlanIDPro: os.Getenv("RAZORPAY_PLAN_ID_PRO"),
RazorpayPlanIDTeam: os.Getenv("RAZORPAY_PLAN_ID_TEAM"),
RazorpayPlanIDHobby: os.Getenv("RAZORPAY_PLAN_ID_HOBBY"),
RazorpayPlanIDPro: os.Getenv("RAZORPAY_PLAN_ID_PRO"),
RazorpayPlanIDTeam: os.Getenv("RAZORPAY_PLAN_ID_TEAM"),
RazorpayPlanIDHobbyYearly: os.Getenv("RAZORPAY_PLAN_ID_HOBBY_YEARLY"),
RazorpayPlanIDProYearly: os.Getenv("RAZORPAY_PLAN_ID_PRO_YEARLY"),
RazorpayPlanIDTeamYearly: os.Getenv("RAZORPAY_PLAN_ID_TEAM_YEARLY"),
ResendAPIKey: os.Getenv("RESEND_API_KEY"),
GitHubClientID: os.Getenv("GITHUB_CLIENT_ID"),
GitHubClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"),
Expand Down
110 changes: 94 additions & 16 deletions internal/handlers/billing.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,20 @@ func NewBillingHandler(db *sql.DB, cfg *config.Config, emailClient *email.Client
}

// checkoutRequest is the request body for POST /api/v1/billing/checkout.
//
// PlanFrequency selects between the monthly and yearly Razorpay plan_id for
// the requested tier. Accepted values: "monthly" (default when empty),
// "yearly". Any other value is rejected as 400 invalid_frequency. The team's
// canonical tier (the value stored on teams.plan_tier) is unchanged by
// frequency — only the underlying Razorpay subscription differs.
type checkoutRequest struct {
Plan string `json:"plan"`
Plan string `json:"plan"`
PlanFrequency string `json:"plan_frequency"`
}

// razorpayPlanIDs returns the configured Razorpay plan_id for each tier.
// razorpayPlanIDs returns the configured monthly Razorpay plan_id for each
// tier. Used by ChangePlanAPI which today supports monthly-only plan
// changes; yearly changes go through a new checkout subscription.
func (h *BillingHandler) razorpayPlanIDs() map[string]string {
m := make(map[string]string)
if h.cfg.RazorpayPlanIDHobby != "" {
Expand All @@ -72,15 +81,63 @@ func (h *BillingHandler) razorpayPlanIDs() map[string]string {
return m
}

// planIDToTier maps a Razorpay plan_id back to an instant.dev tier name.
// Defaults to "pro" when the plan_id is unrecognised.
// razorpayPlanIDFor returns the configured plan_id for (tier, frequency)
// where frequency is "monthly" or "yearly". Returns "" when the tier or
// frequency has no plan_id configured (operator hasn't created it in the
// Razorpay dashboard yet) — callers must surface 503 billing_not_configured.
func (h *BillingHandler) razorpayPlanIDFor(tier, frequency string) string {
switch tier {
case "hobby":
if frequency == "yearly" {
return h.cfg.RazorpayPlanIDHobbyYearly
}
return h.cfg.RazorpayPlanIDHobby
case "pro":
if frequency == "yearly" {
return h.cfg.RazorpayPlanIDProYearly
}
return h.cfg.RazorpayPlanIDPro
case "team":
if frequency == "yearly" {
return h.cfg.RazorpayPlanIDTeamYearly
}
return h.cfg.RazorpayPlanIDTeam
}
return ""
}

// planIDToTier maps a Razorpay plan_id back to a canonical instant.dev tier
// name. Recognises both monthly and yearly plan IDs and returns the bare
// tier (e.g. "pro") in either case — the webhook stores canonical tiers on
// teams.plan_tier so limits resolution stays cycle-agnostic. Defaults to
// "pro" when the plan_id is unrecognised.
//
// An empty planID never matches anything: in development some env vars may
// be "" and we must not silently classify a missing/empty webhook plan_id
// or coincidentally-empty cfg slot as the matching tier.
func (h *BillingHandler) planIDToTier(planID string) string {
switch planID {
case h.cfg.RazorpayPlanIDTeam:
if planID == "" {
return "pro"
}
// Explicit per-tier comparison to skip empty cfg slots — an unconfigured
// yearly variant should not consume a "" webhook plan_id and steal its
// canonical-tier mapping from another configured cfg value.
if h.cfg.RazorpayPlanIDTeam != "" && planID == h.cfg.RazorpayPlanIDTeam {
return "team"
case h.cfg.RazorpayPlanIDPro:
}
if h.cfg.RazorpayPlanIDTeamYearly != "" && planID == h.cfg.RazorpayPlanIDTeamYearly {
return "team"
}
if h.cfg.RazorpayPlanIDPro != "" && planID == h.cfg.RazorpayPlanIDPro {
return "pro"
}
if h.cfg.RazorpayPlanIDProYearly != "" && planID == h.cfg.RazorpayPlanIDProYearly {
return "pro"
case h.cfg.RazorpayPlanIDHobby:
}
if h.cfg.RazorpayPlanIDHobby != "" && planID == h.cfg.RazorpayPlanIDHobby {
return "hobby"
}
if h.cfg.RazorpayPlanIDHobbyYearly != "" && planID == h.cfg.RazorpayPlanIDHobbyYearly {
return "hobby"
}
return "pro"
Expand Down Expand Up @@ -115,12 +172,22 @@ func (h *BillingHandler) CreateCheckoutAPI(c *fiber.Ctx) error {
}

plan := strings.ToLower(strings.TrimSpace(body.Plan))
var planID string
// plan_frequency selects monthly vs yearly Razorpay plan_id. Empty maps
// to "monthly" so existing callers (which never set the field) keep
// today's behaviour. Anything other than monthly|yearly is rejected so
// a typo doesn't silently fall back to the wrong cycle.
frequency := strings.ToLower(strings.TrimSpace(body.PlanFrequency))
if frequency == "" {
frequency = "monthly"
}
if frequency != "monthly" && frequency != "yearly" {
return respondError(c, fiber.StatusBadRequest, "invalid_frequency",
"plan_frequency must be 'monthly' or 'yearly'")
}

switch plan {
case "hobby":
planID = h.cfg.RazorpayPlanIDHobby
case "pro":
planID = h.cfg.RazorpayPlanIDPro
case "hobby", "pro":
// fall through — plan_id is resolved by razorpayPlanIDFor below.
case "team":
// Team tier is under development — block customer-initiated
// subscribe via the public API. The internal /internal/set-tier
Expand All @@ -131,11 +198,13 @@ func (h *BillingHandler) CreateCheckoutAPI(c *fiber.Ctx) error {
default:
return respondError(c, fiber.StatusBadRequest, "invalid_plan", "plan must be 'hobby' or 'pro'")
}
planID := h.razorpayPlanIDFor(plan, frequency)

if h.cfg.RazorpayKeyID == "" || h.cfg.RazorpayKeySecret == "" || planID == "" {
slog.Warn("billing.checkout.not_configured",
"team_id", teamID,
"plan", plan,
"plan_frequency", frequency,
"key_set", h.cfg.RazorpayKeyID != "",
"secret_set", h.cfg.RazorpayKeySecret != "",
"plan_id_set", planID != "",
Expand All @@ -146,14 +215,22 @@ func (h *BillingHandler) CreateCheckoutAPI(c *fiber.Ctx) error {

client := razorpay.NewClient(h.cfg.RazorpayKeyID, h.cfg.RazorpayKeySecret)

// total_count is the number of billing cycles before the subscription
// auto-completes. Monthly: 12 cycles ≈ 12 months. Yearly: 1 cycle ≈ 1
// year. cancel-at-cycle-end exits earlier via the cancelled webhook.
totalCount := 12
if frequency == "yearly" {
totalCount = 1
}
subBody := map[string]interface{}{
"plan_id": planID,
"total_count": 12, // 12 billing cycles; cancel-at-cycle-end exits early via webhook
"total_count": totalCount,
"quantity": 1,
"customer_notify": 1,
"notes": map[string]interface{}{
"team_id": teamID.String(),
"plan": plan,
"team_id": teamID.String(),
"plan": plan,
"plan_frequency": frequency,
},
}

Expand Down Expand Up @@ -197,6 +274,7 @@ func (h *BillingHandler) CreateCheckoutAPI(c *fiber.Ctx) error {
slog.Info("billing.checkout.created",
"team_id", teamID,
"plan", plan,
"plan_frequency", frequency,
"subscription_id", subID,
"request_id", requestID,
)
Expand Down
161 changes: 161 additions & 0 deletions internal/handlers/billing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -569,5 +569,166 @@ func TestGetBillingState_TrialTeam_SurfacesTrialStatus(t *testing.T) {
assert.NotEmpty(t, body["next_renewal_at"], "trial status must surface trial_ends_at as next_renewal_at")
}

// ─── CreateCheckoutAPI plan_frequency (P2 annual pricing) ────────────────

// checkoutAppNoDB builds a tiny Fiber app for testing checkout-handler
// validation paths that never reach the DB or Razorpay (invalid input /
// 503 not-configured branches). The team_id local is fixed.
func checkoutAppNoDB(t *testing.T, cfg *config.Config) *fiber.App {
t.Helper()
bh := handlers.NewBillingHandler(nil, cfg, email.New(""))
app := fiber.New(fiber.Config{
ErrorHandler: func(c *fiber.Ctx, err error) error {
if errors.Is(err, handlers.ErrResponseWritten) {
return nil
}
code := fiber.StatusInternalServerError
if e, ok := err.(*fiber.Error); ok {
code = e.Code
}
return c.Status(code).JSON(fiber.Map{"ok": false, "error": "internal_error", "message": err.Error()})
},
})
app.Use(func(c *fiber.Ctx) error {
c.Locals(middleware.LocalKeyTeamID, uuid.NewString())
return c.Next()
})
app.Post("/api/v1/billing/checkout", bh.CreateCheckoutAPI)
return app
}

func postCheckout(t *testing.T, app *fiber.App, body map[string]any) (int, map[string]any) {
t.Helper()
b, err := json.Marshal(body)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/api/v1/billing/checkout", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req, 5000)
require.NoError(t, err)
defer resp.Body.Close()
var out map[string]any
_ = json.NewDecoder(resp.Body).Decode(&out)
return resp.StatusCode, out
}

// TestCheckout_PlanFrequency_InvalidValue_Returns400 verifies that any
// frequency other than monthly|yearly is rejected before Razorpay is
// contacted — a typo can't silently fall back to monthly.
func TestCheckout_PlanFrequency_InvalidValue_Returns400(t *testing.T) {
cfg := &config.Config{
JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!",
RazorpayKeyID: "rzp_test_key",
RazorpayKeySecret: "rzp_test_secret",
RazorpayPlanIDPro: "plan_monthly_pro",
RazorpayPlanIDProYearly: "plan_yearly_pro",
}
app := checkoutAppNoDB(t, cfg)
status, body := postCheckout(t, app, map[string]any{
"plan": "pro",
"plan_frequency": "lifetime",
})
assert.Equal(t, http.StatusBadRequest, status)
assert.Equal(t, "invalid_frequency", body["error"])
}

// TestCheckout_PlanFrequency_YearlyUnconfigured_Returns503 verifies that
// when the operator hasn't created the yearly Razorpay plan yet and
// RAZORPAY_PLAN_ID_*_YEARLY is empty, the request fails fast with 503
// instead of trying to subscribe with an empty plan_id.
func TestCheckout_PlanFrequency_YearlyUnconfigured_Returns503(t *testing.T) {
cfg := &config.Config{
JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!",
RazorpayKeyID: "rzp_test_key",
RazorpayKeySecret: "rzp_test_secret",
RazorpayPlanIDPro: "plan_monthly_pro",
// RazorpayPlanIDProYearly intentionally left empty.
}
app := checkoutAppNoDB(t, cfg)
status, body := postCheckout(t, app, map[string]any{
"plan": "pro",
"plan_frequency": "yearly",
})
assert.Equal(t, http.StatusServiceUnavailable, status)
assert.Equal(t, "billing_not_configured", body["error"])
}

// TestCheckout_PlanFrequency_MonthlyDefault_NoFrequency verifies that
// requests with no plan_frequency field continue to behave as monthly
// (back-compat with the pre-P2 dashboard).
func TestCheckout_PlanFrequency_MonthlyDefault_NoFrequency(t *testing.T) {
cfg := &config.Config{
JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!",
RazorpayKeyID: "rzp_test_key",
RazorpayKeySecret: "rzp_test_secret",
// No monthly Pro plan configured -> expect 503 not_configured.
// (Verifies it tries monthly when frequency is omitted.)
RazorpayPlanIDProYearly: "plan_yearly_pro_set",
}
app := checkoutAppNoDB(t, cfg)
status, body := postCheckout(t, app, map[string]any{
"plan": "pro",
})
// monthly plan_id is empty → 503
assert.Equal(t, http.StatusServiceUnavailable, status)
assert.Equal(t, "billing_not_configured", body["error"])
}

// TestCheckout_PlanFrequency_TeamGuard_StillFires verifies the team-tier
// guard runs before frequency resolution — team is unavailable on either
// cycle while the multi-seat surface is in development.
func TestCheckout_PlanFrequency_TeamGuard_StillFires(t *testing.T) {
cfg := &config.Config{
JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!",
RazorpayKeyID: "rzp_test_key",
RazorpayKeySecret: "rzp_test_secret",
RazorpayPlanIDTeam: "plan_monthly_team",
RazorpayPlanIDTeamYearly: "plan_yearly_team",
}
app := checkoutAppNoDB(t, cfg)
for _, freq := range []string{"monthly", "yearly", ""} {
body := map[string]any{"plan": "team"}
if freq != "" {
body["plan_frequency"] = freq
}
status, resp := postCheckout(t, app, body)
assert.Equal(t, http.StatusBadRequest, status,
"team is locked regardless of frequency=%q", freq)
assert.Equal(t, "tier_unavailable", resp["error"])
}
}

// TestPlanIDToTier_MapsYearlyPlanIDsToCanonicalTier verifies the webhook's
// plan_id → tier resolver recognises yearly plan IDs and maps them back
// to the canonical (bare) tier name. teams.plan_tier always stores the
// canonical tier so limits resolution is cycle-agnostic.
func TestPlanIDToTier_MapsYearlyPlanIDsToCanonicalTier(t *testing.T) {
cfg := &config.Config{
RazorpayPlanIDHobby: "plan_monthly_hobby",
RazorpayPlanIDHobbyYearly: "plan_yearly_hobby",
RazorpayPlanIDPro: "plan_monthly_pro",
RazorpayPlanIDProYearly: "plan_yearly_pro",
RazorpayPlanIDTeam: "plan_monthly_team",
RazorpayPlanIDTeamYearly: "plan_yearly_team",
}
bh := handlers.NewBillingHandler(nil, cfg, email.New(""))
cases := []struct {
planID string
want string
}{
{"plan_monthly_hobby", "hobby"},
{"plan_yearly_hobby", "hobby"},
{"plan_monthly_pro", "pro"},
{"plan_yearly_pro", "pro"},
{"plan_monthly_team", "team"},
{"plan_yearly_team", "team"},
{"", "pro"}, // empty defaults to pro (unchanged)
{"plan_unknown_xx", "pro"}, // unknown defaults to pro (unchanged)
}
for _, c := range cases {
got := handlers.ExportedPlanIDToTier(bh, c.planID)
assert.Equal(t, c.want, got, "planIDToTier(%q)", c.planID)
}
}

// Ensure the billing test file compiles and is non-empty.
var _ = fmt.Sprintf
9 changes: 9 additions & 0 deletions internal/handlers/export_billing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package handlers

// ExportedPlanIDToTier exposes the unexported planIDToTier resolver to
// the external _test package so the new yearly plan-id mapping can be
// asserted without making the helper itself public. Only included in the
// test binary thanks to the _test.go suffix.
func ExportedPlanIDToTier(h *BillingHandler, planID string) string {
return h.planIDToTier(planID)
}
Loading