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.

Files changed (76) hide show
  1. wbcommission/__init__.py +1 -0
  2. wbcommission/admin/__init__.py +4 -0
  3. wbcommission/admin/accounts.py +22 -0
  4. wbcommission/admin/commission.py +85 -0
  5. wbcommission/admin/rebate.py +7 -0
  6. wbcommission/analytics/__init__.py +0 -0
  7. wbcommission/analytics/marginality.py +181 -0
  8. wbcommission/apps.py +5 -0
  9. wbcommission/dynamic_preferences_registry.py +0 -0
  10. wbcommission/factories/__init__.py +9 -0
  11. wbcommission/factories/commission.py +100 -0
  12. wbcommission/factories/rebate.py +16 -0
  13. wbcommission/filters/__init__.py +7 -0
  14. wbcommission/filters/rebate.py +187 -0
  15. wbcommission/filters/signals.py +44 -0
  16. wbcommission/generators/__init__.py +2 -0
  17. wbcommission/generators/rebate_generator.py +93 -0
  18. wbcommission/migrations/0001_initial.py +299 -0
  19. wbcommission/migrations/0002_commissionrule_remove_accountcustomer_account_and_more.py +395 -0
  20. wbcommission/migrations/0003_alter_commission_account.py +24 -0
  21. wbcommission/migrations/0004_rebate_audit_log.py +19 -0
  22. wbcommission/migrations/0005_alter_rebate_audit_log.py +20 -0
  23. wbcommission/migrations/0006_commissionrule_consider_zero_percent_for_exclusion.py +21 -0
  24. wbcommission/migrations/0007_remove_commission_unique_crm_recipient_account_and_more.py +50 -0
  25. wbcommission/migrations/0008_alter_commission_options_alter_commission_order.py +26 -0
  26. wbcommission/migrations/__init__.py +0 -0
  27. wbcommission/models/__init__.py +9 -0
  28. wbcommission/models/account_service.py +217 -0
  29. wbcommission/models/commission.py +679 -0
  30. wbcommission/models/rebate.py +319 -0
  31. wbcommission/models/signals.py +45 -0
  32. wbcommission/permissions.py +6 -0
  33. wbcommission/reports/__init__.py +0 -0
  34. wbcommission/reports/audit_report.py +51 -0
  35. wbcommission/reports/customer_report.py +299 -0
  36. wbcommission/reports/utils.py +30 -0
  37. wbcommission/serializers/__init__.py +3 -0
  38. wbcommission/serializers/commissions.py +26 -0
  39. wbcommission/serializers/rebate.py +87 -0
  40. wbcommission/serializers/signals.py +27 -0
  41. wbcommission/tests/__init__.py +0 -0
  42. wbcommission/tests/analytics/__init__.py +0 -0
  43. wbcommission/tests/analytics/test_marginality.py +253 -0
  44. wbcommission/tests/conftest.py +89 -0
  45. wbcommission/tests/models/__init__.py +0 -0
  46. wbcommission/tests/models/mixins.py +22 -0
  47. wbcommission/tests/models/test_account_service.py +293 -0
  48. wbcommission/tests/models/test_commission.py +587 -0
  49. wbcommission/tests/models/test_rebate.py +136 -0
  50. wbcommission/tests/signals.py +0 -0
  51. wbcommission/tests/test_permissions.py +66 -0
  52. wbcommission/tests/viewsets/__init__.py +0 -0
  53. wbcommission/tests/viewsets/test_rebate.py +76 -0
  54. wbcommission/urls.py +42 -0
  55. wbcommission/viewsets/__init__.py +7 -0
  56. wbcommission/viewsets/buttons/__init__.py +2 -0
  57. wbcommission/viewsets/buttons/rebate.py +46 -0
  58. wbcommission/viewsets/buttons/signals.py +53 -0
  59. wbcommission/viewsets/commissions.py +21 -0
  60. wbcommission/viewsets/display/__init__.py +5 -0
  61. wbcommission/viewsets/display/commissions.py +21 -0
  62. wbcommission/viewsets/display/rebate.py +117 -0
  63. wbcommission/viewsets/endpoints/__init__.py +4 -0
  64. wbcommission/viewsets/endpoints/commissions.py +0 -0
  65. wbcommission/viewsets/endpoints/rebate.py +21 -0
  66. wbcommission/viewsets/menu/__init__.py +1 -0
  67. wbcommission/viewsets/menu/commissions.py +0 -0
  68. wbcommission/viewsets/menu/rebate.py +13 -0
  69. wbcommission/viewsets/mixins.py +39 -0
  70. wbcommission/viewsets/rebate.py +481 -0
  71. wbcommission/viewsets/titles/__init__.py +1 -0
  72. wbcommission/viewsets/titles/commissions.py +0 -0
  73. wbcommission/viewsets/titles/rebate.py +11 -0
  74. wbcommission-2.2.1.dist-info/METADATA +11 -0
  75. wbcommission-2.2.1.dist-info/RECORD +76 -0
  76. 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()