From fd4af01b060e1d9aa789044d9f0dc4a0046d2f77 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Wed, 13 May 2026 08:50:20 +0530 Subject: [PATCH] billing: validate + redeem admin-issued promo codes alongside plans-yaml codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/v1/billing/promotion/validate now unifies two promo-code sources: 1. plans.Registry.ValidatePromotion (TWITTER15/LAUNCH50 broadcast codes, already wired in PR #47) — checked first. 2. admin_promo_codes table (single-use codes issued by an admin via /api/v1/admin/customers/:team_id/promo, table from PR #48) — fall-through when the plans-yaml side returns "not found". The admin path enforces single-use at validate time (returns promotion_already_used + the new AgentActionPromotionAlreadyUsed sentence) and at the webhook side via UPDATE ... WHERE used_at IS NULL, so two concurrent redemptions can't double-spend a code. Cross-team codes surface as plain "promotion_invalid" — we don't reveal their existence. POST /api/v1/billing/checkout now accepts promotion_code; when the code matches an admin_promo_codes row for the team, the checkout stamps notes.admin_promo_code_id on the Razorpay subscription. The subscription.charged webhook reads that notes key and marks used_at best-effort (fail-open: a redemption miss must not undo the tier upgrade). Tests cover: - plans-yaml regression (PR #47 happy path still works with DB wired) - admin unused/used/expired/cross-team - amount_off and first_month_free kind round-trip - webhook redemption hook (with notes, without notes, redelivery, invalid UUID) - model-level race: two concurrent MarkAdminPromoCodeUsed callers, exactly one wins. Note on the brief's "wrong plan for admin code → invalid": admin codes are scoped by team_id, not plan; admin_promo_codes.applies_to is INTEGER (a percent_off cap in cents per openapi.go), not a tier list. The handler echoes the requested plan back in discount.applies_to so the dashboard renders "applies to " uniformly, but does not reject by plan. Documented in the validate handler's lookupAdminPromotion comment. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/handlers/agent_action.go | 17 + .../handlers/agent_action_contract_test.go | 2 + internal/handlers/billing.go | 135 +++- internal/handlers/billing_promotion.go | 245 ++++++- .../handlers/billing_promotion_redeem_test.go | 690 ++++++++++++++++++ internal/handlers/billing_promotion_test.go | 6 +- internal/models/admin_promo_codes.go | 70 ++ internal/router/router.go | 11 +- 8 files changed, 1137 insertions(+), 39 deletions(-) create mode 100644 internal/handlers/billing_promotion_redeem_test.go diff --git a/internal/handlers/agent_action.go b/internal/handlers/agent_action.go index 8410bcd..33c9155 100644 --- a/internal/handlers/agent_action.go +++ b/internal/handlers/agent_action.go @@ -118,6 +118,23 @@ const AgentActionPrivateDeployRequiresAllowedIPs = "Tell the user a private depl // code") and contains the full https://instanode.dev/billing URL. const AgentActionPromotionInvalid = "Tell the user this promo code isn't valid for the requested plan. Have them try a different code at https://instanode.dev/billing — promotion codes are case-insensitive." +// AgentActionPromotionAlreadyUsed is returned in the 200 + ok:false body when +// an admin-issued single-use promo code is presented at /promotion/validate +// but its used_at column is already non-null. The wall is distinct from +// AgentActionPromotionInvalid because the remedy is different — "try a +// different code" is wrong advice when the code itself was valid but already +// redeemed (typically by another teammate). The sentence names the specific +// reason ("already redeemed by someone on this team") and the exact next +// action ("ask the admin who issued it for a new one") with the full URL. +const AgentActionPromotionAlreadyUsed = "Tell the user this promo code has already been redeemed by someone on this team. Ask the admin who issued it for a new one at https://instanode.dev/billing." + +// AgentActionPromotionExpired is returned when an admin-issued promo code's +// expires_at is in the past. The plans-yaml path's "expired" branch shares +// the AgentActionPromotionInvalid copy via classifyPromotionError, but for +// admin codes we want a distinct "this code has expired, ask for a fresh +// one" sentence because the remedy is different from "try another code." +const AgentActionPromotionExpired = "Tell the user this promo code has expired. Ask the admin who issued it for a fresh code at https://instanode.dev/billing — admin codes have a fixed validity window." + // ───────────────────────────────────────────────────────────────────────────── // Storage / vault tier walls (called from respondErrorWithAgentAction) // ───────────────────────────────────────────────────────────────────────────── diff --git a/internal/handlers/agent_action_contract_test.go b/internal/handlers/agent_action_contract_test.go index 4667df0..73372d8 100644 --- a/internal/handlers/agent_action_contract_test.go +++ b/internal/handlers/agent_action_contract_test.go @@ -36,6 +36,8 @@ func agentActionContractCases() map[string]string { "AgentActionPrivateDeployRequiresAllowedIPs": AgentActionPrivateDeployRequiresAllowedIPs, "AgentActionAdminRequired": AgentActionAdminRequired, "AgentActionPromotionInvalid": AgentActionPromotionInvalid, + "AgentActionPromotionAlreadyUsed": AgentActionPromotionAlreadyUsed, + "AgentActionPromotionExpired": AgentActionPromotionExpired, // Builders — representative inputs covering tier/env/role/limit // interpolation. diff --git a/internal/handlers/billing.go b/internal/handlers/billing.go index 3919302..fdd76d7 100644 --- a/internal/handlers/billing.go +++ b/internal/handlers/billing.go @@ -27,6 +27,13 @@ import ( "instant.dev/internal/razorpaybilling" ) +// checkoutNoteAdminPromoCodeID is the Razorpay subscription `notes` key we +// use to round-trip an admin_promo_codes.id from checkout to the activation +// webhook. The webhook reads this exact key to look up the row to mark used. +// Kept as a named constant (per the project's named-constants convention) so +// the checkout side and the webhook side cannot drift. +const checkoutNoteAdminPromoCodeID = "admin_promo_code_id" + // BillingHandler handles billing and Razorpay webhook endpoints. type BillingHandler struct { db *sql.DB @@ -59,9 +66,21 @@ func NewBillingHandler(db *sql.DB, cfg *config.Config, emailClient *email.Client // "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. +// +// PromotionCode is an optional admin-issued promo code (one of the rows in +// admin_promo_codes). When set, we resolve the code's DB row server-side and +// stamp its id into the Razorpay subscription `notes` field — the webhook +// handler then marks used_at = now() on subscription.activated / +// subscription.charged. We do NOT forward the code text to Razorpay's +// `offer_id` field today (Razorpay's offer model is separate from our +// admin-issued codes); the discount is bookkeeping-only at this layer +// pending the offer-mapping migration. Plans-yaml codes (LAUNCH50 etc.) are +// still allowed in this field but produce no notes side-effect — they're +// handled at validate-time via plans.Registry and never need DB tracking. type checkoutRequest struct { Plan string `json:"plan"` PlanFrequency string `json:"plan_frequency"` + PromotionCode string `json:"promotion_code"` } // razorpayPlanIDs returns the configured monthly Razorpay plan_id for each @@ -222,16 +241,62 @@ func (h *BillingHandler) CreateCheckoutAPI(c *fiber.Ctx) error { if frequency == "yearly" { totalCount = 1 } + notes := map[string]interface{}{ + "team_id": teamID.String(), + "plan": plan, + "plan_frequency": frequency, + } + + // Admin-code redemption: if the caller supplied a promotion_code and it + // matches an admin_promo_codes row for THIS team, stamp the row's id + // into the subscription notes so the webhook handler can mark used_at + // on activation. Cross-team codes don't match (the lookup is scoped by + // team_id). Plans-yaml codes (LAUNCH50 etc.) also don't match — those + // flow through the plans registry and need no DB bookkeeping. + // + // Failures here are best-effort: an unknown code, an already-used code, + // or a transient DB error should not block the checkout itself. + // /promotion/validate is the user-facing gate that surfaces the + // "already used / expired" copy. This branch only writes the bookkeeping + // hook used by the activation webhook. + if rawCode := strings.TrimSpace(body.PromotionCode); rawCode != "" && h.db != nil { + row, lookupErr := models.GetAdminPromoCodeByCode(c.Context(), h.db, rawCode, teamID) + switch { + case lookupErr == nil && !row.UsedAt.Valid && !row.ExpiresAt.IsZero() && time.Now().UTC().Before(row.ExpiresAt): + notes[checkoutNoteAdminPromoCodeID] = row.ID.String() + case lookupErr == nil: + // Row exists but is expired / used — leave notes untouched. The + // /promotion/validate gate should have caught this; if the + // client bypassed it, we silently drop the bookkeeping. + slog.Info("billing.checkout.promo_code_unusable", + "team_id", teamID, + "code", strings.ToUpper(rawCode), + "used", row.UsedAt.Valid, + "expired", time.Now().UTC().After(row.ExpiresAt), + "request_id", requestID, + ) + case errors.Is(lookupErr, models.ErrAdminPromoCodeNotFound): + // Unknown / cross-team / plans-yaml code — no DB bookkeeping + // needed. Plans-yaml codes flow through Razorpay's own + // offer/coupon channel if configured server-side. + default: + // Transient DB failure on the lookup — log but proceed with + // checkout. Better to let the user pay than block on a brownout + // in the bookkeeping path. + slog.Warn("billing.checkout.promo_code_lookup_failed", + "error", lookupErr, + "team_id", teamID, + "request_id", requestID, + ) + } + } + subBody := map[string]interface{}{ "plan_id": planID, "total_count": totalCount, "quantity": 1, "customer_notify": 1, - "notes": map[string]interface{}{ - "team_id": teamID.String(), - "plan": plan, - "plan_frequency": frequency, - }, + "notes": notes, } sub, err := client.Subscription.Create(subBody, nil) @@ -425,6 +490,66 @@ func (h *BillingHandler) handleSubscriptionCharged(ctx context.Context, c *fiber // Best-effort audit emit for the Loops forwarder. Fail-open: an audit // error must not undo the tier update we already committed. emitSubscriptionChangeAudit(ctx, h.db, teamID, fromTier, tier, sub.ID) + + // Admin-code redemption: if the subscription notes carry an + // admin_promo_code_id (stamped at checkout time by CreateCheckoutAPI), + // mark the corresponding admin_promo_codes row as used. Best-effort: + // failures log only — the tier upgrade is already committed. The brief + // names the trigger event as subscription.activated; Razorpay's + // subscription.charged is the equivalent for subscriptions paid by + // invoice (which is our checkout flow), so we hook here instead. + // MarkAdminPromoCodeUsed uses `WHERE used_at IS NULL` so two concurrent + // webhook deliveries can't double-spend the code — the second one + // returns ErrAdminPromoCodeAlreadyUsed and we log + return. + maybeMarkAdminPromoCodeUsed(ctx, h.db, sub, teamID) +} + +// maybeMarkAdminPromoCodeUsed marks an admin-issued promo code as redeemed +// when the subscription notes carry one. Best-effort, no caller cares about +// the outcome — failures log and return. Race-safe via the +// `WHERE used_at IS NULL` predicate on MarkAdminPromoCodeUsed. +func maybeMarkAdminPromoCodeUsed(ctx context.Context, db *sql.DB, sub rzpSubscriptionEntity, teamID uuid.UUID) { + if db == nil { + return + } + idStr := strings.TrimSpace(sub.Notes[checkoutNoteAdminPromoCodeID]) + if idStr == "" { + return + } + id, err := uuid.Parse(idStr) + if err != nil { + slog.Warn("billing.subscription.charged.admin_promo_id_invalid", + "team_id", teamID, + "subscription_id", sub.ID, + "notes_id", idStr, + "error", err, + ) + return + } + if err := models.MarkAdminPromoCodeUsed(ctx, db, id); err != nil { + if errors.Is(err, models.ErrAdminPromoCodeAlreadyUsed) { + // Either a redelivery of the same webhook (idempotent) or a + // concurrent caller won the race. Either way: nothing to do. + slog.Info("billing.subscription.charged.admin_promo_already_used", + "team_id", teamID, + "subscription_id", sub.ID, + "admin_promo_code_id", id, + ) + return + } + slog.Warn("billing.subscription.charged.admin_promo_mark_used_failed", + "team_id", teamID, + "subscription_id", sub.ID, + "admin_promo_code_id", id, + "error", err, + ) + return + } + slog.Info("billing.subscription.charged.admin_promo_redeemed", + "team_id", teamID, + "subscription_id", sub.ID, + "admin_promo_code_id", id, + ) } // handleSubscriptionCancelled processes subscription.cancelled events (cancel → downgrade to hobby). diff --git a/internal/handlers/billing_promotion.go b/internal/handlers/billing_promotion.go index e0840c8..fb5aa59 100644 --- a/internal/handlers/billing_promotion.go +++ b/internal/handlers/billing_promotion.go @@ -31,6 +31,8 @@ package handlers // errors so a cache outage doesn't block valid checkouts. import ( + "database/sql" + "errors" "fmt" "log/slog" "strings" @@ -40,6 +42,7 @@ import ( "github.com/google/uuid" "github.com/redis/go-redis/v9" "instant.dev/internal/middleware" + "instant.dev/internal/models" "instant.dev/internal/plans" ) @@ -51,20 +54,28 @@ const promotionValidationsPerHour = 30 // BillingPromotionHandler serves POST /api/v1/billing/promotion/validate. // -// Separate from BillingHandler so the (rdb, plans) dependency is visible at -// the constructor boundary — BillingHandler proper deals with Razorpay -// state and doesn't need the plan registry today. Splitting also keeps the -// existing billing test rig (which constructs BillingHandler with nil DB) -// untouched. +// Separate from BillingHandler so the (db, rdb, plans) dependency is visible +// at the constructor boundary — BillingHandler proper deals with Razorpay +// state. Splitting also keeps the existing billing test rig untouched. +// +// The handler unifies two promotion-code sources: the static plans-yaml +// registry (broadcast codes like TWITTER15 / LAUNCH50) and the admin-issued +// single-use codes in the admin_promo_codes table (one-off codes scoped to +// a single team). Callers see one endpoint, one response shape; the +// handler dispatches internally based on which source the code lives in. type BillingPromotionHandler struct { + db *sql.DB rdb *redis.Client plans *plans.Registry } // NewBillingPromotionHandler constructs a BillingPromotionHandler. rdb may -// be nil — the rate-limiter then fails open (every request passes). -func NewBillingPromotionHandler(rdb *redis.Client, planRegistry *plans.Registry) *BillingPromotionHandler { - return &BillingPromotionHandler{rdb: rdb, plans: planRegistry} +// be nil — the rate-limiter then fails open (every request passes). db may +// be nil too — the admin-code fallback is skipped (the handler then behaves +// exactly like the PR #47 plans-yaml-only path, preserving backwards +// compatibility with the existing billing_promotion_test.go rig). +func NewBillingPromotionHandler(db *sql.DB, rdb *redis.Client, planRegistry *plans.Registry) *BillingPromotionHandler { + return &BillingPromotionHandler{db: db, rdb: rdb, plans: planRegistry} } // promotionValidateRequest is the JSON body for POST @@ -165,7 +176,42 @@ func (h *BillingPromotionHandler) ValidatePromotion(c *fiber.Ctx) error { // substring so the response carries a structured `error` field // regardless of the registry's wording. promo, validateErr := h.plans.ValidatePromotion(code, plan) - if validateErr != nil { + if validateErr == nil { + resp := promotionValidateResponse{ + OK: true, + Code: strings.ToUpper(code), + Discount: &promotionDiscount{ + Kind: "percent_off", + Value: promo.DiscountPercent, + AppliesTo: promo.AppliesTo, + MaxUses: promo.MaxUses, + Description: promo.Description, + }, + } + // ValidUntil mirrors Promotion.ExpiresAt (YYYY-MM-DD → ISO at end of + // day UTC). Empty string in the struct means "never expires" → we + // omit the field. We pick end-of-day (23:59:59Z) over start-of-day so + // "expires_at: 2026-12-31" displays as "valid through Dec 31", + // matching what an operator means when writing the YAML. + if promo.ExpiresAt != "" { + if t, parseErr := time.Parse("2006-01-02", promo.ExpiresAt); parseErr == nil { + resp.ValidUntil = t.UTC().Add(24*time.Hour - time.Second).Format(time.RFC3339) + } + } + return c.JSON(resp) + } + + // Plans-yaml said the code is unknown / expired / wrong plan. For the + // "unknown" case the user may have been given an admin-issued single-use + // code instead — fall back to the admin_promo_codes table before + // declaring the code invalid. For expired/wrong-plan results from the + // plans-yaml side, we DON'T fall through: those mean the code exists in + // the plans registry but isn't usable, and re-trying the same code as + // an admin lookup would only succeed if an admin happened to issue a + // code with the same name (vanishingly unlikely, but the semantics + // would be wrong — the user typed a plans-yaml code and saw the wrong + // reason). + if !isPromoNotFoundError(validateErr) || h.db == nil { errKind, message := classifyPromotionError(validateErr, code, plan) return c.JSON(promotionValidateResponse{ OK: false, @@ -175,28 +221,171 @@ func (h *BillingPromotionHandler) ValidatePromotion(c *fiber.Ctx) error { }) } - resp := promotionValidateResponse{ - OK: true, - Code: strings.ToUpper(code), - Discount: &promotionDiscount{ - Kind: "percent_off", - Value: promo.DiscountPercent, - AppliesTo: promo.AppliesTo, - MaxUses: promo.MaxUses, - Description: promo.Description, - }, + // Admin-code fallback. Single-row lookup scoped to the caller's team — + // cross-team codes are invisible (we don't reveal their existence on + // purpose; see GetAdminPromoCodeByCode docstring). + adminResp, adminErr := h.lookupAdminPromotion(c, teamID, code, plan) + if adminErr != nil { + // Transient DB failure on the admin lookup. Surface as "invalid" + // rather than a 503 — the user can re-try later, and a brownout on + // the rare admin-code path must not block checkout for the much + // more common plans-yaml path. Log loudly so ops sees it. + slog.Warn("billing.promotion.validate.admin_lookup_failed", + "error", adminErr, + "team_id", teamID, + "request_id", middleware.GetRequestID(c), + ) + return c.JSON(promotionValidateResponse{ + OK: false, + Error: "promotion_invalid", + Message: fmt.Sprintf("Promotion code %q is not valid for the %s plan.", strings.ToUpper(code), plan), + AgentAction: AgentActionPromotionInvalid, + }) + } + return c.JSON(adminResp) +} + +// isPromoNotFoundError returns true when the registry's ValidatePromotion +// returned a "not found" error (vs. expired/wrong-plan). Substring match +// because the registry uses fmt.Errorf with stable wording — keeping the +// check in one place isolates this handler from registry rewording. +func isPromoNotFoundError(err error) bool { + if err == nil { + return false } - // ValidUntil mirrors Promotion.ExpiresAt (YYYY-MM-DD → ISO at end of - // day UTC). Empty string in the struct means "never expires" → we - // omit the field. We pick end-of-day (23:59:59Z) over start-of-day so - // "expires_at: 2026-12-31" displays as "valid through Dec 31", - // matching what an operator means when writing the YAML. - if promo.ExpiresAt != "" { - if t, parseErr := time.Parse("2006-01-02", promo.ExpiresAt); parseErr == nil { - resp.ValidUntil = t.UTC().Add(24*time.Hour - time.Second).Format(time.RFC3339) + return strings.Contains(err.Error(), "not found") +} + +// lookupAdminPromotion handles the admin_promo_codes fallback path on the +// "code not found in plans-yaml" branch. Returns one of: +// +// - (response, nil) — the response to send (could be success or +// one of the ok:false branches: +// promotion_invalid / promotion_expired / +// promotion_already_used). +// - (response{}, ) — transient DB failure. Caller decides +// whether to surface this as 503 or fold +// into a generic "invalid" response. +// +// Single-use enforcement happens at validate time AND again at the webhook +// (UPDATE ... WHERE used_at IS NULL) so a race can't double-spend a code. +// This validate-time check is the friendly path: tell the user the code +// is already redeemed *before* they pay. +func (h *BillingPromotionHandler) lookupAdminPromotion(c *fiber.Ctx, teamID uuid.UUID, code, plan string) (promotionValidateResponse, error) { + row, err := models.GetAdminPromoCodeByCode(c.Context(), h.db, code, teamID) + if err != nil { + if errors.Is(err, models.ErrAdminPromoCodeNotFound) { + // Cross-team codes also surface as "not found" here — we don't + // disclose their existence. Same response as a plain-unknown + // code from the plans-yaml path. + return promotionValidateResponse{ + OK: false, + Error: "promotion_invalid", + Message: fmt.Sprintf("Promotion code %q is not valid for the %s plan.", strings.ToUpper(code), plan), + AgentAction: AgentActionPromotionInvalid, + }, nil } + // Transient DB failure — let caller decide. + return promotionValidateResponse{}, err + } + + upperCode := strings.ToUpper(strings.TrimSpace(code)) + + // Single-use: if used_at is set, surface the "already redeemed" branch + // with its distinct agent_action sentence. The dashboard renders the + // red state via the normal ok:false parser. + if row.UsedAt.Valid { + return promotionValidateResponse{ + OK: false, + Code: upperCode, + Error: "promotion_already_used", + Message: fmt.Sprintf("Promotion code %q has already been redeemed.", upperCode), + AgentAction: AgentActionPromotionAlreadyUsed, + }, nil + } + + // Expired admin code → distinct "promotion_expired" surface so the + // dashboard can show "this code has expired" copy. Comparing on UTC + // avoids the clock-skew edge case at the second around expiry. + if !row.ExpiresAt.IsZero() && time.Now().UTC().After(row.ExpiresAt) { + return promotionValidateResponse{ + OK: false, + Code: upperCode, + Error: "promotion_expired", + Message: fmt.Sprintf("Promotion code %q has expired.", upperCode), + AgentAction: AgentActionPromotionExpired, + }, nil + } + + // Plan-applicability for admin codes: + // + // admin_promo_codes.applies_to is INTEGER (per migration 021) and is + // documented (openapi.go) as the percent_off cap in cents — NOT a tier + // list. Admin codes are scoped to a team, not a plan: any plan the team + // chooses to subscribe to may apply the code. We therefore do not + // reject the validate request based on the requested plan; we echo + // back the plan that was asked for in the discount.applies_to field + // so the dashboard's PromoCodePanel renders "applies to " + // uniformly across both code sources. The plan filter on plans-yaml + // codes (LAUNCH50 → pro/team only) still works because those codes + // take the plans-yaml branch above. + return promotionValidateResponse{ + OK: true, + Code: upperCode, + Discount: adminPromoDiscount(row, plan), + // ValidUntil reflects the admin code's expires_at, full RFC3339 + // timestamp (vs. the YYYY-MM-DD coarseness of plans-yaml codes). + ValidUntil: row.ExpiresAt.UTC().Format(time.RFC3339), + }, nil +} + +// adminPromoDiscount maps an AdminPromoCode row onto the response.discount +// shape that PR #47 introduced for plans-yaml codes. The mapping is: +// +// • Kind — passthrough of admin_promo_codes.kind (one of percent_off / +// first_month_free / amount_off). The dashboard already +// expects "percent_off" today; first_month_free / amount_off +// extend that enum. +// • Value — admin_promo_codes.value. For percent_off this is 1..100; for +// amount_off this is cents; for first_month_free this is +// ignored at billing time (Razorpay free-period coupon). +// • AppliesTo — echoed as []string{plan} because admin codes apply to +// any plan the team subscribes to (the field is structural +// parity with plans-yaml; the actual filter is by team_id). +// • MaxUses — 1 for admin codes (single-use is the whole point of the +// admin_promo_codes table; plans-yaml uses -1 / 1000 etc.). +// • Description — synthesized human-readable copy for the dashboard's +// "applies to X" line; admin codes don't carry a description +// column, so we generate a stable one from kind + value. +func adminPromoDiscount(row *models.AdminPromoCode, plan string) *promotionDiscount { + return &promotionDiscount{ + Kind: row.Kind, + Value: row.Value, + AppliesTo: []string{plan}, + MaxUses: 1, + Description: adminPromoDescription(row), + } +} + +// adminPromoDescription returns the "applies to X" human-readable copy the +// dashboard's PromoCodePanel renders. Stable phrasing per kind so the +// dashboard's tests can match on substring without coupling to the value. +func adminPromoDescription(row *models.AdminPromoCode) string { + switch row.Kind { + case models.PromoKindPercentOff: + return fmt.Sprintf("%d%% off (admin-issued, single use)", row.Value) + case models.PromoKindFirstMonthFree: + return "First month free (admin-issued, single use)" + case models.PromoKindAmountOff: + // Value is cents; show as a rounded-dollar approximation. The + // actual charge math happens server-side at webhook time, so this + // copy is purely for the UI. + return fmt.Sprintf("$%.2f off (admin-issued, single use)", float64(row.Value)/100) + default: + // Unknown kind — should be impossible given the DB CHECK constraint + // in migration 021, but defensive copy beats a panic. + return "Admin-issued promo code (single use)" } - return c.JSON(resp) } // classifyPromotionError maps the registry's error strings to a stable diff --git a/internal/handlers/billing_promotion_redeem_test.go b/internal/handlers/billing_promotion_redeem_test.go new file mode 100644 index 0000000..a45689f --- /dev/null +++ b/internal/handlers/billing_promotion_redeem_test.go @@ -0,0 +1,690 @@ +package handlers_test + +// billing_promotion_redeem_test.go — covers the admin-code fallback inside +// POST /api/v1/billing/promotion/validate and the +// subscription.charged → admin_promo_codes.used_at redemption hook. +// +// Layered on top of billing_promotion_test.go (which exercises the +// plans-yaml-only path with a nil DB). These tests require TEST_DATABASE_URL +// because the admin-code path is purely DB-driven. +// +// Test surface: +// +// 1) Admin-issued code that exists + unused + not expired → 200 + ok:true +// with discount shape carrying the admin code's kind/value. +// 2) Admin code with used_at NOT NULL → 200 + ok:false + +// promotion_already_used + AgentActionPromotionAlreadyUsed. +// 3) Admin code with expires_at in the past → 200 + ok:false + +// promotion_expired + AgentActionPromotionExpired. +// 4) Admin code that belongs to a different team → 200 + ok:false + +// promotion_invalid (we don't reveal cross-team codes exist). +// 5) Webhook subscription.charged with notes.admin_promo_code_id → marks +// admin_promo_codes.used_at. +// 6) Webhook subscription.charged WITHOUT notes.admin_promo_code_id → no +// admin_promo_codes side-effect (regression-safe). +// 7) Plans-yaml code happy path still works when DB is wired (regression +// for PR #47 — the plans-yaml branch must not fall through to admin +// lookup when the registry finds the code). +// +// Note on "wrong plan" for admin codes: admin_promo_codes.applies_to is +// INTEGER (a percent-off cap in cents per openapi.go), NOT a list of +// applicable tiers. Admin codes are scoped to a team_id, not a plan, so +// the handler does not reject the validate request based on the requested +// plan — the discount.applies_to field echoes the requested plan back so +// the dashboard renders "applies to " uniformly. The brief's +// "wrong plan → promotion_invalid" item assumed plan-applicability that +// the migration 021 schema does not carry; that divergence is documented +// in the final PR description. + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/handlers" + "instant.dev/internal/middleware" + "instant.dev/internal/models" + "instant.dev/internal/plans" + "instant.dev/internal/testhelpers" +) + +// adminRedeemNeedsDB skips when no TEST_DATABASE_URL is configured. The +// admin-code path is purely DB-driven so there's no value in running these +// tests without a real test Postgres. +func adminRedeemNeedsDB(t *testing.T) (*sql.DB, func()) { + t.Helper() + if os.Getenv("TEST_DATABASE_URL") == "" { + t.Skip("admin-redeem tests: TEST_DATABASE_URL not set — skipping integration test") + } + return testhelpers.SetupTestDB(t) +} + +// adminRedeemRegistry loads a small plans.yaml fragment so the plans-yaml +// happy path can be regression-tested alongside the admin-code path. +// Mirrors promoTestYAML in billing_promotion_test.go but kept local so the +// two files can evolve independently. +func adminRedeemRegistry(t *testing.T) *plans.Registry { + t.Helper() + const yamlBody = ` +plans: + anonymous: + display_name: "Anonymous" + price_monthly_cents: 0 + trial_days: 0 + limits: { provisions_per_day: 5 } + features: {} + hobby: + display_name: "Hobby" + price_monthly_cents: 900 + trial_days: 0 + limits: { provisions_per_day: 50 } + features: {} + pro: + display_name: "Pro" + price_monthly_cents: 4900 + trial_days: 0 + limits: { provisions_per_day: 500 } + features: {} + team: + display_name: "Team" + price_monthly_cents: 19900 + trial_days: 0 + limits: { provisions_per_day: 5000 } + features: {} + +promotions: + - code: "TWITTER15" + discount_percent: 15 + applies_to: ["pro", "team"] + expires_at: "2099-12-31" + max_uses: -1 + description: "15% off Pro or Team — Twitter promotion" +` + dir := t.TempDir() + path := filepath.Join(dir, "plans.yaml") + require.NoError(t, os.WriteFile(path, []byte(yamlBody), 0o600)) + reg, err := plans.Load(path) + require.NoError(t, err) + return reg +} + +// adminRedeemApp builds the Fiber app for promotion-validate tests with both +// a real DB (so admin-code fallback works) and miniredis. teamID is seeded +// into c.Locals so the rate-limit + admin lookup scopes match a real +// authenticated session. +func adminRedeemApp(t *testing.T, db *sql.DB, teamID uuid.UUID) *fiber.App { + t.Helper() + mr, err := miniredis.Run() + require.NoError(t, err) + t.Cleanup(mr.Close) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = rdb.Close() }) + + reg := adminRedeemRegistry(t) + + app := fiber.New(fiber.Config{ + ErrorHandler: func(c *fiber.Ctx, err error) error { + if errors.Is(err, handlers.ErrResponseWritten) { + return nil + } + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"ok": false, "error": err.Error()}) + }, + }) + app.Use(middleware.RequestID()) + app.Use(func(c *fiber.Ctx) error { + c.Locals(middleware.LocalKeyTeamID, teamID.String()) + c.Locals(middleware.LocalKeyUserID, uuid.NewString()) + return c.Next() + }) + h := handlers.NewBillingPromotionHandler(db, rdb, reg) + app.Post("/api/v1/billing/promotion/validate", h.ValidatePromotion) + return app +} + +// postAdminRedeem posts a body and returns (status, parsed JSON). +func postAdminRedeem(t *testing.T, app *fiber.App, body any) (int, map[string]any) { + t.Helper() + raw, err := json.Marshal(body) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, "/api/v1/billing/promotion/validate", bytes.NewReader(raw)) + 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 + if resp.ContentLength != 0 { + _ = json.NewDecoder(resp.Body).Decode(&out) + } + return resp.StatusCode, out +} + +// seedAdminCode inserts an admin_promo_codes row with the supplied values +// and returns the persisted code + id. Callers can flip used_at / expires_at +// on the returned row before validating. Caller is responsible for cleanup +// (registered as t.Cleanup). +func seedAdminCode(t *testing.T, db *sql.DB, teamID uuid.UUID, opts adminCodeOpts) (string, uuid.UUID) { + t.Helper() + if opts.Kind == "" { + opts.Kind = models.PromoKindPercentOff + } + if opts.Value == 0 { + opts.Value = 25 + } + if opts.ExpiresAt.IsZero() { + opts.ExpiresAt = time.Now().UTC().Add(30 * 24 * time.Hour) + } + if opts.Code == "" { + // Codes are stored UPPER in the table (the production issuance path + // uppercases via generatePromoCode); the validate handler uppercases + // on lookup. Mirror that here so the seeded code round-trips. + opts.Code = strings.ToUpper("TEST" + uuid.NewString()[:4]) + } + + var id uuid.UUID + var usedAt interface{} + if opts.UsedAt != nil { + usedAt = *opts.UsedAt + } + + err := db.QueryRowContext(context.Background(), ` + INSERT INTO admin_promo_codes + (code, team_id, issued_by_email, kind, value, expires_at, used_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id + `, opts.Code, teamID, "admin@instanode.dev", opts.Kind, opts.Value, opts.ExpiresAt, usedAt).Scan(&id) + require.NoError(t, err, "seedAdminCode: insert failed") + + t.Cleanup(func() { + _, _ = db.Exec(`DELETE FROM admin_promo_codes WHERE id = $1`, id) + }) + return opts.Code, id +} + +type adminCodeOpts struct { + Code string + Kind string + Value int + ExpiresAt time.Time + UsedAt *time.Time +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +// TestValidatePromotion_AdminCode_Unused_ReturnsDiscount — happy path for an +// admin-issued, unused, unexpired code. Asserts the response shape matches +// the plans-yaml branch so the dashboard renders both source paths +// uniformly. +func TestValidatePromotion_AdminCode_Unused_ReturnsDiscount(t *testing.T) { + db, cleanup := adminRedeemNeedsDB(t) + defer cleanup() + + teamID := uuid.MustParse(testhelpers.MustCreateTeamDB(t, db, "hobby")) + defer db.Exec(`DELETE FROM teams WHERE id = $1`, teamID) + + code, _ := seedAdminCode(t, db, teamID, adminCodeOpts{Kind: models.PromoKindPercentOff, Value: 40}) + + app := adminRedeemApp(t, db, teamID) + status, body := postAdminRedeem(t, app, map[string]string{"code": code, "plan": "pro"}) + + require.Equal(t, http.StatusOK, status, "body=%v", body) + assert.Equal(t, true, body["ok"], "admin code should validate; body=%v", body) + assert.Equal(t, code, body["code"]) + discount, ok := body["discount"].(map[string]any) + require.True(t, ok, "discount must be populated on happy path; body=%v", body) + assert.Equal(t, "percent_off", discount["kind"]) + assert.Equal(t, float64(40), discount["value"]) + assert.Equal(t, float64(1), discount["max_uses"], "admin codes are single-use") + appliesTo, ok := discount["applies_to"].([]any) + require.True(t, ok) + // Admin codes apply to any plan the team chooses; we echo the requested + // plan back so the dashboard renders "applies to pro". + assert.Contains(t, appliesTo, "pro") +} + +// TestValidatePromotion_AdminCode_AmountOff_MapsCorrectly — admin codes can +// carry kind=amount_off (cents). Asserts the mapping flows through the +// discount.kind/value channel verbatim so dashboard / MCP clients can +// branch on kind. +func TestValidatePromotion_AdminCode_AmountOff_MapsCorrectly(t *testing.T) { + db, cleanup := adminRedeemNeedsDB(t) + defer cleanup() + + teamID := uuid.MustParse(testhelpers.MustCreateTeamDB(t, db, "hobby")) + defer db.Exec(`DELETE FROM teams WHERE id = $1`, teamID) + + code, _ := seedAdminCode(t, db, teamID, adminCodeOpts{Kind: models.PromoKindAmountOff, Value: 5000}) + + app := adminRedeemApp(t, db, teamID) + status, body := postAdminRedeem(t, app, map[string]string{"code": code, "plan": "pro"}) + + require.Equal(t, http.StatusOK, status) + assert.Equal(t, true, body["ok"]) + discount := body["discount"].(map[string]any) + assert.Equal(t, "amount_off", discount["kind"], "amount_off kind must round-trip to the response") + assert.Equal(t, float64(5000), discount["value"]) + assert.Contains(t, discount["description"], "off") +} + +// TestValidatePromotion_AdminCode_FirstMonthFree_MapsCorrectly — first-month-free +// kind is the third admin variant. Same round-trip assertion as +// amount_off so a future change to the kind enum is caught. +func TestValidatePromotion_AdminCode_FirstMonthFree_MapsCorrectly(t *testing.T) { + db, cleanup := adminRedeemNeedsDB(t) + defer cleanup() + + teamID := uuid.MustParse(testhelpers.MustCreateTeamDB(t, db, "hobby")) + defer db.Exec(`DELETE FROM teams WHERE id = $1`, teamID) + + code, _ := seedAdminCode(t, db, teamID, adminCodeOpts{Kind: models.PromoKindFirstMonthFree, Value: 0}) + + app := adminRedeemApp(t, db, teamID) + status, body := postAdminRedeem(t, app, map[string]string{"code": code, "plan": "pro"}) + + require.Equal(t, http.StatusOK, status) + assert.Equal(t, true, body["ok"]) + discount := body["discount"].(map[string]any) + assert.Equal(t, "first_month_free", discount["kind"]) + assert.Contains(t, discount["description"], "First month free") +} + +// TestValidatePromotion_AdminCode_AlreadyUsed_ReturnsOkFalse — used_at +// non-null must surface promotion_already_used + the distinct +// AgentActionPromotionAlreadyUsed sentence. The wall is friendlier than +// "promotion_invalid" because the remedy ("ask for a fresh code") differs. +func TestValidatePromotion_AdminCode_AlreadyUsed_ReturnsOkFalse(t *testing.T) { + db, cleanup := adminRedeemNeedsDB(t) + defer cleanup() + + teamID := uuid.MustParse(testhelpers.MustCreateTeamDB(t, db, "hobby")) + defer db.Exec(`DELETE FROM teams WHERE id = $1`, teamID) + + usedAt := time.Now().UTC().Add(-1 * time.Hour) + code, _ := seedAdminCode(t, db, teamID, adminCodeOpts{ + Kind: models.PromoKindPercentOff, + Value: 20, + UsedAt: &usedAt, + }) + + app := adminRedeemApp(t, db, teamID) + status, body := postAdminRedeem(t, app, map[string]string{"code": code, "plan": "pro"}) + + require.Equal(t, http.StatusOK, status, "200 + ok:false envelope, not 4xx; body=%v", body) + assert.Equal(t, false, body["ok"]) + assert.Equal(t, "promotion_already_used", body["error"]) + assert.Equal(t, handlers.AgentActionPromotionAlreadyUsed, body["agent_action"], + "must surface the distinct already-used agent_action, not the generic promotion_invalid one") + assert.Nil(t, body["discount"]) +} + +// TestValidatePromotion_AdminCode_Expired_ReturnsExpired — expires_at in +// the past surfaces promotion_expired + AgentActionPromotionExpired (NOT +// promotion_invalid). +func TestValidatePromotion_AdminCode_Expired_ReturnsExpired(t *testing.T) { + db, cleanup := adminRedeemNeedsDB(t) + defer cleanup() + + teamID := uuid.MustParse(testhelpers.MustCreateTeamDB(t, db, "hobby")) + defer db.Exec(`DELETE FROM teams WHERE id = $1`, teamID) + + code, _ := seedAdminCode(t, db, teamID, adminCodeOpts{ + Kind: models.PromoKindPercentOff, + Value: 20, + ExpiresAt: time.Now().UTC().Add(-24 * time.Hour), + }) + + app := adminRedeemApp(t, db, teamID) + status, body := postAdminRedeem(t, app, map[string]string{"code": code, "plan": "pro"}) + + require.Equal(t, http.StatusOK, status) + assert.Equal(t, false, body["ok"]) + assert.Equal(t, "promotion_expired", body["error"]) + assert.Equal(t, handlers.AgentActionPromotionExpired, body["agent_action"]) +} + +// TestValidatePromotion_AdminCode_DifferentTeam_RevealsNothing — a code +// issued to team A must surface as promotion_invalid (NOT promotion_* +// anything-else) when team B tries to validate it. We deliberately don't +// reveal cross-team codes exist — that would be an information disclosure +// (e.g. "this code belongs to a different team" leaks the existence of +// the row). +func TestValidatePromotion_AdminCode_DifferentTeam_RevealsNothing(t *testing.T) { + db, cleanup := adminRedeemNeedsDB(t) + defer cleanup() + + teamA := uuid.MustParse(testhelpers.MustCreateTeamDB(t, db, "hobby")) + teamB := uuid.MustParse(testhelpers.MustCreateTeamDB(t, db, "hobby")) + defer db.Exec(`DELETE FROM teams WHERE id IN ($1, $2)`, teamA, teamB) + + // Issue code to team A. + code, _ := seedAdminCode(t, db, teamA, adminCodeOpts{Kind: models.PromoKindPercentOff, Value: 25}) + + // Team B tries to validate it. + app := adminRedeemApp(t, db, teamB) + status, body := postAdminRedeem(t, app, map[string]string{"code": code, "plan": "pro"}) + + require.Equal(t, http.StatusOK, status) + assert.Equal(t, false, body["ok"], "cross-team codes must NOT validate; body=%v", body) + // Surfaces as plain "invalid" (same as an unknown code) so we don't + // disclose that a row exists. + assert.Equal(t, "promotion_invalid", body["error"]) +} + +// TestValidatePromotion_PlansYamlCode_StillWorks — regression for PR #47. +// With the DB wired in, plans-yaml codes must still take the plans-yaml +// branch and never fall through to the admin lookup. We confirm by asserting +// the discount payload matches the YAML registry's shape (max_uses=-1 from +// the YAML, not 1 from the admin-code synthesizer). +func TestValidatePromotion_PlansYamlCode_StillWorks(t *testing.T) { + db, cleanup := adminRedeemNeedsDB(t) + defer cleanup() + + teamID := uuid.MustParse(testhelpers.MustCreateTeamDB(t, db, "hobby")) + defer db.Exec(`DELETE FROM teams WHERE id = $1`, teamID) + + app := adminRedeemApp(t, db, teamID) + status, body := postAdminRedeem(t, app, map[string]string{"code": "TWITTER15", "plan": "pro"}) + + require.Equal(t, http.StatusOK, status, "body=%v", body) + assert.Equal(t, true, body["ok"]) + assert.Equal(t, "TWITTER15", body["code"]) + discount := body["discount"].(map[string]any) + assert.Equal(t, "percent_off", discount["kind"]) + assert.Equal(t, float64(15), discount["value"]) + // PR #47's plans-yaml shape: max_uses=-1 here, NOT the single-use=1 + // of the admin-code synthesizer. This asserts the dispatcher correctly + // kept this in the plans-yaml branch and never reached the admin code + // fallback. + assert.Equal(t, float64(-1), discount["max_uses"]) +} + +// TestValidatePromotion_PlansYamlWrongPlan_DoesNotFallThroughToAdmin — a +// plans-yaml code that doesn't apply to the requested plan must NOT be +// re-tried as an admin code. The classifier already returns +// "promotion_invalid" with the plans-yaml wording; falling through would +// produce stale "this code has expired" wording or worse. +func TestValidatePromotion_PlansYamlWrongPlan_DoesNotFallThroughToAdmin(t *testing.T) { + db, cleanup := adminRedeemNeedsDB(t) + defer cleanup() + + teamID := uuid.MustParse(testhelpers.MustCreateTeamDB(t, db, "hobby")) + defer db.Exec(`DELETE FROM teams WHERE id = $1`, teamID) + + app := adminRedeemApp(t, db, teamID) + // TWITTER15 applies to pro/team but not hobby. + status, body := postAdminRedeem(t, app, map[string]string{"code": "TWITTER15", "plan": "hobby"}) + + require.Equal(t, http.StatusOK, status) + assert.Equal(t, false, body["ok"]) + assert.Equal(t, "promotion_invalid", body["error"]) + assert.Contains(t, body["message"], "hobby", + "wrong-plan response must name the requested plan in the message") +} + +// ───────────────────────────────────────────────────────────────────────────── +// Webhook redemption hook +// ───────────────────────────────────────────────────────────────────────────── + +// makeChargedWithNotes builds a subscription.charged payload with an +// arbitrary notes map. Mirrors makeSubscriptionChargedPayload (in +// billing_test.go) but lets us inject admin_promo_code_id without a custom +// per-test struct. +func makeChargedWithNotes(t *testing.T, subscriptionID, planID string, notes map[string]string) []byte { + t.Helper() + notesAny := map[string]any{} + for k, v := range notes { + notesAny[k] = v + } + subEntity, _ := json.Marshal(map[string]any{ + "id": subscriptionID, + "entity": "subscription", + "plan_id": planID, + "status": "active", + "notes": notesAny, + }) + event := map[string]any{ + "entity": "event", + "event": "subscription.charged", + "payload": map[string]any{ + "subscription": map[string]any{ + "entity": json.RawMessage(subEntity), + }, + }, + } + payload, err := json.Marshal(event) + require.NoError(t, err) + return payload +} + +// TestBillingWebhook_SubscriptionCharged_AdminPromoCodeID_MarksUsed — +// the redemption-on-activation contract. Notes carry the +// admin_promo_code_id stamped by CreateCheckoutAPI; the webhook must +// flip used_at = now() best-effort. +func TestBillingWebhook_SubscriptionCharged_AdminPromoCodeID_MarksUsed(t *testing.T) { + db, cleanup := adminRedeemNeedsDB(t) + defer cleanup() + + app, cfg := billingWebhookDBApp(t, db) + + teamID := uuid.MustParse(testhelpers.MustCreateTeamDB(t, db, "hobby")) + defer db.Exec(`DELETE FROM teams WHERE id = $1`, teamID) + + _, promoID := seedAdminCode(t, db, teamID, adminCodeOpts{ + Kind: models.PromoKindPercentOff, + Value: 50, + }) + + notes := map[string]string{ + "team_id": teamID.String(), + "admin_promo_code_id": promoID.String(), + } + payload := makeChargedWithNotes(t, "sub_test_"+uuid.NewString(), cfg.RazorpayPlanIDPro, notes) + req := signedWebhookRequest(t, payload) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Assert the admin code row is now marked used. + var usedAt sql.NullTime + err = db.QueryRow(`SELECT used_at FROM admin_promo_codes WHERE id = $1`, promoID).Scan(&usedAt) + require.NoError(t, err) + assert.True(t, usedAt.Valid, "used_at must be set after subscription.charged with notes.admin_promo_code_id") + assert.WithinDuration(t, time.Now(), usedAt.Time, 30*time.Second, + "used_at should be set to ~now() (clock-skew tolerance: 30s)") +} + +// TestBillingWebhook_SubscriptionCharged_NoAdminPromoCodeID_NoSideEffect — +// regression-safe contract: a webhook without notes.admin_promo_code_id +// must not touch admin_promo_codes for the team. Proves the redemption +// hook is gated on the notes key, not on the team_id alone. +func TestBillingWebhook_SubscriptionCharged_NoAdminPromoCodeID_NoSideEffect(t *testing.T) { + db, cleanup := adminRedeemNeedsDB(t) + defer cleanup() + + app, cfg := billingWebhookDBApp(t, db) + + teamID := uuid.MustParse(testhelpers.MustCreateTeamDB(t, db, "hobby")) + defer db.Exec(`DELETE FROM teams WHERE id = $1`, teamID) + + // Seed an unused admin code that must stay unused. + _, promoID := seedAdminCode(t, db, teamID, adminCodeOpts{ + Kind: models.PromoKindPercentOff, + Value: 50, + }) + + // Charged webhook for the same team but no admin_promo_code_id in notes. + notes := map[string]string{"team_id": teamID.String()} + payload := makeChargedWithNotes(t, "sub_test_"+uuid.NewString(), cfg.RazorpayPlanIDPro, notes) + req := signedWebhookRequest(t, payload) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Admin code must remain unused. + var usedAt sql.NullTime + err = db.QueryRow(`SELECT used_at FROM admin_promo_codes WHERE id = $1`, promoID).Scan(&usedAt) + require.NoError(t, err) + assert.False(t, usedAt.Valid, "used_at must remain NULL — webhook without notes.admin_promo_code_id is a no-op") +} + +// TestBillingWebhook_SubscriptionCharged_AdminPromoCodeID_AlreadyUsed_NoOp — +// idempotent redelivery: a webhook arriving twice for the same subscription +// must not error and must not flip used_at a second time. The +// `WHERE used_at IS NULL` predicate in MarkAdminPromoCodeUsed enforces this; +// the test asserts the handler still returns 200 (Razorpay retries on +// non-2xx). +func TestBillingWebhook_SubscriptionCharged_AdminPromoCodeID_AlreadyUsed_NoOp(t *testing.T) { + db, cleanup := adminRedeemNeedsDB(t) + defer cleanup() + + app, cfg := billingWebhookDBApp(t, db) + + teamID := uuid.MustParse(testhelpers.MustCreateTeamDB(t, db, "hobby")) + defer db.Exec(`DELETE FROM teams WHERE id = $1`, teamID) + + // Seed the code as already used. + usedAt := time.Now().UTC().Add(-1 * time.Hour).Truncate(time.Second) + _, promoID := seedAdminCode(t, db, teamID, adminCodeOpts{ + Kind: models.PromoKindPercentOff, + Value: 50, + UsedAt: &usedAt, + }) + + notes := map[string]string{ + "team_id": teamID.String(), + "admin_promo_code_id": promoID.String(), + } + payload := makeChargedWithNotes(t, "sub_test_"+uuid.NewString(), cfg.RazorpayPlanIDPro, notes) + req := signedWebhookRequest(t, payload) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode, "redelivery must NOT 5xx — Razorpay would retry forever") + + // used_at must remain at its original value (the first-redemption time). + var dbUsedAt sql.NullTime + err = db.QueryRow(`SELECT used_at FROM admin_promo_codes WHERE id = $1`, promoID).Scan(&dbUsedAt) + require.NoError(t, err) + require.True(t, dbUsedAt.Valid) + assert.WithinDuration(t, usedAt, dbUsedAt.Time, time.Second, + "used_at must not be overwritten on redelivery") +} + +// TestBillingWebhook_SubscriptionCharged_AdminPromoCodeID_Invalid_NoCrash — +// defensive: a malformed UUID in notes.admin_promo_code_id must not crash +// the handler or 5xx. The webhook still returns 200 (Razorpay retries +// otherwise) and the tier upgrade still lands. +func TestBillingWebhook_SubscriptionCharged_AdminPromoCodeID_Invalid_NoCrash(t *testing.T) { + db, cleanup := adminRedeemNeedsDB(t) + defer cleanup() + + app, cfg := billingWebhookDBApp(t, db) + + teamID := uuid.MustParse(testhelpers.MustCreateTeamDB(t, db, "hobby")) + defer db.Exec(`DELETE FROM teams WHERE id = $1`, teamID) + + notes := map[string]string{ + "team_id": teamID.String(), + "admin_promo_code_id": "not-a-uuid", + } + payload := makeChargedWithNotes(t, "sub_test_"+uuid.NewString(), cfg.RazorpayPlanIDPro, notes) + req := signedWebhookRequest(t, payload) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Tier still moved to pro — bad notes don't block the upgrade. + var tier string + require.NoError(t, db.QueryRow(`SELECT plan_tier FROM teams WHERE id = $1`, teamID).Scan(&tier)) + assert.Equal(t, "pro", tier) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Model-level concurrency sanity check +// ───────────────────────────────────────────────────────────────────────────── + +// TestMarkAdminPromoCodeUsed_Race_OnlyOneWins exercises the single-use +// invariant at the model boundary: two concurrent UPDATE callers race; +// exactly one succeeds, the other gets ErrAdminPromoCodeAlreadyUsed. +// Catches the regression where a future refactor removes the +// `WHERE used_at IS NULL` predicate. +func TestMarkAdminPromoCodeUsed_Race_OnlyOneWins(t *testing.T) { + db, cleanup := adminRedeemNeedsDB(t) + defer cleanup() + + teamID := uuid.MustParse(testhelpers.MustCreateTeamDB(t, db, "hobby")) + defer db.Exec(`DELETE FROM teams WHERE id = $1`, teamID) + + _, promoID := seedAdminCode(t, db, teamID, adminCodeOpts{ + Kind: models.PromoKindPercentOff, + Value: 50, + }) + + type result struct{ err error } + results := make(chan result, 2) + for i := 0; i < 2; i++ { + go func() { + results <- result{err: models.MarkAdminPromoCodeUsed(context.Background(), db, promoID)} + }() + } + + wins := 0 + losses := 0 + for i := 0; i < 2; i++ { + r := <-results + switch { + case r.err == nil: + wins++ + case errors.Is(r.err, models.ErrAdminPromoCodeAlreadyUsed): + losses++ + default: + t.Fatalf("unexpected error from concurrent MarkAdminPromoCodeUsed: %v", r.err) + } + } + assert.Equal(t, 1, wins, "exactly one caller must win the race") + assert.Equal(t, 1, losses, "the loser must get ErrAdminPromoCodeAlreadyUsed") +} + +// ───────────────────────────────────────────────────────────────────────────── +// /billing/checkout promo_code stamping +// ───────────────────────────────────────────────────────────────────────────── + +// TestCheckout_PromotionCode_AdminIssued_StampsNotes — exercising the +// CreateCheckoutAPI promo-stamping branch end-to-end requires a Razorpay +// client we cannot reach in unit tests. So instead we directly hit +// /billing/checkout WITHOUT credentials (cfg.RazorpayKeyID="") and assert +// the lookup helper logic isn't bypassed by an early return. The actual +// notes write is covered indirectly via the webhook integration test. + +// We don't add the full end-to-end checkout test because the checkout +// handler calls live Razorpay; mocking the razorpay-go client at this +// boundary requires more surface than this PR should touch. The contract +// is covered by: +// - The validate-time tests above (do we recognise the code?). +// - The webhook test above (does notes.admin_promo_code_id mark used_at?). +// +// Coverage gap: if a future refactor accidentally stops stamping the notes +// at checkout time, the validate-time + webhook tests both still pass; only +// the production wire would silently drop the redemption. The mitigation is +// the named constant checkoutNoteAdminPromoCodeID — both call sites read +// the same constant. A follow-up could add an integration test that swaps +// out the razorpay-go client; punted for now. + diff --git a/internal/handlers/billing_promotion_test.go b/internal/handlers/billing_promotion_test.go index e2b318a..5596a8c 100644 --- a/internal/handlers/billing_promotion_test.go +++ b/internal/handlers/billing_promotion_test.go @@ -120,7 +120,11 @@ func newPromoApp(t *testing.T, rdb *redis.Client, reg *plans.Registry, authentic return c.Next() }) } - h := handlers.NewBillingPromotionHandler(rdb, reg) + // db=nil — the existing PR #47 tests cover only the plans-yaml path, so + // the admin-code fallback in BillingPromotionHandler is never reached. + // Passing nil keeps these tests hermetic (no TEST_DATABASE_URL needed) + // and exercises the "db is nil → skip admin fallback" branch. + h := handlers.NewBillingPromotionHandler(nil, rdb, reg) app.Post("/api/v1/billing/promotion/validate", h.ValidatePromotion) return app } diff --git a/internal/models/admin_promo_codes.go b/internal/models/admin_promo_codes.go index d7bd1de..26e7504 100644 --- a/internal/models/admin_promo_codes.go +++ b/internal/models/admin_promo_codes.go @@ -170,3 +170,73 @@ var ( ErrInvalidPromoDuration = errors.New("valid_for_days must be > 0") ErrInvalidPromoValue = errors.New("value must be >= 0") ) + +// ErrAdminPromoCodeNotFound is returned by GetAdminPromoCodeByCode when no row +// matches the (code, team_id) tuple. Wrapped as a sentinel so handlers can +// distinguish "no such code for this team" (caller error → 200+ok:false) +// from a transient DB failure (→ 503). +var ErrAdminPromoCodeNotFound = errors.New("admin promo code not found") + +// ErrAdminPromoCodeAlreadyUsed is returned by MarkAdminPromoCodeUsed when the +// UPDATE matched zero rows because used_at was already set (or the row no +// longer exists). Lets the caller fall through cleanly without re-querying. +var ErrAdminPromoCodeAlreadyUsed = errors.New("admin promo code already redeemed") + +// GetAdminPromoCodeByCode looks up an admin-issued promo code by its public +// `code` string, scoped to the supplied teamID. Returns the row even if +// used_at is set or expires_at is in the past — the caller (validate +// handler) inspects those fields to surface the right error code. +// +// Scoping by team_id is the whole point of the row's existence: admin codes +// are single-team — leaking the existence of a code that belongs to another +// team would be a cross-team information disclosure. The query is therefore +// (code, team_id) and `not found` covers both "no such code" and "code +// exists but belongs to a different team." +// +// Returns ErrAdminPromoCodeNotFound when no row matches. Any other error is +// a transient DB failure. +func GetAdminPromoCodeByCode(ctx context.Context, db *sql.DB, code string, teamID uuid.UUID) (*AdminPromoCode, error) { + row := &AdminPromoCode{} + err := db.QueryRowContext(ctx, ` + SELECT id, code, team_id, issued_by_email, kind, value, applies_to, used_at, expires_at, created_at + FROM admin_promo_codes + WHERE code = $1 AND team_id = $2 + `, strings.ToUpper(strings.TrimSpace(code)), teamID).Scan( + &row.ID, &row.Code, &row.TeamID, &row.IssuedByEmail, &row.Kind, + &row.Value, &row.AppliesTo, &row.UsedAt, &row.ExpiresAt, &row.CreatedAt, + ) + if err == sql.ErrNoRows { + return nil, ErrAdminPromoCodeNotFound + } + if err != nil { + return nil, fmt.Errorf("models.GetAdminPromoCodeByCode: %w", err) + } + return row, nil +} + +// MarkAdminPromoCodeUsed atomically transitions a row from used_at IS NULL to +// used_at = now(). Uses `WHERE used_at IS NULL` in the predicate so two +// concurrent webhook callers racing on the same code can't both succeed: +// the second UPDATE matches zero rows and returns ErrAdminPromoCodeAlreadyUsed. +// +// The caller is expected to treat ErrAdminPromoCodeAlreadyUsed as a no-op +// (the code was successfully redeemed by the racing caller — there is nothing +// to do). +func MarkAdminPromoCodeUsed(ctx context.Context, db *sql.DB, id uuid.UUID) error { + res, err := db.ExecContext(ctx, ` + UPDATE admin_promo_codes + SET used_at = now() + WHERE id = $1 AND used_at IS NULL + `, id) + if err != nil { + return fmt.Errorf("models.MarkAdminPromoCodeUsed: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("models.MarkAdminPromoCodeUsed: rows_affected: %w", err) + } + if n == 0 { + return ErrAdminPromoCodeAlreadyUsed + } + return nil +} diff --git a/internal/router/router.go b/internal/router/router.go index 6f8351f..0a9c069 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -355,11 +355,12 @@ func New(cfg *config.Config, db *sql.DB, rdb *redis.Client, geoDbs *middleware.G api.Post("/billing/update-payment", billing.UpdatePaymentMethodAPI) api.Post("/billing/change-plan", billing.ChangePlanAPI) - // Promo code validator — HTTP wrapper around plans.ValidatePromotion. - // Separate handler so its (rdb, planRegistry) deps are explicit at the - // constructor boundary; rate-limited per-team per-hour to make - // brute-forcing seed codes impractical. See billing_promotion.go. - billingPromoH := handlers.NewBillingPromotionHandler(rdb, planRegistry) + // Promo code validator — HTTP wrapper around plans.ValidatePromotion + + // admin_promo_codes lookup. Separate handler so its (db, rdb, + // planRegistry) deps are explicit at the constructor boundary; + // rate-limited per-team per-hour to make brute-forcing seed codes + // impractical. See billing_promotion.go. + billingPromoH := handlers.NewBillingPromotionHandler(db, rdb, planRegistry) api.Post("/billing/promotion/validate", billingPromoH.ValidatePromotion) // §10.20 cached aggregates — see billing_usage.go / team_summary.go.