diff --git a/migrations/versions/5e1306f48f38_add_stripe_cancellation_reason_to_.py b/migrations/versions/5e1306f48f38_add_stripe_cancellation_reason_to_.py
new file mode 100644
index 00000000..3b03116f
--- /dev/null
+++ b/migrations/versions/5e1306f48f38_add_stripe_cancellation_reason_to_.py
@@ -0,0 +1,28 @@
+"""add stripe_cancellation_reason to subscription model
+
+Revision ID: 5e1306f48f38
+Revises: 08dcbc5f9c6d
+Create Date: 2026-04-21 21:10:30.829860
+
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = "5e1306f48f38"
+down_revision = "08dcbc5f9c6d"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ with op.batch_alter_table("subscription", schema=None) as batch_op:
+ batch_op.add_column(
+ sa.Column("stripe_cancellation_reason", sa.String(), nullable=True)
+ )
+
+
+def downgrade():
+ pass
diff --git a/subscribie/blueprints/admin/subscription.py b/subscribie/blueprints/admin/subscription.py
index 867fe9c7..1350f8e3 100644
--- a/subscribie/blueprints/admin/subscription.py
+++ b/subscribie/blueprints/admin/subscription.py
@@ -51,6 +51,13 @@ def update_stripe_subscription_status(subscription_uuid):
# Update stripeSubscription.ended_at if stripe subscription has ended # noqa: E501
if stripeSubscription.ended_at is not None:
subscription.stripe_ended_at = stripeSubscription.ended_at
+ cancellation_details = getattr(
+ stripeSubscription, "cancellation_details", None
+ )
+ if cancellation_details is not None:
+ subscription.stripe_cancellation_reason = getattr(
+ cancellation_details, "reason", None
+ )
log.info(subscription.stripe_status)
log.info(subscription.stripe_subscription_id)
database.session.commit()
@@ -126,6 +133,13 @@ def update_stripe_subscription_statuses(app):
# Update stripeSubscription.ended_at if stripe subscription has ended # noqa: E501
if stripeSubscription.ended_at is not None:
subscription.stripe_ended_at = stripeSubscription.ended_at
+ cancellation_details = getattr(
+ stripeSubscription, "cancellation_details", None
+ )
+ if cancellation_details is not None:
+ subscription.stripe_cancellation_reason = getattr(
+ cancellation_details, "reason", None
+ )
log.info(subscription.stripe_status)
log.info(subscription.stripe_subscription_id)
database.session.commit()
diff --git a/subscribie/blueprints/admin/templates/admin/subscriber/show_subscriber.html b/subscribie/blueprints/admin/templates/admin/subscriber/show_subscriber.html
index b47a9235..57700763 100644
--- a/subscribie/blueprints/admin/templates/admin/subscriber/show_subscriber.html
+++ b/subscribie/blueprints/admin/templates/admin/subscriber/show_subscriber.html
@@ -226,6 +226,30 @@
Subscriptions
{% endif %}
{% if subscription.transactions|length > 0 %}(Refresh){% endif %}
+ {% if subscription.stripe_status == 'canceled' %}
+ Cancellation reason:
+
+ {% if subscription.stripe_cancellation_reason == 'payment_failed' %}
+ Payment failed
+
+ explain
+
+
Multiple attempts to collect payment failed, so Stripe automatically
+ cancelled the subscription.
+
+
+ {% elif subscription.stripe_cancellation_reason == 'cancellation_requested' %}
+ Cancellation requested
+ {% elif subscription.stripe_cancellation_reason == 'payment_disputed' %}
+ Payment disputed
+ {% elif subscription.stripe_cancellation_reason %}
+ {{ subscription.stripe_cancellation_reason }}
+ {% else %}
+ Unknown
+ {% endif %}
+
+
+ {% endif %}
Payment Collection Status:
{% if subscription.plan.requirements and subscription.plan.requirements.subscription %}
{% if subscription.stripe_pause_collection == "keep_as_draft" %}
diff --git a/subscribie/blueprints/admin/templates/admin/subscribers.html b/subscribie/blueprints/admin/templates/admin/subscribers.html
index 98784e43..d44f9fd0 100644
--- a/subscribie/blueprints/admin/templates/admin/subscribers.html
+++ b/subscribie/blueprints/admin/templates/admin/subscribers.html
@@ -259,6 +259,52 @@ Search...
Date ended at: {{ subscription.stripe_ended_at | timestampToDate }}
+
+ Cancellation reason:
+
+ {% if subscription.stripe_cancellation_reason == 'payment_failed' %}
+ Payment failed
+
+ explain
+
+
Multiple attempts to collect payment failed, so Stripe automatically
+ cancelled the subscription.
+
Consider reaching out to the subscriber to resolve.
+
+
+ {% elif subscription.stripe_cancellation_reason == 'cancellation_requested' %}
+ Cancellation requested
+
+ explain
+
+
The subscription was cancelled on request (either by you via the
+ dashboard, or by the subscriber).
+
+
+ {% elif subscription.stripe_cancellation_reason == 'payment_disputed' %}
+ Payment disputed
+
+ explain
+
+
The subscription was cancelled because the subscriber disputed
+ a payment (chargeback).
+
+
+ {% elif subscription.stripe_cancellation_reason %}
+ {{ subscription.stripe_cancellation_reason }}
+ {% else %}
+ Unknown
+
+ explain
+
+
No cancellation reason has been recorded for this subscription yet.
+ Click Refresh Status to fetch the latest information from
+ Stripe.
+
+
+ {% endif %}
+
+
{% endif %}
diff --git a/subscribie/blueprints/checkout/__init__.py b/subscribie/blueprints/checkout/__init__.py
index ab653225..3f8bdd92 100644
--- a/subscribie/blueprints/checkout/__init__.py
+++ b/subscribie/blueprints/checkout/__init__.py
@@ -1006,6 +1006,13 @@ def stripe_webhook():
"Unable to locate subscription associated with event customer.subscription.deleted" # noqa: E501
)
+ if subscription is not None:
+ subscription.stripe_cancellation_reason = cancellation_reason
+ if eventObj.get("ended_at") is not None:
+ subscription.stripe_ended_at = eventObj["ended_at"]
+ subscription.stripe_status = eventObj.get("status", "canceled")
+ database.session.commit()
+
if person is not None:
subject = (
f"{company.name}: A Subscription has ended for: {person.given_name}"
diff --git a/subscribie/models.py b/subscribie/models.py
index ecdfb6e7..17180afe 100644
--- a/subscribie/models.py
+++ b/subscribie/models.py
@@ -408,6 +408,11 @@ class Subscription(database.Model, HasCreatedAt):
stripe_external_id = database.Column(database.String())
stripe_status = database.Column(database.String())
stripe_ended_at = database.Column(database.Integer(), nullable=True)
+ # Populated from Stripe's cancellation_details.reason on
+ # customer.subscription.deleted events. Stripe returns one of
+ # "cancellation_requested", "payment_failed", or "payment_disputed".
+ # See https://docs.stripe.com/api/subscriptions/object#subscription_object-cancellation_details-reason # noqa: E501
+ stripe_cancellation_reason = database.Column(database.String(), nullable=True)
# stripe_cancel_at is the 'live' setting (which may change)
# and must be checked via cron/webhooks. Plan.cancel_at allows
diff --git a/tests/browser-automated-tests-playwright/e2e/1466_subscription_cancellation_reason_visible.spec.js b/tests/browser-automated-tests-playwright/e2e/1466_subscription_cancellation_reason_visible.spec.js
new file mode 100644
index 00000000..8d238b2a
--- /dev/null
+++ b/tests/browser-automated-tests-playwright/e2e/1466_subscription_cancellation_reason_visible.spec.js
@@ -0,0 +1,46 @@
+const { test, expect } = require('@playwright/test');
+const { admin_login } = require('./features/admin_login');
+
+/**
+ * Issue #1466 – As a shop owner, I can quickly see *why* a given
+ * subscription was cancelled, not only that it is cancelled.
+ *
+ * This relies on a canceled subscription existing for the logged-in
+ * shop's admin (the companion spec
+ * 147_shop_owner_pause_resume_and_cancel_subscriptions.spec.js cancels
+ * a subscription earlier in the suite, which will populate
+ * stripe_cancellation_reason via the Stripe webhook).
+ *
+ * The test captures screenshots of the subscribers list and subscriber
+ * detail page to demonstrate the cancellation reason is now surfaced
+ * in the admin UI.
+ */
+test.describe('#1466 Cancellation reason is visible in admin UI', () => {
+ test('@1466_subscription_cancellation_reason_visible', async ({ page }) => {
+ await admin_login(page);
+
+ // 1. Subscribers list page – shows the per-subscription cancellation reason
+ await page.goto(process.env['PLAYWRIGHT_HOST'] + '/admin/subscribers');
+ const reasonLocator = page.locator('.subscription-cancellation-reason').first();
+ if (await reasonLocator.count() === 0) {
+ test.skip(true, 'No canceled subscription present to verify reason against');
+ }
+ await expect(reasonLocator).toBeVisible();
+ const reasons = await page.locator('.subscription-cancellation-reason').allTextContents();
+ console.log('[#1466] Reasons on subscribers page:', reasons.map(s => s.trim()));
+ await page.screenshot({
+ path: 'test-results/1466-subscribers-page.png',
+ fullPage: true,
+ });
+
+ // 2. Per-subscriber detail page – also surfaces the reason
+ const subscriberLink = page.locator('a[id^="person-"]').first();
+ await subscriberLink.click();
+ await expect(page.locator('.subscription-cancellation-reason').first())
+ .toBeVisible();
+ await page.screenshot({
+ path: 'test-results/1466-subscriber-detail-page.png',
+ fullPage: true,
+ });
+ });
+});
diff --git a/tests/test_webhook_subscription_cancellation_reason.py b/tests/test_webhook_subscription_cancellation_reason.py
new file mode 100644
index 00000000..4545dbec
--- /dev/null
+++ b/tests/test_webhook_subscription_cancellation_reason.py
@@ -0,0 +1,150 @@
+"""Regression tests for issue #1466.
+
+Issue: https://github.com/Subscribie/subscribie/issues/1466
+
+When Stripe sends a ``customer.subscription.deleted`` webhook, the
+``cancellation_details.reason`` it carries (e.g. ``payment_failed``,
+``cancellation_requested``) must be persisted onto the local
+``Subscription.stripe_cancellation_reason`` column, so the shop owner can
+see *why* a subscription was cancelled from the subscribers UI.
+"""
+
+import json
+from unittest.mock import patch
+
+from subscribie.database import database
+from subscribie.models import Company, Person, Plan, Subscription
+
+
+def _ensure_company():
+ if Company.query.first() is None:
+ company = Company()
+ company.name = "Test Shop"
+ database.session.add(company)
+ database.session.commit()
+
+
+def _make_subscription(checkout_session_id, stripe_subscription_id):
+ _ensure_company()
+ person = Person(
+ given_name="Ada",
+ family_name="Lovelace",
+ email="ada@example.com",
+ uuid=f"person-uuid-1466-{stripe_subscription_id}",
+ )
+ database.session.add(person)
+ plan = Plan(
+ title="Monthly Widget",
+ uuid=f"plan-uuid-1466-{stripe_subscription_id}",
+ )
+ database.session.add(plan)
+ database.session.commit()
+
+ subscription = Subscription(
+ sku_uuid=plan.uuid,
+ person_id=person.id,
+ stripe_subscription_id=stripe_subscription_id,
+ subscribie_checkout_session_id=checkout_session_id,
+ stripe_status="active",
+ )
+ database.session.add(subscription)
+ database.session.commit()
+ return subscription, person, plan
+
+
+def _build_event(
+ checkout_session_id, stripe_subscription_id, reason, person_uuid, plan_uuid
+):
+ return {
+ "livemode": False,
+ "type": "customer.subscription.deleted",
+ "account": "acct_1TestConnectAccount",
+ "data": {
+ "object": {
+ "id": stripe_subscription_id,
+ "status": "canceled",
+ "ended_at": 1700000000,
+ "cancellation_details": {"reason": reason},
+ "metadata": {
+ "subscribie_checkout_session_id": checkout_session_id,
+ "person_uuid": person_uuid,
+ "plan_uuid": plan_uuid,
+ },
+ }
+ },
+ }
+
+
+def test_customer_subscription_deleted_persists_payment_failed_reason(
+ client,
+ app,
+ db_session,
+ with_shop_owner,
+ with_default_country_code_and_default_currency,
+):
+ subscription, person, plan = _make_subscription(
+ checkout_session_id="cs_1466_pf",
+ stripe_subscription_id="sub_1466_pf",
+ )
+
+ payload = _build_event(
+ checkout_session_id=subscription.subscribie_checkout_session_id,
+ stripe_subscription_id=subscription.stripe_subscription_id,
+ reason="payment_failed",
+ person_uuid=person.uuid,
+ plan_uuid=plan.uuid,
+ )
+
+ with patch(
+ "subscribie.blueprints.checkout.PaymentProvider"
+ ) as payment_provider_cls:
+ payment_provider_cls.query.first.return_value.stripe_livemode = False
+ response = client.post(
+ "/stripe_webhook",
+ data=json.dumps(payload),
+ content_type="application/json",
+ )
+
+ assert response.status_code == 200
+
+ refreshed = Subscription.query.filter_by(uuid=subscription.uuid).one()
+ assert refreshed.stripe_cancellation_reason == "payment_failed"
+ assert refreshed.stripe_status == "canceled"
+ assert refreshed.stripe_ended_at == 1700000000
+
+
+def test_customer_subscription_deleted_persists_cancellation_requested_reason(
+ client,
+ app,
+ db_session,
+ with_shop_owner,
+ with_default_country_code_and_default_currency,
+):
+ subscription, person, plan = _make_subscription(
+ checkout_session_id="cs_1466_cr",
+ stripe_subscription_id="sub_1466_cr",
+ )
+
+ payload = _build_event(
+ checkout_session_id=subscription.subscribie_checkout_session_id,
+ stripe_subscription_id=subscription.stripe_subscription_id,
+ reason="cancellation_requested",
+ person_uuid=person.uuid,
+ plan_uuid=plan.uuid,
+ )
+
+ with patch(
+ "subscribie.blueprints.checkout.PaymentProvider"
+ ) as payment_provider_cls:
+ payment_provider_cls.query.first.return_value.stripe_livemode = False
+ response = client.post(
+ "/stripe_webhook",
+ data=json.dumps(payload),
+ content_type="application/json",
+ )
+
+ assert response.status_code == 200
+
+ refreshed = Subscription.query.filter_by(uuid=subscription.uuid).one()
+ assert refreshed.stripe_cancellation_reason == "cancellation_requested"
+ assert refreshed.stripe_status == "canceled"