wbcommission 2.2.1__py2.py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of wbcommission might be problematic. Click here for more details.
- wbcommission/__init__.py +1 -0
- wbcommission/admin/__init__.py +4 -0
- wbcommission/admin/accounts.py +22 -0
- wbcommission/admin/commission.py +85 -0
- wbcommission/admin/rebate.py +7 -0
- wbcommission/analytics/__init__.py +0 -0
- wbcommission/analytics/marginality.py +181 -0
- wbcommission/apps.py +5 -0
- wbcommission/dynamic_preferences_registry.py +0 -0
- wbcommission/factories/__init__.py +9 -0
- wbcommission/factories/commission.py +100 -0
- wbcommission/factories/rebate.py +16 -0
- wbcommission/filters/__init__.py +7 -0
- wbcommission/filters/rebate.py +187 -0
- wbcommission/filters/signals.py +44 -0
- wbcommission/generators/__init__.py +2 -0
- wbcommission/generators/rebate_generator.py +93 -0
- wbcommission/migrations/0001_initial.py +299 -0
- wbcommission/migrations/0002_commissionrule_remove_accountcustomer_account_and_more.py +395 -0
- wbcommission/migrations/0003_alter_commission_account.py +24 -0
- wbcommission/migrations/0004_rebate_audit_log.py +19 -0
- wbcommission/migrations/0005_alter_rebate_audit_log.py +20 -0
- wbcommission/migrations/0006_commissionrule_consider_zero_percent_for_exclusion.py +21 -0
- wbcommission/migrations/0007_remove_commission_unique_crm_recipient_account_and_more.py +50 -0
- wbcommission/migrations/0008_alter_commission_options_alter_commission_order.py +26 -0
- wbcommission/migrations/__init__.py +0 -0
- wbcommission/models/__init__.py +9 -0
- wbcommission/models/account_service.py +217 -0
- wbcommission/models/commission.py +679 -0
- wbcommission/models/rebate.py +319 -0
- wbcommission/models/signals.py +45 -0
- wbcommission/permissions.py +6 -0
- wbcommission/reports/__init__.py +0 -0
- wbcommission/reports/audit_report.py +51 -0
- wbcommission/reports/customer_report.py +299 -0
- wbcommission/reports/utils.py +30 -0
- wbcommission/serializers/__init__.py +3 -0
- wbcommission/serializers/commissions.py +26 -0
- wbcommission/serializers/rebate.py +87 -0
- wbcommission/serializers/signals.py +27 -0
- wbcommission/tests/__init__.py +0 -0
- wbcommission/tests/analytics/__init__.py +0 -0
- wbcommission/tests/analytics/test_marginality.py +253 -0
- wbcommission/tests/conftest.py +89 -0
- wbcommission/tests/models/__init__.py +0 -0
- wbcommission/tests/models/mixins.py +22 -0
- wbcommission/tests/models/test_account_service.py +293 -0
- wbcommission/tests/models/test_commission.py +587 -0
- wbcommission/tests/models/test_rebate.py +136 -0
- wbcommission/tests/signals.py +0 -0
- wbcommission/tests/test_permissions.py +66 -0
- wbcommission/tests/viewsets/__init__.py +0 -0
- wbcommission/tests/viewsets/test_rebate.py +76 -0
- wbcommission/urls.py +42 -0
- wbcommission/viewsets/__init__.py +7 -0
- wbcommission/viewsets/buttons/__init__.py +2 -0
- wbcommission/viewsets/buttons/rebate.py +46 -0
- wbcommission/viewsets/buttons/signals.py +53 -0
- wbcommission/viewsets/commissions.py +21 -0
- wbcommission/viewsets/display/__init__.py +5 -0
- wbcommission/viewsets/display/commissions.py +21 -0
- wbcommission/viewsets/display/rebate.py +117 -0
- wbcommission/viewsets/endpoints/__init__.py +4 -0
- wbcommission/viewsets/endpoints/commissions.py +0 -0
- wbcommission/viewsets/endpoints/rebate.py +21 -0
- wbcommission/viewsets/menu/__init__.py +1 -0
- wbcommission/viewsets/menu/commissions.py +0 -0
- wbcommission/viewsets/menu/rebate.py +13 -0
- wbcommission/viewsets/mixins.py +39 -0
- wbcommission/viewsets/rebate.py +481 -0
- wbcommission/viewsets/titles/__init__.py +1 -0
- wbcommission/viewsets/titles/commissions.py +0 -0
- wbcommission/viewsets/titles/rebate.py +11 -0
- wbcommission-2.2.1.dist-info/METADATA +11 -0
- wbcommission-2.2.1.dist-info/RECORD +76 -0
- wbcommission-2.2.1.dist-info/WHEEL +5 -0
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from contextlib import suppress
|
|
3
|
+
from datetime import date
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
from typing import TYPE_CHECKING, Generator, Optional
|
|
6
|
+
|
|
7
|
+
from django.contrib.postgres.constraints import ExclusionConstraint
|
|
8
|
+
from django.contrib.postgres.fields import (
|
|
9
|
+
DateRangeField,
|
|
10
|
+
DecimalRangeField,
|
|
11
|
+
RangeOperators,
|
|
12
|
+
)
|
|
13
|
+
from django.db import models
|
|
14
|
+
from django.db.models import Exists, OuterRef, Q, QuerySet, Sum, UniqueConstraint
|
|
15
|
+
from django.db.models.signals import post_save
|
|
16
|
+
from django.dispatch import receiver
|
|
17
|
+
from ordered_model.models import OrderedModel, OrderedModelManager, OrderedModelQuerySet
|
|
18
|
+
from psycopg.types.range import DateRange, NumericRange
|
|
19
|
+
from tqdm import tqdm
|
|
20
|
+
from wbcore.contrib.directory.models import Entry
|
|
21
|
+
from wbcore.models import WBModel
|
|
22
|
+
from wbcore.signals import pre_merge
|
|
23
|
+
from wbcore.utils.models import ComplexToStringMixin
|
|
24
|
+
from wbcrm.models.accounts import (
|
|
25
|
+
Account,
|
|
26
|
+
AccountRole,
|
|
27
|
+
AccountRoleType,
|
|
28
|
+
AccountRoleValidity,
|
|
29
|
+
)
|
|
30
|
+
from wbportfolio.models import PortfolioRole
|
|
31
|
+
|
|
32
|
+
from .account_service import AccountRebateManager
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from django.db.models.manager import RelatedManager
|
|
36
|
+
|
|
37
|
+
from wbcore.contrib.authentication.models import User
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CommissionDefaultQueryset(OrderedModelQuerySet):
|
|
41
|
+
def filter_for_user(self, user: User, validity_date: date | None = None) -> QuerySet:
|
|
42
|
+
"""
|
|
43
|
+
Protect the chained queryset and filter the commission this user cannot see based on the following rules:
|
|
44
|
+
|
|
45
|
+
* not-hidden commission: Ever user with a direct valid account role on the commission's account
|
|
46
|
+
* hidden: only user with a direct commission role on that commission line
|
|
47
|
+
* in any case, all user with direct commission role or recipient of the commission
|
|
48
|
+
"""
|
|
49
|
+
if not validity_date:
|
|
50
|
+
validity_date = date.today()
|
|
51
|
+
if user.has_perm("wbcommission.administrate_commission"):
|
|
52
|
+
return self
|
|
53
|
+
valid_accounts = Account.objects.filter_for_user(user, validity_date=validity_date, strict=True)
|
|
54
|
+
return self.annotate(
|
|
55
|
+
can_see_account=Exists(valid_accounts.filter(id=OuterRef("account"))),
|
|
56
|
+
has_direct_commission_role=Exists(
|
|
57
|
+
CommissionRole.objects.filter(person=user.profile, commission=OuterRef("pk"))
|
|
58
|
+
),
|
|
59
|
+
).filter(
|
|
60
|
+
(Q(is_hidden=False) & Q(can_see_account=True))
|
|
61
|
+
| Q(has_direct_commission_role=True)
|
|
62
|
+
| Q(crm_recipient=user.profile.entry_ptr)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class CommissionManager(OrderedModelManager):
|
|
67
|
+
def get_queryset(self) -> CommissionDefaultQueryset:
|
|
68
|
+
return CommissionDefaultQueryset(self.model)
|
|
69
|
+
|
|
70
|
+
def filter_for_user(self, user: User, validity_date: date | None = None) -> QuerySet:
|
|
71
|
+
return self.get_queryset().filter_for_user(user, validity_date=validity_date)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class CommissionType(WBModel):
|
|
75
|
+
id: Optional[int]
|
|
76
|
+
name = models.CharField(max_length=256, verbose_name="Name")
|
|
77
|
+
key = models.CharField(max_length=256, unique=True)
|
|
78
|
+
|
|
79
|
+
def save(self, *args, **kwargs):
|
|
80
|
+
if not self.key:
|
|
81
|
+
self.key = self.name.lower()
|
|
82
|
+
super().save(*args, **kwargs)
|
|
83
|
+
|
|
84
|
+
def get_valid_commissions(
|
|
85
|
+
self,
|
|
86
|
+
terminal_account: "Account",
|
|
87
|
+
compute_date: date,
|
|
88
|
+
content_object: models.Model,
|
|
89
|
+
root_account_total_holding: Decimal,
|
|
90
|
+
) -> Generator[tuple["Commission", Decimal], None, None]:
|
|
91
|
+
"""
|
|
92
|
+
Retrieve valid commissions for the given terminal account and parameters.
|
|
93
|
+
|
|
94
|
+
This function filters and retrieves valid commissions for a specific terminal account
|
|
95
|
+
based on the provided parameters. It iterates through the applicable commissions,
|
|
96
|
+
validates them, and yields tuples of valid commissions along with their calculated percentages.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
terminal_account (Account): The terminal account for which to retrieve valid commissions.
|
|
100
|
+
compute_date (date): The date for which to compute the rebates.
|
|
101
|
+
content_object (models.Model): The content object for which to compute rebates.
|
|
102
|
+
root_account_total_holding (Decimal): The total holding of the root account.
|
|
103
|
+
|
|
104
|
+
Yields:
|
|
105
|
+
tuple: A tuple containing a valid Commission instance and its corresponding actual percentage.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
applicable_commissions = Commission.objects.filter(
|
|
109
|
+
account__in=terminal_account.get_ancestors(include_self=True).filter(is_active=True), commission_type=self
|
|
110
|
+
).order_by("order")
|
|
111
|
+
available_percent = Decimal(1)
|
|
112
|
+
for order in applicable_commissions.values("order").distinct("order"):
|
|
113
|
+
# in case there is duplicates, we get the lower commissison in the account tree
|
|
114
|
+
commission = applicable_commissions.filter(order=order["order"]).order_by("-account__level").first()
|
|
115
|
+
if commission.is_valid(compute_date, content_object, root_account_total_holding):
|
|
116
|
+
actual_percent = (
|
|
117
|
+
available_percent * commission.validated_percent
|
|
118
|
+
if commission.validated_net_commission
|
|
119
|
+
else commission.validated_percent
|
|
120
|
+
)
|
|
121
|
+
actual_percent = max(min(available_percent, actual_percent), Decimal(0))
|
|
122
|
+
available_percent -= actual_percent
|
|
123
|
+
if actual_percent > 0:
|
|
124
|
+
yield commission, actual_percent
|
|
125
|
+
|
|
126
|
+
def compute_rebates(
|
|
127
|
+
self, root_account: "Account", verbose: bool = False, **iterator_kwargs
|
|
128
|
+
) -> Generator[tuple["Account", date, "Commission", models.Model, "Entry", Decimal], None, None]:
|
|
129
|
+
"""
|
|
130
|
+
Generate rebate information for terminal accounts based on given parameters.
|
|
131
|
+
|
|
132
|
+
This method calculates and yields rebate information for terminal accounts based on the provided
|
|
133
|
+
root_account, and iterator_kwargs.
|
|
134
|
+
|
|
135
|
+
It iterates through active terminal accounts,
|
|
136
|
+
computes rebates, and returns the results as a generator of tuples containing terminal account,
|
|
137
|
+
compute date, commission, content object, recipient entry, and rebate amount.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
root_account (Account): The root account for rebate calculations.
|
|
141
|
+
verbose (bool): debugging option. Default to false.
|
|
142
|
+
**iterator_kwargs: Additional keyword arguments for filtering.
|
|
143
|
+
|
|
144
|
+
Yields:
|
|
145
|
+
Generator[tuple]: A generator yielding rebate information tuples with components:
|
|
146
|
+
- Terminal account (Account)
|
|
147
|
+
- Compute date (date)
|
|
148
|
+
- Commission (Commission)
|
|
149
|
+
- Content object (models.Model)
|
|
150
|
+
- Recipient entry (Entry)
|
|
151
|
+
- Rebate amount (Decimal)
|
|
152
|
+
|
|
153
|
+
"""
|
|
154
|
+
rebate_manager = AccountRebateManager(
|
|
155
|
+
root_account, self.key
|
|
156
|
+
) # This will be loaded dynamically in a future iteration
|
|
157
|
+
rebate_manager.initialize()
|
|
158
|
+
# Iterate through active terminal accounts
|
|
159
|
+
iterator = rebate_manager.get_iterator(**iterator_kwargs)
|
|
160
|
+
if verbose:
|
|
161
|
+
iterator = list(iterator)
|
|
162
|
+
iterator = tqdm(iterator, total=len(iterator))
|
|
163
|
+
|
|
164
|
+
for terminal_account, content_object, compute_date in iterator:
|
|
165
|
+
root_account_total_holding = rebate_manager.get_root_account_total_holding(compute_date)
|
|
166
|
+
terminal_account_holding_ratio = rebate_manager.get_terminal_account_holding_ratio(
|
|
167
|
+
terminal_account,
|
|
168
|
+
content_object,
|
|
169
|
+
compute_date,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Calculate rebates for the current terminal account
|
|
173
|
+
if terminal_account_holding_ratio:
|
|
174
|
+
for commission, actual_percent in self.get_valid_commissions(
|
|
175
|
+
terminal_account, compute_date, content_object, root_account_total_holding
|
|
176
|
+
):
|
|
177
|
+
# total commission pool for the given object that day
|
|
178
|
+
commission_pool = rebate_manager.get_commission_pool(content_object, compute_date)
|
|
179
|
+
commission_pool_sign = math.copysign(
|
|
180
|
+
1, commission_pool
|
|
181
|
+
) # in case the pool is negative, the gain is a credit
|
|
182
|
+
commission_pool = abs(commission_pool)
|
|
183
|
+
account_gain = (
|
|
184
|
+
terminal_account_holding_ratio * commission_pool
|
|
185
|
+
) # total account gain that account can get from the total commission pool for that day
|
|
186
|
+
if account_gain == 0:
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
leftover_fees = account_gain
|
|
190
|
+
|
|
191
|
+
# Iterate through recipients and calculate rebates for each
|
|
192
|
+
for recipient, weighting in commission.get_recipients(
|
|
193
|
+
terminal_account, content_object, compute_date
|
|
194
|
+
):
|
|
195
|
+
recipient_percent = actual_percent * weighting
|
|
196
|
+
recipient_fees = max(min(leftover_fees, account_gain * recipient_percent), Decimal(0))
|
|
197
|
+
leftover_fees -= recipient_fees
|
|
198
|
+
|
|
199
|
+
rebate_gain = Decimal(
|
|
200
|
+
math.copysign(recipient_fees, commission_pool_sign)
|
|
201
|
+
) # If fees are negative, then we need to return the negative of the recipient fees
|
|
202
|
+
|
|
203
|
+
# Yield rebate information
|
|
204
|
+
yield terminal_account, compute_date, commission, content_object, recipient, rebate_gain, {
|
|
205
|
+
"terminal_account_holding_ratio": terminal_account_holding_ratio,
|
|
206
|
+
"root_account_total_holding": root_account_total_holding,
|
|
207
|
+
"commission_percent": actual_percent,
|
|
208
|
+
"commission_pool": commission_pool,
|
|
209
|
+
"recipient_percent": recipient_percent,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
def __str__(self) -> str:
|
|
213
|
+
return self.name
|
|
214
|
+
|
|
215
|
+
@classmethod
|
|
216
|
+
def get_endpoint_basename(cls) -> str:
|
|
217
|
+
return "wbcommission:commissiontype"
|
|
218
|
+
|
|
219
|
+
@classmethod
|
|
220
|
+
def get_representation_endpoint(cls) -> str:
|
|
221
|
+
return "wbcommission:commissiontyperepresentation-list"
|
|
222
|
+
|
|
223
|
+
@classmethod
|
|
224
|
+
def get_representation_value_key(cls) -> str:
|
|
225
|
+
return "id"
|
|
226
|
+
|
|
227
|
+
@classmethod
|
|
228
|
+
def get_representation_label_key(cls) -> str:
|
|
229
|
+
return "{{name}}"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class Commission(OrderedModel, WBModel):
|
|
233
|
+
"""Holds all the information on how fees are distributed"""
|
|
234
|
+
|
|
235
|
+
id: Optional[int]
|
|
236
|
+
account = models.ForeignKey[Account](
|
|
237
|
+
to="wbcrm.Account",
|
|
238
|
+
related_name="account_commissions",
|
|
239
|
+
on_delete=models.CASCADE,
|
|
240
|
+
verbose_name="Account",
|
|
241
|
+
)
|
|
242
|
+
crm_recipient = models.ForeignKey[Entry](
|
|
243
|
+
to="directory.Entry",
|
|
244
|
+
related_name="recipient_commissions",
|
|
245
|
+
null=True,
|
|
246
|
+
blank=True,
|
|
247
|
+
on_delete=models.PROTECT,
|
|
248
|
+
verbose_name="Recipient",
|
|
249
|
+
)
|
|
250
|
+
portfolio_role_recipient = models.CharField(
|
|
251
|
+
max_length=32, choices=PortfolioRole.RoleType.choices, null=True, blank=True, verbose_name="Recipient Role"
|
|
252
|
+
)
|
|
253
|
+
account_role_type_recipient = models.ForeignKey[AccountRoleType](
|
|
254
|
+
to="wbcrm.AccountRoleType",
|
|
255
|
+
related_name="recipient_commissions",
|
|
256
|
+
null=True,
|
|
257
|
+
blank=True,
|
|
258
|
+
on_delete=models.PROTECT,
|
|
259
|
+
verbose_name="Account Role Type Recipient",
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
net_commission = models.BooleanField(default=True, verbose_name="Net Commission Rule")
|
|
263
|
+
commission_type = models.ForeignKey[CommissionType](
|
|
264
|
+
"wbcommission.CommissionType",
|
|
265
|
+
on_delete=models.PROTECT,
|
|
266
|
+
related_name="commissions",
|
|
267
|
+
verbose_name="Commission Type",
|
|
268
|
+
)
|
|
269
|
+
is_hidden = models.BooleanField(
|
|
270
|
+
default=True,
|
|
271
|
+
verbose_name="Hidden Commission Rule",
|
|
272
|
+
help_text="If False, this commission rule can be seen by anoyone that can access the related account. Otherwise, only an explicit role will grant access",
|
|
273
|
+
)
|
|
274
|
+
exclusion_rule_account_role_type = models.ForeignKey[AccountRoleType]( # TODO think if this is the best approach
|
|
275
|
+
to="wbcrm.AccountRoleType",
|
|
276
|
+
null=True,
|
|
277
|
+
blank=True,
|
|
278
|
+
on_delete=models.SET_NULL,
|
|
279
|
+
verbose_name="Account Role to decide with exclusion rule applies",
|
|
280
|
+
)
|
|
281
|
+
objects = CommissionManager()
|
|
282
|
+
|
|
283
|
+
if TYPE_CHECKING:
|
|
284
|
+
rules = RelatedManager["CommissionRule"]()
|
|
285
|
+
|
|
286
|
+
class Meta(OrderedModel.Meta):
|
|
287
|
+
verbose_name = "Commission"
|
|
288
|
+
verbose_name_plural = "Commissions"
|
|
289
|
+
permissions = (("administrate_commission", "Can administrate Commission"),)
|
|
290
|
+
constraints = [
|
|
291
|
+
UniqueConstraint(
|
|
292
|
+
name="unique_crm_recipient_account",
|
|
293
|
+
fields=["commission_type", "account", "crm_recipient"],
|
|
294
|
+
condition=Q(crm_recipient__isnull=False),
|
|
295
|
+
),
|
|
296
|
+
UniqueConstraint(
|
|
297
|
+
name="unique_portfolio_role_recipient_account",
|
|
298
|
+
fields=["commission_type", "account", "portfolio_role_recipient"],
|
|
299
|
+
condition=Q(portfolio_role_recipient__isnull=False),
|
|
300
|
+
),
|
|
301
|
+
UniqueConstraint(
|
|
302
|
+
name="unique_account_role_type_recipient_account",
|
|
303
|
+
fields=["commission_type", "account", "account_role_type_recipient"],
|
|
304
|
+
condition=Q(account_role_type_recipient__isnull=False),
|
|
305
|
+
),
|
|
306
|
+
models.CheckConstraint(
|
|
307
|
+
check=(
|
|
308
|
+
Q(
|
|
309
|
+
crm_recipient__isnull=False,
|
|
310
|
+
portfolio_role_recipient__isnull=True,
|
|
311
|
+
account_role_type_recipient__isnull=True,
|
|
312
|
+
)
|
|
313
|
+
| Q(
|
|
314
|
+
crm_recipient__isnull=True,
|
|
315
|
+
portfolio_role_recipient__isnull=False,
|
|
316
|
+
account_role_type_recipient__isnull=True,
|
|
317
|
+
)
|
|
318
|
+
| Q(
|
|
319
|
+
crm_recipient__isnull=True,
|
|
320
|
+
portfolio_role_recipient__isnull=True,
|
|
321
|
+
account_role_type_recipient__isnull=False,
|
|
322
|
+
)
|
|
323
|
+
),
|
|
324
|
+
name="Only one recipient type set",
|
|
325
|
+
),
|
|
326
|
+
]
|
|
327
|
+
|
|
328
|
+
@property
|
|
329
|
+
def recipient_repr(self) -> str:
|
|
330
|
+
if self.crm_recipient:
|
|
331
|
+
return f"Profile {self.crm_recipient.computed_str}"
|
|
332
|
+
elif self.portfolio_role_recipient:
|
|
333
|
+
return f"Portfolio Role: {PortfolioRole.RoleType[self.portfolio_role_recipient].label}"
|
|
334
|
+
else:
|
|
335
|
+
return f"Account Role Type: {self.account_role_type_recipient.title}"
|
|
336
|
+
|
|
337
|
+
def __str__(self) -> str:
|
|
338
|
+
return repr(self)
|
|
339
|
+
|
|
340
|
+
def __repr__(self) -> str:
|
|
341
|
+
return f"Net: {self.net_commission} - {self.account.title} - {self.order} - {self.commission_type} - {self.recipient_repr} (id: {self.id})"
|
|
342
|
+
|
|
343
|
+
@property
|
|
344
|
+
def validated_percent(self) -> Decimal:
|
|
345
|
+
if not hasattr(self, "_validated_percent"):
|
|
346
|
+
raise AssertionError("You must call `.is_valid()` before accessing `.validated_percent`.")
|
|
347
|
+
return self._validated_percent
|
|
348
|
+
|
|
349
|
+
@property
|
|
350
|
+
def validated_net_commission(self) -> bool:
|
|
351
|
+
if not hasattr(self, "_validated_net_commission"):
|
|
352
|
+
raise AssertionError("You must call `.is_valid()` before accessing `.validated_net_commission`.")
|
|
353
|
+
return self._validated_net_commission
|
|
354
|
+
|
|
355
|
+
def is_valid(self, validity_date: date, product: models.Model, asset_under_management: Decimal) -> bool:
|
|
356
|
+
"""
|
|
357
|
+
Check if the commission rule is valid for given parameters.
|
|
358
|
+
|
|
359
|
+
This method determines if the commission rule is valid based on the provided parameters.
|
|
360
|
+
It checks the validity date, the product, and the asset under management against the rules
|
|
361
|
+
associated with the commission. If a valid rule is found, the method sets the validated
|
|
362
|
+
percentage and net commission and handles any overriding exclusion rules.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
validity_date (date): The date for which the validity is checked.
|
|
366
|
+
product (models.Model): The product associated with the commission rule.
|
|
367
|
+
asset_under_management (Decimal): The asset under management for the account.
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
bool: True if the commission rule is valid, False otherwise.
|
|
371
|
+
"""
|
|
372
|
+
try:
|
|
373
|
+
valid_rule = self.rules.get(
|
|
374
|
+
Q(timespan__startswith__lte=validity_date)
|
|
375
|
+
& Q(timespan__endswith__gt=validity_date)
|
|
376
|
+
& Q(assets_under_management_range__startswith__lte=asset_under_management)
|
|
377
|
+
& (
|
|
378
|
+
Q(assets_under_management_range__endswith__gt=asset_under_management)
|
|
379
|
+
| Q(assets_under_management_range__endswith__isnull=True)
|
|
380
|
+
)
|
|
381
|
+
& (Q(percent__gt=0) | (Q(percent=0) & Q(consider_zero_percent_for_exclusion=True)))
|
|
382
|
+
)
|
|
383
|
+
self._validated_percent = valid_rule.percent
|
|
384
|
+
self._validated_net_commission = self.net_commission
|
|
385
|
+
with suppress(CommissionExclusionRule.DoesNotExist):
|
|
386
|
+
overriding_rule = CommissionExclusionRule.objects.get(
|
|
387
|
+
timespan__startswith__lte=validity_date,
|
|
388
|
+
timespan__endswith__gt=validity_date,
|
|
389
|
+
product=product,
|
|
390
|
+
commission_type=self.commission_type,
|
|
391
|
+
account_role_type=self.exclusion_rule_account_role_type,
|
|
392
|
+
)
|
|
393
|
+
self._validated_percent = overriding_rule.overriding_percent
|
|
394
|
+
self._validated_net_commission = overriding_rule.get_net_or_gross(self.net_commission)
|
|
395
|
+
return True
|
|
396
|
+
except CommissionRule.DoesNotExist:
|
|
397
|
+
return False
|
|
398
|
+
|
|
399
|
+
def get_recipients(
|
|
400
|
+
self, account: "Account", product: models.Model, val_date: date
|
|
401
|
+
) -> Generator[tuple["Entry", Decimal], None, None]:
|
|
402
|
+
"""
|
|
403
|
+
This function generates recipients and their respective weightings for distributing
|
|
404
|
+
commissions based on the provided account, product, and validation date.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
account (Account): The account for which recipients are being generated. Only used for account role type typed commission
|
|
408
|
+
product (models.Model): The product model for which recipients are being generated. Only used for portfolio role typed commission
|
|
409
|
+
val_date (date): The date for which the recipients are being generated. Used to determine the role validity
|
|
410
|
+
|
|
411
|
+
Yields:
|
|
412
|
+
tuple[Entry, Decimal]: A tuple containing a recipient entry and its associated weighting.
|
|
413
|
+
|
|
414
|
+
Note:
|
|
415
|
+
The generated recipients and weightings depend on different conditions, including CRM recipients,
|
|
416
|
+
account role type recipients, and portfolio role recipients.
|
|
417
|
+
|
|
418
|
+
Raises:
|
|
419
|
+
AccountRoleValidity.DoesNotExist: If no validity data is available for any account role,
|
|
420
|
+
the exception will be caught, and recipients will not be generated.
|
|
421
|
+
KeyError: If no recipient data is available for any condition,
|
|
422
|
+
a KeyError will be caught, and no recipients will be generated.
|
|
423
|
+
"""
|
|
424
|
+
if self.crm_recipient:
|
|
425
|
+
yield self.crm_recipient, Decimal(1.0)
|
|
426
|
+
elif self.account_role_type_recipient:
|
|
427
|
+
account_roles = AccountRole.objects.annotate(
|
|
428
|
+
is_valid=AccountRoleValidity.get_role_validity_subquery(val_date)
|
|
429
|
+
).filter(
|
|
430
|
+
is_valid=True,
|
|
431
|
+
account__in=account.get_ancestors(include_self=True).filter(level__gte=self.account.level),
|
|
432
|
+
role_type=self.account_role_type_recipient,
|
|
433
|
+
)
|
|
434
|
+
total_weighting = Decimal(account_roles.aggregate(s=Sum("weighting"))["s"] or 0)
|
|
435
|
+
for account_role in account_roles:
|
|
436
|
+
yield account_role.entry, Decimal(
|
|
437
|
+
account_role.weighting
|
|
438
|
+
) / total_weighting if total_weighting else Decimal(0)
|
|
439
|
+
else:
|
|
440
|
+
role_recipients = PortfolioRole.objects.exclude(weighting=0).filter(
|
|
441
|
+
(Q(role_type=self.portfolio_role_recipient) & (Q(instrument=product) | Q(instrument__isnull=True)))
|
|
442
|
+
& (Q(start__lte=val_date) | Q(start__isnull=True))
|
|
443
|
+
& (
|
|
444
|
+
Q(end__gte=val_date) | Q(end__isnull=True)
|
|
445
|
+
) # This breaks the date range default upper range exclusion, will need to change if we ever move to a data range field
|
|
446
|
+
)
|
|
447
|
+
total_weighting = Decimal(role_recipients.aggregate(s=Sum("weighting"))["s"] or 0)
|
|
448
|
+
for portfolio_role in role_recipients:
|
|
449
|
+
yield portfolio_role.person.entry_ptr, Decimal(
|
|
450
|
+
Decimal(portfolio_role.weighting) / total_weighting
|
|
451
|
+
) if total_weighting else Decimal(0)
|
|
452
|
+
|
|
453
|
+
@classmethod
|
|
454
|
+
def get_endpoint_basename(cls) -> str:
|
|
455
|
+
return "wbcommission:commission"
|
|
456
|
+
|
|
457
|
+
@classmethod
|
|
458
|
+
def get_representation_endpoint(cls) -> str:
|
|
459
|
+
return "wbcommission:commissionrepresentation-list"
|
|
460
|
+
|
|
461
|
+
@classmethod
|
|
462
|
+
def get_representation_value_key(cls) -> str:
|
|
463
|
+
return "id"
|
|
464
|
+
|
|
465
|
+
@classmethod
|
|
466
|
+
def get_representation_label_key(cls) -> str:
|
|
467
|
+
return "{{id}}"
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
class CommissionExclusionRule(models.Model):
|
|
471
|
+
class NetOrGrossCommissionOverridingType(models.TextChoices):
|
|
472
|
+
NET = "NET", "Net"
|
|
473
|
+
GROSS = "GROSS", "Gross"
|
|
474
|
+
DEFAULT = "DEFAULT", "Default"
|
|
475
|
+
|
|
476
|
+
id: Optional[int]
|
|
477
|
+
product = models.ForeignKey(
|
|
478
|
+
to="wbportfolio.Product",
|
|
479
|
+
on_delete=models.CASCADE,
|
|
480
|
+
verbose_name="Product",
|
|
481
|
+
)
|
|
482
|
+
timespan = DateRangeField(verbose_name="Timespan")
|
|
483
|
+
commission_type = models.ForeignKey(
|
|
484
|
+
CommissionType,
|
|
485
|
+
on_delete=models.PROTECT,
|
|
486
|
+
related_name="commission_exclusion_rules",
|
|
487
|
+
verbose_name="Commission Type",
|
|
488
|
+
)
|
|
489
|
+
account_role_type = models.ForeignKey(
|
|
490
|
+
to="wbcrm.AccountRoleType",
|
|
491
|
+
null=True,
|
|
492
|
+
blank=True,
|
|
493
|
+
on_delete=models.PROTECT,
|
|
494
|
+
verbose_name="Account Role Type Recipient",
|
|
495
|
+
)
|
|
496
|
+
overriding_percent = models.DecimalField(verbose_name="Overriding Percent", max_digits=4, decimal_places=3)
|
|
497
|
+
overriding_net_or_gross_commission = models.CharField(
|
|
498
|
+
default=NetOrGrossCommissionOverridingType.DEFAULT,
|
|
499
|
+
choices=NetOrGrossCommissionOverridingType.choices,
|
|
500
|
+
max_length=16,
|
|
501
|
+
verbose_name="Overriding Net or Gross Commission Rule",
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
def save(self, *args, **kwargs):
|
|
505
|
+
if not self.timespan:
|
|
506
|
+
self.timespan = DateRange(date.min, date.max) # type: ignore
|
|
507
|
+
super().save(*args, **kwargs)
|
|
508
|
+
|
|
509
|
+
def get_net_or_gross(self, net_commission: bool) -> bool:
|
|
510
|
+
"""
|
|
511
|
+
Determine whether to use net or gross commission based on overriding settings.
|
|
512
|
+
|
|
513
|
+
This function determines whether to use net or gross commission based on the
|
|
514
|
+
provided `net_commission` parameter and the overriding settings.
|
|
515
|
+
|
|
516
|
+
Args:
|
|
517
|
+
net_commission (bool): The original commission type, True for net commission, False for gross commission.
|
|
518
|
+
|
|
519
|
+
Returns:
|
|
520
|
+
bool: The determined commission type after considering the overriding settings.
|
|
521
|
+
"""
|
|
522
|
+
return {
|
|
523
|
+
self.NetOrGrossCommissionOverridingType.DEFAULT.name: net_commission,
|
|
524
|
+
self.NetOrGrossCommissionOverridingType.GROSS.name: False,
|
|
525
|
+
}.get(self.overriding_net_or_gross_commission, True)
|
|
526
|
+
|
|
527
|
+
class Meta:
|
|
528
|
+
verbose_name = "Commission Exclusion Rule"
|
|
529
|
+
verbose_name_plural = "Commissions Exclusion Rules"
|
|
530
|
+
constraints = [
|
|
531
|
+
ExclusionConstraint(
|
|
532
|
+
name="exclude_overlapping_exclusion_rules",
|
|
533
|
+
expressions=[
|
|
534
|
+
("timespan", RangeOperators.OVERLAPS),
|
|
535
|
+
("product", RangeOperators.EQUAL),
|
|
536
|
+
("commission_type", RangeOperators.EQUAL),
|
|
537
|
+
("account_role_type", RangeOperators.EQUAL),
|
|
538
|
+
],
|
|
539
|
+
),
|
|
540
|
+
]
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
class CommissionRule(ComplexToStringMixin, WBModel):
|
|
544
|
+
id: Optional[int]
|
|
545
|
+
commission = models.ForeignKey(
|
|
546
|
+
Commission, on_delete=models.CASCADE, related_name="rules", verbose_name="Commission Line"
|
|
547
|
+
)
|
|
548
|
+
timespan = DateRangeField(verbose_name="Timespan")
|
|
549
|
+
assets_under_management_range = DecimalRangeField(verbose_name="AUM Range")
|
|
550
|
+
percent = models.DecimalField(verbose_name="Percent", default=Decimal(0), max_digits=4, decimal_places=3)
|
|
551
|
+
consider_zero_percent_for_exclusion = models.BooleanField(
|
|
552
|
+
default=False,
|
|
553
|
+
verbose_name="Consider 0 percent for exclusion",
|
|
554
|
+
help_text="If true, the commission rule with percent 0 (initially consider disabled), will be considered for exclusion rule matching",
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
def save(self, *args, **kwargs):
|
|
558
|
+
if not self.timespan:
|
|
559
|
+
self.timespan = DateRange(date.min, date.max) # type: ignore
|
|
560
|
+
super().save(*args, **kwargs)
|
|
561
|
+
|
|
562
|
+
class Meta:
|
|
563
|
+
constraints = [
|
|
564
|
+
ExclusionConstraint(
|
|
565
|
+
name="exclude_overlapping_rules",
|
|
566
|
+
expressions=[
|
|
567
|
+
("timespan", RangeOperators.OVERLAPS),
|
|
568
|
+
("assets_under_management_range", RangeOperators.OVERLAPS),
|
|
569
|
+
("commission", RangeOperators.EQUAL),
|
|
570
|
+
],
|
|
571
|
+
),
|
|
572
|
+
]
|
|
573
|
+
|
|
574
|
+
def compute_str(self) -> str:
|
|
575
|
+
return f"{self.commission} - {self.percent:.2%}: date range = [{self.timespan.lower} - {self.timespan.upper}[, aum range=[{self.assets_under_management_range.lower} - {self.assets_under_management_range.upper}[" # type: ignore
|
|
576
|
+
|
|
577
|
+
@classmethod
|
|
578
|
+
def get_endpoint_basename(cls) -> str:
|
|
579
|
+
return "wbcommission:commissionrule"
|
|
580
|
+
|
|
581
|
+
@classmethod
|
|
582
|
+
def get_representation_endpoint(cls) -> str:
|
|
583
|
+
return "wbcommission:commissionrulerepresentation-list"
|
|
584
|
+
|
|
585
|
+
@classmethod
|
|
586
|
+
def get_representation_value_key(cls) -> str:
|
|
587
|
+
return "id"
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
class CommissionRole(ComplexToStringMixin, WBModel):
|
|
591
|
+
class Meta:
|
|
592
|
+
verbose_name = "Commission Role"
|
|
593
|
+
verbose_name_plural = "Commission Roles"
|
|
594
|
+
|
|
595
|
+
def compute_str(self) -> str:
|
|
596
|
+
return f"{self.person} -> {self.commission}"
|
|
597
|
+
|
|
598
|
+
id: Optional[int]
|
|
599
|
+
person = models.ForeignKey(
|
|
600
|
+
"directory.Person", related_name="rebates_account_roles", on_delete=models.PROTECT, verbose_name="Person"
|
|
601
|
+
)
|
|
602
|
+
commission = models.ForeignKey(
|
|
603
|
+
Commission, related_name="roles", on_delete=models.CASCADE, verbose_name="Commission Line"
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
@classmethod
|
|
607
|
+
def get_endpoint_basename(cls) -> str:
|
|
608
|
+
return "wbcommission:commissionrole"
|
|
609
|
+
|
|
610
|
+
@classmethod
|
|
611
|
+
def get_representation_endpoint(cls) -> str:
|
|
612
|
+
return "wbcommission:commissionrolerepresentation-list"
|
|
613
|
+
|
|
614
|
+
@classmethod
|
|
615
|
+
def get_representation_value_key(cls) -> str:
|
|
616
|
+
return "id"
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
@receiver(post_save, sender="wbcommission.Commission")
|
|
620
|
+
def post_commission_creation(sender, instance, created, **kwargs):
|
|
621
|
+
# if a new commission line is created, we create a general rule
|
|
622
|
+
if created:
|
|
623
|
+
CommissionRule.objects.create(
|
|
624
|
+
commission=instance,
|
|
625
|
+
timespan=DateRange(date.min, date.max), # type: ignore
|
|
626
|
+
assets_under_management_range=NumericRange(0, None), # type: ignore
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
@receiver(pre_merge, sender="wbcrm.Account")
|
|
631
|
+
def handle_pre_merge_account_for_commission(
|
|
632
|
+
sender: models.Model, merged_object: Account, main_object: Account, **kwargs
|
|
633
|
+
):
|
|
634
|
+
"""
|
|
635
|
+
Loop over all commission lines assigned to the account to be merged and reassign them to the remaining account. Handle also the commission roles and rules.
|
|
636
|
+
"""
|
|
637
|
+
for commission in merged_object.account_commissions.all():
|
|
638
|
+
defaults = {
|
|
639
|
+
"order": commission.order,
|
|
640
|
+
"net_commission": commission.net_commission,
|
|
641
|
+
"is_hidden": commission.is_hidden,
|
|
642
|
+
"exclusion_rule_account_role_type": commission.exclusion_rule_account_role_type,
|
|
643
|
+
}
|
|
644
|
+
if commission.crm_recipient:
|
|
645
|
+
new_commission, created = Commission.objects.get_or_create(
|
|
646
|
+
crm_recipient=commission.crm_recipient,
|
|
647
|
+
account=main_object,
|
|
648
|
+
commission_type=commission.commission_type,
|
|
649
|
+
defaults=defaults,
|
|
650
|
+
)
|
|
651
|
+
elif commission.portfolio_role_recipient:
|
|
652
|
+
new_commission, created = Commission.objects.get_or_create(
|
|
653
|
+
portfolio_role_recipient=commission.portfolio_role_recipient,
|
|
654
|
+
account=main_object,
|
|
655
|
+
commission_type=commission.commission_type,
|
|
656
|
+
defaults=defaults,
|
|
657
|
+
)
|
|
658
|
+
else:
|
|
659
|
+
new_commission, created = Commission.objects.get_or_create(
|
|
660
|
+
account_role_type_recipient=commission.account_role_type_recipient,
|
|
661
|
+
account=main_object,
|
|
662
|
+
commission_type=commission.commission_type,
|
|
663
|
+
defaults=defaults,
|
|
664
|
+
)
|
|
665
|
+
for role in commission.roles.all():
|
|
666
|
+
if not new_commission.roles.filter(person=role.person).exists():
|
|
667
|
+
role.commission = new_commission
|
|
668
|
+
role.save()
|
|
669
|
+
if created:
|
|
670
|
+
new_commission.rules.all().delete()
|
|
671
|
+
for rule in commission.rules.all():
|
|
672
|
+
if not new_commission.rules.filter(
|
|
673
|
+
timespan__overlap=rule.timespan,
|
|
674
|
+
assets_under_management_range__overlap=rule.assets_under_management_range,
|
|
675
|
+
).exists():
|
|
676
|
+
rule.commission = new_commission
|
|
677
|
+
rule.save()
|
|
678
|
+
commission.rebates.update(commission=new_commission)
|
|
679
|
+
commission.delete()
|