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
@@ -0,0 +1,97 @@
1
+ module SpreeCmCommissioner
2
+ class PaymentFeeRule < SpreeCmCommissioner::Base
3
+ include SpreeCmCommissioner::ScopedFeeRule
4
+ include Spree::AdjustmentSource
5
+ include Spree::CalculatedAdjustments
6
+
7
+ # Same reason as ServiceFeeRule: without class_name Rails looks for
8
+ # SpreeCmCommissioner::Adjustment.
9
+ has_many :adjustments, as: :source, class_name: 'Spree::Adjustment'
10
+
11
+ # optional: false spelled out because Spree turns off belongs_to_required_by_default; without
12
+ # it a blank admin form reaches the NOT NULL column and raises instead of showing a form error.
13
+ belongs_to :payment_method, class_name: 'Spree::PaymentMethod', optional: false
14
+
15
+ # One rule per (payment method, scope, currency), active or not: `active` is an on/off switch,
16
+ # not a way to keep old versions around. A duplicate is pointed at the existing rule instead
17
+ # (see #duplicate_rule and the admin form).
18
+ validates :currency,
19
+ uniqueness: {
20
+ scope: %i[payment_method_id ruleable_type ruleable_id tenant_id],
21
+ case_sensitive: false,
22
+ message: lambda { |object, _data|
23
+ I18n.t('payment_fee.rules.rule_already_exists_for_scope',
24
+ payment_method: object.payment_method&.name,
25
+ scope_type: object.ruleable_type_label,
26
+ currency: object.currency
27
+ )
28
+ }
29
+ }
30
+
31
+ scope :for_payment_method, -> (payment_method) { where(payment_method_id: payment_method) }
32
+
33
+ def duplicate_rule
34
+ self.class.where.not(id: id).find_by(
35
+ payment_method_id: payment_method_id, ruleable_type: ruleable_type, ruleable_id: ruleable_id,
36
+ tenant_id: tenant_id, currency: currency
37
+ )
38
+ end
39
+
40
+ # Caches on the payment method whether any active rule exists for it, at any scope, so the
41
+ # catalog can serialize fee_configured without a query. Coarser than "could reach this vendor"
42
+ # (a shared store method is flagged for every vendor once one vendor has a fee), but `false`
43
+ # still guarantees no fee, which is all the client relies on.
44
+ after_commit :sync_payment_method_fee_configured!
45
+
46
+ def self.resolve_for(line_item:, payment_method:, currency:)
47
+ resolve_scoped(line_item: line_item, currency: currency, relation: for_payment_method(payment_method))
48
+ end
49
+
50
+ def self.available_calculator_types
51
+ [SpreeCmCommissioner::Calculators::PaymentFeePercent, SpreeCmCommissioner::Calculators::PaymentFeeFlatAmount]
52
+ end
53
+
54
+ # Required by Spree::AdjustmentSource, but PaymentFees::Apply never goes through it: it stores
55
+ # the amounts PaymentFees::Preview computed, as closed adjustments, so this is only reached if
56
+ # something reopens one. Recomputing here would need the whole order's gateway split, so keep
57
+ # whatever was stored.
58
+ def compute_amount(adjustable)
59
+ adjustments.find_by(adjustable: adjustable)&.amount || 0
60
+ end
61
+
62
+ def fee_description
63
+ return Spree.t(:service_fee_rule_not_configured) if calculator.nil?
64
+ return Spree.t(:payment_fee_mode_none) if no_fee?
65
+
66
+ calculator.fee_description(currency: currency)
67
+ end
68
+
69
+ # An explicit zero rule: how a narrower scope opts out of a broader rule's fee.
70
+ def no_fee?
71
+ case calculator
72
+ when Calculators::PaymentFeePercent
73
+ calculator.preferred_percent.to_d.zero? && calculator.preferred_flat_amount.to_d.zero?
74
+ when Calculators::PaymentFeeFlatAmount
75
+ calculator.preferred_flat_amount.to_d.zero?
76
+ else
77
+ true
78
+ end
79
+ end
80
+
81
+ def label
82
+ Spree.t(:payment_fee)
83
+ end
84
+
85
+ private
86
+
87
+ # Also re-syncs the previous method when a rule moves to another one.
88
+ def sync_payment_method_fee_configured!
89
+ ids = [payment_method_id, payment_method_id_previously_was].compact.uniq
90
+
91
+ Spree::PaymentMethod.where(id: ids).find_each do |payment_method|
92
+ payment_method.preload_fee_configured = self.class.active_rules.exists?(payment_method_id: payment_method.id)
93
+ payment_method.save! if payment_method.changed?
94
+ end
95
+ end
96
+ end
97
+ end
@@ -7,6 +7,11 @@ module SpreeCmCommissioner
7
7
 
8
8
  base.whitelisted_ransackable_attributes = %w[name]
9
9
 
10
+ base.include SpreeCmCommissioner::StoreMetadata
11
+ # Kept in sync by PaymentFeeRule#sync_payment_method_fee_configured!, so the cached payment
12
+ # methods catalog can say whether to ask for fee previews without a rules query per request.
13
+ base.store_private_metadata :preload_fee_configured, :boolean, default: false
14
+
10
15
  # Expose a dedicated class helper so services can call Spree::PaymentMethod.ticket_transfer
11
16
  # instead of duplicating lookup/creation logic.
12
17
  base.define_singleton_method(:ticket_transfer) do
@@ -49,13 +49,15 @@ module SpreeCmCommissioner
49
49
  quantity_locked_on_hold: cache_inventory(locked_hold_keys[index], item, item.quantity_locked_on_hold, locked_hold[index]),
50
50
  max_capacity: item.max_capacity,
51
51
  inventory_item_id: item.id,
52
- variant_id: item.variant_id
52
+ variant_id: item.variant_id,
53
+ closed: item.closed
53
54
  )
54
55
  end
55
56
 
56
57
  def cache_inventory(key, inventory_item, db_value, count_in_redis)
58
+ # Past dates are warmed too: InventoryItem#adjust_quantity! relies on this to seed the key before
59
+ # applying its delta, and an admin can still edit a past date's stock.
57
60
  return count_in_redis.to_i if count_in_redis.present?
58
- return db_value unless inventory_item.active?
59
61
 
60
62
  # Use atomic SET NX to prevent race condition where multiple concurrent reads
61
63
  # initialize cache with stale values. Only the first thread wins.
@@ -60,6 +60,13 @@ module SpreeCmCommissioner
60
60
  seat_sections.where.not(shape_path: nil).exists? || blocks.where.not(shape_path: nil).exists?
61
61
  end
62
62
 
63
+ # Products owning the variants this layout's blocks sell. A block references only a variant,
64
+ # and an event's layouts mix variants from several products, so a client opening a sub-layout
65
+ # from a guide zone has no other way to know which products to load to make its seats selectable.
66
+ def product_ids
67
+ Spree::Variant.where(id: blocks.where.not(variant_id: nil).select(:variant_id)).distinct.order(:product_id).pluck(:product_id)
68
+ end
69
+
63
70
  private
64
71
 
65
72
  def sync_seat_layout_id_to_layoutable! # rubocop:disable Metrics/PerceivedComplexity
@@ -1,7 +1,6 @@
1
1
  module SpreeCmCommissioner
2
2
  class ServiceFeeRule < SpreeCmCommissioner::Base
3
- ALLOWED_RULEABLE_TYPES = %w[Spree::Product Spree::Taxon Spree::Vendor].freeze
4
-
3
+ include SpreeCmCommissioner::ScopedFeeRule
5
4
  include Spree::AdjustmentSource
6
5
  include Spree::CalculatedAdjustments
7
6
 
@@ -11,54 +10,26 @@ module SpreeCmCommissioner
11
10
  # default — same fix SpreeCmCommissioner::PricingAction already needed for the same reason.
12
11
  has_many :adjustments, as: :source, class_name: 'Spree::Adjustment'
13
12
 
14
- belongs_to :ruleable, polymorphic: true, optional: true
15
-
16
- # Only meaningful on a ruleable-less row (see the tenant_id absence validation below): a
17
- # tenant's own default, distinct from the true platform-wide default (tenant_id AND ruleable
18
- # both nil). Not used on Product/Vendor/Event-scoped rows — their tenant is already implied by
19
- # the ruleable itself (a vendor belongs to exactly one tenant), so storing it there too would
20
- # just be a second copy that could drift out of sync.
21
- multi_tenant :tenant, class_name: 'SpreeCmCommissioner::Tenant'
22
-
23
- before_validation :normalize_ruleable, :normalize_currency
24
-
25
- validates :currency, presence: true
26
- validates :ruleable_type, inclusion: { in: ALLOWED_RULEABLE_TYPES }, allow_nil: true
27
- validates :ruleable_id, presence: true, if: -> { ruleable_type.present? }
28
- validates :tenant_id, absence: true, if: -> { ruleable_type.present? }
29
-
30
- # Only one active rule per (ruleable, tenant, currency). The global rule is a
31
- # ruleable-less row, so ruleable_type / ruleable_id are both nil for it; tenant_id nil on
32
- # that same row means "true platform-wide," not "unscoped."
13
+ # One rule per (ruleable, tenant, currency), active or not: `active` is an on/off switch, not a
14
+ # way to keep old versions around, and a duplicate is pointed at the existing rule instead (see
15
+ # #duplicate_rule and the admin form). The global rule is a ruleable-less row, so ruleable_type
16
+ # / ruleable_id are both nil for it; tenant_id nil there means "true platform-wide".
33
17
  validates :currency,
34
18
  uniqueness: {
35
- scope: %i[ruleable_type ruleable_id tenant_id active],
36
- conditions: -> { where(active: true) },
19
+ scope: %i[ruleable_type ruleable_id tenant_id],
37
20
  case_sensitive: false,
38
21
  message: lambda { |object, _data|
39
22
  I18n.t('service_fee.rules.rule_already_exists_for_scope',
40
23
  scope_type: object.ruleable_type_label, currency: object.currency
41
24
  )
42
25
  }
43
- },
44
- if: :active?
45
-
46
- scope :active_rules, -> { where(active: true) }
47
- scope :for_currency, -> (currency) { where(currency: currency.to_s.upcase) }
48
-
49
- # Finds the active rule for the given ruleable (nil = global) and currency.
50
- scope :active_for, lambda { |ruleable:, currency:|
51
- active_rules
52
- .for_currency(currency)
53
- .where(ruleable_type: ruleable&.class&.base_class&.name, ruleable_id: ruleable&.id)
54
- }
26
+ }
55
27
 
56
- # Finds the ruleable-less rule for a specific tenant (nil tenant_id = true platform-wide).
57
- scope :active_for_tenant, lambda { |tenant_id:, currency:|
58
- active_rules
59
- .for_currency(currency)
60
- .where(ruleable_type: nil, ruleable_id: nil, tenant_id: tenant_id)
61
- }
28
+ def duplicate_rule
29
+ self.class.where.not(id: id).find_by(
30
+ ruleable_type: ruleable_type, ruleable_id: ruleable_id, tenant_id: tenant_id, currency: currency
31
+ )
32
+ end
62
33
 
63
34
  # Deletes all service-fee adjustments on these line items, then recreates them from
64
35
  # whichever rule currently resolves for each one. Mirrors Spree::TaxRate.adjust: a plain
@@ -81,33 +52,8 @@ module SpreeCmCommissioner
81
52
  create_adjustment(order, line_item)
82
53
  end
83
54
 
84
- # Walks product -> event -> vendor -> global, returning the first active rule that matches the
85
- # currency. The product/event/vendor leg mirrors
86
- # SpreeCmCommissioner::TicketTransfers::FeeCalculator's precedence.
87
- #
88
- # "Global" means two different things depending on whether the vendor belongs to a tenant:
89
- # - No tenant (a base BookMe+ vendor): the true platform-wide default (tenant_id nil).
90
- # - Has a tenant: ONLY that tenant's own default (tenant_id: vendor.tenant_id) — deliberately
91
- # does NOT fall back to the platform-wide default if the tenant hasn't set one. Each
92
- # white-label tenant is expected to configure their own fee (or explicitly have none); a
93
- # tenant silently inheriting BookMe+'s own fee because they forgot to configure one would be
94
- # a surprising, not a sensible, default.
95
55
  def self.resolve_for(line_item:, currency:)
96
- ruleables_for(line_item).each do |ruleable|
97
- rule = active_for(ruleable: ruleable, currency: currency).first
98
- return rule if rule.present?
99
- end
100
-
101
- active_for_tenant(tenant_id: vendor_for(line_item)&.tenant_id, currency: currency).first
102
- end
103
-
104
- def self.ruleables_for(line_item)
105
- [line_item.product, line_item.event, vendor_for(line_item)]
106
- end
107
-
108
- def self.vendor_for(line_item)
109
- product = line_item.product
110
- product.respond_to?(:vendor) ? product.vendor : nil
56
+ resolve_scoped(line_item: line_item, currency: currency)
111
57
  end
112
58
 
113
59
  # Spree calculators only expose a fixed, hardcoded set of calculable model types
@@ -123,43 +69,12 @@ module SpreeCmCommissioner
123
69
  calculator&.compute(line_item) || 0
124
70
  end
125
71
 
126
- # A short, human-readable summary for admin listings, e.g. "10%" or "$0.25" — so a rule's
127
- # actual fee doesn't require opening it to see. Two calculator types (not one combined
128
- # percent+flat calculator) means there's always exactly one number to show here.
72
+ # A short, human-readable summary for admin listings, e.g. "10% (min $0.25)" or "$0.25" — so
73
+ # a rule's actual fee doesn't require opening it to see.
129
74
  def fee_description
130
- case calculator
131
- when SpreeCmCommissioner::Calculators::ServiceFeePercent
132
- "#{calculator.preferred_percent.to_d.round(2).to_s('F')}%"
133
- when SpreeCmCommissioner::Calculators::ServiceFeeFlatAmount
134
- Spree::Money.new(calculator.preferred_flat_amount, currency: currency).to_s
135
- when nil
136
- Spree.t(:service_fee_rule_not_configured)
137
- else
138
- calculator.description
139
- end
140
- end
75
+ return Spree.t(:service_fee_rule_not_configured) if calculator.nil?
141
76
 
142
- def scope_label
143
- if ruleable.blank?
144
- # A tenant-scoped row is that tenant's own "global" — it never falls back to the
145
- # BookMe+-level one (see resolve_for), so it isn't really "global" in the broad sense;
146
- # labeled as its own Tenant scope rather than reusing the word "Global" for both.
147
- return Spree.t(:service_fee_rule_scope_tenant, name: tenant&.name || "##{tenant_id}") if tenant_id.present?
148
-
149
- return Spree.t(:service_fee_rule_scope_global)
150
- end
151
-
152
- label_value = ruleable.respond_to?(:name) ? ruleable.name.to_s : "##{ruleable_id}"
153
- "#{ruleable_type_label}: #{label_value}"
154
- end
155
-
156
- def ruleable_type_label
157
- case ruleable_type
158
- when 'Spree::Product' then Spree.t(:service_fee_rule_scope_product)
159
- when 'Spree::Taxon' then Spree.t(:service_fee_rule_scope_event)
160
- when 'Spree::Vendor' then Spree.t(:service_fee_rule_scope_vendor)
161
- else ruleable_type&.demodulize || Spree.t(:service_fee_rule_scope_global)
162
- end
77
+ calculator.respond_to?(:fee_description) ? calculator.fee_description(currency: currency) : calculator.description
163
78
  end
164
79
 
165
80
  protected
@@ -167,17 +82,5 @@ module SpreeCmCommissioner
167
82
  def label
168
83
  'Service Fee'
169
84
  end
170
-
171
- private
172
-
173
- # "Global" dropdown sends "" not nil. Convert to nil so validations and uniqueness checks work.
174
- def normalize_ruleable
175
- self.ruleable_type = nil if ruleable_type.blank?
176
- self.ruleable_id = nil if ruleable_type.nil?
177
- end
178
-
179
- def normalize_currency
180
- self.currency = currency.to_s.upcase.presence
181
- end
182
85
  end
183
86
  end
@@ -23,7 +23,7 @@ module SpreeCmCommissioner
23
23
  return true if variant.need_confirmation?
24
24
  return false if cached_inventory_items.empty?
25
25
 
26
- cached_inventory_items.all? { |item| item.active? && quantity_in_pool(item, include_locked: include_locked) >= quantity }
26
+ cached_inventory_items.all? { |item| item.sellable? && quantity_in_pool(item, include_locked: include_locked) >= quantity }
27
27
  end
28
28
 
29
29
  # The cache row carries both pools, so choosing between them is a read, not another Redis trip.
@@ -45,7 +45,7 @@ module SpreeCmCommissioner
45
45
  # stock, i.e. read sold out exactly when its allotment was still sellable.
46
46
  cached_inventory_items.all? do |item|
47
47
  quantity = line_item.locked_stock? ? item.quantity_locked : item.quantity_available
48
- item.active? && quantity >= line_item.quantity
48
+ item.sellable? && quantity >= line_item.quantity
49
49
  end
50
50
  end
51
51
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  <%= link_to_with_icon 'box-seam.svg',
4
4
  Spree.t(:stock_managements),
5
- admin_product_stock_managements_url(@product),
5
+ @product.permanent_stock? ? calendar_admin_product_stock_managements_url(@product) : admin_product_stock_managements_url(@product),
6
6
  class: "nav-link #{'active' if current == :stock_managements}" %>
@@ -8,3 +8,8 @@
8
8
  spree.admin_service_fee_rules_path(status: 'active'),
9
9
  { is_selected: controller.controller_name == 'service_fee_rules' }
10
10
  ) if can? :manage, SpreeCmCommissioner::ServiceFeeRule %>
11
+ <%= configurations_sidebar_menu_item(
12
+ Spree.t(:payment_fees),
13
+ spree.admin_payment_fee_rules_path(status: 'active'),
14
+ { is_selected: controller.controller_name == 'payment_fee_rules' }
15
+ ) if can? :manage, SpreeCmCommissioner::PaymentFeeRule %>
@@ -14,7 +14,8 @@ module Spree
14
14
  :pre_tax_item_amount, :display_pre_tax_item_amount, :pre_tax_total, :display_pre_tax_total,
15
15
  :shipment_state, :payment_state, :public_metadata,
16
16
  :phone_number, :intel_phone_number, :country_code, :request_state, :channel,
17
- :service_fee_total, :display_service_fee_total
17
+ :service_fee_total, :display_service_fee_total,
18
+ :payment_fee_total, :display_payment_fee_total
18
19
 
19
20
  belongs_to :user, serializer: Spree::V2::Tenant::UserSerializer
20
21
  belongs_to :billing_address,
@@ -19,6 +19,9 @@ module Spree
19
19
 
20
20
  pref.blank? || pref[:payment_option].blank? ? payment_method.method_type : pref[:payment_option]
21
21
  end
22
+
23
+ # Precomputed on fee rule save (PaymentFeeRule#sync_payment_method_fee_configured!).
24
+ attribute :fee_configured, &:preload_fee_configured?
22
25
  end
23
26
  end
24
27
  end
@@ -3,6 +3,11 @@ module SpreeCmCommissioner
3
3
  module V2
4
4
  module Platform
5
5
  class HomepageSectionRelatableOptionsSerializer < ::Spree::Api::V2::Platform::BaseSerializer
6
+ # Used by the fee rule forms' scope-target picker; other callers keep using `name`.
7
+ attribute :name_with_slug do |object|
8
+ SpreeCmCommissioner::ScopedFeeRule.ruleable_label(object)
9
+ end
10
+
6
11
  attribute :name do |object|
7
12
  if object.respond_to?(:pretty_name)
8
13
  object.pretty_name
@@ -11,6 +11,7 @@ module SpreeCmCommissioner
11
11
  :term_accepted_at,
12
12
  :channel, :hold_expires_at,
13
13
  :service_fee_total, :display_service_fee_total,
14
+ :payment_fee_total, :display_payment_fee_total,
14
15
  :order_total_after_store_credit, :display_order_total_after_store_credit
15
16
 
16
17
  base.attribute :has_incomplete_guest_info, &:has_incomplete_guest_info?
@@ -8,6 +8,7 @@ module SpreeCmCommissioner
8
8
 
9
9
  attributes :variant_id,
10
10
  :inventory_date,
11
+ :closed,
11
12
  :public_quantity_available
12
13
 
13
14
  attribute :currency do |_item, params|
@@ -0,0 +1,19 @@
1
+ module SpreeCmCommissioner
2
+ module V2
3
+ module Storefront
4
+ class PaymentFeePreviewSerializer < BaseSerializer
5
+ set_type :payment_fee_preview
6
+
7
+ attributes :payment_method_id, :currency, :amount, :order_total_after_store_credit, :fee_description
8
+
9
+ attribute :display_amount do |preview|
10
+ preview.display_amount.to_s
11
+ end
12
+
13
+ attribute :display_order_total_after_store_credit do |preview|
14
+ preview.display_order_total_after_store_credit.to_s
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,16 @@
1
+ module SpreeCmCommissioner
2
+ module V2
3
+ module Storefront
4
+ module PaymentMethodSerializerDecorator
5
+ def self.prepended(base)
6
+ # Precomputed on fee rule save (PaymentFeeRule#sync_payment_method_fee_configured!).
7
+ base.attribute :fee_configured, &:preload_fee_configured?
8
+ end
9
+ end
10
+ end
11
+ end
12
+ end
13
+
14
+ unless Spree::V2::Storefront::PaymentMethodSerializer.included_modules.include?(SpreeCmCommissioner::V2::Storefront::PaymentMethodSerializerDecorator)
15
+ Spree::V2::Storefront::PaymentMethodSerializer.prepend(SpreeCmCommissioner::V2::Storefront::PaymentMethodSerializerDecorator)
16
+ end
@@ -4,11 +4,19 @@ module SpreeCmCommissioner
4
4
  class SeatLayoutSerializer < BaseSerializer
5
5
  attributes :name, :status, :width, :height, :guide, :background_svg_url, :background_color
6
6
 
7
+ # Opt-in: TripSerializer embeds this serializer per trip, where an extra query per layout
8
+ # would N+1 a trip list that never needs it.
9
+ attribute :product_ids, if: proc { |_, params| params && params[:include_product_ids] == true }
10
+
7
11
  has_many :seat_sections, serializer: SpreeCmCommissioner::V2::Storefront::SeatSectionSerializer,
8
12
  if: proc { |_, params| params && params[:include_details] == true }
9
13
 
10
14
  has_many :top_level_blocks, serializer: SpreeCmCommissioner::V2::Storefront::BlockSerializer,
11
15
  if: proc { |_, params| params && params[:include_details] == true }
16
+
17
+ # The record cache is keyed by record only, not params, so a cached entry would replay
18
+ # whichever include_details/include_product_ids variant was serialized first.
19
+ cache_options store: nil
12
20
  end
13
21
  end
14
22
  end
@@ -0,0 +1,17 @@
1
+ module SpreeCmCommissioner
2
+ module Checkout
3
+ module AddStoreCreditDecorator
4
+ # override: drops any payment fee from an earlier attempt first, otherwise store credit
5
+ # would be sized against a total that still includes it (outstanding_balance).
6
+ def call(order:, amount: nil)
7
+ SpreeCmCommissioner::PaymentFees::Clear.call(order: order) if order.present? && !order.completed?
8
+
9
+ super
10
+ end
11
+ end
12
+ end
13
+ end
14
+
15
+ unless Spree::Checkout::AddStoreCredit.included_modules.include?(SpreeCmCommissioner::Checkout::AddStoreCreditDecorator)
16
+ Spree::Checkout::AddStoreCredit.prepend(SpreeCmCommissioner::Checkout::AddStoreCreditDecorator)
17
+ end
@@ -0,0 +1,17 @@
1
+ module SpreeCmCommissioner
2
+ module Checkout
3
+ module RemoveStoreCreditDecorator
4
+ # override: the gateway amount the fee was computed on changes with store credit, so the
5
+ # next FindOrCreate recomputes it from scratch instead of the order carrying a stale one.
6
+ def call(order:)
7
+ SpreeCmCommissioner::PaymentFees::Clear.call(order: order) if order.present? && !order.completed?
8
+
9
+ super
10
+ end
11
+ end
12
+ end
13
+ end
14
+
15
+ unless Spree::Checkout::RemoveStoreCredit.included_modules.include?(SpreeCmCommissioner::Checkout::RemoveStoreCreditDecorator)
16
+ Spree::Checkout::RemoveStoreCredit.prepend(SpreeCmCommissioner::Checkout::RemoveStoreCreditDecorator)
17
+ end
@@ -0,0 +1,30 @@
1
+ module SpreeCmCommissioner
2
+ module PaymentFees
3
+ # Replaces the order's payment fee with what PaymentFees::Preview computes for
4
+ # `payment_method`, storing those exact amounts (same pattern as PricingModels::Activate):
5
+ # never recomputed through PaymentFeeRule#compute_amount, and closed so a later
6
+ # Adjustment#update! can't change them. Caller is expected to hold the transaction.
7
+ class Apply
8
+ prepend ::Spree::ServiceModule::Base
9
+
10
+ def call(order:, payment_method:)
11
+ order.adjustments.payment_fee.delete_all
12
+
13
+ preview = Preview.call(order: order, payment_method: payment_method).value[:payment_fee_preview]
14
+ preview.adjustments.each do |adjustment|
15
+ order.adjustments.create!(
16
+ amount: adjustment.amount,
17
+ source: adjustment.rule,
18
+ order: order,
19
+ label: adjustment.rule.label,
20
+ mandatory: true,
21
+ state: 'closed'
22
+ )
23
+ end
24
+
25
+ order.update_with_updater!
26
+ success(order: order, payment_fee_preview: preview)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,15 @@
1
+ module SpreeCmCommissioner
2
+ module PaymentFees
3
+ class Clear
4
+ prepend ::Spree::ServiceModule::Base
5
+
6
+ def call(order:)
7
+ return success(order: order) unless order.adjustments.payment_fee.exists?
8
+
9
+ order.adjustments.payment_fee.delete_all
10
+ order.update_with_updater!
11
+ success(order: order)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,70 @@
1
+ module SpreeCmCommissioner
2
+ module PaymentFees
3
+ # Computes, without persisting, the payment fee `payment_method` would add to `order`.
4
+ #
5
+ # The base is the gateway amount only (order total minus store credit, minus any payment fee
6
+ # already applied from an earlier attempt), since that's what the provider bills on. Line items
7
+ # resolve their own rule (product -> event -> vendor -> tenant/global); when they resolve to
8
+ # different rules, the gateway amount is split between rules by each group's share of line-item
9
+ # totals, and the leftover rounding cent goes to the last group so the bases always add up.
10
+ class Preview
11
+ prepend ::Spree::ServiceModule::Base
12
+
13
+ def call(order:, payment_method:)
14
+ gateway_amount = gateway_amount_for(order)
15
+ adjustments = build_adjustments(order, payment_method, gateway_amount)
16
+ amount = adjustments.sum(BigDecimal(0), &:amount)
17
+
18
+ success(
19
+ payment_fee_preview: PaymentFeePreview.new(
20
+ payment_method_id: payment_method.id,
21
+ currency: order.currency,
22
+ amount: amount,
23
+ order_total_after_store_credit: gateway_amount + amount,
24
+ fee_description: adjustments.one? ? adjustments.first.rule.fee_description : nil,
25
+ adjustments: adjustments
26
+ )
27
+ )
28
+ end
29
+
30
+ private
31
+
32
+ def gateway_amount_for(order)
33
+ [order.order_total_after_store_credit - order.payment_fee_total, 0].max
34
+ end
35
+
36
+ def build_adjustments(order, payment_method, gateway_amount)
37
+ return [] unless gateway_amount.positive?
38
+
39
+ groups = order.line_items.group_by do |line_item|
40
+ PaymentFeeRule.resolve_for(line_item: line_item, payment_method: payment_method, currency: order.currency)
41
+ end
42
+ groups.delete(nil)
43
+ return [] if groups.empty?
44
+
45
+ bases = split(gateway_amount, groups, order)
46
+ groups.keys.zip(bases).filter_map do |rule, base|
47
+ amount = round(rule.calculator&.compute(base) || 0, order)
48
+ PaymentFeePreview::Adjustment.new(rule: rule, amount: amount) if amount.positive?
49
+ end
50
+ end
51
+
52
+ # Items with no rule still pay their share of the gateway amount, just without a fee, so
53
+ # weights are taken over every line item, not only the ruled ones.
54
+ def split(gateway_amount, groups, order)
55
+ all_items_total = order.line_items.sum(&:total)
56
+ return [gateway_amount] + Array.new(groups.size - 1, 0) unless all_items_total.positive?
57
+
58
+ bases = groups.values.map { |items| round(gateway_amount * items.sum(&:total) / all_items_total, order) }
59
+ return bases if groups.size < order.line_items.size
60
+
61
+ bases[-1] += gateway_amount - bases.sum
62
+ bases
63
+ end
64
+
65
+ def round(amount, order)
66
+ amount.to_d.round(::Money::Currency.new(order.currency).exponent)
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,18 @@
1
+ module SpreeCmCommissioner
2
+ module Payments
3
+ module FindOrCreateDecorator
4
+ # override: applies the selected method's payment fee before the payment is built, so
5
+ # `amount: order.order_total_after_store_credit` already includes it. Runs inside
6
+ # FindOrCreate's transaction, so a failed payment save rolls the fee back with it.
7
+ def find_or_create_payment(order:, params:, payment_method:)
8
+ SpreeCmCommissioner::PaymentFees::Apply.call(order: order, payment_method: payment_method)
9
+
10
+ super
11
+ end
12
+ end
13
+ end
14
+ end
15
+
16
+ unless Vpago::Payments::FindOrCreate.included_modules.include?(SpreeCmCommissioner::Payments::FindOrCreateDecorator)
17
+ Vpago::Payments::FindOrCreate.prepend(SpreeCmCommissioner::Payments::FindOrCreateDecorator)
18
+ end