billing: validate + redeem admin-issued promo codes alongside plans-yaml codes#53
Merged
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Unify the two promo-code sources at
POST /api/v1/billing/promotion/validateand wire single-use redemption into the Razorpay webhook path.admin_promo_codes(PR admin: Customers endpoints (list/detail/tier-change/issue-promo) gated on ADMIN_EMAILS #48 table) on thenot foundbranch. Returns one of the existing error codes plus a newpromotion_already_usedwith a distinct agent_action sentence.promotion_codein the body; when it matches anadmin_promo_codesrow for the calling team, the Razorpay subscription is created withnotes.admin_promo_code_id = <row.id>.subscription.chargedwebhook reads that notes key and best-effort flipsadmin_promo_codes.used_at = now(). Race-safe viaUPDATE ... WHERE used_at IS NULL.How
admin_promo_codes.kindmaps to PR #47's response shapekind(percent_off/first_month_free/amount_off)discount.kindvalue(int)discount.valuediscount.applies_to[]string{plan}discount.max_uses1discount.description"40% off (admin-issued, single use)"expires_at(timestamptz)valid_untilPushback / divergences from the brief
admin_promo_codes.applies_toisINTEGER(a percent_off cap in cents peropenapi.go), not a list of tier names. Admin codes are scoped byteam_id, not plan; the handler does not reject by plan. The dashboard still sees a populatedapplies_toarray (echoes the requested plan), so the UI stays uniform across both code sources.promotion_code(from PR obs: provisioner service observability — slog default, NR agent, gRPC interceptor, healthz sidecar (track 5/8) #38)" — checkout did NOT yet acceptpromotion_code. Added it as part of this PR.subscription.activated, mark used_at" — our webhook hookssubscription.charged(the event that drives the tier upgrade). Used the same hook for redemption symmetry; comment inmaybeMarkAdminPromoCodeUseddocuments the choice.razorpay-go. The contract is covered by validate-time tests + the webhook redemption test; the named constantcheckoutNoteAdminPromoCodeIDis read by both call sites so they cannot drift.Test plan
make test-unitgreen — all 25 packages, including 13 new tests inbilling_promotion_redeem_test.go.TestAgentActionContractgreen — covers the two new agent_action constants (AgentActionPromotionAlreadyUsed,AgentActionPromotionExpired).TestValidatePromotion_PlansYamlCode_StillWorksconfirms plans-yaml codes never fall through to the admin lookup when present.TestMarkAdminPromoCodeUsed_Race_OnlyOneWinsconfirms exactly one of two racing callers succeeds at the model boundary.🤖 Generated with Claude Code