spree_cm_commissioner 2.13.4 → 2.13.5

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +5 -0
  3. data/Gemfile.lock +1 -1
  4. data/app/controllers/spree/admin/inventory_items_controller.rb +11 -0
  5. data/app/controllers/spree/admin/payment_fee_rules_controller.rb +76 -0
  6. data/app/controllers/spree/admin/service_fee_rules_controller.rb +1 -0
  7. data/app/controllers/spree/api/v2/storefront/cart_payment_fees_controller.rb +48 -0
  8. data/app/controllers/spree/api/v2/storefront/seat_layouts_controller.rb +3 -1
  9. data/app/controllers/spree/api/v2/tenant/cart_payment_fees_controller.rb +43 -0
  10. data/app/helpers/spree_cm_commissioner/admin/fee_rules_helper.rb +23 -0
  11. data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +2 -0
  12. data/app/models/concerns/spree_cm_commissioner/percent_fee_calculable.rb +36 -0
  13. data/app/models/concerns/spree_cm_commissioner/product_type.rb +5 -1
  14. data/app/models/concerns/spree_cm_commissioner/scoped_fee_rule.rb +129 -0
  15. data/app/models/spree_cm_commissioner/adjustment_decorator.rb +1 -0
  16. data/app/models/spree_cm_commissioner/calculators/payment_fee_flat_amount.rb +21 -0
  17. data/app/models/spree_cm_commissioner/calculators/payment_fee_percent.rb +17 -0
  18. data/app/models/spree_cm_commissioner/calculators/service_fee_flat_amount.rb +4 -0
  19. data/app/models/spree_cm_commissioner/calculators/service_fee_percent.rb +5 -2
  20. data/app/models/spree_cm_commissioner/inventory_item.rb +10 -5
  21. data/app/models/spree_cm_commissioner/order_decorator.rb +12 -1
  22. data/app/models/spree_cm_commissioner/order_updater_decorator.rb +6 -1
  23. data/app/models/spree_cm_commissioner/payment_fee_rule.rb +97 -0
  24. data/app/models/spree_cm_commissioner/payment_method_decorator.rb +5 -0
  25. data/app/models/spree_cm_commissioner/redis_stock/cached_inventory_items_builder.rb +4 -2
  26. data/app/models/spree_cm_commissioner/seat_layout.rb +7 -0
  27. data/app/models/spree_cm_commissioner/service_fee_rule.rb +17 -114
  28. data/app/models/spree_cm_commissioner/stock/availability_checker.rb +1 -1
  29. data/app/models/spree_cm_commissioner/stock/order_availability_checker.rb +1 -1
  30. data/app/overrides/spree/admin/shared/_product_tabs/stock_managements.html.erb.deface +1 -1
  31. data/app/overrides/spree/admin/shared/sub_menu/_configuration/menu_items.html.erb.deface +5 -0
  32. data/app/serializers/spree/v2/tenant/cart_serializer.rb +2 -1
  33. data/app/serializers/spree/v2/tenant/payment_method_serializer.rb +3 -0
  34. data/app/serializers/spree_cm_commissioner/api/v2/platform/homepage_section_relatable_options_serializer.rb +5 -0
  35. data/app/serializers/spree_cm_commissioner/v2/storefront/cart_serializer_decorator.rb +1 -0
  36. data/app/serializers/spree_cm_commissioner/v2/storefront/inventory_item_serializer.rb +1 -0
  37. data/app/serializers/spree_cm_commissioner/v2/storefront/payment_fee_preview_serializer.rb +19 -0
  38. data/app/serializers/spree_cm_commissioner/v2/storefront/payment_method_serializer_decorator.rb +16 -0
  39. data/app/serializers/spree_cm_commissioner/v2/storefront/seat_layout_serializer.rb +8 -0
  40. data/app/services/spree_cm_commissioner/checkout/add_store_credit_decorator.rb +17 -0
  41. data/app/services/spree_cm_commissioner/checkout/remove_store_credit_decorator.rb +17 -0
  42. data/app/services/spree_cm_commissioner/payment_fees/apply.rb +30 -0
  43. data/app/services/spree_cm_commissioner/payment_fees/clear.rb +15 -0
  44. data/app/services/spree_cm_commissioner/payment_fees/preview.rb +70 -0
  45. data/app/services/spree_cm_commissioner/payments/find_or_create_decorator.rb +18 -0
  46. data/app/views/spree/admin/payment_fee_rules/_form.html.erb +135 -0
  47. data/app/views/spree/admin/payment_fee_rules/edit.html.erb +11 -0
  48. data/app/views/spree/admin/payment_fee_rules/index.html.erb +100 -0
  49. data/app/views/spree/admin/payment_fee_rules/new.html.erb +11 -0
  50. data/app/views/spree/admin/service_fee_rules/_form.html.erb +15 -9
  51. data/app/views/spree/admin/service_fee_rules/index.html.erb +1 -1
  52. data/app/views/spree/admin/stock_managements/_header.html.erb +26 -0
  53. data/app/views/spree/admin/stock_managements/_variant_stock_items.html.erb +1 -1
  54. data/app/views/spree/admin/stock_managements/calendar.html.erb +285 -171
  55. data/app/views/spree/admin/stock_managements/index.html.erb +1 -18
  56. data/config/locales/en.yml +12 -1
  57. data/config/locales/km.yml +12 -1
  58. data/config/routes.rb +4 -0
  59. data/db/migrate/20260928000001_add_closed_to_cm_inventory_items.rb +5 -0
  60. data/db/migrate/20261001100000_create_cm_payment_fee_rules.rb +17 -0
  61. data/db/migrate/20261001100001_add_payment_fee_total_to_spree_orders.rb +5 -0
  62. data/lib/spree_cm_commissioner/cached_inventory_item.rb +7 -1
  63. data/lib/spree_cm_commissioner/payment_fees/payment_fee_preview.rb +24 -0
  64. data/lib/spree_cm_commissioner/version.rb +1 -1
  65. data/lib/spree_cm_commissioner.rb +1 -0
  66. metadata +27 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 565fc705e24c32c2f3bd35aad88607d80d7f26b1bab3ff62dc64e2890202ed9a
4
- data.tar.gz: b6647ca2838265f710f999389157765f24ba1baf92369521aab95a711779a0d9
3
+ metadata.gz: 9473bef71175dc42b69c1959ac56827e1ca0ef2b6c13205ed2c83793e678a21f
4
+ data.tar.gz: 314a9fbe0dbc6fe925f435b3eaeef9d737689f81fb7d95e631bbcf10a2e3e8bf
5
5
  SHA512:
6
- metadata.gz: 36a8d1f75f387fb33ab1d394ee91e61ce6c1179bbf2323352f1d56e3bbb303942d57af72933910e8017f184dcf0a24174a12f70ec4a69e42309cbec47fe9cdd7
7
- data.tar.gz: d7dc21e3383b9784c9b0cef12c354f4a3d4d3535fe122b666b8abdd3b4e4fea4660f321ada830e83fdb409ebd8d2d91ed9fa6295b1801371d803475334431860
6
+ metadata.gz: fa509b4c09684a80915bca9927228aa41de193821593d00f84da4cd1bb2f2b0d5034146d9c52e4dec654ad57092ac87273771be63841b66546cb1a1b63e0077d
7
+ data.tar.gz: f04d83d04ce10ebb051931a0bde85c690e279ac08d18e90b3be9e2f14721992c9e04096ef1eab051e1dde8b81ba6ea6aef356ca77422ac27ecb05aebf7f2e5c8
data/.env.example CHANGED
@@ -86,3 +86,8 @@ PAYMENT_INCOMPLETE_NOTIFICATION_DELAY_IN_MINUTES=5 # Delay before notifying a c
86
86
 
87
87
  # See: app/jobs/spree_cm_commissioner/telegram_alerts/order_integrity_check_job.rb
88
88
  ORDER_INTEGRITY_TELEGRAM_ALERT_ENABLED="no" # Set to yes to enable the order integrity Telegram alert job (off by default to avoid flooding Telegram rate limits during traffic spikes)
89
+
90
+ # See: app/models/concerns/spree_cm_commissioner/product_type.rb
91
+ PRE_INVENTORY_DAYS_TRANSIT=180 # How many days ahead inventory items are pre-generated for transit variants
92
+ PRE_INVENTORY_DAYS_ACCOMMODATION=365 # How many days ahead inventory items are pre-generated for accommodation variants
93
+ PRE_INVENTORY_DAYS_SERVICE=180 # How many days ahead inventory items are pre-generated for service variants
data/Gemfile.lock CHANGED
@@ -34,7 +34,7 @@ GIT
34
34
  PATH
35
35
  remote: .
36
36
  specs:
37
- spree_cm_commissioner (2.13.4)
37
+ spree_cm_commissioner (2.13.5)
38
38
  activerecord-multi-tenant
39
39
  activerecord_json_validator (~> 2.1, >= 2.1.3)
40
40
  aws-sdk-cloudfront
@@ -59,6 +59,17 @@ module Spree
59
59
  redirect_back fallback_location: admin_product_stock_managements_path(@product)
60
60
  end
61
61
 
62
+ # PATCH /products/:slug/inventory_items/bulk_update_closed
63
+ #
64
+ # Ids travel in the form body, not the query string: a range across a year can be hundreds of ids.
65
+ def bulk_update_closed
66
+ closed = ActiveModel::Type::Boolean.new.cast(params[:closed])
67
+ count = @product.inventory_items.where(id: params[:inventory_item_ids]).update_all(closed: closed, updated_at: Time.current) # rubocop:disable Rails/SkipsModelValidations
68
+
69
+ flash[:success] = "#{closed ? 'Closed' : 'Reopened'} #{count} inventory items for sale"
70
+ redirect_back fallback_location: admin_product_stock_managements_path(@product)
71
+ end
72
+
62
73
  # PATCH /products/:slug/inventory_items/bulk_reset?inventory_item_ids=1,2,3
63
74
  def bulk_reset
64
75
  inventory_item_ids = params[:inventory_item_ids]
@@ -0,0 +1,76 @@
1
+ module Spree
2
+ module Admin
3
+ # Same CRUD as ServiceFeeRulesController (scope picker, tenant dropdown, calculator partial),
4
+ # plus the payment method a rule applies to.
5
+ class PaymentFeeRulesController < Spree::Admin::ResourceController
6
+ helper SpreeCmCommissioner::Admin::FeeRulesHelper
7
+ helper_method :selected_tenant
8
+
9
+ before_action :load_data
10
+
11
+ def index
12
+ @search = model_class.ransack(params[:q])
13
+ @status = %w[active inactive].include?(params[:status]) ? params[:status] : 'all'
14
+
15
+ scope = @search.result.includes(:payment_method, :ruleable, :calculator, :tenant)
16
+ scope = scope.where(active: true) if @status == 'active'
17
+ scope = scope.where(active: false) if @status == 'inactive'
18
+ # Same tenant semantics as ServiceFeeRulesController#index.
19
+ scope = scope.where(tenant_id: selected_tenant&.id)
20
+
21
+ @payment_fee_rules = scope.order(ruleable_type: :asc, ruleable_id: :asc, payment_method_id: :asc, currency: :asc)
22
+ .page(params[:page])
23
+ .per(25)
24
+ end
25
+
26
+ # See ServiceFeeRulesController#build_resource: tenant_id comes from the URL only.
27
+ def build_resource
28
+ model_class.new(tenant_id: params[:tenant_id].presence)
29
+ end
30
+
31
+ private
32
+
33
+ def load_data
34
+ @calculators = model_class.available_calculator_types.sort_by(&:name)
35
+ @tenants ||= SpreeCmCommissioner::Tenant.order(:name)
36
+ @payment_methods = Spree::PaymentMethod.includes(:vendor).order(:name)
37
+ @currencies = (current_store.supported_currencies_list.map(&:iso_code) | [@object&.currency]).compact.sort
38
+ end
39
+
40
+ def selected_tenant
41
+ return @selected_tenant if defined?(@selected_tenant)
42
+
43
+ @selected_tenant = params[:tenant_id].presence && SpreeCmCommissioner::Tenant.find_by(id: params[:tenant_id])
44
+ end
45
+
46
+ def model_class
47
+ SpreeCmCommissioner::PaymentFeeRule
48
+ end
49
+
50
+ def object_name
51
+ 'spree_cm_commissioner_payment_fee_rule'
52
+ end
53
+
54
+ def collection_url
55
+ admin_payment_fee_rules_url
56
+ end
57
+
58
+ def location_after_save
59
+ edit_admin_payment_fee_rule_path(@object)
60
+ end
61
+
62
+ # tenant_id is deliberately excluded -- see build_resource.
63
+ def permitted_resource_params
64
+ params.require(:spree_cm_commissioner_payment_fee_rule).permit(
65
+ :payment_method_id,
66
+ :ruleable_type,
67
+ :ruleable_id,
68
+ :active,
69
+ :currency,
70
+ :calculator_type,
71
+ calculator_attributes: %i[id preferred_percent preferred_flat_amount]
72
+ )
73
+ end
74
+ end
75
+ end
76
+ end
@@ -1,6 +1,7 @@
1
1
  module Spree
2
2
  module Admin
3
3
  class ServiceFeeRulesController < Spree::Admin::ResourceController
4
+ helper SpreeCmCommissioner::Admin::FeeRulesHelper
4
5
  helper_method :selected_tenant
5
6
 
6
7
  before_action :load_data
@@ -0,0 +1,48 @@
1
+ module Spree
2
+ module Api
3
+ module V2
4
+ module Storefront
5
+ # GET /api/v2/storefront/cart_payment_fees?payment_method_ids[]=1&payment_method_ids[]=2
6
+ #
7
+ # Payment fee each requested payment method would add to the current cart. Order-scoped
8
+ # and uncached on purpose: the answer depends on line items and applied store credit, both
9
+ # of which change on the payment screen. The client skips methods whose catalog entry says
10
+ # `fee_configured: false`, so carts with no fees configured never hit this.
11
+ class CartPaymentFeesController < Spree::Api::V2::ResourceController
12
+ include OrderConcern
13
+
14
+ before_action :ensure_order
15
+
16
+ # override
17
+ def index
18
+ render_serialized_payload do
19
+ collection_serializer.new(collection).serializable_hash
20
+ end
21
+ end
22
+
23
+ # override
24
+ def collection
25
+ @collection ||= requested_payment_methods.map do |payment_method|
26
+ SpreeCmCommissioner::PaymentFees::Preview.call(order: spree_current_order, payment_method: payment_method)
27
+ .value[:payment_fee_preview]
28
+ end
29
+ end
30
+
31
+ # override
32
+ def collection_serializer
33
+ SpreeCmCommissioner::V2::Storefront::PaymentFeePreviewSerializer
34
+ end
35
+
36
+ private
37
+
38
+ # Ids the order can't actually pay with are dropped rather than rejected, so a stale client
39
+ # list (e.g. a method disabled since the catalog was cached) still gets the rest.
40
+ def requested_payment_methods
41
+ ids = Array(params[:payment_method_ids]).map(&:to_s)
42
+ spree_current_order.available_payment_methods.select { |pm| ids.include?(pm.id.to_s) }
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -10,6 +10,8 @@ module Spree
10
10
  class SeatLayoutsController < Spree::Api::V2::ResourceController
11
11
  # GET /api/v2/storefront/seat_layouts/:id
12
12
  # - include_details=true|false (optional, default: true)
13
+ #
14
+ # Always returns `product_ids` (see SeatLayout#product_ids).
13
15
  def show
14
16
  render_serialized_payload { serialize_resource(resource) }
15
17
  end
@@ -70,7 +72,7 @@ module Spree
70
72
 
71
73
  # override
72
74
  def serializer_params
73
- super.merge(include_details: include_details?)
75
+ super.merge(include_details: include_details?, include_product_ids: action_name == 'show')
74
76
  end
75
77
 
76
78
  # default return true
@@ -0,0 +1,43 @@
1
+ module Spree
2
+ module Api
3
+ module V2
4
+ module Tenant
5
+ # See Spree::Api::V2::Storefront::CartPaymentFeesController.
6
+ class CartPaymentFeesController < BaseController
7
+ include Spree::Api::V2::Storefront::OrderConcern
8
+
9
+ before_action :ensure_order
10
+
11
+ # override
12
+ def index
13
+ render_serialized_payload do
14
+ collection_serializer.new(collection).serializable_hash
15
+ end
16
+ end
17
+
18
+ # override
19
+ def collection
20
+ @collection ||= requested_payment_methods.map do |payment_method|
21
+ SpreeCmCommissioner::PaymentFees::Preview.call(order: spree_current_order, payment_method: payment_method)
22
+ .value[:payment_fee_preview]
23
+ end
24
+ end
25
+
26
+ # override
27
+ def collection_serializer
28
+ SpreeCmCommissioner::V2::Storefront::PaymentFeePreviewSerializer
29
+ end
30
+
31
+ private
32
+
33
+ # Ids the order can't actually pay with are dropped rather than rejected, so a stale client
34
+ # list (e.g. a method disabled since the catalog was cached) still gets the rest.
35
+ def requested_payment_methods
36
+ ids = Array(params[:payment_method_ids]).map(&:to_s)
37
+ spree_current_order.available_payment_methods.select { |pm| ids.include?(pm.id.to_s) }
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,23 @@
1
+ module SpreeCmCommissioner
2
+ module Admin
3
+ # Shared by the service fee and payment fee rule lists: the Scope column links to what the
4
+ # rule is scoped to (the rule itself is opened with the row's edit action).
5
+ module FeeRulesHelper
6
+ def fee_rule_scope_link(rule)
7
+ path = fee_rule_scope_path(rule)
8
+ path ? link_to(rule.scope_label, path) : rule.scope_label
9
+ end
10
+
11
+ private
12
+
13
+ def fee_rule_scope_path(rule)
14
+ case rule.ruleable
15
+ when Spree::Product then spree.edit_admin_product_path(rule.ruleable)
16
+ when Spree::Taxon then spree.edit_admin_taxonomy_taxon_path(rule.ruleable.taxonomy_id, rule.ruleable.id)
17
+ when Spree::Vendor then spree.edit_admin_vendor_path(rule.ruleable)
18
+ when nil then rule.tenant ? spree.edit_admin_tenant_path(rule.tenant) : nil
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -18,6 +18,8 @@ module SpreeCmCommissioner
18
18
  # line item changes between steps.
19
19
  state_machine.before_transition to: :payment, do: :create_service_fee_charge!
20
20
  state_machine.before_transition from: :address, do: :create_service_fee_charge!
21
+ state_machine.before_transition to: :payment, do: :clear_stale_payment_fee!
22
+ state_machine.before_transition from: :address, do: :clear_stale_payment_fee!
21
23
 
22
24
  # Fails fast, before any of the other before_transition/around_transition work below runs
23
25
  # (external-system unstock, Redis unstock, notifications, ...), when the inventory hold has
@@ -0,0 +1,36 @@
1
+ module SpreeCmCommissioner
2
+ # "10% + $0.25" fee shape shared by ServiceFeePercent and PaymentFeePercent. Lives outside
3
+ # SpreeCmCommissioner::Calculators on purpose: PricingAction.available_calculator_types lists
4
+ # every constant in that namespace, and a module there would show up as a pickable calculator
5
+ # type. The flat part is named like the *FlatAmount calculators' preference so admins see one
6
+ # term; each including calculator decides what it's multiplied by (per unit for service fees,
7
+ # once per payment for payment fees).
8
+ module PercentFeeCalculable
9
+ extend ActiveSupport::Concern
10
+
11
+ included do
12
+ preference :percent, :decimal, default: 0
13
+ preference :flat_amount, :decimal, default: 0
14
+ end
15
+
16
+ def fee_description(currency:)
17
+ percent = "#{preferred_percent.to_d.round(2).to_s('F')}%"
18
+ return percent unless preferred_flat_amount.to_d.positive?
19
+
20
+ flat = Spree::Money.new(preferred_flat_amount, currency: currency).to_s
21
+ return flat if preferred_percent.to_d.zero?
22
+
23
+ Spree.t(:fee_percent_plus_flat, percent: percent, flat: flat)
24
+ end
25
+
26
+ private
27
+
28
+ # Nothing to charge on a zero base (a free line item, or store credit covering the order),
29
+ # so the flat part isn't added on its own there either.
30
+ def percent_plus_flat(base, flat)
31
+ return 0 unless base.to_d.positive?
32
+
33
+ (base * preferred_percent / 100) + flat
34
+ end
35
+ end
36
+ end
@@ -7,7 +7,11 @@ module SpreeCmCommissioner
7
7
 
8
8
  PRODUCT_TYPES = %i[accommodation service ecommerce transit show_episode vote_package advertisement].freeze
9
9
  PERMANENT_STOCK_PRODUCT_TYPES = %w[accommodation service transit].freeze
10
- PRE_INVENTORY_DAYS = { 'transit' => 90, 'accommodation' => 365, 'service' => 30 }.freeze
10
+ PRE_INVENTORY_DAYS = {
11
+ 'transit' => ENV.fetch('PRE_INVENTORY_DAYS_TRANSIT', 180).to_i,
12
+ 'accommodation' => ENV.fetch('PRE_INVENTORY_DAYS_ACCOMMODATION', 365).to_i,
13
+ 'service' => ENV.fetch('PRE_INVENTORY_DAYS_SERVICE', 180).to_i
14
+ }.freeze
11
15
 
12
16
  included do
13
17
  enum product_type: PRODUCT_TYPES if table_exists? && column_names.include?('product_type')
@@ -0,0 +1,129 @@
1
+ module SpreeCmCommissioner
2
+ # Shared scoping for fee rules that resolve product -> event -> vendor -> tenant/global
3
+ # (ServiceFeeRule, PaymentFeeRule). Each including model keeps its own uniqueness
4
+ # validation, since what makes a rule unique differs (PaymentFeeRule adds payment_method_id).
5
+ module ScopedFeeRule
6
+ extend ActiveSupport::Concern
7
+
8
+ ALLOWED_RULEABLE_TYPES = %w[Spree::Product Spree::Taxon Spree::Vendor].freeze
9
+
10
+ included do
11
+ belongs_to :ruleable, polymorphic: true, optional: true
12
+
13
+ # Only meaningful on a ruleable-less row (see the tenant_id absence validation below): a
14
+ # tenant's own default, distinct from the true platform-wide default (tenant_id AND ruleable
15
+ # both nil). Not used on Product/Vendor/Event-scoped rows — their tenant is already implied by
16
+ # the ruleable itself (a vendor belongs to exactly one tenant), so storing it there too would
17
+ # just be a second copy that could drift out of sync.
18
+ multi_tenant :tenant, class_name: 'SpreeCmCommissioner::Tenant'
19
+
20
+ before_validation :normalize_ruleable, :normalize_currency
21
+
22
+ validates :currency, presence: true
23
+ validates :ruleable_type, inclusion: { in: ALLOWED_RULEABLE_TYPES }, allow_nil: true
24
+ validates :ruleable_id, presence: true, if: -> { ruleable_type.present? }
25
+ validates :tenant_id, absence: true, if: -> { ruleable_type.present? }
26
+
27
+ scope :active_rules, -> { where(active: true) }
28
+ scope :for_currency, -> (currency) { where(currency: currency.to_s.upcase) }
29
+
30
+ # Finds the active rule for the given ruleable (nil = global) and currency.
31
+ scope :active_for, lambda { |ruleable:, currency:|
32
+ active_rules
33
+ .for_currency(currency)
34
+ .where(ruleable_type: ruleable&.class&.base_class&.name, ruleable_id: ruleable&.id)
35
+ }
36
+
37
+ # Finds the ruleable-less rule for a specific tenant (nil tenant_id = true platform-wide).
38
+ scope :active_for_tenant, lambda { |tenant_id:, currency:|
39
+ active_rules
40
+ .for_currency(currency)
41
+ .where(ruleable_type: nil, ruleable_id: nil, tenant_id: tenant_id)
42
+ }
43
+ end
44
+
45
+ # "Name (slug)" for a product/event/vendor, so same-named records can be told apart in the
46
+ # admin scope-target picker. Events (taxons) show their permalink first: that's what the
47
+ # picker searches them by, and it reads clearer than the taxon's generated slug.
48
+ def self.ruleable_label(record)
49
+ name = record.respond_to?(:pretty_name) ? record.pretty_name : record.try(:name)
50
+ slug = record.try(:permalink).presence || record.try(:slug).presence
51
+ slug ? "#{name} (#{slug})" : name.to_s
52
+ end
53
+
54
+ class_methods do
55
+ # Walks product -> event -> vendor -> global within `relation`, returning the first active
56
+ # rule that matches the currency. The product/event/vendor leg mirrors
57
+ # SpreeCmCommissioner::TicketTransfers::FeeCalculator's precedence.
58
+ #
59
+ # "Global" means two different things depending on whether the vendor belongs to a tenant:
60
+ # - No tenant (a base BookMe+ vendor): the true platform-wide default (tenant_id nil).
61
+ # - Has a tenant: ONLY that tenant's own default (tenant_id: vendor.tenant_id) — deliberately
62
+ # does NOT fall back to the platform-wide default if the tenant hasn't set one. Each
63
+ # white-label tenant is expected to configure their own fee (or explicitly have none); a
64
+ # tenant silently inheriting BookMe+'s own fee because they forgot to configure one would be
65
+ # a surprising, not a sensible, default.
66
+ #
67
+ # Runs outside MultiTenant scoping: tenant API requests set a current tenant, which would
68
+ # filter every query to `tenant_id = <tenant>` and so skip product/event/vendor rules (they
69
+ # never store a tenant_id). Tenancy is handled explicitly above instead.
70
+ def resolve_scoped(line_item:, currency:, relation: all)
71
+ MultiTenant.without do
72
+ ruleables_for(line_item).each do |ruleable|
73
+ rule = relation.active_for(ruleable: ruleable, currency: currency).first
74
+ return rule if rule.present?
75
+ end
76
+
77
+ relation.active_for_tenant(tenant_id: vendor_for(line_item)&.tenant_id, currency: currency).first
78
+ end
79
+ end
80
+
81
+ # Compacted because active_for(ruleable: nil) means "ruleable-less", i.e. the global/tenant
82
+ # rows: a line item without an event would otherwise match a global rule before ever
83
+ # checking its vendor.
84
+ def ruleables_for(line_item)
85
+ [line_item.product, line_item.event, vendor_for(line_item)].compact
86
+ end
87
+
88
+ def vendor_for(line_item)
89
+ product = line_item.product
90
+ product.respond_to?(:vendor) ? product.vendor : nil
91
+ end
92
+ end
93
+
94
+ def scope_label
95
+ if ruleable.blank?
96
+ # A tenant-scoped row is that tenant's own "global" — it never falls back to the
97
+ # BookMe+-level one (see resolve_scoped), so it isn't really "global" in the broad sense;
98
+ # labeled as its own Tenant scope rather than reusing the word "Global" for both.
99
+ return Spree.t(:service_fee_rule_scope_tenant, name: tenant&.name || "##{tenant_id}") if tenant_id.present?
100
+
101
+ return Spree.t(:service_fee_rule_scope_global)
102
+ end
103
+
104
+ label_value = ruleable.respond_to?(:name) ? ruleable.name.to_s : "##{ruleable_id}"
105
+ "#{ruleable_type_label}: #{label_value}"
106
+ end
107
+
108
+ def ruleable_type_label
109
+ case ruleable_type
110
+ when 'Spree::Product' then Spree.t(:service_fee_rule_scope_product)
111
+ when 'Spree::Taxon' then Spree.t(:service_fee_rule_scope_event)
112
+ when 'Spree::Vendor' then Spree.t(:service_fee_rule_scope_vendor)
113
+ else ruleable_type&.demodulize || Spree.t(:service_fee_rule_scope_global)
114
+ end
115
+ end
116
+
117
+ private
118
+
119
+ # "Global" dropdown sends "" not nil. Convert to nil so validations and uniqueness checks work.
120
+ def normalize_ruleable
121
+ self.ruleable_type = nil if ruleable_type.blank?
122
+ self.ruleable_id = nil if ruleable_type.nil?
123
+ end
124
+
125
+ def normalize_currency
126
+ self.currency = currency.to_s.upcase.presence
127
+ end
128
+ end
129
+ end
@@ -4,6 +4,7 @@ module SpreeCmCommissioner
4
4
  base.belongs_to :payable, polymorphic: true, optional: true
5
5
  base.scope :pricing_action, -> { where(source_type: 'SpreeCmCommissioner::PricingAction') }
6
6
  base.scope :service_fee, -> { where(source_type: 'SpreeCmCommissioner::ServiceFeeRule') }
7
+ base.scope :payment_fee, -> { where(source_type: 'SpreeCmCommissioner::PaymentFeeRule') }
7
8
  base.whitelisted_ransackable_attributes |= %w[payable_id]
8
9
  end
9
10
 
@@ -0,0 +1,21 @@
1
+ module SpreeCmCommissioner
2
+ module Calculators
3
+ class PaymentFeeFlatAmount < Spree::Calculator
4
+ preference :flat_amount, :decimal, default: 0
5
+
6
+ def self.description
7
+ Spree.t(:payment_fee_flat_amount)
8
+ end
9
+
10
+ # Once per payment, not per unit: providers bill per transaction. Nothing to charge when
11
+ # store credit covers the whole order and no gateway payment happens.
12
+ def compute(base)
13
+ base.to_d.positive? ? preferred_flat_amount : 0
14
+ end
15
+
16
+ def fee_description(currency:)
17
+ Spree::Money.new(preferred_flat_amount, currency: currency).to_s
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,17 @@
1
+ module SpreeCmCommissioner
2
+ module Calculators
3
+ class PaymentFeePercent < Spree::Calculator
4
+ include SpreeCmCommissioner::PercentFeeCalculable
5
+
6
+ def self.description
7
+ Spree.t(:payment_fee_percent)
8
+ end
9
+
10
+ # `base` is the share of the gateway amount this rule covers (see PaymentFees::Preview),
11
+ # not a line item: providers bill per transaction, so the flat part applies once per payment.
12
+ def compute(base)
13
+ percent_plus_flat(base.to_d, preferred_flat_amount)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -12,6 +12,10 @@ module SpreeCmCommissioner
12
12
  def compute(line_item)
13
13
  preferred_flat_amount * line_item.quantity
14
14
  end
15
+
16
+ def fee_description(currency:)
17
+ Spree::Money.new(preferred_flat_amount, currency: currency).to_s
18
+ end
15
19
  end
16
20
  end
17
21
  end
@@ -1,7 +1,7 @@
1
1
  module SpreeCmCommissioner
2
2
  module Calculators
3
3
  class ServiceFeePercent < Spree::Calculator
4
- preference :percent, :decimal, default: 0
4
+ include SpreeCmCommissioner::PercentFeeCalculable
5
5
 
6
6
  def self.description
7
7
  Spree.t(:service_fee_percent)
@@ -12,8 +12,11 @@ module SpreeCmCommissioner
12
12
  # Spree::TaxRate.store_pre_tax_amount running for this line item, so it would make
13
13
  # service-fee computation order-dependent on tax having already run. discounted_amount
14
14
  # is a live computed value (amount + taxable_adjustment_total) with no such dependency.
15
+ #
16
+ # The flat part is per unit, same as ServiceFeeFlatAmount, so "10% + $0.25" adds $0.25 per
17
+ # ticket regardless of how tickets are grouped into lines.
15
18
  def compute(line_item)
16
- line_item.discounted_amount * preferred_percent / 100
19
+ percent_plus_flat(line_item.discounted_amount, preferred_flat_amount * line_item.quantity)
17
20
  end
18
21
  end
19
22
  end
@@ -109,6 +109,10 @@ module SpreeCmCommissioner
109
109
  # This method is only used when admin update stock
110
110
  def adjust_quantity!(quantity)
111
111
  with_lock do
112
+ # Seed a cold Redis key from the DB value before applying the delta: adjust_quantity_in_redis treats a
113
+ # missing key as 0, so it would otherwise cache only the delta (e.g. 1 for a row the DB holds at 3).
114
+ SpreeCmCommissioner::RedisStock::CachedInventoryItemsBuilder.new([self]).call
115
+
112
116
  # IMPORTANT: Apply quantity changes directly without defensive clamping.
113
117
  # The model validation will catch any attempts to go negative, surfacing bugs
114
118
  # in upstream logic rather than silently losing data.
@@ -170,12 +174,13 @@ module SpreeCmCommissioner
170
174
  inventory_date.nil? || inventory_date >= Time.zone.today
171
175
  end
172
176
 
173
- # 1 year expiry, whether the inventory item is permanent stock or not.
174
- #
175
- # Why even for permanent stock? Because if we expire it too soon (e.g. right after inventory_date),
176
- # admin usually still need to look up past/old inventory items or adjust stock when needed.
177
+ # Dated keys expire a week after their date, or a day from now for an old date touched later. Expiring
178
+ # is safe: CachedInventoryItemsBuilder re-warms any cold key from the DB, past dates included.
177
179
  def redis_expired_in
178
- 31_536_000
180
+ return 1.year.to_i if inventory_date.nil?
181
+
182
+ expires_at = [inventory_date.end_of_day + 7.days, 1.day.from_now].max
183
+ (expires_at - Time.zone.now).to_i
179
184
  end
180
185
  end
181
186
  end
@@ -21,7 +21,7 @@ module SpreeCmCommissioner
21
21
  base.store_private_metadata :preload_main_trip_ids, :array, default: []
22
22
  base.store_public_metadata :order_options, :array, default: []
23
23
 
24
- base.money_methods :service_fee_total
24
+ base.money_methods :service_fee_total, :payment_fee_total
25
25
 
26
26
  base.validates :promo_total, base::MONEY_VALIDATION
27
27
  base.validates :phone_number, presence: true, if: :require_phone_number
@@ -166,6 +166,17 @@ module SpreeCmCommissioner
166
166
  SpreeCmCommissioner::ServiceFeeRule.adjust(self, line_items)
167
167
  end
168
168
 
169
+ # A payment fee is only correct for the total it was computed on, so one left over from an
170
+ # earlier payment attempt is dropped whenever checkout steps re-run. Skipped once a gateway
171
+ # payment exists: its amount already includes the fee, and clearing it would make the order
172
+ # total smaller than what that payment charges.
173
+ def clear_stale_payment_fee!
174
+ return unless adjustments.payment_fee.exists?
175
+ return if payments.valid.any? { |payment| !payment.store_credit? }
176
+
177
+ SpreeCmCommissioner::PaymentFees::Clear.call(order: self)
178
+ end
179
+
169
180
  # overrided
170
181
  def payment_required?
171
182
  return false if need_confirmation?
@@ -12,10 +12,14 @@ module SpreeCmCommissioner
12
12
  def update_adjustment_total
13
13
  super
14
14
  order.service_fee_total = line_items.sum(:service_fee_total)
15
+
16
+ # Order-level adjustments (see PaymentFees::Apply) are already in adjustment_total via
17
+ # core's `adjustments.eligible.sum(:amount)`; this is the display-only breakdown.
18
+ order.payment_fee_total = adjustments.payment_fee.eligible.sum(:amount)
15
19
  end
16
20
 
17
21
  # Duplicates Spree::OrderUpdater#persist_totals' update_columns call (spree_core 4.5.3)
18
- # rather than calling super + a second update_columns for service_fee_total, to keep this
22
+ # rather than calling super + a second update_columns for the fee totals, to keep this
19
23
  # to a single UPDATE per order -- with read replicas/connection pooling in front of this,
20
24
  # a second round-trip per persist_totals call isn't free. Must be kept in sync if core ever
21
25
  # changes that column list.
@@ -33,6 +37,7 @@ module SpreeCmCommissioner
33
37
  promo_total: order.promo_total,
34
38
  total: order.total,
35
39
  service_fee_total: order.service_fee_total,
40
+ payment_fee_total: order.payment_fee_total,
36
41
  updated_at: Time.current
37
42
  )
38
43
  end