karrio-server-pricing 2026.1.1__py3-none-any.whl → 2026.1.3__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.
@@ -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 Surcharge(core.ControlledAccessModel, core.Entity):
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 = "surcharge"
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="chrg_"),
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 surcharge name (label) that will appear in the rate (quote)",
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.1)],
68
+ validators=[validators.MinValueValidator(0.0)],
40
69
  default=0.0,
41
70
  help_text="""
42
- The surcharge amount or percentage to add to the quote
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
- surcharge_type = models.CharField(
76
+ markup_type = models.CharField(
46
77
  max_length=25,
47
- choices=serializers.SURCHAGE_TYPE,
48
- default=serializers.SURCHAGE_TYPE[0][0],
78
+ choices=MARKUP_TYPE_CHOICES,
79
+ default=MARKUP_TYPE_CHOICES[0][0],
49
80
  help_text="""
50
- Determine whether the surcharge is in percentage or net amount
51
- <br/><br/>
52
- For <strong>AMOUNT</strong>: rate ($22) and amount (1) will result in a new total_charge of ($23)
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
- carriers = fields.MultiChoiceField(
58
- choices=serializers.CARRIERS,
59
- null=True,
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
- The list of carriers you want to apply the surcharge to.
63
- <br/>
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
- carrier_accounts = models.ManyToManyField(
68
- providers.Carrier,
100
+ service_codes = models.JSONField(
101
+ default=list,
69
102
  blank=True,
70
103
  help_text="""
71
- The list of carrier accounts you want to apply the surcharge to.
72
- <br/>
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
- services = fields.MultiChoiceField(
77
- choices=serializers.SERVICES,
78
- null=True,
108
+ connection_ids = models.JSONField(
109
+ default=list,
79
110
  blank=True,
80
111
  help_text="""
81
- The list of services you want to apply the surcharge to.
82
- <br/>
83
- Note that by default, the surcharge is applied to all services
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 "surcharge"
141
+ return "markup"
90
142
 
91
143
  def __str__(self):
92
- type_ = "$" if self.surcharge_type == "AMOUNT" else "%"
93
- return f"{self.id} ({self.amount} {type_})"
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
- applicable = []
98
- carrier_ids = [c.carrier_id for c in self.carrier_accounts.all()]
99
- charges = getattr(rate, "extra_charges", None) or []
100
-
101
- if any(self.carriers or []):
102
- # For custom carriers (ext="generic"), check if "generic" is in the addon's carrier list
103
- # since rate.carrier_name contains custom_carrier_name but users select "generic"
104
- if (
105
- rate.meta
106
- and rate.meta.get("ext") == "generic"
107
- and "generic" in self.carriers
108
- ):
109
- applicable.append(True)
110
- else:
111
- applicable.append(rate.carrier_name in self.carriers)
112
-
113
- if any(carrier_ids):
114
- applicable.append(rate.carrier_id in carrier_ids)
115
-
116
- if any(self.services or []):
117
- applicable.append(rate.service in self.services)
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 rate
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 SurchargeType(enum.Enum):
12
+ class MarkupType(enum.Enum):
13
+ """Type of markup to apply."""
9
14
  AMOUNT = "AMOUNT"
10
15
  PERCENTAGE = "PERCENTAGE"
11
16
 
12
17
 
13
- CARRIER_SERVICES = [
14
- dataunits.REFERENCE_MODELS["services"][name]
15
- for name in sorted(dataunits.REFERENCE_MODELS["services"].keys())
16
- ]
17
- SERVICES = [(code, code) for services in CARRIER_SERVICES for code in services]
18
- SURCHAGE_TYPE = [(c.name, c.name) for c in list(SurchargeType)]
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
@@ -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,140 @@ 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
- Rates.post_process_functions += [apply_custom_surcharges]
13
- logger.info("Signal registration complete", module="karrio.pricing")
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
- def apply_custom_surcharges(context: Context, result):
17
- _filters = tuple()
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 importlib.util.find_spec("karrio.server.orgs") is not None:
20
- _filters += (
21
- Q(active=True, org__id=getattr(context.org, "id", None))
22
- | Q(active=True, org=None),
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
+ _filters = (
50
+ Q(active=True, organization_ids__contains=[org_id])
51
+ | Q(active=True, organization_ids=[]),
23
52
  )
24
53
  else:
25
- _filters += (Q(active=True),)
54
+ # No organization context - only apply system-wide markups
55
+ _filters = (Q(active=True, organization_ids=[]),)
26
56
 
27
- charges = models.Surcharge.objects.filter(*_filters)
57
+ markups = models.Markup.objects.filter(*_filters)
28
58
 
29
59
  return functools.reduce(
30
- lambda cummulated_result, charge: charge.apply_charge(cummulated_result),
31
- charges,
60
+ lambda cumulated_result, markup: markup.apply_charge(cumulated_result),
61
+ markups,
32
62
  result,
33
63
  )
64
+
65
+
66
+ # ─────────────────────────────────────────────────────────────────────────────
67
+ # FEE CAPTURE (Record fees after shipment creation)
68
+ # ─────────────────────────────────────────────────────────────────────────────
69
+
70
+
71
+ def capture_fees_for_shipment(shipment):
72
+ """
73
+ Capture fee records for all markups applied to a shipment.
74
+
75
+ This function extracts markup charges from the shipment's selected_rate
76
+ and creates Fee snapshot records for usage statistics and reporting.
77
+ All fields are captured as plain values (no FK references).
78
+ """
79
+ if not shipment.selected_rate:
80
+ return
81
+
82
+ selected_rate = shipment.selected_rate
83
+ extra_charges = selected_rate.get("extra_charges", [])
84
+ meta = selected_rate.get("meta", {}) or {}
85
+ carrier_code = meta.get("carrier_code") or selected_rate.get("carrier_name", "")
86
+ service_code = selected_rate.get("service", "")
87
+ connection_id = meta.get("carrier_connection_id", "") or meta.get("connection_id", "")
88
+ currency = selected_rate.get("currency", "USD")
89
+ test_mode = getattr(shipment, "test_mode", False)
90
+
91
+ # Resolve account/org ID from shipment's org link
92
+ account_id = None
93
+ if hasattr(shipment, "org"):
94
+ _org = shipment.org.first()
95
+ account_id = getattr(_org, "id", None)
96
+
97
+ for charge in extra_charges:
98
+ charge_id = charge.get("id")
99
+
100
+ # Only capture charges that have IDs starting with 'mkp_' (our markups)
101
+ # or 'chrg_' (legacy surcharges)
102
+ if not charge_id or not (charge_id.startswith("mkp_") or charge_id.startswith("chrg_")):
103
+ continue
104
+
105
+ # Look up markup for fee_type/percentage snapshot
106
+ markup = models.Markup.objects.filter(id=charge_id).first()
107
+
108
+ # Create fee snapshot record (no FK references)
109
+ try:
110
+ models.Fee.objects.create(
111
+ shipment_id=shipment.id,
112
+ markup_id=charge_id,
113
+ account_id=account_id,
114
+ test_mode=test_mode,
115
+ name=charge.get("name", ""),
116
+ amount=charge.get("amount", 0),
117
+ currency=currency,
118
+ fee_type=markup.markup_type if markup else "AMOUNT",
119
+ percentage=markup.amount if markup and markup.markup_type == "PERCENTAGE" else None,
120
+ carrier_code=carrier_code,
121
+ service_code=service_code,
122
+ connection_id=connection_id,
123
+ )
124
+ logger.debug(
125
+ "Fee captured for shipment",
126
+ shipment_id=shipment.id,
127
+ markup_id=charge_id,
128
+ amount=charge.get("amount"),
129
+ )
130
+ except Exception as e:
131
+ logger.warning(
132
+ "Failed to capture fee for shipment",
133
+ shipment_id=shipment.id,
134
+ charge_id=charge_id,
135
+ error=str(e),
136
+ )
137
+
138
+
139
+ def register_fee_capture(*args, **kwargs):
140
+ """Register the fee capture signal for shipment post-save."""
141
+ # Import here to avoid circular imports
142
+ import karrio.server.manager.models as manager
143
+
144
+ @receiver(post_save, sender=manager.Shipment)
145
+ def on_shipment_saved(sender, instance, created, **kwargs):
146
+ """Capture fees when a shipment is created or updated with a selected rate."""
147
+ # Only capture fees when:
148
+ # 1. Shipment has a selected_rate (label was purchased)
149
+ # 2. Shipment status indicates it's been purchased/processed
150
+ if instance.selected_rate and instance.status not in ["draft", "created"]:
151
+ # Check if we've already captured fees for this shipment
152
+ if not models.Fee.objects.filter(shipment_id=instance.id).exists():
153
+ capture_fees_for_shipment(instance)
154
+
155
+ logger.info("Fee capture signal registered", module="karrio.pricing")
156
+
157
+