spree_core 6.0.0.beta4 → 6.0.0.rc2

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 (575) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/app/helpers/spree/images_helper.rb +0 -57
  4. data/app/jobs/spree/base_job.rb +21 -0
  5. data/app/jobs/spree/carts/reap_expired_job.rb +2 -2
  6. data/app/jobs/spree/collections/regenerate_time_based_job.rb +6 -3
  7. data/app/jobs/spree/orders/finalize_stale_drafts_job.rb +8 -5
  8. data/app/jobs/spree/payments/cancel_unused_sessions_job.rb +61 -0
  9. data/app/jobs/spree/search_provider/index_job.rb +4 -0
  10. data/app/jobs/spree/seller_payouts/sweep_seller_job.rb +2 -0
  11. data/app/jobs/spree/uploads/purge_unattached_job.rb +49 -0
  12. data/app/mailers/spree/admin_user_mailer.rb +4 -19
  13. data/app/mailers/spree/base_mailer.rb +97 -13
  14. data/app/mailers/spree/email_template_mailer.rb +27 -0
  15. data/app/mailers/spree/export_mailer.rb +3 -4
  16. data/app/mailers/spree/import_mailer.rb +3 -4
  17. data/app/mailers/spree/invitation_mailer.rb +24 -7
  18. data/app/mailers/spree/seller_user_mailer.rb +4 -16
  19. data/app/mailers/spree/webhook_mailer.rb +2 -2
  20. data/app/models/concerns/spree/acted_by.rb +1 -1
  21. data/app/models/concerns/spree/admin_user_methods.rb +15 -1
  22. data/app/models/concerns/spree/api_typed.rb +91 -0
  23. data/app/models/concerns/spree/calculated_adjustments.rb +41 -13
  24. data/app/models/concerns/spree/calculator/currency_amounts.rb +1 -1
  25. data/app/models/concerns/spree/calculator/tiers.rb +63 -0
  26. data/app/models/concerns/spree/canonical_decimals.rb +61 -0
  27. data/app/models/concerns/spree/customer_methods.rb +23 -18
  28. data/app/models/concerns/spree/derived_number.rb +11 -13
  29. data/app/models/concerns/spree/email_template_identity.rb +52 -0
  30. data/app/models/concerns/spree/parameterizable_name.rb +2 -0
  31. data/app/models/concerns/spree/payment_session_transitions.rb +8 -2
  32. data/app/models/concerns/spree/post_sale/taxation.rb +54 -0
  33. data/app/models/concerns/spree/post_sale/taxed_line.rb +51 -0
  34. data/app/models/concerns/spree/preference_schema/json_schema.rb +139 -0
  35. data/app/models/concerns/spree/preference_schema.rb +62 -32
  36. data/app/models/concerns/spree/product_scopes.rb +16 -2
  37. data/app/models/concerns/spree/provider_listing.rb +41 -0
  38. data/app/models/concerns/spree/publishable.rb +82 -14
  39. data/app/models/concerns/spree/purchase/addresses.rb +23 -10
  40. data/app/models/concerns/spree/purchase/currency.rb +1 -1
  41. data/app/models/concerns/spree/purchase/deprecated_aliases.rb +87 -0
  42. data/app/models/concerns/spree/purchase/gift_cards.rb +10 -4
  43. data/app/models/concerns/spree/purchase/locale.rb +1 -1
  44. data/app/models/concerns/spree/purchase/payment_processing.rb +8 -4
  45. data/app/models/concerns/spree/purchase/purchase_order.rb +9 -4
  46. data/app/models/concerns/spree/purchase/store_credits.rb +36 -1
  47. data/app/models/concerns/spree/purchase/totals.rb +49 -8
  48. data/app/models/concerns/spree/ransackable_attributes.rb +46 -0
  49. data/app/models/concerns/spree/receivable.rb +1 -1
  50. data/app/models/concerns/spree/searchable.rb +0 -4
  51. data/app/models/concerns/spree/secret_preferences.rb +59 -0
  52. data/app/models/concerns/spree/signed_id_attachments.rb +54 -0
  53. data/app/models/concerns/spree/single_store_resource.rb +1 -1
  54. data/app/models/concerns/spree/stores/email_branding.rb +32 -0
  55. data/app/models/concerns/spree/stores/markets.rb +5 -2
  56. data/app/models/concerns/spree/type_labels.rb +38 -0
  57. data/app/models/concerns/spree/typed_adjustment_line.rb +5 -6
  58. data/app/models/concerns/spree/typed_associations.rb +12 -18
  59. data/app/models/concerns/spree/vat_price_calculation.rb +5 -6
  60. data/app/models/spree/address.rb +12 -2
  61. data/app/models/spree/adjusters/largest_remainder.rb +14 -1
  62. data/app/models/spree/adjusters/promotion.rb +4 -19
  63. data/app/models/spree/allowed_origin.rb +3 -1
  64. data/app/models/spree/api_key.rb +5 -6
  65. data/app/models/spree/authentication/strategies/oidc_strategy.rb +5 -5
  66. data/app/models/spree/base.rb +99 -22
  67. data/app/models/spree/calculator/flat_percent_item_total.rb +2 -4
  68. data/app/models/spree/calculator/flat_rate.rb +3 -5
  69. data/app/models/spree/calculator/flexi_rate.rb +4 -6
  70. data/app/models/spree/calculator/percent_on_line_item.rb +2 -2
  71. data/app/models/spree/calculator/price_sack.rb +5 -7
  72. data/app/models/spree/calculator/shipping/digital_delivery.rb +3 -5
  73. data/app/models/spree/calculator/shipping/flat_percent_item_total.rb +7 -7
  74. data/app/models/spree/calculator/shipping/flat_rate.rb +5 -7
  75. data/app/models/spree/calculator/shipping/flexi_rate.rb +4 -6
  76. data/app/models/spree/calculator/shipping/per_item.rb +3 -5
  77. data/app/models/spree/calculator/shipping/price_sack.rb +5 -7
  78. data/app/models/spree/calculator/tiered_flat_rate.rb +7 -24
  79. data/app/models/spree/calculator/tiered_percent.rb +7 -25
  80. data/app/models/spree/cart.rb +78 -18
  81. data/app/models/spree/catalog.rb +1 -0
  82. data/app/models/spree/catalog_price.rb +0 -7
  83. data/app/models/spree/catalog_price_tier.rb +0 -8
  84. data/app/models/spree/category.rb +3 -1
  85. data/app/models/spree/channel/gating.rb +3 -2
  86. data/app/models/spree/channel.rb +6 -4
  87. data/app/models/spree/claim.rb +22 -1
  88. data/app/models/spree/claim_line_item.rb +41 -6
  89. data/app/models/spree/collection.rb +2 -1
  90. data/app/models/spree/collection_rule.rb +5 -21
  91. data/app/models/spree/commission_line.rb +1 -1
  92. data/app/models/spree/commission_rate.rb +7 -4
  93. data/app/models/spree/commission_rate_value.rb +1 -1
  94. data/app/models/spree/commission_rule.rb +4 -11
  95. data/app/models/spree/commission_rules/category_rule.rb +2 -5
  96. data/app/models/spree/commission_rules/item_total_rule.rb +2 -2
  97. data/app/models/spree/commission_rules/product_rule.rb +1 -1
  98. data/app/models/spree/commission_rules/seller_rule.rb +2 -5
  99. data/app/models/spree/company.rb +1 -0
  100. data/app/models/spree/company_invitation.rb +1 -0
  101. data/app/models/spree/consent_record.rb +0 -1
  102. data/app/models/spree/credit_card.rb +1 -1
  103. data/app/models/spree/current.rb +23 -0
  104. data/app/models/spree/custom_field.rb +2 -2
  105. data/app/models/spree/custom_field_definition.rb +20 -35
  106. data/app/models/spree/custom_fields/boolean.rb +1 -1
  107. data/app/models/spree/custom_fields/json.rb +1 -1
  108. data/app/models/spree/customer_group.rb +1 -0
  109. data/app/models/spree/data_feed/google.rb +0 -2
  110. data/app/models/spree/data_request.rb +2 -1
  111. data/app/models/spree/delivery_method.rb +36 -14
  112. data/app/models/spree/delivery_method_rule.rb +4 -11
  113. data/app/models/spree/delivery_method_rules/channel_rule.rb +2 -5
  114. data/app/models/spree/delivery_method_rules/item_total_rule.rb +2 -2
  115. data/app/models/spree/delivery_profile.rb +1 -0
  116. data/app/models/spree/delivery_rate.rb +7 -8
  117. data/app/models/spree/delivery_rate_provider/base.rb +1 -0
  118. data/app/models/spree/delivery_zone.rb +1 -2
  119. data/app/models/spree/delivery_zone_member.rb +4 -4
  120. data/app/models/spree/digital_asset.rb +7 -0
  121. data/app/models/spree/digital_asset_provider/base.rb +2 -0
  122. data/app/models/spree/digital_link.rb +2 -5
  123. data/app/models/spree/discount.rb +1 -1
  124. data/app/models/spree/email_template.rb +25 -0
  125. data/app/models/spree/email_template_draft.rb +13 -0
  126. data/app/models/spree/email_template_revision.rb +17 -0
  127. data/app/models/spree/email_templates/entry.rb +118 -0
  128. data/app/models/spree/email_templates/sample_record.rb +16 -0
  129. data/app/models/spree/emails/branding.rb +80 -0
  130. data/app/models/spree/exchange.rb +16 -1
  131. data/app/models/spree/exchange_line_item.rb +121 -5
  132. data/app/models/spree/export.rb +0 -1
  133. data/app/models/spree/exports/products.rb +10 -1
  134. data/app/models/spree/exports/report.rb +3 -3
  135. data/app/models/spree/fee.rb +4 -2
  136. data/app/models/spree/file_upload.rb +155 -0
  137. data/app/models/spree/fulfillment.rb +66 -30
  138. data/app/models/spree/fulfillment_changer.rb +2 -2
  139. data/app/models/spree/fulfillment_group.rb +6 -1
  140. data/app/models/spree/fulfillment_item.rb +0 -4
  141. data/app/models/spree/fulfillment_provider/base.rb +1 -0
  142. data/app/models/spree/gateway/bogus.rb +6 -0
  143. data/app/models/spree/gateway/custom_payment_source_method.rb +5 -0
  144. data/app/models/spree/gateway.rb +6 -2
  145. data/app/models/spree/gift_card.rb +18 -8
  146. data/app/models/spree/gift_card_batch.rb +3 -5
  147. data/app/models/spree/import.rb +5 -7
  148. data/app/models/spree/import_mapping.rb +1 -2
  149. data/app/models/spree/import_row.rb +2 -2
  150. data/app/models/spree/import_schemas/products.rb +1 -0
  151. data/app/models/spree/integration.rb +9 -19
  152. data/app/models/spree/invitation.rb +5 -5
  153. data/app/models/spree/line_item.rb +15 -4
  154. data/app/models/spree/locale.rb +11 -8
  155. data/app/models/spree/market.rb +8 -2
  156. data/app/models/spree/media.rb +1 -9
  157. data/app/models/spree/newsletter_subscriber.rb +4 -0
  158. data/app/models/spree/option_type.rb +2 -1
  159. data/app/models/spree/option_value.rb +2 -1
  160. data/app/models/spree/order.rb +60 -142
  161. data/app/models/spree/order_group.rb +18 -4
  162. data/app/models/spree/order_inventory.rb +10 -5
  163. data/app/models/spree/order_promotion.rb +1 -1
  164. data/app/models/spree/order_routing/has_strategy_preference.rb +1 -1
  165. data/app/models/spree/order_routing/strategy/base.rb +4 -2
  166. data/app/models/spree/order_routing/strategy/rules.rb +1 -1
  167. data/app/models/spree/order_routing_rule.rb +6 -22
  168. data/app/models/spree/package_type.rb +2 -2
  169. data/app/models/spree/payment/gateway_options.rb +15 -53
  170. data/app/models/spree/payment/processing.rb +2 -4
  171. data/app/models/spree/payment.rb +21 -13
  172. data/app/models/spree/payment_method/check.rb +2 -0
  173. data/app/models/spree/payment_method/session_instrumentation.rb +1 -0
  174. data/app/models/spree/payment_method/store_credit.rb +15 -13
  175. data/app/models/spree/payment_method.rb +78 -6
  176. data/app/models/spree/payment_session.rb +5 -1
  177. data/app/models/spree/payment_setup_session.rb +1 -0
  178. data/app/models/spree/payout_provider/base.rb +3 -1
  179. data/app/models/spree/pickup_point_provider/base.rb +2 -0
  180. data/app/models/spree/policy.rb +1 -0
  181. data/app/models/spree/price.rb +17 -14
  182. data/app/models/spree/price_adjustment_tier.rb +4 -0
  183. data/app/models/spree/price_list.rb +14 -9
  184. data/app/models/spree/price_rules/channel_rule.rb +3 -6
  185. data/app/models/spree/price_rules/customer_group_rule.rb +3 -6
  186. data/app/models/spree/price_rules/market_rule.rb +2 -5
  187. data/app/models/spree/price_rules/user_rule.rb +1 -3
  188. data/app/models/spree/product/channels.rb +9 -2
  189. data/app/models/spree/product.rb +54 -30
  190. data/app/models/spree/product_submission.rb +0 -12
  191. data/app/models/spree/product_type.rb +1 -1
  192. data/app/models/spree/promotion/actions/create_line_items.rb +28 -18
  193. data/app/models/spree/promotion/rules/category.rb +1 -5
  194. data/app/models/spree/promotion/rules/channel.rb +2 -9
  195. data/app/models/spree/promotion/rules/country.rb +3 -8
  196. data/app/models/spree/promotion/rules/currency.rb +1 -5
  197. data/app/models/spree/promotion/rules/customer_group.rb +2 -9
  198. data/app/models/spree/promotion/rules/first_order.rb +0 -4
  199. data/app/models/spree/promotion/rules/item_total.rb +6 -10
  200. data/app/models/spree/promotion/rules/market.rb +2 -9
  201. data/app/models/spree/promotion/rules/one_use_per_user.rb +0 -4
  202. data/app/models/spree/promotion/rules/option_value.rb +2 -6
  203. data/app/models/spree/promotion/rules/product.rb +1 -5
  204. data/app/models/spree/promotion/rules/user.rb +0 -4
  205. data/app/models/spree/promotion/rules/user_logged_in.rb +0 -4
  206. data/app/models/spree/promotion.rb +36 -6
  207. data/app/models/spree/promotion_action.rb +7 -14
  208. data/app/models/spree/promotion_action_line_item.rb +1 -1
  209. data/app/models/spree/promotion_handler/coupon.rb +59 -27
  210. data/app/models/spree/promotion_rule.rb +7 -14
  211. data/app/models/spree/purchase_order.rb +3 -0
  212. data/app/models/spree/quantity_rule.rb +1 -1
  213. data/app/models/spree/refund.rb +41 -18
  214. data/app/models/spree/return.rb +31 -2
  215. data/app/models/spree/return_line_item.rb +65 -14
  216. data/app/models/spree/role.rb +7 -7
  217. data/app/models/spree/saved_report.rb +2 -1
  218. data/app/models/spree/search_provider/base.rb +4 -0
  219. data/app/models/spree/seller.rb +10 -2
  220. data/app/models/spree/seller_payout.rb +8 -0
  221. data/app/models/spree/seller_requirement.rb +5 -12
  222. data/app/models/spree/seller_requirement_custom_field.rb +1 -2
  223. data/app/models/spree/seller_requirement_submission.rb +3 -2
  224. data/app/models/spree/seller_requirements/accept_terms.rb +2 -3
  225. data/app/models/spree/seller_transfer.rb +6 -0
  226. data/app/models/spree/shipping_label.rb +2 -0
  227. data/app/models/spree/stock/availability_validator.rb +2 -2
  228. data/app/models/spree/stock/estimator.rb +1 -1
  229. data/app/models/spree/stock/inventory_unit_builder.rb +1 -1
  230. data/app/models/spree/stock/packer.rb +2 -2
  231. data/app/models/spree/stock/splitter/weight.rb +1 -1
  232. data/app/models/spree/stock_level/custom_events.rb +6 -0
  233. data/app/models/spree/stock_level.rb +3 -1
  234. data/app/models/spree/stock_location.rb +1 -0
  235. data/app/models/spree/stock_movement.rb +1 -1
  236. data/app/models/spree/stock_receipt_item.rb +1 -1
  237. data/app/models/spree/stock_reservation.rb +2 -2
  238. data/app/models/spree/stock_transfer.rb +6 -2
  239. data/app/models/spree/store.rb +70 -19
  240. data/app/models/spree/store_credit.rb +13 -13
  241. data/app/models/spree/store_credit_event.rb +5 -5
  242. data/app/models/spree/supplier.rb +1 -0
  243. data/app/models/spree/tax_category.rb +1 -0
  244. data/app/models/spree/tax_exemption_certificate.rb +3 -0
  245. data/app/models/spree/tax_identifier.rb +3 -1
  246. data/app/models/spree/tax_line.rb +53 -3
  247. data/app/models/spree/tax_provider/base.rb +71 -14
  248. data/app/models/spree/tax_provider/internal.rb +70 -41
  249. data/app/models/spree/tax_provider/recorded_share.rb +114 -0
  250. data/app/models/spree/tax_rate.rb +30 -8
  251. data/app/models/spree/taxon.rb +1 -1
  252. data/app/models/spree/taxonomy.rb +0 -1
  253. data/app/models/spree/update_status.rb +25 -0
  254. data/app/models/spree/variant.rb +10 -12
  255. data/app/models/spree/variant_media.rb +1 -1
  256. data/app/models/spree/webhook_delivery.rb +1 -0
  257. data/app/models/spree/webhook_endpoint.rb +46 -13
  258. data/app/models/spree/wishlist.rb +1 -6
  259. data/app/models/spree/wishlist_item/custom_events.rb +6 -0
  260. data/app/presenters/spree/csv/customer_presenter.rb +1 -1
  261. data/app/presenters/spree/csv/newsletter_subscriber_presenter.rb +1 -1
  262. data/app/presenters/spree/csv/order_line_item_presenter.rb +10 -6
  263. data/app/presenters/spree/csv/price_list_price_presenter.rb +3 -8
  264. data/app/presenters/spree/csv/product_variant_presenter.rb +59 -20
  265. data/app/presenters/spree/csv/purchase_order_item_presenter.rb +1 -3
  266. data/app/serializers/spree/emails/base_serializer.rb +14 -0
  267. data/app/serializers/spree/emails/export_serializer.rb +13 -0
  268. data/app/serializers/spree/emails/import_serializer.rb +19 -0
  269. data/app/serializers/spree/emails/invitation_serializer.rb +28 -0
  270. data/app/serializers/spree/emails/store_serializer.rb +31 -0
  271. data/app/serializers/spree/emails/user_serializer.rb +12 -0
  272. data/app/serializers/spree/emails/webhook_endpoint_serializer.rb +16 -0
  273. data/app/services/spree/carts/create.rb +8 -2
  274. data/app/services/spree/carts/destroy.rb +2 -2
  275. data/app/services/spree/carts/remove_out_of_stock_items.rb +2 -2
  276. data/app/services/spree/carts/split_by_seller.rb +1 -1
  277. data/app/services/spree/carts/update.rb +22 -8
  278. data/app/services/spree/catalogs/set_product_terms.rb +1 -1
  279. data/app/services/spree/categories/add_products.rb +13 -31
  280. data/app/services/spree/categories/remove_products.rb +12 -51
  281. data/app/services/spree/checkout/advance.rb +1 -4
  282. data/app/services/spree/collections/add_products.rb +10 -23
  283. data/app/services/spree/collections/remove_products.rb +7 -43
  284. data/app/services/spree/coupon_codes/bulk_generate.rb +8 -14
  285. data/app/services/spree/data_requests/create.rb +1 -1
  286. data/app/services/spree/deliveries/destroy.rb +2 -2
  287. data/app/services/spree/email_templates/check.rb +58 -0
  288. data/app/services/spree/email_templates/no_sample_record.rb +7 -0
  289. data/app/services/spree/email_templates/preview.rb +99 -0
  290. data/app/services/spree/emails/file_system.rb +23 -0
  291. data/app/services/spree/emails/filters.rb +84 -0
  292. data/app/services/spree/emails/liquid_context.rb +48 -0
  293. data/app/services/spree/emails/rendered_email.rb +14 -0
  294. data/app/services/spree/emails/renderer.rb +125 -0
  295. data/app/services/spree/emails/template.rb +51 -0
  296. data/app/services/spree/emails/template_data.rb +23 -0
  297. data/app/services/spree/emails/template_resolver.rb +109 -0
  298. data/app/services/spree/emails/text_converter.rb +72 -0
  299. data/app/services/spree/emails/translation_inliner.rb +134 -0
  300. data/app/services/spree/imports/row_processors/price_list_price.rb +10 -10
  301. data/app/services/spree/imports/row_processors/product_variant.rb +77 -34
  302. data/app/services/spree/imports/row_processors/purchase_order.rb +13 -13
  303. data/app/services/spree/locales.rb +43 -10
  304. data/app/services/spree/newsletter/subscribe.rb +8 -12
  305. data/app/services/spree/orders/build_fulfillments.rb +3 -2
  306. data/app/services/spree/orders/create.rb +5 -4
  307. data/app/services/spree/orders/discounts/create.rb +14 -11
  308. data/app/services/spree/orders/discounts/destroy.rb +1 -0
  309. data/app/services/spree/orders/discounts/update.rb +1 -0
  310. data/app/services/spree/orders/fees/create.rb +1 -0
  311. data/app/services/spree/orders/fees/destroy.rb +1 -0
  312. data/app/services/spree/orders/fees/update.rb +1 -0
  313. data/app/services/spree/orders/remove_line_item.rb +13 -3
  314. data/app/services/spree/orders/update.rb +10 -6
  315. data/app/services/spree/orders/update_item.rb +6 -3
  316. data/app/services/spree/orders/update_statuses.rb +3 -1
  317. data/app/services/spree/prices/bulk_upsert.rb +32 -25
  318. data/app/services/spree/product_memberships.rb +61 -0
  319. data/app/services/spree/returns/eligibility_validator.rb +1 -1
  320. data/app/services/spree/search_provider/custom_field_schema.rb +2 -2
  321. data/app/services/spree/seeds/commission_rates.rb +1 -1
  322. data/app/services/spree/seeds/digital_delivery.rb +1 -1
  323. data/app/services/spree/seeds/payment_methods.rb +2 -2
  324. data/app/services/spree/seeds/saved_reports.rb +3 -3
  325. data/app/services/spree/seeds/stores.rb +1 -1
  326. data/app/services/spree/sellers/requirements.rb +1 -2
  327. data/app/services/spree/shipping_labels/confirm_refund.rb +1 -1
  328. data/app/services/spree/shipping_labels/store_file.rb +3 -3
  329. data/app/services/spree/stock_levels/correct.rb +3 -3
  330. data/app/services/spree/stock_reservations/reserve.rb +2 -3
  331. data/app/services/spree/store_credits/apply.rb +15 -13
  332. data/app/services/spree/stores/provision_defaults.rb +23 -25
  333. data/app/services/spree/update_check.rb +160 -0
  334. data/app/services/spree/uploads/assign_stores.rb +92 -0
  335. data/app/subscribers/spree/seller_onboarding_subscriber.rb +1 -1
  336. data/app/validators/spree/attachment_content_type_validator.rb +19 -2
  337. data/app/validators/spree/locale_codes_validator.rb +22 -0
  338. data/app/views/layouts/spree/base_mailer.html.erb +2 -43
  339. data/app/views/layouts/spree/base_mailer.liquid +79 -0
  340. data/app/views/spree/admin_user_mailer/password_reset_email.liquid +14 -0
  341. data/app/views/spree/export_mailer/export_done.liquid +12 -0
  342. data/app/views/spree/import_mailer/import_done.liquid +15 -0
  343. data/app/views/spree/invitation_mailer/invitation_accepted.liquid +9 -0
  344. data/app/views/spree/invitation_mailer/invitation_email.liquid +13 -0
  345. data/app/views/spree/seller_user_mailer/password_reset_email.liquid +14 -0
  346. data/app/views/spree/shared/_mailer_button.html.erb +4 -5
  347. data/app/views/spree/shared/_mailer_hero.html.erb +6 -5
  348. data/app/views/spree/shared/_summary_row.liquid +8 -0
  349. data/app/views/spree/webhook_mailer/endpoint_disabled.liquid +21 -0
  350. data/app/workflows/spree/carts/add_item.rb +4 -4
  351. data/app/workflows/spree/carts/complete.rb +13 -3
  352. data/app/workflows/spree/carts/merge.rb +1 -1
  353. data/app/workflows/spree/carts/recalculate_totals.rb +5 -0
  354. data/app/workflows/spree/carts/upsert_items.rb +18 -9
  355. data/app/workflows/spree/claims/approve.rb +5 -22
  356. data/app/workflows/spree/claims/cancel.rb +5 -21
  357. data/app/workflows/spree/claims/create.rb +15 -0
  358. data/app/workflows/spree/claims/deny.rb +7 -0
  359. data/app/workflows/spree/claims/resolve.rb +34 -31
  360. data/app/workflows/spree/customers/anonymize.rb +3 -4
  361. data/app/workflows/spree/customers/create.rb +1 -1
  362. data/app/workflows/spree/data_requests/fulfill.rb +2 -2
  363. data/app/workflows/spree/deliveries/update_tracking.rb +1 -1
  364. data/app/workflows/spree/email_templates/discard_draft.rb +20 -0
  365. data/app/workflows/spree/email_templates/publish.rb +70 -0
  366. data/app/workflows/spree/email_templates/restore_revision.rb +33 -0
  367. data/app/workflows/spree/email_templates/revert.rb +29 -0
  368. data/app/workflows/spree/email_templates/save_draft.rb +85 -0
  369. data/app/workflows/spree/exchanges/approve.rb +5 -22
  370. data/app/workflows/spree/exchanges/cancel.rb +5 -21
  371. data/app/workflows/spree/exchanges/create.rb +6 -0
  372. data/app/workflows/spree/exchanges/fulfill.rb +75 -51
  373. data/app/workflows/spree/exchanges/receive.rb +6 -65
  374. data/app/workflows/spree/fulfillments/cancel.rb +1 -1
  375. data/app/workflows/spree/fulfillments/create.rb +53 -49
  376. data/app/workflows/spree/fulfillments/fulfill.rb +23 -27
  377. data/app/workflows/spree/fulfillments/mark_delivered.rb +1 -1
  378. data/app/workflows/spree/fulfillments/replacements.rb +59 -0
  379. data/app/workflows/spree/gift_cards/apply.rb +2 -2
  380. data/app/workflows/spree/orders/add_item.rb +5 -1
  381. data/app/workflows/spree/orders/cancel.rb +3 -3
  382. data/app/workflows/spree/orders/complete.rb +16 -8
  383. data/app/workflows/spree/payments/capture.rb +20 -5
  384. data/app/workflows/spree/payments/process.rb +5 -3
  385. data/app/workflows/spree/post_sale/approval.rb +35 -0
  386. data/app/workflows/spree/post_sale/cancellation.rb +44 -0
  387. data/app/workflows/spree/post_sale/receipt.rb +92 -0
  388. data/app/workflows/spree/price_lists/update.rb +4 -4
  389. data/app/workflows/spree/products/nested_attributes.rb +1 -1
  390. data/app/workflows/spree/purchase_orders/cancel.rb +1 -1
  391. data/app/workflows/spree/purchase_orders/close.rb +3 -31
  392. data/app/workflows/spree/purchase_orders/create.rb +3 -1
  393. data/app/workflows/spree/purchase_orders/mark_draft.rb +2 -2
  394. data/app/workflows/spree/purchase_orders/mark_ordered.rb +2 -2
  395. data/app/workflows/spree/purchase_orders/receive.rb +1 -1
  396. data/app/workflows/spree/purchase_orders/update.rb +5 -34
  397. data/app/workflows/spree/receivables/closing_short.rb +46 -0
  398. data/app/workflows/spree/receivables/draft_editing.rb +48 -0
  399. data/app/workflows/spree/refunds/create.rb +5 -2
  400. data/app/workflows/spree/refunds/order_payments.rb +14 -2
  401. data/app/workflows/spree/refunds/tax_credit.rb +67 -0
  402. data/app/workflows/spree/returns/approve.rb +5 -22
  403. data/app/workflows/spree/returns/cancel.rb +5 -22
  404. data/app/workflows/spree/returns/create.rb +8 -0
  405. data/app/workflows/spree/returns/receive.rb +5 -76
  406. data/app/workflows/spree/returns/refund.rb +31 -8
  407. data/app/workflows/spree/seller_requirement_submissions/create.rb +1 -2
  408. data/app/workflows/spree/seller_transfers/reverse.rb +28 -1
  409. data/app/workflows/spree/shipping_labels/purchase.rb +8 -8
  410. data/app/workflows/spree/shipping_labels/record.rb +7 -7
  411. data/app/workflows/spree/shipping_labels/refund.rb +2 -2
  412. data/app/workflows/spree/stock_receipts/recording.rb +4 -4
  413. data/app/workflows/spree/stock_transfers/cancel.rb +2 -2
  414. data/app/workflows/spree/stock_transfers/close.rb +3 -30
  415. data/app/workflows/spree/stock_transfers/create.rb +3 -1
  416. data/app/workflows/spree/stock_transfers/mark_draft.rb +1 -1
  417. data/app/workflows/spree/stock_transfers/mark_in_transit.rb +3 -3
  418. data/app/workflows/spree/stock_transfers/mark_ready.rb +2 -2
  419. data/app/workflows/spree/stock_transfers/receive.rb +1 -1
  420. data/app/workflows/spree/stock_transfers/update.rb +5 -33
  421. data/config/currency_exponents.json +180 -0
  422. data/config/i18n-tasks.yml +32 -18
  423. data/config/initializers/alba.rb +7 -0
  424. data/config/language_names.csv +264 -0
  425. data/config/locales/ar.yml +811 -0
  426. data/config/locales/be.yml +855 -0
  427. data/config/locales/bg.yml +723 -0
  428. data/config/locales/ca.yml +689 -0
  429. data/config/locales/content_locales.rb +9 -0
  430. data/config/locales/cs.yml +855 -0
  431. data/config/locales/da.yml +771 -0
  432. data/config/locales/de-CH.yml +248 -0
  433. data/config/locales/de.yml +835 -0
  434. data/config/locales/el.yml +769 -0
  435. data/config/locales/en.yml +47 -505
  436. data/config/locales/es-CL.yml +301 -0
  437. data/config/locales/es-EC.yml +186 -0
  438. data/config/locales/es-MX.yml +334 -0
  439. data/config/locales/es.yml +831 -0
  440. data/config/locales/et.yml +644 -0
  441. data/config/locales/fa.yml +709 -0
  442. data/config/locales/fi.yml +771 -0
  443. data/config/locales/fr.yml +830 -0
  444. data/config/locales/hu.yml +751 -0
  445. data/config/locales/id.yml +756 -0
  446. data/config/locales/it.yml +833 -0
  447. data/config/locales/ja.yml +726 -0
  448. data/config/locales/km.yml +759 -0
  449. data/config/locales/ko.yml +575 -0
  450. data/config/locales/ku.yml +753 -0
  451. data/config/locales/lt.yml +821 -0
  452. data/config/locales/lv.yml +685 -0
  453. data/config/locales/nb.yml +824 -0
  454. data/config/locales/ne.yml +823 -0
  455. data/config/locales/nl.yml +826 -0
  456. data/config/locales/pl.yml +835 -0
  457. data/config/locales/pt-BR.yml +547 -0
  458. data/config/locales/pt.yml +636 -0
  459. data/config/locales/ro.yml +750 -0
  460. data/config/locales/ru.yml +834 -0
  461. data/config/locales/sk.yml +680 -0
  462. data/config/locales/sl-SI.yml +600 -0
  463. data/config/locales/sv.yml +827 -0
  464. data/config/locales/th.yml +745 -0
  465. data/config/locales/tr.yml +777 -0
  466. data/config/locales/uk.yml +836 -0
  467. data/config/locales/vi.yml +768 -0
  468. data/config/locales/zh-CN.yml +786 -0
  469. data/config/locales/zh-TW.yml +834 -0
  470. data/db/migrate/20260925000001_add_replacement_to_spree_fulfillment_items.rb +5 -0
  471. data/db/migrate/20261001000001_add_post_sale_tax.rb +30 -0
  472. data/db/migrate/20261001000001_convert_preferences_to_json.rb +166 -0
  473. data/db/migrate/20261001000001_create_spree_email_templates.rb +52 -0
  474. data/db/migrate/20261009000001_change_money_columns_to_decimal_19_4.rb +116 -0
  475. data/db/migrate/20261010000001_add_store_id_to_active_storage_blobs.rb +12 -0
  476. data/lib/generators/spree/dummy/dummy_generator.rb +0 -9
  477. data/lib/generators/spree/dummy/templates/rails/test.rb +26 -0
  478. data/lib/spree/checkout/default_requirements.rb +37 -13
  479. data/lib/spree/core/configuration.rb +19 -1
  480. data/lib/spree/core/controller_helpers/store.rb +1 -1
  481. data/lib/spree/core/dependencies.rb +5 -4
  482. data/lib/spree/core/emails/escaped_output.rb +60 -0
  483. data/lib/spree/core/engine.rb +72 -165
  484. data/lib/spree/core/permissions/default_catalog.rb +5 -0
  485. data/lib/spree/core/preferences/invalid_preferences.rb +18 -0
  486. data/lib/spree/core/preferences/json_conversion.rb +186 -0
  487. data/lib/spree/core/preferences/masking.rb +55 -2
  488. data/lib/spree/core/preferences/preferable.rb +343 -103
  489. data/lib/spree/core/preferences/preferable_class_methods.rb +188 -136
  490. data/lib/spree/core/previews/import_preview.rb +10 -0
  491. data/lib/spree/core/ransack_configuration.rb +13 -2
  492. data/lib/spree/core/tax/provider_error.rb +37 -0
  493. data/lib/spree/core/version.rb +1 -1
  494. data/lib/spree/core.rb +91 -373
  495. data/lib/spree/emails/editable_templates.rb +82 -0
  496. data/lib/spree/events/catalog.rb +189 -0
  497. data/lib/spree/events.rb +21 -1
  498. data/lib/spree/hooks.rb +0 -17
  499. data/lib/spree/i18n.rb +32 -15
  500. data/lib/spree/iso_data.rb +7 -0
  501. data/lib/spree/localized_number.rb +6 -0
  502. data/lib/spree/migrations.rb +4 -1
  503. data/lib/spree/money.rb +196 -15
  504. data/lib/spree/reporting/adapters/base.rb +7 -6
  505. data/lib/spree/reporting/adapters/live.rb +6 -6
  506. data/lib/spree/reporting/default_vocabulary.rb +22 -10
  507. data/lib/spree/reporting/hydration.rb +1 -1
  508. data/lib/spree/reporting/query.rb +8 -0
  509. data/lib/spree/reporting/registry.rb +13 -0
  510. data/lib/spree/reporting/schema.rb +10 -10
  511. data/lib/spree/testing_support/editable_email_templates.rb +61 -0
  512. data/lib/spree/testing_support/emails.rb +22 -0
  513. data/lib/spree/testing_support/factories/cart_factory.rb +1 -1
  514. data/lib/spree/testing_support/factories/email_template_factory.rb +28 -0
  515. data/lib/spree/testing_support/factories/order_factory.rb +2 -2
  516. data/lib/spree/testing_support/factories/product_factory.rb +9 -0
  517. data/lib/spree/testing_support/factories/refund_factory.rb +1 -1
  518. data/lib/spree/testing_support/factories/stock_factory.rb +1 -1
  519. data/lib/spree/testing_support/factories/variant_factory.rb +1 -1
  520. data/lib/spree/testing_support/i18n.rb +3 -94
  521. data/lib/spree/testing_support/lifecycle_events.rb +1 -1
  522. data/lib/spree/testing_support/order_walkthrough.rb +1 -1
  523. data/lib/spree/testing_support/preferences.rb +0 -5
  524. data/lib/spree/testing_support/store.rb +9 -1
  525. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +67 -1
  526. data/lib/spree/uploads.rb +101 -0
  527. data/lib/tasks/categories_and_collections.rake +3 -3
  528. data/lib/tasks/core.rake +6 -4
  529. data/lib/tasks/markets.rake +2 -1
  530. data/lib/tasks/media.rake +3 -3
  531. data/lib/tasks/migrate_users_to_customers.rake +23 -18
  532. data/lib/tasks/money.rake +46 -0
  533. data/lib/tasks/package_types.rake +1 -1
  534. data/lib/tasks/preferences_json.rake +101 -0
  535. data/lib/tasks/seller_sample_data.rake +2 -2
  536. data/lib/tasks/store_settings.rake +13 -6
  537. data/lib/tasks/typed_adjustments_migration.rake +1 -1
  538. data/lib/tasks/uploads.rake +19 -0
  539. data/spec/fixtures/mailer_views/custom_views_probe_mailer/welcome_email.html.erb +4 -0
  540. metadata +219 -46
  541. data/app/jobs/spree/products/touch_categories_job.rb +0 -15
  542. data/app/jobs/spree/products/touch_taxons_job.rb +0 -13
  543. data/app/models/acts_as_taggable_on/tag_decorator.rb +0 -10
  544. data/app/models/concerns/spree/has_image_alt_text.rb +0 -18
  545. data/app/models/spree/base_analytics_event_handler.rb +0 -54
  546. data/app/models/spree/invitations/store.rb +0 -6
  547. data/app/models/spree/preference.rb +0 -14
  548. data/app/models/spree/promotion_handler/page.rb +0 -25
  549. data/app/models/spree/promotion_handler/promotion_duplicator.rb +0 -60
  550. data/app/models/spree/stock/differentiator.rb +0 -45
  551. data/app/presenters/spree/filters/options_presenter.rb +0 -47
  552. data/app/services/spree/carts/empty.rb +0 -42
  553. data/app/services/spree/country_to_timezone.rb +0 -273
  554. data/app/services/spree/payments/create.rb +0 -92
  555. data/app/validators/spree/url_validator.rb +0 -23
  556. data/app/views/active_storage/blobs/_blob.html.erb +0 -14
  557. data/app/views/spree/admin_user_mailer/password_reset_email.html.erb +0 -8
  558. data/app/views/spree/export_mailer/export_done.html.erb +0 -7
  559. data/app/views/spree/import_mailer/import_done.html.erb +0 -12
  560. data/app/views/spree/invitation_mailer/invitation_accepted.html.erb +0 -4
  561. data/app/views/spree/invitation_mailer/invitation_email.html.erb +0 -12
  562. data/app/views/spree/seller_user_mailer/password_reset_email.html.erb +0 -8
  563. data/app/views/spree/shared/_base_mailer_footer.html.erb +0 -19
  564. data/app/views/spree/shared/_base_mailer_header.html.erb +0 -8
  565. data/app/views/spree/shared/_base_mailer_stylesheets.html.erb +0 -399
  566. data/app/views/spree/shared/_mailer_logo.html.erb +0 -41
  567. data/app/views/spree/shared/_purchased_items_styles.html.erb +0 -119
  568. data/app/views/spree/webhook_mailer/endpoint_disabled.html.erb +0 -51
  569. data/app/views/spree/webhook_mailer/endpoint_disabled.text.erb +0 -10
  570. data/app/workflows/spree/fulfillments/purchase_label.rb +0 -40
  571. data/app/workflows/spree/returns/purchase_label.rb +0 -32
  572. data/lib/spree/analytics.rb +0 -11
  573. data/lib/spree/core/preferences/scoped_store.rb +0 -34
  574. data/lib/spree/core/preferences/store.rb +0 -105
  575. data/lib/spree/translation_migrations.rb +0 -52
data/lib/spree/money.rb CHANGED
@@ -1,10 +1,23 @@
1
1
  require 'money'
2
2
 
3
3
  Money.locale_backend = :i18n
4
+ Money.default_currency = 'USD'
4
5
  Money.rounding_mode = BigDecimal::ROUND_HALF_UP
5
6
 
6
7
  module Spree
7
8
  class Money
9
+ # An amount that is not a canonical decimal string: a JSON number, a
10
+ # localized or padded string, or more decimals than the field allows.
11
+ class InvalidFormat < ArgumentError
12
+ # @return [Symbol, nil] the request field the amount came from
13
+ attr_reader :field
14
+
15
+ def initialize(message = nil, field: nil)
16
+ super(message)
17
+ @field = field
18
+ end
19
+ end
20
+
8
21
  # How Spree rounds an amount to a currency.
9
22
  #
10
23
  # Lives here beside the rounding mode above because "how much is this worth
@@ -14,42 +27,156 @@ module Spree
14
27
  module Rounding
15
28
  module_function
16
29
 
30
+ # ISO 4217 minor units, the one table Ruby and the TypeScript clients
31
+ # share. The Money gem disagrees for a few currencies (it writes HUF
32
+ # without decimals), so Spree reads its own copy.
33
+ EXPONENTS = JSON.parse(File.read(File.expand_path('../../config/currency_exponents.json', __dir__))).freeze
34
+
35
+ # Every currency Spree knows, the closed list the API types publish as
36
+ # `CurrencyCode`.
37
+ CURRENCY_CODES = EXPONENTS.keys.sort.freeze
38
+
39
+ # Unit prices may be quoted below the minor unit ("0.0125" USD); amounts
40
+ # that change hands never are.
41
+ UNIT_PRICE_DECIMALS = 4
42
+
43
+ CANONICAL_DECIMAL = /\A-?\d+(\.\d+)?\z/
44
+
45
+ ROUNDING_MODES = {
46
+ half_up: BigDecimal::ROUND_HALF_UP,
47
+ half_even: BigDecimal::ROUND_HALF_EVEN,
48
+ half_down: BigDecimal::ROUND_HALF_DOWN,
49
+ up: BigDecimal::ROUND_UP,
50
+ down: BigDecimal::ROUND_DOWN
51
+ }.freeze
52
+
17
53
  # The number of decimal places a currency is written in: 2 for most, 0
18
54
  # for yen, 3 for dinar. An unknown currency falls back to two places
19
55
  # rather than raising — a sale is not the moment to discover a typo in a
20
56
  # currency code.
21
57
  #
22
- # Note that Spree's money columns are `decimal(_, 2)` throughout, so a
23
- # three-decimal currency still loses its last place on the way into the
24
- # database. Rounding here to the currency's own precision keeps the
25
- # arithmetic honest and means widening those columns would be the only
26
- # change needed; it does not by itself make Spree dinar-exact.
27
- #
28
58
  # @param currency [String, ::Money::Currency, nil]
29
59
  # @return [Integer]
30
60
  def precision(currency)
31
- (::Money::Currency.find(currency) || ::Money::Currency.find('USD')).exponent
61
+ code = currency.respond_to?(:iso_code) ? currency.iso_code : currency.to_s.upcase
62
+ EXPONENTS[code] || ::Money::Currency.find(code)&.exponent || EXPONENTS['USD']
32
63
  end
33
64
 
34
- # Rounds to the currency's own minor unit, half-up, so amounts reconcile
35
- # to the cent when they are summed.
65
+ # Rounds to the currency's own minor unit, so amounts reconcile to the
66
+ # cent when they are summed.
36
67
  #
37
68
  # @param amount [Numeric, String, nil]
38
69
  # @param currency [String, ::Money::Currency, nil]
70
+ # @param mode [Symbol] one of {ROUNDING_MODES}; tax providers pick their own
39
71
  # @return [BigDecimal]
40
- def to_currency(amount, currency)
41
- quantize(amount, precision(currency))
72
+ def to_currency(amount, currency, mode: :half_up)
73
+ quantize(amount, precision(currency), mode: mode)
42
74
  end
43
75
 
44
76
  # The same rounding against a precision already in hand.
45
77
  #
46
78
  # @param amount [Numeric, String, nil]
47
79
  # @param precision [Integer]
80
+ # @param mode [Symbol] one of {ROUNDING_MODES}
48
81
  # @return [BigDecimal]
49
- def quantize(amount, precision)
50
- BigDecimal(amount.to_s).round(precision, BigDecimal::ROUND_HALF_UP)
82
+ def quantize(amount, precision, mode: :half_up)
83
+ BigDecimal(amount.to_s).round(precision, ROUNDING_MODES.fetch(mode))
51
84
  end
52
85
 
86
+ # Reads a money amount from an API request. Only a canonical decimal
87
+ # string is accepted — never a JSON number, which may already have lost
88
+ # precision as a float, and never more decimals than the field holds.
89
+ #
90
+ # @param value [String, nil]
91
+ # @param currency [String, ::Money::Currency, nil] nil when unknown,
92
+ # which allows up to {UNIT_PRICE_DECIMALS} decimals
93
+ # @param unit_price [Boolean] allows up to {UNIT_PRICE_DECIMALS} decimals
94
+ # @return [BigDecimal, nil]
95
+ # @raise [Spree::Money::InvalidFormat]
96
+ def parse_canonical(value, currency, unit_price: false)
97
+ amount = parse_canonical_decimal(value)
98
+ return if amount.nil?
99
+
100
+ allowed = if currency.nil? || unit_price
101
+ [precision(currency), UNIT_PRICE_DECIMALS].max
102
+ else
103
+ precision(currency)
104
+ end
105
+ raise InvalidFormat, "has more than #{allowed} decimal places" if decimal_places(value) > allowed
106
+
107
+ amount
108
+ end
109
+
110
+ # Reads a rate or percentage from an API request: a canonical decimal
111
+ # string with no limit on its decimals.
112
+ #
113
+ # @param value [String, nil]
114
+ # @return [BigDecimal, nil]
115
+ # @raise [Spree::Money::InvalidFormat]
116
+ def parse_canonical_decimal(value)
117
+ return if value.nil?
118
+ raise InvalidFormat, 'must be a decimal string like "19.99"' unless value.is_a?(String) && value.match?(CANONICAL_DECIMAL)
119
+
120
+ BigDecimal(value)
121
+ end
122
+
123
+ # Reads a decimal from a caller that may send a number or text: a blank
124
+ # is nil, a number passes, and text must be a plain decimal ("16.50"),
125
+ # never read as a wrong number the way `"1,599.99".to_d` reads 1.
126
+ #
127
+ # @param value [Numeric, String, nil]
128
+ # @return [BigDecimal, nil]
129
+ # @raise [Spree::Money::InvalidFormat]
130
+ def parse_decimal(value)
131
+ return if value.nil? || (value.is_a?(String) && value.strip.empty?)
132
+ return BigDecimal(value.to_s) if value.is_a?(Numeric)
133
+
134
+ parse_canonical_decimal(value.is_a?(String) ? value.strip : value)
135
+ end
136
+
137
+ # Writes an amount the way the API and exports carry it: exactly the
138
+ # currency's decimal places ("10.00", "1.500", "100"). A unit price keeps
139
+ # up to {UNIT_PRICE_DECIMALS}, with zeros beyond the currency's own
140
+ # places dropped ("0.0125", "19.99").
141
+ #
142
+ # @param amount [Numeric, String, nil]
143
+ # @param currency [String, ::Money::Currency, nil]
144
+ # @param unit_price [Boolean]
145
+ # @return [String, nil]
146
+ def format(amount, currency, unit_price: false)
147
+ return if amount.nil?
148
+
149
+ places = precision(currency)
150
+ kept = unit_price ? [places, UNIT_PRICE_DECIMALS].max : places
151
+ rounded = quantize(amount, kept)
152
+ whole, fraction = (rounded.zero? ? BigDecimal(0) : rounded).to_s('F').split('.')
153
+ fraction = without_trailing_zeros(fraction.to_s).ljust(places, '0')
154
+ fraction.empty? ? whole : "#{whole}.#{fraction}"
155
+ end
156
+
157
+ # Writes a rate or percentage with no trailing zeros ("0.23", "23").
158
+ #
159
+ # @param value [Numeric, String, nil]
160
+ # @return [String, nil]
161
+ def format_decimal(value)
162
+ return if value.nil?
163
+
164
+ without_trailing_zeros(BigDecimal(value.to_s).to_s('F')).delete_suffix('.')
165
+ end
166
+
167
+ # Trailing zeros carry no value, so "1000.0" is a whole yen.
168
+ def decimal_places(value)
169
+ value.include?('.') ? without_trailing_zeros(value.split('.').last).length : 0
170
+ end
171
+ private_class_method :decimal_places
172
+
173
+ # Scans back to the last other digit, in linear time on request input.
174
+ def without_trailing_zeros(text)
175
+ last = text.rindex(/[^0]/)
176
+ last ? text[0..last] : ''
177
+ end
178
+ private_class_method :without_trailing_zeros
179
+
53
180
  # An amount as a whole number of the currency's smallest unit — cents for
54
181
  # most currencies, whole yen for one written without decimals.
55
182
  #
@@ -67,14 +194,65 @@ module Spree
67
194
  (BigDecimal(amount.to_s) * (10**precision(currency))).round.to_i
68
195
  end
69
196
 
197
+ # An amount in hundredths of the currency, whatever its own minor unit:
198
+ # the unit payment gateways are called with until they take amounts with
199
+ # their currency.
200
+ #
201
+ # @param amount [Numeric, String]
202
+ # @return [Integer]
203
+ def to_hundredths(amount)
204
+ quantize(BigDecimal(amount.to_s) * 100, 0).to_i
205
+ end
206
+
207
+ # @param hundredths [Integer] as {#to_hundredths} returns
208
+ # @return [BigDecimal]
209
+ def from_hundredths(hundredths)
210
+ BigDecimal(hundredths) / 100
211
+ end
212
+
213
+ # Whether an amount is missing or zero. Text that is not a number is
214
+ # neither, so a malformed value is never mistaken for "nothing to pay".
215
+ #
216
+ # @param value [Numeric, String, nil]
217
+ # @return [Boolean]
218
+ def blank_or_zero?(value)
219
+ return true if value.blank?
220
+
221
+ BigDecimal(value.to_s, exception: false)&.zero? || false
222
+ end
223
+
70
224
  # @param units [Integer] whole minor units, as {#to_minor_units} returns
71
225
  # @param currency [String, ::Money::Currency, nil]
72
226
  # @return [BigDecimal]
73
227
  def from_minor_units(units, currency)
74
228
  BigDecimal(units) / (10**precision(currency))
75
229
  end
230
+
231
+ # Makes the Money gem agree with {EXPONENTS}, so formatting, `cents`
232
+ # and anything else built on it use ISO 4217 decimals too.
233
+ def register_with_money_gem!
234
+ EXPONENTS.each do |code, exponent|
235
+ subunit_to_unit = 10**exponent
236
+ currency = ::Money::Currency.find(code)
237
+
238
+ if currency.nil?
239
+ ::Money::Currency.register(
240
+ iso_code: code, name: code, symbol: code, subunit: nil, subunit_to_unit: subunit_to_unit,
241
+ priority: 100, symbol_first: false, thousands_separator: ',', decimal_mark: '.'
242
+ )
243
+ elsif currency.subunit_to_unit != subunit_to_unit
244
+ # The smallest cash amount is counted in subunits, so it scales
245
+ # with them: 5 forints stays 5 forints, not 0.05.
246
+ scaled = currency.smallest_denomination &&
247
+ currency.smallest_denomination * subunit_to_unit / currency.subunit_to_unit
248
+ ::Money::Currency.inherit(code.downcase.to_sym, subunit_to_unit: subunit_to_unit, smallest_denomination: scaled)
249
+ end
250
+ end
251
+ end
76
252
  end
77
253
 
254
+ Rounding.register_with_money_gem!
255
+
78
256
  include Comparable
79
257
 
80
258
  class << self
@@ -97,13 +275,16 @@ module Spree
97
275
  delegate :cents, :currency, :to_d, :positive?, :zero?, to: :money
98
276
 
99
277
  def initialize(amount, options = {})
100
- ::Money.default_currency ||= Spree::Store.default.default_currency || 'USD'
101
278
  @money = Monetize.parse(amount, (options[:currency] || Spree::Store.default.default_currency))
102
279
  @options = Spree::Money.default_formatting_rules.merge(options)
103
280
  end
104
281
 
282
+ # The amount in hundredths of the currency, whatever its own minor unit —
283
+ # the unit payment gateways have always been called with.
284
+ #
285
+ # @return [Integer]
105
286
  def amount_in_cents
106
- (cents / currency.subunit_to_unit.to_f * 100).round
287
+ Rounding.to_hundredths(to_d)
107
288
  end
108
289
 
109
290
  def abs
@@ -22,24 +22,25 @@ module Spree
22
22
 
23
23
  def cast_value(metric, raw)
24
24
  case metric.format
25
- when :money, :decimal, :percent then raw.to_f.round(2)
25
+ when :money then BigDecimal((raw || 0).to_s)
26
+ when :decimal, :percent then BigDecimal((raw || 0).to_s).round(2)
26
27
  else raw.to_i
27
28
  end
28
29
  end
29
30
 
30
31
  def zero_for(metric)
31
- metric.format == :integer ? 0 : 0.0
32
+ metric.format == :integer ? 0 : BigDecimal(0)
32
33
  end
33
34
 
34
35
  # Computes requested ratio metrics from their aggregated components.
35
36
  def apply_derived(metrics)
36
37
  query.metrics.select(&:derived?).each do |metric|
37
38
  numerator, denominator = metric.ratio.map { |name| metrics[name] || 0 }
38
- ratio = denominator.to_f.zero? ? 0.0 : numerator / denominator.to_f
39
+ ratio = denominator.zero? ? BigDecimal(0) : BigDecimal(numerator.to_s) / BigDecimal(denominator.to_s)
39
40
  # A percent metric carries the number a merchant reads (42.5), not
40
41
  # the fraction, so clients never have to know to multiply.
41
42
  ratio *= 100 if metric.format == :percent
42
- metrics[metric.name] = ratio.round(2)
43
+ metrics[metric.name] = metric.money? ? ratio : ratio.round(2)
43
44
  end
44
45
  end
45
46
 
@@ -58,12 +59,12 @@ module Spree
58
59
  def growth_rate(current, previous)
59
60
  return nil if previous.nil?
60
61
  if previous.zero?
61
- return 0.0 if current.zero?
62
+ return BigDecimal(0) if current.zero?
62
63
 
63
64
  return nil
64
65
  end
65
66
 
66
- (((current - previous) / previous.to_f) * 100).round(1)
67
+ ((BigDecimal(current.to_s) - BigDecimal(previous.to_s)) / BigDecimal(previous.to_s) * 100).round(1)
67
68
  end
68
69
  end
69
70
  end
@@ -642,12 +642,12 @@ module Spree
642
642
  end.to_h
643
643
  end
644
644
 
645
- # Both periods span the same number of days, so their bucket counts
646
- # differ by at most one partial edge. Pairing runs from the start —
647
- # the nth bucket of this period with the nth of the previous — because
648
- # the ranges share a start offset: a range beginning mid-month pairs
649
- # its partial first bucket with the previous period's partial first
650
- # bucket. A bucket past the end of the previous list has no
645
+ # Both periods span the same number of days (or of whole months), so
646
+ # their bucket counts differ by at most one partial edge. Pairing runs
647
+ # from the start — the nth bucket of this period with the nth of the
648
+ # previous — because the ranges share a start offset: a range
649
+ # beginning mid-month pairs its partial first bucket with the previous
650
+ # period's partial first bucket. A bucket past the end of the previous list has no
651
651
  # counterpart and pairs with nothing rather than wrapping around.
652
652
  def bucket_pairs(grain)
653
653
  current = expected_buckets(query.time_range, grain)
@@ -113,16 +113,27 @@ module Spree
113
113
  SUM(COALESCE((SELECT SUM(r.amount) FROM %{refunds} r WHERE r.order_id = %{orders}.id), 0))
114
114
  SQL
115
115
 
116
- # A refund is issued against the order, not a line, so it is apportioned
117
- # by the line's share of the order's pre-tax value. The denominator is
118
- # summed over the order's own lines rather than read from item_total:
119
- # item_total is priced before discounts, so on a discounted order the
120
- # shares would not add up to one and part of the refund would vanish.
121
- # The `* 1.0` forces real division: SQLite divides two integer-valued
122
- # decimals as integers, which silently rounds every share to zero.
116
+ # A refund that gives tax back carries it in its own column, so the goods
117
+ # and the tax it returned are netted from goods and tax respectively.
118
+ REFUNDED_GOODS_SUBQUERY = <<~SQL.squish.freeze
119
+ SUM(COALESCE((SELECT SUM(r.amount - r.tax_amount) FROM %{refunds} r WHERE r.order_id = %{orders}.id), 0))
120
+ SQL
121
+
122
+ REFUNDED_TAX_SUBQUERY = <<~SQL.squish.freeze
123
+ SUM(COALESCE((SELECT SUM(r.tax_amount) FROM %{refunds} r WHERE r.order_id = %{orders}.id), 0))
124
+ SQL
125
+
126
+ # A refund is issued against the order, not a line, so its goods are
127
+ # apportioned by the line's share of the order's pre-tax value. The
128
+ # denominator is summed over the order's own lines rather than read from
129
+ # item_total: item_total is priced before discounts, so on a discounted
130
+ # order the shares would not add up to one and part of the refund would
131
+ # vanish. The `* 1.0` forces real division: SQLite divides two
132
+ # integer-valued decimals as integers, which silently rounds every share
133
+ # to zero.
123
134
  LINE_ITEM_REFUNDS_SUBQUERY = <<~SQL.squish.freeze
124
135
  SUM(
125
- COALESCE((SELECT SUM(r.amount) FROM %{refunds} r WHERE r.order_id = %{orders}.id), 0)
136
+ COALESCE((SELECT SUM(r.amount - r.tax_amount) FROM %{refunds} r WHERE r.order_id = %{orders}.id), 0)
126
137
  * CASE
127
138
  WHEN COALESCE((SELECT SUM(sibling.pre_tax_amount) FROM %{line_items} sibling
128
139
  WHERE sibling.order_id = %{orders}.id), 0) > 0
@@ -293,7 +304,7 @@ module Spree
293
304
  metric :gross_sales, sql: 'SUM(%{line_items}.price * %{line_items}.quantity)',
294
305
  base: :line_items, format: :money
295
306
  metric :discounts, sql: 'SUM(%{line_items}.discount_total)', base: :line_items, format: :money
296
- metric :returns, sql: REFUNDS_SUBQUERY, base: :orders, format: :money
307
+ metric :returns, sql: REFUNDED_GOODS_SUBQUERY, base: :orders, format: :money
297
308
  # Net of returns as well as discounts, so this is the figure a
298
309
  # merchant recognises as "what we actually sold".
299
310
  metric :net_sales, sql: "SUM(%{line_items}.pre_tax_amount) - #{LINE_ITEM_REFUNDS_SUBQUERY}",
@@ -303,7 +314,8 @@ module Spree
303
314
  # Every shopper-visible fee except duties, which the chain counts separately.
304
315
  metric :fees, sql: "SUM(COALESCE(%{orders}.fee_total, 0)) - #{DUTIES_SUBQUERY}",
305
316
  base: :orders, format: :money, suggests: 'net_sales'
306
- metric :taxes, sql: 'SUM(%{orders}.additional_tax_total + %{orders}.included_tax_total)',
317
+ # Net of the tax given back, as net_sales is net of the goods.
318
+ metric :taxes, sql: "SUM(%{orders}.additional_tax_total + %{orders}.included_tax_total) - #{REFUNDED_TAX_SUBQUERY}",
307
319
  base: :orders, format: :money, suggests: 'net_sales'
308
320
  # The order grand total, net of what came back.
309
321
  metric :total_sales, sql: "SUM(%{orders}.total) - #{REFUNDS_SUBQUERY}",
@@ -21,7 +21,7 @@ module Spree
21
21
  dimension = definition(name)
22
22
  # A row whose key is NULL is a real group — an order with no market or
23
23
  # channel — and needs a name a person can read, not a blank cell.
24
- return { id: nil, label: Spree.t('reporting.unassigned'), meta: {} } if raw.nil? && !plain?(dimension)
24
+ return { id: nil, label: I18n.t('spree.reporting.unassigned'), meta: {} } if raw.nil? && !plain?(dimension)
25
25
  return { id: raw.to_s, label: Schema.value_label(dimension, raw), meta: {} } if dimension.enumerated_values
26
26
  return raw unless dimension.hydrate
27
27
 
@@ -126,12 +126,20 @@ module Spree
126
126
  # year" means February to February, and an equal-day-count shift off a
127
127
  # 28-day February would land in January. ActiveSupport maps February 29
128
128
  # to February 28 in a non-leap year.
129
+ #
130
+ # A range of whole calendar months (a full quarter, `last_month`) shifts
131
+ # by the same number of months for the same reason: July – September is
132
+ # 92 days, and shifting it by days would start on March 31, putting a
133
+ # one-day March bucket at the front of the previous period.
129
134
  def previous_time_range
130
135
  first = time_range.first.in_time_zone(time_zone)
131
136
  last = time_range.last.in_time_zone(time_zone)
132
137
 
133
138
  if compare == 'previous_year'
134
139
  (first - 1.year)..(last - 1.year)
140
+ elsif first == first.beginning_of_month && last == last.end_of_month
141
+ months = (last.year * 12 + last.month) - (first.year * 12 + first.month) + 1
142
+ (first - months.months)..(last - months.months).end_of_month
135
143
  else
136
144
  days = (last.to_date - first.to_date).to_i + 1
137
145
  (first - days.days)..(last - days.days)
@@ -45,6 +45,19 @@ module Spree
45
45
  # total is suppressed rather than rendered as a headline.
46
46
  def per_group? = requires_grouping.present?
47
47
  def money? = format == :money
48
+
49
+ # A value as the API and exports write it: money to the currency's
50
+ # decimal places, other decimals without trailing zeros, counts as
51
+ # integers.
52
+ #
53
+ # @param value [Numeric, nil]
54
+ # @param currency [String, nil]
55
+ # @return [String, Integer, nil]
56
+ def format_value(value, currency)
57
+ return value if value.nil? || format == :integer
58
+
59
+ money? ? Spree::Money::Rounding.format(value, currency) : Spree::Money::Rounding.format_decimal(value)
60
+ end
48
61
  end
49
62
 
50
63
  # A dimension groups rows. The definition owns every behavior keyed off
@@ -32,7 +32,7 @@ module Spree
32
32
  metrics: metrics.map { |m| metric_entry(m) },
33
33
  dimensions: dimensions.map { |d| dimension_entry(d, metrics) },
34
34
  time_range: {
35
- presets: (Query::PRESETS + Query::RELATIVE_PRESETS).map { |p| { name: p, label: translate('presets', p, :label) } },
35
+ presets: (Query::PRESETS + Query::RELATIVE_PRESETS).map { |p| { name: p, label: catalog_text('presets', p, :label) } },
36
36
  relative: %w[last_<n>_days last_<n>_weeks last_<n>_months],
37
37
  absolute: 'ISO 8601 dates or datetimes in `since` / `until`, resolved in the store timezone'
38
38
  },
@@ -50,7 +50,7 @@ module Spree
50
50
  # @param group [Symbol] :metrics | :dimensions
51
51
  # @return [String] localized label (humanized name when none registered)
52
52
  def label_for(group, name)
53
- translate(group, name, :label)
53
+ catalog_text(group, name, :label)
54
54
  end
55
55
 
56
56
  private
@@ -61,7 +61,7 @@ module Spree
61
61
  metrics.group_by { |metric| @registry.family_of(metric) }.map do |family, family_metrics|
62
62
  {
63
63
  name: family,
64
- label: translate('families', family, :label),
64
+ label: catalog_text('families', family, :label),
65
65
  metrics: family_metrics.map(&:name),
66
66
  dimensions: @registry.dimensions.values.
67
67
  select { |d| @allowed.call(d) && @registry.base!(d.base).family == family }.map(&:name)
@@ -73,8 +73,8 @@ module Spree
73
73
  {
74
74
  name: metric.name,
75
75
  family: @registry.family_of(metric),
76
- label: translate('metrics', metric.name, :label),
77
- description: translate('metrics', metric.name, :description),
76
+ label: catalog_text('metrics', metric.name, :label),
77
+ description: catalog_text('metrics', metric.name, :description),
78
78
  format: metric.format,
79
79
  currency: (metric.money? ? @store.default_currency : nil),
80
80
  derived: metric.derived?,
@@ -88,8 +88,8 @@ module Spree
88
88
  def dimension_entry(dimension, metrics)
89
89
  {
90
90
  name: dimension.name,
91
- label: translate('dimensions', dimension.name, :label),
92
- description: translate('dimensions', dimension.name, :description),
91
+ label: catalog_text('dimensions', dimension.name, :label),
92
+ description: catalog_text('dimensions', dimension.name, :description),
93
93
  type: dimension.type,
94
94
  grains: dimension.grains,
95
95
  lookup: dimension.lookup,
@@ -104,8 +104,8 @@ module Spree
104
104
  }.compact
105
105
  end
106
106
 
107
- def translate(group, name, facet)
108
- Spree.t("reporting.#{group}.#{name}.#{facet}", default: (facet == :label ? name.to_s.humanize : nil))
107
+ def catalog_text(group, name, facet)
108
+ I18n.t("spree.reporting.#{group}.#{name}.#{facet}", default: (facet == :label ? name.to_s.humanize : nil))
109
109
  end
110
110
 
111
111
 
@@ -117,7 +117,7 @@ module Spree
117
117
  # @param value [String]
118
118
  # @return [String]
119
119
  def self.value_label(dimension, value)
120
- Spree.t("reporting.values.#{dimension.name}.#{value}", default: value.to_s.humanize)
120
+ I18n.t("spree.reporting.values.#{dimension.name}.#{value}", default: value.to_s.humanize)
121
121
  end
122
122
  end
123
123
  end
@@ -0,0 +1,61 @@
1
+ # Registers core's staff password reset as an editable email for the length
2
+ # of an example, with a fixed sample, so specs in any engine can exercise the
3
+ # template editor without spree_emails' customer emails. Restores whatever was
4
+ # registered under the key before.
5
+ #
6
+ # RSpec.describe '...' do
7
+ # include_context 'with an editable email template'
8
+ # end
9
+ module Spree
10
+ module TestingSupport
11
+ class EmailTemplateSample
12
+ def self.record_type
13
+ nil
14
+ end
15
+
16
+ def initialize(store:, record_id: nil)
17
+ @store = store
18
+ end
19
+
20
+ def currency
21
+ @store.default_currency
22
+ end
23
+
24
+ def recent_records(limit: 5)
25
+ @store.orders.order(created_at: :desc).limit(limit).map do |order|
26
+ Spree::EmailTemplates::SampleRecord.new(id: order.prefixed_id, label: order.number, created_at: order.created_at)
27
+ end
28
+ end
29
+
30
+ def variables
31
+ {
32
+ user: { 'first_name' => 'Ann', 'email' => 'ann@example.com' },
33
+ order: { 'number' => 'R123456789', 'customer_name' => 'Ann Lee' },
34
+ reset_url: "#{@store.storefront_url}/reset-password?token=preview"
35
+ }
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ RSpec.shared_context 'with an editable email template' do
42
+ let(:editable_key) { 'spree/admin_user_mailer/password_reset_email' }
43
+
44
+ # Templates have per-language versions only in the store's languages.
45
+ before { allow_any_instance_of(Spree::Store).to receive(:supported_locales_list).and_return(%w[de en fr]) }
46
+
47
+ around do |example|
48
+ registry = Spree.editable_email_templates
49
+ previous = registry[editable_key]
50
+ previous_layout = registry['layouts/spree/base_mailer']
51
+ registry.register(editable_key, kind: :email, sample: 'Spree::TestingSupport::EmailTemplateSample')
52
+ registry.register('layouts/spree/base_mailer', kind: :layout)
53
+
54
+ begin
55
+ example.run
56
+ ensure
57
+ previous ? registry.register(previous.key, kind: previous.kind, sample: previous.sample) : registry.delete(editable_key)
58
+ previous_layout ? registry.register(previous_layout.key, kind: :layout) : registry.delete('layouts/spree/base_mailer')
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,22 @@
1
+ module Spree
2
+ module TestingSupport
3
+ module Emails
4
+ # The decoded text of every part of an email, HTML and plain text
5
+ # together. Rendered emails are quoted-printable on the wire, so the raw
6
+ # encoded body splits long lines and turns "=" into "=3D".
7
+ #
8
+ # @param message [Mail::Message, ActionMailer::MessageDelivery]
9
+ # @return [String]
10
+ def email_body(message)
11
+ message = message.message if message.respond_to?(:message) && !message.is_a?(Mail::Message)
12
+ parts = message.multipart? ? message.parts : [message]
13
+
14
+ parts.map(&:decoded).join("\n")
15
+ end
16
+ end
17
+ end
18
+ end
19
+
20
+ RSpec.configure do |config|
21
+ config.include Spree::TestingSupport::Emails
22
+ end
@@ -31,7 +31,7 @@ FactoryBot.define do
31
31
  country = Spree::Country.by_iso('US')
32
32
 
33
33
  if Spree::DeliveryMethod.none?
34
- create(:shipping_method).tap do |delivery_method|
34
+ create(:delivery_method).tap do |delivery_method|
35
35
  delivery_method.calculator.preferred_amount = evaluator.shipping_cost
36
36
  delivery_method.calculator.preferred_currency = cart.store.default_currency
37
37
  delivery_method.calculator.save!
@@ -0,0 +1,28 @@
1
+ FactoryBot.define do
2
+ factory :email_template, class: Spree::EmailTemplate do
3
+ store { Spree::Store.default || create(:store) }
4
+ key { 'spree/admin_user_mailer/password_reset_email' }
5
+ locale { 'any' }
6
+ subject { 'Reset {{ store.name }}' }
7
+ body { '<mj-section><mj-column><mj-text>Hello</mj-text></mj-column></mj-section>' }
8
+ base_subject { 'Reset {{ store.name }}' }
9
+ base_body { '<mj-section><mj-column><mj-text>Default</mj-text></mj-column></mj-section>' }
10
+ published_at { Time.current }
11
+ end
12
+
13
+ factory :email_template_draft, class: Spree::EmailTemplateDraft do
14
+ store { Spree::Store.default || create(:store) }
15
+ key { 'spree/admin_user_mailer/password_reset_email' }
16
+ locale { 'any' }
17
+ subject { 'Reset {{ store.name }}' }
18
+ body { '<mj-section><mj-column><mj-text>Draft</mj-text></mj-column></mj-section>' }
19
+ base_subject { 'Reset {{ store.name }}' }
20
+ base_body { '<mj-section><mj-column><mj-text>Default</mj-text></mj-column></mj-section>' }
21
+ end
22
+
23
+ factory :email_template_revision, class: Spree::EmailTemplateRevision do
24
+ email_template
25
+ subject { 'Reset {{ store.name }}' }
26
+ body { '<mj-section><mj-column><mj-text>Hello</mj-text></mj-column></mj-section>' }
27
+ end
28
+ end
@@ -39,7 +39,7 @@ FactoryBot.define do
39
39
  create(:line_item, order: order, price: evaluator.line_items_price)
40
40
  order.line_items.reload # to ensure order.line_items is accessible after
41
41
 
42
- order.update_column(:item_count, order.line_items.count)
42
+ order.update_column(:total_quantity, order.line_items.count)
43
43
  order.reload
44
44
  end
45
45
  end
@@ -78,7 +78,7 @@ FactoryBot.define do
78
78
  end
79
79
 
80
80
  stock_location = order.line_items&.first&.variant&.stock_levels&.first&.stock_location || create(:stock_location)
81
- create(:shipment, order: order, cost: evaluator.shipment_cost, stock_location: stock_location)
81
+ create(:fulfillment, order: order, cost: evaluator.shipment_cost, stock_location: stock_location)
82
82
  order.fulfillments.reload
83
83
 
84
84
  order.recalculate_totals!