Skip to content

Commit

Permalink
Fix #1307 faster subscription status refreshes 30 days default
Browse files Browse the repository at this point in the history
#1307 Detect and correct unmapped Stripe Subscriptions to new Subscription subscription objects
  • Loading branch information
chrisjsimpson committed Apr 24, 2024
1 parent 4f86fdc commit ffd5ad3
Show file tree
Hide file tree
Showing 4 changed files with 169 additions and 105 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,4 @@ tests/browser-automated-tests-playwright
*.pkl
emails
*.bk
email-queue
49 changes: 47 additions & 2 deletions subscribie/blueprints/admin/subscription.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
stripe_connect_active,
)
from subscribie.tasks import background_task
from subscribie.blueprints.checkout import create_subscription
import stripe
import logging

Expand All @@ -16,7 +17,25 @@
@background_task
def update_stripe_subscription_statuses(app):
"""Update Stripe subscriptions with their current status
by querying Stripe api
by querying Stripe api.
A subscription status can include: incomplete, incomplete_expired,
trialing, active, past_due, canceled, unpaid, or paused.
If a subscription object exists in Stripe, but does
not exist in Subscribie*, it's metadata is pulled from
Stripe and Subscribie's database is brought up to date.
*In the majority case, a Subscribie Subscription object
is created upon Stripe Subscription creation right after
a Stripe `checkout.session.completed` event is processed by
the /stripe_webhook endpoint, however, if webhook fails all retrys
this task also recovers from those webhook delivery failures (and
can be triggered manually via 'Refresh Subscriptions' on the
admin subscribers page.
See also:
- https://docs.stripe.com/api/subscriptions/object#subscription_object-status
:param: app (required) note app is automatically injected by @background_task decorator # noqa: E501
"""
Expand All @@ -31,13 +50,16 @@ def update_stripe_subscription_statuses(app):
log.warning(
"Stripe connect account not set. Refusing to update subscription statuses" # noqa: E501
)
count = 0
if stripe_connect_active():
try:
# See https://stripe.com/docs/api/subscriptions/list#list_subscriptions-status # noqa: E501
stripeSubscriptions = stripe.Subscription.list(
stripe_account=connect_account.id, status="all", limit=100
)
for stripeSubscription in stripeSubscriptions.auto_paging_iter():
count += 1
print(f"Subscription refresh tally: {count}")
log.debug(
f"processing subscription status for Stripe subscription: {stripeSubscription.id}" # noqa: E501
)
Expand All @@ -63,7 +85,30 @@ def update_stripe_subscription_statuses(app):
database.session.commit()
else:
log.warning(
"subscription is in stripe but not in the subscribie database" # noqa: E501
f"subscription {stripeSubscription.id} is in stripe but not in the subscribie database" # noqa: E501
)
log.warning(
"Trying to recover missed subscription creation from Stripe"
)
email = stripe.Customer.retrieve(
stripeSubscription.customer,
stripe_account=connect_account.id,
).email
currency = stripeSubscription.currency.upper()
metadata = stripeSubscription.metadata
package = metadata.package
chosen_option_ids = metadata.chosen_option_ids
subscribie_checkout_session_id = (
metadata.subscribie_checkout_session_id
)
stripe_subscription_id = stripeSubscription.id
create_subscription(
currency=currency,
email=email,
package=package,
chosen_option_ids=chosen_option_ids,
subscribie_checkout_session_id=subscribie_checkout_session_id, # noqa: E501
stripe_subscription_id=stripe_subscription_id,
)
except Exception as e:
log.warning(f"Could not update stripe subscription status: {e}")
Expand Down
212 changes: 111 additions & 101 deletions subscribie/blueprints/checkout/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
import json
from uuid import uuid4
import sqlalchemy
from sqlalchemy import desc

log = logging.getLogger(__name__)
checkout = Blueprint("checkout", __name__, template_folder="templates")
Expand Down Expand Up @@ -573,114 +574,123 @@ def create_subscription(
the storing of chosen options againt their plan choice.
Chosen option ids may be passed via webhook or through session
"""
log.info("Creating Subscription model if needed")
subscription = None # Initalize subscription model to None
# Get the associated plan they have purchased
plan = database.session.query(Plan).filter_by(uuid=package).one()
if currency is not None:
sell_price, interval_amount = plan.getPrice(currency)
else:
log.warning(
"currency was set to None, so setting Subscription sell_price and interval_amount to zero" # noqa: E501
)
sell_price = 0
interval_amount = 0

# Store Subscription against Person locally
if email is None:
email = session["email"]

if package is None:
package = session["package"]

person = database.session.query(Person).filter_by(email=email).one()

# subscribie_checkout_session_id can be passed by stripe metadata (webhook) or
# via session (e.g. when session only with no up-front payment)
if subscribie_checkout_session_id is None:
subscribie_checkout_session_id = session.get(
"subscribie_checkout_session_id", None
with current_app.test_request_context("/"): # TODO remove need for request context
log.info("Creating Subscription model if needed")
subscription = None # Initalize subscription model to None
# Get the associated plan they have purchased
plan = (
Plan.query.execution_options(include_archived=True)
.order_by(desc("created_at"))
.where(Plan.uuid == package)
.one()
)
log.info(f"subscribie_checkout_session_id is: {subscribie_checkout_session_id}")

# Verify Subscription not already created (e.g. stripe payment webhook)
# another hook or mandate only payment may have already created the Subscription
# model, if so, fetch it via its subscribie_checkout_session_id
if subscribie_checkout_session_id is not None:
subscription = (
Subscription.query.filter_by(
subscribie_checkout_session_id=subscribie_checkout_session_id
if currency is not None:
sell_price, interval_amount = plan.getPrice(currency)
else:
log.warning(
"currency was set to None, so setting Subscription sell_price and interval_amount to zero" # noqa: E501
)
sell_price = 0
interval_amount = 0

# Store Subscription against Person locally
# TODO enforce unique email address per Person object
person = database.session.query(Person).filter_by(email=email).all()[0]

# subscribie_checkout_session_id can be passed by stripe metadata (webhook) or
# via session (e.g. when session only with no up-front payment)
if subscribie_checkout_session_id is None:
subscribie_checkout_session_id = session.get(
"subscribie_checkout_session_id", None
)
log.info(f"subscribie_checkout_session_id is: {subscribie_checkout_session_id}")

# Verify Subscription not already created (e.g. stripe payment webhook)
# another hook or mandate only payment may have already created the Subscription
# model, if so, fetch it via its subscribie_checkout_session_id
if subscribie_checkout_session_id is not None:
subscription = (
Subscription.query.filter_by(
stripe_subscription_id=stripe_subscription_id
)
.filter(Subscription.person.has(email=email))
.first()
)
.filter(Subscription.person.has(email=email))
.first()
)

if subscription is None:
log.info("No existing subscription model found, creating Subscription model")
# Create new subscription model
# - Get current pricing
# - TODO address race condition:
# - add validation for potential discrepency between Stripe
# webhook delivery delay and price rules changing
subscription = Subscription(
sku_uuid=package,
person=person,
subscribie_checkout_session_id=subscribie_checkout_session_id,
stripe_external_id=stripe_external_id,
stripe_subscription_id=stripe_subscription_id,
interval_unit=plan.interval_unit,
interval_amount=interval_amount,
sell_price=sell_price,
currency=currency,
)
# Add chosen options (if any)
if chosen_option_ids is None:
chosen_option_ids = session.get("chosen_option_ids", None)

if chosen_option_ids:
log.info(f"Applying chosen_option_ids to subscription: {chosen_option_ids}")
chosen_options = []
for option_id in chosen_option_ids:
log.info(f"Locating option id: {option_id}")
option = Option.query.get(option_id)
# Store as ChosenOption because options may change after the order
# has processed. This preserves integrity of the actual chosen options
chosen_option = ChosenOption()
if option is not None:
chosen_option.option_title = option.title
chosen_option.choice_group_title = option.choice_group.title
chosen_option.choice_group_id = (
option.choice_group.id
) # Used for grouping latest choice
chosen_options.append(chosen_option)
else:
log.error(f"Failed to get Open from session option_id: {option_id}")
subscription.chosen_options = chosen_options
else:
log.info("No chosen_option_ids were found or applied.")
if subscription is None:
log.info(
"No existing subscription model found, creating Subscription model"
)
# Create new subscription model
# - Get current pricing
# - TODO address race condition:
# - add validation for potential discrepency between Stripe
# webhook delivery delay and price rules changing
subscription = Subscription(
sku_uuid=package,
person=person,
subscribie_checkout_session_id=subscribie_checkout_session_id,
stripe_external_id=stripe_external_id,
stripe_subscription_id=stripe_subscription_id,
interval_unit=plan.interval_unit,
interval_amount=interval_amount,
sell_price=sell_price,
currency=currency,
)
# Add chosen options (if any)
if chosen_option_ids is None:
chosen_option_ids = session.get("chosen_option_ids", None)

database.session.add(subscription)
database.session.commit()
session["subscription_uuid"] = subscription.uuid

# If subscription plan has cancel_at set, modify Stripe subscription
# charge_at property
stripe.api_key = get_stripe_secret_key()
connect_account_id = get_stripe_connect_account_id()
if subscription.plan.cancel_at:
cancel_at = subscription.plan.cancel_at
try:
stripe.Subscription.modify(
sid=subscription.stripe_subscription_id,
stripe_account=connect_account_id,
cancel_at=cancel_at,
if chosen_option_ids and chosen_option_ids != "null":
log.info(
f"Applying chosen_option_ids to subscription: {chosen_option_ids}"
)
subscription.stripe_cancel_at = cancel_at
database.session.commit()
except Exception as e: # noqa
log.error("Could not set cancel_at: {e}")
chosen_options = []
for option_id in chosen_option_ids:
log.info(f"Locating option id: {option_id}")
option = Option.query.get(option_id)
# Store as ChosenOption because options may change after the order
# has processed. This preserves integrity of the actual
# chosen options
chosen_option = ChosenOption()
if option is not None:
chosen_option.option_title = option.title
chosen_option.choice_group_title = option.choice_group.title
chosen_option.choice_group_id = (
option.choice_group.id
) # Used for grouping latest choice
chosen_options.append(chosen_option)
else:
log.error(
f"Failed to get Open from session option_id: {option_id}"
)
subscription.chosen_options = chosen_options
else:
log.info("No chosen_option_ids were found or applied.")

newSubscriberEmailNotification()
database.session.add(subscription)
database.session.commit()
session["subscription_uuid"] = subscription.uuid

# If subscription plan has cancel_at set, modify Stripe subscription
# charge_at property
stripe.api_key = get_stripe_secret_key()
connect_account_id = get_stripe_connect_account_id()
if subscription.plan.cancel_at:
cancel_at = subscription.plan.cancel_at
try:
stripe.Subscription.modify(
sid=subscription.stripe_subscription_id,
stripe_account=connect_account_id,
cancel_at=cancel_at,
)
subscription.stripe_cancel_at = cancel_at
database.session.commit()
except Exception as e: # noqa
log.error("Could not set cancel_at: {e}")

newSubscriberEmailNotification()
return subscription


Expand Down
12 changes: 10 additions & 2 deletions subscribie/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from currency_symbols import CurrencySymbols
import logging
from subscribie.tasks import background_task
from datetime import datetime, timedelta

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -282,7 +283,7 @@ def get_stripe_livemode():


@background_task
def get_stripe_invoices(app):
def get_stripe_invoices(app, last_n_days=30):
"""Upsert Stripe invoices into stripe_invoices
Fetches all Stripe Invoices for a given connect customer,
Expand All @@ -302,13 +303,20 @@ def get_stripe_invoices(app):
log.debug("get_stripe_invoices called")
from subscribie.models import StripeInvoice, Subscription

# Calculate the date last_n_days before today
today = datetime.now()
days_before_today = today - timedelta(days=last_n_days)
days_before_today_timestamp = int(days_before_today.timestamp())

# Remember: "Subscription" is a Subscribie model, not a Stripe one
# because Subscribie does not assume all Subscriptions are from Stripe
with app.app_context():
stripe.api_key = get_stripe_secret_key()
stripe_connect_account_id = get_stripe_connect_account_id()
invoices = stripe.Invoice.list(
stripe_account=stripe_connect_account_id, limit=100
stripe_account=stripe_connect_account_id,
limit=100,
created={"gte": days_before_today_timestamp},
)
for latest_stripe_invoice in invoices.auto_paging_iter():
# Upsert each Stripe Invoice into stripe_invoice.
Expand Down

0 comments on commit ffd5ad3

Please sign in to comment.