Skip to content

admin: cancel Razorpay subscription on demote + emit subscription.canceled_by_admin audit#52

Merged
mastermanas805 merged 2 commits into
masterfrom
feat/razorpay-cancel-on-demote-fresh
May 13, 2026
Merged

admin: cancel Razorpay subscription on demote + emit subscription.canceled_by_admin audit#52
mastermanas805 merged 2 commits into
masterfrom
feat/razorpay-cancel-on-demote-fresh

Conversation

@mastermanas805
Copy link
Copy Markdown
Member

Summary

  • Demote = cancel. POST /api/v1/admin/customers/:team_id/tier now cancels the customer's active Razorpay subscription when the tier change is a demote (toRank < fromRank). Promotes are unchanged — comp-tier flow stays Razorpay-free.
  • New audit kind subscription.canceled_by_admin with metadata {from_tier, to_tier, by_admin_email, reason, subscription_id, cancel_attempted, cancel_succeeded, cancel_error}. Provider-agnostic — Brevo / Loops keys on the kind string + reads cancel_succeeded to decide template copy.
  • Immediate cancel (not at-cycle-end) via new razorpaybilling.Portal.CancelImmediately. Customer self-serve cancel still uses CancelAtCycleEnd; the admin-initiated demote is immediate so MRR drops in the same billing cycle.
  • Fail-open. Razorpay 5xx during cancel does NOT block the demote. DB-side demote already succeeded; audit row records cancel_succeeded=false + the error; operator reconciles in the Razorpay dashboard.

Behavior matrix

from → to Razorpay action audit_log emitted
pro → hobby CancelImmediately admin.tier_changed + subscription.canceled_by_admin
team → hobby CancelImmediately admin.tier_changed + subscription.canceled_by_admin
pro → free CancelImmediately admin.tier_changed + subscription.canceled_by_admin
demote w/ empty subscription_id none (logs WARN) admin.tier_changed + subscription.canceled_by_admin (cancel_attempted=false)
hobby → pro (promote) none admin.tier_changed (existing behavior preserved)
pro → pro (same-tier) none (409 short-circuit) none

Files

  • internal/handlers/admin_customers.goCancelSubscription hook field, demote detection, cancelOnDemote leg.
  • internal/razorpaybilling/portal.go — new CancelImmediately sibling to CancelAtCycleEnd.
  • internal/models/audit_kinds.goAuditKindSubscriptionCanceledByAdmin constant.
  • internal/router/router.go — wires the portal-backed cancel onto the handler.
  • internal/handlers/admin_customers_test.go — six new integration tests.

Test plan

  • make test-unit — green across all packages (24.3s on handlers, 0 failures).
  • TestAdminTierChange_DemoteProToHobby_CancelsSubscription — happy path, both audit rows present.
  • TestAdminTierChange_DemoteTeamToHobby_CancelsSubscription — multi-rank delta.
  • TestAdminTierChange_DemoteWithoutSubscriptionID_NoRazorpayCall — empty sub_id branch.
  • TestAdminTierChange_PromoteHobbyToPro_NoRazorpayCall — promotes skip Razorpay + no canceled-by-admin row.
  • TestAdminTierChange_DemoteRazorpayCancelFails_StillReturns200 — fail-open with error in audit metadata.
  • TestAdminTierChange_SameTier_409_NoRazorpayCall — idempotency (no duplicate cancel on re-run).
  • TestAgentActionContract — green (no new agent_action strings added).

Operator notes

Brevo template ID for subscription.canceled_by_admin is operator-defined; the audit kind emits regardless of Brevo state. When cancel_succeeded=false, the template must NOT claim the subscription was canceled — the operator needs to cancel manually in the Razorpay dashboard.

🤖 Generated with Claude Code

mastermanas805 and others added 2 commits May 13, 2026 08:43
…celed_by_admin audit

POST /api/v1/admin/customers/:team_id/tier now cancels the customer's active
Razorpay subscription when the tier change is a demote (toRank < fromRank).
Promotes are unchanged — the comp-tier flow stays Razorpay-free.

Razorpay cancel uses cancel_at_cycle_end=false (CancelImmediately) so MRR
drops in the same billing cycle the demote happened, rather than carrying
the old tier through to end-of-cycle. Adds a sibling helper
razorpaybilling.Portal.CancelImmediately alongside the existing
CancelAtCycleEnd (which the customer's own self-serve cancel still uses).

Emits a new audit_log kind `subscription.canceled_by_admin` with metadata
carrying {from_tier, to_tier, by_admin_email, reason, subscription_id,
cancel_attempted, cancel_succeeded, cancel_error}. The Brevo / Loops
forwarder keys on the kind string to fire a "your subscription was canceled
by support" template; cancel_succeeded gates the "we canceled" copy so a
failed Razorpay call doesn't lie to the customer.

Fail-open posture: a Razorpay 5xx during cancel does NOT block the admin
demote — the DB-side demote already succeeded, the audit row records
cancel_succeeded=false + the error, and the operator reconciles manually
in the Razorpay dashboard. Same fail-open shape as resource elevation.

Six new integration tests cover: demote pro→hobby fires cancel + audit;
demote team→hobby (multi-rank delta); demote with empty subscription_id
skips Razorpay but still audits; promote does NOT call Razorpay; Razorpay
500 still returns 200 with cancel_succeeded=false audit; same-tier 409
makes no Razorpay call (idempotency).

make test-unit: green across all packages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…l-on-demote-fresh

# Conflicts:
#	internal/router/router.go
@mastermanas805 mastermanas805 merged commit f3b8b87 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