Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Support for Tiered Pricing Plans #629

Open
wants to merge 14 commits into
base: original
Choose a base branch
from
15 changes: 14 additions & 1 deletion pinax/stripe/actions/plans.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,24 @@ def sync_plan(plan, event=None):
"name": plan["name"],
"statement_descriptor": plan["statement_descriptor"] or "",
"trial_period_days": plan["trial_period_days"],
"metadata": plan["metadata"]
"metadata": plan["metadata"],
"billing_scheme": plan["billing_scheme"],
"tiers_mode": plan["tiers_mode"]
}

obj, created = models.Plan.objects.get_or_create(
stripe_id=plan["id"],
defaults=defaults
)
utils.update_with_defaults(obj, defaults, created)

if plan["tiers"]:
obj.tiers.all().delete() # delete all tiers, since they don't have ids in Stripe
for tier in plan["tiers"]:
tier_obj = models.Tier.objects.create(
plan=obj,
amount=utils.convert_amount_for_db(tier["amount"], plan["currency"]),
flat_amount=utils.convert_amount_for_db(tier["flat_amount"], plan["currency"]),
up_to=tier["up_to"]
)
obj.tiers.add(tier_obj)
40 changes: 40 additions & 0 deletions pinax/stripe/managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,43 @@ def paid_totals_for(self, year, month):
total_amount=models.Sum("amount"),
total_refunded=models.Sum("amount_refunded")
)


class TieredPricingManager(models.Manager):

TIERS_MODE_VOLUME = "volume"
TIERS_MODE_GRADUATED = "graduated"
TIERS_MODES = (TIERS_MODE_VOLUME, TIERS_MODE_GRADUATED)

def closed_tiers(self, plan):
return self.filter(plan=plan, up_to__isnull=False).order_by("up_to")

def open_tiers(self, plan):
return self.filter(plan=plan, up_to__isnull=True)

def all_tiers(self, plan):
return list(self.closed_tiers(plan)) + list(self.open_tiers(plan))

def calculate_final_cost(self, plan, quantity, mode):
if mode not in self.TIERS_MODES:
raise Exception("Received wrong type of mode ({})".format(mode))

all_tiers = self.all_tiers(plan)
cost = 0
if mode == self.TIERS_MODE_VOLUME:
applicable_tiers = list(filter(lambda t: not t.up_to or quantity <= t.up_to, all_tiers))
tier = applicable_tiers[0] if applicable_tiers else all_tiers[-1]
cost = tier.calculate_cost(quantity)

if mode == self.TIERS_MODE_GRADUATED:
quantity_billed = 0
idx = 0
while quantity > 0:
tier = all_tiers[idx]
quantity_to_bill = min(quantity, tier.up_to - quantity_billed) if tier.up_to else quantity
cost += tier.calculate_cost(quantity_to_bill)
quantity -= quantity_to_bill
quantity_billed += quantity_to_bill
idx += 1

return cost
34 changes: 34 additions & 0 deletions pinax/stripe/migrations/0015_auto_20190203_0949.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Generated by Django 2.2a1 on 2019-02-03 14:49

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('pinax_stripe', '0014_auto_20180413_1959'),
]

operations = [
migrations.AddField(
model_name='plan',
name='billing_scheme',
field=models.CharField(default='per_unit', max_length=15),
),
migrations.AddField(
model_name='plan',
name='tiers_mode',
field=models.CharField(blank=True, max_length=15, null=True),
),
migrations.CreateModel(
name='Tier',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('amount', models.DecimalField(decimal_places=2, max_digits=9)),
('flat_amount', models.DecimalField(decimal_places=2, max_digits=9)),
('up_to', models.IntegerField(blank=True, null=True)),
('plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tiers', to='pinax_stripe.Plan')),
],
),
]
31 changes: 29 additions & 2 deletions pinax/stripe/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from jsonfield.fields import JSONField

from .conf import settings
from .managers import ChargeManager, CustomerManager
from .managers import ChargeManager, CustomerManager, TieredPricingManager
from .utils import CURRENCY_SYMBOLS


Expand Down Expand Up @@ -76,6 +76,9 @@ def stripe_account_stripe_id(self):

@python_2_unicode_compatible
class Plan(UniquePerAccountStripeObject):
BILLING_SCHEME_PER_UNIT = "per_unit"
BILLING_SCHEME_TIERED = "tiered"

amount = models.DecimalField(decimal_places=2, max_digits=9)
currency = models.CharField(max_length=15, blank=False)
interval = models.CharField(max_length=15)
Expand All @@ -84,6 +87,8 @@ class Plan(UniquePerAccountStripeObject):
statement_descriptor = models.TextField(blank=True)
trial_period_days = models.IntegerField(null=True, blank=True)
metadata = JSONField(null=True, blank=True)
billing_scheme = models.CharField(max_length=15, default=BILLING_SCHEME_PER_UNIT)
tiers_mode = models.CharField(max_length=15, null=True, blank=True)

def __str__(self):
return "{} ({}{})".format(self.name, CURRENCY_SYMBOLS.get(self.currency, ""), self.amount)
Expand All @@ -107,6 +112,14 @@ def stripe_plan(self):
stripe_account=self.stripe_account_stripe_id,
)

def calculate_total_amount(self, quantity):
if self.billing_scheme == self.BILLING_SCHEME_PER_UNIT:
return self.amount * quantity
elif self.billing_scheme == self.BILLING_SCHEME_TIERED:
return Tier.pricing.calculate_final_cost(self, quantity, self.tiers_mode)
else:
raise Exception("The Plan ({}) received the wrong type of billing_scheme ({})".format(self.name, self.billing_scheme))


@python_2_unicode_compatible
class Coupon(StripeObject):
Expand Down Expand Up @@ -379,7 +392,7 @@ def stripe_subscription(self):

@property
def total_amount(self):
return self.plan.amount * self.quantity
return self.plan.calculate_total_amount(self.quantity)

def plan_display(self):
return self.plan.name
Expand Down Expand Up @@ -653,3 +666,17 @@ def stripe_bankaccount(self):
return self.account.stripe_account.external_accounts.retrieve(
self.stripe_id
)


class Tier(models.Model):

plan = models.ForeignKey(Plan, related_name="tiers", on_delete=models.CASCADE)
amount = models.DecimalField(decimal_places=2, max_digits=9)
flat_amount = models.DecimalField(decimal_places=2, max_digits=9)
up_to = models.IntegerField(blank=True, null=True)

objects = models.Manager()
pricing = TieredPricingManager()

def calculate_cost(self, quantity):
return (self.amount * quantity) + self.flat_amount
5 changes: 4 additions & 1 deletion pinax/stripe/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,10 @@
"currency": "usd",
"created": 1498573686,
"name": "Pro Plan",
"metadata": {}
"metadata": {},
"billing_scheme": "per_unit",
"tiers_mode": None,
"tiers": None
}
},
"type": "plan.updated",
Expand Down
88 changes: 81 additions & 7 deletions pinax/stripe/tests/test_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1212,7 +1212,10 @@ def test_sync_plans(self, PlanAutoPagerMock):
"metadata": {},
"name": "The Pro Plan",
"statement_descriptor": "ALTMAN",
"trial_period_days": 3
"trial_period_days": 3,
"billing_scheme": "per_unit",
"tiers_mode": None,
"tiers": None
},
{
"id": "simple1",
Expand All @@ -1226,12 +1229,45 @@ def test_sync_plans(self, PlanAutoPagerMock):
"metadata": {},
"name": "The Simple Plan",
"statement_descriptor": "ALTMAN",
"trial_period_days": 3
"trial_period_days": 3,
"billing_scheme": "per_unit",
"tiers_mode": None,
"tiers": None
},
{
"id": "tiered1",
"object": "plan",
"amount": None,
"created": 1448121054,
"currency": "usd",
"interval": "month",
"interval_count": 1,
"livemode": False,
"metadata": {},
"name": "The Simple Plan",
"statement_descriptor": "ALTMAN",
"trial_period_days": 3,
"billing_scheme": "tiered",
"tiers_mode": "test",
"tiers": [
{
"amount": None,
"flat_amount": 14900,
"up_to": 100
},
{
"amount": 100,
"flat_amount": None,
"up_to": None
}
],
},
]
plans.sync_plans()
self.assertTrue(Plan.objects.all().count(), 2)
self.assertEqual(Plan.objects.all().count(), len(PlanAutoPagerMock.return_value))
self.assertEqual(Plan.objects.get(stripe_id="simple1").amount, decimal.Decimal("9.99"))
self.assertTrue(Plan.objects.filter(stripe_id="tiered1").exists())
self.assertEqual(Plan.objects.get(stripe_id="tiered1").tiers.count(), 2)

@patch("stripe.Plan.auto_paging_iter", create=True)
def test_sync_plans_update(self, PlanAutoPagerMock):
Expand All @@ -1248,7 +1284,10 @@ def test_sync_plans_update(self, PlanAutoPagerMock):
"metadata": {},
"name": "The Pro Plan",
"statement_descriptor": "ALTMAN",
"trial_period_days": 3
"trial_period_days": 3,
"billing_scheme": "per_unit",
"tiers_mode": None,
"tiers": None
},
{
"id": "simple1",
Expand All @@ -1262,15 +1301,47 @@ def test_sync_plans_update(self, PlanAutoPagerMock):
"metadata": {},
"name": "The Simple Plan",
"statement_descriptor": "ALTMAN",
"trial_period_days": 3
"trial_period_days": 3,
"billing_scheme": "per_unit",
"tiers_mode": None,
"tiers": None
},
{
"id": "tiered1",
"object": "plan",
"amount": None,
"created": 1448121054,
"currency": "usd",
"interval": "month",
"interval_count": 1,
"livemode": False,
"metadata": {},
"name": "The Simple Plan",
"statement_descriptor": "ALTMAN",
"trial_period_days": 3,
"billing_scheme": "tiered",
"tiers_mode": "test",
"tiers": [
{
"amount": None,
"flat_amount": 14900,
"up_to": 100
},
{
"amount": 100,
"flat_amount": None,
"up_to": None
}
],
},
]
plans.sync_plans()
self.assertTrue(Plan.objects.all().count(), 2)
self.assertEqual(Plan.objects.all().count(), len(PlanAutoPagerMock.return_value))
self.assertEqual(Plan.objects.get(stripe_id="simple1").amount, decimal.Decimal("9.99"))
PlanAutoPagerMock.return_value[1].update({"amount": 499})
plans.sync_plans()
self.assertEqual(Plan.objects.get(stripe_id="simple1").amount, decimal.Decimal("4.99"))
self.assertEqual(Plan.objects.get(stripe_id="tiered1").tiers.count(), 2)

def test_sync_plan(self):
"""
Expand All @@ -1295,7 +1366,10 @@ def test_sync_plan(self):
"metadata": {},
"name": "Gold Plan",
"statement_descriptor": "ALTMAN",
"trial_period_days": 3
"trial_period_days": 3,
"billing_scheme": "per_unit",
"tiers_mode": None,
"tiers": None
}
plans.sync_plan(plan)
self.assertTrue(Plan.objects.all().count(), 1)
Expand Down
5 changes: 4 additions & 1 deletion pinax/stripe/tests/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ def test_plans_create(self, PlanAutoPagerMock):
"statement_descriptor": None,
"trial_period_days": None,
"name": "Pro",
"metadata": {}
"metadata": {},
"billing_scheme": "per_unit",
"tiers_mode": None,
"tiers": None
}]
management.call_command("sync_plans")
self.assertEqual(Plan.objects.count(), 1)
Expand Down
Loading