From 68c26b09323766596e37eb10e0710a35e8a59f7d Mon Sep 17 00:00:00 2001 From: Bogdan Petrea Date: Thu, 7 Feb 2019 22:38:10 +0200 Subject: [PATCH 1/2] Fix proration percentage for intervals other than month. --- silver/models/plans.py | 7 +- silver/models/subscriptions.py | 52 ++--- silver/tests/commands/test_generate_docs.py | 225 +++++++++++++++++++- silver/utils/dates.py | 49 +++++ 4 files changed, 302 insertions(+), 31 deletions(-) diff --git a/silver/models/plans.py b/silver/models/plans.py index 20db5920..f1c6236c 100644 --- a/silver/models/plans.py +++ b/silver/models/plans.py @@ -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 @@ -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')), diff --git a/silver/models/subscriptions.py b/silver/models/subscriptions.py index 856d91e7..2a4a7441 100644 --- a/silver/models/subscriptions.py +++ b/silver/models/subscriptions.py @@ -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 @@ -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 @@ -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 @@ -950,6 +948,8 @@ 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 @@ -957,21 +957,25 @@ def _get_proration_status_and_percent(self, start_date, end_date): :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 + ) - if start_date == first_day_of_month and end_date == last_day_of_month: + 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_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( diff --git a/silver/tests/commands/test_generate_docs.py b/silver/tests/commands/test_generate_docs.py index 2d91e16c..da2d578e 100644 --- a/silver/tests/commands/test_generate_docs.py +++ b/silver/tests/commands/test_generate_docs.py @@ -30,7 +30,7 @@ MeteredFeatureFactory, MeteredFeatureUnitsLogFactory, CustomerFactory, ProviderFactory) -from silver.utils.dates import ONE_DAY +from silver.utils.dates import ONE_DAY, INTERVALS class TestInvoiceGenerationCommand(TestCase): @@ -201,7 +201,7 @@ def test_gen_for_non_consolidated_billing_with_consumed_units(self): assert entry.quantity == consumed_mfs assert entry.unit_price == metered_feature.price_per_unit - def test_gen_for_non_consolidated_billing_without_consumed_units(self): + def test_gen_for_non_consolidated_monthly_billing_without_consumed_units(self): """ A customer has 3 subscriptions for which he does not have any consumed units => 3 different proformas, each containing only the @@ -1900,6 +1900,227 @@ def test_subscription_with_documents_generation_during_and_after_trial(self): call_command('generate_docs', date=generate_docs_date('2015-02-10'), stdout=self.output) assert Proforma.objects.all().count() == 3 + def test_weekly_subscription_with_documents_generation_during_and_after_trial(self): + separate_cycles_during_trial = True + generate_documents_on_trial_end = False + + metered_feature = MeteredFeatureFactory( + included_units_during_trial=Decimal('5.00'), + price_per_unit=Decimal('1.00') + ) + plan = PlanFactory.create(interval=Plan.INTERVALS.WEEK, + interval_count=1, generate_after=120, + enabled=True, trial_period_days=15, + amount=Decimal('200.00'), + separate_cycles_during_trial=separate_cycles_during_trial, + generate_documents_on_trial_end=generate_documents_on_trial_end, + metered_features=[metered_feature]) + + subscription = SubscriptionFactory.create(plan=plan, start_date=dt.date(2015, 1, 23)) + subscription.activate() + subscription.save() + subscription.customer.sales_tax_percent = None + subscription.customer.save() + + mf_log_first_week = MeteredFeatureUnitsLogFactory.create( + subscription=subscription, metered_feature=metered_feature, + start_date=subscription.start_date, end_date=dt.date(2015, 1, 25), + consumed_units=Decimal('5.00') + ) + + # generate for first week + call_command('generate_docs', date=generate_docs_date('2015-01-23'), stdout=self.output) + + assert Proforma.objects.all().count() == 1 + + proforma = Proforma.objects.all()[0] + assert proforma.total == Decimal('0.00') + + # plan trial for first week (+-) + assert proforma.proforma_entries.count() == 2 + for entry in proforma.proforma_entries.all(): + assert entry.start_date == subscription.start_date + # align to next week start + assert entry.end_date == dt.date(2015, 1, 25) + # only 3 days are prorated + unit_price = Decimal(3 / 7.0).quantize(Decimal('0.0000')) * plan.amount + + if entry.unit_price < 0: + unit_price *= -1 + + assert entry.quantity == 1 + assert entry.unit_price == unit_price + assert entry.prorated + + mf_log_second_week = MeteredFeatureUnitsLogFactory.create( + subscription=subscription, metered_feature=metered_feature, + start_date=dt.date(2015, 1, 26), end_date=dt.date(2015, 2, 1), + consumed_units=Decimal('5.00') + ) + # generate for second week + call_command('generate_docs', date=generate_docs_date('2015-01-27'), stdout=self.output) + + assert Proforma.objects.all().count() == 2 + + proforma = Proforma.objects.all()[1] + assert proforma.total == Decimal('0.00') + + # mfs for first week (+-) + # plan trial for second week (+-) + assert proforma.proforma_entries.count() == 4 + for entry in proforma.proforma_entries.all(): + if entry.product_code == plan.product_code: + # full week plan + assert entry.quantity == 1 + unit_price = plan.amount + assert not entry.prorated + else: + # 3 days from first week metered feature + assert entry.quantity == mf_log_first_week.consumed_units + unit_price = metered_feature.price_per_unit + assert entry.prorated + + if entry.unit_price < 0: # discount + unit_price *= -1 + + assert entry.unit_price == unit_price + + # generate for third week + call_command('generate_docs', date=generate_docs_date('2015-02-03'), stdout=self.output) + assert Proforma.objects.all().count() == 3 + + trial_end = dt.date(2015, 2, 6) + assert trial_end == subscription.trial_end + + proforma = Proforma.objects.all()[2] + paid_plan_amount = Decimal(2 / 7.0).quantize(Decimal('0.0000')) * plan.amount + entries_amount = Decimal(5) * metered_feature.price_per_unit + assert proforma.total == paid_plan_amount + entries_amount + + # mfs for second week (+) (no discount because included trial units were consumed) + # plan trial for third week (+-) + # remaining plan + assert proforma.proforma_entries.count() == 4 + + for entry in proforma.proforma_entries.all(): + if entry.product_code == plan.product_code: + assert entry.quantity == 1 + + if entry.end_date == trial_end: + unit_price = plan.amount - paid_plan_amount + else: + unit_price = paid_plan_amount + + if entry.unit_price < 0: # discount + unit_price *= -1 + + assert entry.prorated is True + else: + assert entry.quantity == mf_log_second_week.consumed_units + unit_price = metered_feature.price_per_unit + assert entry.prorated is False + assert entry.unit_price == unit_price + + # no proforma is created if trying to generate for the same billing cycle + call_command('generate_docs', date=generate_docs_date('2015-02-08'), stdout=self.output) + assert Proforma.objects.all().count() == 3 + + def test_anual_subscription_with_documents_generation_during_and_after_trial(self): + separate_cycles_during_trial = True + generate_documents_on_trial_end = True + + metered_feature = MeteredFeatureFactory( + included_units_during_trial=Decimal('5.00'), + price_per_unit=Decimal('1.00') + ) + plan = PlanFactory.create(interval=Plan.INTERVALS.YEAR, + interval_count=1, generate_after=120, + enabled=True, trial_period_days=30, + amount=Decimal('200.00'), + separate_cycles_during_trial=separate_cycles_during_trial, + generate_documents_on_trial_end=generate_documents_on_trial_end, + metered_features=[metered_feature]) + + subscription = SubscriptionFactory.create(plan=plan, start_date=dt.date(2015, 1, 23)) + subscription.activate() + subscription.save() + subscription.customer.sales_tax_percent = None + subscription.customer.save() + + # create some metered feature logs that are not perfectly aligned to billing cycle but are + # still within the cycle limits + + # covered by trial units + first_mf_log = MeteredFeatureUnitsLogFactory.create( + subscription=subscription, metered_feature=metered_feature, + start_date=subscription.start_date, end_date=dt.date(2015, 1, 29), + consumed_units=Decimal('5.00') + ) + + # extra consumed units, not covered by trial + second_mf_log = MeteredFeatureUnitsLogFactory.create( + subscription=subscription, metered_feature=metered_feature, + start_date=dt.date(2015, 2, 2), end_date=dt.date(2015, 2, 19), + consumed_units=Decimal('5.00') + ) + + # generate for first year + call_command('generate_docs', date=generate_docs_date('2015-01-23'), stdout=self.output) + + assert Proforma.objects.all().count() == 1 + + proforma = Proforma.objects.all()[0] + assert proforma.total == Decimal('0.00') + + # plan trial for first 30 days (+-) + assert proforma.proforma_entries.count() == 2 + for entry in proforma.proforma_entries.all(): + assert entry.start_date == subscription.start_date + assert entry.end_date == dt.date(2015, 2, 21) == subscription.trial_end + # only 30 days of trial out of 365 are prorated + unit_price = Decimal(30 / 365.0).quantize(Decimal('0.0000')) * plan.amount + + if entry.unit_price < 0: + unit_price *= -1 + + assert entry.quantity == 1 + assert entry.unit_price == unit_price + assert entry.prorated + + # generate for the remaining year + call_command('generate_docs', date=generate_docs_date('2015-02-22'), stdout=self.output) + + assert Proforma.objects.all().count() == 2 + + proforma = Proforma.objects.all()[1] + + # (365 days - 30 trial days - subscription start_date offset) / 365 days + plan_amount = Decimal(313 / 365.0).quantize(Decimal('0.0000')) * plan.amount + # 5 units consumed from second_mf_log + entries_amount = Decimal(5) * metered_feature.price_per_unit + assert proforma.total == plan_amount + entries_amount + + # first_mf_log during trial (+-) + # second_mf_log during trial (+) + # plan prepay for remaining year (+) + assert proforma.proforma_entries.count() == 4 + for entry in proforma.proforma_entries.all(): + if entry.product_code == plan.product_code: + assert entry.quantity == 1 + unit_price = plan_amount + assert entry.prorated + else: + assert entry.quantity == 5 + unit_price = metered_feature.price_per_unit + # since the mf logs have intervals that don't match the trial cycle perfectly + # they appear as prorated, but it doesn't mean much anyway + assert entry.prorated + + if entry.unit_price < 0: # discount + unit_price *= -1 + + assert entry.unit_price == unit_price + def test_subscription_cycle_billing_duration(self): plan = PlanFactory.create(interval=Plan.INTERVALS.MONTH, interval_count=1, generate_after=120, diff --git a/silver/utils/dates.py b/silver/utils/dates.py index bc69cded..29bdbb0b 100644 --- a/silver/utils/dates.py +++ b/silver/utils/dates.py @@ -22,10 +22,51 @@ ONE_MONTH = relativedelta(months=1) +class INTERVALS(object): + DAY = 'day' + WEEK = 'week' + MONTH = 'month' + YEAR = 'year' + + def next_month(date): return date + ONE_MONTH +def first_day_of_interval(date, interval): + if interval == INTERVALS.DAY: + return date + elif interval == INTERVALS.WEEK: + return first_day_of_week(date) + elif interval == INTERVALS.MONTH: + return first_day_of_month(date) + elif interval == INTERVALS.YEAR: + return first_day_of_year(date) + + +def end_of_interval(start_date, interval, interval_count): + if interval == INTERVALS.YEAR: + relative_delta = {'years': interval_count} + elif interval == INTERVALS.MONTH: + relative_delta = {'months': interval_count} + elif interval == INTERVALS.WEEK: + relative_delta = {'weeks': interval_count} + elif interval == INTERVALS.DAY: + relative_delta = {'days': interval_count} + else: + return None + + return start_date + relativedelta(**relative_delta) - ONE_DAY + + +def first_day_of_week(date): + return date + relativedelta(weekday=MO(-1)) + + +def last_day_of_week(date): + return date + relativedelta(weekday=SU(-1)) + + def first_day_of_month(date): return date + relativedelta(day=1) @@ -34,5 +75,13 @@ def last_day_of_month(date): return date + relativedelta(day=31) +def first_day_of_year(date): + return date + relativedelta(month=1, day=1) + + +def last_day_of_year(date): + return date + relativedelta(month=12, day=31) + + def prev_month(date): return date - ONE_MONTH From 33249b55de20e166831f20aed0c9029e614f5cd3 Mon Sep 17 00:00:00 2001 From: Bogdan Petrea Date: Mon, 11 Feb 2019 22:01:09 +0200 Subject: [PATCH 2/2] Small improvements to DocumentGenerator. - Wrap document generation in an atomic transaction. - Return the generated documents. --- silver/documents_generator.py | 115 +++++++++++++++++---------------- silver/models/subscriptions.py | 7 -- 2 files changed, 58 insertions(+), 64 deletions(-) diff --git a/silver/documents_generator.py b/silver/documents_generator.py index 996f14ab..8b3b4123 100644 --- a/silver/documents_generator.py +++ b/silver/documents_generator.py @@ -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 @@ -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. @@ -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 @@ -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): """ @@ -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 ) @@ -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 @@ -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): @@ -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): @@ -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, @@ -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 @@ -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 @@ -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) @@ -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) diff --git a/silver/models/subscriptions.py b/silver/models/subscriptions.py index 2a4a7441..edfaa9fa 100644 --- a/silver/models/subscriptions.py +++ b/silver/models/subscriptions.py @@ -615,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