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"