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.
- checksums.yaml +4 -4
- data/.env.example +5 -0
- data/Gemfile.lock +1 -1
- data/app/controllers/spree/admin/inventory_items_controller.rb +11 -0
- data/app/controllers/spree/admin/payment_fee_rules_controller.rb +76 -0
- data/app/controllers/spree/admin/service_fee_rules_controller.rb +1 -0
- data/app/controllers/spree/api/v2/storefront/cart_payment_fees_controller.rb +48 -0
- data/app/controllers/spree/api/v2/storefront/seat_layouts_controller.rb +3 -1
- data/app/controllers/spree/api/v2/tenant/cart_payment_fees_controller.rb +43 -0
- data/app/helpers/spree_cm_commissioner/admin/fee_rules_helper.rb +23 -0
- data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +2 -0
- data/app/models/concerns/spree_cm_commissioner/percent_fee_calculable.rb +36 -0
- data/app/models/concerns/spree_cm_commissioner/product_type.rb +5 -1
- data/app/models/concerns/spree_cm_commissioner/scoped_fee_rule.rb +129 -0
- data/app/models/spree_cm_commissioner/adjustment_decorator.rb +1 -0
- data/app/models/spree_cm_commissioner/calculators/payment_fee_flat_amount.rb +21 -0
- data/app/models/spree_cm_commissioner/calculators/payment_fee_percent.rb +17 -0
- data/app/models/spree_cm_commissioner/calculators/service_fee_flat_amount.rb +4 -0
- data/app/models/spree_cm_commissioner/calculators/service_fee_percent.rb +5 -2
- data/app/models/spree_cm_commissioner/inventory_item.rb +10 -5
- data/app/models/spree_cm_commissioner/order_decorator.rb +12 -1
- data/app/models/spree_cm_commissioner/order_updater_decorator.rb +6 -1
- data/app/models/spree_cm_commissioner/payment_fee_rule.rb +97 -0
- data/app/models/spree_cm_commissioner/payment_method_decorator.rb +5 -0
- data/app/models/spree_cm_commissioner/redis_stock/cached_inventory_items_builder.rb +4 -2
- data/app/models/spree_cm_commissioner/seat_layout.rb +7 -0
- data/app/models/spree_cm_commissioner/service_fee_rule.rb +17 -114
- data/app/models/spree_cm_commissioner/stock/availability_checker.rb +1 -1
- data/app/models/spree_cm_commissioner/stock/order_availability_checker.rb +1 -1
- data/app/overrides/spree/admin/shared/_product_tabs/stock_managements.html.erb.deface +1 -1
- data/app/overrides/spree/admin/shared/sub_menu/_configuration/menu_items.html.erb.deface +5 -0
- data/app/serializers/spree/v2/tenant/cart_serializer.rb +2 -1
- data/app/serializers/spree/v2/tenant/payment_method_serializer.rb +3 -0
- data/app/serializers/spree_cm_commissioner/api/v2/platform/homepage_section_relatable_options_serializer.rb +5 -0
- data/app/serializers/spree_cm_commissioner/v2/storefront/cart_serializer_decorator.rb +1 -0
- data/app/serializers/spree_cm_commissioner/v2/storefront/inventory_item_serializer.rb +1 -0
- data/app/serializers/spree_cm_commissioner/v2/storefront/payment_fee_preview_serializer.rb +19 -0
- data/app/serializers/spree_cm_commissioner/v2/storefront/payment_method_serializer_decorator.rb +16 -0
- data/app/serializers/spree_cm_commissioner/v2/storefront/seat_layout_serializer.rb +8 -0
- data/app/services/spree_cm_commissioner/checkout/add_store_credit_decorator.rb +17 -0
- data/app/services/spree_cm_commissioner/checkout/remove_store_credit_decorator.rb +17 -0
- data/app/services/spree_cm_commissioner/payment_fees/apply.rb +30 -0
- data/app/services/spree_cm_commissioner/payment_fees/clear.rb +15 -0
- data/app/services/spree_cm_commissioner/payment_fees/preview.rb +70 -0
- data/app/services/spree_cm_commissioner/payments/find_or_create_decorator.rb +18 -0
- data/app/views/spree/admin/payment_fee_rules/_form.html.erb +135 -0
- data/app/views/spree/admin/payment_fee_rules/edit.html.erb +11 -0
- data/app/views/spree/admin/payment_fee_rules/index.html.erb +100 -0
- data/app/views/spree/admin/payment_fee_rules/new.html.erb +11 -0
- data/app/views/spree/admin/service_fee_rules/_form.html.erb +15 -9
- data/app/views/spree/admin/service_fee_rules/index.html.erb +1 -1
- data/app/views/spree/admin/stock_managements/_header.html.erb +26 -0
- data/app/views/spree/admin/stock_managements/_variant_stock_items.html.erb +1 -1
- data/app/views/spree/admin/stock_managements/calendar.html.erb +285 -171
- data/app/views/spree/admin/stock_managements/index.html.erb +1 -18
- data/config/locales/en.yml +12 -1
- data/config/locales/km.yml +12 -1
- data/config/routes.rb +4 -0
- data/db/migrate/20260928000001_add_closed_to_cm_inventory_items.rb +5 -0
- data/db/migrate/20261001100000_create_cm_payment_fee_rules.rb +17 -0
- data/db/migrate/20261001100001_add_payment_fee_total_to_spree_orders.rb +5 -0
- data/lib/spree_cm_commissioner/cached_inventory_item.rb +7 -1
- data/lib/spree_cm_commissioner/payment_fees/payment_fee_preview.rb +24 -0
- data/lib/spree_cm_commissioner/version.rb +1 -1
- data/lib/spree_cm_commissioner.rb +1 -0
- 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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
#
|
|
17
|
-
#
|
|
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
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
|
127
|
-
# actual fee doesn't require opening it to see.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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?
|
|
@@ -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
|
data/app/serializers/spree_cm_commissioner/v2/storefront/payment_method_serializer_decorator.rb
ADDED
|
@@ -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
|