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

1"""Helper functions for currency support.""" 

2 

3import decimal 

4import math 

5from typing import Optional 

6 

7from django.core.exceptions import ValidationError 

8from django.utils.translation import gettext_lazy as _ 

9 

10import structlog 

11from moneyed import CURRENCIES 

12 

13import InvenTree.helpers 

14import InvenTree.ready 

15 

16logger = structlog.get_logger('inventree') 

17 

18 

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 

23 

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 

27 

28 code = '' 

29 

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 = '' 

38 

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 

41 

42 return code 

43 

44 

45def all_currency_codes() -> list: 

46 """Returns a list of all currency codes.""" 

47 return [(a, CURRENCIES[a].name) for a in CURRENCIES] 

48 

49 

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' 

53 

54 

55def currency_codes() -> list: 

56 """Returns the current currency codes.""" 

57 from common.settings import get_global_setting 

58 

59 codes = None 

60 

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() 

66 

67 if not codes: 

68 codes = currency_codes_default_list() 

69 

70 codes = codes.split(',') 

71 

72 valid_codes = [] 

73 

74 for code in codes: 

75 code = code.strip().upper() 

76 

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 

79 

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}'") 

84 

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(',')) 

87 

88 return valid_codes 

89 

90 

91def currency_code_mappings() -> list: 

92 """Returns the current currency choices.""" 

93 return [(a, f'{a} - {CURRENCIES[a].name}') for a in currency_codes()] 

94 

95 

96def after_change_currency(setting) -> None: 

97 """Callback function when base currency is changed. 

98 

99 - Update exchange rates 

100 - Recalculate prices for all parts 

101 """ 

102 import InvenTree.ready 

103 import InvenTree.tasks 

104 

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 

107 

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 

110 

111 from part import tasks as part_tasks 

112 

113 # Immediately update exchange rates 

114 InvenTree.tasks.update_exchange_rates(force=True) 

115 

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 ) 

120 

121 

122def validate_currency_codes(value): 

123 """Validate the currency codes.""" 

124 values = value.strip().split(',') 

125 

126 valid_currencies = set() 

127 

128 for code in values: 

129 code = code.strip().upper() 

130 

131 if not code: 

132 continue 

133 

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) 

140 

141 if len(valid_currencies) == 0: 

142 raise ValidationError(_('No valid currency codes provided')) 

143 

144 return list(valid_currencies) 

145 

146 

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 

151 

152 plugs = registry.with_mixin(PluginMixinEnum.CURRENCY_EXCHANGE, active=True) 

153 except Exception: 

154 plugs = [] 

155 

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 

158 

159 return [('', _('No plugin'))] + [(plug.slug, plug.human_name) for plug in plugs] 

160 

161 

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. 

171 

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 

177 

178 if hasattr(instance, break_name): 

179 price_breaks = getattr(instance, break_name).all() 

180 else: 

181 price_breaks = [] 

182 

183 # No price break information available? 

184 if len(price_breaks) == 0: 

185 return None 

186 

187 # Check if quantity is fraction and disable multiples 

188 multiples = quantity % 1 == 0 

189 

190 # Order multiples 

191 if multiples: 

192 quantity = int(math.ceil(quantity / instance.multiple) * instance.multiple) 

193 

194 pb_found = False 

195 pb_quantity = -1 

196 pb_cost = 0.0 

197 

198 if currency is None: 

199 # Default currency selection 

200 currency = currency_code_default() 

201 

202 pb_min = None 

203 for pb in price_breaks: 

204 # Store smallest price break 

205 if not pb_min: 

206 pb_min = pb 

207 

208 # Ignore this pricebreak (quantity is too high) 

209 if pb.quantity > quantity: 

210 continue 

211 

212 pb_found = True 

213 

214 # If this price-break quantity is the largest so far, use it! 

215 if pb.quantity > pb_quantity: 

216 pb_quantity = pb.quantity 

217 

218 # Convert everything to the selected currency 

219 pb_cost = pb.convert_to(currency) 

220 

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 

228 

229 # Convert quantity to decimal.Decimal format 

230 quantity = decimal.Decimal(f'{quantity}') 

231 

232 if pb_found: 

233 cost = decimal.Decimal(pb_cost) * quantity 

234 return InvenTree.helpers.normalize(cost + instance.base_cost) 

235 return None