~linuxgoose/bocpress

7156c8ada9a57ce4db240c2399a203304b2ee343 — Jordan Robinson 8 days ago fe70bdd
update billing
M main/middleware.py => main/middleware.py +18 -13
@@ 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 <subdomain>.bocpress.co.uk:
            # this case is for <subdomain>.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

A main/scheme.py => main/scheme.py +8 -0
@@ 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

A main/templates/main/billing_overview.html => main/templates/main/billing_overview.html +194 -0
@@ 0,0 1,194 @@
{% extends 'main/layout.html' %}

{% block title %}Billing{% endblock %}

{% block content %}
<main>
    <h1>Billing</h1>

    {# grandfather case #}
    {% if request.user.is_grandfathered %}
        Currently and forever on the
        <strong><a href="https://dictionary.cambridge.org/dictionary/english/grandfathered">Grandfather Plan</a></strong>.
        Rejoice eternally.
    {% endif %}

    {# monero case #}
    {% if not request.user.is_grandfathered and request.user.monero_address %}
    {% if request.user.is_premium %}
    <p>
        Currently on <strong>Premium Plan</strong>.
    </p>
    <p>
        Our Premium Plan costs 0.05 XMR per year and includes all features including
        the ability to set a custom domain.
    </p>
    <p>
        Your Monero address is:
    </p>
    <code style="word-wrap: break-word; background: #ffd9c3;">
        {{ request.user.monero_address }}
    </code>
    {% else %}
    <p>
        Currently on <strong>Free Plan</strong>.
    </p>
    <p>
        Our Premium Plan costs 0.05 XMR per year and includes all features including
        the ability to set a custom domain.
    </p>
    <p>
        Subscribe by sending 0.05 XMR to the Monero address below.
    </p>
    <code style="word-wrap: break-word; background: #ffd9c3;">
        {{ request.user.monero_address }}
    </code>
    <p>
        Please allow 1 hour for the transaction to be verified and BōcPress to get up-to-date.
    </p>
    {% 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' %}
    <p>
        Your Premium subscription will end on {{ current_period_end|date:"F j, Y" }}.
    </p>
    {% else %}
    <p>
        Currently on <strong>Premium Plan</strong>.
    </p>
    {% endif %}
    <ul>
        {% if current_period_start %}
        <li>Last payment of £12 was charged {{ current_period_start|date:"F j, Y" }}.</li>
        {% endif %}
        {% if current_period_end and subscription_status != 'canceling' %}
        <li>Next payment will be at {{ current_period_end|date:"F j, Y" }}.</li>
        {% endif %}
        <li>$0.45 – 5% of your previous payment was used to <a href="https://climate.stripe.com/AN3d4p">fund CO₂ removal</a>.</li>
    </ul>
    {% endif %}

    {# stripe case - non-premium #}
    {% if not request.user.is_premium %}
    <p>
        Currently on <strong>Free Plan</strong>.
    </p>
    {% if current_period_start %}
    <p>
        Last payment of £12 was charged {{ current_period_start|date:"F j, Y" }}.
    </p>
    {% endif %}
    <p>
        Our Premium Plan costs £12 per year and includes all features including
        the ability to set a custom domain.
    </p>
    <p>
        BōcPress is also participating in
        <a href="https://climate.stripe.com/AN3d4p">Stripe Climate</a>
        and will contribute 5% of its subscription revenues to remove CO₂ from
        the atmosphere.
    </p>
    {% endif %}

    {# stripe case - payment cards #}
    {% if payment_methods %}
    <p>Cards:</p>
    <ul>
        <!-- this displays only the default card -->
        {% for pm in payment_methods %}
        {% if pm.is_default %}
        <li>
            {{ pm.brand|capfirst }} {{ pm.last4 }} (exp. {{ pm.exp_month }}/{{ pm.exp_year }}) — default

            <!-- if user not on premium then allow them to delete all cards -->
            {% if not request.user.is_premium %}
            | <a href="{% url 'billing_card_delete' pm.id %}">remove</a>
            {% endif %}
        </li>
        {% endif %}
        {% endfor %}

        <!-- this displays all non-default cards -->
        {% for pm in payment_methods %}
        {% if not pm.is_default %}
        <li>
            {{ pm.brand|capfirst }} {{ pm.last4 }} (exp. {{ pm.exp_month }}/{{ pm.exp_year }})
            — <form action="{% url 'billing_card_default' pm.id %}" method="post" class="form-inline">
                {% csrf_token %}
                <input type="submit" value="make default">
            </form>
            | <a href="{% url 'billing_card_delete' pm.id %}">remove</a>
        </li>
        {% endif %}
        {% endfor %}
    </ul>
    {% endif %}

    {# stripe case - premium add card #}
    {% if request.user.is_premium %}
    <p>
        <a href="{% url 'billing_card' %}">Add new card »</a>
    </p>
    {% endif %}

    {# stripe case - premium add card #}
    {% if not request.user.is_premium and payment_methods%}
    <p>
        <a href="{% url 'billing_card' %}">Add new card »</a>
    </p>
    {% endif %}

    {# stripe case - invoices for premium #}
    {% if request.user.is_premium %}
    <p>
        Invoices:
    </p>
    <ul>
        {% for invoice in invoice_list %}
        <li>
            <span title="Period: {{ invoice.period_start|date:"Y-m-d" }} – {{ invoice.period_end|date:"Y-m-d" }}">
                {{ invoice.created|date }}
            </span>
            {{ invoice.created|time:"H:i:s" }}
            <a href="{{ invoice.url }}">see invoice</a>
            |
            <a href="{{ invoice.pdf }}">download pdf</a>
        </li>
        {% endfor %}
    </ul>
    {% endif %}

    {# stripe case - non-premium controls #}
    {% if not request.user.is_premium %}
    <p>
        {% if payment_methods %}
        <a href="{% url 'billing_resubscribe' %}">Resubscribe to Premium »</a>
        {% else %}
        <a href="{% url 'billing_subscribe' %}">Subscribe to Premium »</a>
        {% endif %}
    </p>
    {% endif %}

    {# stripe case - premium cancel #}
    {% if request.user.is_premium %}
    {% if subscription_status != 'canceling' %}
    <p style="margin-top: 32px;">
        <a href="{% url 'billing_subscription_cancel' %}" class="type-delete">Cancel subscription</a>
    </p>
    {% else %}
    <p style="margin-top: 32px;">
        <a href="{% url 'billing_subscription_resume' %}">Resume subscription</a>
    </p>
    {% endif %}
    {% endif %}

    {% endif %}
</main>
{% endblock content %}

A main/templates/main/billing_resubscribe.html => main/templates/main/billing_resubscribe.html +27 -0
@@ 0,0 1,27 @@
{% extends 'main/layout.html' %}

{% block title %}Resubscribe to Premium{% endblock %}

{% block content %}
<main>
    <h1>Resubscribe to Premium</h1>

    <p>
        Your <strong>{{ default_card.brand|capfirst }}</strong> card ending in <strong>{{ default_card.last4 }}</strong>
        will be charged <strong>£12</strong> immediately.
    </p>

    <p>
        You'll be charged £12 per year. You will be able to cancel anytime from the billing page.
    </p>

    <form method="post">
        {% csrf_token %}
        <input type="submit" value="Confirm">
    </form>

    <p style="margin-top: 24px;">
        <a href="{% url 'billing_overview' %}">← Back to Billing</a>
    </p>
</main>
{% endblock content %}

M main/templates/main/billing_subscribe.html => main/templates/main/billing_subscribe.html +3 -3
@@ 15,7 15,7 @@
<main>
    <h1>Subscribe to Premium</h1>

    <form id="payment-form" data-secret="{{ client_secret }}">
    <form id="payment-form" data-secret="{{ stripe_client_secret }}">

        <div id="payment-element">
            {# stripe.js will create stripe elements forms here #}


@@ 56,7 56,7 @@
        </li>
        <li>
            All terms of service can be found in our
            <a href="{% url 'methodology' %}">Platform Methodology</a>
            <a href="{% url 'methodology' %}">Methodology</a>
            page.
        </li>
    </ol>


@@ 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');

A main/templates/main/billing_subscription_resume.html => main/templates/main/billing_subscription_resume.html +14 -0
@@ 0,0 1,14 @@
{% extends 'main/layout.html' %}

{% block title %}Resume subscription — {{ request.user.username }}{% endblock %}

{% block content %}
<main>
    <h1>Resume Premium subscription?</h1>

    <form method="post">
        {% csrf_token %}
        <input type="submit" value="Yes, resume subscription">
    </form>
</main>
{% endblock content %}

M main/templates/main/dashboard.html => main/templates/main/dashboard.html +1 -1
@@ 44,7 44,7 @@
        <br><a href="{% url 'api_docs' %}">API</a>

        {% if billing_enabled %}
        <br><a href="{% url 'billing_index' %}">Billing</a>
        <br><a href="{% url 'billing_overview' %}">Billing</a>
        {% endif %}

        <br><a href="{% url 'blog_import' %}">Import posts</a>

M main/urls.py => main/urls.py +12 -7
@@ 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",
    ),

M main/views/billing.py => main/views/billing.py +475 -187
@@ 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