From ac3d84b2d7ddef5bf59785c5164a09cfeb584886 Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Sun, 19 May 2024 18:45:54 +0100 Subject: [PATCH] Methods for creating, updating and cancelling subscriptions --- .gitignore | 1 + booking/models/membership_models.py | 18 +- pipsevents/settings.py | 2 +- requirements.txt | 2 +- stripe_payments/utils.py | 336 ++++++++++++++++++++++------ 5 files changed, 281 insertions(+), 78 deletions(-) diff --git a/.gitignore b/.gitignore index 23652306..17f457d6 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ accounts/test_file.txt provision/.vaultpass pipsevents.log .venv*/ +prices.json diff --git a/booking/models/membership_models.py b/booking/models/membership_models.py index 9aa3d935..646791b6 100644 --- a/booking/models/membership_models.py +++ b/booking/models/membership_models.py @@ -1,12 +1,12 @@ import logging -import uuid +import re from django.db import models from django.utils.text import slugify from booking.models import EventType -from stripe_payments.utils import create_stripe_product, update_stripe_product, get_or_create_stripe_price +from stripe_payments.utils import StripeConnector logger = logging.getLogger(__name__) @@ -33,15 +33,17 @@ def generate_stripe_product_id(self): slug = slugify(self.name) counter = 1 while Membership.objects.filter(stripe_product_id=slug).exists(): - slug = f"{slug}_{counter}" + slug_without_counter = re.sub(r"(_\d+$)", "", slug) + slug = f"{slug_without_counter}_{counter}" counter += 1 return slug def save(self, *args, **kwargs): + stripe_client = StripeConnector() if not self.id: self.stripe_product_id = self.generate_stripe_product_id() # create stripe product with price - product = create_stripe_product( + product = stripe_client.create_stripe_product( product_id=self.stripe_product_id, name=self.name, description=self.description, @@ -54,19 +56,21 @@ def save(self, *args, **kwargs): changed = False if self.price != presaved.price: # if price has changed, create new Price and update stripe price ID - price_id = get_or_create_stripe_price(self.stripe_product_id, self.price) + price_id = stripe_client.get_or_create_stripe_price(self.stripe_product_id, self.price) self.stripe_price_id = price_id changed = True if self.name != presaved.name or self.description != presaved.description or self.active != presaved.active: changed = True if changed: - update_stripe_product( + stripe_client.update_stripe_product( product_id=self.stripe_product_id, name=self.name, description=self.description, active=self.active, price_id=self.stripe_price_id, - ) + ) + # TODO: If price has changed, update UserMemberships with active subscriptions + # beyond this month (with stripe_client.update_subscription_price()) super().save(*args, **kwargs) diff --git a/pipsevents/settings.py b/pipsevents/settings.py index 0eaca841..27a438d2 100644 --- a/pipsevents/settings.py +++ b/pipsevents/settings.py @@ -27,7 +27,7 @@ LOCAL=(bool, False), SHOW_VAT=(bool, True), TESTING=(bool, False), - PAYMENT_METHOD=(str, "paypal") , + PAYMENT_METHOD=(str, "stripe"), ENFORCE_AUTO_CANCELLATION=(bool, False) ) diff --git a/requirements.txt b/requirements.txt index a897dfe2..3662390f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -172,7 +172,7 @@ static3==0.7.0 # via # -r requirements.in # dj-static -stripe==6.4.0 +stripe==9.7.0 # via -r requirements.in tomli==2.0.1 # via pytest diff --git a/stripe_payments/utils.py b/stripe_payments/utils.py index db89c742..c0fcc6f3 100644 --- a/stripe_payments/utils.py +++ b/stripe_payments/utils.py @@ -1,4 +1,8 @@ +from datetime import datetime import logging +import requests +from dateutil.relativedelta import relativedelta + from django.conf import settings from django.contrib.sites.models import Site from django.urls import reverse @@ -81,80 +85,274 @@ def process_invoice_items(invoice, payment_method, request=None): ) -def get_connected_account_id(request=None): - stripe.api_key = settings.STRIPE_SECRET_KEY - seller = Seller.objects.filter(site=Site.objects.get_current(request=request)).first() - return seller.stripe_user_id - - -def create_stripe_product(product_id, name, description, price, connected_account_id=None): - connected_account_id = connected_account_id or get_connected_account_id() - price_in_p = int(price * 100) - product = stripe.Product.create( - stripe_account=connected_account_id, - id=product_id, - name=name, - description=description, - default_price_data={ - "unit_amount": price_in_p, - "currency": "gbp", - "recurring": {"interval": "month"}, - }, - ) - return product - - -def update_stripe_product(product_id, name, description, active, price_id, connected_account_id=None): - connected_account_id = connected_account_id or get_connected_account_id() - product = stripe.Product.modify( - product_id, - stripe_account=connected_account_id, - name=name, - description=description, - active=active, - default_price=price_id, - ) - return product +class StripeConnector: + + def __init__(self, request=None): + stripe.api_key = settings.STRIPE_SECRET_KEY + stripe.max_network_retries = 3 + self.connected_account_id = self.get_connected_account_id(request) + def get_connected_account_id(self, request=None): + seller = Seller.objects.filter(site=Site.objects.get_current(request=request)).first() + return seller.stripe_user_id -def get_or_create_stripe_price(product_id, price, connected_account_id=None): - connected_account_id = connected_account_id or get_connected_account_id() - price_in_p = int(price * 100) + def create_stripe_product(self, product_id, name, description, price): + price_in_p = int(price * 100) + try: + product = stripe.Product.create( + stripe_account=self.connected_account_id, + id=product_id, + name=name, + description=description, + default_price_data={ + "unit_amount": price_in_p, + "currency": "gbp", + "recurring": {"interval": "month"}, + }, + ) + except stripe.error.InvalidRequestError as e: + # We use a unique Membership slug as the product id, so we shouldn't attempt to create + # duplicate products; if we do, it's because we deleted a Membership instance, so + # reuse the existing Product and make sure it's active and has the details we've + # just defined + if "already exists" in str(e): + price_id = self.get_or_create_stripe_price(product_id, price) + product = self.update_stripe_product( + product_id, name, description, price_id=price_id, active=True + ) + else: + raise + return product + + def update_stripe_product(self, product_id, name, description, active, price_id): + product = stripe.Product.modify( + product_id, + stripe_account=self.connected_account_id, + name=name, + description=description, + active=active, + default_price=price_id, + ) + return product + + def get_or_create_stripe_price(self, product_id, price): + price_in_p = int(price * 100) + + # get existing active Price for this product and amount if one exists + matching_prices = stripe.Price.list( + product=product_id, + stripe_account=self.connected_account_id, + unit_amount=price_in_p, + active=True, + recurring={"interval": "month"} + ) + if matching_prices.data: + return matching_prices.data[0].id + + new_price = stripe.Price.create( + product=product_id, + stripe_account=self.connected_account_id, + currency="gbp", + unit_amount=price_in_p, + recurring={"interval": "month"}, + ) + return new_price.id + + def get_or_create_stripe_customer(self, user, **kwargs): + if user.userprofile.stripe_customer_id: + return user.userprofile.stripe_customer_id + + # Find existing customers by email (Stripe allows more than one) + customers = stripe.Customer.list( + stripe_account=self.connected_account_id, + email=user.email + ) + if customers.data: + customer_id = customers.data[0].id + else: + # TODO handle card errors if kwargs include payment methods + customer = stripe.Customer.create( + name=f"{user.first_name} {user.last_name}", + email=user.email, + stripe_account=self.connected_account_id, + **kwargs + ) + customer_id = customer.id + user.userprofile.stripe_customer_id = customer_id + user.userprofile.save() + return customer_id + + def update_stripe_customer(self, customer_id, **kwargs): + # TODO handle card errors if kwargs include payment methods + stripe.Customer.modify( + customer_id, + stripe_account=self.connected_account_id, + **kwargs + ) - # get existing active Price for this product and amount if one exists - matching_prices = stripe.Price.list( - product=product_id, - stripe_account=connected_account_id, - unit_amount=price_in_p, - active=True, - recurring={"interval": "month"} - ) - if matching_prices.data: - return matching_prices.data[0].id - - new_price = stripe.Price.create( - product=product_id, - stripe_account=connected_account_id, - currency="gbp", - unit_amount=price_in_p, - recurring={"interval": "month"}, - ) - return new_price.id + def create_subscription(self, customer_id, price_id): + """ + The stripe python API doesn't accept stripe_account for subscriptions, so we need to + call it directly with the headers + https://docs.stripe.com/billing/subscriptions/build-subscriptions?platform=web&ui=elements + subscription = stripe.Subscription.create( + customer=customer_id, + items=[{ + 'price': price_id, + }], + payment_behavior='default_incomplete', # create subscription as incomplete + payment_settings={'save_default_payment_method': 'on_subscription'}, # save customer default payment + expand=['latest_invoice.payment_intent'], + ) -def get_or_create_stripe_customer(user, connected_account_id=None, **kwargs): - if user.user_profile.stripe_customer_id: - return user.user_profile.stripe_customer_id + curl https://api.stripe.com/v1/subscriptions -u : -H "Stripe-Account: acct_1LkrQXBwhuOJbY2i" -d customer=cus_Q8LyvVdwr3AQMg -d "items[0][price]"=price_1PI6o2BwhuOJbY2iLzuO8Vot -d "expand[0]"="latest_invoice.payment_intent" + """ + url = "https://api.stripe.com/v1/subscriptions" + headers = {"Stripe-Account": self.connected_account_id} + auth = (settings.STRIPE_SECRET_KEY, "") + params = { + "customer": customer_id, + "items[0][price]": price_id, + "expand[0]": "latest_invoice.payment_intent" + } + resp = requests.post(url, headers=headers, auth=auth, params=params) + return resp.json() + + def create_subscription(self, customer_id, price_id, backdate=True): + """ + Create subscription for this customer and price + Backdate to 1st of current month if backdate is True + Start from 1st of next month (billing_cycle_anchor) + """ + now = datetime.utcnow() + month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + backdate_to = int(month_start.timestamp()) + next_billing_date = int((month_start + relativedelta(months=1)).timestamp()) + subscription = stripe.Subscription.create( + customer=customer_id, + items=[{'price': price_id}], + backdate_start_date=backdate_to, + billing_cycle_anchor=next_billing_date, + payment_behavior='default_incomplete', # create subscription as incomplete + payment_settings={'save_default_payment_method': 'on_subscription'}, # save customer default payment + expand=['latest_invoice.payment_intent'], + stripe_account=self.connected_account_id, + proration_behavior="none" + ) + return subscription - connected_account_id = connected_account_id or get_connected_account_id() - try: - customer = stripe.Customer.create( - name=f"{user.first_name} {user.last_name}", - email=f"{user.email}", - **kwargs + def get_or_create_subscription_schedule(self, subscription_id): + subscription = stripe.Subscription.retrieve(id=subscription_id, stripe_account=self.connected_account_id) + if subscription.schedule: + schedule = stripe.SubscriptionSchedule.retrieve(id=subscription.schedule, stripe_account=self.connected_account_id) + else: + schedule = stripe.SubscriptionSchedule.create( + from_subscription=subscription_id, + stripe_account=self.connected_account_id, + ) + return schedule + + def update_subscription_price(self, subscription_id, new_price_id): + """ + For when a Membership price changes + Create a subscription schedule for each subscription + Phases = current phase to end of billing period, then new phase with + new price + On next billing period, subscription price will increase. + """ + # retrieve or create schedule from subscription id + schedule = self.get_or_create_subscription_schedule(subscription_id) + # check schedule for end_behvavior + # if end_behavior is cancel, don't update, it's going to cancel at the end of the current billing period + if schedule.end_behavior == "release": + schedule = stripe.SubscriptionSchedule.modify( + schedule.id, + end_behvavior="release", + phases=[ + { + 'items': [ + { + 'price': schedule.phases[0]["items"][0].price, + 'quantity': schedule.phases[0]["items"][0].quantity, + } + ], + 'start_date': schedule.phases[0].start_date, + 'end_date': schedule.phases[0].end_date, + }, + { + 'items': [ + { + 'price': new_price_id, + 'quantity': 1, + } + ], + }, + ], + ) + + return schedule + + def cancel_subscription(self, subscription_id): + """ + Always cancel from end of period + Update schedule to contain only the current phase, and to cancel at the end + """ + # retrieve or create schedule from subscription id + schedule = self.get_or_create_subscription_schedule(subscription_id) + stripe.SubscriptionSchedule.modify( + schedule.id, + end_behavior="cancel", + phases=[ + { + 'items': [ + { + 'price': schedule.phases[0]["items"][0].price, + 'quantity': schedule.phases[0]["items"][0].quantity, + } + ], + 'start_date': schedule.phases[0].start_date, + 'end_date': schedule.phases[0].end_date, + "proration_behavior": "none" + } + ], + stripe_account=self.connected_account_id ) - except Exception as e: - import ipdb; ipdb.set_trace() - ... - return customer + def customer_portal_configuration(self): + """ + Create a customer portal config to allow updating payment information + """ + # fetch an active config and make sure it has the correct configuration + configs = stripe.billing_portal.Configuration.list(stripe_account=self.connected_account_id, active=True) + config_data = dict( + business_profile={ + "privacy_policy_url": "https://booking.thewatermelonstudio.co.uk/data-privacy-policy/", + "terms_of_service_url": "https://www.thewatermelonstudio.co.uk/t&c.html", + }, + features={ + "customer_update": {"allowed_updates": ["email", "name", "address"], "enabled": True}, + "payment_method_update": {"enabled": True}, + "invoice_history": {"enabled": True}, + }, + default_return_url="https://booking.thewatermelonstudio.co.uk/accounts/profile", + stripe_account=self.connected_account_id, + ) + if configs.data: + config_id = configs.data[0].id + return stripe.billing_portal.Configuration.modify(config_id, **config_data) + return stripe.billing_portal.Configuration.create(**config_data) + + def customer_portal_url(self, customer_id): + """ + Create a portal session for this user and return a URL that they can use to access it + This is short-lived; use a view on their account profile to generate it. + If a subscription renewal fails, send an email with the profile link and ask them + to update payment method. + """ + portal = stripe.billing_portal.Session.create( + customer=customer_id, + configuration=self.customer_portal_configuration(), + stripe_account=self.connected_account_id, + ) + return portal.url