Skip to content

Commit

Permalink
Add script to schedule in price changes.
Browse files Browse the repository at this point in the history
  • Loading branch information
dracos committed Oct 9, 2024
1 parent 0a67dba commit 5dbcc85
Show file tree
Hide file tree
Showing 2 changed files with 252 additions and 0 deletions.
95 changes: 95 additions & 0 deletions subscriptions/management/commands/schedule_price_change.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
from django.conf import settings
from django.core.management.base import BaseCommand
import stripe

from subscriptions.models import Subscription

stripe.api_key = settings.STRIPE_SECRET_KEY
stripe.api_version = settings.STRIPE_API_VERSION


class Command(BaseCommand):
help = "Update subscriptions on one Stripe Price to a different Price"

def add_arguments(self, parser):
prices = stripe.Price.list(limit=100)
price_ids = [price.id for price in prices.data if price.product.name.startswith('MapIt')]
parser.add_argument('--old-price', choices=price_ids, required=True)
parser.add_argument('--new-price', choices=price_ids, required=True)
parser.add_argument('--commit', action='store_true')

def new_schedule(self, subscription, new_price):
schedule = stripe.SubscriptionSchedule.create(from_subscription=subscription.id)
phases = [
{
'items': [{'price': schedule.phases[0]['items'][0].price}],
'start_date': schedule.phases[0].start_date,
'iterations': 2,
'proration_behavior': 'none',
'default_tax_rates': [settings.STRIPE_TAX_RATE],
},
{
'items': [{'price': new_price}],
'iterations': 1,
'proration_behavior': 'none',
'default_tax_rates': [settings.STRIPE_TAX_RATE],
},
]
# Maintain current discount, if any
if schedule.phases[0].discounts and schedule.phases[0].discounts[0].coupon:
phases[0]['discounts'] = [{'coupon': schedule.phases[0].discounts[0].coupon}]
phases[1]['discounts'] = [{'coupon': schedule.phases[0].discounts[0].coupon}]
stripe.SubscriptionSchedule.modify(schedule.id, phases=phases)

def handle(self, *args, **options):
old_price = options['old_price']
new_price = options['new_price']

for sub_obj in Subscription.objects.all():
subscription = stripe.Subscription.retrieve(sub_obj.stripe_id, expand=[
'schedule.phases.items.price'])

# Possibilities:
# * No schedule, just a monthly plan
# * 3 phases: This script has already been run
# * 2 phases: Just asked for downgrade, so at current price, then new price, then no schedule
# * 2 phases: Already on 'new price' schedule, awaiting change - nothing to do
# * 1 phase: Been downgraded, so new price, then no schedule
# * 1 phase: On 'new price' schedule, change already happened - nothing to do
if subscription.schedule:
schedule = subscription.schedule
if len(schedule.phases) > 2:
self.stdout.write(f"{subscription.id} has {len(schedule.phases)} phases, assume processed already")
continue
elif len(schedule.phases) == 2:
if schedule.phases[1]['items'][0].price != old_price:
continue
phases = [
schedule.phases[0],
schedule.phases[1],
{
'items': [{'price': new_price}],
'iterations': 1,
'proration_behavior': 'none',
'default_tax_rates': [settings.STRIPE_TAX_RATE],
},
]
# Maintain current discount, if any
if schedule.phases[1].discounts and schedule.phases[1].discounts[0].coupon:
phases[2]['discounts'] = [{'coupon': schedule.phases[1].discounts[0].coupon}]
self.stdout.write(f"{subscription.id} has two phases, adding third phase to new price")
if options['commit']:
stripe.SubscriptionSchedule.modify(schedule.id, phases=phases)
else: # Must be 1
if schedule.phases[0]['items'][0].price != old_price:
continue
self.stdout.write(f"{subscription.id} has one phase, releasing and adding schedule to new price")
if options['commit']:
stripe.SubscriptionSchedule.release(schedule)
self.new_schedule(subscription, new_price)
else:
if subscription['items'].data[0].price.id != old_price:
continue
self.stdout.write(f"{subscription.id} has no phase, adding schedule to new price")
if options['commit']:
self.new_schedule(subscription, new_price)
157 changes: 157 additions & 0 deletions subscriptions/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,163 @@ def test_invoice_succeeded_sub_present(self):
self.MockStripe.PaymentIntent.modify.assert_called_once_with('PI', description='MapIt')


class PriceChangeTest(PatchedStripeMixin, UserTestCase):
def setUp(self):
super().setUp()
Subscription.objects.create(user=self.user, stripe_id='ID')
self.MockStripe.Price.list.return_value = convert_to_stripe_object({
'data': [
{'id': 'price_789',
'metadata': {'calls': '0'},
'product': {'id': 'prod_GHI', 'name': 'MapIt, unlimited calls'}},
{'id': 'price_789b',
'metadata': {'calls': '0'},
'product': {'id': 'prod_GHI', 'name': 'MapIt, unlimited calls'}},
{'id': 'price_123',
'metadata': {'calls': '10000'},
'product': {'id': 'prod_ABC', 'name': 'MapIt, 10,000 calls'}},
{'id': 'price_123b',
'metadata': {'calls': '10000'},
'product': {'id': 'prod_ABC', 'name': 'MapIt, 10,000 calls'}},
{'id': 'price_456',
'metadata': {'calls': '100000'},
'product': {'id': 'prod_DEF', 'name': 'MapIt, 100,000 calls'}},
{'id': 'price_456b',
'metadata': {'calls': '100000'},
'product': {'id': 'prod_DEF', 'name': 'MapIt, 100,000 calls'}},
]
}, None, None)

def test_change_price_charitable_sub(self):
with patch('subscriptions.management.commands.schedule_price_change.stripe', self.MockStripe):
call_command(
'schedule_price_change', '--old', 'price_789', '--new', 'price_789b',
'--commit', stdout=StringIO(), stderr=StringIO())

self.MockStripe.SubscriptionSchedule.create.assert_called_once_with(
from_subscription='SUBSCRIPTION-ID')
self.MockStripe.SubscriptionSchedule.modify.assert_called_once_with(
'SCHEDULE-ID', phases=[{
'items': [{'price': 'price_789'}],
'start_date': ANY,
'iterations': 2,
'proration_behavior': 'none',
'default_tax_rates': [ANY],
'discounts': [{'coupon': 'charitable50'}]
}, {
'items': [{'price': 'price_789b'}],
'iterations': 1,
'proration_behavior': 'none',
'default_tax_rates': [ANY],
'discounts': [{'coupon': 'charitable50'}]
}])

def test_change_price_non_charitable_sub(self):
sub = self.MockStripe.SubscriptionSchedule.create.return_value
sub.phases[0].discounts = None
with patch('subscriptions.management.commands.schedule_price_change.stripe', self.MockStripe):
call_command(
'schedule_price_change', '--old', 'price_789', '--new', 'price_789b',
'--commit', stdout=StringIO(), stderr=StringIO())

self.MockStripe.SubscriptionSchedule.create.assert_called_once_with(
from_subscription='SUBSCRIPTION-ID')
self.MockStripe.SubscriptionSchedule.modify.assert_called_once_with(
'SCHEDULE-ID', phases=[{
'items': [{'price': 'price_789'}],
'start_date': ANY,
'iterations': 2,
'proration_behavior': 'none',
'default_tax_rates': [ANY],
}, {
'items': [{'price': 'price_789b'}],
'iterations': 1,
'proration_behavior': 'none',
'default_tax_rates': [ANY],
}])

def test_change_price_just_downgraded(self):
sub = self.MockStripe.Subscription.retrieve.return_value
sub.schedule = convert_to_stripe_object({
'id': 'SCHEDULE-ID',
'phases': [{
'start_date': time.time(),
'end_date': time.time(),
'discounts': [{'coupon': 'charitable50'}],
'items': [{
'price': 'price_789',
}]
}, {
'start_date': time.time(),
'end_date': time.time(),
'discounts': [{'coupon': 'charitable50'}],
'items': [{
'price': 'price_456',
}]
}],
}, None, None)
with patch('subscriptions.management.commands.schedule_price_change.stripe', self.MockStripe):
call_command(
'schedule_price_change', '--old', 'price_456', '--new', 'price_456b',
'--commit', stdout=StringIO(), stderr=StringIO())

self.MockStripe.SubscriptionSchedule.modify.assert_called_once_with(
'SCHEDULE-ID', phases=[{
'items': [{'price': 'price_789'}],
'start_date': ANY,
'end_date': ANY,
'discounts': [{'coupon': 'charitable50'}]
}, {
'items': [{'price': 'price_456'}],
'start_date': ANY,
'end_date': ANY,
'discounts': [{'coupon': 'charitable50'}]
}, {
'items': [{'price': 'price_456b'}],
'iterations': 1,
'proration_behavior': 'none',
'default_tax_rates': [ANY],
'discounts': [{'coupon': 'charitable50'}]
}])

def test_change_price_downgraded_last_month(self):
sub = self.MockStripe.Subscription.retrieve.return_value
sub.schedule = convert_to_stripe_object({
'id': 'SCHEDULE-ID',
'phases': [{
'start_date': time.time(),
'end_date': time.time(),
'discounts': [{'coupon': 'charitable50'}],
'items': [{
'price': 'price_456',
}]
}],
}, None, None)
sub = self.MockStripe.SubscriptionSchedule.create.return_value
sub.phases[0].discounts = None
sub.phases[0]['items'][0].price = 'price_456'
with patch('subscriptions.management.commands.schedule_price_change.stripe', self.MockStripe):
call_command(
'schedule_price_change', '--old', 'price_456', '--new', 'price_456b',
'--commit', stdout=StringIO(), stderr=StringIO())

self.MockStripe.SubscriptionSchedule.create.assert_called_once_with(
from_subscription='SUBSCRIPTION-ID')
self.MockStripe.SubscriptionSchedule.modify.assert_called_once_with(
'SCHEDULE-ID', phases=[{
'items': [{'price': 'price_456'}],
'iterations': 2,
'start_date': ANY,
'proration_behavior': 'none',
'default_tax_rates': [ANY],
}, {
'items': [{'price': 'price_456b'}],
'iterations': 1,
'proration_behavior': 'none',
'default_tax_rates': [ANY],
}])


@override_settings(REDIS_API_NAME='test_api')
class ManagementTest(PatchedRedisTestCase):
@override_settings(API_THROTTLE_UNLIMITED=['127.0.0.4'])
Expand Down

0 comments on commit 5dbcc85

Please sign in to comment.