Coverage for src/backend/InvenTree/common/currency.py: 43%
116 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
1"""Helper functions for currency support."""
3import decimal
4import math
5from typing import Optional
7from django.core.exceptions import ValidationError
8from django.utils.translation import gettext_lazy as _
10import structlog
11from moneyed import CURRENCIES
13import InvenTree.helpers
14import InvenTree.ready
16logger = structlog.get_logger('inventree')
19def currency_code_default(create: bool = True):
20 """Returns the default currency code (or USD if not specified)."""
21 from common.settings import get_global_setting
22 from InvenTree.ready import isRunningBackup, isRunningMigrations
24 if isRunningMigrations() or isRunningBackup(): 24 ↛ 26line 24 didn't jump to line 26 because the condition on line 24 was never true
25 # Prevent database writes during migration or backup operations
26 create = False
28 code = ''
30 if InvenTree.ready.isAppLoaded('common'): 30 ↛ 39line 30 didn't jump to line 39 because the condition on line 30 was always true
31 try:
32 code = get_global_setting(
33 'INVENTREE_DEFAULT_CURRENCY', create=create, cache=True
34 )
35 except Exception: # pragma: no cover
36 # Database may not yet be ready, no need to throw an error here
37 code = ''
39 if not code or code not in CURRENCIES: 39 ↛ 40line 39 didn't jump to line 40 because the condition on line 39 was never true
40 code = 'USD' # pragma: no cover
42 return code
45def all_currency_codes() -> list:
46 """Returns a list of all currency codes."""
47 return [(a, CURRENCIES[a].name) for a in CURRENCIES]
50def currency_codes_default_list() -> str:
51 """Return a comma-separated list of default currency codes."""
52 return 'AUD,CAD,CNY,EUR,GBP,JPY,NZD,USD'
55def currency_codes() -> list:
56 """Returns the current currency codes."""
57 from common.settings import get_global_setting
59 codes = None
61 # Ensure we do not hit the database until the common app is loaded
62 if InvenTree.ready.isAppLoaded('common'):
63 codes = get_global_setting(
64 'CURRENCY_CODES', create=False, environment_key='INVENTREE_CURRENCY_CODES'
65 ).strip()
67 if not codes:
68 codes = currency_codes_default_list()
70 codes = codes.split(',')
72 valid_codes = []
74 for code in codes:
75 code = code.strip().upper()
77 if code in valid_codes: 77 ↛ 78line 77 didn't jump to line 78 because the condition on line 77 was never true
78 continue
80 if code in CURRENCIES: 80 ↛ 83line 80 didn't jump to line 83 because the condition on line 80 was always true
81 valid_codes.append(code)
82 else:
83 logger.warning(f"Invalid currency code: '{code}'")
85 if len(valid_codes) == 0: 85 ↛ 86line 85 didn't jump to line 86 because the condition on line 85 was never true
86 valid_codes = list(currency_codes_default_list().split(','))
88 return valid_codes
91def currency_code_mappings() -> list:
92 """Returns the current currency choices."""
93 return [(a, f'{a} - {CURRENCIES[a].name}') for a in currency_codes()]
96def after_change_currency(setting) -> None:
97 """Callback function when base currency is changed.
99 - Update exchange rates
100 - Recalculate prices for all parts
101 """
102 import InvenTree.ready
103 import InvenTree.tasks
105 if InvenTree.ready.isImportingData(): 105 ↛ 106line 105 didn't jump to line 106 because the condition on line 105 was never true
106 return
108 if not InvenTree.ready.canAppAccessDatabase(): 108 ↛ 109line 108 didn't jump to line 109 because the condition on line 108 was never true
109 return
111 from part import tasks as part_tasks
113 # Immediately update exchange rates
114 InvenTree.tasks.update_exchange_rates(force=True)
116 # Offload update of part prices to a background task
117 InvenTree.tasks.offload_task(
118 part_tasks.check_missing_pricing, force_async=True, group='pricing'
119 )
122def validate_currency_codes(value):
123 """Validate the currency codes."""
124 values = value.strip().split(',')
126 valid_currencies = set()
128 for code in values:
129 code = code.strip().upper()
131 if not code:
132 continue
134 if code not in CURRENCIES:
135 raise ValidationError(_('Invalid currency code') + f": '{code}'")
136 elif code in valid_currencies:
137 raise ValidationError(_('Duplicate currency code') + f": '{code}'")
138 else:
139 valid_currencies.add(code)
141 if len(valid_currencies) == 0:
142 raise ValidationError(_('No valid currency codes provided'))
144 return list(valid_currencies)
147def currency_exchange_plugins() -> Optional[list]:
148 """Return a list of plugin choices which can be used for currency exchange."""
149 try:
150 from plugin import PluginMixinEnum, registry
152 plugs = registry.with_mixin(PluginMixinEnum.CURRENCY_EXCHANGE, active=True)
153 except Exception:
154 plugs = []
156 if len(plugs) == 0: 156 ↛ 157line 156 didn't jump to line 157 because the condition on line 156 was never true
157 return None
159 return [('', _('No plugin'))] + [(plug.slug, plug.human_name) for plug in plugs]
162def get_price(
163 instance,
164 quantity,
165 moq=True,
166 multiples=True,
167 currency=None,
168 break_name: str = 'price_breaks',
169):
170 """Calculate the price based on quantity price breaks.
172 - Don't forget to add in flat-fee cost (base_cost field)
173 - If MOQ (minimum order quantity) is required, bump quantity
174 - If order multiples are to be observed, then we need to calculate based on that, too
175 """
176 # from common.currency import currency_code_default
178 if hasattr(instance, break_name):
179 price_breaks = getattr(instance, break_name).all()
180 else:
181 price_breaks = []
183 # No price break information available?
184 if len(price_breaks) == 0:
185 return None
187 # Check if quantity is fraction and disable multiples
188 multiples = quantity % 1 == 0
190 # Order multiples
191 if multiples:
192 quantity = int(math.ceil(quantity / instance.multiple) * instance.multiple)
194 pb_found = False
195 pb_quantity = -1
196 pb_cost = 0.0
198 if currency is None:
199 # Default currency selection
200 currency = currency_code_default()
202 pb_min = None
203 for pb in price_breaks:
204 # Store smallest price break
205 if not pb_min:
206 pb_min = pb
208 # Ignore this pricebreak (quantity is too high)
209 if pb.quantity > quantity:
210 continue
212 pb_found = True
214 # If this price-break quantity is the largest so far, use it!
215 if pb.quantity > pb_quantity:
216 pb_quantity = pb.quantity
218 # Convert everything to the selected currency
219 pb_cost = pb.convert_to(currency)
221 # Use smallest price break
222 if not pb_found and pb_min:
223 # Update price break information
224 pb_quantity = pb_min.quantity
225 pb_cost = pb_min.convert_to(currency)
226 # Trigger cost calculation using smallest price break
227 pb_found = True
229 # Convert quantity to decimal.Decimal format
230 quantity = decimal.Decimal(f'{quantity}')
232 if pb_found:
233 cost = decimal.Decimal(pb_cost) * quantity
234 return InvenTree.helpers.normalize(cost + instance.base_cost)
235 return None