admin: cancel Razorpay subscription on demote + emit subscription.canceled_by_admin audit#52
Merged
Conversation
…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
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
subscription.canceled_by_adminwith 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 + readscancel_succeededto decide template copy.razorpaybilling.Portal.CancelImmediately. Customer self-serve cancel still usesCancelAtCycleEnd; the admin-initiated demote is immediate so MRR drops in the same billing cycle.cancel_succeeded=false+ the error; operator reconciles in the Razorpay dashboard.Behavior matrix
Files
internal/handlers/admin_customers.go—CancelSubscriptionhook field, demote detection,cancelOnDemoteleg.internal/razorpaybilling/portal.go— newCancelImmediatelysibling toCancelAtCycleEnd.internal/models/audit_kinds.go—AuditKindSubscriptionCanceledByAdminconstant.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_adminis operator-defined; the audit kind emits regardless of Brevo state. Whencancel_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