Skip to content

Fix proration percentage for intervals other than month. #639

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

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 58 additions & 57 deletions silver/documents_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,12 @@
from __future__ import absolute_import

import datetime as dt
from itertools import chain

import logging

from decimal import Decimal
from django.db import transaction

from django.utils import timezone

Expand All @@ -29,8 +32,7 @@


class DocumentsGenerator(object):
def generate(self, subscription=None, billing_date=None, customers=None,
force_generate=False):
def generate(self, subscription=None, billing_date=None, customers=None, force_generate=False):
"""
The `public` method called when one wants to generate the billing documents.

Expand All @@ -42,11 +44,12 @@ def generate(self, subscription=None, billing_date=None, customers=None,
:param force_generate: if True, invoices are generated at the date
indicated by `billing_date` instead of the normal end of billing
cycle.
:returns: A list of generated documents.

:note
If `subscription` is passed, only the documents for that subscription are
generated.
If the `customers` parameter is passed, only the docments for those customers are
If the `customers` parameter is passed, only the documents for those customers are
generated.
Only one of the `customers` and `subscription` parameters may be passed at a time.
If neither the `subscription` nor the `customers` parameters are passed, the
Expand All @@ -55,13 +58,13 @@ def generate(self, subscription=None, billing_date=None, customers=None,

if not subscription:
customers = customers or Customer.objects.all()
self._generate_all(billing_date=billing_date,
customers=customers,
force_generate=force_generate)
return self._generate_all(billing_date=billing_date,
customers=customers,
force_generate=force_generate)
else:
self._generate_for_single_subscription(subscription=subscription,
billing_date=billing_date,
force_generate=force_generate)
return self._generate_for_single_subscription(subscription=subscription,
billing_date=billing_date,
force_generate=force_generate)

def _generate_all(self, billing_date=None, customers=None, force_generate=False):
"""
Expand All @@ -72,13 +75,14 @@ def _generate_all(self, billing_date=None, customers=None, force_generate=False)
billing_date = billing_date or timezone.now().date()
# billing_date -> the date when the billing documents are issued.

documents = []
for customer in customers:
if customer.consolidated_billing:
self._generate_for_user_with_consolidated_billing(
documents += self._generate_for_user_with_consolidated_billing(
customer, billing_date, force_generate
)
else:
self._generate_for_user_without_consolidated_billing(
documents += self._generate_for_user_without_consolidated_billing(
customer, billing_date, force_generate
)

Expand Down Expand Up @@ -116,11 +120,15 @@ def _bill_subscription_into_document(self, subscription, billing_date, document=
'subscription': subscription,
subscription.provider.flow: document,
})
self.add_subscription_cycles_to_document(**kwargs)
with transaction.atomic():
self.add_subscription_cycles_to_document(**kwargs)

if subscription.state == Subscription.STATES.CANCELED:
subscription.end()
subscription.save()

if subscription.state == Subscription.STATES.CANCELED:
subscription.end()
subscription.save()
if subscription.provider.default_document_state == Provider.DEFAULT_DOC_STATE.ISSUED:
document.issue()

return document

Expand All @@ -145,9 +153,7 @@ def _generate_for_user_with_consolidated_billing(self, customer, billing_date, f
subscription, billing_date, document=existing_document
)

for provider, document in existing_provider_documents.items():
if provider.default_document_state == Provider.DEFAULT_DOC_STATE.ISSUED:
document.issue()
return list(chain(existing_provider_documents.items()))

def _generate_for_user_without_consolidated_billing(self, customer, billing_date,
force_generate):
Expand All @@ -157,14 +163,10 @@ def _generate_for_user_without_consolidated_billing(self, customer, billing_date
"""

# The user does not use consolidated_billing => add each subscription to a separate document
for subscription in self.get_subscriptions_prepared_for_billing(customer, billing_date,
force_generate):
provider = subscription.plan.provider

document = self._bill_subscription_into_document(subscription, billing_date)

if provider.default_document_state == Provider.DEFAULT_DOC_STATE.ISSUED:
document.issue()
subscriptions_to_bill = self.get_subscriptions_prepared_for_billing(customer, billing_date,
force_generate)
return [self._bill_subscription_into_document(subscription, billing_date)
for subscription in subscriptions_to_bill]

def _generate_for_single_subscription(self, subscription=None, billing_date=None,
force_generate=False):
Expand All @@ -175,15 +177,10 @@ def _generate_for_single_subscription(self, subscription=None, billing_date=None

billing_date = billing_date or timezone.now().date()

provider = subscription.provider

if not subscription.should_be_billed(billing_date) or force_generate:
return

document = self._bill_subscription_into_document(subscription, billing_date)
return []

if provider.default_document_state == Provider.DEFAULT_DOC_STATE.ISSUED:
document.issue()
return [self._bill_subscription_into_document(subscription, billing_date)]

def add_subscription_cycles_to_document(self, billing_date, metered_features_billed_up_to,
plan_billed_up_to, subscription,
Expand All @@ -203,14 +200,12 @@ def add_subscription_cycles_to_document(self, billing_date, metered_features_bil
# cycle) and add the entries to the document

# relative_start_date and relative_end_date define the cycle that is billed within the
# loop's iteration (referred throughout the comments as the cycle)
# loop's iteration (referred throughout the comments as a cycle)
while relative_start_date <= last_cycle_end_date:
relative_end_date = subscription.bucket_end_date(
reference_date=relative_start_date
)
relative_end_date = subscription.bucket_end_date(reference_date=relative_start_date)

# There was no cycle for the given billing date
if not relative_end_date:
# There was no cycle for the given billing date
break

# This is here in order to separate the trial entries from the paid ones
Expand All @@ -231,14 +226,10 @@ def add_subscription_cycles_to_document(self, billing_date, metered_features_bil

# Bill the plan amount
if should_bill_plan:
if subscription.on_trial(relative_start_date):
plan_amount += subscription._add_plan_trial(start_date=relative_start_date,
end_date=relative_end_date,
invoice=invoice, proforma=proforma)
else:
plan_amount += subscription._add_plan_value(start_date=relative_start_date,
end_date=relative_end_date,
proforma=proforma, invoice=invoice)
plan_amount += self.add_plan_entry(
subscription, start_date=relative_start_date, end_date=relative_end_date,
invoice=invoice, proforma=proforma
)
plan_now_billed_up_to = relative_end_date

# Only bill metered features if the cycle the metered features belong to has ended
Expand All @@ -247,17 +238,10 @@ def add_subscription_cycles_to_document(self, billing_date, metered_features_bil

# Bill the metered features
if should_bill_metered_features:
if subscription.on_trial(relative_start_date):
metered_features_amount += subscription._add_mfs_for_trial(
start_date=relative_start_date, end_date=relative_end_date,
invoice=invoice, proforma=proforma
)
else:
metered_features_amount += subscription._add_mfs(
start_date=relative_start_date, end_date=relative_end_date,
proforma=proforma, invoice=invoice
)

metered_features_amount += self.add_plan_metered_features(
subscription, start_date=relative_start_date, end_date=relative_end_date,
invoice=invoice, proforma=proforma
)
metered_features_now_billed_up_to = relative_end_date

# Obtain a start date for the next iteration (cycle)
Expand Down Expand Up @@ -290,3 +274,20 @@ def _create_document(self, subscription, billing_date):
currency=subscription.plan.currency)

return document

def add_plan_entry(self, subscription, start_date, end_date, invoice=None, proforma=None):
if subscription.on_trial(start_date):
return subscription._add_plan_trial(start_date=start_date, end_date=end_date,
invoice=invoice, proforma=proforma)
else:
return subscription._add_plan_value(start_date=start_date, end_date=end_date,
proforma=proforma, invoice=invoice)

def add_plan_metered_features(self, subscription, start_date, end_date,
invoice=None, proforma=None):
if subscription.on_trial(start_date):
return subscription._add_mfs_for_trial(start_date=start_date, end_date=end_date,
invoice=invoice, proforma=proforma)
else:
return subscription._add_mfs(start_date=start_date, end_date=end_date,
proforma=proforma, invoice=invoice)
7 changes: 2 additions & 5 deletions silver/models/plans.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from django.utils.encoding import python_2_unicode_compatible
from django.utils.translation import ugettext_lazy as _

from silver.utils.dates import INTERVALS as DATE_INTERVALS
from silver.utils.international import currencies
from silver.utils.models import UnsavedForeignKey

Expand All @@ -35,11 +36,7 @@ def get_queryset(self):
class Plan(models.Model):
objects = PlanManager()

class INTERVALS(object):
DAY = 'day'
WEEK = 'week'
MONTH = 'month'
YEAR = 'year'
INTERVALS = DATE_INTERVALS

INTERVAL_CHOICES = Choices(
(INTERVALS.DAY, _('Day')),
Expand Down
59 changes: 28 additions & 31 deletions silver/models/subscriptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@

from silver.models.billing_entities import Customer
from silver.models.documents import DocumentEntry
from silver.utils.dates import ONE_DAY, relativedelta, first_day_of_month
from silver.utils.dates import ONE_DAY, first_day_of_month, first_day_of_interval, end_of_interval
from silver.validators import validate_reference


Expand Down Expand Up @@ -242,16 +242,21 @@ def _get_last_start_date_within_range(self, range_start, range_end,

return aligned_start_date if not dates else dates[-1].date()

def _cycle_start_date(self, reference_date=None, ignore_trial=None, granulate=None):
def _cycle_start_date(self, reference_date=None, ignore_trial=None, granulate=None,
ignore_start_date=None):
ignore_trial_default = False
granulate_default = False
ignore_start_date_default = False

ignore_trial = ignore_trial_default or ignore_trial
granulate = granulate_default or granulate
ignore_start_date = ignore_start_date_default or ignore_start_date

if reference_date is None:
reference_date = timezone.now().date()

start_date = reference_date

if not self.start_date or reference_date < self.start_date:
return None

Expand Down Expand Up @@ -311,16 +316,9 @@ def _cycle_end_date(self, reference_date=None, ignore_trial=None, granulate=None
granulate):
return min(self.trial_end, (self.ended_at or datetime.max.date()))

if self.plan.interval == self.plan.INTERVALS.YEAR:
relative_delta = {'years': self.plan.interval_count}
elif self.plan.interval == self.plan.INTERVALS.MONTH:
relative_delta = {'months': self.plan.interval_count}
elif self.plan.interval == self.plan.INTERVALS.WEEK:
relative_delta = {'weeks': self.plan.interval_count}
else: # plan.INTERVALS.DAY
relative_delta = {'days': self.plan.interval_count}

maximum_cycle_end_date = real_cycle_start_date + relativedelta(**relative_delta) - ONE_DAY
maximum_cycle_end_date = end_of_interval(
real_cycle_start_date, self.plan.interval, self.plan.interval_count
)

# We know that the cycle end_date is the day before the next cycle start_date,
# therefore we check if the cycle start_date for our maximum cycle end_date is the same
Expand Down Expand Up @@ -617,13 +615,6 @@ def _cancel_now(self):
def _cancel_at_end_of_billing_cycle(self):
self.cancel(when=self.CANCEL_OPTIONS.END_OF_BILLING_CYCLE)

def _add_trial_value(self, start_date, end_date, invoice=None,
proforma=None):
self._add_plan_trial(start_date=start_date, end_date=end_date,
invoice=invoice, proforma=proforma)
self._add_mfs_for_trial(start_date=start_date, end_date=end_date,
invoice=invoice, proforma=proforma)

def _get_interval_end_date(self, date=None):
"""
:returns: the end date of the interval that should be billed. The
Expand Down Expand Up @@ -950,28 +941,34 @@ def _get_proration_status_and_percent(self, start_date, end_date):
"""
Returns the proration percent (how much of the interval will be billed)
and the status (if the subscription is prorated or not).
If start_date and end_date are not from the same billing cycle, you are entering
undefined behaviour territory.

:returns: a tuple containing (Decimal(percent), status) where status
can be one of [True, False]. The decimal value will from the
interval [0.00; 1.00].
:rtype: tuple
"""

first_day_of_month = date(start_date.year, start_date.month, 1)
last_day_index = calendar.monthrange(start_date.year,
start_date.month)[1]
last_day_of_month = date(start_date.year, start_date.month,
last_day_index)
cycle_start_date = self._cycle_start_date(
ignore_trial=True,
reference_date=start_date
)

first_day_of_full_interval = first_day_of_interval(cycle_start_date, self.plan.interval)
last_day_of_full_interval = end_of_interval(
first_day_of_full_interval, self.plan.interval, self.plan.interval_count
)

if start_date == first_day_of_month and end_date == last_day_of_month:
if start_date == first_day_of_full_interval and end_date == last_day_of_full_interval:
return False, Decimal('1.0000')
else:
days_in_full_interval = (last_day_of_month - first_day_of_month).days + 1
days_in_interval = (end_date - start_date).days + 1
percent = 1.0 * days_in_interval / days_in_full_interval
percent = Decimal(percent).quantize(Decimal('0.0000'))

return True, percent
full_interval_days = (last_day_of_full_interval - first_day_of_full_interval).days + 1
billing_cycle_days = (end_date - start_date).days + 1
return (
True,
Decimal(1.0 * billing_cycle_days / full_interval_days).quantize(Decimal('.0000'))
)

def _entry_unit(self, context):
unit_template_path = field_template_path(
Expand Down
Loading