brain/decisions/000023-cancellation-feedback-rides-the-cancel-call.md
Self-serve cancellation had no churn survey. The dialog was a generic ConfirmationDeleteDialog carrying
only the "what happens next" copy (dropToFreeWarning), rendered from two
entry points that both call cancelWithSeatCheck in useCancelSubscriptionGuard.
Sales had no way to see why anyone left. Autumn records that a subscription was cancelled, not the reason.
AP already proxies the cancellation to the console: POST /v1/platform-billing/cancel reaches
${AUTUMN_CONSOLE_URL}/api/v1/billing/cancel with the platform's scoped Autumn key as Bearer, and the
console's auth hook resolves request.autumnCustomerId and request.autumnCustomerPlanId from that key.
The survey is folded into the cancel dialog as a single step, and the answers travel on the existing
cancel request rather than a dedicated feedback endpoint. The console stores them in an append-only
cancellation_feedback table and lists them at /cancellations.
ConfirmationDeleteDialog at both cancel entry points, shared through
useCancelSubscriptionGuard. Reason checkboxes, a free-text comment, then the unchanged
dropToFreeWarning alert directly above the footer.translation.json; the console maps code to label and falls back to the raw code.reasons, comment, and canceledByEmail. Identity for the stored row comes from
the key-verified request.autumnCustomerId and request.autumnCustomerPlanId, never the body.billingService.cancel, and is best-effort:
a failed insert is logged and the cancellation still succeeds.Riding the cancel call means one round trip, no second AP to console auth path, and both AP entry points
covered by one change. The row also joins straight to autumn_customers, so a rep gets a real customer and
plan instead of an orphaned email.
Codes rather than label text because the dialog goes through t() like every AP surface: storing what the
user saw would file a Spanish customer's churn reason as Spanish free text and silently split the rep's
grouping. Codes also survive copy edits, which this dialog will get.
Optional rather than required because a survey that blocks the exit is the friction pattern click-to-cancel rules target, and forced answers are worse than none: people tick the first option to dismiss the dialog, which is indistinguishable from a genuine answer and poisons the dataset.
Append-only because the second cancellation after a save attempt is usually the honest one, and an upsert would erase the first.
The insert cannot sit inside billingService.cancel: that method early-returns when the plan is nil or
Free, which would swallow feedback in exactly the edge cases worth reading.
cancelWithSeatCheck opens the deactivate-users dialog instead
of cancelling, and a server QUOTA_EXCEEDED does the same after the fact. Someone who answers the survey
and then abandons at that dialog leaves no row. Accepted for now; a separate endpoint is the fix if the
gap turns out to matter.canceled_by_email is unverified body data. It is the acting admin, sent so a rep has a human to
reply to rather than the billing address on autumn_customers. Use it for outreach only, never for
identity or joins./customers/:email in v1.