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
@@ -47,6 +47,25 @@ module Spree
47
47
  value.is_a?(String) && value.start_with?(TOKEN)
48
48
  end
49
49
 
50
+ # Money is written to at least the decimals of the currency the record
51
+ # names (a calculator's `currency` preference), keeping up to four like
52
+ # a unit price, so a per-item amount below a cent survives a round trip.
53
+ # A money preference with no currency of its own, such as an order-total
54
+ # rule that applies to any currency, keeps its exact decimal.
55
+ def self.wire_value(preferable, key, type, value)
56
+ case type
57
+ when :password then mask(value)
58
+ when :money
59
+ # Stored as the JSON text of a decimal.
60
+ amount = value.is_a?(String) ? BigDecimal(value, exception: false) : value
61
+ return value unless amount.is_a?(Numeric)
62
+
63
+ currency = preferable.money_preference_currency(key)
64
+ currency.present? ? Spree::Money::Rounding.format(amount, currency, unit_price: true) : Spree::Money::Rounding.format_decimal(amount)
65
+ else value
66
+ end
67
+ end
68
+
50
69
  # Serializes a Preferable's `preferences` hash for the wire,
51
70
  # masking `:password` values. Keys are stringified to match the
52
71
  # wire shape expected by JSON clients — schema entries built by
@@ -59,11 +78,45 @@ module Spree
59
78
  def self.serialize(preferable)
60
79
  return {} if preferable.nil?
61
80
 
81
+ definitions = preferable.class.preference_definitions
62
82
  preferable.preference_schema.each_with_object({}) do |field, hash|
63
- value = preferable.preferences[field[:key]]
64
- hash[field[:key_string] || field[:key].to_s] = field[:type] == :password ? mask(value) : value
83
+ # The stored value only, never the default — an unset secret must not
84
+ # reveal what it would fall back to — read through the record,
85
+ # because a secret lives in its own column.
86
+ value = preferable.stored_preference(field[:key]) { nil }
87
+ definition = definitions.fetch(field[:key])
88
+ hash[field[:key_string] || field[:key].to_s] =
89
+ if definition[:of] == :id
90
+ prefixed_ids(value, definition)
91
+ elsif %i[password money].include?(field[:type])
92
+ wire_value(preferable, field[:key], field[:type], value)
93
+ elsif definition[:keys] == :currency && definition[:values] == :money
94
+ money_by_currency(preferable.wire_preference_value(value, definition))
95
+ else
96
+ preferable.wire_preference_value(value, definition)
97
+ end
65
98
  end
66
99
  end
100
+
101
+ # Amounts keyed by currency (`{ "EUR" => "12.5" }`), each written with
102
+ # the decimals of its own currency, as every money value is.
103
+ def self.money_by_currency(value)
104
+ return value unless value.is_a?(Hash)
105
+
106
+ value.to_h do |currency, amount|
107
+ decimal = BigDecimal(amount.to_s, exception: false)
108
+ [currency, decimal ? Spree::Money::Rounding.format(decimal, currency, unit_price: true) : amount]
109
+ end
110
+ end
111
+
112
+ # Ids are stored as raw primary keys and leave as prefixed ids, the form
113
+ # every write accepts.
114
+ def self.prefixed_ids(value, definition)
115
+ return value unless value.is_a?(Array)
116
+
117
+ model = Spree::Preferences::Preferable.preference_model(definition)
118
+ value.map { |id| Spree::PrefixedId.prefixed_id?(id.to_s) ? id : model.prefixed_id_for(id) }
119
+ end
67
120
  end
68
121
  end
69
122
  end
@@ -1,84 +1,92 @@
1
- # Preferable allows defining preference accessor methods.
1
+ # Typed model preferences, each a Rails store accessor on the `preferences`
2
+ # JSON column — `secret_preferences` for a `:password` one (see
3
+ # {Spree::SecretPreferences}). Included by `Spree::Base`.
2
4
  #
3
- # A class including Preferable must implement #preferences which should return
4
- # an object responding to .fetch(key), []=(key, val), and .delete(key).
5
- #
6
- # The generated writer method performs typecasting before assignment into the
7
- # preferences object.
8
- #
9
- # Examples:
10
- #
11
- # # Spree::Base includes Preferable and defines preferences as a serialized
12
- # # column.
13
5
  # class Settings < Spree::Base
14
6
  # preference :color, :string, default: 'red'
15
7
  # preference :temperature, :integer, default: 21
16
8
  # end
17
9
  #
18
10
  # s = Settings.new
19
- # s.preferred_color # => 'red'
20
- # s.preferred_temperature # => 21
21
- #
22
- # s.preferred_color = 'blue'
23
- # s.preferred_color # => 'blue'
24
- #
25
- # # Typecasting is performed on assignment
26
- # s.preferred_temperature = '24'
27
- # s.preferred_temperature # => 24
28
- #
29
- # # Modifications have been made to the .preferences hash
30
- # s.preferences #=> {color: 'blue', temperature: 24}
31
- #
32
- # # Save the changes. All handled by activerecord
33
- # s.save!
11
+ # s.preferred_color # => 'red'
12
+ # s.preferred_temperature = '24' # cast on assignment
13
+ # s.preferred_temperature # => 24
14
+ # s.preferences # => { 'color' => 'red', 'temperature' => 24 }
34
15
 
35
16
  require 'spree/core/preferences/preferable_class_methods'
36
17
 
37
18
  module Spree::Preferences::Preferable
38
19
  extend ActiveSupport::Concern
39
20
 
21
+ BOOLEAN_TYPE = ActiveModel::Type::Boolean.new
22
+ INTEGER_TYPE = ActiveModel::Type::Integer.new
23
+ STRING_TYPE = ActiveModel::Type::String.new
24
+
40
25
  included do
41
- serialize :preferences, type: Hash, coder: YAML if defined?(serialize)
26
+ serialize :preferences, coder: Spree::Metadata::HashSerializer
42
27
  extend Spree::Preferences::PreferableClassMethods
28
+
29
+ # The preferences read and written under their plain names (see `exposes_preferences`).
30
+ class_attribute :exposed_preference_names, instance_accessor: false, default: Set.new.freeze
31
+ end
32
+
33
+ # JSON keeps decimals and times as strings; the declared type turns them
34
+ # back into a BigDecimal and a time. Anything else, or a string that does
35
+ # not parse, comes back as stored.
36
+ #
37
+ # @param value [Object] a value as JSON stores it
38
+ # @param type [Symbol] the preference's declared type
39
+ # @return [Object]
40
+ def self.restore_value(value, type)
41
+ return value unless value.is_a?(String)
42
+
43
+ case type
44
+ when :decimal, :money then BigDecimal(value, exception: false) || value
45
+ when :datetime then Time.zone.iso8601(value)
46
+ else value
47
+ end
48
+ rescue ArgumentError
49
+ value
50
+ end
51
+
52
+ # The record class an `of: :id` preference points at. Declared as a class
53
+ # name or a block, so a declaration never loads another model, nor fixes a
54
+ # configurable class like `Spree.customer_class`, at class load.
55
+ #
56
+ # @param definition [Hash] a preference definition
57
+ # @return [Class]
58
+ def self.preference_model(definition)
59
+ model = definition[:model]
60
+ model.respond_to?(:call) ? model.call : model.to_s.constantize
43
61
  end
44
62
 
45
63
  def get_preference(name)
46
64
  has_preference! name
47
- send self.class.preference_getter_method(name)
65
+ public_send(:"preferred_#{name}")
48
66
  end
49
67
 
50
68
  def set_preference(name, value)
51
69
  has_preference! name
52
- send self.class.preference_setter_method(name), value
70
+ public_send(:"preferred_#{name}=", value)
53
71
  end
54
72
 
55
73
  def preference_type(name)
56
- has_preference! name
57
- send self.class.preference_type_getter_method(name)
74
+ preference_definition(name)[:type]
58
75
  end
59
76
 
60
77
  def preference_default(name)
61
- has_preference! name
62
- send self.class.preference_default_getter_method(name)
78
+ instance_exec(&preference_definition(name)[:default])
63
79
  end
64
80
 
65
81
  def preference_deprecated(name)
66
- has_preference! name
67
- send(self.class.preference_deprecated_getter_method(name))
82
+ preference_definition(name)[:deprecated]
68
83
  end
69
84
 
70
85
  # Whether this preference is written by Spree rather than supplied by the
71
86
  # operator — a webhook secret a provider issues, say. Kept out of the
72
87
  # schema, so nothing offers it as a field to fill in.
73
88
  def preference_internal(name)
74
- has_preference! name
75
- getter = self.class.preference_internal_getter_method(name)
76
- # A preference declared before this option existed has no such reader.
77
- # Treated as not internal rather than raising, so one old declaration
78
- # cannot take a whole class's preference schema down with it.
79
- return false unless respond_to?(getter)
80
-
81
- send(getter)
89
+ preference_definition(name)[:internal]
82
90
  end
83
91
 
84
92
  # The fixed set this preference's value must come from, when it has one.
@@ -87,14 +95,26 @@ module Spree::Preferences::Preferable
87
95
  #
88
96
  # @return [Array, nil]
89
97
  def preference_choices(name)
90
- has_preference! name
91
- getter = self.class.preference_choices_getter_method(name)
92
- # A preference declared before this option existed has no such reader.
93
- # Treated as unconstrained rather than raising, so one old declaration
94
- # cannot take a whole class's preference schema down with it.
95
- return unless respond_to?(getter)
98
+ choices = preference_definition(name)[:choices]
99
+ choices.respond_to?(:call) ? choices.call : choices
100
+ end
96
101
 
97
- send(getter)
102
+ # The currency a `:money` preference is in: its declared `currency:` source
103
+ # (another preference, or a method of the record), else the record's own
104
+ # `currency` preference. Values about to be assigned win over stored ones,
105
+ # so a request changing both the amount and its currency is read as one.
106
+ #
107
+ # @param name [Symbol, String]
108
+ # @param pending [Hash{String => Object}] values not yet assigned
109
+ # @return [String, nil] nil for an amount in any currency
110
+ def money_preference_currency(name, pending = {})
111
+ declared = preference_definition(name)[:currency]
112
+ source = declared || :currency
113
+ # A currency sent in the same payload wins, even a blank one clearing it.
114
+ return pending[source.to_s].presence if pending.key?(source.to_s)
115
+ return get_preference(source).presence if has_preference?(source)
116
+
117
+ public_send(source).presence if declared && respond_to?(source)
98
118
  end
99
119
 
100
120
  def has_preference!(name)
@@ -102,92 +122,310 @@ module Spree::Preferences::Preferable
102
122
  end
103
123
 
104
124
  def has_preference?(name)
105
- respond_to? self.class.preference_getter_method(name)
125
+ self.class.preference_definitions.key?(name.to_sym)
106
126
  end
107
127
 
108
128
  def defined_preferences
109
- # Only real `preference`-macro definitions carry a `_default` getter —
110
- # the name grep alone would also catch `preferred_*` association writers
111
- # (e.g. Order#preferred_stock_location=).
112
- methods.grep(/\Apreferred_.*=\Z/).filter_map do |pref_method|
113
- name = pref_method.to_s.gsub(/\Apreferred_|=\Z/, '').to_sym
114
- name if respond_to?(self.class.preference_default_getter_method(name))
129
+ self.class.preference_definitions.keys
130
+ end
131
+
132
+ def default_preferences
133
+ defined_preferences.index_with { |name| preference_default(name) }
134
+ end
135
+
136
+ # Every preference value, secrets included — what `preferences` alone held
137
+ # before secrets moved to their own column. Declared preferences read
138
+ # through their `preferred_*` reader, so a default applies and decimals and
139
+ # times come back typed rather than in the string form JSON stores.
140
+ #
141
+ # @return [ActiveSupport::HashWithIndifferentAccess]
142
+ def preference_values
143
+ (preferences || {}).to_h.with_indifferent_access.merge(defined_preferences.index_with { |name| get_preference(name) })
144
+ end
145
+
146
+ # The stored value of a preference, before its default applies. A secret
147
+ # found in `preferences` wins over the encrypted column: it is either a
148
+ # newer value assigned as part of a whole hash, waiting for the save that
149
+ # moves it across, or one the upgrade has not moved yet — the writer always
150
+ # removes that copy, so it is never stale.
151
+ #
152
+ # @param name [Symbol, String]
153
+ # @return [Object] the stored value, or the block's result when nothing is stored
154
+ def stored_preference(name)
155
+ if secret_preference?(name)
156
+ (preferences || {}).fetch(name) { (secret_preferences || {}).fetch(name) { return yield } }
157
+ else
158
+ (preferences || {}).fetch(name) { yield }
115
159
  end
116
160
  end
117
161
 
118
- def deprecated_preferences
119
- defined_preferences.each_with_object([]) do |pref_name, array|
120
- deprecated_message = preference_deprecated(pref_name)
121
- array << { name: pref_name, message: deprecated_message } unless deprecated_message.nil?
162
+ # Fills in the declared default of every preference this record has no
163
+ # stored value for, so a preference added to the class after the row was
164
+ # saved never reads as missing and the raw `preferences` hash always holds
165
+ # every value. Secrets are left out: their defaults are read through the
166
+ # `preferred_*` reader, never copied into storage.
167
+ #
168
+ # Filling in a default is not a change to a stored record: a loaded record
169
+ # stays clean, so `with_lock` still accepts it, and the defaults are written
170
+ # with its next real save.
171
+ def backfill_default_preferences
172
+ secrets = self.class.secret_preference_names
173
+ missing = defined_preferences.reject { |name| preferences.key?(name) || secrets.include?(name) }
174
+ return if missing.empty?
175
+
176
+ already_changed = attribute_changed?(:preferences)
177
+ self.preferences = preferences.merge(missing.index_with { |name| stored_default(name) })
178
+ clear_attribute_change(:preferences) if persisted? && !already_changed
179
+ end
180
+
181
+ # Writes a `preferences` payload an API client sent. The whole payload is
182
+ # checked against the type's schema first, so nothing is written when any
183
+ # value is wrong. Then each value goes through its typed writer. A secret
184
+ # sent back masked, as it was read, keeps the stored one; `null` clears it.
185
+ #
186
+ # @param values [Hash, ActionController::Parameters]
187
+ # @param pointer [String] where the preferences object sits in the request,
188
+ # e.g. `/rules/1/preferences`, so a failure names its place
189
+ # @raise [Spree::Preferences::InvalidPreferences] naming every value that does not match
190
+ # @return [void]
191
+ def assign_preferences(values, pointer: '/preferences')
192
+ values = values.respond_to?(:to_unsafe_h) ? values.to_unsafe_h : values.to_h
193
+ values = values.deep_stringify_keys
194
+ failures = self.class.preference_failures(values)
195
+ failures += money_precision_failures(values) if failures.empty?
196
+ raise Spree::Preferences::InvalidPreferences.new(failures, prefix: pointer) if failures.any?
197
+
198
+ values.each do |key, value|
199
+ next if secret_preference?(key) && Spree::Preferences::Masking.masked?(value)
200
+
201
+ begin
202
+ set_preference(key, value)
203
+ rescue ActiveRecord::RecordNotFound => e
204
+ raise Spree::Preferences::InvalidPreferences.new([{ pointer: "/#{key}", message: e.message }], prefix: pointer)
205
+ end
122
206
  end
123
207
  end
124
208
 
125
- def default_preferences
126
- Hash[
127
- defined_preferences.map do |preference|
128
- [preference, preference_default(preference)]
209
+ # A stored value in the form the schema describes. Rows written before the
210
+ # declarations were typed can hold a number where an exact decimal string
211
+ # is declared (an `amounts` hash, a decimal default from YAML), or a string
212
+ # where a number or boolean is; read back as they are, a client sending
213
+ # them unchanged would be refused.
214
+ #
215
+ # @param value [Object] the stored value
216
+ # @param definition [Hash] its preference definition
217
+ # @return [Object]
218
+ def wire_preference_value(value, definition)
219
+ return BigDecimal(value.to_s).as_json if %i[decimal money].include?(definition[:type]) && value.is_a?(Numeric)
220
+ return nil if value == '' && definition[:nullable] && %i[integer boolean].include?(definition[:type])
221
+ return cast_preference_item(value, :integer) if definition[:type] == :integer && value.is_a?(String)
222
+ return convert_preference_value(value, :boolean, nullable: definition[:nullable]) if definition[:type] == :boolean && value.is_a?(String)
223
+ return value if value.nil? || (definition[:type] == :array && !value.is_a?(Array))
224
+
225
+ cast_preference_contents(value, definition)
226
+ end
227
+
228
+ # Names of the preferences the last save changed, secrets included.
229
+ #
230
+ # @return [Array<Symbol>]
231
+ def previously_changed_preference_names
232
+ defined_preferences.select { |name| public_send(:"saved_change_to_preferred_#{name}?") }
233
+ end
234
+
235
+ private
236
+
237
+ # A `:money` value with more decimals than its currency has is refused, as
238
+ # every other amount on the API is. The schema has already checked the
239
+ # format; it cannot know the currency.
240
+ def money_precision_failures(values)
241
+ values.filter_map do |key, value|
242
+ next unless value.is_a?(String) && has_preference?(key) && preference_type(key) == :money
243
+
244
+ currency = money_preference_currency(key, values)
245
+ amount = BigDecimal(value, exception: false)
246
+ next if currency.blank? || amount.nil?
247
+
248
+ places = Spree::Money::Rounding.precision(currency)
249
+ next if amount.round(places) == amount
250
+
251
+ { pointer: "/#{key}", message: I18n.t('spree.errors.messages.money_preference_too_many_decimals', count: places, currency: currency) }
252
+ end
253
+ end
254
+
255
+ def preference_definition(name)
256
+ has_preference! name
257
+ self.class.preference_definitions[name.to_sym]
258
+ end
259
+
260
+ # A declared default in the form JSON stores, as the writer would store it,
261
+ # so a decimal default reads back as a BigDecimal and setting the same
262
+ # amount is not a change.
263
+ def stored_default(name)
264
+ default = preference_default(name)
265
+ type = preference_type(name)
266
+ return default if default.nil? || %i[decimal money datetime].exclude?(type)
267
+
268
+ convert_preference_value(default, type, nullable: true).as_json
269
+ end
270
+
271
+ # Only a class that stores secrets can declare one, so the names say it all.
272
+ def secret_preference?(name)
273
+ self.class.secret_preference_names.include?(name.to_sym)
274
+ end
275
+
276
+ def cast_preference(name, value)
277
+ definition = preference_definition(name)
278
+ parse_on_set = definition[:parse_on_set]
279
+ if parse_on_set.is_a?(Proc)
280
+ # Procs that accept more than one arg opt into receiving the owning
281
+ # record so they can scope (e.g. `normalize_id_preference` rejecting
282
+ # cross-store IDs). `arity.abs > 1` covers both the `(value, owner)` and
283
+ # `(value, owner = nil)` shapes.
284
+ value = parse_on_set.arity.abs > 1 ? parse_on_set.call(value, self) : parse_on_set.call(value)
285
+ end
286
+ # Spree 6.0 bridge, removed with incomplete declarations in 6.1: an id list
287
+ # an extension declared without `of: :id` still has its prefixed ids
288
+ # decoded, as the API did for every `*_ids` key before.
289
+ if definition[:type] == :array && definition[:of].nil? && name.to_s.end_with?('_ids') && value.is_a?(Array)
290
+ value = value.map { |id| (Spree::PrefixedId.decode_prefixed_id(id) if Spree::PrefixedId.prefixed_id?(id)) || id }
291
+ end
292
+ value = convert_preference_value(value, definition[:type], nullable: definition[:nullable])
293
+ value = cast_preference_contents(value, definition)
294
+ # Decimals and times are kept in the string form JSON stores, so a record
295
+ # read back from the database and one just written compare equal and
296
+ # setting the same value is not a change. The reader restores them.
297
+ value = value.as_json if %i[decimal money datetime].include?(definition[:type])
298
+
299
+ Spree::Deprecation.warn("`#{name}` is deprecated. #{definition[:deprecated]}") if definition[:deprecated]
300
+ value
301
+ end
302
+
303
+ # Casts what a typed `:array` or `:hash` holds to its declared item type, in
304
+ # the form JSON stores. A value that does not cast is left as it came, so
305
+ # validation can name it rather than a silent zero hiding it.
306
+ def cast_preference_contents(value, definition)
307
+ case definition[:type]
308
+ when :array
309
+ case definition[:of]
310
+ when nil then value
311
+ when :id then decode_preference_ids(value, definition)
312
+ when :object then value.map { |item| cast_preference_object(item, definition[:properties]) }
313
+ else split_preference_list(value).map { |item| cast_preference_item(item, definition[:of]) }
314
+ end
315
+ when :hash
316
+ return value unless definition[:values] && value.is_a?(Hash)
317
+
318
+ value.to_h do |key, item|
319
+ key = definition[:keys] == :currency ? key.to_s.upcase : key.to_s
320
+ [key, cast_preference_item(item, definition[:values])]
129
321
  end
130
- ]
322
+ else
323
+ value
324
+ end
131
325
  end
132
326
 
133
- def preferences_of_type(type)
134
- defined_preferences.find_all { |preference| preference_type(preference) == type.to_sym }
327
+ # A comma-separated string is a list too: what a plain text field sends.
328
+ def split_preference_list(values)
329
+ values.flat_map { |item| item.is_a?(String) ? item.split(',') : [item] }
330
+ .map { |item| item.is_a?(String) ? item.strip : item }
331
+ .reject { |item| item.respond_to?(:empty?) && item.empty? }
135
332
  end
136
333
 
137
- def clear_preferences
138
- preferences.keys.each { |pref| preferences.delete pref }
334
+ def cast_preference_item(value, item_type)
335
+ case item_type
336
+ when :string then value.to_s
337
+ when :integer then Integer(value.to_s, 10, exception: false) || value
338
+ when :decimal, :money then BigDecimal(value.to_s, exception: false)&.as_json || value
339
+ when :boolean then BOOLEAN_TYPE.cast(value)
340
+ else value
341
+ end
139
342
  end
140
343
 
141
- def restore_preferences_for(preference_keys)
142
- preference_keys.each { |pref| preferences[pref] = preference_default(pref) }
344
+ def cast_preference_object(item, properties)
345
+ return item unless item.respond_to?(:to_h) && !item.is_a?(Array)
346
+
347
+ item.to_h.stringify_keys.slice(*properties.keys.map(&:to_s)).to_h do |key, value|
348
+ [key, cast_preference_item(value, properties[key.to_sym])]
349
+ end
143
350
  end
144
351
 
145
- def preference_change(name, changes_or_previous_changes)
146
- preference_changes = changes_or_previous_changes.with_indifferent_access.fetch('preferences', [{}, {}])
147
- before_preferences = preference_changes[0] || {}
148
- after_preferences = preference_changes[1] || {}
352
+ # Turns a list of record ids into the raw primary keys the preference
353
+ # stores. A prefixed id must carry the declared model's prefix — a
354
+ # channel's id never decodes into a market id — and every id must belong to
355
+ # the record's scope, so a rule cannot point at another store's records.
356
+ #
357
+ # @raise [ActiveRecord::RecordNotFound] naming the ids that were not found
358
+ def decode_preference_ids(values, definition)
359
+ model = Spree::Preferences::Preferable.preference_model(definition)
360
+ ids = split_preference_list(values).map(&:to_s).map do |id|
361
+ Spree::PrefixedId.prefixed_id?(id) ? (model.decode_prefixed_id(id)&.to_s || id) : id
362
+ end
363
+ return ids if ids.empty?
364
+
365
+ relation = definition[:scope] ? definition[:scope].call(self) : model
366
+ found = relation.where(id: ids.reject { |id| Spree::PrefixedId.prefixed_id?(id) }).pluck(:id).map(&:to_s).to_set
367
+ missing = ids.reject { |id| found.include?(id) }
368
+ if missing.any?
369
+ raise ActiveRecord::RecordNotFound.new("Couldn't find #{model.name} with id=#{missing.join(',')}", model.name)
370
+ end
149
371
 
150
- return if before_preferences[name] == after_preferences[name]
372
+ ids
373
+ end
151
374
 
152
- [before_preferences[name], after_preferences[name]]
375
+ # Assigns a new hash rather than calling the store accessor's writer, which
376
+ # skips writing nil to a missing key — leaving the default in force when nil
377
+ # was meant. Rails still sees no change when the value is the one stored.
378
+ def write_preference(name, value)
379
+ if secret_preference?(name)
380
+ # A copy assigned as part of a whole hash would otherwise outlive the
381
+ # value written here and be moved over it on save.
382
+ self.preferences = preferences.except(name) if preferences&.key?(name)
383
+ self.secret_preferences = (secret_preferences || {}).with_indifferent_access.merge(name => value)
384
+ else
385
+ self.preferences = (preferences || {}).with_indifferent_access.merge(name => value)
386
+ end
153
387
  end
154
388
 
155
- private
389
+ # Whitespace alone is no number or boolean, so for those it is blank too; a
390
+ # string keeps it.
391
+ def blank_preference_value?(value, type = nil)
392
+ return true if value.nil? || (value.respond_to?(:empty?) && value.empty?)
393
+
394
+ %i[integer boolean].include?(type) && value.is_a?(String) && value.strip.empty?
395
+ end
156
396
 
157
397
  def convert_preference_value(value, type, nullable: false)
158
398
  case type
399
+ # Strings, integers and booleans cast the way Rails casts attributes (and
400
+ # `has_json` its keys). Our own rule on top: a blank value stays nil for a
401
+ # nullable preference, so it can fall back to another, and becomes the
402
+ # type's empty value otherwise.
159
403
  when :string, :text
160
- # A nullable string keeps "unset" (nil / empty string) as nil so it can
161
- # fall back to another value, instead of collapsing it to "".
162
- if nullable && (value.nil? || (value.respond_to?(:empty?) && value.empty?))
163
- nil
404
+ if blank_preference_value?(value)
405
+ nullable ? nil : ''
164
406
  else
165
- value.to_s
407
+ STRING_TYPE.cast(value)
166
408
  end
167
409
  when :password
168
410
  value.to_s
169
- when :decimal
170
- decimal_value = value.presence
411
+ when :decimal, :money
412
+ # A number or canonical decimal text only: "1,599.99" raises rather
413
+ # than being read as 1.
414
+ decimal_value = value.is_a?(String) ? value.presence : value
171
415
  decimal_value ||= 0 unless nullable
172
- decimal_value.present? ? decimal_value.to_s.to_d : decimal_value
416
+ decimal_value.nil? ? nil : Spree::Money::Rounding.parse_decimal(decimal_value)
173
417
  when :integer
174
- int_value = value.presence
175
- int_value ||= 0 unless nullable
176
- int_value.present? ? int_value.to_i : int_value
418
+ if blank_preference_value?(value, :integer)
419
+ nullable ? nil : 0
420
+ else
421
+ INTEGER_TYPE.cast(value) || 0
422
+ end
177
423
  when :boolean
178
- # A nullable boolean keeps "unset" (nil / empty string) as nil so it can
179
- # fall back to another value, instead of collapsing it to false. An
180
- # explicit false is preserved (it is neither nil nor empty).
181
- if nullable && (value.nil? || (value.respond_to?(:empty?) && value.empty?))
182
- nil
183
- elsif value.is_a?(FalseClass) ||
184
- value.nil? ||
185
- value == 0 ||
186
- value&.to_s =~ /^(f|false|0)$/i ||
187
- (value.respond_to?(:empty?) && value.empty?)
188
- false
424
+ # An explicit false is kept: it is neither nil nor empty.
425
+ if blank_preference_value?(value, :boolean)
426
+ nullable ? nil : false
189
427
  else
190
- true
428
+ BOOLEAN_TYPE.cast(value)
191
429
  end
192
430
  when :array
193
431
  value.is_a?(Array) ? value : Array.wrap(value)
@@ -195,6 +433,8 @@ module Spree::Preferences::Preferable
195
433
  case value.class.to_s
196
434
  when 'Hash'
197
435
  value
436
+ when 'ActionController::Parameters'
437
+ value.to_h
198
438
  when 'String'
199
439
  # only works with hashes whose keys are strings
200
440
  JSON.parse value.gsub('=>', ':')
@@ -221,7 +461,7 @@ module Spree::Preferences::Preferable
221
461
  value.to_time
222
462
  end
223
463
  # Kept as the `yyyy-MM-dd` string it arrived as rather than coerced to a
224
- # Time. Preferences are YAML-serialized, and what a date *means* depends on
464
+ # Time. Preferences are stored as JSON, and what a date *means* depends on
225
465
  # the store's timezone, which this method has no access to: coercing here
226
466
  # would freeze the value against the server's zone and move the operator's
227
467
  # deadline by hours. The reader owns that (see