Skip to content

Commit

Permalink
Add option for separate entries for bonuses
Browse files Browse the repository at this point in the history
  • Loading branch information
bogdanpetrea committed Apr 3, 2023
1 parent 09383c5 commit 7da6ca1
Show file tree
Hide file tree
Showing 6 changed files with 174 additions and 42 deletions.
18 changes: 18 additions & 0 deletions silver/migrations/0060_auto_20230330_0858.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 3.1.14 on 2023-03-30 08:58

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('silver', '0059_auto_20230307_0939'),
]

operations = [
migrations.AddField(
model_name='bonus',
name='document_entry_behavior',
field=models.CharField(choices=[('apply_directly_to_target', 'Apply directly to target entries'), ('apply_separately_per_entry', 'Apply as separate entry, per entry')], default='apply_separately_per_entry', help_text='Defines how the discount will be shown in the billing documents.', max_length=32),
),
]
30 changes: 28 additions & 2 deletions silver/models/bonuses.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,16 @@ class BonusTarget(models.TextChoices):
METERED_FEATURES_UNITS = "metered_features_units"


class DocumentEntryBehavior(models.TextChoices):
APPLY_DIRECTLY_TO_TARGET_ENTRIES = "apply_directly_to_target", "Apply directly to target entries"
APPLY_AS_SEPARATE_ENTRY_PER_ENTRY = "apply_separately_per_entry", "Apply as separate entry, per entry"


class Bonus(AutoCleanModelMixin, models.Model):
STATES = BonusState
TARGET = BonusTarget
DURATION_INTERVALS = DurationIntervals
ENTRY_BEHAVIOR = DocumentEntryBehavior

name = models.CharField(
max_length=200,
Expand Down Expand Up @@ -63,6 +69,11 @@ class Bonus(AutoCleanModelMixin, models.Model):
help_text="Defines what the bonus applies to.",
default=TARGET.METERED_FEATURES_UNITS)

document_entry_behavior = models.CharField(choices=ENTRY_BEHAVIOR.choices,
max_length=32, default=ENTRY_BEHAVIOR.APPLY_AS_SEPARATE_ENTRY_PER_ENTRY,
help_text="Defines how the discount will be shown in the billing "
"documents.")

state = models.CharField(choices=STATES.choices, max_length=16, default=STATES.ACTIVE,
help_text="Can be used to easily toggle bonuses on or off.")

Expand Down Expand Up @@ -125,7 +136,7 @@ def for_subscription(cls, subscription: "silver.models.Subscription"):
Q(filter_subscriptions=subscription) | Q(filter_subscriptions=None),
Q(filter_plans=subscription.plan) | Q(filter_plans=None),
Q(filter_product_codes=subscription.plan.product_code) | Q(filter_product_codes=None),
)
).annotate(_filtered_product_codes=F("filter_product_codes"))

def is_active_for_subscription(self, subscription):
if not subscription.state == subscription.STATES.ACTIVE:
Expand Down Expand Up @@ -200,11 +211,26 @@ def matching_subscriptions(self):

return subscriptions

def matches_metered_feature_units(self, metered_feature, annotations) -> bool:
if hasattr(self, "_filtered_product_codes"):
if self._filtered_product_codes and metered_feature.product_code not in self._filtered_product_codes:
return False

if self.filter_annotations:
if not set(self.filter_annotations).intersection(set(annotations)):
return False

return True

@property
def amount_description(self) -> str:
bonus = []
amount = bonus.amount or f"{bonus.amount_percentage}%"
amount = self.amount or f"{self.amount_percentage}%"

if self.applies_to in [self.TARGET.METERED_FEATURES_UNITS]:
bonus.append(f"{amount} off Metered Features")

return ", ".join(bonus)

def __str__(self) -> str:
return self.name
148 changes: 112 additions & 36 deletions silver/models/subscriptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from __future__ import absolute_import, unicode_literals

import logging
from dataclasses import dataclass

from datetime import datetime, timedelta
from decimal import Decimal
Expand Down Expand Up @@ -119,6 +120,14 @@ def __str__(self):
return self.metered_feature.name


@dataclass
class OverageInfo:
extra_consumed_units: Decimal
annotations: List[str]
directly_applied_bonuses: List["silver.models.Bonus"]
separately_applied_bonuses: List["silver.models.Bonus"]


class Subscription(models.Model):
class STATES(object):
ACTIVE = 'active'
Expand Down Expand Up @@ -872,7 +881,9 @@ def _get_consumed_units_from_total_included_in_trial(self, metered_feature, star

# _, extra_proration_fraction = self._get_proration_status_and_fraction_during_trial(start_date, end_date)
#
# included_units_during_trial = quantize_fraction(Fraction(str(metered_feature.included_units_during_trial)) * extra_proration_fraction)
# included_units_during_trial = quantize_fraction(
# Fraction(str(metered_feature.included_units_during_trial)) * extra_proration_fraction
# )

included_units_during_trial = metered_feature.included_units_during_trial

Expand Down Expand Up @@ -1091,38 +1102,63 @@ def _add_plan_entries(self, start_date, end_date, invoice=None, proforma=None) \

return entries[0].total, entries

def _get_consumed_units(self, metered_feature, extra_proration_fraction: Fraction,
start_datetime, end_datetime, bonuses=None):
def _included_units_from_bonuses(
self, metered_feature, start_date, end_date, extra_proration_fraction: Fraction, bonuses: List
):
included_units = extra_proration_fraction * Fraction(metered_feature.included_units or Decimal(0))

return sum(
[
(
Fraction(str(bonus.amount)) if bonus.amount else
Fraction(str(bonus.amount_percentage)) / 100 * included_units
) * bonus.extra_proration_fraction(self, start_date, end_date, OriginType.MeteredFeature)[0]
for bonus in bonuses
]
)

def _get_extra_consumed_units(self, metered_feature, extra_proration_fraction: Fraction,
start_datetime, end_datetime, bonuses=None) -> OverageInfo:
included_units = extra_proration_fraction * Fraction(metered_feature.included_units or Decimal(0))

log_entries = self.mf_log_entries.filter(
metered_feature=metered_feature,
start_datetime__gte=start_datetime,
end_datetime__lte=end_datetime
)

consumed_units = [entry.consumed_units for entry in log_entries]
total_consumed_units = reduce(lambda x, y: x + y, consumed_units, 0)

annotations = list({log_entry.annotation for log_entry in log_entries})

start_date = start_datetime.date()
end_date = end_datetime.date()

if bonuses:
included_from_bonuses = sum(
[
(
Fraction(str(bonus.amount)) if bonus.amount else
Fraction(str(bonus.amount_percentage)) / 100 * included_units
) * bonus.extra_proration_fraction(self, start_date, end_date, OriginType.MeteredFeature)[0]
for bonus in bonuses
]
)
bonuses = [bonus for bonus in bonuses if bonus.matches_metered_feature_units(metered_feature, annotations)]

included_units += included_from_bonuses
applied_directly_bonuses = [
bonus for bonus in bonuses
if bonus.document_entry_behavior == bonus.ENTRY_BEHAVIOR.APPLY_DIRECTLY_TO_TARGET_ENTRIES
]

included_units = quantize_fraction(included_units)
applied_separately_bonuses = [
bonus for bonus in bonuses
if bonus.document_entry_behavior == bonus.ENTRY_BEHAVIOR.APPLY_AS_SEPARATE_ENTRY_PER_ENTRY
]

included_units += self._included_units_from_bonuses(
metered_feature, start_date, end_date, extra_proration_fraction, applied_directly_bonuses
)

qs = self.mf_log_entries.filter(metered_feature=metered_feature,
start_datetime__gte=start_datetime,
end_datetime__lte=end_datetime)
log = [qs_item.consumed_units for qs_item in qs]
total_consumed_units = reduce(lambda x, y: x + y, log, 0)
included_units = quantize_fraction(included_units)

if total_consumed_units > included_units:
return total_consumed_units - included_units
extra_consumed_units = max(total_consumed_units - included_units, Decimal(0))

return 0
return OverageInfo(
extra_consumed_units, annotations, applied_directly_bonuses, applied_separately_bonuses
)

def _add_mfs_entries(self, start_date, end_date, invoice=None, proforma=None, bonuses=None) \
-> Tuple[Decimal, List['silver.models.DocumentEntry']]:
Expand All @@ -1140,46 +1176,86 @@ def _add_mfs_entries(self, start_date, end_date, invoice=None, proforma=None, bo

prorated, fraction = self._get_proration_status_and_fraction(start_date, end_date, OriginType.MeteredFeature)

context = self._build_entry_context({
'name': self.plan.name,
'unit': self.plan.metered_features_interval,
'product_code': self.plan.product_code,
base_context = self._build_entry_context({
'start_date': start_date,
'end_date': end_date,
'prorated': prorated,
'proration_percentage': quantize_fraction(fraction),
'bonuses': bonuses,
'context': 'metered-feature'
})

mfs_total = Decimal('0.00')
entries = []
for metered_feature in self.plan.metered_features.all():
consumed_units = self._get_consumed_units(
overage_info = self._get_extra_consumed_units(
metered_feature, fraction, start_datetime, end_datetime, bonuses=bonuses
)
extra_consumed_units = overage_info.extra_consumed_units

context.update({
entry_context = base_context.copy()
entry_context.update({
'metered_feature': metered_feature,
'unit': metered_feature.unit,
'name': metered_feature.name,
'product_code': metered_feature.product_code
'product_code': metered_feature.product_code,
'annotations': overage_info.annotations,
'directly_applied_bonuses': overage_info.directly_applied_bonuses,
})

description = self._entry_description(context)
unit = self._entry_unit(context)
description = self._entry_description(entry_context)
unit = self._entry_unit(entry_context)

de = DocumentEntry.objects.create(
entry = DocumentEntry.objects.create(
invoice=invoice, proforma=proforma,
description=description, unit=unit,
quantity=consumed_units, prorated=prorated,
quantity=overage_info.extra_consumed_units, prorated=prorated,
unit_price=metered_feature.price_per_unit,
product_code=metered_feature.product_code,
start_date=start_date, end_date=end_date
)
entries.append(de)
entries.append(entry)

for separate_bonus in overage_info.separately_applied_bonuses:
if extra_consumed_units <= 0:
break

bonus_included_units = quantize_fraction(
self._included_units_from_bonuses(
metered_feature, start_date, end_date,
extra_proration_fraction=fraction, bonuses=[separate_bonus]
)
)
if not bonus_included_units:
continue

bonus_consumed_units = min(bonus_included_units, extra_consumed_units)
extra_consumed_units -= bonus_consumed_units

bonus_entry_context = base_context.copy()
bonus_entry_context.update({
'metered_feature': metered_feature,
'unit': metered_feature.unit,
'name': metered_feature.name,
'product_code': metered_feature.product_code,
'annotations': overage_info.annotations,
'directly_applied_bonuses': overage_info.directly_applied_bonuses,
'context': 'metered-feature-bonus'
})

description = self._entry_description(bonus_entry_context)

bonus_entry = DocumentEntry.objects.create(
invoice=invoice, proforma=proforma,
description=description, unit=unit,
quantity=bonus_consumed_units, prorated=prorated,
unit_price=-metered_feature.price_per_unit,
product_code=separate_bonus.product_code,
start_date=start_date, end_date=end_date
)
entries.append(bonus_entry)
mfs_total += bonus_entry.total

mfs_total += de.total
mfs_total += entry.total

return mfs_total, entries

Expand Down
14 changes: 10 additions & 4 deletions silver/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from django.utils import timezone

from silver.documents_generator import DocumentsGenerator
from silver.models import Invoice, Proforma, Transaction, BillingDocumentBase
from silver.models import Invoice, Proforma, Transaction, BillingDocumentBase, Customer
from silver.payment_processors.mixins import PaymentProcessorTypes
from silver.vendors.redis_server import redis

Expand Down Expand Up @@ -55,13 +55,19 @@ def generate_pdfs():
60 * 60) # default 60m


@shared_task(base=QueueOnce, once={'graceful': True},
@shared_task(base=QueueOnce, once={'graceful': True, 'keys': ['billing_date']},
time_limit=DOCS_GENERATION_TIME_LIMIT, ignore_result=True)
def generate_billing_documents(billing_date=None):
def generate_billing_documents(billing_date=None, customers_ids=None):
if not billing_date:
billing_date = timezone.now().date()

DocumentsGenerator().generate(billing_date=billing_date)
generate_kwargs = {
'billing_date': billing_date,
}
if customers_ids:
generate_kwargs['customers'] = Customer.objects.filter(id__in=customers_ids)

DocumentsGenerator().generate(**generate_kwargs)


FETCH_TRANSACTION_STATUS_TIME_LIMIT = getattr(settings, 'FETCH_TRANSACTION_STATUS_TIME_LIMIT',
Expand Down
3 changes: 3 additions & 0 deletions silver/templates/billing_documents/entry_description.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
{% elif context == 'metered-feature' %}
Extra {{ name }} ({{ start_date }} - {{ end_date }}).

{% elif context == 'metered-feature-bonus' %}
Discount from {{ name }} Bonus ({{ start_date }} - {{ end_date }}).

{% elif context == 'metered-feature-trial' %}
{{ name }} ({{ start_date }} - {{ end_date }}).

Expand Down
3 changes: 3 additions & 0 deletions silver/templates/billing_documents/entry_unit.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
{% elif context == 'metered-feature' %}
{{ unit }}

{% elif context == 'metered-feature-bonus' %}
{{ unit }}

{% elif context == 'metered-feature-trial' %}
{{ unit }}

Expand Down

0 comments on commit 7da6ca1

Please sign in to comment.