Skip to content

Commit e8f4c18

Browse files
chrisjsimpsonclaude
andcommitted
Fix #1466 surface subscription cancellation reason in admin UI
Shop owners could see that a subscription had been cancelled, but not why. Persist Stripe's ``cancellation_details.reason`` on the local Subscription (new ``stripe_cancellation_reason`` column, populated from the ``customer.subscription.deleted`` webhook and the manual refresh path) and surface it on the subscribers list and subscriber detail pages alongside the existing cancellation metadata. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 1be54a3 commit e8f4c18

8 files changed

Lines changed: 320 additions & 0 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""add stripe_cancellation_reason to subscription model
2+
3+
Revision ID: 5e1306f48f38
4+
Revises: 08dcbc5f9c6d
5+
Create Date: 2026-04-21 21:10:30.829860
6+
7+
"""
8+
9+
from alembic import op
10+
import sqlalchemy as sa
11+
12+
13+
# revision identifiers, used by Alembic.
14+
revision = "5e1306f48f38"
15+
down_revision = "08dcbc5f9c6d"
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade():
21+
with op.batch_alter_table("subscription", schema=None) as batch_op:
22+
batch_op.add_column(
23+
sa.Column("stripe_cancellation_reason", sa.String(), nullable=True)
24+
)
25+
26+
27+
def downgrade():
28+
pass

subscribie/blueprints/admin/subscription.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,13 @@ def update_stripe_subscription_status(subscription_uuid):
5151
# Update stripeSubscription.ended_at if stripe subscription has ended # noqa: E501
5252
if stripeSubscription.ended_at is not None:
5353
subscription.stripe_ended_at = stripeSubscription.ended_at
54+
cancellation_details = getattr(
55+
stripeSubscription, "cancellation_details", None
56+
)
57+
if cancellation_details is not None:
58+
subscription.stripe_cancellation_reason = getattr(
59+
cancellation_details, "reason", None
60+
)
5461
log.info(subscription.stripe_status)
5562
log.info(subscription.stripe_subscription_id)
5663
database.session.commit()
@@ -126,6 +133,13 @@ def update_stripe_subscription_statuses(app):
126133
# Update stripeSubscription.ended_at if stripe subscription has ended # noqa: E501
127134
if stripeSubscription.ended_at is not None:
128135
subscription.stripe_ended_at = stripeSubscription.ended_at
136+
cancellation_details = getattr(
137+
stripeSubscription, "cancellation_details", None
138+
)
139+
if cancellation_details is not None:
140+
subscription.stripe_cancellation_reason = getattr(
141+
cancellation_details, "reason", None
142+
)
129143
log.info(subscription.stripe_status)
130144
log.info(subscription.stripe_subscription_id)
131145
database.session.commit()

subscribie/blueprints/admin/templates/admin/subscriber/show_subscriber.html

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,30 @@ <h3>Subscriptions</h3>
226226
{% endif %}
227227
{% if subscription.transactions|length > 0 %}<a href="{{ url_for('admin.refresh_subscription', subscription_uuid=subscription.uuid, person_id=person.id) }}">(Refresh)</a>{% endif %}
228228
<br />
229+
{% if subscription.stripe_status == 'canceled' %}
230+
<strong>Cancellation reason:</strong>
231+
<span class="subscription-cancellation-reason">
232+
{% if subscription.stripe_cancellation_reason == 'payment_failed' %}
233+
Payment failed
234+
<details style="display: inline">
235+
<summary><em><small>explain</small></em></summary>
236+
<div class="alert alert-info">
237+
<p>Multiple attempts to collect payment failed, so Stripe automatically
238+
cancelled the subscription.</p>
239+
</div>
240+
</details>
241+
{% elif subscription.stripe_cancellation_reason == 'cancellation_requested' %}
242+
Cancellation requested
243+
{% elif subscription.stripe_cancellation_reason == 'payment_disputed' %}
244+
Payment disputed
245+
{% elif subscription.stripe_cancellation_reason %}
246+
{{ subscription.stripe_cancellation_reason }}
247+
{% else %}
248+
Unknown
249+
{% endif %}
250+
</span>
251+
<br />
252+
{% endif %}
229253
<strong>Payment Collection Status:</strong>
230254
{% if subscription.plan.requirements and subscription.plan.requirements.subscription %}
231255
{% if subscription.stripe_pause_collection == "keep_as_draft" %}

subscribie/blueprints/admin/templates/admin/subscribers.html

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,52 @@ <h4>Search...</h4>
259259
<li>
260260
<strong>Date ended at:</strong> {{ subscription.stripe_ended_at | timestampToDate }}
261261
</li>
262+
<li>
263+
<strong>Cancellation reason:</strong>
264+
<span class="subscription-cancellation-reason">
265+
{% if subscription.stripe_cancellation_reason == 'payment_failed' %}
266+
Payment failed
267+
<details style="display: inline">
268+
<summary><em><small>explain</small></em></summary>
269+
<div class="alert alert-info">
270+
<p>Multiple attempts to collect payment failed, so Stripe automatically
271+
cancelled the subscription.</p>
272+
<p>Consider reaching out to the subscriber to resolve.</p>
273+
</div>
274+
</details>
275+
{% elif subscription.stripe_cancellation_reason == 'cancellation_requested' %}
276+
Cancellation requested
277+
<details style="display: inline">
278+
<summary><em><small>explain</small></em></summary>
279+
<div class="alert alert-info">
280+
<p>The subscription was cancelled on request (either by you via the
281+
dashboard, or by the subscriber).</p>
282+
</div>
283+
</details>
284+
{% elif subscription.stripe_cancellation_reason == 'payment_disputed' %}
285+
Payment disputed
286+
<details style="display: inline">
287+
<summary><em><small>explain</small></em></summary>
288+
<div class="alert alert-info">
289+
<p>The subscription was cancelled because the subscriber disputed
290+
a payment (chargeback).</p>
291+
</div>
292+
</details>
293+
{% elif subscription.stripe_cancellation_reason %}
294+
{{ subscription.stripe_cancellation_reason }}
295+
{% else %}
296+
Unknown
297+
<details style="display: inline">
298+
<summary><em><small>explain</small></em></summary>
299+
<div class="alert alert-info">
300+
<p>No cancellation reason has been recorded for this subscription yet.
301+
Click <em>Refresh Status</em> to fetch the latest information from
302+
Stripe.</p>
303+
</div>
304+
</details>
305+
{% endif %}
306+
</span>
307+
</li>
262308
{% endif %}
263309
<li>
264310
</li>

subscribie/blueprints/checkout/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1006,6 +1006,13 @@ def stripe_webhook():
10061006
"Unable to locate subscription associated with event customer.subscription.deleted" # noqa: E501
10071007
)
10081008

1009+
if subscription is not None:
1010+
subscription.stripe_cancellation_reason = cancellation_reason
1011+
if eventObj.get("ended_at") is not None:
1012+
subscription.stripe_ended_at = eventObj["ended_at"]
1013+
subscription.stripe_status = eventObj.get("status", "canceled")
1014+
database.session.commit()
1015+
10091016
if person is not None:
10101017
subject = (
10111018
f"{company.name}: A Subscription has ended for: {person.given_name}"

subscribie/models.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,11 @@ class Subscription(database.Model, HasCreatedAt):
408408
stripe_external_id = database.Column(database.String())
409409
stripe_status = database.Column(database.String())
410410
stripe_ended_at = database.Column(database.Integer(), nullable=True)
411+
# Populated from Stripe's cancellation_details.reason on
412+
# customer.subscription.deleted events. Stripe returns one of
413+
# "cancellation_requested", "payment_failed", or "payment_disputed".
414+
# See https://docs.stripe.com/api/subscriptions/object#subscription_object-cancellation_details-reason # noqa: E501
415+
stripe_cancellation_reason = database.Column(database.String(), nullable=True)
411416

412417
# stripe_cancel_at is the 'live' setting (which may change)
413418
# and must be checked via cron/webhooks. Plan.cancel_at allows
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
const { test, expect } = require('@playwright/test');
2+
const { admin_login } = require('./features/admin_login');
3+
4+
/**
5+
* Issue #1466 – As a shop owner, I can quickly see *why* a given
6+
* subscription was cancelled, not only that it is cancelled.
7+
*
8+
* This relies on a canceled subscription existing for the logged-in
9+
* shop's admin (the companion spec
10+
* 147_shop_owner_pause_resume_and_cancel_subscriptions.spec.js cancels
11+
* a subscription earlier in the suite, which will populate
12+
* stripe_cancellation_reason via the Stripe webhook).
13+
*
14+
* The test captures screenshots of the subscribers list and subscriber
15+
* detail page to demonstrate the cancellation reason is now surfaced
16+
* in the admin UI.
17+
*/
18+
test.describe('#1466 Cancellation reason is visible in admin UI', () => {
19+
test('@1466_subscription_cancellation_reason_visible', async ({ page }) => {
20+
await admin_login(page);
21+
22+
// 1. Subscribers list page – shows the per-subscription cancellation reason
23+
await page.goto(process.env['PLAYWRIGHT_HOST'] + '/admin/subscribers');
24+
const reasonLocator = page.locator('.subscription-cancellation-reason').first();
25+
if (await reasonLocator.count() === 0) {
26+
test.skip(true, 'No canceled subscription present to verify reason against');
27+
}
28+
await expect(reasonLocator).toBeVisible();
29+
const reasons = await page.locator('.subscription-cancellation-reason').allTextContents();
30+
console.log('[#1466] Reasons on subscribers page:', reasons.map(s => s.trim()));
31+
await page.screenshot({
32+
path: 'test-results/1466-subscribers-page.png',
33+
fullPage: true,
34+
});
35+
36+
// 2. Per-subscriber detail page – also surfaces the reason
37+
const subscriberLink = page.locator('a[id^="person-"]').first();
38+
await subscriberLink.click();
39+
await expect(page.locator('.subscription-cancellation-reason').first())
40+
.toBeVisible();
41+
await page.screenshot({
42+
path: 'test-results/1466-subscriber-detail-page.png',
43+
fullPage: true,
44+
});
45+
});
46+
});
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"""Regression tests for issue #1466.
2+
3+
Issue: https://github.com/Subscribie/subscribie/issues/1466
4+
5+
When Stripe sends a ``customer.subscription.deleted`` webhook, the
6+
``cancellation_details.reason`` it carries (e.g. ``payment_failed``,
7+
``cancellation_requested``) must be persisted onto the local
8+
``Subscription.stripe_cancellation_reason`` column, so the shop owner can
9+
see *why* a subscription was cancelled from the subscribers UI.
10+
"""
11+
12+
import json
13+
from unittest.mock import patch
14+
15+
from subscribie.database import database
16+
from subscribie.models import Company, Person, Plan, Subscription
17+
18+
19+
def _ensure_company():
20+
if Company.query.first() is None:
21+
company = Company()
22+
company.name = "Test Shop"
23+
database.session.add(company)
24+
database.session.commit()
25+
26+
27+
def _make_subscription(checkout_session_id, stripe_subscription_id):
28+
_ensure_company()
29+
person = Person(
30+
given_name="Ada",
31+
family_name="Lovelace",
32+
email="ada@example.com",
33+
uuid=f"person-uuid-1466-{stripe_subscription_id}",
34+
)
35+
database.session.add(person)
36+
plan = Plan(
37+
title="Monthly Widget",
38+
uuid=f"plan-uuid-1466-{stripe_subscription_id}",
39+
)
40+
database.session.add(plan)
41+
database.session.commit()
42+
43+
subscription = Subscription(
44+
sku_uuid=plan.uuid,
45+
person_id=person.id,
46+
stripe_subscription_id=stripe_subscription_id,
47+
subscribie_checkout_session_id=checkout_session_id,
48+
stripe_status="active",
49+
)
50+
database.session.add(subscription)
51+
database.session.commit()
52+
return subscription, person, plan
53+
54+
55+
def _build_event(
56+
checkout_session_id, stripe_subscription_id, reason, person_uuid, plan_uuid
57+
):
58+
return {
59+
"livemode": False,
60+
"type": "customer.subscription.deleted",
61+
"account": "acct_1TestConnectAccount",
62+
"data": {
63+
"object": {
64+
"id": stripe_subscription_id,
65+
"status": "canceled",
66+
"ended_at": 1700000000,
67+
"cancellation_details": {"reason": reason},
68+
"metadata": {
69+
"subscribie_checkout_session_id": checkout_session_id,
70+
"person_uuid": person_uuid,
71+
"plan_uuid": plan_uuid,
72+
},
73+
}
74+
},
75+
}
76+
77+
78+
def test_customer_subscription_deleted_persists_payment_failed_reason(
79+
client,
80+
app,
81+
db_session,
82+
with_shop_owner,
83+
with_default_country_code_and_default_currency,
84+
):
85+
subscription, person, plan = _make_subscription(
86+
checkout_session_id="cs_1466_pf",
87+
stripe_subscription_id="sub_1466_pf",
88+
)
89+
90+
payload = _build_event(
91+
checkout_session_id=subscription.subscribie_checkout_session_id,
92+
stripe_subscription_id=subscription.stripe_subscription_id,
93+
reason="payment_failed",
94+
person_uuid=person.uuid,
95+
plan_uuid=plan.uuid,
96+
)
97+
98+
with patch(
99+
"subscribie.blueprints.checkout.PaymentProvider"
100+
) as payment_provider_cls:
101+
payment_provider_cls.query.first.return_value.stripe_livemode = False
102+
response = client.post(
103+
"/stripe_webhook",
104+
data=json.dumps(payload),
105+
content_type="application/json",
106+
)
107+
108+
assert response.status_code == 200
109+
110+
refreshed = Subscription.query.filter_by(uuid=subscription.uuid).one()
111+
assert refreshed.stripe_cancellation_reason == "payment_failed"
112+
assert refreshed.stripe_status == "canceled"
113+
assert refreshed.stripe_ended_at == 1700000000
114+
115+
116+
def test_customer_subscription_deleted_persists_cancellation_requested_reason(
117+
client,
118+
app,
119+
db_session,
120+
with_shop_owner,
121+
with_default_country_code_and_default_currency,
122+
):
123+
subscription, person, plan = _make_subscription(
124+
checkout_session_id="cs_1466_cr",
125+
stripe_subscription_id="sub_1466_cr",
126+
)
127+
128+
payload = _build_event(
129+
checkout_session_id=subscription.subscribie_checkout_session_id,
130+
stripe_subscription_id=subscription.stripe_subscription_id,
131+
reason="cancellation_requested",
132+
person_uuid=person.uuid,
133+
plan_uuid=plan.uuid,
134+
)
135+
136+
with patch(
137+
"subscribie.blueprints.checkout.PaymentProvider"
138+
) as payment_provider_cls:
139+
payment_provider_cls.query.first.return_value.stripe_livemode = False
140+
response = client.post(
141+
"/stripe_webhook",
142+
data=json.dumps(payload),
143+
content_type="application/json",
144+
)
145+
146+
assert response.status_code == 200
147+
148+
refreshed = Subscription.query.filter_by(uuid=subscription.uuid).one()
149+
assert refreshed.stripe_cancellation_reason == "cancellation_requested"
150+
assert refreshed.stripe_status == "canceled"

0 commit comments

Comments
 (0)