@@ 169,7 169,7 @@ 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/",
@@ 177,6 177,11 @@ urlpatterns += [
name="billing_subscribe",
),
path(
+ "billing/resubscribe/",
+ billing.BillingResubscribe.as_view(),
+ name="billing_resubscribe",
+ ),
+ path(
"billing/card/<slug:stripe_payment_method_id>/delete/",
billing.BillingCardDelete.as_view(),
name="billing_card_delete",
@@ 187,11 192,6 @@ urlpatterns += [
name="billing_card_default",
),
path(
- "billing/subscription/",
- billing.billing_subscription,
- name="billing_subscription",
- ),
- path(
"billing/subscription/welcome/",
billing.billing_welcome,
name="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",
),
@@ 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