Skip to content

billing: validate + redeem admin-issued promo codes alongside plans-yaml codes#53

Merged
mastermanas805 merged 1 commit into
masterfrom
feat/promo-redemption-checkout-fresh
May 13, 2026
Merged

billing: validate + redeem admin-issued promo codes alongside plans-yaml codes#53
mastermanas805 merged 1 commit into
masterfrom
feat/promo-redemption-checkout-fresh

Conversation

@mastermanas805
Copy link
Copy Markdown
Member

Summary

Unify the two promo-code sources at POST /api/v1/billing/promotion/validate and wire single-use redemption into the Razorpay webhook path.

How admin_promo_codes.kind maps to PR #47's response shape

admin_promo_codes column response.discount field mapping
kind (percent_off/first_month_free/amount_off) discount.kind passthrough — same enum
value (int) discount.value passthrough
(n/a — admin codes are team-scoped) discount.applies_to echo the requested plan: []string{plan}
(always single-use) discount.max_uses hardcoded 1
(no description column) discount.description synthesized: "40% off (admin-issued, single use)"
expires_at (timestamptz) valid_until RFC3339 (more precise than the plans-yaml YYYY-MM-DD path)

Pushback / divergences from the brief

  1. "applies-to includes the requested plan" doesn't map onto migration 021 — admin_promo_codes.applies_to is INTEGER (a percent_off cap in cents per openapi.go), not a list of tier names. Admin codes are scoped by team_id, not plan; the handler does not reject by plan. The dashboard still sees a populated applies_to array (echoes the requested plan), so the UI stays uniform across both code sources.
  2. "Validate admin code for wrong plan → promotion_invalid" therefore doesn't fire — admin codes apply to any plan the team subscribes to. The plans-yaml wrong-plan branch (LAUNCH50 against hobby) is still covered.
  3. "Already accepts promotion_code (from PR obs: provisioner service observability — slog default, NR agent, gRPC interceptor, healthz sidecar (track 5/8) #38)" — checkout did NOT yet accept promotion_code. Added it as part of this PR.
  4. "On subscription.activated, mark used_at" — our webhook hooks subscription.charged (the event that drives the tier upgrade). Used the same hook for redemption symmetry; comment in maybeMarkAdminPromoCodeUsed documents the choice.
  5. End-to-end checkout-stamps-notes test is not included because it would require mocking razorpay-go. The contract is covered by validate-time tests + the webhook redemption test; the named constant checkoutNoteAdminPromoCodeID is read by both call sites so they cannot drift.

Test plan

  • make test-unit green — all 25 packages, including 13 new tests in billing_promotion_redeem_test.go.
  • TestAgentActionContract green — covers the two new agent_action constants (AgentActionPromotionAlreadyUsed, AgentActionPromotionExpired).
  • Plans-yaml regression: TestValidatePromotion_PlansYamlCode_StillWorks confirms plans-yaml codes never fall through to the admin lookup when present.
  • Concurrency: TestMarkAdminPromoCodeUsed_Race_OnlyOneWins confirms exactly one of two racing callers succeeds at the model boundary.

🤖 Generated with Claude Code

…aml codes

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 <plan>" uniformly, but does not reject by
plan. Documented in the validate handler's lookupAdminPromotion comment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mastermanas805 mastermanas805 merged commit 802e0a4 into master May 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant