Skip to content

Commit

Permalink
Fix subscriptions created with payment failed
Browse files Browse the repository at this point in the history
  • Loading branch information
rebkwok committed Oct 31, 2024
1 parent ea2a0cf commit 290107b
Show file tree
Hide file tree
Showing 14 changed files with 151 additions and 30 deletions.
6 changes: 3 additions & 3 deletions booking/tests/test_membership_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -620,7 +620,7 @@ def test_membership_change_post_invalid_form(client, seller, configured_stripe_u
},
"setup_secret", None, "setup_secret", "setup", None, False,
),
# matching subscriptions, invoice
# matching subscriptions, invoice unpaid
(
{
"s1": MockEventObject(
Expand All @@ -629,7 +629,7 @@ def test_membership_change_post_invalid_form(client, seller, configured_stripe_u
customer="cus-1",
status="active",
pending_setup_intent=None,
latest_invoice=Mock(payment_intent="pi-1", paid=False, client_secret="foo"),
latest_invoice=Mock(payment_intent=Mock(id="pi-1",client_secret="foo"), paid=False),
items=Mock(data=[Mock(price=Mock(id="price-1"), quantity=1)]),
billing_cycle_anchor=datetime(2024, 2, 25, tzinfo=datetime_tz.utc).timestamp(),
payment_settings=Mock(save_default_payment_method="on_subscription"),
Expand All @@ -647,7 +647,7 @@ def test_membership_change_post_invalid_form(client, seller, configured_stripe_u
customer="cus-1",
status="active",
pending_setup_intent=None,
latest_invoice=Mock(payment_intent="pi-1", paid=True, client_secret="foo"),
latest_invoice=Mock(payment_intent=Mock(id="pi-1",client_secret="foo"), paid=True),
items=Mock(data=[Mock(price=Mock(id="price-1"), quantity=1)]),
billing_cycle_anchor=datetime(2024, 2, 25, tzinfo=datetime_tz.utc).timestamp(),
payment_settings=Mock(save_default_payment_method="on_subscription"),
Expand Down
11 changes: 7 additions & 4 deletions booking/views/membership_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,8 +215,7 @@ def _compare_subscription_kwargs(subsc):
client_secret = subscription.pending_setup_intent.client_secret
else:
confirm_type = "payment"
payment_intent_id = subscription.latest_invoice.payment_intent
payment_intent = client.get_payment_intent(payment_intent_id)
payment_intent = subscription.latest_invoice.payment_intent
client_secret = payment_intent.client_secret
else:
sub_kwargs_with_discounts = client.get_subscription_kwargs(customer_id, price_id, backdate=backdate, discounts=discounts)
Expand Down Expand Up @@ -407,7 +406,7 @@ def stripe_subscription_checkout(request):
"membership": user_membership.membership,
"customer_id": request.user.userprofile.stripe_customer_id,
"client_secret": client_secret,
"confirm_type": confirm_type
"confirm_type": confirm_type,
})
else:
membership = get_object_or_404(Membership, id=request.POST.get("membership"))
Expand Down Expand Up @@ -436,7 +435,6 @@ def stripe_subscription_checkout(request):
"regular_amount": regular_amount,
"next_amount": regular_amount,
})

return TemplateResponse(request, "stripe_payments/subscribe.html", context)


Expand Down Expand Up @@ -468,6 +466,11 @@ def ensure_subscription_up_to_date(user_membership, subscription, subscription_i
):
status = "setup_pending"

if status == "active" and user_membership.subscription_status == "incomplete":
payment_intent_status = subscription.latest_invoice.payment_intent.status
if payment_intent_status != "succeeded":
status = "incomplete"

subscription_data = {
"subscription_start_date": get_utcdate_from_timestamp(subscription.start_date),
"subscription_end_date": sub_end,
Expand Down
4 changes: 3 additions & 1 deletion stripe_payments/emails.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,9 @@ def send_subscription_renewal_upcoming_email(event_object):
_send_subscription_email(event_object, "subscription_renewal_upcoming", "Your membership will renew soon")


def send_subscription_setup_failed_email(event_object):
_send_subscription_email(event_object, "subscription_setup_failed", "Your membership setup has failed")

def send_subscription_created_email(user_membership):
_send_subscription_email(None, "subscription_created", "Your membership has been set up", user_membership=user_membership)
if settings.NOTIFY_STUDIO_FOR_NEW_MEMBERSHIPS:
Expand All @@ -157,4 +160,3 @@ def send_subscription_created_email(user_membership):
user_membership=user_membership,
to_email=settings.DEFAULT_STUDIO_EMAIL
)

3 changes: 2 additions & 1 deletion stripe_payments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,5 +233,6 @@ def subscription(self):
def voucher_code(self):
from booking.models import StripeSubscriptionVoucher
if self.promo_code_id:
return StripeSubscriptionVoucher.objects.filter(promo_code_id=self.promo_code_id).first().code
voucher = StripeSubscriptionVoucher.objects.filter(promo_code_id=self.promo_code_id).first()
return voucher.code if voucher else ""
return ""
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{% extends 'email_base.html' %}

{% block messagecontent %}
<h2>Your membership setup failed</h2>
<p><strong>Membership:</strong> {{ user_membership.membership.name }}</p>
<p>We have been unable to set up your membership because your payment has failed.</p>
{% endblock %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{% include "account/email/do_not_reply.txt" %}


Your membership setup failed
----------------------------
Membership: {{ user_membership.membership.name }}

We have been unable to set up your membership because your payment has failed.

Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
<form id="payment-form">
<h3 class="mt-1">Billing Details</h3>

{% if creating %}
<div>
<p>

{% if amount %}
<strong>£{{ next_amount|floatformat:2 }}</strong> will be charged immediately.
{% else %}
Expand All @@ -13,6 +15,7 @@ <h3 class="mt-1">Billing Details</h3>
Regular membership price (charged monthly on 25th): <strong>£{{ regular_amount|floatformat:2 }}</strong>
</p>
</div>
{% endif %}

<div id="payment-element">
<!--Stripe.js injects the Payment Element-->
Expand Down
37 changes: 34 additions & 3 deletions stripe_payments/tests/test_stripe_webhook_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,8 @@ def test_webhook_subscription_deleted(
mock_webhook.construct_event.return_value = get_mock_webhook_event(
webhook_event_type="customer.subscription.deleted",
canceled_at=datetime(2024, 3, 1).timestamp(),
status="canceled"
status="canceled",
cancellation_details=Mock(reason="")
)
resp = client.post(webhook_url, data={}, HTTP_STRIPE_SIGNATURE="foo")
assert resp.status_code == 200, resp.content
Expand All @@ -409,6 +410,29 @@ def test_webhook_subscription_deleted(
assert len(mail.outbox) == 0


@patch("booking.models.membership_models.StripeConnector", MockConnector)
@patch("stripe_payments.views.webhook.stripe.Webhook")
def test_webhook_subscription_deleted_due_to_failed_payment(
mock_webhook, get_mock_webhook_event, client, configured_stripe_user
):
membership = baker.make(Membership, name="membership1")
user_membership = baker.make(
UserMembership, membership=membership, user=configured_stripe_user, subscription_id="id"
)
mock_webhook.construct_event.return_value = get_mock_webhook_event(
webhook_event_type="customer.subscription.deleted",
canceled_at=datetime(2024, 3, 1).timestamp(),
status="canceled",
cancellation_details=Mock(reason="payment_failed")
)
resp = client.post(webhook_url, data={}, HTTP_STRIPE_SIGNATURE="foo")
assert resp.status_code == 200, resp.content

assert not UserMembership.objects.exists()
assert len(mail.outbox) == 1
assert mail.outbox[0].to == [configured_stripe_user.email]


@patch("booking.models.membership_models.StripeConnector", MockConnector)
@patch("stripe_payments.views.webhook.stripe.Webhook")
def test_webhook_subscription_deleted_no_user_membership(
Expand Down Expand Up @@ -521,12 +545,18 @@ def test_webhook_subscription_updated_status_changed_to_active_from_cancelled(
)
@pytest.mark.freeze_time("2024-02-26")
@patch("booking.models.membership_models.StripeConnector", MockConnector)
@patch("stripe_payments.views.webhook.StripeConnector")
@patch("stripe_payments.views.webhook.stripe.Webhook")
def test_webhook_subscription_updated_status_changed_to_active_from_incomplete(
mock_webhook, get_mock_webhook_event, client, configured_stripe_user, settings, studio_email
mock_webhook, mock_webhook_connector, get_mock_webhook_event, client, configured_stripe_user, settings, studio_email
):
connector = MockConnector(subscriptions={"id": "1"})
connector.invoice=Mock(payment_intent=Mock(status="succeeded"))
mock_webhook_connector.return_value = connector

mock_webhook.construct_event.return_value = get_mock_webhook_event(
webhook_event_type="customer.subscription.updated"
webhook_event_type="customer.subscription.updated",
latest_invoice=Mock(payment_intent=Mock(status="succeeded"))
)
settings.NOTIFY_STUDIO_FOR_NEW_MEMBERSHIPS = studio_email

Expand Down Expand Up @@ -554,6 +584,7 @@ def test_webhook_subscription_updated_status_changed_to_active_from_incomplete(
status="active",
canceled_at=None,
cancel_at=None,
latest_invoice=Mock(payment_intent=Mock(status="succeeded"))
)
resp = client.post(webhook_url, data={}, HTTP_STRIPE_SIGNATURE="foo")
assert resp.status_code == 200, resp.content
Expand Down
56 changes: 43 additions & 13 deletions stripe_payments/views/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
send_payment_expiring_email,
send_subscription_past_due_email,
send_subscription_renewal_upcoming_email,
send_subscription_created_email
send_subscription_created_email,
send_subscription_setup_failed_email,
)
from ..exceptions import StripeProcessingError
from ..models import Seller, StripeSubscriptionInvoice
Expand Down Expand Up @@ -171,16 +172,31 @@ def stripe_webhook(request):
# It might not exist if we're cancelling an unpaid setup pending subscription
...
else:
# the end date for the membership is the stripe subscription end; cancelled at will be the 25th
# the actual membership ends at the end of the month
user_membership.subscription_status = event_object.status
user_membership.subscription_end_date = get_utcdate_from_timestamp(event_object.canceled_at)
user_membership.end_date = UserMembership.calculate_membership_end_date(user_membership.subscription_end_date)
user_membership.save()
user_membership.reallocate_bookings()
ActivityLog.objects.create(
log=f"Stripe webhook: Membership {user_membership.membership.name} for {user_membership.user} (stripe subscription id {event_object.id}, cancelled on {user_membership.subscription_end_date})"
)
# Check cancellation reason; if it's payment_failed, delete instead of cancelling
# check no bookings exist on it, as a sanity check; if they do, just cancel
if event_object.cancellation_details.reason == "payment_failed" and not user_membership.bookings.exists():
ActivityLog.objects.create(
log=(
f"Stripe webhook: Membership {user_membership.membership.name} for user {user_membership.user} cancelled "
f"due to failed payment and deleted (stripe id {event_object.id})"
)
)
# Send emails, because user will have seen message saying it's processing
# send it before we delete, so we have access to the user membership to find the email to send
send_subscription_setup_failed_email(event_object)
user_membership.delete()

else:
# the end date for the membership is the stripe subscription end; cancelled at will be the 25th
# the actual membership ends at the end of the month
user_membership.subscription_status = event_object.status
user_membership.subscription_end_date = get_utcdate_from_timestamp(event_object.canceled_at)
user_membership.end_date = UserMembership.calculate_membership_end_date(user_membership.subscription_end_date)
user_membership.save()
user_membership.reallocate_bookings()
ActivityLog.objects.create(
log=f"Stripe webhook: Membership {user_membership.membership.name} for {user_membership.user} (stripe subscription id {event_object.id}, cancelled on {user_membership.subscription_end_date})"
)

elif event.type == "customer.subscription.updated":
user_membership = UserMembership.objects.get(subscription_id=event_object.id)
Expand All @@ -207,8 +223,22 @@ def stripe_webhook(request):

elif event_object.status == "active":
if old_status == "incomplete":
# A new subscription was just activated
send_subscription_created_email(user_membership)
subscription_activated = True
if event_object.latest_invoice:
subscription = client.get_subscription(subscription_id=event_object.id)
payment_intent_status = subscription.latest_invoice.payment_intent.status
if payment_intent_status != "succeeded":
subscription_activated = False
user_membership.subscription_status = "incomplete"
user_membership.save()
ActivityLog.objects.create(
log=(
f"Stripe webhook: Membership {user_membership.membership.name} for user {user_membership.user} changed back to incomplete due to failed payment"
)
)
if subscription_activated:
# A new subscription was just activated
send_subscription_created_email(user_membership)
# has it been cancelled in future?
if event_object.cancel_at:
subscription_end_date = datetime.fromtimestamp(event_object.cancel_at).replace(tzinfo=datetime_tz.utc)
Expand Down
3 changes: 1 addition & 2 deletions templates/403_csrf.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@
{% block content %}

<div>
<p>Sorry, we encountered a minor blip. Please click <a href={{ back_url }}>here</a> to go back and try again.</h2>

<p>Sorry, we encountered a minor blip. Please click <a href={{ back_url }}>here</a> to go back and try again.</p>
</div>

{% endblock content %}
12 changes: 12 additions & 0 deletions templates/404.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% load static %}

{% block content %}

<div>
<h1>Page not found</h1>

<p>Sorry, this page could not be found.</p>
</div>

{% endblock content %}
12 changes: 12 additions & 0 deletions templates/429.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% load static %}

{% block content %}

<div>
<h1>Too many requests</h1>

<p>Sorry, there seems to be an error. Tech support has been notified. Please try again later.</p>
</div>

{% endblock content %}
12 changes: 12 additions & 0 deletions templates/500.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% load static %}

{% block content %}

<div>
<h1>Internal server error</h1>

<p>Sorry, there seems to be an error. Tech support has been notified. Please try again later.</p>
</div>

{% endblock content %}
6 changes: 3 additions & 3 deletions templates/studioadmin/subscription_invoices.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,23 @@ <h1>Membership Transactions</h1>

<table class="table table-responsive table-striped">
<thead>
<th>Invoice Date</th>
<th>Invoice</th>
<th>Status</th>
<th>User</th>
<th>Amount</th>
<th>Voucher</th>
<th>Date Paid</th>
<th>Membership</th>
</thead>
<tbody>
{% for invoice in invoices %}
<tr>
<tr {% if invoice.status == "paid" %}class="text-success"{% endif %}>
<td>{{ invoice.invoice_date|date:"d-M-y H:i" }}</td>
<td>{{ invoice.invoice_id }}</td>
<td class="text-center">{{ invoice.status }}</td>
<td class="text-center">{{ invoice.subscription.user.username }}</td>
<td class="text-center">£{{ invoice.total }}</td>
<td class="text-center">{{ invoice.voucher_code }}</td>
<td>{{ invoice.invoice_date|date:"d-M-y H:i" }}</td>
<td>{{ invoice.subscription.membership }}</td>
</tr>
{% endfor %}
Expand Down

0 comments on commit 290107b

Please sign in to comment.