diff --git a/subscribie/blueprints/admin/__init__.py b/subscribie/blueprints/admin/__init__.py index 6da8eee80..a61ee5a22 100644 --- a/subscribie/blueprints/admin/__init__.py +++ b/subscribie/blueprints/admin/__init__.py @@ -115,6 +115,16 @@ def dec2pence(amount): return int(math.ceil(float(amount) * 100)) +def ordinal(n): + """ + Convert 1 -> 1st, 2 -> 2nd etc... + Credit Dr. Drang 2020 https://leancrew.com/all-this/2020/06/ordinals-in-python/ + """ + return str(n) + ( + "th" if 4 <= n % 100 <= 20 else {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th") + ) # noqa: E501 + + @admin.app_template_filter() def timestampToDate(timestamp: str): if timestamp is None: @@ -122,6 +132,24 @@ def timestampToDate(timestamp: str): return datetime.fromtimestamp(int(timestamp)).strftime("%d-%m-%Y") +def dtStylish(dt, f): + """ + Add "nd", "th" and "rd" to date formatting for + human readable dates. + Credit https://stackoverflow.com/a/16671271 + """ + return dt.strftime(f).replace("{th}", ordinal(dt.day)) + + +@admin.app_template_filter() +def timestampToHumanReadableDate(timestamp: str): + if timestamp is None: + return None + dt = datetime.fromtimestamp(int(timestamp)) + + return dtStylish(dt, "{th} %B %Y") + + def store_stripe_transaction(stripe_external_id): """Store Stripe invoice payment in transactions table""" stripe.api_key = get_stripe_secret_key() @@ -768,7 +796,7 @@ def list_documents(): documents = ( Document.query.where(Document.type == "terms-and-conditions-agreed") .execution_options(include_archived=True) - .where(Document.read_only == True) + .where(Document.read_only == True) # noqa: E712 .all() ) else: @@ -1249,11 +1277,11 @@ def subscribers(): ) elif action == "show_donors": query = query.filter(Person.transactions) - query = query.where(Transaction.is_donation == True) + query = query.where(Transaction.is_donation == True) # noqa: E712 elif action == "show_one_off_payments": query = query.filter(Person.subscriptions) - query = query.where(Subscription.stripe_subscription_id == None) + query = query.where(Subscription.stripe_subscription_id == None) # noqa: E711 people = query.order_by(desc(Person.created_at)) @@ -1356,6 +1384,7 @@ def invoices(): @login_required def transactions(): action = request.args.get("action", None) + page = request.args.get("page", 1, type=int) plan_title = request.args.get("plan_title", None) subscriber_name = request.args.get("subscriber_name", None) @@ -1390,10 +1419,10 @@ def transactions(): query = query.filter(False) if action == "show_refunded": - query = query.filter(Transaction.external_refund_id != None) + query = query.filter(Transaction.external_refund_id != None) # noqa: E711 if action == "show_donations": - query = query.filter(Transaction.is_donation == True) + query = query.filter(Transaction.is_donation == True) # noqa: E712 transactions = query.paginate(page=page, per_page=10) if transactions.total == 0: @@ -1946,7 +1975,7 @@ def change_thank_you_url(): if request.form.get("default"): settings.custom_thank_you_url = None database.session.commit() - flash("Custom thank you url changed to default") + flash("Thank page has been set back to the to the default thank you page.") return render_template( "admin/settings/custom_thank_you_page.html", form=form, diff --git a/subscribie/blueprints/admin/invoice.py b/subscribie/blueprints/admin/invoice.py index 2de03022b..a7dc4243d 100644 --- a/subscribie/blueprints/admin/invoice.py +++ b/subscribie/blueprints/admin/invoice.py @@ -1,14 +1,15 @@ from . import admin +import json import logging from subscribie.auth import login_required, stripe_connect_id_required from subscribie.database import database -from subscribie.models import UpcomingInvoice, Subscription +from subscribie.models import UpcomingInvoice, Subscription, StripeInvoice, Person from subscribie.utils import ( get_stripe_secret_key, get_stripe_connect_account, + get_stripe_connect_account_id, ) from subscribie.utils import ( - getBadInvoices, get_stripe_invoices, ) from flask import render_template, flash, request, redirect, url_for @@ -21,13 +22,87 @@ @login_required @stripe_connect_id_required def failed_invoices(): + stripe.api_key = get_stripe_secret_key() + stripe_connect_account_id = get_stripe_connect_account_id() if "refreshFailedInvoices" in request.args: flash("Invoice statuses are being refreshed") get_stripe_invoices() - badInvoices = getBadInvoices() + # Get failed invoices, grouped by person and their invoices + failedInvoices = ( + database.session.query(StripeInvoice) + .join(Subscription, StripeInvoice.subscribie_subscription) + .join(Person, Subscription.person) + .group_by(Person.id, StripeInvoice.id) + .where(StripeInvoice.status == "open") + .where(StripeInvoice.next_payment_attempt == None) # noqa: E711 + .execution_options(include_archived=True) + .order_by(Person.given_name) + .all() + ) + # Build dictionary of person uuid -> (bad) invoices so + # that it's easier for template to display bad invoices broken down + # per person + subscribersWithFailedInvoicesMap = {} + + for failedInvoice in failedInvoices: + # Populate map with each Person.uuid + if ( + failedInvoice.subscribie_subscription.person.uuid + not in subscribersWithFailedInvoicesMap + ): + # Create person uuid key in map + subscribersWithFailedInvoicesMap[ + failedInvoice.subscribie_subscription.person.uuid + ] = {} + # Create empty list to store persons bad invoices + subscribersWithFailedInvoicesMap[ + failedInvoice.subscribie_subscription.person.uuid + ]["failedInvoices"] = [] + + # Create reference to person object via invoice reference + subscribersWithFailedInvoicesMap[ + failedInvoice.subscribie_subscription.person.uuid + ]["person"] = failedInvoice.subscribie_subscription.person + + # Add hosted_invoice_url attribute to invoice + try: + stripe_invoice = stripe.Invoice.retrieve( + id=failedInvoice.id, stripe_account=stripe_connect_account_id + ) + setattr( + failedInvoice, + "hosted_invoice_url", + stripe_invoice.hosted_invoice_url, + ) + except Exception as e: + log.error( + f"Unable to get/set hosted_invoice_url for invoice: {failedInvoice.id}. {e}" # noqa: E501 + ) + + # Get stripe_decline_code if possible + try: + stripeRawInvoice = json.loads(failedInvoice.stripe_invoice_raw_json) + + payment_intent_id = stripeRawInvoice["payment_intent"] + stripe_decline_code = stripe.PaymentIntent.retrieve( + payment_intent_id, + stripe_account=stripe_connect_account_id, + ).last_payment_error.decline_code + setattr(failedInvoice, "stripe_decline_code", stripe_decline_code) + except Exception as e: + log.debug( + f"Failed to get stripe_decline_code for invoice {failedInvoice.id}. Exeption: {e}" # noqa: E501 + ) + + # Insert invoices per person + subscribersWithFailedInvoicesMap[ + failedInvoice.subscribie_subscription.person.uuid + ]["failedInvoices"].append(failedInvoice) + return render_template( - "admin/invoice/failed_invoices.html", badInvoices=badInvoices + "admin/invoice/failed_invoices.html", + debtors=subscribersWithFailedInvoicesMap, ) diff --git a/subscribie/blueprints/admin/subscriber.py b/subscribie/blueprints/admin/subscriber.py index c45f64b4c..07bb73ab0 100644 --- a/subscribie/blueprints/admin/subscriber.py +++ b/subscribie/blueprints/admin/subscriber.py @@ -2,6 +2,8 @@ from subscribie.auth import login_required from subscribie.models import Person from flask import render_template +from subscribie.utils import get_stripe_secret_key, get_stripe_connect_account, get_stripe_connect_account_id +import stripe import logging log = logging.getLogger(__name__) @@ -10,8 +12,38 @@ @admin.route("/show-subscriber/", methods=["GET", "POST"]) @login_required def show_subscriber(subscriber_id): + stripe.api_key = get_stripe_secret_key() + stripe_connect_account_id = get_stripe_connect_account_id() + person = Person.query.execution_options(include_archived=True).get(subscriber_id) - return render_template("admin/subscriber/show_subscriber.html", person=person) + customer_balance_list = person.balance() # See models.py 'class Person' + invoices = person.invoices() + open_invoices = person.failed_invoices() + # Add hosted_invoice_url attribute to all open invoices + try: + for index, open_invoice in enumerate(open_invoices): + stripe_invoice = stripe.Invoice.retrieve(id=open_invoice.id, stripe_account=stripe_connect_account_id) + setattr(open_invoices[index], 'hosted_invoice_url', stripe_invoice.hosted_invoice_url) + except Exception as e: + log.error(f"Unable to get/set hosted_invoice_url for invoice: {open_invoice.id}. {e}") + + # Try to be helpful to the shop owner by highlighting recent payment + # payment stripe_decline_code errors (if any). + collection_decline_codes = [] + for invoice in invoices: + try: + collection_decline_codes.append(invoice.stripe_decline_code) + except AttributeError: + pass + + return render_template( + "admin/subscriber/show_subscriber.html", + person=person, + invoices=invoices, + open_invoices=open_invoices, + customer_balance_list=customer_balance_list, + collection_decline_codes=set(collection_decline_codes), + ) @admin.route("/charge/subscriber/", methods=["GET"]) diff --git a/subscribie/blueprints/admin/templates/admin/dashboard.html b/subscribie/blueprints/admin/templates/admin/dashboard.html index f963f5fcc..d68dd8a0b 100644 --- a/subscribie/blueprints/admin/templates/admin/dashboard.html +++ b/subscribie/blueprints/admin/templates/admin/dashboard.html @@ -13,7 +13,6 @@

Manage My Shop

-

Checklist

@@ -24,7 +23,7 @@

Checklist

{% if stripe_connected is sameas False %}
  • Stripe connection is not yet complete. - Review Stripe @@ -56,15 +55,15 @@

    Stats

    - Edit plans - Add plan - Delete plans @@ -86,7 +85,7 @@

    Stats

    @@ -94,7 +93,7 @@

    Stats

    View your subscribers.

    - View Subscribers @@ -121,9 +120,9 @@

    Stats

    View upcoming payments from your subscribers.

    - - View Upcoming Payments + View Upcoming Payments
    @@ -142,9 +141,9 @@

    Stats

    - ...
    Stripe
    @@ -169,10 +168,10 @@
    Stripe

    - Create choice groups. Offer a choice between a selection - of products. E.g. Shampoo A or Shampoo B. + Create choice groups. Offer a choice between a selection + of products. E.g. Shampoo A or Shampoo B.

    - Add / Edit / Delete Choice Groups @@ -182,7 +181,7 @@
    Stripe
    @@ -191,15 +190,15 @@
    Stripe

    View all transactions / Manage Refunds

    - - View Transactions / Manage Refunds + View Transactions / Manage Refunds

    View all Invoices (excluding upcoming).

    - View Invoices @@ -232,10 +231,10 @@
    Stripe

    - View (optional) notes customers may give you when starting their + View (optional) notes customers may give you when starting their subscription.

    - View Notes @@ -268,9 +267,9 @@
    Stripe
    - Tawk logo
    Tawk Online Chat
    @@ -311,9 +310,9 @@
    Inject Code
    - Google Tag Manager logo
    Google Tag Manager
    @@ -356,7 +355,7 @@
    Google Tag Manager
    embed your Subscription website into another website if you have an existing website you want to keep using, but use Subscribie for your subscriptions.

    - + {% for moduleName in loadedModules %} {% if loadedModules[moduleName]['links']|length != 0 %}
    @@ -374,14 +373,14 @@
    {{ loadedModules[moduleName]['friendly-name'] }}
    Pages

    Create pages on your shop.

    - + List pages

    Mark pages as private, visible only to your subscribers.

    - + Set private pages
    @@ -416,7 +415,7 @@
    iFrame Embed
    SEO Titles

    Set optimised title tags for your pages.

    - Manage pages @@ -435,7 +434,7 @@
    SEO Titles
    - Add Shop Admin @@ -443,7 +442,7 @@
    SEO Titles
    href="{{ url_for('admin.delete_admin_by_id') }}"> Delete Shop Admin - Change my password @@ -481,7 +480,7 @@

    Reply-to email address

    Templates

    Email subscriber gets when they sign-up to a plan.

    - Customer Signup Confirmation diff --git a/subscribie/blueprints/admin/templates/admin/invoice/failed_invoices.html b/subscribie/blueprints/admin/templates/admin/invoice/failed_invoices.html index fa1fb93d0..8284a93ac 100644 --- a/subscribie/blueprints/admin/templates/admin/invoice/failed_invoices.html +++ b/subscribie/blueprints/admin/templates/admin/invoice/failed_invoices.html @@ -3,7 +3,7 @@ {% block body %} -

    Failing Invoices

    +

    Failed Invoices