Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions subscribie/blueprints/admin/subscription.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,30 @@ <h3>Subscriptions</h3>
{% endif %}
{% if subscription.transactions|length > 0 %}<a href="{{ url_for('admin.refresh_subscription', subscription_uuid=subscription.uuid, person_id=person.id) }}">(Refresh)</a>{% endif %}
<br />
{% if subscription.stripe_status == 'canceled' %}
<strong>Cancellation reason:</strong>
<span class="subscription-cancellation-reason">
{% if subscription.stripe_cancellation_reason == 'payment_failed' %}
Payment failed
<details style="display: inline">
<summary><em><small>explain</small></em></summary>
<div class="alert alert-info">
<p>Multiple attempts to collect payment failed, so Stripe automatically
cancelled the subscription.</p>
</div>
</details>
{% 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 %}
</span>
<br />
{% endif %}
<strong>Payment Collection Status:</strong>
{% if subscription.plan.requirements and subscription.plan.requirements.subscription %}
{% if subscription.stripe_pause_collection == "keep_as_draft" %}
Expand Down
46 changes: 46 additions & 0 deletions subscribie/blueprints/admin/templates/admin/subscribers.html
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,52 @@ <h4>Search...</h4>
<li>
<strong>Date ended at:</strong> {{ subscription.stripe_ended_at | timestampToDate }}
</li>
<li>
<strong>Cancellation reason:</strong>
<span class="subscription-cancellation-reason">
{% if subscription.stripe_cancellation_reason == 'payment_failed' %}
Payment failed
<details style="display: inline">
<summary><em><small>explain</small></em></summary>
<div class="alert alert-info">
<p>Multiple attempts to collect payment failed, so Stripe automatically
cancelled the subscription.</p>
<p>Consider reaching out to the subscriber to resolve.</p>
</div>
</details>
{% elif subscription.stripe_cancellation_reason == 'cancellation_requested' %}
Cancellation requested
<details style="display: inline">
<summary><em><small>explain</small></em></summary>
<div class="alert alert-info">
<p>The subscription was cancelled on request (either by you via the
dashboard, or by the subscriber).</p>
</div>
</details>
{% elif subscription.stripe_cancellation_reason == 'payment_disputed' %}
Payment disputed
<details style="display: inline">
<summary><em><small>explain</small></em></summary>
<div class="alert alert-info">
<p>The subscription was cancelled because the subscriber disputed
a payment (chargeback).</p>
</div>
</details>
{% elif subscription.stripe_cancellation_reason %}
{{ subscription.stripe_cancellation_reason }}
{% else %}
Unknown
<details style="display: inline">
<summary><em><small>explain</small></em></summary>
<div class="alert alert-info">
<p>No cancellation reason has been recorded for this subscription yet.
Click <em>Refresh Status</em> to fetch the latest information from
Stripe.</p>
</div>
</details>
{% endif %}
</span>
</li>
{% endif %}
<li>
</li>
Expand Down
7 changes: 7 additions & 0 deletions subscribie/blueprints/checkout/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
5 changes: 5 additions & 0 deletions subscribie/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
150 changes: 150 additions & 0 deletions tests/test_webhook_subscription_cancellation_reason.py
Original file line number Diff line number Diff line change
@@ -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"
Loading