spree_core 6.0.0.beta4 → 6.0.0.rc1

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 (495) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  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/seller_payouts/sweep_seller_job.rb +2 -0
  10. data/app/mailers/spree/admin_user_mailer.rb +4 -19
  11. data/app/mailers/spree/base_mailer.rb +96 -13
  12. data/app/mailers/spree/email_template_mailer.rb +27 -0
  13. data/app/mailers/spree/export_mailer.rb +3 -4
  14. data/app/mailers/spree/import_mailer.rb +3 -4
  15. data/app/mailers/spree/invitation_mailer.rb +24 -7
  16. data/app/mailers/spree/seller_user_mailer.rb +4 -16
  17. data/app/mailers/spree/webhook_mailer.rb +2 -2
  18. data/app/models/concerns/spree/acted_by.rb +1 -1
  19. data/app/models/concerns/spree/admin_user_methods.rb +13 -1
  20. data/app/models/concerns/spree/api_typed.rb +91 -0
  21. data/app/models/concerns/spree/calculator/tiers.rb +76 -0
  22. data/app/models/concerns/spree/customer_methods.rb +15 -14
  23. data/app/models/concerns/spree/email_template_identity.rb +52 -0
  24. data/app/models/concerns/spree/parameterizable_name.rb +2 -0
  25. data/app/models/concerns/spree/payment_session_transitions.rb +8 -2
  26. data/app/models/concerns/spree/post_sale/taxation.rb +54 -0
  27. data/app/models/concerns/spree/post_sale/taxed_line.rb +51 -0
  28. data/app/models/concerns/spree/preference_schema.rb +4 -12
  29. data/app/models/concerns/spree/product_scopes.rb +11 -1
  30. data/app/models/concerns/spree/provider_listing.rb +41 -0
  31. data/app/models/concerns/spree/publishable.rb +82 -14
  32. data/app/models/concerns/spree/purchase/addresses.rb +23 -10
  33. data/app/models/concerns/spree/purchase/currency.rb +1 -1
  34. data/app/models/concerns/spree/purchase/deprecated_aliases.rb +87 -0
  35. data/app/models/concerns/spree/purchase/gift_cards.rb +10 -4
  36. data/app/models/concerns/spree/purchase/locale.rb +1 -1
  37. data/app/models/concerns/spree/purchase/payment_processing.rb +7 -3
  38. data/app/models/concerns/spree/purchase/purchase_order.rb +2 -2
  39. data/app/models/concerns/spree/purchase/totals.rb +49 -1
  40. data/app/models/concerns/spree/receivable.rb +1 -1
  41. data/app/models/concerns/spree/searchable.rb +0 -4
  42. data/app/models/concerns/spree/secret_preferences.rb +59 -0
  43. data/app/models/concerns/spree/single_store_resource.rb +1 -1
  44. data/app/models/concerns/spree/stores/email_branding.rb +31 -0
  45. data/app/models/concerns/spree/type_labels.rb +38 -0
  46. data/app/models/concerns/spree/typed_adjustment_line.rb +1 -1
  47. data/app/models/concerns/spree/typed_associations.rb +6 -0
  48. data/app/models/spree/address.rb +11 -1
  49. data/app/models/spree/adjusters/promotion.rb +1 -15
  50. data/app/models/spree/allowed_origin.rb +0 -1
  51. data/app/models/spree/api_key.rb +5 -6
  52. data/app/models/spree/authentication/strategies/oidc_strategy.rb +5 -5
  53. data/app/models/spree/base.rb +70 -22
  54. data/app/models/spree/calculator/flat_percent_item_total.rb +1 -3
  55. data/app/models/spree/calculator/flat_rate.rb +1 -3
  56. data/app/models/spree/calculator/flexi_rate.rb +1 -3
  57. data/app/models/spree/calculator/percent_on_line_item.rb +1 -1
  58. data/app/models/spree/calculator/price_sack.rb +1 -3
  59. data/app/models/spree/calculator/shipping/digital_delivery.rb +1 -3
  60. data/app/models/spree/calculator/shipping/flat_percent_item_total.rb +1 -3
  61. data/app/models/spree/calculator/shipping/flat_rate.rb +1 -3
  62. data/app/models/spree/calculator/shipping/flexi_rate.rb +1 -3
  63. data/app/models/spree/calculator/shipping/per_item.rb +1 -3
  64. data/app/models/spree/calculator/shipping/price_sack.rb +1 -3
  65. data/app/models/spree/calculator/tiered_flat_rate.rb +6 -23
  66. data/app/models/spree/calculator/tiered_percent.rb +7 -25
  67. data/app/models/spree/cart.rb +78 -18
  68. data/app/models/spree/channel/gating.rb +1 -1
  69. data/app/models/spree/channel.rb +4 -4
  70. data/app/models/spree/claim.rb +21 -1
  71. data/app/models/spree/claim_line_item.rb +41 -6
  72. data/app/models/spree/collection.rb +0 -1
  73. data/app/models/spree/collection_rule.rb +5 -21
  74. data/app/models/spree/commission_line.rb +1 -1
  75. data/app/models/spree/commission_rate.rb +2 -3
  76. data/app/models/spree/commission_rate_value.rb +1 -1
  77. data/app/models/spree/commission_rule.rb +4 -11
  78. data/app/models/spree/commission_rules/product_rule.rb +1 -1
  79. data/app/models/spree/company_invitation.rb +1 -0
  80. data/app/models/spree/consent_record.rb +0 -1
  81. data/app/models/spree/credit_card.rb +1 -1
  82. data/app/models/spree/current.rb +23 -0
  83. data/app/models/spree/custom_field.rb +2 -2
  84. data/app/models/spree/custom_field_definition.rb +9 -23
  85. data/app/models/spree/custom_fields/boolean.rb +1 -1
  86. data/app/models/spree/custom_fields/json.rb +1 -1
  87. data/app/models/spree/data_feed/google.rb +0 -2
  88. data/app/models/spree/data_request.rb +1 -1
  89. data/app/models/spree/delivery_method.rb +30 -13
  90. data/app/models/spree/delivery_method_rule.rb +4 -11
  91. data/app/models/spree/delivery_rate.rb +4 -5
  92. data/app/models/spree/delivery_rate_provider/base.rb +1 -0
  93. data/app/models/spree/delivery_zone.rb +0 -1
  94. data/app/models/spree/delivery_zone_member.rb +4 -4
  95. data/app/models/spree/digital_asset.rb +7 -0
  96. data/app/models/spree/digital_asset_provider/base.rb +2 -0
  97. data/app/models/spree/digital_link.rb +2 -5
  98. data/app/models/spree/discount.rb +1 -1
  99. data/app/models/spree/email_template.rb +25 -0
  100. data/app/models/spree/email_template_draft.rb +13 -0
  101. data/app/models/spree/email_template_revision.rb +17 -0
  102. data/app/models/spree/email_templates/entry.rb +118 -0
  103. data/app/models/spree/email_templates/sample_record.rb +16 -0
  104. data/app/models/spree/emails/branding.rb +80 -0
  105. data/app/models/spree/exchange.rb +15 -1
  106. data/app/models/spree/exchange_line_item.rb +121 -5
  107. data/app/models/spree/export.rb +0 -1
  108. data/app/models/spree/exports/products.rb +10 -1
  109. data/app/models/spree/exports/report.rb +1 -1
  110. data/app/models/spree/fee.rb +4 -2
  111. data/app/models/spree/fulfillment.rb +53 -27
  112. data/app/models/spree/fulfillment_changer.rb +2 -2
  113. data/app/models/spree/fulfillment_item.rb +0 -4
  114. data/app/models/spree/fulfillment_provider/base.rb +1 -0
  115. data/app/models/spree/gateway/bogus.rb +4 -0
  116. data/app/models/spree/gateway/custom_payment_source_method.rb +5 -0
  117. data/app/models/spree/gateway.rb +5 -1
  118. data/app/models/spree/gift_card.rb +12 -1
  119. data/app/models/spree/gift_card_batch.rb +0 -1
  120. data/app/models/spree/import.rb +2 -7
  121. data/app/models/spree/import_mapping.rb +1 -2
  122. data/app/models/spree/import_row.rb +2 -2
  123. data/app/models/spree/import_schemas/products.rb +1 -0
  124. data/app/models/spree/integration.rb +8 -18
  125. data/app/models/spree/invitation.rb +5 -5
  126. data/app/models/spree/line_item.rb +13 -2
  127. data/app/models/spree/locale.rb +11 -8
  128. data/app/models/spree/market.rb +1 -0
  129. data/app/models/spree/newsletter_subscriber.rb +3 -0
  130. data/app/models/spree/order.rb +56 -138
  131. data/app/models/spree/order_group.rb +11 -2
  132. data/app/models/spree/order_inventory.rb +10 -5
  133. data/app/models/spree/order_promotion.rb +1 -1
  134. data/app/models/spree/order_routing/has_strategy_preference.rb +1 -1
  135. data/app/models/spree/order_routing/strategy/base.rb +4 -2
  136. data/app/models/spree/order_routing/strategy/rules.rb +1 -1
  137. data/app/models/spree/order_routing_rule.rb +6 -22
  138. data/app/models/spree/package_type.rb +1 -2
  139. data/app/models/spree/payment/gateway_options.rb +14 -52
  140. data/app/models/spree/payment/processing.rb +2 -4
  141. data/app/models/spree/payment.rb +20 -5
  142. data/app/models/spree/payment_method/session_instrumentation.rb +1 -0
  143. data/app/models/spree/payment_method/store_credit.rb +4 -4
  144. data/app/models/spree/payment_method.rb +36 -4
  145. data/app/models/spree/payment_session.rb +5 -1
  146. data/app/models/spree/payment_setup_session.rb +1 -0
  147. data/app/models/spree/payout_provider/base.rb +3 -1
  148. data/app/models/spree/pickup_point_provider/base.rb +2 -0
  149. data/app/models/spree/price_list.rb +1 -2
  150. data/app/models/spree/price_rules/channel_rule.rb +1 -1
  151. data/app/models/spree/price_rules/customer_group_rule.rb +1 -1
  152. data/app/models/spree/product.rb +31 -7
  153. data/app/models/spree/product_submission.rb +0 -12
  154. data/app/models/spree/product_type.rb +0 -1
  155. data/app/models/spree/promotion/actions/create_line_items.rb +27 -17
  156. data/app/models/spree/promotion/rules/category.rb +0 -4
  157. data/app/models/spree/promotion/rules/channel.rb +0 -4
  158. data/app/models/spree/promotion/rules/country.rb +0 -4
  159. data/app/models/spree/promotion/rules/currency.rb +0 -4
  160. data/app/models/spree/promotion/rules/customer_group.rb +0 -4
  161. data/app/models/spree/promotion/rules/first_order.rb +0 -4
  162. data/app/models/spree/promotion/rules/item_total.rb +4 -8
  163. data/app/models/spree/promotion/rules/market.rb +0 -4
  164. data/app/models/spree/promotion/rules/one_use_per_user.rb +0 -4
  165. data/app/models/spree/promotion/rules/option_value.rb +0 -3
  166. data/app/models/spree/promotion/rules/product.rb +0 -4
  167. data/app/models/spree/promotion/rules/user.rb +0 -4
  168. data/app/models/spree/promotion/rules/user_logged_in.rb +0 -4
  169. data/app/models/spree/promotion.rb +35 -6
  170. data/app/models/spree/promotion_action.rb +7 -14
  171. data/app/models/spree/promotion_action_line_item.rb +1 -1
  172. data/app/models/spree/promotion_handler/coupon.rb +59 -27
  173. data/app/models/spree/promotion_rule.rb +7 -14
  174. data/app/models/spree/purchase_order.rb +1 -0
  175. data/app/models/spree/quantity_rule.rb +1 -1
  176. data/app/models/spree/refund.rb +28 -3
  177. data/app/models/spree/return.rb +30 -2
  178. data/app/models/spree/return_line_item.rb +65 -14
  179. data/app/models/spree/role.rb +7 -7
  180. data/app/models/spree/saved_report.rb +1 -1
  181. data/app/models/spree/seller.rb +2 -1
  182. data/app/models/spree/seller_payout.rb +7 -0
  183. data/app/models/spree/seller_requirement.rb +4 -12
  184. data/app/models/spree/seller_requirement_custom_field.rb +1 -2
  185. data/app/models/spree/seller_requirement_submission.rb +2 -2
  186. data/app/models/spree/seller_requirements/accept_terms.rb +2 -3
  187. data/app/models/spree/seller_transfer.rb +5 -0
  188. data/app/models/spree/shipping_label.rb +1 -0
  189. data/app/models/spree/stock/availability_validator.rb +2 -2
  190. data/app/models/spree/stock/inventory_unit_builder.rb +1 -1
  191. data/app/models/spree/stock/packer.rb +2 -2
  192. data/app/models/spree/stock/splitter/weight.rb +1 -1
  193. data/app/models/spree/stock_level/custom_events.rb +6 -0
  194. data/app/models/spree/stock_level.rb +1 -1
  195. data/app/models/spree/stock_movement.rb +1 -1
  196. data/app/models/spree/stock_receipt_item.rb +1 -1
  197. data/app/models/spree/stock_reservation.rb +2 -2
  198. data/app/models/spree/stock_transfer.rb +4 -2
  199. data/app/models/spree/store.rb +33 -7
  200. data/app/models/spree/store_credit.rb +8 -9
  201. data/app/models/spree/store_credit_event.rb +5 -5
  202. data/app/models/spree/tax_exemption_certificate.rb +2 -0
  203. data/app/models/spree/tax_identifier.rb +3 -1
  204. data/app/models/spree/tax_line.rb +53 -3
  205. data/app/models/spree/tax_provider/base.rb +71 -14
  206. data/app/models/spree/tax_provider/internal.rb +59 -33
  207. data/app/models/spree/tax_provider/recorded_share.rb +114 -0
  208. data/app/models/spree/tax_rate.rb +1 -2
  209. data/app/models/spree/taxonomy.rb +0 -1
  210. data/app/models/spree/variant.rb +4 -3
  211. data/app/models/spree/variant_media.rb +1 -1
  212. data/app/models/spree/webhook_endpoint.rb +10 -13
  213. data/app/models/spree/wishlist.rb +1 -6
  214. data/app/models/spree/wishlist_item/custom_events.rb +6 -0
  215. data/app/presenters/spree/csv/customer_presenter.rb +1 -1
  216. data/app/presenters/spree/csv/newsletter_subscriber_presenter.rb +1 -1
  217. data/app/presenters/spree/csv/order_line_item_presenter.rb +1 -1
  218. data/app/presenters/spree/csv/product_variant_presenter.rb +42 -12
  219. data/app/serializers/spree/emails/base_serializer.rb +14 -0
  220. data/app/serializers/spree/emails/export_serializer.rb +13 -0
  221. data/app/serializers/spree/emails/import_serializer.rb +19 -0
  222. data/app/serializers/spree/emails/invitation_serializer.rb +28 -0
  223. data/app/serializers/spree/emails/store_serializer.rb +31 -0
  224. data/app/serializers/spree/emails/user_serializer.rb +12 -0
  225. data/app/serializers/spree/emails/webhook_endpoint_serializer.rb +16 -0
  226. data/app/services/spree/carts/create.rb +8 -2
  227. data/app/services/spree/carts/destroy.rb +2 -2
  228. data/app/services/spree/carts/remove_out_of_stock_items.rb +2 -2
  229. data/app/services/spree/carts/split_by_seller.rb +1 -1
  230. data/app/services/spree/carts/update.rb +19 -5
  231. data/app/services/spree/catalogs/set_product_terms.rb +1 -1
  232. data/app/services/spree/categories/add_products.rb +13 -31
  233. data/app/services/spree/categories/remove_products.rb +12 -51
  234. data/app/services/spree/checkout/advance.rb +1 -4
  235. data/app/services/spree/collections/add_products.rb +10 -23
  236. data/app/services/spree/collections/remove_products.rb +7 -43
  237. data/app/services/spree/coupon_codes/bulk_generate.rb +8 -14
  238. data/app/services/spree/data_requests/create.rb +1 -1
  239. data/app/services/spree/deliveries/destroy.rb +2 -2
  240. data/app/services/spree/email_templates/check.rb +58 -0
  241. data/app/services/spree/email_templates/no_sample_record.rb +7 -0
  242. data/app/services/spree/email_templates/preview.rb +99 -0
  243. data/app/services/spree/emails/file_system.rb +23 -0
  244. data/app/services/spree/emails/filters.rb +84 -0
  245. data/app/services/spree/emails/liquid_context.rb +48 -0
  246. data/app/services/spree/emails/rendered_email.rb +14 -0
  247. data/app/services/spree/emails/renderer.rb +125 -0
  248. data/app/services/spree/emails/template.rb +51 -0
  249. data/app/services/spree/emails/template_data.rb +23 -0
  250. data/app/services/spree/emails/template_resolver.rb +109 -0
  251. data/app/services/spree/emails/text_converter.rb +72 -0
  252. data/app/services/spree/emails/translation_inliner.rb +134 -0
  253. data/app/services/spree/imports/row_processors/price_list_price.rb +10 -10
  254. data/app/services/spree/imports/row_processors/product_variant.rb +65 -34
  255. data/app/services/spree/imports/row_processors/purchase_order.rb +13 -13
  256. data/app/services/spree/locales.rb +4 -5
  257. data/app/services/spree/newsletter/subscribe.rb +8 -12
  258. data/app/services/spree/orders/build_fulfillments.rb +3 -2
  259. data/app/services/spree/orders/create.rb +2 -1
  260. data/app/services/spree/orders/discounts/create.rb +7 -4
  261. data/app/services/spree/orders/discounts/destroy.rb +1 -0
  262. data/app/services/spree/orders/discounts/update.rb +1 -0
  263. data/app/services/spree/orders/fees/create.rb +1 -0
  264. data/app/services/spree/orders/fees/destroy.rb +1 -0
  265. data/app/services/spree/orders/fees/update.rb +1 -0
  266. data/app/services/spree/orders/remove_line_item.rb +13 -3
  267. data/app/services/spree/orders/update.rb +10 -6
  268. data/app/services/spree/orders/update_item.rb +6 -3
  269. data/app/services/spree/orders/update_statuses.rb +3 -1
  270. data/app/services/spree/product_memberships.rb +61 -0
  271. data/app/services/spree/returns/eligibility_validator.rb +1 -1
  272. data/app/services/spree/search_provider/custom_field_schema.rb +2 -2
  273. data/app/services/spree/seeds/commission_rates.rb +1 -1
  274. data/app/services/spree/seeds/digital_delivery.rb +1 -1
  275. data/app/services/spree/seeds/payment_methods.rb +2 -2
  276. data/app/services/spree/seeds/saved_reports.rb +3 -3
  277. data/app/services/spree/sellers/requirements.rb +1 -2
  278. data/app/services/spree/shipping_labels/confirm_refund.rb +1 -1
  279. data/app/services/spree/shipping_labels/store_file.rb +3 -3
  280. data/app/services/spree/stock_levels/correct.rb +3 -3
  281. data/app/services/spree/stock_reservations/reserve.rb +2 -3
  282. data/app/services/spree/store_credits/apply.rb +15 -13
  283. data/app/services/spree/stores/provision_defaults.rb +15 -14
  284. data/app/subscribers/spree/seller_onboarding_subscriber.rb +1 -1
  285. data/app/views/layouts/spree/base_mailer.html.erb +2 -43
  286. data/app/views/layouts/spree/base_mailer.liquid +79 -0
  287. data/app/views/spree/admin_user_mailer/password_reset_email.liquid +14 -0
  288. data/app/views/spree/export_mailer/export_done.liquid +12 -0
  289. data/app/views/spree/import_mailer/import_done.liquid +15 -0
  290. data/app/views/spree/invitation_mailer/invitation_accepted.liquid +9 -0
  291. data/app/views/spree/invitation_mailer/invitation_email.liquid +13 -0
  292. data/app/views/spree/seller_user_mailer/password_reset_email.liquid +14 -0
  293. data/app/views/spree/shared/_mailer_button.html.erb +4 -5
  294. data/app/views/spree/shared/_mailer_hero.html.erb +6 -5
  295. data/app/views/spree/shared/_summary_row.liquid +8 -0
  296. data/app/views/spree/webhook_mailer/endpoint_disabled.liquid +21 -0
  297. data/app/workflows/spree/carts/add_item.rb +4 -4
  298. data/app/workflows/spree/carts/complete.rb +13 -3
  299. data/app/workflows/spree/carts/merge.rb +1 -1
  300. data/app/workflows/spree/carts/recalculate_totals.rb +5 -0
  301. data/app/workflows/spree/carts/upsert_items.rb +18 -9
  302. data/app/workflows/spree/claims/approve.rb +5 -22
  303. data/app/workflows/spree/claims/cancel.rb +5 -21
  304. data/app/workflows/spree/claims/create.rb +15 -0
  305. data/app/workflows/spree/claims/deny.rb +7 -0
  306. data/app/workflows/spree/claims/resolve.rb +34 -31
  307. data/app/workflows/spree/customers/anonymize.rb +3 -4
  308. data/app/workflows/spree/customers/create.rb +1 -1
  309. data/app/workflows/spree/data_requests/fulfill.rb +2 -2
  310. data/app/workflows/spree/deliveries/update_tracking.rb +1 -1
  311. data/app/workflows/spree/email_templates/discard_draft.rb +20 -0
  312. data/app/workflows/spree/email_templates/publish.rb +70 -0
  313. data/app/workflows/spree/email_templates/restore_revision.rb +33 -0
  314. data/app/workflows/spree/email_templates/revert.rb +29 -0
  315. data/app/workflows/spree/email_templates/save_draft.rb +85 -0
  316. data/app/workflows/spree/exchanges/approve.rb +5 -22
  317. data/app/workflows/spree/exchanges/cancel.rb +5 -21
  318. data/app/workflows/spree/exchanges/create.rb +6 -0
  319. data/app/workflows/spree/exchanges/fulfill.rb +75 -51
  320. data/app/workflows/spree/exchanges/receive.rb +6 -65
  321. data/app/workflows/spree/fulfillments/cancel.rb +1 -1
  322. data/app/workflows/spree/fulfillments/create.rb +51 -47
  323. data/app/workflows/spree/fulfillments/fulfill.rb +23 -27
  324. data/app/workflows/spree/fulfillments/mark_delivered.rb +1 -1
  325. data/app/workflows/spree/fulfillments/replacements.rb +59 -0
  326. data/app/workflows/spree/gift_cards/apply.rb +2 -2
  327. data/app/workflows/spree/orders/add_item.rb +5 -1
  328. data/app/workflows/spree/orders/cancel.rb +3 -3
  329. data/app/workflows/spree/orders/complete.rb +16 -8
  330. data/app/workflows/spree/payments/process.rb +2 -2
  331. data/app/workflows/spree/post_sale/approval.rb +35 -0
  332. data/app/workflows/spree/post_sale/cancellation.rb +44 -0
  333. data/app/workflows/spree/post_sale/receipt.rb +92 -0
  334. data/app/workflows/spree/products/nested_attributes.rb +1 -1
  335. data/app/workflows/spree/purchase_orders/cancel.rb +1 -1
  336. data/app/workflows/spree/purchase_orders/close.rb +3 -31
  337. data/app/workflows/spree/purchase_orders/mark_draft.rb +2 -2
  338. data/app/workflows/spree/purchase_orders/mark_ordered.rb +2 -2
  339. data/app/workflows/spree/purchase_orders/receive.rb +1 -1
  340. data/app/workflows/spree/purchase_orders/update.rb +5 -34
  341. data/app/workflows/spree/receivables/closing_short.rb +46 -0
  342. data/app/workflows/spree/receivables/draft_editing.rb +48 -0
  343. data/app/workflows/spree/refunds/create.rb +5 -2
  344. data/app/workflows/spree/refunds/order_payments.rb +14 -2
  345. data/app/workflows/spree/refunds/tax_credit.rb +67 -0
  346. data/app/workflows/spree/returns/approve.rb +5 -22
  347. data/app/workflows/spree/returns/cancel.rb +5 -22
  348. data/app/workflows/spree/returns/create.rb +8 -0
  349. data/app/workflows/spree/returns/receive.rb +5 -76
  350. data/app/workflows/spree/returns/refund.rb +31 -8
  351. data/app/workflows/spree/seller_requirement_submissions/create.rb +1 -2
  352. data/app/workflows/spree/seller_transfers/reverse.rb +28 -1
  353. data/app/workflows/spree/shipping_labels/purchase.rb +8 -8
  354. data/app/workflows/spree/shipping_labels/record.rb +5 -5
  355. data/app/workflows/spree/shipping_labels/refund.rb +2 -2
  356. data/app/workflows/spree/stock_receipts/recording.rb +4 -4
  357. data/app/workflows/spree/stock_transfers/cancel.rb +2 -2
  358. data/app/workflows/spree/stock_transfers/close.rb +3 -30
  359. data/app/workflows/spree/stock_transfers/mark_draft.rb +1 -1
  360. data/app/workflows/spree/stock_transfers/mark_in_transit.rb +3 -3
  361. data/app/workflows/spree/stock_transfers/mark_ready.rb +2 -2
  362. data/app/workflows/spree/stock_transfers/receive.rb +1 -1
  363. data/app/workflows/spree/stock_transfers/update.rb +5 -33
  364. data/config/i18n-tasks.yml +32 -18
  365. data/config/initializers/alba.rb +7 -0
  366. data/config/language_names.csv +264 -0
  367. data/config/locales/ar.yml +811 -0
  368. data/config/locales/be.yml +855 -0
  369. data/config/locales/bg.yml +723 -0
  370. data/config/locales/ca.yml +689 -0
  371. data/config/locales/cs.yml +855 -0
  372. data/config/locales/da.yml +771 -0
  373. data/config/locales/de-CH.yml +248 -0
  374. data/config/locales/de.yml +835 -0
  375. data/config/locales/el.yml +769 -0
  376. data/config/locales/en.yml +37 -503
  377. data/config/locales/es-CL.yml +301 -0
  378. data/config/locales/es-EC.yml +186 -0
  379. data/config/locales/es-MX.yml +334 -0
  380. data/config/locales/es.yml +831 -0
  381. data/config/locales/et.yml +644 -0
  382. data/config/locales/fa.yml +709 -0
  383. data/config/locales/fi.yml +771 -0
  384. data/config/locales/fr.yml +830 -0
  385. data/config/locales/hu.yml +751 -0
  386. data/config/locales/id.yml +756 -0
  387. data/config/locales/it.yml +833 -0
  388. data/config/locales/ja.yml +726 -0
  389. data/config/locales/km.yml +759 -0
  390. data/config/locales/ko.yml +575 -0
  391. data/config/locales/ku.yml +753 -0
  392. data/config/locales/lt.yml +821 -0
  393. data/config/locales/lv.yml +685 -0
  394. data/config/locales/nb.yml +824 -0
  395. data/config/locales/ne.yml +823 -0
  396. data/config/locales/nl.yml +826 -0
  397. data/config/locales/pl.yml +835 -0
  398. data/config/locales/pt-BR.yml +547 -0
  399. data/config/locales/pt.yml +636 -0
  400. data/config/locales/ro.yml +750 -0
  401. data/config/locales/ru.yml +834 -0
  402. data/config/locales/sk.yml +680 -0
  403. data/config/locales/sl-SI.yml +600 -0
  404. data/config/locales/sv.yml +827 -0
  405. data/config/locales/th.yml +745 -0
  406. data/config/locales/tr.yml +777 -0
  407. data/config/locales/uk.yml +836 -0
  408. data/config/locales/vi.yml +768 -0
  409. data/config/locales/zh-CN.yml +786 -0
  410. data/config/locales/zh-TW.yml +834 -0
  411. data/db/migrate/20260925000001_add_replacement_to_spree_fulfillment_items.rb +5 -0
  412. data/db/migrate/20261001000001_add_post_sale_tax.rb +30 -0
  413. data/db/migrate/20261001000001_convert_preferences_to_json.rb +166 -0
  414. data/db/migrate/20261001000001_create_spree_email_templates.rb +52 -0
  415. data/lib/generators/spree/dummy/dummy_generator.rb +0 -9
  416. data/lib/generators/spree/dummy/templates/rails/test.rb +7 -0
  417. data/lib/spree/checkout/default_requirements.rb +37 -13
  418. data/lib/spree/core/configuration.rb +1 -1
  419. data/lib/spree/core/controller_helpers/store.rb +1 -1
  420. data/lib/spree/core/dependencies.rb +5 -4
  421. data/lib/spree/core/emails/escaped_output.rb +60 -0
  422. data/lib/spree/core/engine.rb +48 -167
  423. data/lib/spree/core/permissions/default_catalog.rb +5 -0
  424. data/lib/spree/core/preferences/json_conversion.rb +182 -0
  425. data/lib/spree/core/preferences/masking.rb +4 -1
  426. data/lib/spree/core/preferences/preferable.rb +137 -83
  427. data/lib/spree/core/preferences/preferable_class_methods.rb +81 -146
  428. data/lib/spree/core/previews/import_preview.rb +10 -0
  429. data/lib/spree/core/tax/provider_error.rb +37 -0
  430. data/lib/spree/core/version.rb +1 -1
  431. data/lib/spree/core.rb +85 -370
  432. data/lib/spree/emails/editable_templates.rb +82 -0
  433. data/lib/spree/events/catalog.rb +189 -0
  434. data/lib/spree/events.rb +21 -1
  435. data/lib/spree/hooks.rb +0 -17
  436. data/lib/spree/i18n.rb +32 -15
  437. data/lib/spree/reporting/adapters/live.rb +6 -6
  438. data/lib/spree/reporting/default_vocabulary.rb +22 -10
  439. data/lib/spree/reporting/hydration.rb +1 -1
  440. data/lib/spree/reporting/query.rb +8 -0
  441. data/lib/spree/reporting/schema.rb +10 -10
  442. data/lib/spree/testing_support/editable_email_templates.rb +61 -0
  443. data/lib/spree/testing_support/emails.rb +22 -0
  444. data/lib/spree/testing_support/factories/cart_factory.rb +1 -1
  445. data/lib/spree/testing_support/factories/email_template_factory.rb +28 -0
  446. data/lib/spree/testing_support/factories/order_factory.rb +2 -2
  447. data/lib/spree/testing_support/factories/refund_factory.rb +1 -1
  448. data/lib/spree/testing_support/factories/stock_factory.rb +1 -1
  449. data/lib/spree/testing_support/factories/variant_factory.rb +1 -1
  450. data/lib/spree/testing_support/i18n.rb +3 -94
  451. data/lib/spree/testing_support/lifecycle_events.rb +1 -1
  452. data/lib/spree/testing_support/order_walkthrough.rb +1 -1
  453. data/lib/spree/testing_support/preferences.rb +0 -5
  454. data/lib/spree/testing_support/store.rb +9 -1
  455. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +45 -0
  456. data/lib/tasks/categories_and_collections.rake +3 -3
  457. data/lib/tasks/core.rake +6 -4
  458. data/lib/tasks/media.rake +3 -3
  459. data/lib/tasks/package_types.rake +1 -1
  460. data/lib/tasks/preferences_json.rake +101 -0
  461. data/spec/fixtures/mailer_views/custom_views_probe_mailer/welcome_email.html.erb +4 -0
  462. metadata +188 -44
  463. data/app/models/acts_as_taggable_on/tag_decorator.rb +0 -10
  464. data/app/models/concerns/spree/has_image_alt_text.rb +0 -18
  465. data/app/models/spree/base_analytics_event_handler.rb +0 -54
  466. data/app/models/spree/invitations/store.rb +0 -6
  467. data/app/models/spree/preference.rb +0 -14
  468. data/app/models/spree/promotion_handler/page.rb +0 -25
  469. data/app/models/spree/promotion_handler/promotion_duplicator.rb +0 -60
  470. data/app/models/spree/stock/differentiator.rb +0 -45
  471. data/app/presenters/spree/filters/options_presenter.rb +0 -47
  472. data/app/services/spree/carts/empty.rb +0 -42
  473. data/app/services/spree/country_to_timezone.rb +0 -273
  474. data/app/services/spree/payments/create.rb +0 -92
  475. data/app/validators/spree/url_validator.rb +0 -23
  476. data/app/views/active_storage/blobs/_blob.html.erb +0 -14
  477. data/app/views/spree/admin_user_mailer/password_reset_email.html.erb +0 -8
  478. data/app/views/spree/export_mailer/export_done.html.erb +0 -7
  479. data/app/views/spree/import_mailer/import_done.html.erb +0 -12
  480. data/app/views/spree/invitation_mailer/invitation_accepted.html.erb +0 -4
  481. data/app/views/spree/invitation_mailer/invitation_email.html.erb +0 -12
  482. data/app/views/spree/seller_user_mailer/password_reset_email.html.erb +0 -8
  483. data/app/views/spree/shared/_base_mailer_footer.html.erb +0 -19
  484. data/app/views/spree/shared/_base_mailer_header.html.erb +0 -8
  485. data/app/views/spree/shared/_base_mailer_stylesheets.html.erb +0 -399
  486. data/app/views/spree/shared/_mailer_logo.html.erb +0 -41
  487. data/app/views/spree/shared/_purchased_items_styles.html.erb +0 -119
  488. data/app/views/spree/webhook_mailer/endpoint_disabled.html.erb +0 -51
  489. data/app/views/spree/webhook_mailer/endpoint_disabled.text.erb +0 -10
  490. data/app/workflows/spree/fulfillments/purchase_label.rb +0 -40
  491. data/app/workflows/spree/returns/purchase_label.rb +0 -32
  492. data/lib/spree/analytics.rb +0 -11
  493. data/lib/spree/core/preferences/scoped_store.rb +0 -34
  494. data/lib/spree/core/preferences/store.rb +0 -105
  495. data/lib/spree/translation_migrations.rb +0 -52
@@ -2,6 +2,7 @@ module Spree
2
2
  module Collections
3
3
  class RemoveProducts
4
4
  prepend Spree::ServiceModule::Base
5
+ include Spree::ProductMemberships
5
6
 
6
7
  # Removes the given products from the given collections and re-packs positions.
7
8
  #
@@ -11,54 +12,17 @@ module Spree
11
12
  def call(collections:, products:)
12
13
  return if collections.blank? || products.blank?
13
14
 
14
- collection_ids = collections.pluck(:id)
15
- product_ids = products.pluck(:id)
16
-
17
- ApplicationRecord.transaction do
18
- collection_ids.each do |collection_id|
19
- Spree::ProductCollection.where(collection_id: collection_id, product_id: product_ids).delete_all
20
- end
21
-
22
- product_collections_params = collection_ids.flat_map do |collection_id|
23
- position = 0
24
- existing_product_ids = Spree::ProductCollection.where(collection_id: collection_id).pluck(:product_id)
25
-
26
- existing_product_ids.map do |product_id|
27
- {
28
- collection_id: collection_id,
29
- product_id: product_id,
30
- position: (position += 1),
31
- created_at: Time.current,
32
- updated_at: Time.current
33
- }
34
- end
35
- end
36
-
37
- if product_collections_params.any?
38
- opts = {}
39
- # Column list rather than the index name — the composite unique index is named
40
- # index_product_collections_on_collection_and_product (shortened to fit the
41
- # identifier limit), so match by columns to stay name-independent.
42
- opts[:unique_by] = %i[collection_id product_id] unless mysql_adapter?
43
-
44
- Spree::ProductCollection.upsert_all(product_collections_params, **opts)
45
- end
46
- end
47
-
48
- collection_ids.each { |id| Spree::Collection.reset_counters(id, :product_collections) }
49
- product_ids.each { |id| Spree::Product.reset_counters(id, :product_collections) }
50
-
51
- Spree::Product.where(id: product_ids).touch_all
52
- products.each(&:enqueue_search_index)
53
- Spree::Collection.where(id: collection_ids).touch_all
54
-
15
+ remove_memberships(collections, products)
55
16
  success(true)
56
17
  end
57
18
 
58
19
  private
59
20
 
60
- def mysql_adapter?
61
- Spree.mysql?
21
+ def membership_class = Spree::ProductCollection
22
+ def group_class = Spree::Collection
23
+
24
+ def refresh_group_counters(collection_ids)
25
+ collection_ids.each { |id| Spree::Collection.reset_counters(id, :product_collections) }
62
26
  end
63
27
  end
64
28
  end
@@ -4,13 +4,15 @@ module Spree
4
4
  prepend Spree::ServiceModule::Base
5
5
 
6
6
  def call(promotion:, quantity: 10)
7
- coupon_codes = []
8
-
9
7
  Spree::CouponCode.transaction do
10
- quantity.times do
11
- coupon_codes << coupon_attributes(promotion).merge(code: create_code(promotion.code_prefix))
12
- end
13
- Spree::CouponCode.insert_all coupon_codes
8
+ Spree::CouponCode.insert_all(
9
+ Array.new(quantity) do
10
+ {
11
+ promotion_id: promotion.id, code: create_code(promotion.code_prefix),
12
+ created_at: Time.current, updated_at: Time.current
13
+ }
14
+ end
15
+ )
14
16
  end
15
17
 
16
18
  success(promotion.reload.coupon_codes)
@@ -24,14 +26,6 @@ module Spree
24
26
  break code unless Spree::CouponCode.exists?(code: code)
25
27
  end
26
28
  end
27
-
28
- def coupon_attributes(promotion)
29
- {
30
- promotion_id: promotion.id,
31
- created_at: Time.current,
32
- updated_at: Time.current
33
- }
34
- end
35
29
  end
36
30
  end
37
31
  end
@@ -26,7 +26,7 @@ module Spree
26
26
  # and a second erasure has nothing to erase.
27
27
  if customer.anonymized?
28
28
  data_request = Spree::DataRequest.new(customer: customer)
29
- data_request.errors.add(:base, Spree.t('data_request_errors.already_anonymized'))
29
+ data_request.errors.add(:base, I18n.t('spree.data_request_errors.already_anonymized'))
30
30
  return failure(data_request)
31
31
  end
32
32
 
@@ -15,12 +15,12 @@ module Spree
15
15
  # @return [Spree::ServiceModule::Result]
16
16
  def call(delivery:)
17
17
  if delivery.shipping_label_id.present?
18
- delivery.errors.add(:base, :has_label, message: Spree.t('deliveries.errors.has_label'))
18
+ delivery.errors.add(:base, :has_label, message: I18n.t('spree.deliveries.errors.has_label'))
19
19
  return failure(delivery)
20
20
  end
21
21
 
22
22
  if delivery.delivered?
23
- delivery.errors.add(:base, :delivered, message: Spree.t('deliveries.errors.delivered'))
23
+ delivery.errors.add(:base, :delivered, message: I18n.t('spree.deliveries.errors.delivered'))
24
24
  return failure(delivery)
25
25
  end
26
26
 
@@ -0,0 +1,58 @@
1
+ module Spree
2
+ module EmailTemplates
3
+ # Renders a draft the way customers would receive it, with sample data
4
+ # and strict variables, and reports what stops it from rendering. A
5
+ # draft of the layout or a shared partial is checked inside every
6
+ # editable email, since all of them use it, and in every language the
7
+ # store has published its own version of an email in.
8
+ #
9
+ # An email the store has no record for cannot be rendered with data; its
10
+ # draft is still checked for Liquid syntax errors.
11
+ class Check
12
+ # @param store [Spree::Store]
13
+ # @param draft [Spree::EmailTemplateDraft]
14
+ def initialize(store:, draft:)
15
+ @store = store
16
+ @draft = draft
17
+ end
18
+
19
+ # @return [Array<Hash>] one `{ email:, message:, line: }` per problem; empty when the draft renders
20
+ def call
21
+ locales.flat_map { |locale| email_keys.filter_map { |email_key| problem_in(email_key, locale) } }.uniq
22
+ end
23
+
24
+ private
25
+
26
+ def email_keys
27
+ definition = @draft.definition
28
+ definition.email? ? [definition.key] : Spree.editable_email_templates.emails.map(&:key)
29
+ end
30
+
31
+ def locales
32
+ return [@draft.locale] if @draft.definition.email? || @draft.locale != Spree::EmailTemplate::ANY_LOCALE
33
+
34
+ [@draft.locale, *@store.email_templates.published.where.not(locale: Spree::EmailTemplate::ANY_LOCALE).distinct.pluck(:locale)]
35
+ end
36
+
37
+ def problem_in(email_key, locale)
38
+ Preview.new(store: @store, key: @draft.key, locale: locale, subject: @draft.subject, body: @draft.body,
39
+ email_key: email_key, strict: true).call
40
+ nil
41
+ rescue Spree::EmailTemplates::NoSampleRecord
42
+ syntax_problem(email_key)&.merge(locale: locale)
43
+ rescue Liquid::Error => e
44
+ { email: email_key, locale: locale, message: e.message, line: e.line_number }
45
+ rescue MRML::Error => e
46
+ { email: email_key, locale: locale, message: e.message, line: nil }
47
+ end
48
+
49
+ def syntax_problem(email_key)
50
+ Liquid::Template.parse(@draft.body, environment: Spree::Emails::Renderer.environment, line_numbers: true)
51
+ Liquid::Template.parse(@draft.subject.to_s, environment: Spree::Emails::Renderer.environment)
52
+ nil
53
+ rescue Liquid::Error => e
54
+ { email: email_key, message: e.message, line: e.line_number }
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,7 @@
1
+ module Spree
2
+ module EmailTemplates
3
+ # The store has no record to build an email's sample data from, so it
4
+ # cannot be previewed with data yet.
5
+ class NoSampleRecord < StandardError; end
6
+ end
7
+ end
@@ -0,0 +1,99 @@
1
+ module Spree
2
+ module EmailTemplates
3
+ # Renders an editable email template with sample data, through the same
4
+ # renderer customers' emails go through. An unsaved subject and body take
5
+ # the place of the store's published version; everything else the email
6
+ # uses comes from the store's published templates and the files.
7
+ #
8
+ # The layout and shared partials are previewed inside an email that uses
9
+ # them, the first editable email unless another is named.
10
+ class Preview
11
+ # @return [Hash] the variables the email was rendered with, once {#call} has run
12
+ attr_reader :variables
13
+
14
+ # @return [Spree::Emails::RenderedEmail, nil] the rendered email, once {#call} has run
15
+ attr_reader :rendered
16
+
17
+ # @param store [Spree::Store]
18
+ # @param key [String] the editable template being previewed
19
+ # @param locale [String] a language code, or "any"
20
+ # @param subject [String, nil] an unsaved subject; nil keeps the current one
21
+ # @param body [String, nil] an unsaved body; nil keeps the current one
22
+ # @param record_id [String, nil] the prefixed id of the record to build sample data from
23
+ # @param email_key [String, nil] for the layout or a partial, the email to show it in
24
+ # @param strict [Boolean] whether an unknown variable raises
25
+ # @param branding [Hash] unsaved branding values (see Spree::Emails::Branding)
26
+ def initialize(store:, key:, locale: Spree::EmailTemplate::ANY_LOCALE, subject: nil, body: nil,
27
+ record_id: nil, email_key: nil, strict: false, branding: {})
28
+ @store = store
29
+ @key = key
30
+ @locale = locale.presence || Spree::EmailTemplate::ANY_LOCALE
31
+ @subject = subject
32
+ @body = body
33
+ @record_id = record_id
34
+ @email_key = email_key
35
+ @strict = strict
36
+ @branding = branding
37
+ end
38
+
39
+ # @return [Spree::Emails::RenderedEmail]
40
+ # @raise [Spree::EmailTemplates::NoSampleRecord] when the store has no record to build the sample from
41
+ def call
42
+ sample = email.sample_class.new(store: @store, record_id: @record_id)
43
+
44
+ in_locale do
45
+ renderer = Spree::Emails::Renderer.new(resolver: resolver, store: @store, currency: sample.currency, strict: @strict,
46
+ branding: @branding)
47
+ sample_variables = sample.variables
48
+ @variables = renderer.variables(sample_variables)
49
+ @rendered = renderer.render(resolver.find(email.key), sample_variables)
50
+ end
51
+ end
52
+
53
+ # @return [Spree::Emails::EditableTemplates::Definition] the email the preview renders
54
+ def email
55
+ @email ||= if definition.email?
56
+ definition
57
+ else
58
+ emails = Spree.editable_email_templates.emails
59
+ emails.find { |candidate| candidate.key == @email_key } || emails.first
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def definition
66
+ Spree.editable_email_templates[@key] || raise(ArgumentError, "#{@key} is not an editable email template")
67
+ end
68
+
69
+ def resolver
70
+ @resolver ||= Spree::Emails::TemplateResolver.for_mailers(store: @store, locale: language, drafts: drafts)
71
+ end
72
+
73
+ # An unsaved subject or body sent alone is previewed with the other half
74
+ # customers currently get.
75
+ def drafts
76
+ return {} if @subject.nil? && @body.nil?
77
+
78
+ current = Spree::Emails::TemplateResolver.for_mailers(store: @store, locale: language).find(@key)
79
+ subject = @subject.nil? ? current&.subject : @subject
80
+ body = @body.nil? ? current&.body : @body
81
+ { @key => Spree::Emails::Template.new(key: @key, subject: subject, body: body) }
82
+ end
83
+
84
+ def language
85
+ @locale == Spree::EmailTemplate::ANY_LOCALE ? @store.default_locale : @locale
86
+ end
87
+
88
+ # Mirrors how mailers render: the email's language with the store's
89
+ # translation fallbacks, restored afterwards.
90
+ def in_locale(&block)
91
+ previous_fallbacks = Mobility.store_based_fallbacks
92
+ Spree::Locales::SetFallbackLocaleForStore.new.call(store: @store)
93
+ I18n.with_locale(language.presence || I18n.default_locale, &block)
94
+ ensure
95
+ Mobility.store_based_fallbacks = previous_fallbacks
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,23 @@
1
+ module Spree
2
+ module Emails
3
+ # Loads the partials templates pull in with `{% render 'spree/shared/line_item' %}`
4
+ # from `spree/shared/_line_item.liquid`, following Rails' partial naming.
5
+ class FileSystem
6
+ # @param resolver [Spree::Emails::TemplateResolver]
7
+ def initialize(resolver)
8
+ @resolver = resolver
9
+ end
10
+
11
+ # @param name [String]
12
+ # @return [String] the partial's source
13
+ def read_template_file(name)
14
+ partial = @resolver.find_partial(name.to_s)
15
+ raise Liquid::FileSystemError, "No such email partial: #{name}" unless partial
16
+
17
+ partial.body
18
+ rescue Spree::Emails::TemplateResolver::InvalidKey
19
+ raise Liquid::FileSystemError, 'Invalid email partial name'
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,84 @@
1
+ module Spree
2
+ module Emails
3
+ # The filters email templates can use, in addition to Liquid's standard
4
+ # set. Names follow common Liquid conventions where the meaning is the same,
5
+ # is the Liquid most developers and agents already know.
6
+ module Filters
7
+ # @example {{ line_item.price | money }}
8
+ # @param amount [String, Numeric, nil]
9
+ # @param currency [String, nil] defaults to the email's currency
10
+ # @return [String]
11
+ def money(amount, currency = nil)
12
+ return '' if amount.blank?
13
+
14
+ Spree::Money.new(amount, currency: currency.presence || @context.registers[:currency]).to_s
15
+ end
16
+
17
+ # @example {{ order.total | money_with_currency }} # => "$10.00 USD"
18
+ # @return [String]
19
+ def money_with_currency(amount, currency = nil)
20
+ return '' if amount.blank?
21
+
22
+ Spree::Money.new(amount, currency: currency.presence || @context.registers[:currency], with_currency: true).to_s
23
+ end
24
+
25
+ # Formats a date in the store's time zone, never the server's. Takes a
26
+ # named format from the locale's `date.formats` (`long`, `short`,
27
+ # `default`), or a strftime pattern. A date with no time of day, such as
28
+ # "2026-12-31", is shown as that day in every zone.
29
+ #
30
+ # @example {{ order.completed_at | date: 'long' }}
31
+ # @example {{ 'now' | date: '%Y' }}
32
+ # @return [String]
33
+ def date(input, format = 'default')
34
+ value = parse_time(input)
35
+ return input.to_s if value.nil?
36
+
37
+ value = value.in_time_zone(time_zone) unless value.instance_of?(Date)
38
+ format = format.to_s
39
+
40
+ return value.strftime(format) if format.include?('%')
41
+
42
+ format = 'default' unless I18n.exists?("date.formats.#{format}")
43
+ I18n.l(value.to_date, format: format.to_sym)
44
+ end
45
+
46
+ # Translates one of Spree's own keys, with interpolation.
47
+ #
48
+ # @example {{ 'order_mailer.confirm_email.dear_customer' | t: name: order.customer_name }}
49
+ # @return [String]
50
+ def t(key, options = {})
51
+ interpolations = options.to_h.except('scope', 'default', :scope, :default).transform_keys(&:to_sym)
52
+
53
+ I18n.t(key.to_s, **interpolations, scope: :spree).to_s
54
+ end
55
+
56
+ # Marks a value as trusted HTML, so it is output without escaping. Only
57
+ # for HTML that was sanitized when it was written.
58
+ #
59
+ # @example {{ product.description_html | raw }}
60
+ # @return [ActiveSupport::SafeBuffer]
61
+ def raw(input)
62
+ input.to_s.html_safe
63
+ end
64
+
65
+ private
66
+
67
+ def parse_time(input)
68
+ case input
69
+ when Time, DateTime, ActiveSupport::TimeWithZone then input
70
+ when Date then input
71
+ when 'now', 'today' then Time.current
72
+ when /\A\d{4}-\d{2}-\d{2}\z/ then Date.iso8601(input)
73
+ when String then Time.iso8601(input)
74
+ end
75
+ rescue ArgumentError
76
+ nil
77
+ end
78
+
79
+ def time_zone
80
+ Time.find_zone(@context.registers[:store]&.preferred_timezone) || Time.zone
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,48 @@
1
+ module Spree
2
+ module Emails
3
+ # `{% render %}` gives each partial a fresh, isolated context, and Liquid
4
+ # does not carry strict mode into it.
5
+ #
6
+ # Inside a partial an argument the caller left out reads as empty, so
7
+ # partials can take optional arguments; a misspelled field on an object
8
+ # still raises under strict variables.
9
+ class LiquidContext < Liquid::Context
10
+ attr_accessor :optional_arguments
11
+
12
+ def new_isolated_subcontext
13
+ super.tap do |subcontext|
14
+ subcontext.strict_variables = strict_variables
15
+ subcontext.strict_filters = strict_filters
16
+ subcontext.optional_arguments = true
17
+ end
18
+ end
19
+
20
+ def find_variable(key, raise_on_not_found: true)
21
+ super(key, raise_on_not_found: raise_on_not_found && !optional_arguments)
22
+ end
23
+
24
+ # Filters that can only rearrange escaped text, never decode it into
25
+ # markup. `url_decode` and the like are left out on purpose: turning
26
+ # "%3Cb%3E" back into "<b>" must not produce trusted HTML.
27
+ MARKUP_PRESERVING_FILTERS = %w[
28
+ strip lstrip rstrip strip_newlines upcase downcase capitalize
29
+ append prepend replace replace_first replace_last remove remove_first remove_last
30
+ truncate truncatewords
31
+ ].freeze
32
+
33
+ # Captured text is already-escaped HTML. A filter from the list above
34
+ # keeps it that way: plain string arguments are escaped going in, and the
35
+ # result stays marked safe, so `{{ greeting | upcase }}` is not escaped a
36
+ # second time when printed. Any other filter's result is escaped again.
37
+ def invoke(method, *args)
38
+ input = args.first
39
+ return super unless registers[:escape_output] && input.is_a?(ActiveSupport::SafeBuffer)
40
+ return super(method, input.to_str, *args.drop(1)) unless MARKUP_PRESERVING_FILTERS.include?(method.to_s)
41
+
42
+ escaped = args.drop(1).map { |arg| arg.is_a?(String) ? ERB::Util.html_escape(arg) : arg }
43
+ result = super(method, input, *escaped)
44
+ result.is_a?(String) ? result.html_safe : result
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,14 @@
1
+ module Spree
2
+ module Emails
3
+ # The finished parts of one email, ready for Action Mailer.
4
+ class RenderedEmail
5
+ attr_reader :subject, :html, :text
6
+
7
+ def initialize(subject:, html:, text:)
8
+ @subject = subject
9
+ @html = html
10
+ @text = text
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,125 @@
1
+ module Spree
2
+ module Emails
3
+ # Renders an email template into its subject, HTML and plain-text parts.
4
+ #
5
+ # Liquid fills in the data first, into the template and then the layout
6
+ # wrapping it; MJML then compiles the result into email-safe HTML. Every
7
+ # output is HTML-escaped unless a template marks it `| raw`, and a runaway
8
+ # template fails its one email rather than stalling the job queue.
9
+ class Renderer
10
+ LAYOUT = 'layouts/spree/base_mailer'.freeze
11
+
12
+ RESOURCE_LIMITS = {
13
+ render_length_limit: 2_000_000,
14
+ render_score_limit: 200_000,
15
+ assign_score_limit: 50_000
16
+ }.freeze
17
+
18
+ class << self
19
+ # The Liquid environment every email renders in: strict parsing and
20
+ # Spree's filters, isolated from any other Liquid the host app runs.
21
+ #
22
+ # @return [Liquid::Environment]
23
+ def environment
24
+ @environment ||= Liquid::Environment.build(error_mode: :strict) do |environment|
25
+ environment.register_filter(Spree::Emails::Filters)
26
+ # `include` takes a variable as the partial name, which would echo
27
+ # data back in its error message; `render` only takes a fixed name.
28
+ environment.tags.delete('include')
29
+ environment.register_tag('capture', Spree::Core::Emails::EscapedOutput::Capture)
30
+ environment.register_tag('cycle', Spree::Core::Emails::EscapedOutput::Cycle)
31
+ end
32
+ end
33
+ end
34
+
35
+ # @param resolver [Spree::Emails::TemplateResolver]
36
+ # @param store [Spree::Store]
37
+ # @param currency [String, nil] what `money` formats in, defaults to the store's
38
+ # @param strict [Boolean] whether an unknown variable raises; defaults to
39
+ # true in development and test, false in production
40
+ # @param branding [Hash] unsaved branding values over the store's, for previews
41
+ def initialize(resolver:, store:, currency: nil, strict: Rails.env.local?, branding: {})
42
+ @resolver = resolver
43
+ @store = store
44
+ @branding = branding
45
+ @currency = currency.presence || store.default_currency
46
+ @strict = strict
47
+ end
48
+
49
+ # @param template [Spree::Emails::Template] a Liquid template
50
+ # @param assigns [Hash] the template's variables
51
+ # @return [Spree::Emails::RenderedEmail]
52
+ def render(template, assigns = {})
53
+ assigns = variables(assigns)
54
+ subject = subject_for(template, assigns)
55
+ body = render_liquid(template.body, assigns)
56
+ html = render_layout(body, assigns.merge('subject' => subject))
57
+
58
+ Spree::Emails::RenderedEmail.new(subject: subject, html: html, text: render_text(template, assigns, html))
59
+ end
60
+
61
+ # Wraps HTML a mailer rendered some other way — its own ERB views — in
62
+ # the email layout, so it carries the store's logo, header and footer.
63
+ #
64
+ # @param html [String] the email's body, trusted as rendered
65
+ # @param subject [String, nil]
66
+ # @return [String] the finished email HTML
67
+ def wrap(html, subject: nil)
68
+ body = %(<mj-section><mj-column><mj-text align="left">#{html}</mj-text></mj-column></mj-section>)
69
+
70
+ render_layout(body, variables('subject' => subject.to_s))
71
+ end
72
+
73
+ # Everything a template receives: the given variables plus `store` and
74
+ # `locale`, with string keys.
75
+ #
76
+ # @param assigns [Hash]
77
+ # @return [Hash]
78
+ def variables(assigns = {})
79
+ base_assigns.merge(assigns.deep_stringify_keys)
80
+ end
81
+
82
+ private
83
+
84
+ def render_layout(body, assigns)
85
+ layout = @resolver.find(LAYOUT) || raise(ArgumentError, "Missing email layout #{LAYOUT}.liquid")
86
+
87
+ MRML.to_html(render_liquid(layout.body, assigns.merge('content_for_layout' => body.html_safe)))
88
+ end
89
+
90
+ # The subject is plain text for a mail header, so it is not HTML-escaped.
91
+ def subject_for(template, assigns)
92
+ render_liquid(template.subject.to_s, assigns, escape: false).squish
93
+ end
94
+
95
+ def render_text(template, assigns, html)
96
+ text_template = @resolver.find_text(template.key)
97
+ return Spree::Emails::TextConverter.call(html) unless text_template
98
+
99
+ render_liquid(text_template.body, assigns, escape: false)
100
+ end
101
+
102
+ def render_liquid(source, assigns, escape: true)
103
+ context = Spree::Emails::LiquidContext.build(
104
+ environment: self.class.environment,
105
+ environments: [assigns],
106
+ registers: { store: @store, currency: @currency, escape_output: escape,
107
+ file_system: Spree::Emails::FileSystem.new(@resolver) },
108
+ resource_limits: Liquid::ResourceLimits.new(RESOURCE_LIMITS),
109
+ rethrow_errors: true
110
+ )
111
+ context.strict_variables = @strict
112
+ context.strict_filters = true
113
+
114
+ Liquid::Template.parse(source, environment: self.class.environment, line_numbers: true).render!(context)
115
+ end
116
+
117
+ def base_assigns
118
+ @base_assigns ||= {
119
+ 'store' => JSON.parse(Spree::Emails::StoreSerializer.new(@store, params: { branding: @branding }).serialize),
120
+ 'locale' => I18n.locale.to_s
121
+ }
122
+ end
123
+ end
124
+ end
125
+ end
@@ -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