karrio-server-pricing 2026.1.1__py3-none-any.whl → 2026.1.4__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.
- karrio/server/pricing/admin.py +119 -23
- karrio/server/pricing/apps.py +5 -1
- karrio/server/pricing/migrations/0076_create_markup_and_fee_models.py +258 -0
- karrio/server/pricing/migrations/0077_migrate_surcharge_to_markup_data.py +70 -0
- karrio/server/pricing/migrations/0078_cleanup.py +109 -0
- karrio/server/pricing/migrations/0079_fee_snapshot_model.py +283 -0
- karrio/server/pricing/models.py +268 -87
- karrio/server/pricing/serializers.py +16 -11
- karrio/server/pricing/signals.py +138 -12
- karrio/server/pricing/tests.py +397 -9
- {karrio_server_pricing-2026.1.1.dist-info → karrio_server_pricing-2026.1.4.dist-info}/METADATA +1 -1
- {karrio_server_pricing-2026.1.1.dist-info → karrio_server_pricing-2026.1.4.dist-info}/RECORD +14 -10
- {karrio_server_pricing-2026.1.1.dist-info → karrio_server_pricing-2026.1.4.dist-info}/WHEEL +1 -1
- {karrio_server_pricing-2026.1.1.dist-info → karrio_server_pricing-2026.1.4.dist-info}/top_level.txt +0 -0
karrio/server/pricing/models.py
CHANGED
|
@@ -2,147 +2,213 @@ import typing
|
|
|
2
2
|
import functools
|
|
3
3
|
import django.db.models as models
|
|
4
4
|
import django.core.validators as validators
|
|
5
|
+
from django.contrib.contenttypes.fields import GenericRelation
|
|
5
6
|
|
|
6
7
|
import karrio.lib as lib
|
|
7
8
|
import karrio.core.models as karrio
|
|
8
9
|
import karrio.server.core.models as core
|
|
9
|
-
import karrio.server.core.fields as fields
|
|
10
10
|
import karrio.server.core.datatypes as datatypes
|
|
11
11
|
import karrio.server.providers.models as providers
|
|
12
|
-
import karrio.server.pricing.serializers as serializers
|
|
13
12
|
from karrio.server.core.logging import logger
|
|
14
13
|
|
|
15
14
|
|
|
15
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
16
|
+
# MARKUP TYPE ENUM
|
|
17
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
MARKUP_TYPE_CHOICES = [
|
|
20
|
+
("AMOUNT", "AMOUNT"),
|
|
21
|
+
("PERCENTAGE", "PERCENTAGE"),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
26
|
+
# MARKUP MODEL (replaces Surcharge)
|
|
27
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
|
|
16
30
|
@core.register_model
|
|
17
|
-
class
|
|
31
|
+
class Markup(core.Entity):
|
|
32
|
+
"""
|
|
33
|
+
Flexible markup/surcharge applied to shipping rates.
|
|
34
|
+
|
|
35
|
+
This is an admin-managed model (no user-level access control).
|
|
36
|
+
Use organization_ids JSONField to scope markups to specific organizations.
|
|
37
|
+
An empty organization_ids list means the markup applies system-wide.
|
|
38
|
+
|
|
39
|
+
Key changes from legacy Surcharge:
|
|
40
|
+
- Renamed from Surcharge to Markup
|
|
41
|
+
- Uses string lists instead of M2M/FK/enum relations
|
|
42
|
+
- connection_ids supports all connection types (account, system, brokered)
|
|
43
|
+
- carrier_codes and service_codes are validated strings, not enums
|
|
44
|
+
"""
|
|
45
|
+
|
|
18
46
|
class Meta:
|
|
19
|
-
db_table = "
|
|
47
|
+
db_table = "markup"
|
|
20
48
|
verbose_name = "Markup"
|
|
21
49
|
verbose_name_plural = "Markups"
|
|
22
|
-
|
|
23
|
-
CONTEXT_RELATIONS = ["carrier_accounts"]
|
|
50
|
+
ordering = ["-created_at"]
|
|
24
51
|
|
|
25
52
|
id = models.CharField(
|
|
26
53
|
max_length=50,
|
|
27
54
|
primary_key=True,
|
|
28
|
-
default=functools.partial(core.uuid, prefix="
|
|
55
|
+
default=functools.partial(core.uuid, prefix="mkp_"),
|
|
29
56
|
editable=False,
|
|
30
57
|
)
|
|
31
|
-
active = models.BooleanField(default=True)
|
|
32
|
-
|
|
33
58
|
name = models.CharField(
|
|
34
59
|
max_length=100,
|
|
35
60
|
unique=True,
|
|
36
|
-
help_text="The
|
|
61
|
+
help_text="The markup name (label) that will appear in the rate breakdown",
|
|
62
|
+
)
|
|
63
|
+
active = models.BooleanField(
|
|
64
|
+
default=True,
|
|
65
|
+
help_text="Whether the markup is active and will be applied to rates",
|
|
37
66
|
)
|
|
38
67
|
amount = models.FloatField(
|
|
39
|
-
validators=[validators.MinValueValidator(0.
|
|
68
|
+
validators=[validators.MinValueValidator(0.0)],
|
|
40
69
|
default=0.0,
|
|
41
70
|
help_text="""
|
|
42
|
-
The
|
|
71
|
+
The markup amount or percentage to add to the quote.
|
|
72
|
+
For AMOUNT type: the exact dollar amount to add.
|
|
73
|
+
For PERCENTAGE type: the percentage (e.g., 5 means 5%).
|
|
43
74
|
""",
|
|
44
75
|
)
|
|
45
|
-
|
|
76
|
+
markup_type = models.CharField(
|
|
46
77
|
max_length=25,
|
|
47
|
-
choices=
|
|
48
|
-
default=
|
|
78
|
+
choices=MARKUP_TYPE_CHOICES,
|
|
79
|
+
default=MARKUP_TYPE_CHOICES[0][0],
|
|
49
80
|
help_text="""
|
|
50
|
-
Determine whether the
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
<br/>
|
|
54
|
-
For <strong>PERCENTAGE</strong>: rate ($22) and amount (5) will result in a new total_charge of ($23.10)
|
|
81
|
+
Determine whether the markup is in percentage or net amount.
|
|
82
|
+
AMOUNT: rate ($22) + amount (1) = $23
|
|
83
|
+
PERCENTAGE: rate ($22) * 5% = $23.10
|
|
55
84
|
""",
|
|
56
85
|
)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
86
|
+
is_visible = models.BooleanField(
|
|
87
|
+
default=True,
|
|
88
|
+
help_text="Whether to show this markup in the rate breakdown to users",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Filters (all string lists, validated on save)
|
|
92
|
+
carrier_codes = models.JSONField(
|
|
93
|
+
default=list,
|
|
60
94
|
blank=True,
|
|
61
95
|
help_text="""
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
Note that by default, the surcharge is applied to all carriers
|
|
96
|
+
List of carrier codes to apply the markup to (e.g., ["fedex", "ups"]).
|
|
97
|
+
Empty list means apply to all carriers.
|
|
65
98
|
""",
|
|
66
99
|
)
|
|
67
|
-
|
|
68
|
-
|
|
100
|
+
service_codes = models.JSONField(
|
|
101
|
+
default=list,
|
|
69
102
|
blank=True,
|
|
70
103
|
help_text="""
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
Note that by default, the surcharge is applied to all carrier accounts
|
|
104
|
+
List of service codes to apply the markup to (e.g., ["fedex_ground", "ups_next_day"]).
|
|
105
|
+
Empty list means apply to all services.
|
|
74
106
|
""",
|
|
75
107
|
)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
null=True,
|
|
108
|
+
connection_ids = models.JSONField(
|
|
109
|
+
default=list,
|
|
79
110
|
blank=True,
|
|
80
111
|
help_text="""
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
112
|
+
List of connection IDs to apply the markup to (e.g., ["car_xxx", "car_yyy"]).
|
|
113
|
+
Supports all connection types: CarrierConnection, SystemConnection, BrokeredConnection.
|
|
114
|
+
Empty list means apply to all connections.
|
|
84
115
|
""",
|
|
85
116
|
)
|
|
117
|
+
organization_ids = models.JSONField(
|
|
118
|
+
default=list,
|
|
119
|
+
blank=True,
|
|
120
|
+
help_text="""
|
|
121
|
+
List of organization IDs to apply the markup to.
|
|
122
|
+
Empty list means apply to all organizations (system-wide).
|
|
123
|
+
""",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Metadata
|
|
127
|
+
metadata = models.JSONField(
|
|
128
|
+
default=dict,
|
|
129
|
+
blank=True,
|
|
130
|
+
help_text="Additional metadata for the markup",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Metafields via GenericRelation
|
|
134
|
+
metafields = GenericRelation(
|
|
135
|
+
"core.Metafield",
|
|
136
|
+
related_query_name="markup",
|
|
137
|
+
)
|
|
86
138
|
|
|
87
139
|
@property
|
|
88
140
|
def object_type(self):
|
|
89
|
-
return "
|
|
141
|
+
return "markup"
|
|
90
142
|
|
|
91
143
|
def __str__(self):
|
|
92
|
-
type_ = "$" if self.
|
|
93
|
-
return f"{self.
|
|
144
|
+
type_ = "$" if self.markup_type == "AMOUNT" else "%"
|
|
145
|
+
return f"{self.name} ({self.amount}{type_})"
|
|
146
|
+
|
|
147
|
+
def _is_applicable(self, rate: datatypes.Rate) -> bool:
|
|
148
|
+
"""Check if this markup should be applied to the given rate."""
|
|
149
|
+
applicable = []
|
|
150
|
+
|
|
151
|
+
# Check carrier code filter
|
|
152
|
+
if self.carrier_codes:
|
|
153
|
+
# For custom carriers (ext="generic"), check if "generic" is in the carrier list
|
|
154
|
+
if (
|
|
155
|
+
rate.meta
|
|
156
|
+
and rate.meta.get("ext") == "generic"
|
|
157
|
+
and "generic" in self.carrier_codes
|
|
158
|
+
):
|
|
159
|
+
applicable.append(True)
|
|
160
|
+
else:
|
|
161
|
+
applicable.append(rate.carrier_name in self.carrier_codes)
|
|
162
|
+
|
|
163
|
+
# Check connection ID filter
|
|
164
|
+
if self.connection_ids:
|
|
165
|
+
connection_id = rate.meta.get("carrier_connection_id") if rate.meta else None
|
|
166
|
+
applicable.append(connection_id in self.connection_ids)
|
|
167
|
+
|
|
168
|
+
# Check service code filter
|
|
169
|
+
if self.service_codes:
|
|
170
|
+
applicable.append(rate.service in self.service_codes)
|
|
171
|
+
|
|
172
|
+
# All specified filters must match (AND logic)
|
|
173
|
+
# If no filters specified (all empty), markup applies to all rates
|
|
174
|
+
return (not applicable) or (any(applicable) and all(applicable))
|
|
94
175
|
|
|
95
176
|
def apply_charge(self, response: datatypes.RateResponse) -> datatypes.RateResponse:
|
|
177
|
+
"""Apply this markup to all applicable rates in the response."""
|
|
178
|
+
|
|
96
179
|
def apply(rate: datatypes.Rate) -> datatypes.Rate:
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
else
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if any(applicable) and all(applicable):
|
|
120
|
-
logger.debug("Applying broker surcharge to rate", rate_id=rate.id, surcharge_id=self.id)
|
|
121
|
-
|
|
122
|
-
amount = lib.to_decimal(
|
|
123
|
-
self.amount
|
|
124
|
-
if self.surcharge_type == "AMOUNT"
|
|
125
|
-
else (rate.total_charge * (typing.cast(float, self.amount) / 100))
|
|
126
|
-
)
|
|
127
|
-
total_charge = lib.to_decimal(rate.total_charge + amount)
|
|
128
|
-
extra_charges = rate.extra_charges + [
|
|
129
|
-
karrio.ChargeDetails(
|
|
130
|
-
name=typing.cast(str, self.name),
|
|
131
|
-
amount=amount,
|
|
132
|
-
currency=rate.currency,
|
|
133
|
-
id=self.id,
|
|
134
|
-
)
|
|
135
|
-
]
|
|
136
|
-
|
|
137
|
-
return datatypes.Rate(
|
|
138
|
-
**{
|
|
139
|
-
**lib.to_dict(rate),
|
|
140
|
-
"total_charge": total_charge,
|
|
141
|
-
"extra_charges": extra_charges,
|
|
142
|
-
}
|
|
180
|
+
if not self._is_applicable(rate):
|
|
181
|
+
return rate
|
|
182
|
+
|
|
183
|
+
logger.debug(
|
|
184
|
+
"Applying markup to rate",
|
|
185
|
+
rate_id=rate.id,
|
|
186
|
+
markup_id=self.id,
|
|
187
|
+
markup_name=self.name,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
amount = lib.to_decimal(
|
|
191
|
+
self.amount
|
|
192
|
+
if self.markup_type == "AMOUNT"
|
|
193
|
+
else (rate.total_charge * (typing.cast(float, self.amount) / 100))
|
|
194
|
+
)
|
|
195
|
+
total_charge = lib.to_decimal(rate.total_charge + amount)
|
|
196
|
+
extra_charges = rate.extra_charges + [
|
|
197
|
+
karrio.ChargeDetails(
|
|
198
|
+
name=typing.cast(str, self.name),
|
|
199
|
+
amount=amount,
|
|
200
|
+
currency=rate.currency,
|
|
201
|
+
id=self.id,
|
|
143
202
|
)
|
|
203
|
+
]
|
|
144
204
|
|
|
145
|
-
return
|
|
205
|
+
return datatypes.Rate(
|
|
206
|
+
**{
|
|
207
|
+
**lib.to_dict(rate),
|
|
208
|
+
"total_charge": total_charge,
|
|
209
|
+
"extra_charges": extra_charges,
|
|
210
|
+
}
|
|
211
|
+
)
|
|
146
212
|
|
|
147
213
|
return datatypes.RateResponse(
|
|
148
214
|
messages=response.messages,
|
|
@@ -151,3 +217,118 @@ class Surcharge(core.ControlledAccessModel, core.Entity):
|
|
|
151
217
|
key=lambda rate: rate.total_charge,
|
|
152
218
|
),
|
|
153
219
|
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
223
|
+
# FEE MODEL (tracks applied markups for usage statistics)
|
|
224
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class Fee(models.Model):
|
|
228
|
+
"""
|
|
229
|
+
Immutable snapshot of an applied fee at time of shipment purchase.
|
|
230
|
+
Primary source of truth for usage statistics and financial reporting.
|
|
231
|
+
|
|
232
|
+
All reference fields are plain CharFields (no FKs) — this decouples
|
|
233
|
+
fee records from live objects so they survive deletions/changes.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
class Meta:
|
|
237
|
+
db_table = "fee"
|
|
238
|
+
verbose_name = "Fee"
|
|
239
|
+
verbose_name_plural = "Fees"
|
|
240
|
+
ordering = ["-created_at"]
|
|
241
|
+
indexes = [
|
|
242
|
+
# Single-field indexes for fields without db_index=True
|
|
243
|
+
models.Index(fields=["carrier_code"]),
|
|
244
|
+
models.Index(fields=["created_at"]),
|
|
245
|
+
# Composite indexes for time-series queries
|
|
246
|
+
models.Index(fields=["account_id", "created_at"]),
|
|
247
|
+
models.Index(fields=["connection_id", "created_at"]),
|
|
248
|
+
models.Index(fields=["markup_id", "created_at"]),
|
|
249
|
+
]
|
|
250
|
+
|
|
251
|
+
id = models.CharField(
|
|
252
|
+
max_length=50,
|
|
253
|
+
primary_key=True,
|
|
254
|
+
default=functools.partial(core.uuid, prefix="fee_"),
|
|
255
|
+
editable=False,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
# Fee details (captured at time of application)
|
|
259
|
+
name = models.CharField(
|
|
260
|
+
max_length=100,
|
|
261
|
+
help_text="The fee name at time of application",
|
|
262
|
+
)
|
|
263
|
+
amount = models.FloatField(
|
|
264
|
+
help_text="The fee amount in the shipment's currency",
|
|
265
|
+
)
|
|
266
|
+
currency = models.CharField(
|
|
267
|
+
max_length=3,
|
|
268
|
+
help_text="Currency code (e.g., USD, EUR)",
|
|
269
|
+
)
|
|
270
|
+
fee_type = models.CharField(
|
|
271
|
+
max_length=25,
|
|
272
|
+
choices=MARKUP_TYPE_CHOICES,
|
|
273
|
+
help_text="Whether this was a fixed amount or percentage markup",
|
|
274
|
+
)
|
|
275
|
+
percentage = models.FloatField(
|
|
276
|
+
null=True,
|
|
277
|
+
blank=True,
|
|
278
|
+
help_text="Original percentage if this was a percentage-based markup",
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# Markup reference (snapshot, no FK)
|
|
282
|
+
markup_id = models.CharField(
|
|
283
|
+
max_length=50,
|
|
284
|
+
null=True,
|
|
285
|
+
blank=True,
|
|
286
|
+
db_index=True,
|
|
287
|
+
help_text="The markup ID that generated this fee",
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
# Shipment reference (snapshot, no FK)
|
|
291
|
+
shipment_id = models.CharField(
|
|
292
|
+
max_length=50,
|
|
293
|
+
db_index=True,
|
|
294
|
+
help_text="The shipment this fee was applied to",
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# Organization/Account reference (snapshot, no FK)
|
|
298
|
+
account_id = models.CharField(
|
|
299
|
+
max_length=50,
|
|
300
|
+
null=True,
|
|
301
|
+
blank=True,
|
|
302
|
+
db_index=True,
|
|
303
|
+
help_text="The organization/account this fee belongs to",
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# Carrier connection context
|
|
307
|
+
connection_id = models.CharField(
|
|
308
|
+
max_length=50,
|
|
309
|
+
db_index=True,
|
|
310
|
+
help_text="Connection ID used for this shipment",
|
|
311
|
+
)
|
|
312
|
+
carrier_code = models.CharField(
|
|
313
|
+
max_length=50,
|
|
314
|
+
help_text="Carrier code at time of shipment creation",
|
|
315
|
+
)
|
|
316
|
+
service_code = models.CharField(
|
|
317
|
+
max_length=100,
|
|
318
|
+
null=True,
|
|
319
|
+
blank=True,
|
|
320
|
+
help_text="Service code at time of shipment creation",
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
# Context
|
|
324
|
+
test_mode = models.BooleanField(default=False)
|
|
325
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
326
|
+
|
|
327
|
+
def __str__(self):
|
|
328
|
+
return f"{self.name}: {self.amount} {self.currency}"
|
|
329
|
+
|
|
330
|
+
@property
|
|
331
|
+
def object_type(self):
|
|
332
|
+
return "fee"
|
|
333
|
+
|
|
334
|
+
|
|
@@ -1,18 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pricing module serializers.
|
|
3
|
+
|
|
4
|
+
This module provides simple constants and enums for markup types.
|
|
5
|
+
The old enum-based CARRIERS and SERVICES are no longer used - markups
|
|
6
|
+
now use plain string lists for carrier_codes and service_codes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
1
9
|
import enum
|
|
2
|
-
import karrio.server.core.dataunits as dataunits
|
|
3
|
-
from karrio.server.core.serializers import (
|
|
4
|
-
CARRIERS,
|
|
5
|
-
)
|
|
6
10
|
|
|
7
11
|
|
|
8
|
-
class
|
|
12
|
+
class MarkupType(enum.Enum):
|
|
13
|
+
"""Type of markup to apply."""
|
|
9
14
|
AMOUNT = "AMOUNT"
|
|
10
15
|
PERCENTAGE = "PERCENTAGE"
|
|
11
16
|
|
|
12
17
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
SURCHAGE_TYPE =
|
|
18
|
+
# Markup type choices for Django model field
|
|
19
|
+
MARKUP_TYPE = [(c.name, c.name) for c in list(MarkupType)]
|
|
20
|
+
|
|
21
|
+
# Legacy aliases for backward compatibility
|
|
22
|
+
SurchargeType = MarkupType
|
|
23
|
+
SURCHAGE_TYPE = MARKUP_TYPE
|
karrio/server/pricing/signals.py
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pricing module signals.
|
|
3
|
+
|
|
4
|
+
This module provides:
|
|
5
|
+
1. Rate post-processing to apply custom markups to shipping quotes
|
|
6
|
+
2. Fee capture after shipment label creation
|
|
7
|
+
"""
|
|
8
|
+
|
|
1
9
|
import functools
|
|
2
10
|
import importlib
|
|
3
11
|
from django.db.models import Q
|
|
12
|
+
from django.db.models.signals import post_save
|
|
13
|
+
from django.dispatch import receiver
|
|
4
14
|
|
|
5
15
|
from karrio.server.serializers import Context
|
|
6
16
|
from karrio.server.core.gateway import Rates
|
|
@@ -8,26 +18,142 @@ from karrio.server.core.logging import logger
|
|
|
8
18
|
import karrio.server.pricing.models as models
|
|
9
19
|
|
|
10
20
|
|
|
21
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
22
|
+
# RATE POST-PROCESSING (Apply markups to quotes)
|
|
23
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
|
|
11
26
|
def register_rate_post_processing(*args, **kwargs):
|
|
12
|
-
|
|
13
|
-
|
|
27
|
+
"""Register the markup application function for rate post-processing."""
|
|
28
|
+
Rates.post_process_functions += [apply_custom_markups]
|
|
29
|
+
logger.info("Markup rate post-processing registered", module="karrio.pricing")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def apply_custom_markups(context: Context, result):
|
|
33
|
+
"""
|
|
34
|
+
Apply active markups to rate quotes.
|
|
14
35
|
|
|
36
|
+
This function is called after rates are fetched from carriers.
|
|
37
|
+
It applies all active markups that match the organization context.
|
|
15
38
|
|
|
16
|
-
|
|
17
|
-
|
|
39
|
+
Markup scoping via organization_ids JSONField:
|
|
40
|
+
- Markups with org ID in organization_ids apply only to that org
|
|
41
|
+
- Markups with empty organization_ids are system-wide
|
|
42
|
+
"""
|
|
43
|
+
org_id = getattr(context.org, "id", None)
|
|
18
44
|
|
|
19
|
-
if
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
45
|
+
if org_id:
|
|
46
|
+
# Filter markups that either:
|
|
47
|
+
# 1. Have the current organization in their organization_ids list
|
|
48
|
+
# 2. Have an empty organization_ids list (system-wide markups)
|
|
49
|
+
# Note: icontains is used instead of __contains for cross-DB
|
|
50
|
+
# compatibility (SQLite does not support JSON containment lookups).
|
|
51
|
+
_filters = (
|
|
52
|
+
Q(active=True, organization_ids__icontains=org_id)
|
|
53
|
+
| Q(active=True, organization_ids=[]),
|
|
23
54
|
)
|
|
24
55
|
else:
|
|
25
|
-
|
|
56
|
+
# No organization context - only apply system-wide markups
|
|
57
|
+
_filters = (Q(active=True, organization_ids=[]),)
|
|
26
58
|
|
|
27
|
-
|
|
59
|
+
markups = models.Markup.objects.filter(*_filters)
|
|
28
60
|
|
|
29
61
|
return functools.reduce(
|
|
30
|
-
lambda
|
|
31
|
-
|
|
62
|
+
lambda cumulated_result, markup: markup.apply_charge(cumulated_result),
|
|
63
|
+
markups,
|
|
32
64
|
result,
|
|
33
65
|
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
69
|
+
# FEE CAPTURE (Record fees after shipment creation)
|
|
70
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def capture_fees_for_shipment(shipment):
|
|
74
|
+
"""
|
|
75
|
+
Capture fee records for all markups applied to a shipment.
|
|
76
|
+
|
|
77
|
+
This function extracts markup charges from the shipment's selected_rate
|
|
78
|
+
and creates Fee snapshot records for usage statistics and reporting.
|
|
79
|
+
All fields are captured as plain values (no FK references).
|
|
80
|
+
"""
|
|
81
|
+
if not shipment.selected_rate:
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
selected_rate = shipment.selected_rate
|
|
85
|
+
extra_charges = selected_rate.get("extra_charges", [])
|
|
86
|
+
meta = selected_rate.get("meta", {}) or {}
|
|
87
|
+
carrier_code = meta.get("carrier_code") or selected_rate.get("carrier_name", "")
|
|
88
|
+
service_code = selected_rate.get("service", "")
|
|
89
|
+
connection_id = meta.get("carrier_connection_id", "") or meta.get("connection_id", "")
|
|
90
|
+
currency = selected_rate.get("currency", "USD")
|
|
91
|
+
test_mode = getattr(shipment, "test_mode", False)
|
|
92
|
+
|
|
93
|
+
# Resolve account/org ID from shipment's org link
|
|
94
|
+
account_id = None
|
|
95
|
+
if hasattr(shipment, "org"):
|
|
96
|
+
_org = shipment.org.first()
|
|
97
|
+
account_id = getattr(_org, "id", None)
|
|
98
|
+
|
|
99
|
+
for charge in extra_charges:
|
|
100
|
+
charge_id = charge.get("id")
|
|
101
|
+
|
|
102
|
+
# Only capture charges that have IDs starting with 'mkp_' (our markups)
|
|
103
|
+
# or 'chrg_' (legacy surcharges)
|
|
104
|
+
if not charge_id or not (charge_id.startswith("mkp_") or charge_id.startswith("chrg_")):
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
# Look up markup for fee_type/percentage snapshot
|
|
108
|
+
markup = models.Markup.objects.filter(id=charge_id).first()
|
|
109
|
+
|
|
110
|
+
# Create fee snapshot record (no FK references)
|
|
111
|
+
try:
|
|
112
|
+
models.Fee.objects.create(
|
|
113
|
+
shipment_id=shipment.id,
|
|
114
|
+
markup_id=charge_id,
|
|
115
|
+
account_id=account_id,
|
|
116
|
+
test_mode=test_mode,
|
|
117
|
+
name=charge.get("name", ""),
|
|
118
|
+
amount=charge.get("amount", 0),
|
|
119
|
+
currency=currency,
|
|
120
|
+
fee_type=markup.markup_type if markup else "AMOUNT",
|
|
121
|
+
percentage=markup.amount if markup and markup.markup_type == "PERCENTAGE" else None,
|
|
122
|
+
carrier_code=carrier_code,
|
|
123
|
+
service_code=service_code,
|
|
124
|
+
connection_id=connection_id,
|
|
125
|
+
)
|
|
126
|
+
logger.debug(
|
|
127
|
+
"Fee captured for shipment",
|
|
128
|
+
shipment_id=shipment.id,
|
|
129
|
+
markup_id=charge_id,
|
|
130
|
+
amount=charge.get("amount"),
|
|
131
|
+
)
|
|
132
|
+
except Exception as e:
|
|
133
|
+
logger.warning(
|
|
134
|
+
"Failed to capture fee for shipment",
|
|
135
|
+
shipment_id=shipment.id,
|
|
136
|
+
charge_id=charge_id,
|
|
137
|
+
error=str(e),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def register_fee_capture(*args, **kwargs):
|
|
142
|
+
"""Register the fee capture signal for shipment post-save."""
|
|
143
|
+
# Import here to avoid circular imports
|
|
144
|
+
import karrio.server.manager.models as manager
|
|
145
|
+
|
|
146
|
+
@receiver(post_save, sender=manager.Shipment)
|
|
147
|
+
def on_shipment_saved(sender, instance, created, **kwargs):
|
|
148
|
+
"""Capture fees when a shipment is created or updated with a selected rate."""
|
|
149
|
+
# Only capture fees when:
|
|
150
|
+
# 1. Shipment has a selected_rate (label was purchased)
|
|
151
|
+
# 2. Shipment status indicates it's been purchased/processed
|
|
152
|
+
if instance.selected_rate and instance.status not in ["draft", "created"]:
|
|
153
|
+
# Check if we've already captured fees for this shipment
|
|
154
|
+
if not models.Fee.objects.filter(shipment_id=instance.id).exists():
|
|
155
|
+
capture_fees_for_shipment(instance)
|
|
156
|
+
|
|
157
|
+
logger.info("Fee capture signal registered", module="karrio.pricing")
|
|
158
|
+
|
|
159
|
+
|