Skip to content

billing: POST /api/v1/billing/promotion/validate — HTTP wrapper around plans.validatePromotion#47

Merged
mastermanas805 merged 1 commit into
masterfrom
feat/billing-promotion-validate-fresh
May 13, 2026
Merged

billing: POST /api/v1/billing/promotion/validate — HTTP wrapper around plans.validatePromotion#47
mastermanas805 merged 1 commit into
masterfrom
feat/billing-promotion-validate-fresh

Conversation

@mastermanas805
Copy link
Copy Markdown
Member

Summary

  • Adds POST /api/v1/billing/promotion/validate — the agent-facing endpoint the dashboard's PromoCodePanel (PR obs: provisioner service observability — slog default, NR agent, gRPC interceptor, healthz sidecar (track 5/8) #38) submits to before checkout.
  • Wraps plans.Registry.ValidatePromotion with: JWT auth gate, per-team per-hour rate limit (30/hr), structured discount payload on success, typed error + agent_action on rejection.
  • Returns 200 + ok:false for invalid/wrong-plan/expired codes (NOT 4xx) so the dashboard can render the red banner through its happy-path parser without a catch on the fetch promise. 400 is reserved for empty/malformed bodies (developer error, not user error).
  • Adds AgentActionPromotionInvalid constant covered by the TestAgentActionContract gate.

Response shapes

Success (200):

{
  "ok": true,
  "code": "LAUNCH50",
  "discount": {
    "kind": "percent_off",
    "value": 50,
    "applies_to": ["pro", "team"],
    "max_uses": 1000,
    "description": "50% off Pro or Team for the first 1000 signups"
  },
  "valid_until": "2099-12-31T23:59:59Z"
}

Invalid code (200):

{
  "ok": false,
  "error": "promotion_invalid",
  "message": "Promotion code \"SAVE20\" is not valid for the pro plan.",
  "agent_action": "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."
}

Expired: error: "promotion_expired". Same envelope shape.

Divergence from the brief spec

The brief described discount.applies_to as an integer ("3 billing cycles") with a unit field ("months"). The actual plans.Promotion struct has no such concept — it carries AppliesTo []string (the list of tier names the code applies to). Per the brief's iron rule "don't reshape the struct", I kept applies_to as []string and added max_uses + description (which the struct does carry). Dropped unit. The dashboard agent should adjust its rendering accordingly.

Rate limit

Implemented inline (not middleware.RateLimit, which is fingerprint+day-scoped). Per-team per-hour bucket: promo_validate:<team_id>:<YYYY-MM-DDTHH> with EXPIRE 65m. Fail-open on Redis errors. Tests cover the 31st-call-is-429 path + the per-team-bucket isolation.

Test plan

  • make test-unit green — all packages including internal/handlers (23s) and internal/router (0.7s).
  • TestAgentActionContract includes AgentActionPromotionInvalid (passes the 4-rule contract + <280 char ceiling).
  • 11 new test cases in billing_promotion_test.go:
    • valid code happy path (discount shape, valid_until ISO)
    • case-insensitive code lookup
    • invalid code → ok:false + agent_action
    • wrong plan → ok:false
    • expired code → promotion_expired
    • empty code → 400 invalid_body
    • missing plan → 400 invalid_body
    • 31st call/hr → 429
    • per-team bucket isolation
    • unauthenticated → 401
    • Redis down → fails open (200 + ok:true)
  • OpenAPI spec at /openapi.json documents both success and invalid response shapes with examples.

🤖 Generated with Claude Code

…d plans.ValidatePromotion

Adds the agent-facing endpoint that the dashboard's PromoCodePanel
(PR #38) submits to before checkout. Returns 200 + ok:true with the
structured discount on success and 200 + ok:false with a typed
error + agent_action on rejection — the dashboard renders the red
state through its normal success-path parser without a catch.

Rate-limited per-team per-hour (30/hr) to make brute-forcing the
seed-code namespace impractical; Redis errors fail open so a cache
outage can't block a user mid-checkout. Adds the
AgentActionPromotionInvalid constant which the TestAgentActionContract
gate covers automatically.
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