From 7156c8ada9a57ce4db240c2399a203304b2ee343 Mon Sep 17 00:00:00 2001 From: Jordan Robinson Date: Thu, 27 Nov 2025 22:35:05 +0000 Subject: [PATCH] update billing --- main/middleware.py | 31 +- main/scheme.py | 8 + main/templates/main/billing_overview.html | 194 +++++ main/templates/main/billing_resubscribe.html | 27 + main/templates/main/billing_subscribe.html | 6 +- .../main/billing_subscription_resume.html | 14 + main/templates/main/dashboard.html | 2 +- main/urls.py | 19 +- main/views/billing.py | 662 +++++++++++++----- 9 files changed, 752 insertions(+), 211 deletions(-) create mode 100644 main/scheme.py create mode 100644 main/templates/main/billing_overview.html create mode 100644 main/templates/main/billing_resubscribe.html create mode 100644 main/templates/main/billing_subscription_resume.html diff --git a/main/middleware.py b/main/middleware.py index 4488631af8df951520d597e296e91b252b43b16f..d4a6cae3711f048f24f658f5d4ee037c65bd243d 100644 --- a/main/middleware.py +++ b/main/middleware.py @@ -4,7 +4,7 @@ from django.conf import settings from django.http import Http404, HttpResponseBadRequest from django.shortcuts import redirect -from main import denylist, models, util +from main import denylist, models, scheme def host_middleware(get_response): @@ -14,6 +14,17 @@ def host_middleware(get_response): # no http Host header in testing if not host: return get_response(request) + + # allow localhost for webhooks in local development + if host.startswith("localhost:") or host.startswith("127.0.0.1:"): + return get_response(request) + + # allow localhost for webhooks and Caddy on-demand TLS validation + if (host.startswith("localhost:") or host.startswith("127.0.0.1:")) and ( + request.path.startswith("/webhook/") + or request.path.startswith("/accounts/domain/") + ): + return get_response(request) host_parts = host.split(".") canonical_parts = settings.CANONICAL_HOST.split(".") @@ -28,24 +39,19 @@ def host_middleware(get_response): request.theme_sansserif = request.user.theme_sansserif return get_response(request) elif ( - len(host_parts) == 4 - and host_parts[1] == canonical_parts[0] # should be "bocpress" - and host_parts[2] == canonical_parts[1] # should be "co" - and host_parts[3] == canonical_parts[2] # should be "uk" + len(host_parts) == 3 + and host_parts[1] == canonical_parts[0] # should be "mataroa" + and host_parts[2] == canonical_parts[1] # should be "blog" ): - # this case is for .bocpress.co.uk: + # this case is for .mataroa.blog: # * set subdomain to given subdomain # * the lists indexes are different because CANONICAL_HOST has no subdomain # * also validation will happen inside views request.subdomain = host_parts[0] - allow_docs_user = False - if request.subdomain == "docs" and settings.ALLOW_DOCS_USER: - allow_docs_user = True - # check if subdomain is disallowed - if request.subdomain in denylist.DISALLOWED_USERNAMES and not allow_docs_user: - return redirect(f"{util.get_protocol()}//{settings.CANONICAL_HOST}") + if request.subdomain in denylist.DISALLOWED_USERNAMES: + return redirect(f"{scheme.get_protocol()}//{settings.CANONICAL_HOST}") # check if subdomain exists as blog elif models.User.objects.filter(username=request.subdomain).exists(): request.blog_user = models.User.objects.get(username=request.subdomain) @@ -62,7 +68,6 @@ def host_middleware(get_response): and request.user.username != request.subdomain ): redir_domain = "" - print(request.blog_user.custom_domain) if request.blog_user.custom_domain: # user has set custom domain redir_domain = ( request.blog_user.custom_domain + request.path_info diff --git a/main/scheme.py b/main/scheme.py new file mode 100644 index 0000000000000000000000000000000000000000..73b559c93044da02d3c967b3bdc2ae5f9881aa21 --- /dev/null +++ b/main/scheme.py @@ -0,0 +1,8 @@ +from django.conf import settings + + +def get_protocol(): + if settings.DEBUG: + return "http:" + else: + return "https:" \ No newline at end of file diff --git a/main/templates/main/billing_overview.html b/main/templates/main/billing_overview.html new file mode 100644 index 0000000000000000000000000000000000000000..21e042d6b195f1df1d8b5b5c5fba29fcbab0fcfb --- /dev/null +++ b/main/templates/main/billing_overview.html @@ -0,0 +1,194 @@ +{% extends 'main/layout.html' %} + +{% block title %}Billing{% endblock %} + +{% block content %} +
+

Billing

+ + {# grandfather case #} + {% if request.user.is_grandfathered %} + Currently and forever on the + Grandfather Plan. + Rejoice eternally. + {% endif %} + + {# monero case #} + {% if not request.user.is_grandfathered and request.user.monero_address %} + {% if request.user.is_premium %} +

+ Currently on Premium Plan. +

+

+ Our Premium Plan costs 0.05 XMR per year and includes all features including + the ability to set a custom domain. +

+

+ Your Monero address is: +

+ + {{ request.user.monero_address }} + + {% else %} +

+ Currently on Free Plan. +

+

+ Our Premium Plan costs 0.05 XMR per year and includes all features including + the ability to set a custom domain. +

+

+ Subscribe by sending 0.05 XMR to the Monero address below. +

+ + {{ request.user.monero_address }} + +

+ Please allow 1 hour for the transaction to be verified and BōcPress to get up-to-date. +

+ {% endif %} + {% endif %} + + {# stripe case #} + {% if not request.user.is_grandfathered and not request.user.monero_address %} + + {# stripe case - premium intro #} + {% if request.user.is_premium %} + {% if subscription_status == 'canceling' %} +

+ Your Premium subscription will end on {{ current_period_end|date:"F j, Y" }}. +

+ {% else %} +

+ Currently on Premium Plan. +

+ {% endif %} +
    + {% if current_period_start %} +
  • Last payment of £12 was charged {{ current_period_start|date:"F j, Y" }}.
  • + {% endif %} + {% if current_period_end and subscription_status != 'canceling' %} +
  • Next payment will be at {{ current_period_end|date:"F j, Y" }}.
  • + {% endif %} +
  • $0.45 – 5% of your previous payment was used to fund CO₂ removal.
  • +
+ {% endif %} + + {# stripe case - non-premium #} + {% if not request.user.is_premium %} +

+ Currently on Free Plan. +

+ {% if current_period_start %} +

+ Last payment of £12 was charged {{ current_period_start|date:"F j, Y" }}. +

+ {% endif %} +

+ Our Premium Plan costs £12 per year and includes all features including + the ability to set a custom domain. +

+

+ BōcPress is also participating in + Stripe Climate + and will contribute 5% of its subscription revenues to remove CO₂ from + the atmosphere. +

+ {% endif %} + + {# stripe case - payment cards #} + {% if payment_methods %} +

Cards:

+
    + + {% for pm in payment_methods %} + {% if pm.is_default %} +
  • + {{ pm.brand|capfirst }} {{ pm.last4 }} (exp. {{ pm.exp_month }}/{{ pm.exp_year }}) — default + + + {% if not request.user.is_premium %} + | remove + {% endif %} +
  • + {% endif %} + {% endfor %} + + + {% for pm in payment_methods %} + {% if not pm.is_default %} +
  • + {{ pm.brand|capfirst }} {{ pm.last4 }} (exp. {{ pm.exp_month }}/{{ pm.exp_year }}) + —
    + {% csrf_token %} + +
    + | remove +
  • + {% endif %} + {% endfor %} +
+ {% endif %} + + {# stripe case - premium add card #} + {% if request.user.is_premium %} +

+ Add new card » +

+ {% endif %} + + {# stripe case - premium add card #} + {% if not request.user.is_premium and payment_methods%} +

+ Add new card » +

+ {% endif %} + + {# stripe case - invoices for premium #} + {% if request.user.is_premium %} +

+ Invoices: +

+
    + {% for invoice in invoice_list %} +
  • + + {{ invoice.created|date }} + + {{ invoice.created|time:"H:i:s" }} + — + see invoice + | + download pdf +
  • + {% endfor %} +
+ {% endif %} + + {# stripe case - non-premium controls #} + {% if not request.user.is_premium %} +

+ {% if payment_methods %} + Resubscribe to Premium » + {% else %} + Subscribe to Premium » + {% endif %} +

+ {% endif %} + + {# stripe case - premium cancel #} + {% if request.user.is_premium %} + {% if subscription_status != 'canceling' %} +

+ Cancel subscription +

+ {% else %} +

+ Resume subscription +

+ {% endif %} + {% endif %} + + {% endif %} +
+{% endblock content %} diff --git a/main/templates/main/billing_resubscribe.html b/main/templates/main/billing_resubscribe.html new file mode 100644 index 0000000000000000000000000000000000000000..85a4fc9224c25fd5d46dea7452b55dcf11d67c5b --- /dev/null +++ b/main/templates/main/billing_resubscribe.html @@ -0,0 +1,27 @@ +{% extends 'main/layout.html' %} + +{% block title %}Resubscribe to Premium{% endblock %} + +{% block content %} +
+

Resubscribe to Premium

+ +

+ Your {{ default_card.brand|capfirst }} card ending in {{ default_card.last4 }} + will be charged £12 immediately. +

+ +

+ You'll be charged £12 per year. You will be able to cancel anytime from the billing page. +

+ +
+ {% csrf_token %} + +
+ +

+ ← Back to Billing +

+
+{% endblock content %} diff --git a/main/templates/main/billing_subscribe.html b/main/templates/main/billing_subscribe.html index 93536a463106922ee8ce85f7def12d009391509f..667aaf489e8c606b5469cf81b9de5795edc029f1 100644 --- a/main/templates/main/billing_subscribe.html +++ b/main/templates/main/billing_subscribe.html @@ -15,7 +15,7 @@

Subscribe to Premium

-
+
{# stripe.js will create stripe elements forms here #} @@ -56,7 +56,7 @@
  • All terms of service can be found in our - Platform Methodology + Methodology page.
  • @@ -87,7 +87,7 @@ // add loading message const loadingContainer = document.querySelector('#loading-message'); - loadingContainer.textContent = 'Submitting...'; + loadingContainer.textContent = 'Charging card using Stripe, this should take less than 10 seconds...'; // clear error if exists const messageContainer = document.querySelector('#error-message'); diff --git a/main/templates/main/billing_subscription_resume.html b/main/templates/main/billing_subscription_resume.html new file mode 100644 index 0000000000000000000000000000000000000000..48e6ce9b61a446fc3a3701d6b3e9546258506e72 --- /dev/null +++ b/main/templates/main/billing_subscription_resume.html @@ -0,0 +1,14 @@ +{% extends 'main/layout.html' %} + +{% block title %}Resume subscription — {{ request.user.username }}{% endblock %} + +{% block content %} +
    +

    Resume Premium subscription?

    + + + {% csrf_token %} + + +
    +{% endblock content %} diff --git a/main/templates/main/dashboard.html b/main/templates/main/dashboard.html index 8a9699cdcb7fe235491b7f15c1f6ea71a922568f..368183671863163997385e1309663dbcd9a513e1 100644 --- a/main/templates/main/dashboard.html +++ b/main/templates/main/dashboard.html @@ -44,7 +44,7 @@
    API {% if billing_enabled %} -
    Billing +
    Billing {% endif %}
    Import posts diff --git a/main/urls.py b/main/urls.py index 6eb13b9a9a79f96c40b4860a0b5f8d057fb2a24a..2b51046b2477f38a8512b230a9ace63e40e629a1 100644 --- a/main/urls.py +++ b/main/urls.py @@ -169,13 +169,18 @@ urlpatterns += [ # billing urlpatterns += [ - path("billing/", billing.billing_index, name="billing_index"), + path("billing/overview/", billing.billing_overview, name="billing_overview"), path("billing/card/", billing.BillingCard.as_view(), name="billing_card"), path( "billing/subscribe/", billing.BillingSubscribe.as_view(), name="billing_subscribe", ), + path( + "billing/resubscribe/", + billing.BillingResubscribe.as_view(), + name="billing_resubscribe", + ), path( "billing/card//delete/", billing.BillingCardDelete.as_view(), @@ -186,11 +191,6 @@ urlpatterns += [ billing.billing_card_default, name="billing_card_default", ), - path( - "billing/subscription/", - billing.billing_subscription, - name="billing_subscription", - ), path( "billing/subscription/welcome/", billing.billing_welcome, @@ -207,7 +207,12 @@ urlpatterns += [ name="billing_subscription_cancel", ), path( - "billing/stripe/webhook/", + "billing/subscription/resume/", + billing.BillingResume.as_view(), + name="billing_subscription_resume", + ), + path( + "webhook/stripe/", billing.billing_stripe_webhook, name="billing_stripe_webhook", ), diff --git a/main/views/billing.py b/main/views/billing.py index c3b1d2ae66546e607bb29bdfd5a704c14ed75e21..ef5b24d9d6be248c14912899d75fdb518886fe4a 100644 --- a/main/views/billing.py +++ b/main/views/billing.py @@ -1,6 +1,6 @@ import json import logging -from datetime import datetime +from datetime import UTC, datetime import stripe from django.conf import settings @@ -11,73 +11,112 @@ from django.core.mail import mail_admins from django.http import ( HttpResponse, HttpResponseBadRequest, - HttpResponseNotAllowed, HttpResponseRedirect, ) from django.shortcuts import redirect, render from django.urls import reverse_lazy from django.views import View from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST from django.views.generic.edit import FormView -from main import forms, models, util +from main import forms, models, scheme logger = logging.getLogger(__name__) -def _create_setup_intent(customer_id): - stripe.api_key = settings.STRIPE_API_KEY - - try: - stripe_setup_intent = stripe.SetupIntent.create( - automatic_payment_methods={"enabled": True}, - customer=customer_id, +@login_required +def billing_overview(request): + """ + Renders the billing index page which includes a summary of subscription and + payment methods. + """ + # respond for grandfathered users first + if request.user.is_grandfathered: + return render( + request, + "main/billing_overview.html", + { + "is_grandfathered": True, + }, ) - except stripe.error.StripeError as ex: - logger.error(str(ex)) - raise Exception("Failed to create setup intent on Stripe.") from ex - - return { - "stripe_client_secret": stripe_setup_intent["client_secret"], - } + # respond for monero case + if request.user.monero_address: + return render(request, "main/billing_overview.html") -def _create_stripe_subscription(customer_id): stripe.api_key = settings.STRIPE_API_KEY - # expand subscription's latest invoice and invoice's payment_intent - # so we can pass it to the front end to confirm the payment - try: - stripe_subscription = stripe.Subscription.create( - customer=customer_id, - items=[ - { - "price": settings.STRIPE_PRICE_ID, - } - ], - payment_behavior="default_incomplete", - payment_settings={"save_default_payment_method": "on_subscription"}, - expand=["latest_invoice.payment_intent"], - ) - except stripe.error.StripeError as ex: - logger.error(str(ex)) - raise Exception("Failed to create subscription on Stripe.") from ex + # create stripe customer for user if it does not exist + if not request.user.stripe_customer_id: + try: + stripe_response = stripe.Customer.create() + except stripe.StripeError as ex: + logger.error(str(ex)) + raise Exception("Failed to create customer on Stripe.") from ex + request.user.stripe_customer_id = stripe_response["id"] + request.user.save() - return { - "stripe_subscription_id": stripe_subscription["id"], - "stripe_client_secret": stripe_subscription["latest_invoice"]["payment_intent"][ - "client_secret" - ], - } + # get subscription if exists + current_period_start = None + current_period_end = None + subscription_status = None + if request.user.stripe_subscription_id: + subscription = _get_stripe_subscription(request.user.stripe_subscription_id) + if subscription: + subscription_status = subscription.get("status") + if subscription.get("cancel_at_period_end"): + subscription_status = "canceling" + # parse period fields when present (even if not active), + # so "Last payment" can be shown after scheduling cancellation + latest_invoice = subscription.get("latest_invoice") if subscription else None + if isinstance(latest_invoice, dict) and latest_invoice.get("status") == "paid": + items = (subscription or {}).get("items") or {} + item_data = items.get("data") or [] + first_item = item_data[0] if item_data else {} + if current_period_start := first_item.get("current_period_start"): + current_period_start = datetime.fromtimestamp( + current_period_start, tz=UTC + ) + if current_period_end := first_item.get("current_period_end"): + current_period_end = datetime.fromtimestamp(current_period_end, tz=UTC) + + # transform into list of values + payment_methods = _get_payment_methods(request.user.stripe_customer_id).values() + + return render( + request, + "main/billing_overview.html", + { + "stripe_customer_id": request.user.stripe_customer_id, + "stripe_public_key": settings.STRIPE_PUBLIC_KEY, + "stripe_price_id": settings.STRIPE_PRICE_ID, + "current_period_end": current_period_end, + "current_period_start": current_period_start, + "subscription_status": subscription_status, + "payment_methods": payment_methods, + "invoice_list": _get_invoices(request.user.stripe_customer_id), + }, + ) def _get_stripe_subscription(stripe_subscription_id): stripe.api_key = settings.STRIPE_API_KEY try: - stripe_subscription = stripe.Subscription.retrieve(stripe_subscription_id) - except stripe.error.StripeError as ex: - logger.error(str(ex)) + stripe_subscription = stripe.Subscription.retrieve( + stripe_subscription_id, + expand=["latest_invoice", "latest_invoice.payment_intent"], + ) + except stripe.InvalidRequestError as ex: + logger.warning("Subscription %s not found: %s", stripe_subscription_id, str(ex)) + return None + except stripe.StripeError as ex: + logger.error( + "Failed to get subscription %s from Stripe: %s", + stripe_subscription_id, + str(ex), + ) raise Exception("Failed to get subscription from Stripe.") from ex return stripe_subscription @@ -92,7 +131,7 @@ def _get_payment_methods(stripe_customer_id): default_pm_id = stripe.Customer.retrieve( stripe_customer_id ).invoice_settings.default_payment_method - except stripe.error.StripeError as ex: + except stripe.StripeError as ex: logger.error(str(ex)) raise Exception("Failed to retrieve customer data from Stripe.") from ex @@ -102,7 +141,7 @@ def _get_payment_methods(stripe_customer_id): customer=stripe_customer_id, type="card", ) - except stripe.error.StripeError as ex: + except stripe.StripeError as ex: logger.error(str(ex)) raise Exception("Failed to retrieve payment methods from Stripe.") from ex @@ -130,11 +169,11 @@ def _get_invoices(stripe_customer_id): # get user invoices try: stripe_invoices = stripe.Invoice.list(customer=stripe_customer_id) - except stripe.error.StripeError as ex: + except stripe.StripeError as ex: logger.error(str(ex)) raise Exception("Failed to retrieve invoices data from Stripe.") from ex - # normalise invoices objects + # normalize invoice objects invoice_list = [] for stripe_inv in stripe_invoices.data: invoice_list.append( @@ -142,82 +181,22 @@ def _get_invoices(stripe_customer_id): "id": stripe_inv.id, "url": stripe_inv.hosted_invoice_url, "pdf": stripe_inv.invoice_pdf, - "period_start": datetime.fromtimestamp(stripe_inv.period_start), - "period_end": datetime.fromtimestamp(stripe_inv.period_end), - "created": datetime.fromtimestamp(stripe_inv.created), + "period_start": datetime.fromtimestamp(stripe_inv.period_start, tz=UTC), + "period_end": datetime.fromtimestamp(stripe_inv.period_end, tz=UTC), + "created": datetime.fromtimestamp(stripe_inv.created, tz=UTC), } ) return invoice_list -@login_required -def billing_index(request): - """ - View method that shows the billing index, a summary of subscription and - payment methods. - """ - # respond for grandfathered users first - if request.user.is_grandfathered: - return render( - request, - "main/billing_index.html", - { - "is_grandfathered": True, - }, - ) - - # respond for monero case - if request.user.monero_address: - return render(request, "main/billing_index.html") - - stripe.api_key = settings.STRIPE_API_KEY - - # create stripe customer for user if it does not exist - if not request.user.stripe_customer_id: - try: - stripe_response = stripe.Customer.create() - except stripe.error.StripeError as ex: - logger.error(str(ex)) - raise Exception("Failed to create customer on Stripe.") from ex - request.user.stripe_customer_id = stripe_response["id"] - request.user.save() - - # get subscription if exists - current_period_start = None - current_period_end = None - if request.user.stripe_subscription_id: - subscription = _get_stripe_subscription(request.user.stripe_subscription_id) - current_period_start = datetime.utcfromtimestamp( - subscription["current_period_start"] - ) - current_period_end = datetime.utcfromtimestamp( - subscription["current_period_end"] - ) - - # transform into list of values - payment_methods = _get_payment_methods(request.user.stripe_customer_id).values() - - return render( - request, - "main/billing_index.html", - { - "stripe_customer_id": request.user.stripe_customer_id, - "stripe_public_key": settings.STRIPE_PUBLIC_KEY, - "stripe_price_id": settings.STRIPE_PRICE_ID, - "current_period_end": current_period_end, - "current_period_start": current_period_start, - "payment_methods": payment_methods, - "invoice_list": _get_invoices(request.user.stripe_customer_id), - }, - ) - - class BillingSubscribe(LoginRequiredMixin, FormView): form_class = forms.StripeForm template_name = "main/billing_subscribe.html" - success_url = reverse_lazy("billing_index") - success_message = "premium subscription enabled" + success_url = reverse_lazy("billing_overview") + success_message = ( + "payment is processing; premium will be enabled once the charge succeeds" + ) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -227,17 +206,53 @@ class BillingSubscribe(LoginRequiredMixin, FormView): def get(self, request, *args, **kwargs): stripe.api_key = settings.STRIPE_API_KEY - data = _create_stripe_subscription(request.user.stripe_customer_id) - request.user.stripe_subscription_id = data["stripe_subscription_id"] + # ensure customer exists + if not request.user.stripe_customer_id: + try: + created = stripe.Customer.create() + request.user.stripe_customer_id = created.get("id") + request.user.save() + except stripe.StripeError as ex: + logger.error("Failed creating customer before subscribe: %s", str(ex)) + messages.error( + request, "payment processor unavailable; please try again later" + ) + return redirect("billing_overview") + + url = f"{scheme.get_protocol()}//{settings.CANONICAL_HOST}" + url += reverse_lazy("billing_welcome") + + if request.user.stripe_subscription_id: + stripe_subscription = _get_stripe_subscription( + request.user.stripe_subscription_id + ) + # create new subscription if: + # * subscription is canceled but webhook was not received (yet) + # * stripe fails or returns None + if ( + stripe_subscription.get("status") == "canceled" + or stripe_subscription is None + ): + stripe_subscription = _create_stripe_subscription( + request.user.stripe_customer_id + ) + else: + stripe_subscription = _create_stripe_subscription( + request.user.stripe_customer_id + ) + request.user.stripe_subscription_id = stripe_subscription.get("id") request.user.save() - url = f"{util.get_protocol()}//{settings.CANONICAL_HOST}" - url += reverse_lazy("billing_welcome") + payment_intents = stripe.PaymentIntent.list( + customer=request.user.stripe_customer_id, limit=1 + ) + client_secret = None + if payment_intents.data: + client_secret = payment_intents.data[0].client_secret context = self.get_context_data() - context["stripe_client_secret"] = data["stripe_client_secret"] + context["stripe_client_secret"] = client_secret context["stripe_return_url"] = url - return self.render_to_response(context) def post(self, request, *args, **kwargs): @@ -251,10 +266,34 @@ class BillingSubscribe(LoginRequiredMixin, FormView): return self.form_invalid(form) +def _create_stripe_subscription(customer_id): + stripe.api_key = settings.STRIPE_API_KEY + + # expand subscription's latest invoice and invoice's payment_intent + # so we can pass it to the front end to confirm the payment + try: + stripe_subscription = stripe.Subscription.create( + customer=customer_id, + items=[ + { + "price": settings.STRIPE_PRICE_ID, + } + ], + payment_behavior="default_incomplete", + payment_settings={"save_default_payment_method": "on_subscription"}, + ) + logger.info(f"Created subscription: {stripe_subscription.get('id')}") + except stripe.StripeError as ex: + logger.error(str(ex)) + raise Exception("Failed to create subscription on Stripe.") from ex + + return stripe_subscription + + class BillingCard(LoginRequiredMixin, FormView): form_class = forms.StripeForm template_name = "main/billing_card.html" - success_url = reverse_lazy("billing_index") + success_url = reverse_lazy("billing_overview") success_message = "new card added" def get_context_data(self, **kwargs): @@ -269,7 +308,7 @@ class BillingCard(LoginRequiredMixin, FormView): data = _create_setup_intent(request.user.stripe_customer_id) context["stripe_client_secret"] = data["stripe_client_secret"] - url = f"{util.get_protocol()}//{settings.CANONICAL_HOST}" + url = f"{scheme.get_protocol()}//{settings.CANONICAL_HOST}" url += reverse_lazy("billing_card_confirm") context["stripe_return_url"] = url @@ -286,11 +325,28 @@ class BillingCard(LoginRequiredMixin, FormView): return self.form_invalid(form) +def _create_setup_intent(customer_id): + stripe.api_key = settings.STRIPE_API_KEY + + try: + stripe_setup_intent = stripe.SetupIntent.create( + automatic_payment_methods={"enabled": True}, + customer=customer_id, + ) + except stripe.StripeError as ex: + logger.error(str(ex)) + raise Exception("Failed to create setup intent on Stripe.") from ex + + return { + "stripe_client_secret": stripe_setup_intent["client_secret"], + } + + class BillingCardDelete(LoginRequiredMixin, View): """View that deletes a card from a user on Stripe.""" template_name = "main/billing_card_confirm_delete.html" - success_url = reverse_lazy("billing_index") + success_url = reverse_lazy("billing_overview") success_message = "card deleted" slug_url_kwarg = "stripe_payment_method_id" @@ -308,10 +364,10 @@ class BillingCardDelete(LoginRequiredMixin, View): card_id = self.kwargs.get(self.slug_url_kwarg) try: stripe.PaymentMethod.detach(card_id) - except stripe.error.StripeError as ex: + except stripe.StripeError as ex: logger.error(str(ex)) messages.error(request, "payment processor unresponsive; please try again") - return redirect(reverse_lazy("billing_index")) + return redirect(reverse_lazy("billing_overview")) messages.success(request, self.success_message) return HttpResponseRedirect(self.success_url) @@ -352,11 +408,10 @@ class BillingCardDelete(LoginRequiredMixin, View): return super().dispatch(request, *args, **kwargs) +@require_POST @login_required def billing_card_default(request, stripe_payment_method_id): """View method that changes the default card of a user on Stripe.""" - if request.method != "POST": - return HttpResponseNotAllowed(["POST"]) stripe_payment_methods = _get_payment_methods(request.user.stripe_customer_id) @@ -371,31 +426,29 @@ def billing_card_default(request, stripe_payment_method_id): "default_payment_method": stripe_payment_method_id, }, ) - except stripe.error.StripeError as ex: + except stripe.StripeError as ex: logger.error(str(ex)) return HttpResponse("Could not change default card.", status=503) messages.success(request, "default card updated") - return redirect("billing_index") + return redirect("billing_overview") class BillingCancel(LoginRequiredMixin, View): """View that cancels a user subscription on Stripe.""" template_name = "main/billing_subscription_cancel.html" - success_url = reverse_lazy("billing_index") - success_message = "premium subscription canceled" + success_url = reverse_lazy("billing_overview") + success_message = "subscription will be canceled at period end" - def post(self, request, *args, **kwargs): + def post(self, request): subscription = _get_stripe_subscription(request.user.stripe_subscription_id) try: - stripe.Subscription.delete(subscription["id"]) - except stripe.error.StripeError as ex: + # cancel at period end to keep access for the remainder of the paid period + stripe.Subscription.modify(subscription["id"], cancel_at_period_end=True) + except stripe.StripeError as ex: logger.error(str(ex)) return HttpResponse("Subscription could not be canceled.", status=503) - request.user.is_premium = False - request.user.stripe_subscription_id = None - request.user.save() mail_admins( f"Cancellation premium subscriber: {request.user.username}", f"{request.user.blog_absolute_url}\n", @@ -403,6 +456,46 @@ class BillingCancel(LoginRequiredMixin, View): messages.success(request, self.success_message) return HttpResponseRedirect(self.success_url) + def get(self, request): + return render(request, self.template_name) + + def dispatch(self, request, *args, **kwargs): + # redirect grandfathered users to dashboard + if request.user.is_grandfathered: + return redirect("dashboard") + + # if user has no customer id, redirect to billing_overview to have it generated + if not request.user.stripe_customer_id: + return redirect("billing_overview") + + # if user is not premium, redirect + if not request.user.is_premium: + return redirect("billing_overview") + + subscription = _get_stripe_subscription(request.user.stripe_subscription_id) + if not subscription: + return redirect("billing_overview") + + return super().dispatch(request, *args, **kwargs) + + +class BillingResume(LoginRequiredMixin, View): + """View that resumes a canceled user subscription on Stripe.""" + + template_name = "main/billing_subscription_resume.html" + success_url = reverse_lazy("billing_overview") + success_message = "subscription resumed" + + def post(self, request, *args, **kwargs): + subscription = _get_stripe_subscription(request.user.stripe_subscription_id) + try: + stripe.Subscription.modify(subscription["id"], cancel_at_period_end=False) + except stripe.StripeError as ex: + logger.error(str(ex)) + return HttpResponse("Subscription could not be resumed.", status=503) + messages.success(request, self.success_message) + return HttpResponseRedirect(self.success_url) + def get(self, request, *args, **kwargs): return render(request, self.template_name) @@ -411,67 +504,170 @@ class BillingCancel(LoginRequiredMixin, View): if request.user.is_grandfathered: return redirect("dashboard") - # if user has no customer id, redirect to billing_index to have it generated + # if user has no customer id, redirect to billing_overview if not request.user.stripe_customer_id: - return redirect("billing_index") + return redirect("billing_overview") # if user is not premium, redirect if not request.user.is_premium: - return redirect("billing_index") + return redirect("billing_overview") subscription = _get_stripe_subscription(request.user.stripe_subscription_id) if not subscription: - return redirect("billing_index") + return redirect("billing_overview") + + # only allow resuming if subscription is set to cancel + if not subscription.get("cancel_at_period_end"): + return redirect("billing_overview") return super().dispatch(request, *args, **kwargs) -@login_required -def billing_subscription(request): +class BillingResubscribe(LoginRequiredMixin, View): """ - View that creates a new subscription for user on Stripe, - given they already have a card registered. + View that creates a new subscription for returning users with saved payment methods. + Charges immediately using the default payment method. """ - if request.method != "POST": - return HttpResponseNotAllowed(["POST"]) - # redirect grandfathered users to dashboard - if request.user.is_grandfathered: - return redirect("dashboard") - - data = _create_stripe_subscription(request.user.stripe_customer_id) - request.user.stripe_subscription_id = data["stripe_subscription_id"] - request.user.is_premium = True - request.user.is_approved = True - request.user.save() - mail_admins( - f"New premium subscriber: {request.user.username}", - f"{request.user.blog_absolute_url}\n\n{request.user.blog_url}", - ) + template_name = "main/billing_resubscribe.html" + success_url = reverse_lazy("billing_overview") + success_message = "premium subscription enabled" + + def get(self, request): + stripe.api_key = settings.STRIPE_API_KEY - messages.success(request, "premium subscription enabled") - return redirect("billing_index") + payment_methods = _get_payment_methods(request.user.stripe_customer_id) + + # make a card default if not already + has_default = any(pm.get("is_default") for pm in payment_methods.values()) + if payment_methods and not has_default: + first_pm = next(iter(payment_methods.values())) + try: + stripe.Customer.modify( + request.user.stripe_customer_id, + invoice_settings={"default_payment_method": first_pm["id"]}, + ) + payment_methods[first_pm["id"]]["is_default"] = True + except Exception as e: + logger.error(f"Unable to set default payment method: {e}") + + default_card = None + for pm in payment_methods.values(): + if pm["is_default"]: + default_card = pm + break + + context = { + "default_card": default_card, + } + return render(request, self.template_name, context) + + def post(self, request): + stripe.api_key = settings.STRIPE_API_KEY + + try: + # create subscription with immediate charge using saved payment method + stripe_subscription = stripe.Subscription.create( + customer=request.user.stripe_customer_id, + items=[ + { + "price": settings.STRIPE_PRICE_ID, + } + ], + payment_behavior="error_if_incomplete", + payment_settings={"save_default_payment_method": "on_subscription"}, + expand=["latest_invoice.payment_intent"], + ) + + request.user.stripe_subscription_id = stripe_subscription.get("id") + request.user.save() + + # check if payment succeeded immediately + latest_invoice = stripe_subscription.get("latest_invoice") + if latest_invoice: + payment_intent = latest_invoice.get("payment_intent") + if payment_intent and payment_intent.get("status") == "succeeded": + if not request.user.is_premium: + request.user.is_premium = True + request.user.is_approved = True + request.user.save() + if request.user.blog_absolute_url == request.user.blog_url: + blog_info = request.user.blog_absolute_url + else: + blog_info = f"{request.user.blog_absolute_url}\n\n{request.user.blog_url}" + mail_admins( + f"New premium resubscriber: {request.user.username}", + blog_info, + ) + messages.success(request, self.success_message) + else: + messages.info(request, "payment is processing") + else: + messages.info(request, "payment processing") + + except stripe.StripeError as ex: + logger.error("Failed to create resubscription: %s", str(ex)) + messages.error( + request, + "failed to create subscription; please try again or contact support", + ) + + return redirect(self.success_url) + + def dispatch(self, request, *args, **kwargs): + # redirect grandfathered users + if request.user.is_grandfathered: + return redirect("billing_overview") + + # redirect if already premium + if request.user.is_premium: + return redirect("billing_overview") + + # ensure customer exists + if not request.user.stripe_customer_id: + return redirect("billing_overview") + + stripe.api_key = settings.STRIPE_API_KEY + + # check if user has payment methods + payment_methods = _get_payment_methods(request.user.stripe_customer_id) + if not payment_methods: + # no saved payment methods, redirect to regular subscribe flow + return redirect("billing_subscribe") + + return super().dispatch(request, *args, **kwargs) @login_required def billing_welcome(request): """ View that Stripe returns to if subscription initialisation is successful. - Adds a message alert and redirects to billing_index. + Adds a message alert and redirects to billing_overview. """ payment_intent = request.GET.get("payment_intent") + if not payment_intent: + return redirect("billing_overview") stripe.api_key = settings.STRIPE_API_KEY stripe_intent = stripe.PaymentIntent.retrieve(payment_intent) if stripe_intent["status"] == "succeeded": - request.user.is_premium = True - request.user.is_approved = True - request.user.save() - mail_admins( - f"New premium subscriber: {request.user.username}", - f"{request.user.blog_absolute_url}\n\n{request.user.blog_url}", - ) + # charge succeeded during client-side confirmation flow + # enable premium if not already enabled via webhook + if not request.user.is_premium: + request.user.is_premium = True + request.user.is_approved = True + request.user.save() + if request.user.blog_absolute_url == request.user.blog_url: + blog_info = request.user.blog_absolute_url + else: + blog_info = ( + f"{request.user.blog_absolute_url}\n\n{request.user.blog_url}" + ) + mail_admins( + f"New premium subscriber from welcome page: {request.user.username}", + blog_info, + ) messages.success(request, "premium subscription enabled") elif stripe_intent["status"] == "processing": messages.info(request, "payment is currently processing") @@ -481,12 +677,14 @@ def billing_welcome(request): "something is wrong. don't sweat it, worst case you get premium for free", ) - return redirect("billing_index") + return redirect("billing_overview") @login_required def billing_card_confirm(request): setup_intent = request.GET.get("setup_intent") + if not setup_intent: + return redirect("billing_overview") stripe.api_key = settings.STRIPE_API_KEY stripe_intent = stripe.SetupIntent.retrieve(setup_intent) @@ -503,7 +701,7 @@ def billing_card_confirm(request): "something is wrong. don't sweat it, worst case you get premium for free", ) - return redirect("billing_index") + return redirect("billing_overview") @csrf_exempt @@ -513,22 +711,112 @@ def billing_stripe_webhook(request): See: https://stripe.com/docs/webhooks """ + # ensure only POST requests are allowed + if request.method != "POST": + return HttpResponse(status=405) + + # get Stripe settings stripe.api_key = settings.STRIPE_API_KEY - data = json.loads(request.body) + webhook_secret = getattr(settings, "STRIPE_WEBHOOK_SECRET", "") try: - event = stripe.Event.construct_from(data, stripe.api_key) - except ValueError as ex: - logger.error(str(ex)) - return HttpResponse(status=400) + # parse event from Stripe + if webhook_secret: + # verify webhook signature + sig_header = request.headers.get("Stripe-Signature", "") + event = stripe.Webhook.construct_event( + payload=request.body, + sig_header=sig_header, + secret=webhook_secret, + ) + else: + # Development mode: skip signature verification + data = json.loads(request.body.decode("utf-8")) + event = stripe.Event.construct_from(data, stripe.api_key) - if event.type == "payment_intent.created": - payment_intent = event.data.object - if payment_intent.customer is None: - return HttpResponse(status=400) - user = models.User.objects.filter( - stripe_customer_id=payment_intent.customer.id - ).is_premium = True - user.save() + except (ValueError, stripe.SignatureVerificationError) as ex: + # invalid payload or signature + logger.error(f"Webhook validation failed: {type(ex).__name__}: {str(ex)}") + return HttpResponse(status=400) + except Exception as ex: + logger.error(f"Webhook processing error: {str(ex)}") + return HttpResponse(status=500) - return HttpResponse() + # process webhook event types + try: + if event.type == "invoice.payment_succeeded": + invoice = event.data.object + customer_id = getattr(invoice, "customer", None) + if customer_id: + customer_id_str = ( + customer_id.id if hasattr(customer_id, "id") else str(customer_id) + ) + + try: + user = models.User.objects.get(stripe_customer_id=customer_id_str) + if not user.is_premium: + user.is_premium = True + user.is_approved = True + user.save() + if user.blog_absolute_url == user.blog_url: + blog_info = user.blog_absolute_url + else: + blog_info = f"{user.blog_absolute_url}\n\n{user.blog_url}" + mail_admins( + f"New premium subscriber from webhook: {user.username}", + blog_info, + ) + except models.User.DoesNotExist: + logger.warning( + f"Webhook: user not found for customer_id={customer_id_str}" + ) + + elif event.type == "customer.subscription.deleted": + subscription = event.data.object + customer_id = getattr(subscription, "customer", None) + if customer_id: + customer_id_str = ( + customer_id.id if hasattr(customer_id, "id") else str(customer_id) + ) + + try: + user = models.User.objects.get(stripe_customer_id=customer_id_str) + if user.is_premium: + user.is_premium = False + user.stripe_subscription_id = None + user.save() + except models.User.DoesNotExist: + logger.warning( + f"Webhook: user not found for customer_id={customer_id_str}" + ) + + elif event.type == "payment_method.attached": + payment_method = event.data.object + customer_id = getattr(payment_method, "customer", None) + if customer_id: + customer_id_str = ( + customer_id.id if hasattr(customer_id, "id") else str(customer_id) + ) + + try: + # set payment method as default if customer has no default one yet + customer = stripe.Customer.retrieve(customer_id_str) + if not customer.invoice_settings.default_payment_method: + stripe.Customer.modify( + customer_id_str, + invoice_settings={ + "default_payment_method": payment_method.id, + }, + ) + logger.info( + f"Set payment method {payment_method.id} as default for customer {customer_id_str}" + ) + except stripe.StripeError as ex: + logger.error( + f"Failed to set default payment method for customer {customer_id_str}: {str(ex)}" + ) + + except Exception as ex: + logger.error(f"Webhook event processing error: {str(ex)}") + + return HttpResponse(status=200) \ No newline at end of file