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
@@ -0,0 +1,51 @@
1
+ module Spree
2
+ module Emails
3
+ # One Liquid email template: a file on disk, with optional front matter
4
+ # holding its subject, or a store's saved version with the subject in its
5
+ # own field.
6
+ class Template
7
+ FRONT_MATTER = /\A---\s*\n(.*?)\n---\s*(?:\n|\z)/m
8
+
9
+ attr_reader :key, :path
10
+
11
+ # @param key [String] the template key, e.g. "spree/order_mailer/confirm_email"
12
+ # @param path [String, nil] the file on disk
13
+ # @param subject [String, nil] the subject, for a template not read from a file
14
+ # @param body [String, nil] the body, for a template not read from a file
15
+ def initialize(key:, path: nil, subject: nil, body: nil)
16
+ @key = key
17
+ @path = path
18
+ @subject = subject
19
+ @body = body
20
+ end
21
+
22
+ # @return [Boolean] whether this is a store's saved template or a draft, rather than a file
23
+ def stored?
24
+ path.nil?
25
+ end
26
+
27
+ # @return [String, nil] the subject line as Liquid
28
+ def subject
29
+ stored? ? @subject : front_matter['subject']
30
+ end
31
+
32
+ # @return [String] the template without its front matter
33
+ def body
34
+ stored? ? @body.to_s : source.sub(FRONT_MATTER, '')
35
+ end
36
+
37
+ private
38
+
39
+ def source
40
+ @source ||= File.read(path)
41
+ end
42
+
43
+ def front_matter
44
+ @front_matter ||= begin
45
+ match = source.match(FRONT_MATTER)
46
+ match ? YAML.safe_load(match[1]).to_h : {}
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,23 @@
1
+ module Spree
2
+ module Emails
3
+ # Turns a record into the JSON an email template reads: the serializer's
4
+ # output, the same data a renderer outside Ruby would receive. Shared by
5
+ # mailers and by the sample data the template editor previews with.
6
+ # Download links and other bearer tokens stay out of it.
7
+ module TemplateData
8
+ # @param object [Object, nil] the record
9
+ # @param serializer [Class] an email serializer
10
+ # @param store [Spree::Store]
11
+ # @param currency [String, nil] defaults to the store's
12
+ # @param params [Hash] serializer params over the defaults
13
+ # @return [Hash, nil]
14
+ def self.call(object, serializer, store:, currency: nil, **params)
15
+ return if object.nil?
16
+
17
+ params = { store: store, currency: currency || store.default_currency, locale: I18n.locale.to_s,
18
+ storefront_url: store.storefront_url.to_s.chomp('/'), hide_credentials: true }.merge(params)
19
+ JSON.parse(serializer.new(object, params: params).serialize)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,109 @@
1
+ module Spree
2
+ module Emails
3
+ # Finds the Liquid template behind an email key.
4
+ #
5
+ # With a store, an editable template (see Spree.editable_email_templates)
6
+ # is looked up in the store first: its published version in the email's
7
+ # language, then its version for every language. Otherwise, and for every
8
+ # other template, the app's own file wins over the one a gem ships.
9
+ # `drafts` puts unsaved templates in front of everything, for previews and
10
+ # for checking a draft before it is published.
11
+ class TemplateResolver
12
+ KEY_FORMAT = %r{\A[a-z0-9_]+(?:/[a-z0-9_]+)*\z}
13
+
14
+ # @return [Spree::Store, nil] the store whose published templates apply
15
+ attr_reader :store
16
+
17
+ class InvalidKey < ArgumentError; end
18
+
19
+ # @param options [Hash] see {#initialize}
20
+ # @return [Spree::Emails::TemplateResolver] searching the directories Spree's mailers render from
21
+ def self.for_mailers(**options)
22
+ new(Spree::BaseMailer.view_paths.paths.map(&:path), **options)
23
+ end
24
+
25
+ # @param view_paths [Array<String, Pathname>] directories to search, highest precedence first
26
+ # @param store [Spree::Store, nil] the store whose published templates apply
27
+ # @param locale [String, Symbol, nil] the email's language
28
+ # @param drafts [Hash{String => Spree::Emails::Template}] unsaved templates by key
29
+ def initialize(view_paths, store: nil, locale: nil, drafts: {})
30
+ @view_paths = view_paths.map(&:to_s).uniq
31
+ @store = store
32
+ @locale = locale.to_s
33
+ @drafts = drafts
34
+ @stored = {}
35
+ end
36
+
37
+ # @param key [String] e.g. "spree/order_mailer/confirm_email"
38
+ # @return [Spree::Emails::Template, nil]
39
+ def find(key)
40
+ validate!(key)
41
+ @drafts[key] || stored(key) || find_file(key, '.liquid')
42
+ end
43
+
44
+ # The default: what answers for the key without the store's templates or drafts.
45
+ #
46
+ # @param key [String]
47
+ # @return [Spree::Emails::Template, nil]
48
+ def find_default(key)
49
+ validate!(key)
50
+ Spree.editable_email_templates[key]&.kind == :partial ? partial_file(key) : find_file(key, '.liquid')
51
+ end
52
+
53
+ # A hand-written plain-text part. A store's saved template has none, so
54
+ # its text is always generated from its HTML.
55
+ #
56
+ # @param key [String]
57
+ # @return [Spree::Emails::Template, nil]
58
+ def find_text(key)
59
+ validate!(key)
60
+ return if @drafts[key] || stored(key)
61
+
62
+ find_file(key, '.text.liquid')
63
+ end
64
+
65
+ # @param name [String] a partial name, e.g. "spree/shared/line_item"
66
+ # @return [Spree::Emails::Template, nil] the store's version, or "spree/shared/_line_item.liquid"
67
+ def find_partial(name)
68
+ validate!(name)
69
+ @drafts[name] || stored(name) || partial_file(name)
70
+ end
71
+
72
+ # @return [Boolean] whether a store's published template answered any lookup so far
73
+ def stored_templates_used?
74
+ @stored.values.any?
75
+ end
76
+
77
+ private
78
+
79
+ attr_reader :view_paths
80
+
81
+ def stored(key)
82
+ return unless @store && Spree.editable_email_templates.include?(key)
83
+ return @stored[key] if @stored.key?(key)
84
+
85
+ record = @store.email_templates.published.where(key: key, locale: [@locale, Spree::EmailTemplate::ANY_LOCALE]).
86
+ min_by { |template| template.locale == Spree::EmailTemplate::ANY_LOCALE ? 1 : 0 }
87
+ @stored[key] = record && Template.new(key: key, subject: record.subject, body: record.body)
88
+ end
89
+
90
+ def partial_file(name)
91
+ *folders, file = name.split('/')
92
+ find_file([*folders, "_#{file}"].join('/'), '.liquid')
93
+ end
94
+
95
+ def find_file(key, extension)
96
+ view_paths.each do |view_path|
97
+ path = File.join(view_path, "#{key}#{extension}")
98
+ return Template.new(key: key, path: path) if File.file?(path)
99
+ end
100
+
101
+ nil
102
+ end
103
+
104
+ def validate!(key)
105
+ raise InvalidKey, "Invalid email template name: #{key.inspect}" unless key.to_s.match?(KEY_FORMAT)
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,72 @@
1
+ module Spree
2
+ module Emails
3
+ # Turns a rendered email's HTML into its plain-text part, keeping each
4
+ # link as "label (url)". Generating the text rather than writing it by
5
+ # hand means it can never drift from the HTML.
6
+ class TextConverter
7
+ BLOCK_ELEMENTS = %w[p div h1 h2 h3 h4 h5 h6 table tr ul ol li section blockquote].freeze
8
+ SKIPPED_ELEMENTS = %w[head style script title img].freeze
9
+ HIDDEN_STYLE = /display\s*:\s*none/i
10
+
11
+ # @param html [String]
12
+ # @return [String]
13
+ def self.call(html)
14
+ new(html).call
15
+ end
16
+
17
+ def initialize(html)
18
+ @document = Nokogiri::HTML(html)
19
+ end
20
+
21
+ # @return [String]
22
+ def call
23
+ output = +''
24
+ walk(@document.at('body') || @document, output)
25
+
26
+ output.lines.map { |line| line.squeeze(' ').strip }.join("\n").gsub(/\n{3,}/, "\n\n").strip + "\n"
27
+ end
28
+
29
+ private
30
+
31
+ def walk(node, output)
32
+ node.children.each do |child|
33
+ if child.text?
34
+ output << child.text.gsub(/\s+/, ' ')
35
+ elsif child.element?
36
+ element(child, output)
37
+ end
38
+ end
39
+ end
40
+
41
+ def element(node, output)
42
+ name = node.name.downcase
43
+ return if SKIPPED_ELEMENTS.include?(name) || node['style'].to_s.match?(HIDDEN_STYLE)
44
+
45
+ case name
46
+ when 'br' then output << "\n"
47
+ when 'a' then link(node, output)
48
+ when 'td', 'th'
49
+ walk(node, output)
50
+ output << ' '
51
+ else
52
+ block = BLOCK_ELEMENTS.include?(name)
53
+ output << "\n" if block
54
+ walk(node, output)
55
+ output << "\n" if block
56
+ end
57
+ end
58
+
59
+ def link(node, output)
60
+ label = +''
61
+ walk(node, label)
62
+ label = label.squeeze(' ').strip
63
+ href = node['href'].to_s.strip.delete_prefix('mailto:')
64
+
65
+ # A link with no text is a linked image, which has nothing to say in text.
66
+ return if label.blank?
67
+
68
+ output << (href.blank? || href == label ? label : "#{label} (#{href})")
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,134 @@
1
+ module Spree
2
+ module Emails
3
+ # Turns Spree's translation keys in a template into readable text in one
4
+ # language, for a merchant who starts editing it:
5
+ #
6
+ # {{ 'order_mailer.confirm_email.dear_customer' | t: name: order.customer_name }}
7
+ # # => Dear {{ order.customer_name }},
8
+ #
9
+ # {% assign heading = 'order_mailer.confirm_email.order_summary' | t: number: order.number %}
10
+ # # => {% capture heading %}Order {{ order.number }} Summary{% endcapture %}
11
+ #
12
+ # A use it cannot turn into text exactly (a plural form, a missing
13
+ # translation, a value it does not recognize) is left as it was, so the
14
+ # template still renders the same.
15
+ class TranslationInliner
16
+ # Tags are found with simple patterns and read with string handling, so
17
+ # matching stays linear on any input a merchant pastes in.
18
+ OUTPUT_TAG = /\{\{([^{}]*)\}\}/
19
+ ASSIGN_TAG = /\{%([^{}%]*)%\}/
20
+ TRANSLATED = /\A'([a-z0-9_.]+)'\s*\|\s*t\b/
21
+ ASSIGNED = /\Aassign\s+(\w+)\s*=\s*/
22
+ INTERPOLATION = /%\{(\w+)\}/
23
+
24
+ # @param source [String, nil] a template's subject or body
25
+ # @param locale [String, Symbol] the language to write the text in
26
+ # @param escape [Boolean] whether the text is HTML: false for a subject,
27
+ # which renders unescaped as a mail header
28
+ # @return [String, nil]
29
+ def self.call(source, locale:, escape: true)
30
+ new(locale, escape).call(source)
31
+ end
32
+
33
+ def initialize(locale, escape = true)
34
+ @locale = locale
35
+ @escape = escape
36
+ end
37
+
38
+ def call(source)
39
+ return source if source.blank?
40
+
41
+ source.
42
+ gsub(ASSIGN_TAG) { |tag| inline_assign(markup(Regexp.last_match[1])) || tag }.
43
+ gsub(OUTPUT_TAG) { |tag| inline_output(markup(Regexp.last_match[1])) || tag }
44
+ end
45
+
46
+ private
47
+
48
+ # A tag's content without whitespace control dashes or padding.
49
+ def markup(content)
50
+ content.delete_prefix('-').delete_suffix('-').strip
51
+ end
52
+
53
+ # `'key' | t`, `'key' | t: name: value` and `'key' | t | filter`, split into
54
+ # the key, its arguments and the filters after it.
55
+ def translation_call(markup)
56
+ match = TRANSLATED.match(markup)
57
+ return unless match
58
+
59
+ rest = match.post_match.strip
60
+ if rest.start_with?(':')
61
+ arguments, filters = rest.delete_prefix(':').split('|', 2)
62
+ [match[1], arguments.strip, filters&.strip]
63
+ elsif rest.start_with?('|')
64
+ [match[1], nil, rest.delete_prefix('|').strip]
65
+ elsif rest.empty?
66
+ [match[1], nil, nil]
67
+ end
68
+ end
69
+
70
+ def inline_output(markup)
71
+ key, arguments, filters = translation_call(markup)
72
+ text = key && translation(key)
73
+ return unless text
74
+
75
+ if filters.present?
76
+ return if arguments.present? || text.include?("'")
77
+
78
+ "{{ '#{text}' | #{filters} }}"
79
+ else
80
+ interpolate(text, arguments) { |literal| literal_text(literal) }
81
+ end
82
+ end
83
+
84
+ def inline_assign(markup)
85
+ assigned = ASSIGNED.match(markup)
86
+ return unless assigned
87
+
88
+ variable = assigned[1]
89
+ key, arguments, filters = translation_call(assigned.post_match)
90
+ text = key && filters.nil? && translation(key)
91
+ return unless text
92
+
93
+ if arguments.blank? && text.exclude?("'") && text !~ INTERPOLATION
94
+ "{% assign #{variable} = '#{text}' %}"
95
+ else
96
+ body = interpolate(text, arguments) { |literal| literal_text(literal) }
97
+ body && "{% capture #{variable} %}#{body}{% endcapture %}"
98
+ end
99
+ end
100
+
101
+ # Literal text is escaped by the caller's block; each `%{name}` becomes
102
+ # the Liquid expression the template passed for it.
103
+ def interpolate(text, arguments)
104
+ values = parse_arguments(arguments)
105
+ return unless values
106
+ return if text.scan(INTERPOLATION).flatten.any? { |name| !values.key?(name) }
107
+
108
+ text.split(INTERPOLATION).each_with_index.map do |part, index|
109
+ index.odd? ? "{{ #{values[part]} }}" : yield(part)
110
+ end.join
111
+ end
112
+
113
+ def literal_text(literal)
114
+ @escape ? ERB::Util.html_escape(literal) : literal
115
+ end
116
+
117
+ def parse_arguments(arguments)
118
+ return {} if arguments.blank?
119
+
120
+ arguments.split(/,\s*(?=\w+\s*:)/).to_h do |pair|
121
+ name, expression = pair.split(':', 2).map(&:strip)
122
+ return nil if name.blank? || expression.blank?
123
+
124
+ [name, expression]
125
+ end
126
+ end
127
+
128
+ def translation(key)
129
+ text = I18n.t(key, scope: :spree, locale: @locale, default: nil)
130
+ text if text.is_a?(String) && !text.include?('{{') && !text.include?('{%')
131
+ end
132
+ end
133
+ end
134
+ end
@@ -22,7 +22,7 @@ module Spree
22
22
  # the product's update either way
23
23
  def process!
24
24
  price_list = cached_lookup(:price_list_import_list) { import.price_list }
25
- raise ArgumentError, Spree.t(:price_list_import_no_price_list) if price_list.nil?
25
+ raise ArgumentError, I18n.t('spree.price_list_import_no_price_list') if price_list.nil?
26
26
 
27
27
  variant = find_variant
28
28
  currency = currency_for(attributes['currency'])
@@ -62,15 +62,15 @@ module Spree
62
62
  # group is one SKU's rows and every rung would otherwise repeat the join.
63
63
  def find_variant
64
64
  sku = attributes['sku'].to_s.strip
65
- raise ArgumentError, Spree.t(:price_list_import_sku_required) if sku.blank?
65
+ raise ArgumentError, I18n.t('spree.price_list_import_sku_required') if sku.blank?
66
66
 
67
67
  variants = cached_lookup(:price_list_import_variant, sku.downcase) do
68
68
  # Case-insensitive, as the product import matches SKUs and as the
69
69
  # uniqueness validation compares them.
70
70
  store.variants.where(Spree::Variant.arel_table[:sku].lower.eq(sku.downcase)).limit(2).to_a
71
71
  end
72
- raise ArgumentError, Spree.t(:price_list_import_unknown_sku, sku: sku) if variants.empty?
73
- raise ArgumentError, Spree.t(:price_list_import_ambiguous_sku, sku: sku) if variants.many?
72
+ raise ArgumentError, I18n.t('spree.price_list_import_unknown_sku', sku: sku) if variants.empty?
73
+ raise ArgumentError, I18n.t('spree.price_list_import_ambiguous_sku', sku: sku) if variants.many?
74
74
 
75
75
  variants.first
76
76
  end
@@ -82,7 +82,7 @@ module Spree
82
82
  currency = value.to_s.strip.upcase.presence || store.default_currency
83
83
  return currency if supported_currencies.include?(currency)
84
84
 
85
- raise ArgumentError, Spree.t(:price_list_import_unsupported_currency, currency: currency)
85
+ raise ArgumentError, I18n.t('spree.price_list_import_unsupported_currency', currency: currency)
86
86
  end
87
87
 
88
88
  def supported_currencies
@@ -99,7 +99,7 @@ module Spree
99
99
  def amount_for(value, column)
100
100
  text = value.to_s.strip
101
101
  return nil if text.blank?
102
- raise ArgumentError, Spree.t(:price_list_import_invalid_amount, column: column, value: text) unless text.match?(AMOUNT_FORMAT)
102
+ raise ArgumentError, I18n.t('spree.price_list_import_invalid_amount', column: column, value: text) unless text.match?(AMOUNT_FORMAT)
103
103
 
104
104
  BigDecimal(text)
105
105
  end
@@ -151,17 +151,17 @@ module Spree
151
151
  value = {} unless value.is_a?(Hash)
152
152
 
153
153
  if value[:over_cap].present?
154
- Spree.t(:price_list_import_too_many_breaks, count: Spree::Price::MAXIMUM_BREAKS_PER_VARIANT)
154
+ I18n.t('spree.price_list_import_too_many_breaks', count: Spree::Price::MAXIMUM_BREAKS_PER_VARIANT)
155
155
  elsif value[:invalid_quantities].present?
156
- Spree.t(:price_list_import_invalid_quantity, value: attributes['min_quantity'])
156
+ I18n.t('spree.price_list_import_invalid_quantity', value: attributes['min_quantity'])
157
157
  elsif (rising = value[:rising_ladders].presence)
158
158
  # Named rung and floor, like the sibling branches: a file is written
159
159
  # one rung per row, so the merchant needs to know which one and what
160
160
  # it had to beat.
161
- Spree.t(:price_list_import_price_rises_with_quantity,
161
+ I18n.t('spree.price_list_import_price_rises_with_quantity',
162
162
  quantity: rising.first[:min_quantity], floor: rising.first[:floor])
163
163
  else
164
- result.error.to_s.presence || Spree.t(:price_list_import_failed)
164
+ result.error.to_s.presence || I18n.t('spree.price_list_import_failed')
165
165
  end
166
166
  end
167
167
  end
@@ -3,15 +3,23 @@ module Spree
3
3
  module RowProcessors
4
4
  class ProductVariant < Base
5
5
  OPTION_TYPES_COUNT = 3
6
+ CATEGORY_FIELDS = %w[category1 category2 category3].freeze
7
+ # Written as plain decimals ("16.50"): the models store text as sent,
8
+ # and "1,599.99" or "16,50" would otherwise be saved as 1 or 16.
9
+ DECIMAL_FIELDS = %w[price compare_at_price cost_price weight height width depth carton_weight].freeze
6
10
 
7
11
  def initialize(row, **)
8
12
  super
13
+ ensure_decimals!
9
14
  @store = row.store
10
15
  @seller = import.seller
16
+ # Before the product is saved: rows are not wrapped in a transaction,
17
+ # so an unknown location must fail the row with nothing written.
18
+ @stock_location = import_stock_location if attributes['inventory_count'].present?
11
19
  @product = ensure_product_exists
12
20
  end
13
21
 
14
- attr_reader :product, :store, :seller
22
+ attr_reader :product, :store, :seller, :stock_location
15
23
 
16
24
  def process!
17
25
  variant = if attributes['sku'].present?
@@ -62,7 +70,7 @@ module Spree
62
70
  variant.set_stock(
63
71
  attributes['inventory_count'].to_i,
64
72
  attributes['inventory_backorderable']&.to_b,
65
- import_stock_location
73
+ stock_location
66
74
  )
67
75
  end
68
76
 
@@ -79,6 +87,14 @@ module Spree
79
87
 
80
88
  private
81
89
 
90
+ def ensure_decimals!
91
+ DECIMAL_FIELDS.each do |column|
92
+ Spree::Money::Rounding.parse_decimal(attributes[column])
93
+ rescue Spree::Money::InvalidFormat
94
+ raise ArgumentError, I18n.t('spree.import_invalid_number', column: column, value: attributes[column].to_s.strip)
95
+ end
96
+ end
97
+
82
98
  # A product-header (options-empty) row creates an option-less default
83
99
  # variant to hold product-level attributes. Once an option-bearing
84
100
  # variant exists, that placeholder is a leftover — remove it and
@@ -91,9 +107,17 @@ module Spree
91
107
  product.update_column(:default_variant_id, product.variants.reload.first&.id)
92
108
  end
93
109
 
110
+ # Whether this row carries the product itself, as opposed to only one of
111
+ # its variants. A row is a product row when it has no options at all, or
112
+ # when it names the product alongside them — an export (and Shopify's
113
+ # own CSV) writes the product's attributes on its first variant's row,
114
+ # so that row has to both create the product and create a variant.
115
+ def product_row?
116
+ options.empty? || has_product_attributes?
117
+ end
118
+
94
119
  def ensure_product_exists
95
- if options.empty?
96
- # For product/header rows (no options), create or update the product
120
+ if product_row?
97
121
  product = Spree::Product.new
98
122
  if attributes['slug'].present?
99
123
  existing_product = product_scope.find_by(slug: attributes['slug'].strip.downcase)
@@ -167,9 +191,12 @@ module Spree
167
191
  end
168
192
 
169
193
  def assign_attributes_to_product(product)
170
- # set the SKU on a product/header row so process! updates the default variant instead of creating a new one
171
194
  if product.new_record?
172
195
  product.slug = attributes['slug']
196
+ # Only an option-less row's SKU belongs to the product's own variant,
197
+ # so process! updates that variant instead of creating a second one.
198
+ # On a row that also carries options the SKU identifies that option
199
+ # variant, which process! creates for itself.
173
200
  product.sku = attributes['sku'] if attributes['sku'].present? && options.empty?
174
201
  product.store = store
175
202
  # A seller's import creates the seller's products. Without this the
@@ -197,11 +224,9 @@ module Spree
197
224
  product.status = to_spree_status(attributes['status'])
198
225
  end
199
226
 
200
- if options.empty?
201
- if attributes['product_type'].present?
202
- product_type = prepare_product_type
203
- product.product_type = product_type if product_type.present?
204
- end
227
+ if attributes['product_type'].present?
228
+ product_type = prepare_product_type
229
+ product.product_type = product_type if product_type.present?
205
230
  end
206
231
 
207
232
  product
@@ -243,23 +268,32 @@ module Spree
243
268
  end
244
269
  end
245
270
 
246
- # Where an imported row's stock lands.
247
- #
248
- # Nil for an operator's import, which leaves `set_stock` to pick the
249
- # store's default. A seller's stock belongs in their own warehouse:
250
- # `Variant#default_stock_location` answers the *store's*, so without
251
- # this a seller's `inventory_count` would deposit their goods in the
252
- # marketplace's location — the same mistake the seller products
253
- # endpoint narrows `stock_levels` to prevent.
254
- #
255
- # A seller with no location of their own falls back to the store's
256
- # rather than dropping the count on the floor.
257
271
  def import_stock_location
258
- return nil if seller.blank?
272
+ name = attributes['stock_location'].to_s.squish
259
273
 
260
- cached_lookup(:seller_stock_location, seller.id) do
261
- seller.stock_locations.active.order_default.first
274
+ if name.blank?
275
+ return cached_lookup(:default_stock_location) do
276
+ Spree::StockLocation.owned_by(store_id: store.id, seller_id: seller&.id).active.order_default.first ||
277
+ store.default_stock_location
278
+ end
262
279
  end
280
+
281
+ owner_ids = stock_location_owner_ids
282
+ name_column = Spree::StockLocation.arel_table[:name]
283
+ cached_lookup(:stock_location, owner_ids, name.downcase) do
284
+ store.stock_locations.where(seller_id: owner_ids).
285
+ order(Arel.sql('CASE WHEN seller_id IS NULL THEN 1 ELSE 0 END')).
286
+ find_by(name_column.lower.eq(Arel::Nodes::NamedFunction.new('LOWER', [Arel::Nodes.build_quoted(name)])))
287
+ end || raise(ArgumentError, I18n.t('spree.product_import_unknown_stock_location', name: name))
288
+ end
289
+
290
+ # A seller reaches only their own locations — never the marketplace's
291
+ # shelf. An operator reaches the marketplace's and, for a seller's
292
+ # product, that seller's, whose own wins on a shared name as with cartons.
293
+ def stock_location_owner_ids
294
+ return [seller.id] if seller.present?
295
+
296
+ [nil, product_scope.where(slug: attributes['slug'].to_s.strip.downcase).pick(:seller_id)].uniq
263
297
  end
264
298
 
265
299
  def prepare_option_value_variants
@@ -304,11 +338,11 @@ module Spree
304
338
  def find_or_create_option_type!(label)
305
339
  cached_lookup(:option_type, label) do
306
340
  begin
307
- store_option_types.search_by_name(label).first || store_option_types.create!(label: label)
341
+ store_option_types.with_name(label).first || store_option_types.create!(label: label)
308
342
  rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
309
343
  raise unless uniqueness_conflict?(e, :name)
310
344
 
311
- store_option_types.search_by_name(label).first!
345
+ store_option_types.with_name(label).first!
312
346
  end
313
347
  end
314
348
  end
@@ -320,11 +354,11 @@ module Spree
320
354
  def find_or_create_option_value!(option_type, label)
321
355
  cached_lookup(:option_value, option_type.id, label) do
322
356
  begin
323
- option_type.option_values.search_by_name(label).first || option_type.option_values.create!(label: label)
357
+ option_type.option_values.with_name(label).first || option_type.option_values.create!(label: label)
324
358
  rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
325
359
  raise unless uniqueness_conflict?(e, :name)
326
360
 
327
- option_type.option_values.search_by_name(label).first!
361
+ option_type.option_values.with_name(label).first!
328
362
  end
329
363
  end
330
364
  end
@@ -375,7 +409,7 @@ module Spree
375
409
  end
376
410
 
377
411
  def has_product_attributes?
378
- %w[name status description category1 category2 category3].any? { |key| attributes[key].present? }
412
+ (%w[name status description] + CATEGORY_FIELDS).any? { |key| attributes[key].present? }
379
413
  end
380
414
 
381
415
  def handle_custom_fields(product)
@@ -442,6 +476,7 @@ module Spree
442
476
  # seller audience in the permission catalog at all.
443
477
  def handle_categories(product)
444
478
  return if seller.present?
479
+ return unless category_columns_mapped?
445
480
 
446
481
  names = prepare_taxon_pretty_names
447
482
  return Spree::Imports::CreateCategoriesJob.perform_now(product.id, store.id, names) if import.preferred_inline
@@ -450,11 +485,19 @@ module Spree
450
485
  end
451
486
 
452
487
  def prepare_taxon_pretty_names
453
- [
454
- attributes['category1'],
455
- attributes['category2'],
456
- attributes['category3']
457
- ].compact_blank.map(&:strip).uniq
488
+ CATEGORY_FIELDS.map { |field| attributes[field] }.compact_blank.map(&:strip).uniq
489
+ end
490
+
491
+ # A blank category cell means "file this product under nothing", and
492
+ # CreateCategoriesJob assigns the list wholesale to honour that. A file
493
+ # that carries no category columns at all means something different —
494
+ # it simply doesn't speak about categories — so without this a Shopify
495
+ # export, or any spreadsheet that omits them, would unfile every
496
+ # product it touched.
497
+ def category_columns_mapped?
498
+ cached_lookup(:category_columns_mapped) do
499
+ import.mappings.mapped.any? { |mapping| CATEGORY_FIELDS.include?(mapping.schema_field) }
500
+ end
458
501
  end
459
502
 
460
503
  def to_spree_status(status)