Skip to content

Commit

Permalink
Fix #1370 graceful handling of webhook endpoint downtime
Browse files Browse the repository at this point in the history
Fix #1369 As a shop owner I can see if a subscriber attempted checkout or not
  • Loading branch information
chrisjsimpson committed Jul 5, 2024
1 parent b533aa7 commit 44e4f10
Show file tree
Hide file tree
Showing 6 changed files with 149 additions and 40 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""add stripe_user_attempted_checkout_flow to Subscription
Revision ID: 8c008c1333d5
Revises: 3447b58b5c69
Create Date: 2024-07-05 20:39:42.709426
"""

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = "8c008c1333d5"
down_revision = "3447b58b5c69"
branch_labels = None
depends_on = None


def upgrade():
with op.batch_alter_table("subscription", schema=None) as batch_op:
batch_op.add_column(
sa.Column(
"stripe_user_attempted_checkout_flow", sa.Boolean(), default=False
)
)


def downgrade():
pass
3 changes: 3 additions & 0 deletions subscribie/blueprints/admin/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,5 +112,8 @@ def get_number_of_recent_subscription_cancellations():
except stripe._error.AuthenticationError as e:
log.error(f"stripe._error.AuthenticationError {e} ")
return "unknown"
except stripe._error.APIConnectionError as e:
log.error(f"stripe._error.APIConnectionError {e} ")
return "unknown"

return len(subscription_cancellations)
27 changes: 26 additions & 1 deletion subscribie/blueprints/admin/templates/admin/subscribers.html
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ <h4>Search...</h4>
{% endif %}</li>
<li class=mt-2><strong>History: </strong>
<a href="{{ url_for('admin.transactions',
subscriber=subscription.person.uuid) }}">View Transactions
subscriber=subscription.person.uuid) }}">View Transactions ({{ subscription.transactions|length }})
</a>
</li>
<li class=mt-2><strong>Documents: </strong>
Expand All @@ -230,6 +230,31 @@ <h4>Search...</h4>
</ul>
{% endif %}
</li>
{% if subscription.stripe_user_attempted_checkout_flow is sameas True and subscription.transactions|length == 0
or
subscription.stripe_user_attempted_checkout_flow is sameas False and subscription.transactions|length == 0
%}

<li class="mt-2"><strong>Insights:</strong>
<ul>
{% if subscription.stripe_user_attempted_checkout_flow is sameas True and subscription.transactions|length == 0 %}
<li>🔎 During sign-up, the <a href="{{ url_for('admin.show_subscriber', subscriber_id=person.id) }}">Subscriber</a>
clicked to visit the payment page but no transactions have been registered yet.
<a href="{{ url_for("views.view_plan", plan_title=subscription.plan.title, uuid=subscription.plan.uuid ) }}">Share Plan URL</a>
or contact this subscriber.
</li>
{% endif %}
{% if subscription.stripe_user_attempted_checkout_flow is sameas False and subscription.transactions|length == 0 %}
<li>🔭 During sign-up the <a href="{{ url_for('admin.show_subscriber', subscriber_id=person.id) }}">Subscriber</a>
left before getting to the payment page.
<a href="{{ url_for("views.view_plan", plan_title=subscription.plan.title, uuid=subscription.plan.uuid ) }}">Share Plan URL</a>
or contact this subscriber.
</li>

{% endif %}
</ul>
</li>
{% endif %}
</ul>
</div>
</li>
Expand Down
118 changes: 83 additions & 35 deletions subscribie/blueprints/checkout/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,35 @@ def store_customer():
def order_summary():
plan = None
payment_provider = PaymentProvider.query.first()
chosen_option_ids = session.get("chosen_option_ids", None)

# We create a subscription object regardless of payment,
# note this occurs *before* directing the user to checkouts
# (such as Stripe) since payment is separate from the
# *intent* to create a subscription (that is, a subscription may
# be created, with a failure to pay in a business context, and
# such subscription would be in arrears (so long as the subscription
# plan is not free).
# Marking a subscription as having a checkout flow attached to it
# is a therefore a separate asynchronous event which may or may not happen
# right after a subscription creation, at which point, the created subscription
# must be updated with checkout session attributes such as
# `stripe_subscription_id=stripe_subscription_id` and `stripe_external_id`
# The existance of a subscription object may be located by its
# `subscribie_checkout_session_id` (an identifier created by subscribie, not
# an external payment provider such as Stripe)
subscribie_checkout_session_id = session.get("subscribie_checkout_session_id")
log.info(
f"Calling create_subscription with subscribie_checkout_session_id: {subscribie_checkout_session_id}" # noqa: E501
)
create_subscription(
email=session["email"],
package=session["package"],
currency=get_geo_currency_code(),
chosen_option_ids=chosen_option_ids,
chosen_question_ids_answers=session.get("chosen_question_ids_answers"),
subscribie_checkout_session_id=subscribie_checkout_session_id, # noqa: E501
)

# Check if checkout flow is a donation
is_donation = session["is_donation"]
Expand All @@ -197,16 +226,6 @@ def order_summary():
# if plan is free, skip Stripe checkout and store subscription right away
if plan.is_free():
log.info("Plan is free, so skipping Stripe checkout")
chosen_option_ids = session.get("chosen_option_ids", None)
create_subscription(
email=session["email"],
package=session["package"],
chosen_option_ids=chosen_option_ids,
chosen_question_ids_answers=session.get("chosen_question_ids_answers"),
subscribie_checkout_session_id=session.get(
"subscribie_checkout_session_id"
), # noqa: E501
)

return redirect(url_for("checkout.thankyou"))

Expand Down Expand Up @@ -363,6 +382,33 @@ def stripe_create_checkout_session():
plan = None
charge = {}
metadata = {}
# Store the fact the person at least attempted to go to stripe
# checkout (https://github.com/Subscribie/subscribie/issues/1370)
# so that can distingish between drop-offs before or after attempting
# payment via checkout flow.

# Store stripe_checkout_attampted True or similar against
# subscription object using subscribie_checkout_session_id
# to determine subscription object
email = session["email"]
subscribie_checkout_session_id = session.get("subscribie_checkout_session_id")
subscription = (
Subscription.query.filter_by(
subscribie_checkout_session_id=subscribie_checkout_session_id
)
.filter(Subscription.person.has(email=email))
.first()
)
if subscription is not None:
subscription.stripe_user_attempted_checkout_flow = True
database.session.commit()
log.info(
"Updated subscription object with stripe_user_attempted_checkout_flow: True"
)
else:
msg = "Could not locate subscription during stripe_create_checkout_session"
log.error(msg)

currency_code = get_geo_currency_code()
# If VAT tax is enabled, get stripe tax id
settings = Setting.query.first()
Expand Down Expand Up @@ -996,6 +1042,7 @@ def stripe_webhook():
log.info("Processing checkout.session.completed event")
session = event["data"]["object"]
currency = session["currency"].upper()
email = session["customer_email"]
try:
is_donation = session["metadata"]["is_donation"]
except KeyError as e:
Expand Down Expand Up @@ -1029,22 +1076,6 @@ def stripe_webhook():
except KeyError:
chosen_option_ids = None

chosen_question_ids_answers = None
try:
chosen_question_ids_answers = json.loads(
session["metadata"]["chosen_question_ids_answers"]
) # noqa
except KeyError:
msg = "KeyError on subscription metadata chosen_question_ids_answers (maybe none for this plan)" # noqa
log.warning(msg)
except json.decoder.JSONDecodeError:
log.warning("Unable to decode chosen_question_ids_answers")

try:
package = session["metadata"]["package"]
except KeyError:
package = None

"""
We treat Stripe checkout session.mode equally because
a subscribie plan may either be a one-off plan or a
Expand All @@ -1055,16 +1086,33 @@ def stripe_webhook():
"""
if is_donation != "True":
if session["mode"] == "subscription" or session["mode"] == "payment":
create_subscription(
currency=currency,
email=session["customer_email"],
package=package,
chosen_option_ids=chosen_option_ids,
chosen_question_ids_answers=chosen_question_ids_answers,
subscribie_checkout_session_id=subscribie_checkout_session_id,
stripe_subscription_id=stripe_subscription_id,
stripe_external_id=session["id"],
# Rather than call create_subscription here (as we used to do)
# https://github.com/Subscribie/subscribie/issues/1370
# Instead, locate the existing subscription object (which are created
# irrespective of payments) by it's subscribie_checkout_session_id
# which will be stored in the payment providers webhook metadata
# and update the subscription object with the currency of the payment,
# and relevant Payment
# provider checkout attributes (such as `stripe_subscription_id`
# and `stripe_external_id`) for Stripe.
subscription = (
Subscription.query.filter_by(
subscribie_checkout_session_id=subscribie_checkout_session_id
)
.filter(Subscription.person.has(email=email))
.first()
)
if subscription is not None:
subscription.stripe_subscription_id = stripe_subscription_id
subscription.stripe_external_id = session["id"]
subscription.currency = currency
database.session.commit()
msg = f"Updated subscription object with stripe_subscription_id: {stripe_subscription_id} and stripe_external_id {session['id']}" # noqa: E501
log.info(msg)
else:
msg = "Webhook event failure. Could not locate subscription object for checkout.session.completed event." # noqa: E501
log.error(msg)
return msg, 500
return "OK", 200

if (
Expand Down
10 changes: 6 additions & 4 deletions subscribie/blueprints/checkout/templates/order_summary.html
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,6 @@ <h4 class="my-3 body-lg">{{ plan.title|capitalize() }}</h4>


<script type="text/javascript">
// Create an instance of the Stripe
var stripe = Stripe('{{ stripe_pub_key }}', {
stripeAccount: "{{ stripe_connected_account_id }}"
});
var checkoutButton = document.getElementById('checkout-button');

checkoutButton.addEventListener('click', function(e) {
Expand All @@ -120,6 +116,12 @@ <h4 class="my-3 body-lg">{{ plan.title|capitalize() }}</h4>
return response.json();
})
.then(function(session) {
// Create an instance of the Stripe
// intentionally late to allow stripe_create_checkout_session_url
// metrics if / when stripe object creation errors/network
var stripe = Stripe('{{ stripe_pub_key }}', {
stripeAccount: "{{ stripe_connected_account_id }}"
});
return stripe.redirectToCheckout({ sessionId: session.id });
})
.then(function(result) {
Expand Down
1 change: 1 addition & 0 deletions subscribie/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,7 @@ class Subscription(database.Model):
) # noqa: E501
question_answers = relationship("Answer", back_populates="subscription")
currency = database.Column(database.String(), default="USD")
stripe_user_attempted_checkout_flow = database.Column(database.Boolean(), default=0)
subscribie_checkout_session_id = database.Column(database.String())
stripe_subscription_id = database.Column(database.String())
stripe_external_id = database.Column(database.String())
Expand Down

0 comments on commit 44e4f10

Please sign in to comment.