Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix #1295 As a shop owner, I can see a list of recently cancelled subscriptions via the dashboard #1296

Merged
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
61 changes: 61 additions & 0 deletions subscribie/blueprints/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
get_number_of_signups,
get_number_of_one_off_purchases,
get_number_of_transactions_with_donations,
get_number_of_recent_subscription_cancellations,
)

import stripe
Expand Down Expand Up @@ -487,6 +488,9 @@ def dashboard():
num_signups = get_number_of_signups()
num_one_off_purchases = get_number_of_one_off_purchases()
num_donations = get_number_of_transactions_with_donations()
num_recent_subscription_cancellations = (
get_number_of_recent_subscription_cancellations()
)
shop_default_country_code = get_shop_default_country_code()
saas_url = current_app.config.get("SAAS_URL")

Expand All @@ -500,6 +504,7 @@ def dashboard():
num_signups=num_signups,
num_donations=num_donations,
num_one_off_purchases=num_one_off_purchases,
num_recent_subscription_cancellations=num_recent_subscription_cancellations,
shop_default_country_code=shop_default_country_code,
saas_url=saas_url,
)
Expand Down Expand Up @@ -1292,6 +1297,62 @@ def subscribers():
)


@admin.route("/recent-subscription-cancellations")
@login_required
def show_recent_subscription_cancellations():
"""Get the last 30 days subscription cancellations (if any)
Note: Stripe api only guarentees the last 30 days of events.
At time of writing this method performs no caching of events,
see StripeInvoice for possible improvements
"""
stripe.api_key = get_stripe_secret_key()
connect_account = get_stripe_connect_account()

subscription_cancellations = stripe.Event.list(
stripe_account=connect_account.id,
limit=100,
types=["customer.subscription.deleted"],
)
for event in subscription_cancellations.auto_paging_iter():
log.info("appending event")
log.info(
f"Length of subscription_cancellations.data is {len(subscription_cancellations.data)}" # noqa: E501
)
if len(subscription_cancellations.data) > 100:
break
subscription_cancellations.data.append(event)

cancellations = []
# subscription id
for index, value in enumerate(subscription_cancellations):
# Get Person
person = (
Person.query.execution_options(include_archived=True)
.filter_by(uuid=value.data.object.metadata.person_uuid)
.one()
)
# Get Subscription
subscription = (
Subscription.query.execution_options(include_archived=True)
.filter_by(stripe_subscription_id=value.data.object.id)
.one()
)
cancellation_date = value.data.object.canceled_at
cancellation_reason = value.data.object.cancellation_details.reason
cancellations.append(
{
"subscription": subscription,
"person": person,
"cancellation_date": cancellation_date,
"cancellation_reason": cancellation_reason,
}
)
return render_template(
"admin/recent_subscription_cancellations.html",
cancellations=cancellations,
)


@admin.route("/refresh-subscription-statuses")
def refresh_subscriptions():
update_stripe_subscription_statuses()
Expand Down
15 changes: 15 additions & 0 deletions subscribie/blueprints/admin/stats.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from subscribie.database import database
from subscribie.models import Person, Subscription, Plan, PlanRequirements, Transaction
from subscribie.utils import get_stripe_secret_key, get_stripe_connect_account_id
from sqlalchemy.sql import func
import stripe
import logging


Expand Down Expand Up @@ -95,3 +97,16 @@ def get_number_of_transactions_with_donations():
.count()
)
return count


def get_number_of_recent_subscription_cancellations():
stripe.api_key = get_stripe_secret_key()
connect_account_id = get_stripe_connect_account_id()

subscription_cancellations = stripe.Event.list(
stripe_account=connect_account_id,
limit=100,
types=["customer.subscription.deleted"],
)

return len(subscription_cancellations)
1 change: 1 addition & 0 deletions subscribie/blueprints/admin/templates/admin/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ <h3 class="card-title">Checklist</h3>
<div class="card px-3 py-3 my-3">
<h3 class="card-title justify-content-between d-flex">Stats</h3>
<p>You have: {{ num_active_subscribers }} <a href="{{ url_for('admin.subscribers', action='show_active')}}">subscribers</a> with <em>active</em> subscriptions.</p>
<p>You've had: {{ num_recent_subscription_cancellations }} <a href="{{ url_for('admin.show_recent_subscription_cancellations')}}">subscription cancellations</a> in the last 30 days.</p>
<p>You've had: {{ num_subscribers }} <a href="{{ url_for('admin.subscribers') }}">subscribers</a> since starting your shop.</p>
<p>You've had: {{ num_one_off_purchases }} <a href="{{ url_for('admin.subscribers', action='show_one_off_payments')}}">people</a> buy a one-off item from your shop.</p>
<p>You've had: {{ num_signups }} <a href="{{ url_for('admin.subscribers') }}">people</a> either buy one-off or start a subscription{% if settings.donations_enabled %} or donations {% endif %} since starting your shop.</p>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
{% extends "admin/layout.html" %}
{% block title %} Recent Subscription Cancellations{% endblock %}

{% block body %}

<h2 class="text-center text-dark mb-3">Recent Subscription Cancellations</h2>

<div class="container">
<ul class="breadcrumb">
<li class="breadcrumb-item"><a href="/">Shop</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('admin.dashboard') }}">Manage My Shop</a></li>
<li class="breadcrumb-item active" aria-current="page">Recent Subscription Cancellations</li>
</ul>
</div>
<main>
<div class="section">
<div class="container">

<p>
Below is the list of recent subscription cancellations (if any) within the last 30 days.
</p>
<p>
Be sure to click the subscriber name to investigate further, since they may have since signed-up to a new, or different plan.
</p>
<h3>Total Collected &amp; Monies Owed</h3>
<p><em>"Total Collected"</em> is the total collected from that Subscriber <em>all time</em> accross <em>all</em> their subscriptions.</p>
<p><em>"Monies owed"</em> is the amount not collected, for instance due to insufficient funds accross <em>all</em> the Subscriber's account.
<br />To investigate further, click a Subscribers name.</p>

<p>Please note: 'Monies owed' is a guide: Subscribie isn't aware of payments if they are made outside of this system (such as cash).</p>


<h3>Reason Code</h3>
<ul style="list-style: disc">
<li><em>payment_failed</em> - means all retry attempts have failed and the subscription is cancelled</li>
<li><em>payment_disputed</em> - means the subscriber disputed the charge(s) at their bank or card issuer</li>
<li><em>cancellation_requested</em> - means a cancellation was requested which caused the subscription to be cancelled. If plans have a "Cancel at" date set, they will natually cancel at the "Cancel at" date set on the plan</li>
</ul>

<p class="alert alert-warning" role="alert">Please note this list only goes back 30 days</p>

<table class="table mobile-optimised">
<thead>
<tr>
<th>Subscriber</th>
<th>Subscription</th>
<th>Cancellation Date</th>
<th>Reason</th>
<th>Total Collected</th>
<th>Monies Owed</th>
</tr>
</thead>
<tbody>
{% for cancellation in cancellations %}
<tr>
<td data-th="Name">
<a href="{{ url_for('admin.show_subscriber', subscriber_id=cancellation['person'].id) }}">{{ cancellation['person'].given_name }} {{ cancellation['person'].family_name }}</a>
<br />
</td>
<td data-th="Plan">
{{ cancellation['subscription'].plan.title }}
</td>
<td>
{{ cancellation['cancellation_date'] | timestampToDate }}
</td>
<td>
{{ cancellation['cancellation_reason'] }}
</td>
<td>
{{ currencyFormat(get_geo_currency_code(), cancellation['person'].balance(skipFetchDeclineCode=True)['total_collected']) }}
</td>
<td>
{{ currencyFormat(get_geo_currency_code(), cancellation['person'].balance(skipFetchDeclineCode=True)['customer_balance']) }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> <!-- end .container -->
</div><!-- end .section -->
</main>

{% endblock body %}
26 changes: 16 additions & 10 deletions subscribie/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,15 +164,15 @@ def get_subscriptions(self, include_archived=True):

return subscriptions

def balance(self):
def balance(self, skipFetchDeclineCode=False):
"""Return the customer balance
Customer balance is the total charged - total collected over the lifetime
of their account.
"""
total_charged = 0
total_collected = 0
customer_balance = 0
invoices = self.invoices()
invoices = self.invoices(skipFetchDeclineCode=skipFetchDeclineCode)
for invoice in invoices:
total_charged += invoice.amount_due

Expand All @@ -192,7 +192,7 @@ def balance(self):
"customer_balance": customer_balance,
}

def invoices(self, refetchCachedStripeInvoices=False):
def invoices(self, refetchCachedStripeInvoices=False, skipFetchDeclineCode=False):
"""Get all cached Stripe invoices for a given person

NOTE: This is a **cached** view of Stripe invoices,
Expand All @@ -217,7 +217,9 @@ def invoices(self, refetchCachedStripeInvoices=False):

stripe.api_key = get_stripe_secret_key()
stripe_account_id = get_stripe_connect_account_id()
query = database.session.query(StripeInvoice).execution_options(include_archived=True)
query = database.session.query(StripeInvoice).execution_options(
include_archived=True
)
query = query.join(
Subscription,
StripeInvoice.subscribie_subscription_id == Subscription.id, # noqa: E501
Expand All @@ -230,12 +232,16 @@ def invoices(self, refetchCachedStripeInvoices=False):
setattr(invoice, "created", stripeRawInvoice["created"])
# Get stripe_decline_code if possible
try:
payment_intent_id = stripeRawInvoice["payment_intent"]
stripe_decline_code = stripe.PaymentIntent.retrieve(
payment_intent_id,
stripe_account=stripe_account_id,
).last_payment_error.decline_code
setattr(invoice, "stripe_decline_code", stripe_decline_code)
if skipFetchDeclineCode is not True:
payment_intent_id = stripeRawInvoice["payment_intent"]
stripe_decline_code = stripe.PaymentIntent.retrieve(
payment_intent_id,
stripe_account=stripe_account_id,
).last_payment_error.decline_code
setattr(invoice, "stripe_decline_code", stripe_decline_code)
else:
setattr(invoice, "stripe_decline_code", "unknown")

except Exception as e:
log.debug(
f"Failed to get stripe_decline_code for invoice {invoice.id}. Exeption: {e}" # noqa: E501
Expand Down
Loading