spree_cm_commissioner 2.9.7 → 2.9.9

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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/app/controllers/concerns/spree_cm_commissioner/content_cachable.rb +1 -0
  4. data/app/controllers/concerns/spree_cm_commissioner/order_concern.rb +5 -0
  5. data/app/controllers/spree/api/v2/storefront/draft_orders_controller.rb +2 -1
  6. data/app/controllers/spree/api/v2/storefront/payment_methods_controller.rb +40 -0
  7. data/app/controllers/spree/api/v2/tenant/draft_orders_controller.rb +2 -1
  8. data/app/controllers/spree/api/v2/tenant/payment_methods_controller.rb +54 -0
  9. data/app/finders/spree_cm_commissioner/payment_methods/find.rb +59 -0
  10. data/app/interactors/spree_cm_commissioner/create_ticket.rb +7 -3
  11. data/app/models/concerns/spree_cm_commissioner/option_type_attr_type.rb +0 -1
  12. data/app/models/concerns/spree_cm_commissioner/purchase_limitable.rb +59 -0
  13. data/app/models/concerns/spree_cm_commissioner/variant_options_concern.rb +0 -1
  14. data/app/models/spree_cm_commissioner/line_item_decorator.rb +4 -3
  15. data/app/models/spree_cm_commissioner/product_decorator.rb +1 -0
  16. data/app/models/spree_cm_commissioner/variant_options.rb +0 -4
  17. data/app/serializers/spree/v2/storefront/product_serializer_decorator.rb +4 -0
  18. data/app/serializers/spree/v2/storefront/user_serializer_decorator.rb +2 -1
  19. data/app/serializers/spree/v2/storefront/variant_serializer_decorator.rb +2 -1
  20. data/app/serializers/spree/v2/tenant/product_serializer.rb +5 -0
  21. data/app/serializers/spree/v2/tenant/user_serializer.rb +1 -1
  22. data/app/serializers/spree/v2/tenant/variant_serializer.rb +2 -1
  23. data/app/services/spree_cm_commissioner/api_caches/invalidate.rb +24 -3
  24. data/app/services/spree_cm_commissioner/draft_order/create.rb +13 -4
  25. data/app/services/spree_cm_commissioner/line_items/validate_purchase_limits.rb +97 -0
  26. data/app/services/spree_cm_commissioner/load_test/generate_booking_ticket_data.rb +4 -4
  27. data/config/locales/en.yml +12 -1
  28. data/config/locales/km.yml +16 -5
  29. data/config/routes.rb +2 -0
  30. data/db/migrate/20260730000000_add_purchase_limit_scopes_to_spree_orders.rb +22 -0
  31. data/lib/spree_cm_commissioner/test_helper/factories/option_type_factory.rb +0 -6
  32. data/lib/spree_cm_commissioner/version.rb +1 -1
  33. metadata +7 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e267212ae81075d66a95e65c2eb320ea05bc9b18f53b1bc4a74896639ddd4ed2
4
- data.tar.gz: cc28b8d31dd89f481864b6f5c366d264335403ca4f8aaf9a00dbd39e502012b6
3
+ metadata.gz: 391b15d7aada08289da9a21d18a9d48d461ecf1fe0c36a361a276df201455a5e
4
+ data.tar.gz: af3f07ac5d9e449cb5300949dbbcdcb80981b78b41f0aa23a396ce6cdd49e68b
5
5
  SHA512:
6
- metadata.gz: 7cb85b5d8aaac69dc845ddeb068ec96200cf400a7c6d9f3bd979681fe4d03dd08cf4358a059855825785d9524e1a4d04e0447e3b0bfc682f4827c1b09734fa7d
7
- data.tar.gz: 5727d1541df27c51c58ef2b4a7e022096c62aa6666fcb58514dbe39621a44395478d1f5ea5ecef24280767c0d41f47e43f823276720ce63f45c7ef371278b0a8
6
+ metadata.gz: 49e38586ae76dfcf37be8bb6f9c4ddef60a2ef694f491ffb648b5903070dd0932c2efb1d936f3c5a6b4c1af16355b2ddc26e4706cc04c7ed32bed0908ed88db5
7
+ data.tar.gz: be58778bd8a6124b8b3c914e9c0c83a44dce42a75c1df0f677ef00868bc388f3fa88518828e1833b701366eedf551e03a30528919a2c1f8ea16aec108d92b8fe
data/Gemfile.lock CHANGED
@@ -34,7 +34,7 @@ GIT
34
34
  PATH
35
35
  remote: .
36
36
  specs:
37
- spree_cm_commissioner (2.9.7)
37
+ spree_cm_commissioner (2.9.9)
38
38
  activerecord-multi-tenant
39
39
  activerecord_json_validator (~> 2.1, >= 2.1.3)
40
40
  aws-sdk-cloudfront
@@ -33,6 +33,7 @@ module SpreeCmCommissioner
33
33
  ShowContestantsController
34
34
  VotePackagesController
35
35
  ShowEliminationSessionsController
36
+ PaymentMethodsController
36
37
  ].freeze
37
38
 
38
39
  # Priority 3: Moderate Freshness (30 minutes)
@@ -12,6 +12,7 @@ module SpreeCmCommissioner
12
12
  end
13
13
 
14
14
  @spree_current_order.last_ip_address = request.remote_ip
15
+ @spree_current_order.device_id = device_id if device_id.present?
15
16
  @spree_current_order
16
17
  end
17
18
 
@@ -25,5 +26,9 @@ module SpreeCmCommissioner
25
26
  state: params[:state]
26
27
  )
27
28
  end
29
+
30
+ def device_id
31
+ params[:device_id].presence
32
+ end
28
33
  end
29
34
  end
@@ -15,7 +15,8 @@ module Spree
15
15
  line_items: draft_order_line_items,
16
16
  user: spree_current_user,
17
17
  store: current_store,
18
- currency: current_currency
18
+ currency: current_currency,
19
+ options: { device_id: params[:device_id], last_ip_address: request.remote_ip }
19
20
  )
20
21
 
21
22
  if result.success?
@@ -0,0 +1,40 @@
1
+ module Spree
2
+ module Api
3
+ module V2
4
+ module Storefront
5
+ # Order & user independent catalog of the payment methods a vendor accepts.
6
+ # Takes vendor_id as a required query param. See
7
+ # Spree::Api::V2::Tenant::PaymentMethodsController for the caching caveat
8
+ # that comes with that, and for what this deliberately leaves out.
9
+ class PaymentMethodsController < Spree::Api::V2::ResourceController
10
+ # override
11
+ def index
12
+ render_serialized_payload do
13
+ collection_serializer.new(collection).serializable_hash
14
+ end
15
+ end
16
+
17
+ # override
18
+ def collection
19
+ @collection ||= SpreeCmCommissioner::PaymentMethods::Find.new(
20
+ vendor: vendor,
21
+ store: current_store,
22
+ type: params[:type]
23
+ ).execute
24
+ end
25
+
26
+ # override
27
+ def collection_serializer
28
+ ::Spree::V2::Storefront::PaymentMethodSerializer
29
+ end
30
+
31
+ private
32
+
33
+ def vendor
34
+ @vendor ||= ::Spree::Vendor.active.find(params[:vendor_id])
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -13,7 +13,8 @@ module Spree
13
13
  user: spree_current_user,
14
14
  store: current_store,
15
15
  currency: current_currency,
16
- tenant: @tenant
16
+ tenant: @tenant,
17
+ options: { device_id: params[:device_id], last_ip_address: request.remote_ip }
17
18
  )
18
19
 
19
20
  if result.success?
@@ -0,0 +1,54 @@
1
+ module Spree
2
+ module Api
3
+ module V2
4
+ module Tenant
5
+ # Order & user independent catalog of the payment methods a vendor accepts.
6
+ #
7
+ # vendor_id is a required query param. A vendor id is globally unique and
8
+ # belongs to exactly one tenant, so it is what identifies tenant, vendor and
9
+ # store here — the tenant itself comes from the Doorkeeper token, not the URL.
10
+ #
11
+ # IMPORTANT for caching (see SpreeCmCommissioner::ContentCachable): the CDN
12
+ # must include vendor_id in its cache key. If query strings are stripped,
13
+ # every vendor collapses into one cache entry and the wrong payment methods
14
+ # are served. Moving to /vendors/:vendor_id/payment_methods would remove that
15
+ # dependency — tracked as a follow-up.
16
+ #
17
+ # For the per-order truth (store credit, early adopter, payout filtering)
18
+ # use CartPaymentMethodGroupsController instead.
19
+ class PaymentMethodsController < BaseController
20
+ # override
21
+ def index
22
+ render_serialized_payload do
23
+ collection_serializer.new(collection).serializable_hash
24
+ end
25
+ end
26
+
27
+ # override
28
+ def collection
29
+ @collection ||= SpreeCmCommissioner::PaymentMethods::Find.new(
30
+ vendor: vendor,
31
+ store: current_store,
32
+ type: params[:type]
33
+ ).execute
34
+ end
35
+
36
+ # override
37
+ def collection_serializer
38
+ Spree::V2::Tenant::PaymentMethodSerializer
39
+ end
40
+
41
+ private
42
+
43
+ # Scoped to the token's tenant, so a token from another tenant gets a 404
44
+ # rather than a cache hit on someone else's vendor.
45
+ def vendor
46
+ @vendor ||= MultiTenant.with(@tenant) do
47
+ ::Spree::Vendor.active.find(params[:vendor_id])
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,59 @@
1
+ module SpreeCmCommissioner
2
+ module PaymentMethods
3
+ # Order & user independent lookup of the payment methods a vendor accepts.
4
+ #
5
+ # Mirrors the scope selection of Vpago::OrderDecorator#available_payment_methods,
6
+ # minus everything that depends on the order or the current user, so the result
7
+ # is safe to cache on the vendor id alone:
8
+ #
9
+ # - available_for_order? (only Spree::PaymentMethod::StoreCredit varies, on user balance)
10
+ # - available_on_frontend_for_early_adopter (user attribute)
11
+ # - support_payout? (order line items)
12
+ # - preferred_payment_method_id (user, affects sort only)
13
+ #
14
+ # Callers needing the per-order truth must keep using
15
+ # Spree::Api::V2::*::CartPaymentMethodGroupsController.
16
+ class Find
17
+ attr_reader :vendor, :store, :type
18
+
19
+ def initialize(vendor:, store: nil, type: nil)
20
+ @vendor = vendor
21
+ @store = store
22
+ @type = type
23
+ end
24
+
25
+ def execute
26
+ return mini_app_scope if type.present?
27
+
28
+ scope.reject(&:mini_app?)
29
+ end
30
+
31
+ private
32
+
33
+ def scope
34
+ @scope ||= source.available_on_front_end.order(:position).to_a
35
+ end
36
+
37
+ # Vendors without their own payment methods fall back to the store's,
38
+ # same as Vpago::OrderDecorator#available_payment_methods.
39
+ def source
40
+ vendor_payment_methods.exists? ? vendor_payment_methods : store_payment_methods
41
+ end
42
+
43
+ def vendor_payment_methods
44
+ @vendor_payment_methods ||= vendor&.payment_methods || Spree::PaymentMethod.none
45
+ end
46
+
47
+ def store_payment_methods
48
+ (store || Spree::Store.default).payment_methods
49
+ end
50
+
51
+ def mini_app_scope
52
+ mapped_type = SpreeCmCommissioner::PaymentMethodTypeMapper.call(type)
53
+ return [] if mapped_type.blank?
54
+
55
+ scope.select { |payment_method| payment_method.mini_app? && payment_method.type == mapped_type }
56
+ end
57
+ end
58
+ end
59
+ end
@@ -117,12 +117,15 @@ module SpreeCmCommissioner
117
117
  end
118
118
  end
119
119
 
120
- def ticket_params
120
+ def ticket_params # rubocop:disable Metrics/AbcSize
121
121
  {
122
122
  name: context.params[:name],
123
123
  price: context.params[:price],
124
124
  compare_at_price: context.params[:compare_at_price],
125
- max_order_quantity: context.params[:max_order_quantity],
125
+ max_quantity_per_order: context.params[:max_quantity_per_order],
126
+ max_quantity_per_account: context.params[:max_quantity_per_account],
127
+ max_quantity_per_device: context.params[:max_quantity_per_device],
128
+ max_quantity_per_ip: context.params[:max_quantity_per_ip],
126
129
  available_on: context.params[:available_on],
127
130
  discontinue_on: context.params[:discontinue_on],
128
131
  description: context.params[:description],
@@ -132,7 +135,8 @@ module SpreeCmCommissioner
132
135
  shipping_category_id: context.params[:shipping_category_id],
133
136
  option_type_ids: context.params[:option_type_ids],
134
137
  allow_anonymous_booking: context.params[:allow_anonymous_booking],
135
- allow_self_check_in: context.params[:allow_self_check_in]
138
+ allow_self_check_in: context.params[:allow_self_check_in],
139
+ purchasable_on: context.params[:purchasable_on] || 'both'
136
140
  }
137
141
  end
138
142
  end
@@ -29,7 +29,6 @@ module SpreeCmCommissioner
29
29
  'duration-in-seconds' => 'integer',
30
30
  'payment-option' => 'payment_option',
31
31
  'delivery-option' => 'delivery_option',
32
- 'max-quantity-per-order' => 'integer',
33
32
  'due-date' => 'integer',
34
33
  'month' => 'integer',
35
34
  'number-of-adults' => 'integer',
@@ -0,0 +1,59 @@
1
+ # Product-level purchase caps per buyer identity (order, account, device, IP).
2
+ # Stored as public metadata and enforced at cart time by
3
+ # SpreeCmCommissioner::LineItems::ValidatePurchaseLimits.
4
+ module SpreeCmCommissioner
5
+ module PurchaseLimitable
6
+ extend ActiveSupport::Concern
7
+
8
+ PURCHASE_LIMIT_KEYS = %i[
9
+ max_quantity_per_order
10
+ max_quantity_per_account
11
+ max_quantity_per_device
12
+ max_quantity_per_ip
13
+ ].freeze
14
+
15
+ POSITIVE_INTEGER = { only_integer: true, greater_than: 0, message: :must_be_positive_integer }.freeze
16
+
17
+ included do
18
+ store_public_metadata :max_quantity_per_order, :integer
19
+ store_public_metadata :max_quantity_per_account, :integer
20
+ store_public_metadata :max_quantity_per_device, :integer
21
+ store_public_metadata :max_quantity_per_ip, :integer
22
+
23
+ validates :max_quantity_per_order, numericality: POSITIVE_INTEGER, allow_nil: true
24
+ validates :max_quantity_per_account, numericality: POSITIVE_INTEGER, allow_nil: true
25
+ validates :max_quantity_per_device, numericality: POSITIVE_INTEGER, allow_nil: true
26
+ validates :max_quantity_per_ip, numericality: POSITIVE_INTEGER, allow_nil: true
27
+
28
+ validate :validate_max_quantity_per_account
29
+ validate :validate_max_quantity_per_device
30
+
31
+ before_validation :normalize_purchase_limits
32
+ end
33
+
34
+ private
35
+
36
+ # A blank cap means "unset". store_public_metadata writes the raw value and its
37
+ # :integer reader casts with to_i, so a form-submitted "" would otherwise read back
38
+ # as 0 and fail both the numericality check and the metadata type check.
39
+ def normalize_purchase_limits
40
+ return if public_metadata.blank?
41
+
42
+ public_metadata.slice(*PURCHASE_LIMIT_KEYS.map(&:to_s)).each do |key, value|
43
+ public_metadata[key] = nil if value.blank?
44
+ end
45
+ end
46
+
47
+ def validate_max_quantity_per_account
48
+ return unless max_quantity_per_account.to_i.positive? && allow_anonymous_booking?
49
+
50
+ errors.add(:base, I18n.t('purchase_limit.account_cap_requires_named_booking'))
51
+ end
52
+
53
+ def validate_max_quantity_per_device
54
+ return unless max_quantity_per_device.to_i.positive? && !purchasable_on_app?
55
+
56
+ errors.add(:base, I18n.t('purchase_limit.device_cap_requires_app_only'))
57
+ end
58
+ end
59
+ end
@@ -14,7 +14,6 @@ module SpreeCmCommissioner
14
14
  :duration_in_seconds,
15
15
  :payment_option,
16
16
  :delivery_option,
17
- :max_quantity_per_order,
18
17
  :due_date,
19
18
  :month,
20
19
  :number_of_adults,
@@ -32,7 +32,7 @@ module SpreeCmCommissioner
32
32
  base.before_save :update_vendor_id
33
33
 
34
34
  base.before_create :add_due_date, if: :subscription?
35
- base.validate :ensure_not_exceed_max_quantity_per_order, if: -> { variant&.max_quantity_per_order.present? }
35
+ base.validate :ensure_not_exceed_purchase_limits, if: -> { product.present? }
36
36
  base.validate :ensure_all_guests_have_blocks, if: :required_seat_selection?
37
37
 
38
38
  base.whitelisted_ransackable_associations |= %w[guests order]
@@ -290,8 +290,9 @@ module SpreeCmCommissioner
290
290
 
291
291
  private
292
292
 
293
- def ensure_not_exceed_max_quantity_per_order
294
- errors.add(:quantity, I18n.t('line_item.validation.exceeded_max_quantity_per_order')) if quantity > variant.max_quantity_per_order
293
+ def ensure_not_exceed_purchase_limits
294
+ result = SpreeCmCommissioner::LineItems::ValidatePurchaseLimits.call(line_item: self)
295
+ errors.add(:quantity, result.error.to_s) unless result.success?
295
296
  end
296
297
 
297
298
  def ensure_all_guests_have_blocks
@@ -11,6 +11,7 @@ module SpreeCmCommissioner
11
11
  base.include SpreeCmCommissioner::Integrations::IntegrationMappable
12
12
  base.include SpreeCmCommissioner::StoreMetadata
13
13
  base.include SpreeCmCommissioner::HomepageSectionRelatableConcern
14
+ base.include SpreeCmCommissioner::PurchaseLimitable
14
15
 
15
16
  base.delegate :is_open_dated, :is_open_dated?, to: :trip, allow_nil: true
16
17
 
@@ -81,10 +81,6 @@ module SpreeCmCommissioner
81
81
  @delivery_option ||= option_value_name_for(option_type_name: 'delivery-option')
82
82
  end
83
83
 
84
- def max_quantity_per_order
85
- @max_quantity_per_order ||= option_value_name_for(option_type_name: 'max-quantity-per-order')&.to_i
86
- end
87
-
88
84
  def due_date
89
85
  @due_date ||= option_value_name_for(option_type_name: 'due-date')&.to_i
90
86
  end
@@ -54,6 +54,10 @@ module Spree
54
54
  base.cache_options store: nil
55
55
 
56
56
  base.attribute :action_button, &:action_button
57
+ base.attribute :max_quantity_per_order, &:max_quantity_per_order
58
+ base.attribute :max_quantity_per_account, &:max_quantity_per_account
59
+ base.attribute :max_quantity_per_device, &:max_quantity_per_device
60
+ base.attribute :max_quantity_per_ip, &:max_quantity_per_ip
57
61
  end
58
62
  end
59
63
  end
@@ -7,7 +7,8 @@ module Spree
7
7
  :country_code, :otp_enabled, :otp_email, :otp_phone_number,
8
8
  :confirm_pin_code_enabled, :tenant_id,
9
9
  :login, :qr_data, :qr_data_version, :qr_data_invalidated_at,
10
- :unread_notifications_count, :request_orders_count
10
+ :unread_notifications_count, :request_orders_count,
11
+ :preferred_payment_method_id
11
12
 
12
13
  base.attribute :has_incomplete_guest_info, &:has_incomplete_guest_info?
13
14
 
@@ -5,9 +5,10 @@ module Spree
5
5
  def self.prepended(base)
6
6
  base.attributes :need_confirmation, :product_type, :kyc, :allow_anonymous_booking,
7
7
  :reminder_in_hours, :start_time, :delivery_option,
8
- :number_of_guests, :max_quantity_per_order, :discontinue_on, :high_demand
8
+ :number_of_guests, :discontinue_on, :high_demand
9
9
 
10
10
  base.attribute :delivery_required, &:delivery_required?
11
+ base.attribute(:max_quantity_per_order) { |variant| variant.product&.max_quantity_per_order }
11
12
 
12
13
  base.has_many :stock_locations
13
14
  base.has_many :stock_items, serializer: ::SpreeCmCommissioner::V2::Storefront::StockItemSerializer
@@ -8,6 +8,11 @@ module Spree
8
8
 
9
9
  # Overrided dynamic fields relationship of the parent serializer(Storefront).
10
10
  has_many :dynamic_fields, serializer: Spree::V2::Tenant::DynamicFieldSerializer
11
+
12
+ attribute :max_quantity_per_order, &:max_quantity_per_order
13
+ attribute :max_quantity_per_account, &:max_quantity_per_account
14
+ attribute :max_quantity_per_device, &:max_quantity_per_device
15
+ attribute :max_quantity_per_ip, &:max_quantity_per_ip
11
16
  end
12
17
  end
13
18
  end
@@ -6,7 +6,7 @@ module Spree
6
6
  :public_metadata, :completed_orders,
7
7
  :phone_number, :intel_phone_number, :country_code,
8
8
  :otp_enabled, :otp_email, :otp_phone_number,
9
- :confirm_pin_code_enabled
9
+ :confirm_pin_code_enabled, :preferred_payment_method_id
10
10
 
11
11
  has_one :profile, serializer: Spree::V2::Tenant::AssetSerializer
12
12
  has_many :device_tokens, serializer: Spree::V2::Tenant::UserDeviceTokenSerializer
@@ -30,9 +30,10 @@ module Spree
30
30
 
31
31
  attributes :need_confirmation, :product_type, :kyc, :allow_anonymous_booking,
32
32
  :reminder_in_hours, :start_time, :delivery_option,
33
- :number_of_guests, :max_quantity_per_order, :discontinue_on, :high_demand
33
+ :number_of_guests, :discontinue_on, :high_demand
34
34
 
35
35
  attribute :delivery_required, &:delivery_required?
36
+ attribute(:max_quantity_per_order) { |variant| variant.product&.max_quantity_per_order }
36
37
 
37
38
  belongs_to :product, serializer: Spree::V2::Storefront::ProductSerializer
38
39
 
@@ -9,7 +9,8 @@ module SpreeCmCommissioner
9
9
  'Spree::Menu' => Spree::Menu,
10
10
  'Spree::Taxon' => Spree::Taxon,
11
11
  'Spree::Product' => Spree::Product,
12
- 'Spree::Vendor' => Spree::Vendor
12
+ 'Spree::Vendor' => Spree::Vendor,
13
+ 'Spree::PaymentMethod' => Spree::PaymentMethod
13
14
  }.freeze
14
15
 
15
16
  # eg. 'Spree::Product', 123 or 'Spree::Product', nil
@@ -84,7 +85,8 @@ module SpreeCmCommissioner
84
85
  '/api/v2/storefront/vendors/:id*',
85
86
  '/api/v2/storefront/accommodations/:id*',
86
87
  '/api/v2/storefront/accommodations/:slug*'
87
- ]
88
+ ],
89
+ 'Spree::PaymentMethod' => payment_method_patterns
88
90
  }
89
91
  end
90
92
 
@@ -107,12 +109,31 @@ module SpreeCmCommissioner
107
109
  '/api/v2/tenant/vendors/*',
108
110
  '/api/v2/storefront/vendors/*',
109
111
  '/api/v2/storefront/accommodations/*'
110
- ]
112
+ ],
113
+ 'Spree::PaymentMethod' => payment_method_patterns
111
114
  }
112
115
  end
113
116
 
114
117
  private
115
118
 
119
+ # Deliberately broad, and identical for single-record and all-record
120
+ # invalidation: a payment method is either vendor-owned or store-level, and a
121
+ # store-level one is served for every vendor through the fallback in
122
+ # SpreeCmCommissioner::PaymentMethods::Find. From the record alone we cannot
123
+ # tell which vendors are affected.
124
+ #
125
+ # vendor_id is a query param, so the trailing `*` is what clears every
126
+ # vendor's cached variant of this path in one go.
127
+ #
128
+ # Payment methods change a handful of times a year, so a broad invalidation
129
+ # is cheap.
130
+ def payment_method_patterns
131
+ [
132
+ '/api/v2/tenant/payment_methods*',
133
+ '/api/v2/storefront/payment_methods*'
134
+ ]
135
+ end
136
+
116
137
  # eg. updated_at: Fri, 26 Dec 2025 10:40:56.505450000 +07 +07:00
117
138
  def refresh_record_cache_key(record)
118
139
  # rubocop:disable Rails/SkipsModelValidations
@@ -27,19 +27,28 @@ module SpreeCmCommissioner
27
27
  class Create
28
28
  prepend ::Spree::ServiceModule::Base
29
29
 
30
- def call(line_items:, user: nil, store: nil, currency: nil, tenant: nil)
30
+ def call(line_items:, user: nil, store: nil, currency: nil, tenant: nil, options: {}) # rubocop:disable Metrics/ParameterLists
31
31
  return failure(nil, I18n.t('spree_cm_commissioner.draft_order.line_items_missing')) if line_items.blank?
32
32
 
33
- order = create_order!(line_items, user, store, currency, tenant)
33
+ order = create_order!(line_items, user, store, currency, tenant, options)
34
34
  success(order: order)
35
35
  rescue StandardError => e
36
36
  failure(nil, e.message)
37
37
  end
38
38
 
39
- def create_order!(line_items_params, user, store, currency, tenant)
39
+ def create_order!(line_items_params, user, store, currency, tenant, options) # rubocop:disable Metrics/ParameterLists
40
40
  store ||= Spree::Store.default
41
41
  currency ||= store&.default_currency
42
- order = Spree::Order.new(state: 'cart', use_billing: true, user: user, store: store, currency: currency, tenant: tenant)
42
+ order = Spree::Order.new(
43
+ state: 'cart',
44
+ use_billing: true,
45
+ user: user,
46
+ store: store,
47
+ currency: currency,
48
+ tenant: tenant,
49
+ device_id: options[:device_id],
50
+ last_ip_address: options[:last_ip_address]
51
+ )
43
52
  line_items_params.each { |line_item_params| build_line_item!(order, store, line_item_params) }
44
53
 
45
54
  ActiveRecord::Base.transaction do
@@ -0,0 +1,97 @@
1
+ # Per-product purchase caps, one independent check per scope. The order cap looks at a single
2
+ # order; account/ip/device aggregate over the buyer's completed orders, so a customer can't
3
+ # bypass a cap by splitting the purchase across carts.
4
+ #
5
+ # A cap of nil or 0 is off. A scope with no value on the order (guest order, no IP, no device
6
+ # id) has nothing to count the buyer against, so its cap does not apply.
7
+ #
8
+ # Counting is a live aggregation over spree_orders/spree_line_items - no ledger table,
9
+ # nothing to keep in sync, always reflects current order state.
10
+ module SpreeCmCommissioner
11
+ module LineItems
12
+ class ValidatePurchaseLimits
13
+ prepend ::Spree::ServiceModule::Base
14
+
15
+ LimitExceededError = Class.new(StandardError)
16
+
17
+ COUNTED_STATES = %w[complete resumed].freeze
18
+
19
+ def call(line_item:)
20
+ @line_item = line_item
21
+ @product = line_item.product
22
+ @order = line_item.order
23
+
24
+ return success(nil) if @product.blank?
25
+
26
+ check_order_limit!
27
+ check_account_limit!
28
+ check_ip_limit!
29
+ check_device_limit!
30
+
31
+ success(nil)
32
+ rescue LimitExceededError => e
33
+ failure(nil, e.message)
34
+ end
35
+
36
+ private
37
+
38
+ def check_order_limit!
39
+ limit = @product.max_quantity_per_order.to_i
40
+ return unless limit.positive?
41
+ return if quantity_in_order <= limit
42
+
43
+ raise LimitExceededError, I18n.t('line_item.validation.exceeded_order_purchase_limit')
44
+ end
45
+
46
+ def check_account_limit!
47
+ limit = @product.max_quantity_per_account.to_i
48
+ return unless limit.positive? && @order&.user_id.present?
49
+ return if quantity_in_order + purchased_by(user_id: @order.user_id) <= limit
50
+
51
+ raise LimitExceededError, I18n.t('line_item.validation.exceeded_account_purchase_limit')
52
+ end
53
+
54
+ def check_ip_limit!
55
+ limit = @product.max_quantity_per_ip.to_i
56
+ return unless limit.positive? && @order&.last_ip_address.present?
57
+ return if quantity_in_order + purchased_by(last_ip_address: @order.last_ip_address) <= limit
58
+
59
+ raise LimitExceededError, I18n.t('line_item.validation.exceeded_ip_purchase_limit')
60
+ end
61
+
62
+ def check_device_limit!
63
+ limit = @product.max_quantity_per_device.to_i
64
+ return unless limit.positive? && @order&.device_id.present?
65
+ return if quantity_in_order + purchased_by(device_id: @order.device_id) <= limit
66
+
67
+ raise LimitExceededError, I18n.t('line_item.validation.exceeded_device_purchase_limit')
68
+ end
69
+
70
+ # Sums per product, not per line item: two variants of one product share one allowance,
71
+ # so 3 + 3 must not both pass a cap of 4.
72
+ def quantity_in_order
73
+ @quantity_in_order ||= begin
74
+ siblings = @order ? @order.line_items.reject { |item| item == @line_item } : []
75
+ # One variant load for the whole cart rather than one per sibling; already-loaded
76
+ # siblings are skipped.
77
+ ActiveRecord::Associations::Preloader.new(records: siblings, associations: :variant).call if siblings.any?
78
+
79
+ @line_item.quantity.to_i + siblings.sum { |item| item.variant&.product_id == @product.id ? item.quantity : 0 }
80
+ end
81
+ end
82
+
83
+ # What the buyer already holds for this product, outside the order being validated.
84
+ # product_id is not a column on spree_line_items - it is `delegate :product_id, to:
85
+ # :variant` - so the product filter joins through spree_variants, indexed on product_id.
86
+ def purchased_by(scope)
87
+ Spree::LineItem.joins(:order, :variant)
88
+ .where(spree_orders: { state: COUNTED_STATES })
89
+ .where(spree_orders: scope)
90
+ .where(spree_variants: { product_id: @product.id })
91
+ .where.not(spree_orders: { completed_at: nil })
92
+ .where.not(spree_orders: { id: @line_item.order_id })
93
+ .sum(:quantity)
94
+ end
95
+ end
96
+ end
97
+ end
@@ -117,7 +117,7 @@ module SpreeCmCommissioner
117
117
  request_to_book_issue(variant),
118
118
  out_of_stock_issue(variant),
119
119
  seat_selection_issue(variant, product),
120
- max_quantity_issue(variant),
120
+ max_quantity_issue(product),
121
121
  missing_event_issue(event)
122
122
  ].compact
123
123
  end
@@ -176,11 +176,11 @@ module SpreeCmCommissioner
176
176
  'Variant requires seat selection (it has seat blocks).'
177
177
  end
178
178
 
179
- def max_quantity_issue(variant)
180
- max = variant.max_quantity_per_order
179
+ def max_quantity_issue(product)
180
+ max = product.max_quantity_per_order
181
181
  return if max.blank? || max >= 1
182
182
 
183
- "Variant max-quantity-per-order is #{max} (less than one ticket)."
183
+ "Product max_quantity_per_order is #{max} (less than one ticket)."
184
184
  end
185
185
 
186
186
  def missing_event_issue(event)
@@ -163,7 +163,10 @@ en:
163
163
 
164
164
  line_item:
165
165
  validation:
166
- exceeded_max_quantity_per_order: "Exceeded maximum quantity per order"
166
+ exceeded_order_purchase_limit: "You have reached the maximum quantity allowed per order for this item."
167
+ exceeded_account_purchase_limit: "You have reached the purchase limit for this item."
168
+ exceeded_ip_purchase_limit: "This network has reached the purchase limit for this item."
169
+ exceeded_device_purchase_limit: "This device has reached the purchase limit for this item."
167
170
  seats_are_required: "Seats are required for all guests"
168
171
  blocks_are_reserved_by_other_guest: "Seats were recently reserved by another guest"
169
172
  blocks_are_reserved_by_other_guest_with_label: "Seat %{label} was recently reserved by another guest"
@@ -173,6 +176,14 @@ en:
173
176
  unable_to_save_reserved_block_record: "Unable to save seat reservation"
174
177
  unable_to_save_reserved_block_record_with_label: "Unable to save reservation for seat %{label}"
175
178
 
179
+ errors:
180
+ messages:
181
+ must_be_positive_integer: "must be a positive whole number"
182
+
183
+ purchase_limit:
184
+ device_cap_requires_app_only: "Device limit requires the ticket to be app-only."
185
+ account_cap_requires_named_booking: "Account limit requires anonymous booking to be off."
186
+
176
187
  vectors:
177
188
  icons:
178
189
  info_rules: 'Only allow <b>%{allow_extensions}</b> files. Icons are stored inside "images/" folder, eg. app/assets/images/backend-adjust.svg.'
@@ -133,7 +133,10 @@ km:
133
133
 
134
134
  line_item:
135
135
  validation:
136
- exceeded_max_quantity_per_order: "លើសពីបរិមាណអតិបរមាក្នុងមួយការបញ្ជាទិញ"
136
+ exceeded_order_purchase_limit: "អ្នកបានឈានដល់ចំនួនអតិបរមាក្នុងមួយការបញ្ជាទិញសម្រាប់ទំនិញនេះ។"
137
+ exceeded_account_purchase_limit: "អ្នកបានឈានដល់ដែនកំណត់នៃការទិញសម្រាប់ទំនិញនេះ។"
138
+ exceeded_ip_purchase_limit: "បណ្តាញនេះបានឈានដល់ដែនកំណត់នៃការទិញសម្រាប់ទំនិញនេះ។"
139
+ exceeded_device_purchase_limit: "ឧបករណ៍នេះបានឈានដល់ដែនកំណត់នៃការទិញសម្រាប់ទំនិញនេះ។"
137
140
  seats_are_required: "ត្រូវការលេខកៅអីសម្រាប់ភ្ញៀវទាំងអស់"
138
141
  blocks_are_reserved_by_other_guest: "កៅអីត្រូវបានកក់ថ្មីៗដោយភ្ញៀវផ្សេងទៀត"
139
142
  blocks_are_reserved_by_other_guest_with_label: "កៅអី %{label} ត្រូវបានកក់ថ្មីៗដោយភ្ញៀវផ្សេងទៀត"
@@ -143,6 +146,14 @@ km:
143
146
  unable_to_save_reserved_block_record: "មិនអាចរក្សាទុកការកក់កៅអីបានទេ"
144
147
  unable_to_save_reserved_block_record_with_label: "មិនអាចរក្សាទុកការកក់សម្រាប់កៅអី %{label} បានទេ"
145
148
 
149
+ errors:
150
+ messages:
151
+ must_be_positive_integer: "ត្រូវតែជាចំនួនគត់វិជ្ជមាន"
152
+
153
+ purchase_limit:
154
+ device_cap_requires_app_only: "ដែនកំណត់តាមឧបករណ៍ត្រូវការឱ្យសំបុត្រមានតែក្នុងកម្មវិធីប៉ុណ្ណោះ។"
155
+ account_cap_requires_named_booking: "ដែនកំណត់តាមគណនីត្រូវការឱ្យបិទការកក់ដោយអនាមិក។"
156
+
146
157
  subscription:
147
158
  validation:
148
159
  out_of_range: "Subscription date is out of range."
@@ -436,11 +447,11 @@ km:
436
447
  attributes:
437
448
  base:
438
449
  last_contestant_in_parent_session: 'មិនអាចដកអ្នកចូលប្រកួតចុងក្រោយពី "%{session_name}" ព្រោះ session កូនពឹងផ្អែកលើវា'
439
- advanced_from_session: 'ត្រូវបានដំឡើងពី session មុន ហើយមិនអាចដកចេញដោយផ្ទាល់បានទេ។ សូមលុបការដំឡើងនៅក្នុង session ប្រភពជំនួសវិញ។'
440
- pre_vote_session: 'មិនអាចដកចេញពី pre-vote session បានទេ។'
441
- has_advanced_to_session: 'ត្រូវបានកំណត់ឱ្យដំឡើងទៅ session មួយទៀត។ សូមលុបការដំឡើងជាមុនសិន មុននឹងដកអ្នកចូលប្រកួតនេះចេញ។'
450
+ advanced_from_session: "ត្រូវបានដំឡើងពី session មុន ហើយមិនអាចដកចេញដោយផ្ទាល់បានទេ។ សូមលុបការដំឡើងនៅក្នុង session ប្រភពជំនួសវិញ។"
451
+ pre_vote_session: "មិនអាចដកចេញពី pre-vote session បានទេ។"
452
+ has_advanced_to_session: "ត្រូវបានកំណត់ឱ្យដំឡើងទៅ session មួយទៀត។ សូមលុបការដំឡើងជាមុនសិន មុននឹងដកអ្នកចូលប្រកួតនេះចេញ។"
442
453
  advanced_to_id:
443
- same_as_advanced_from: 'មិនអាចដូចនឹងអ្នកចូលប្រកួតដែលវាត្រូវបានដំឡើងចេញពីនោះទេ'
454
+ same_as_advanced_from: "មិនអាចដូចនឹងអ្នកចូលប្រកួតដែលវាត្រូវបានដំឡើងចេញពីនោះទេ"
444
455
  spree_cm_commissioner/voting_credit:
445
456
  attributes:
446
457
  free_votes_amount:
data/config/routes.rb CHANGED
@@ -532,6 +532,7 @@ Spree::Core::Engine.add_routes do
532
532
  namespace :tenant do
533
533
  resource :token, controller: :access_tokens, only: [:create]
534
534
  resources :vendors
535
+ resources :payment_methods, only: %i[index]
535
536
  resources :products
536
537
  resources :taxons, only: %i[index show], id: /.+/
537
538
  resources :ad_campaigns, only: [:index]
@@ -757,6 +758,7 @@ Spree::Core::Engine.add_routes do
757
758
  resources :vendor_photos
758
759
  resources :events
759
760
  end
761
+ resources :payment_methods, only: %i[index]
760
762
  resource :homepage_data, only: [:show]
761
763
  resources :homepage_sections, only: [:index]
762
764
  resources :order_qrs, only: [:show]
@@ -0,0 +1,22 @@
1
+ class AddPurchaseLimitScopesToSpreeOrders < ActiveRecord::Migration[7.0]
2
+ # Partial, matching ValidatePurchaseLimits::COUNTED_STATES. Most spree_orders rows are
3
+ # abandoned carts the purchase-limit query never looks at, so indexing them only costs
4
+ # storage. Keeping state in the predicate also drops a varchar from every entry.
5
+ COUNTED = "state IN ('complete', 'resumed')".freeze
6
+
7
+ def up
8
+ add_column :spree_orders, :device_id, :string, if_not_exists: true
9
+
10
+ add_index :spree_orders, :user_id, where: COUNTED, name: 'idx_orders_user_for_limits', if_not_exists: true
11
+ add_index :spree_orders, :last_ip_address, where: COUNTED, name: 'idx_orders_ip_for_limits', if_not_exists: true
12
+ add_index :spree_orders, :device_id, where: COUNTED, name: 'idx_orders_device_for_limits', if_not_exists: true
13
+ end
14
+
15
+ def down
16
+ remove_index :spree_orders, name: 'idx_orders_user_for_limits', if_exists: true
17
+ remove_index :spree_orders, name: 'idx_orders_ip_for_limits', if_exists: true
18
+ remove_index :spree_orders, name: 'idx_orders_device_for_limits', if_exists: true
19
+
20
+ remove_column :spree_orders, :device_id, if_exists: true
21
+ end
22
+ end
@@ -75,12 +75,6 @@ FactoryBot.define do
75
75
  presentation { 'delivery-option' }
76
76
  end
77
77
 
78
- trait :max_quantity_per_order do
79
- attr_type { :integer }
80
- name { 'max-quantity-per-order' }
81
- presentation { 'max-quantity-per-order' }
82
- end
83
-
84
78
  trait :due_date do
85
79
  attr_type { :integer }
86
80
  name { 'due-date' }
@@ -1,5 +1,5 @@
1
1
  module SpreeCmCommissioner
2
- VERSION = '2.9.7'.freeze
2
+ VERSION = '2.9.9'.freeze
3
3
 
4
4
  module_function
5
5
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spree_cm_commissioner
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.9.7
4
+ version: 2.9.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - You
@@ -1004,6 +1004,7 @@ files:
1004
1004
  - app/controllers/spree/api/v2/storefront/order_promotions_controller.rb
1005
1005
  - app/controllers/spree/api/v2/storefront/order_qrs_controller.rb
1006
1006
  - app/controllers/spree/api/v2/storefront/order_request_notifications_controller.rb
1007
+ - app/controllers/spree/api/v2/storefront/payment_methods_controller.rb
1007
1008
  - app/controllers/spree/api/v2/storefront/pending_line_items_controller.rb
1008
1009
  - app/controllers/spree/api/v2/storefront/pin_code_checkers_controller.rb
1009
1010
  - app/controllers/spree/api/v2/storefront/pin_code_generators_controller.rb
@@ -1074,6 +1075,7 @@ files:
1074
1075
  - app/controllers/spree/api/v2/tenant/my_votes_controller.rb
1075
1076
  - app/controllers/spree/api/v2/tenant/notifications_controller.rb
1076
1077
  - app/controllers/spree/api/v2/tenant/order_histories_controller.rb
1078
+ - app/controllers/spree/api/v2/tenant/payment_methods_controller.rb
1077
1079
  - app/controllers/spree/api/v2/tenant/pin_code_checkers_controller.rb
1078
1080
  - app/controllers/spree/api/v2/tenant/pin_code_generators_controller.rb
1079
1081
  - app/controllers/spree/api/v2/tenant/popular_route_places_controller.rb
@@ -1201,6 +1203,7 @@ files:
1201
1203
  - app/finders/spree_cm_commissioner/orders/find.rb
1202
1204
  - app/finders/spree_cm_commissioner/orders/find_by_all_state.rb
1203
1205
  - app/finders/spree_cm_commissioner/orders/find_by_state.rb
1206
+ - app/finders/spree_cm_commissioner/payment_methods/find.rb
1204
1207
  - app/finders/spree_cm_commissioner/payment_methods/group_by_bank.rb
1205
1208
  - app/finders/spree_cm_commissioner/places/find_with_route.rb
1206
1209
  - app/finders/spree_cm_commissioner/products/find_decorator.rb
@@ -1482,6 +1485,7 @@ files:
1482
1485
  - app/models/concerns/spree_cm_commissioner/product_delegation.rb
1483
1486
  - app/models/concerns/spree_cm_commissioner/product_relation_type.rb
1484
1487
  - app/models/concerns/spree_cm_commissioner/product_type.rb
1488
+ - app/models/concerns/spree_cm_commissioner/purchase_limitable.rb
1485
1489
  - app/models/concerns/spree_cm_commissioner/route_order_countable.rb
1486
1490
  - app/models/concerns/spree_cm_commissioner/route_type.rb
1487
1491
  - app/models/concerns/spree_cm_commissioner/service_calendar_type.rb
@@ -2282,6 +2286,7 @@ files:
2282
2286
  - app/services/spree_cm_commissioner/invite_guests/bulk_create.rb
2283
2287
  - app/services/spree_cm_commissioner/invite_guests/create.rb
2284
2288
  - app/services/spree_cm_commissioner/line_items/sync_event_date.rb
2289
+ - app/services/spree_cm_commissioner/line_items/validate_purchase_limits.rb
2285
2290
  - app/services/spree_cm_commissioner/load_test/create_accounts.rb
2286
2291
  - app/services/spree_cm_commissioner/load_test/destroy_accounts.rb
2287
2292
  - app/services/spree_cm_commissioner/load_test/flow_registry.rb
@@ -3463,6 +3468,7 @@ files:
3463
3468
  - db/migrate/20260728074153_add_role_to_cm_exports.rb
3464
3469
  - db/migrate/20260729000000_add_visibility_to_cm_trips.rb
3465
3470
  - db/migrate/20260729000001_add_session_created_at_index_to_cm_votes.rb
3471
+ - db/migrate/20260730000000_add_purchase_limit_scopes_to_spree_orders.rb
3466
3472
  - docker-compose.yml
3467
3473
  - docs/api/scoped-access-token-endpoints.md
3468
3474
  - docs/option_types/attr_types.md