wbcommission 1.59.8__tar.gz → 1.61.0__tar.gz

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.
Files changed (96) hide show
  1. {wbcommission-1.59.8 → wbcommission-1.61.0}/PKG-INFO +1 -1
  2. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/admin/commission.py +1 -0
  3. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/filters/__init__.py +1 -0
  4. wbcommission-1.61.0/wbcommission/filters/commissions.py +110 -0
  5. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/generators/rebate_generator.py +7 -5
  6. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/locale/de/LC_MESSAGES/django.po +7 -3
  7. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/locale/en/LC_MESSAGES/django.po +7 -2
  8. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/locale/fr/LC_MESSAGES/django.po +7 -3
  9. wbcommission-1.61.0/wbcommission/migrations/0010_alter_rebate_unique_together_rebate_unique_rebate.py +24 -0
  10. wbcommission-1.61.0/wbcommission/migrations/0011_commissionrole_unique_commission_role.py +18 -0
  11. wbcommission-1.61.0/wbcommission/migrations/0012_commission_root_account.py +40 -0
  12. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/models/account_service.py +7 -5
  13. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/models/commission.py +43 -16
  14. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/models/rebate.py +31 -1
  15. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/serializers/__init__.py +1 -1
  16. wbcommission-1.61.0/wbcommission/serializers/commissions.py +216 -0
  17. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/tests/models/test_account_service.py +7 -2
  18. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/tests/models/test_commission.py +19 -11
  19. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/tests/models/test_rebate.py +2 -2
  20. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/urls.py +23 -4
  21. wbcommission-1.61.0/wbcommission/viewsets/__init__.py +13 -0
  22. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/viewsets/buttons/__init__.py +1 -0
  23. wbcommission-1.61.0/wbcommission/viewsets/buttons/commissions.py +5 -0
  24. wbcommission-1.61.0/wbcommission/viewsets/commissions.py +150 -0
  25. wbcommission-1.61.0/wbcommission/viewsets/display/__init__.py +5 -0
  26. wbcommission-1.61.0/wbcommission/viewsets/display/commissions.py +181 -0
  27. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/viewsets/endpoints/__init__.py +1 -0
  28. wbcommission-1.61.0/wbcommission/viewsets/endpoints/commissions.py +16 -0
  29. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/viewsets/menu/__init__.py +1 -0
  30. wbcommission-1.61.0/wbcommission/viewsets/menu/commissions.py +7 -0
  31. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/viewsets/mixins.py +11 -3
  32. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/viewsets/titles/__init__.py +1 -0
  33. wbcommission-1.61.0/wbcommission/viewsets/titles/commissions.py +9 -0
  34. wbcommission-1.59.8/wbcommission/serializers/commissions.py +0 -27
  35. wbcommission-1.59.8/wbcommission/viewsets/__init__.py +0 -7
  36. wbcommission-1.59.8/wbcommission/viewsets/commissions.py +0 -22
  37. wbcommission-1.59.8/wbcommission/viewsets/display/__init__.py +0 -5
  38. wbcommission-1.59.8/wbcommission/viewsets/display/commissions.py +0 -21
  39. wbcommission-1.59.8/wbcommission/viewsets/endpoints/commissions.py +0 -0
  40. wbcommission-1.59.8/wbcommission/viewsets/menu/commissions.py +0 -0
  41. wbcommission-1.59.8/wbcommission/viewsets/titles/commissions.py +0 -0
  42. {wbcommission-1.59.8 → wbcommission-1.61.0}/.gitignore +0 -0
  43. {wbcommission-1.59.8 → wbcommission-1.61.0}/pyproject.toml +0 -0
  44. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/__init__.py +0 -0
  45. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/admin/__init__.py +0 -0
  46. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/admin/accounts.py +0 -0
  47. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/admin/rebate.py +0 -0
  48. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/analytics/__init__.py +0 -0
  49. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/analytics/marginality.py +0 -0
  50. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/apps.py +0 -0
  51. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/dynamic_preferences_registry.py +0 -0
  52. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/factories/__init__.py +0 -0
  53. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/factories/commission.py +0 -0
  54. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/factories/rebate.py +0 -0
  55. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/filters/rebate.py +0 -0
  56. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/filters/signals.py +0 -0
  57. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/generators/__init__.py +0 -0
  58. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/locale/de/LC_MESSAGES/django.mo +0 -0
  59. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/locale/en/LC_MESSAGES/django.mo +0 -0
  60. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/locale/fr/LC_MESSAGES/django.mo +0 -0
  61. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/migrations/0001_initial.py +0 -0
  62. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/migrations/0002_commissionrule_remove_accountcustomer_account_and_more.py +0 -0
  63. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/migrations/0003_alter_commission_account.py +0 -0
  64. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/migrations/0004_rebate_audit_log.py +0 -0
  65. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/migrations/0005_alter_rebate_audit_log.py +0 -0
  66. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/migrations/0006_commissionrule_consider_zero_percent_for_exclusion.py +0 -0
  67. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/migrations/0007_remove_commission_unique_crm_recipient_account_and_more.py +0 -0
  68. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/migrations/0008_alter_commission_options_alter_commission_order.py +0 -0
  69. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/migrations/0009_rename_rebate_commission_type_date_recipient_product_account_wbcommissio_commiss_432402_idx.py +0 -0
  70. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/migrations/__init__.py +0 -0
  71. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/models/__init__.py +0 -0
  72. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/models/signals.py +0 -0
  73. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/permissions.py +0 -0
  74. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/reports/__init__.py +0 -0
  75. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/reports/audit_report.py +0 -0
  76. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/reports/customer_report.py +0 -0
  77. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/reports/utils.py +0 -0
  78. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/serializers/rebate.py +0 -0
  79. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/serializers/signals.py +0 -0
  80. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/tests/__init__.py +0 -0
  81. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/tests/analytics/__init__.py +0 -0
  82. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/tests/analytics/test_marginality.py +0 -0
  83. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/tests/conftest.py +0 -0
  84. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/tests/models/__init__.py +0 -0
  85. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/tests/models/mixins.py +0 -0
  86. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/tests/signals.py +0 -0
  87. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/tests/test_permissions.py +0 -0
  88. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/tests/viewsets/__init__.py +0 -0
  89. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/tests/viewsets/test_rebate.py +0 -0
  90. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/viewsets/buttons/rebate.py +0 -0
  91. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/viewsets/buttons/signals.py +0 -0
  92. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/viewsets/display/rebate.py +0 -0
  93. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/viewsets/endpoints/rebate.py +0 -0
  94. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/viewsets/menu/rebate.py +0 -0
  95. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/viewsets/rebate.py +0 -0
  96. {wbcommission-1.59.8 → wbcommission-1.61.0}/wbcommission/viewsets/titles/rebate.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wbcommission
3
- Version: 1.59.8
3
+ Version: 1.61.0
4
4
  Summary: A workbench module for managing human resources.
5
5
  Author-email: Christopher Wittlinger <c.wittlinger@stainly.com>
6
6
  Requires-Dist: wbcompliance
@@ -52,6 +52,7 @@ class CommissionTabularInline(admin.TabularInline):
52
52
  ordering = ["order"]
53
53
  autocomplete_fields = ["account", "crm_recipient"]
54
54
  show_change_link = True
55
+ fk_name = "account"
55
56
 
56
57
 
57
58
  @admin.register(Commission)
@@ -5,3 +5,4 @@ from .rebate import (
5
5
  RebateMarginalityFilter,
6
6
  )
7
7
  from .signals import *
8
+ from .commissions import CommissionAccountFilterSet, CommissionRuleCommissionFilterSet
@@ -0,0 +1,110 @@
1
+ from datetime import date
2
+
3
+ from django.db.models import Exists, OuterRef, Q
4
+ from wbcore import filters as wb_filters
5
+ from wbportfolio.models import Product
6
+
7
+ from wbcommission.models import Commission, CommissionRule
8
+
9
+
10
+ def filter_aum(queryset, value):
11
+ return queryset.filter(
12
+ Q(assets_under_management_range__startswith__lte=value)
13
+ & (
14
+ Q(assets_under_management_range__endswith__gt=value)
15
+ | Q(assets_under_management_range__endswith__isnull=True)
16
+ )
17
+ & (Q(percent__gt=0) | Q(consider_zero_percent_for_exclusion=True))
18
+ )
19
+
20
+
21
+ def filter_timestamp(queryset, value):
22
+ return queryset.filter(
23
+ Q(timespan__startswith__lte=value)
24
+ & Q(timespan__endswith__gt=value)
25
+ & (Q(percent__gt=0) | Q(consider_zero_percent_for_exclusion=True))
26
+ )
27
+
28
+
29
+ class CommissionRuleCommissionFilterSet(wb_filters.FilterSet):
30
+ only_valid_at_date = wb_filters.DateFilter(
31
+ required=False,
32
+ initial=lambda f, r, v: date.today(),
33
+ method="filter_only_valid_at_date",
34
+ label="Only Valid at date",
35
+ help_text="Set to show only rules that are valid for the given date",
36
+ )
37
+
38
+ def filter_only_valid_at_date(self, queryset, name, value):
39
+ if value:
40
+ return filter_timestamp(queryset, value)
41
+ return queryset
42
+
43
+ only_valid_for_aum = wb_filters.NumberFilter(
44
+ required=False,
45
+ method="filter_only_valid_for_aum",
46
+ label="Only Valid for AUM",
47
+ help_text="Set to show only rules that are valid for the given aum",
48
+ )
49
+
50
+ def filter_only_valid_for_aum(self, queryset, name, value):
51
+ if value:
52
+ return filter_aum(queryset, value)
53
+ return queryset
54
+
55
+ class Meta:
56
+ model = CommissionRule
57
+ fields = {
58
+ "commission": ["exact"],
59
+ }
60
+
61
+
62
+ class CommissionAccountFilterSet(wb_filters.FilterSet):
63
+ only_valid_at_date = wb_filters.DateFilter(
64
+ required=False,
65
+ initial=lambda f, r, v: date.today(),
66
+ method="filter_only_valid_at_date",
67
+ label="Only Valid at date",
68
+ help_text="Set to show only rules that are valid for the given date",
69
+ )
70
+
71
+ def filter_only_valid_at_date(self, queryset, name, value):
72
+ if value:
73
+ return queryset.annotate(
74
+ has_valid_rule=Exists(
75
+ filter_timestamp(CommissionRule.objects.filter(commission=OuterRef("pk")), value)
76
+ )
77
+ ).filter(has_valid_rule=True)
78
+
79
+ return queryset
80
+
81
+ only_valid_for_aum = wb_filters.NumberFilter(
82
+ required=False,
83
+ method="filter_only_valid_for_aum",
84
+ label="Only Valid for AUM",
85
+ help_text="Set to show only rules that are valid for the given aum",
86
+ )
87
+
88
+ def filter_only_valid_for_aum(self, queryset, name, value):
89
+ if value:
90
+ return queryset.annotate(
91
+ has_valid_rule=Exists(filter_aum(CommissionRule.objects.filter(commission=OuterRef("pk")), value))
92
+ ).filter(has_valid_rule=True)
93
+
94
+ return queryset
95
+
96
+ simulate_for_product = wb_filters.ModelChoiceFilter(
97
+ label="Simulate for Product",
98
+ queryset=Product.objects.all(),
99
+ endpoint=Product.get_representation_endpoint(),
100
+ value_key=Product.get_representation_value_key(),
101
+ label_key=Product.get_representation_label_key(),
102
+ method="fake_filter",
103
+ depends_on=[{"field": "only_valid_at_date", "options": {}}],
104
+ filter_params=lambda request, view: {"with_claim_for_account": view.account.id},
105
+ help_text='Choose a product to compute the actual percent paid for the bellow recipient (need the "Valide at date" filter)',
106
+ )
107
+
108
+ class Meta:
109
+ model = Commission
110
+ fields = {}
@@ -17,9 +17,11 @@ class RebateGenerator(AbstractBookingEntryGenerator):
17
17
  TITLE = "Rebate Generation"
18
18
 
19
19
  @staticmethod
20
- def generate_booking_entries(from_date: date, to_date: date, counterparty: Entry) -> Iterable[BookingEntry]:
20
+ def generate_booking_entries(
21
+ from_date: date, to_date: date, counterparty: Entry, booking_date: date
22
+ ) -> Iterable[BookingEntry]:
21
23
  rebates = Rebate.objects.filter(recipient=counterparty, date__gte=from_date, date__lte=to_date)
22
-
24
+ vat = counterparty.entry_accounting_information.vat.get_rate(booking_date)
23
25
  for product_id in rebates.distinct("product_id").values_list("product_id", flat=True):
24
26
  product = Product.objects.get(id=product_id)
25
27
 
@@ -33,10 +35,10 @@ class RebateGenerator(AbstractBookingEntryGenerator):
33
35
  if rebate_sum > 0:
34
36
  yield BookingEntry(
35
37
  title=f"{product.name} {product.isin} {rebate_title} Fees",
36
- booking_date=date.today(),
38
+ booking_date=booking_date,
37
39
  reference_date=to_date,
38
40
  gross_value=Decimal(-1 * rebate_sum),
39
- vat=Decimal(counterparty.entry_accounting_information.vat) or Decimal(0.0),
41
+ vat=vat,
40
42
  currency=product.currency,
41
43
  counterparty=counterparty,
42
44
  parameters={
@@ -48,7 +50,7 @@ class RebateGenerator(AbstractBookingEntryGenerator):
48
50
  "title": "Rebates",
49
51
  "reverse": "wbcommission:rebatetable-list",
50
52
  "parameters": {
51
- "date": f'{from_date.strftime("%Y-%m-%d")},{to_date.strftime("%Y-%m-%d")}',
53
+ "date": f"{from_date.strftime('%Y-%m-%d')},{to_date.strftime('%Y-%m-%d')}",
52
54
  "product": product_id,
53
55
  "recipient": counterparty.id,
54
56
  "group_by": "ACCOUNT",
@@ -3,12 +3,11 @@
3
3
  # This file is distributed under the same license as the PACKAGE package.
4
4
  # FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
5
5
  #
6
- #, fuzzy
7
6
  msgid ""
8
7
  msgstr ""
9
8
  "Project-Id-Version: PACKAGE VERSION\n"
10
9
  "Report-Msgid-Bugs-To: \n"
11
- "POT-Creation-Date: 2025-05-30 11:37+0200\n"
10
+ "POT-Creation-Date: 2026-01-16 14:04+0100\n"
12
11
  "PO-Revision-Date: 2025-05-30 09:40+0000\n"
13
12
  "Language-Team: German (https://app.transifex.com/stainly/teams/171242/de/)\n"
14
13
  "MIME-Version: 1.0\n"
@@ -17,11 +16,16 @@ msgstr ""
17
16
  "Language: de\n"
18
17
  "Plural-Forms: nplurals=2; plural=(n != 1);\n"
19
18
 
19
+ #: serializers/commissions.py:104 serializers/commissions.py:105
20
+ #: serializers/commissions.py:106
21
+ msgid "Exactly one recipient field must be set (others left empty)."
22
+ msgstr ""
23
+
20
24
  #: viewsets/buttons/rebate.py:19
21
25
  msgid "Start"
22
26
  msgstr ""
23
27
 
24
- #: viewsets/rebate.py:435
28
+ #: viewsets/rebate.py:438
25
29
  msgid ""
26
30
  "The selected date range includes a Saturday or Sunday. Please note that fees"
27
31
  " and rebates are normalized over the weekend, as fees continue to accumulate"
@@ -7,7 +7,7 @@ msgid ""
7
7
  msgstr ""
8
8
  "Project-Id-Version: PACKAGE VERSION\n"
9
9
  "Report-Msgid-Bugs-To: \n"
10
- "POT-Creation-Date: 2025-05-30 11:37+0200\n"
10
+ "POT-Creation-Date: 2026-01-16 14:04+0100\n"
11
11
  "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
12
12
  "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
13
13
  "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -16,11 +16,16 @@ msgstr ""
16
16
  "Content-Type: text/plain; charset=UTF-8\n"
17
17
  "Content-Transfer-Encoding: 8bit\n"
18
18
 
19
+ #: serializers/commissions.py:104 serializers/commissions.py:105
20
+ #: serializers/commissions.py:106
21
+ msgid "Exactly one recipient field must be set (others left empty)."
22
+ msgstr ""
23
+
19
24
  #: viewsets/buttons/rebate.py:19
20
25
  msgid "Start"
21
26
  msgstr ""
22
27
 
23
- #: viewsets/rebate.py:435
28
+ #: viewsets/rebate.py:438
24
29
  msgid ""
25
30
  "The selected date range includes a Saturday or Sunday. Please note that fees "
26
31
  "and rebates are normalized over the weekend, as fees continue to accumulate "
@@ -3,12 +3,11 @@
3
3
  # This file is distributed under the same license as the PACKAGE package.
4
4
  # FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
5
5
  #
6
- #, fuzzy
7
6
  msgid ""
8
7
  msgstr ""
9
8
  "Project-Id-Version: PACKAGE VERSION\n"
10
9
  "Report-Msgid-Bugs-To: \n"
11
- "POT-Creation-Date: 2025-05-30 11:37+0200\n"
10
+ "POT-Creation-Date: 2026-01-16 14:04+0100\n"
12
11
  "PO-Revision-Date: 2025-05-30 09:40+0000\n"
13
12
  "Language-Team: French (https://app.transifex.com/stainly/teams/171242/fr/)\n"
14
13
  "MIME-Version: 1.0\n"
@@ -17,11 +16,16 @@ msgstr ""
17
16
  "Language: fr\n"
18
17
  "Plural-Forms: nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n"
19
18
 
19
+ #: serializers/commissions.py:104 serializers/commissions.py:105
20
+ #: serializers/commissions.py:106
21
+ msgid "Exactly one recipient field must be set (others left empty)."
22
+ msgstr ""
23
+
20
24
  #: viewsets/buttons/rebate.py:19
21
25
  msgid "Start"
22
26
  msgstr ""
23
27
 
24
- #: viewsets/rebate.py:435
28
+ #: viewsets/rebate.py:438
25
29
  msgid ""
26
30
  "The selected date range includes a Saturday or Sunday. Please note that fees"
27
31
  " and rebates are normalized over the weekend, as fees continue to accumulate"
@@ -0,0 +1,24 @@
1
+ # Generated by Django 5.2.9 on 2025-12-16 15:26
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('directory', '0014_alter_entry_relationship_managers_and_more'),
10
+ ('wbcommission', '0009_rename_rebate_commission_type_date_recipient_product_account_wbcommissio_commiss_432402_idx'),
11
+ ('wbcrm', '0019_alter_activity_companies_alter_activity_participants_and_more'),
12
+ ('wbportfolio', '0095_portfolio_keep_only_essential_positions'),
13
+ ]
14
+
15
+ operations = [
16
+ migrations.AlterUniqueTogether(
17
+ name='rebate',
18
+ unique_together=set(),
19
+ ),
20
+ migrations.AddConstraint(
21
+ model_name='rebate',
22
+ constraint=models.UniqueConstraint(fields=('date', 'recipient', 'account', 'product', 'commission_type'), name='unique_rebate'),
23
+ ),
24
+ ]
@@ -0,0 +1,18 @@
1
+ # Generated by Django 5.2.9 on 2026-01-14 09:21
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('directory', '0015_alter_emailcontact_address_and_more'),
10
+ ('wbcommission', '0010_alter_rebate_unique_together_rebate_unique_rebate'),
11
+ ]
12
+
13
+ operations = [
14
+ migrations.AddConstraint(
15
+ model_name='commissionrole',
16
+ constraint=models.UniqueConstraint(fields=('person', 'commission'), name='unique_commission_role'),
17
+ ),
18
+ ]
@@ -0,0 +1,40 @@
1
+ # Generated by Django 5.2.9 on 2026-01-15 13:43
2
+
3
+ import django.db.models.deletion
4
+ from django.db import migrations, models
5
+
6
+ def migrate_root_account(apps, schema_editor):
7
+ Commission = apps.get_model('wbcommission', 'Commission')
8
+ Account = apps.get_model('wbcrm', 'Account')
9
+ objs = []
10
+ for c in Commission.objects.all():
11
+ c.root_account = Account.objects.filter(tree_id=c.account.tree_id, level=0).first()
12
+ objs.append(c)
13
+ Commission.objects.bulk_update(objs, ["root_account"])
14
+
15
+ class Migration(migrations.Migration):
16
+
17
+ dependencies = [
18
+ ('wbcommission', '0011_commissionrole_unique_commission_role'),
19
+ ('wbcrm', '0020_alter_product_unique_together_product_unique_product'),
20
+ ]
21
+
22
+ operations = [
23
+ migrations.AddField(
24
+ model_name='commission',
25
+ name='root_account',
26
+ field=models.ForeignKey(blank=True, null=True,on_delete=django.db.models.deletion.CASCADE, related_name='root_account_commissions', to='wbcrm.account', verbose_name='Root Account'),
27
+ preserve_default=False,
28
+ ),
29
+ migrations.RunSQL(sql="SET CONSTRAINTS ALL IMMEDIATE;"),
30
+ migrations.RunPython(migrate_root_account),
31
+ migrations.RunSQL(sql="SET CONSTRAINTS ALL DEFERRED;"),
32
+ migrations.AlterField(
33
+ model_name='commission',
34
+ name='root_account',
35
+ field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE,
36
+ related_name='root_account_commissions', to='wbcrm.account',
37
+ verbose_name='Root Account'),
38
+ preserve_default=False,
39
+ ),
40
+ ]
@@ -195,7 +195,7 @@ class AccountRebateManager:
195
195
  )
196
196
  return Decimal(0)
197
197
 
198
- def get_root_account_total_holding(self, compute_date: date) -> Decimal:
198
+ def get_terminal_accounts_total_holding(self, compute_date: date) -> dict[int, Decimal]:
199
199
  """
200
200
  Calculate the total assets under management (AUM) for the root account.
201
201
 
@@ -206,14 +206,16 @@ class AccountRebateManager:
206
206
  compute_date (date): The date for which the total AUM is calculated.
207
207
 
208
208
  Returns:
209
- Decimal: The total assets under management for the root account on the given date.
209
+ dict: The total assets under management for each account on the given date.
210
210
 
211
211
  Raises:
212
212
  KeyError: If no AUM data is available for any terminal account on the given date,
213
213
  a KeyError will be caught, and the function will return Decimal(0).
214
214
  """
215
- account_aum = Decimal(0)
215
+ res = {}
216
216
  for terminal_account in self.terminal_accounts:
217
217
  with suppress(KeyError):
218
- account_aum += Decimal(self.df_aum.loc[(terminal_account.id, slice(None), compute_date)].sum())
219
- return account_aum
218
+ res[terminal_account.id] = Decimal(
219
+ self.df_aum.loc[(terminal_account.id, slice(None), compute_date)].sum()
220
+ )
221
+ return res
@@ -86,7 +86,7 @@ class CommissionType(WBModel):
86
86
  terminal_account: "Account",
87
87
  compute_date: date,
88
88
  content_object: models.Model,
89
- root_account_total_holding: Decimal,
89
+ terminal_accounts_total_holding: dict[int, Decimal],
90
90
  ) -> Generator[tuple["Commission", Decimal], None, None]:
91
91
  """
92
92
  Retrieve valid commissions for the given terminal account and parameters.
@@ -99,20 +99,25 @@ class CommissionType(WBModel):
99
99
  terminal_account (Account): The terminal account for which to retrieve valid commissions.
100
100
  compute_date (date): The date for which to compute the rebates.
101
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.
102
+ terminal_accounts_total_holding (dict): The total holding for each terminal account of the root account.
103
103
 
104
104
  Yields:
105
105
  tuple: A tuple containing a valid Commission instance and its corresponding actual percentage.
106
106
  """
107
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
108
  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):
109
+ # for hiearchical account structure, we prioritize the immidiate parent account (i.e. self)
110
+ for commission in Commission.objects.filter(
111
+ root_account=terminal_account.get_root(), commission_type=self, account__is_active=True
112
+ ).order_by("order"):
113
+ account_holding = sum(
114
+ [
115
+ terminal_accounts_total_holding.get(account.id, Decimal("0"))
116
+ for account in commission.account.get_descendants(include_self=True)
117
+ ]
118
+ )
119
+ commission.commission_account_holding = account_holding
120
+ if commission.is_valid(compute_date, content_object, account_holding):
116
121
  actual_percent = (
117
122
  available_percent * commission.validated_percent
118
123
  if commission.validated_net_commission
@@ -125,7 +130,7 @@ class CommissionType(WBModel):
125
130
 
126
131
  def compute_rebates(
127
132
  self, root_account: "Account", verbose: bool = False, **iterator_kwargs
128
- ) -> Generator[tuple["Account", date, "Commission", models.Model, "Entry", Decimal], None, None]:
133
+ ) -> Generator[tuple["Account", date, "Commission", models.Model, "Entry", Decimal, dict], None, None]:
129
134
  """
130
135
  Generate rebate information for terminal accounts based on given parameters.
131
136
 
@@ -162,7 +167,7 @@ class CommissionType(WBModel):
162
167
  iterator = tqdm(iterator, total=len(iterator))
163
168
 
164
169
  for terminal_account, content_object, compute_date in iterator:
165
- root_account_total_holding = rebate_manager.get_root_account_total_holding(compute_date)
170
+ terminal_accounts_total_holding = rebate_manager.get_terminal_accounts_total_holding(compute_date)
166
171
  terminal_account_holding_ratio = rebate_manager.get_terminal_account_holding_ratio(
167
172
  terminal_account,
168
173
  content_object,
@@ -172,7 +177,7 @@ class CommissionType(WBModel):
172
177
  # Calculate rebates for the current terminal account
173
178
  if terminal_account_holding_ratio:
174
179
  for commission, actual_percent in self.get_valid_commissions(
175
- terminal_account, compute_date, content_object, root_account_total_holding
180
+ terminal_account, compute_date, content_object, terminal_accounts_total_holding
176
181
  ):
177
182
  # total commission pool for the given object that day
178
183
  commission_pool = rebate_manager.get_commission_pool(content_object, compute_date)
@@ -210,7 +215,7 @@ class CommissionType(WBModel):
210
215
  rebate_gain,
211
216
  {
212
217
  "terminal_account_holding_ratio": terminal_account_holding_ratio,
213
- "root_account_total_holding": root_account_total_holding,
218
+ "commission_account_holding": getattr(commission, "commission_account_holding", None),
214
219
  "commission_percent": actual_percent,
215
220
  "commission_pool": commission_pool,
216
221
  "recipient_percent": recipient_percent,
@@ -247,6 +252,13 @@ class Commission(OrderedModel, WBModel):
247
252
  on_delete=models.CASCADE,
248
253
  verbose_name="Account",
249
254
  )
255
+ root_account = models.ForeignKey[Account](
256
+ to="wbcrm.Account",
257
+ related_name="root_account_commissions",
258
+ on_delete=models.CASCADE,
259
+ verbose_name="Root Account",
260
+ ) # we store the root account in order to allow reordering on root account as well as type
261
+
250
262
  crm_recipient = models.ForeignKey[Entry](
251
263
  to="directory.Entry",
252
264
  related_name="recipient_commissions",
@@ -287,6 +299,7 @@ class Commission(OrderedModel, WBModel):
287
299
  verbose_name="Account Role to decide with exclusion rule applies",
288
300
  )
289
301
  objects = CommissionManager()
302
+ order_with_respect_to = ("commission_type", "root_account")
290
303
 
291
304
  if TYPE_CHECKING:
292
305
  rules = RelatedManager["CommissionRule"]()
@@ -333,8 +346,7 @@ class Commission(OrderedModel, WBModel):
333
346
  ),
334
347
  ]
335
348
 
336
- @property
337
- def recipient_repr(self) -> str:
349
+ def get_recipient_repr(self) -> str:
338
350
  if self.crm_recipient:
339
351
  return f"Profile {self.crm_recipient.computed_str}"
340
352
  elif self.portfolio_role_recipient:
@@ -342,11 +354,15 @@ class Commission(OrderedModel, WBModel):
342
354
  else:
343
355
  return f"Account Role Type: {self.account_role_type_recipient.title}"
344
356
 
357
+ def save(self, *args, **kwargs):
358
+ self.root_account = self.account.get_root()
359
+ super().save(*args, **kwargs)
360
+
345
361
  def __str__(self) -> str:
346
362
  return repr(self)
347
363
 
348
364
  def __repr__(self) -> str:
349
- return f"Net: {self.net_commission} - {self.account.title} - {self.order} - {self.commission_type} - {self.recipient_repr} (id: {self.id})"
365
+ return f"Net: {self.net_commission} - {self.account.title} - {self.order} - {self.commission_type} - {self.get_recipient_repr()} (id: {self.id})"
350
366
 
351
367
  @property
352
368
  def validated_percent(self) -> Decimal:
@@ -606,6 +622,7 @@ class CommissionRole(ComplexToStringMixin, WBModel):
606
622
  class Meta:
607
623
  verbose_name = "Commission Role"
608
624
  verbose_name_plural = "Commission Roles"
625
+ constraints = [models.UniqueConstraint(fields=["person", "commission"], name="unique_commission_role")]
609
626
 
610
627
  def compute_str(self) -> str:
611
628
  return f"{self.person} -> {self.commission}"
@@ -631,6 +648,16 @@ class CommissionRole(ComplexToStringMixin, WBModel):
631
648
  return "id"
632
649
 
633
650
 
651
+ @receiver(pre_merge, sender="directory.Person")
652
+ def pre_merge_person_commisionrole(sender: models.Model, merged_object, main_object, **kwargs):
653
+ for role in CommissionRole.objects.filter(person=merged_object):
654
+ if CommissionRole.objects.filter(person=main_object, commission=role.commission).exists():
655
+ role.delete()
656
+ else:
657
+ role.person = main_object
658
+ role.save()
659
+
660
+
634
661
  @receiver(post_save, sender="wbcommission.Commission")
635
662
  def post_commission_creation(sender, instance, created, **kwargs):
636
663
  # if a new commission line is created, we create a general rule
@@ -13,6 +13,7 @@ from wbcore.contrib.notifications.dispatch import send_notification
13
13
  from wbcore.contrib.notifications.utils import create_notification_type
14
14
  from wbcore.signals import pre_merge
15
15
  from wbcore.utils.enum import ChoiceEnum
16
+ from wbcore.utils.models import MergeError
16
17
  from wbcore.workers import Queue
17
18
  from wbcrm.models.accounts import Account
18
19
 
@@ -155,7 +156,11 @@ class Rebate(BookingEntryCalculatedValueMixin, models.Model):
155
156
  class Meta:
156
157
  verbose_name = "Rebate"
157
158
  verbose_name_plural = "Rebates"
158
- unique_together = ("date", "recipient", "account", "product", "commission_type")
159
+ constraints = (
160
+ models.UniqueConstraint(
161
+ name="unique_rebate", fields=("date", "recipient", "account", "product", "commission_type")
162
+ ),
163
+ )
159
164
  indexes = [models.Index(fields=["commission_type", "date", "recipient", "product", "account"])]
160
165
  notification_types = [
161
166
  create_notification_type(
@@ -315,3 +320,28 @@ def handle_pre_merge_account_for_rebate(sender: models.Model, merged_object: Acc
315
320
  except Rebate.DoesNotExist:
316
321
  rebate.account = main_object
317
322
  rebate.save()
323
+
324
+
325
+ @receiver(pre_merge, sender="directory.Entry")
326
+ def pre_merge_entry_rebate(sender: models.Model, merged_object, main_object, **kwargs):
327
+ for commission in Commission.objects.filter(crm_recipient=merged_object):
328
+ if Commission.objects.filter(crm_recipient=main_object, account=commission.account).exists():
329
+ raise MergeError(f"Commission already exists for {main_object} and the account {commission.account}")
330
+ else:
331
+ commission.crm_recipient = main_object
332
+ commission.save()
333
+
334
+ for rebate in Rebate.objects.filter(recipient=merged_object):
335
+ if Rebate.objects.filter(
336
+ date=rebate.date,
337
+ recipient=main_object,
338
+ account=rebate.account,
339
+ product=rebate.product,
340
+ commission_type=rebate.commission_type,
341
+ ).exists():
342
+ raise MergeError(
343
+ f"Rebate already exists for {main_object} and the account {rebate.account}, we cannot safely handle this automatically."
344
+ )
345
+ else:
346
+ rebate.recipient = main_object
347
+ rebate.save()
@@ -1,3 +1,3 @@
1
1
  from .rebate import RebateModelSerializer, RebateProductMarginalitySerializer
2
- from .commissions import CommissionTypeRepresentationSerializer, CommissionTypeModelSerializer
2
+ from .commissions import CommissionTypeRepresentationSerializer, CommissionTypeModelSerializer, CommissionModelSerializer, CommissionRepresentationSerializer
3
3
  from .signals import *