Skip to content

Commit 62d63ca

Browse files
committed
Add script to schedule in price changes.
1 parent e12aa05 commit 62d63ca

File tree

2 files changed

+252
-0
lines changed

2 files changed

+252
-0
lines changed
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
from django.conf import settings
2+
from django.core.management.base import BaseCommand
3+
import stripe
4+
5+
from subscriptions.models import Subscription
6+
7+
stripe.api_key = settings.STRIPE_SECRET_KEY
8+
stripe.api_version = settings.STRIPE_API_VERSION
9+
10+
11+
class Command(BaseCommand):
12+
help = "Update subscriptions on one Stripe Price to a different Price"
13+
14+
def add_arguments(self, parser):
15+
prices = stripe.Price.list(limit=100)
16+
price_ids = [price.id for price in prices.data if price.product.name.startswith('MapIt')]
17+
parser.add_argument('--old-price', choices=price_ids, required=True)
18+
parser.add_argument('--new-price', choices=price_ids, required=True)
19+
parser.add_argument('--commit', action='store_true')
20+
21+
def new_schedule(self, subscription, new_price):
22+
schedule = stripe.SubscriptionSchedule.create(from_subscription=subscription.id)
23+
phases = [
24+
{
25+
'items': [{'price': schedule.phases[0]['items'][0].price}],
26+
'start_date': schedule.phases[0].start_date,
27+
'iterations': 2,
28+
'proration_behavior': 'none',
29+
'default_tax_rates': [settings.STRIPE_TAX_RATE],
30+
},
31+
{
32+
'items': [{'price': new_price}],
33+
'iterations': 1,
34+
'proration_behavior': 'none',
35+
'default_tax_rates': [settings.STRIPE_TAX_RATE],
36+
},
37+
]
38+
# Maintain current discount, if any
39+
if schedule.phases[0].discounts and schedule.phases[0].discounts[0].coupon:
40+
phases[0]['discounts'] = [{'coupon': schedule.phases[0].discounts[0].coupon}]
41+
phases[1]['discounts'] = [{'coupon': schedule.phases[0].discounts[0].coupon}]
42+
stripe.SubscriptionSchedule.modify(schedule.id, phases=phases)
43+
44+
def handle(self, *args, **options):
45+
old_price = options['old_price']
46+
new_price = options['new_price']
47+
48+
for sub_obj in Subscription.objects.all():
49+
subscription = stripe.Subscription.retrieve(sub_obj.stripe_id, expand=[
50+
'schedule.phases.items.price'])
51+
52+
# Possibilities:
53+
# * No schedule, just a monthly plan
54+
# * 3 phases: This script has already been run
55+
# * 2 phases: Just asked for downgrade, so at current price, then new price, then no schedule
56+
# * 2 phases: Already on 'new price' schedule, awaiting change - nothing to do
57+
# * 1 phase: Been downgraded, so new price, then no schedule
58+
# * 1 phase: On 'new price' schedule, change already happened - nothing to do
59+
if subscription.schedule:
60+
schedule = subscription.schedule
61+
if len(schedule.phases) > 2:
62+
self.stdout.write(f"{subscription.id} has {len(schedule.phases)} phases, assume processed already")
63+
continue
64+
elif len(schedule.phases) == 2:
65+
if schedule.phases[1]['items'][0].price != old_price:
66+
continue
67+
phases = [
68+
schedule.phases[0],
69+
schedule.phases[1],
70+
{
71+
'items': [{'price': new_price}],
72+
'iterations': 1,
73+
'proration_behavior': 'none',
74+
'default_tax_rates': [settings.STRIPE_TAX_RATE],
75+
},
76+
]
77+
# Maintain current discount, if any
78+
if schedule.phases[1].discounts and schedule.phases[1].discounts[0].coupon:
79+
phases[2]['discounts'] = [{'coupon': schedule.phases[1].discounts[0].coupon}]
80+
self.stdout.write(f"{subscription.id} has two phases, adding third phase to new price")
81+
if options['commit']:
82+
stripe.SubscriptionSchedule.modify(schedule.id, phases=phases)
83+
else: # Must be 1
84+
if schedule.phases[0]['items'][0].price != old_price:
85+
continue
86+
self.stdout.write(f"{subscription.id} has one phase, releasing and adding schedule to new price")
87+
if options['commit']:
88+
stripe.SubscriptionSchedule.release(schedule)
89+
self.new_schedule(subscription, new_price)
90+
else:
91+
if subscription['items'].data[0].price.id != old_price:
92+
continue
93+
self.stdout.write(f"{subscription.id} has no phase, adding schedule to new price")
94+
if options['commit']:
95+
self.new_schedule(subscription, new_price)

subscriptions/tests.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -683,6 +683,163 @@ def test_invoice_succeeded_sub_present(self):
683683
self.MockStripe.PaymentIntent.modify.assert_called_once_with('PI', description='MapIt')
684684

685685

686+
class PriceChangeTest(PatchedStripeMixin, UserTestCase):
687+
def setUp(self):
688+
super().setUp()
689+
Subscription.objects.create(user=self.user, stripe_id='ID')
690+
self.MockStripe.Price.list.return_value = convert_to_stripe_object({
691+
'data': [
692+
{'id': 'price_789',
693+
'metadata': {'calls': '0'},
694+
'product': {'id': 'prod_GHI', 'name': 'MapIt, unlimited calls'}},
695+
{'id': 'price_789b',
696+
'metadata': {'calls': '0'},
697+
'product': {'id': 'prod_GHI', 'name': 'MapIt, unlimited calls'}},
698+
{'id': 'price_123',
699+
'metadata': {'calls': '10000'},
700+
'product': {'id': 'prod_ABC', 'name': 'MapIt, 10,000 calls'}},
701+
{'id': 'price_123b',
702+
'metadata': {'calls': '10000'},
703+
'product': {'id': 'prod_ABC', 'name': 'MapIt, 10,000 calls'}},
704+
{'id': 'price_456',
705+
'metadata': {'calls': '100000'},
706+
'product': {'id': 'prod_DEF', 'name': 'MapIt, 100,000 calls'}},
707+
{'id': 'price_456b',
708+
'metadata': {'calls': '100000'},
709+
'product': {'id': 'prod_DEF', 'name': 'MapIt, 100,000 calls'}},
710+
]
711+
}, None, None)
712+
713+
def test_change_price_charitable_sub(self):
714+
with patch('subscriptions.management.commands.schedule_price_change.stripe', self.MockStripe):
715+
call_command(
716+
'schedule_price_change', '--old', 'price_789', '--new', 'price_789b',
717+
'--commit', stdout=StringIO(), stderr=StringIO())
718+
719+
self.MockStripe.SubscriptionSchedule.create.assert_called_once_with(
720+
from_subscription='SUBSCRIPTION-ID')
721+
self.MockStripe.SubscriptionSchedule.modify.assert_called_once_with(
722+
'SCHEDULE-ID', phases=[{
723+
'items': [{'price': 'price_789'}],
724+
'start_date': ANY,
725+
'iterations': 2,
726+
'proration_behavior': 'none',
727+
'default_tax_rates': [ANY],
728+
'discounts': [{'coupon': 'charitable50'}]
729+
}, {
730+
'items': [{'price': 'price_789b'}],
731+
'iterations': 1,
732+
'proration_behavior': 'none',
733+
'default_tax_rates': [ANY],
734+
'discounts': [{'coupon': 'charitable50'}]
735+
}])
736+
737+
def test_change_price_non_charitable_sub(self):
738+
sub = self.MockStripe.SubscriptionSchedule.create.return_value
739+
sub.phases[0].discounts = None
740+
with patch('subscriptions.management.commands.schedule_price_change.stripe', self.MockStripe):
741+
call_command(
742+
'schedule_price_change', '--old', 'price_789', '--new', 'price_789b',
743+
'--commit', stdout=StringIO(), stderr=StringIO())
744+
745+
self.MockStripe.SubscriptionSchedule.create.assert_called_once_with(
746+
from_subscription='SUBSCRIPTION-ID')
747+
self.MockStripe.SubscriptionSchedule.modify.assert_called_once_with(
748+
'SCHEDULE-ID', phases=[{
749+
'items': [{'price': 'price_789'}],
750+
'start_date': ANY,
751+
'iterations': 2,
752+
'proration_behavior': 'none',
753+
'default_tax_rates': [ANY],
754+
}, {
755+
'items': [{'price': 'price_789b'}],
756+
'iterations': 1,
757+
'proration_behavior': 'none',
758+
'default_tax_rates': [ANY],
759+
}])
760+
761+
def test_change_price_just_downgraded(self):
762+
sub = self.MockStripe.Subscription.retrieve.return_value
763+
sub.schedule = convert_to_stripe_object({
764+
'id': 'SCHEDULE-ID',
765+
'phases': [{
766+
'start_date': time.time(),
767+
'end_date': time.time(),
768+
'discounts': [{'coupon': 'charitable50'}],
769+
'items': [{
770+
'price': 'price_789',
771+
}]
772+
}, {
773+
'start_date': time.time(),
774+
'end_date': time.time(),
775+
'discounts': [{'coupon': 'charitable50'}],
776+
'items': [{
777+
'price': 'price_456',
778+
}]
779+
}],
780+
}, None, None)
781+
with patch('subscriptions.management.commands.schedule_price_change.stripe', self.MockStripe):
782+
call_command(
783+
'schedule_price_change', '--old', 'price_456', '--new', 'price_456b',
784+
'--commit', stdout=StringIO(), stderr=StringIO())
785+
786+
self.MockStripe.SubscriptionSchedule.modify.assert_called_once_with(
787+
'SCHEDULE-ID', phases=[{
788+
'items': [{'price': 'price_789'}],
789+
'start_date': ANY,
790+
'end_date': ANY,
791+
'discounts': [{'coupon': 'charitable50'}]
792+
}, {
793+
'items': [{'price': 'price_456'}],
794+
'start_date': ANY,
795+
'end_date': ANY,
796+
'discounts': [{'coupon': 'charitable50'}]
797+
}, {
798+
'items': [{'price': 'price_456b'}],
799+
'iterations': 1,
800+
'proration_behavior': 'none',
801+
'default_tax_rates': [ANY],
802+
'discounts': [{'coupon': 'charitable50'}]
803+
}])
804+
805+
def test_change_price_downgraded_last_month(self):
806+
sub = self.MockStripe.Subscription.retrieve.return_value
807+
sub.schedule = convert_to_stripe_object({
808+
'id': 'SCHEDULE-ID',
809+
'phases': [{
810+
'start_date': time.time(),
811+
'end_date': time.time(),
812+
'discounts': [{'coupon': 'charitable50'}],
813+
'items': [{
814+
'price': 'price_456',
815+
}]
816+
}],
817+
}, None, None)
818+
sub = self.MockStripe.SubscriptionSchedule.create.return_value
819+
sub.phases[0].discounts = None
820+
sub.phases[0]['items'][0].price = 'price_456'
821+
with patch('subscriptions.management.commands.schedule_price_change.stripe', self.MockStripe):
822+
call_command(
823+
'schedule_price_change', '--old', 'price_456', '--new', 'price_456b',
824+
'--commit', stdout=StringIO(), stderr=StringIO())
825+
826+
self.MockStripe.SubscriptionSchedule.create.assert_called_once_with(
827+
from_subscription='SUBSCRIPTION-ID')
828+
self.MockStripe.SubscriptionSchedule.modify.assert_called_once_with(
829+
'SCHEDULE-ID', phases=[{
830+
'items': [{'price': 'price_456'}],
831+
'iterations': 2,
832+
'start_date': ANY,
833+
'proration_behavior': 'none',
834+
'default_tax_rates': [ANY],
835+
}, {
836+
'items': [{'price': 'price_456b'}],
837+
'iterations': 1,
838+
'proration_behavior': 'none',
839+
'default_tax_rates': [ANY],
840+
}])
841+
842+
686843
@override_settings(REDIS_API_NAME='test_api')
687844
class ManagementTest(PatchedRedisTestCase):
688845
@override_settings(API_THROTTLE_UNLIMITED=['127.0.0.4'])

0 commit comments

Comments
 (0)