spree_cm_commissioner 2.15.0 → 2.15.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b32073d2114c66c1a2f712bbcfb05712cb153b8a651c2c33800665b0422d2541
4
- data.tar.gz: 790420e894c681441fba0109c0aafc51bbcbd19a3c076743404db71dd0c6ce7b
3
+ metadata.gz: 7cd929df06e0243dc4cf2b0b4ffc57d3346ced9f9d0cb5bdd3142d6adc153a88
4
+ data.tar.gz: 29a71dceac61e9e6497137c525ff9e76ace8ca1a179f0dc6eab157fd83a30a4d
5
5
  SHA512:
6
- metadata.gz: 79d3b977f4d3d63523ff5920cc17c7598eb320ed3b6e3dae89bff7d3ee3a0d3f40744a2788e033910da0fa61fcddeaff589414d2b6d9373c52b9f444fe44ff2a
7
- data.tar.gz: ca37c84b2064fe10e2187b9610385c6db5ab1016aa64df2f8e34b520fd65f37c59566f1d879aedb822fa43467f43da8f2acda7609ae9aba56ea67d40dc807e72
6
+ metadata.gz: 2bd7890db6844d7057a5e89f8e2cd0285e387772aaccba0ba2806aee4b7f78de3cea566402fb5a97279613bae6be342bbef2b55e2d185c82d8d85fec59401520
7
+ data.tar.gz: 682f9b46dae492ff3f0bdb9f558dccc3185ec4b9a6a0770e4fca4146ecd496e90577b97c049a9a171f7ab6ac76486fc14d4cdefe6bf7098ec96de9b8c09c170b
data/Gemfile.lock CHANGED
@@ -34,7 +34,7 @@ GIT
34
34
  PATH
35
35
  remote: .
36
36
  specs:
37
- spree_cm_commissioner (2.15.0)
37
+ spree_cm_commissioner (2.15.1)
38
38
  activerecord-multi-tenant
39
39
  activerecord_json_validator (~> 2.1, >= 2.1.3)
40
40
  aws-sdk-cloudfront
@@ -25,7 +25,8 @@ module Spree
25
25
  outbound_legs: @outbound_legs,
26
26
  inbound_legs: @inbound_legs,
27
27
  user: spree_current_user,
28
- include_return: ActiveModel::Type::Boolean.new.cast(params[:include_return])
28
+ include_return: ActiveModel::Type::Boolean.new.cast(params[:include_return]),
29
+ oauth_application_id: doorkeeper_token&.application_id
29
30
  )
30
31
 
31
32
  if result.success?
@@ -25,7 +25,8 @@ module Spree
25
25
  outbound_legs: @outbound_legs,
26
26
  inbound_legs: @inbound_legs,
27
27
  user: spree_current_user,
28
- include_return: ActiveModel::Type::Boolean.new.cast(params[:include_return])
28
+ include_return: ActiveModel::Type::Boolean.new.cast(params[:include_return]),
29
+ oauth_application_id: doorkeeper_token&.application_id
29
30
  )
30
31
 
31
32
  if result.success?
@@ -0,0 +1,44 @@
1
+ module SpreeCmCommissioner
2
+ module Promotion
3
+ module Rules
4
+ # The OAuth applications a promotion rule is scoped to, shared by every rule that narrows by
5
+ # app. Kept in `cm_oauth_application_promotion_rules` rather than a preference so "which
6
+ # promotions use this app" is a join, and deleting an app cannot leave a dead id in a rule.
7
+ # How an empty list behaves is up to the rule.
8
+ module OauthApplicationScoped
9
+ extend ActiveSupport::Concern
10
+
11
+ included do
12
+ has_many :oauth_application_promotion_rules,
13
+ class_name: 'SpreeCmCommissioner::OauthApplicationPromotionRule',
14
+ foreign_key: :promotion_rule_id,
15
+ inverse_of: :promotion_rule,
16
+ autosave: true,
17
+ dependent: :destroy
18
+
19
+ # For reading and reporting. Not written through: that would load and validate every
20
+ # Spree::OauthApplication (whose `api_key` is encrypted) just to link it, and the admin form
21
+ # posts ids anyway.
22
+ has_many :oauth_applications, through: :oauth_application_promotion_rules, class_name: 'Spree::OauthApplication'
23
+ end
24
+
25
+ # Reads the join rows, loaded once per rule instance: eligibility is re-checked several times in
26
+ # one cart update, and a plain `pluck` would query every time. Join rows rather than
27
+ # `oauth_applications`, which would load every app just to get its id.
28
+ def oauth_application_ids
29
+ oauth_application_promotion_rules.load.reject(&:marked_for_destruction?).map(&:oauth_application_id)
30
+ end
31
+
32
+ # What the admin form posts. Only the join rows change, saved with the rule. The blank value a
33
+ # multi-select always submits is dropped.
34
+ def oauth_application_ids=(ids)
35
+ wanted = Array(ids).compact_blank.map(&:to_i).uniq
36
+ existing = oauth_application_promotion_rules.load.to_a
37
+
38
+ existing.each { |row| row.mark_for_destruction unless wanted.include?(row.oauth_application_id) }
39
+ (wanted - existing.map(&:oauth_application_id)).each { |id| oauth_application_promotion_rules.build(oauth_application_id: id) }
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,8 @@
1
+ module SpreeCmCommissioner
2
+ class OauthApplicationPromotionRule < Base
3
+ belongs_to :promotion_rule, class_name: 'Spree::PromotionRule'
4
+ belongs_to :oauth_application, class_name: 'Spree::OauthApplication'
5
+
6
+ validates :oauth_application_id, uniqueness: { scope: :promotion_rule_id }
7
+ end
8
+ end
@@ -0,0 +1,87 @@
1
+ module SpreeCmCommissioner
2
+ module Promotion
3
+ module Rules
4
+ # Limits a promotion to a customer's Nth booking: 2 → 2 is "2nd booking only", 5 → (empty) is
5
+ # "5th booking and later". With apps chosen, only bookings placed through them count (the 2nd
6
+ # Wing booking); with none, bookings from every app count. Narrow by vendor with its own rule.
7
+ class CustomerOrderCount < Spree::PromotionRule
8
+ include OauthApplicationScoped
9
+
10
+ # Bookings that still stand. Also exactly the predicate of the partial index
11
+ # `idx_orders_user_for_limits` (user_id WHERE state IN complete, resumed), so the count is an
12
+ # index lookup on one user's rows.
13
+ COUNTED_STATES = %w[complete resumed].freeze
14
+
15
+ preference :min_order_number, :integer, default: 2
16
+ # Spree casts an empty integer field to 0, so 0 (or empty) means "no upper limit".
17
+ preference :max_order_number, :integer, default: 0
18
+
19
+ validate :order_number_range_valid
20
+
21
+ # Line items too: PromotionHandler::Cart activates a promotion when it is eligible for the
22
+ # line item just added, and a rule that does not apply to line items is skipped there.
23
+ def applicable?(promotable)
24
+ promotable.is_a?(Spree::Order) || promotable.is_a?(Spree::LineItem)
25
+ end
26
+
27
+ def eligible?(promotable, _options = {})
28
+ order = promotable.is_a?(Spree::LineItem) ? promotable.order : promotable
29
+
30
+ if order.user_id.nil?
31
+ eligibility_errors.add(:base, eligibility_error_message(:customer_order_count_login_required))
32
+ elsif !order_number_in_range?(order_number(order))
33
+ eligibility_errors.add(:base, eligibility_error_message(:customer_order_count_not_eligible))
34
+ end
35
+
36
+ eligibility_errors.empty?
37
+ end
38
+
39
+ # Which booking this order is for its customer, counted no further than the range needs.
40
+ # Memoized: one cart update calls eligible? several times (line item, order, each adjustment)
41
+ # and the answer can't change within it.
42
+ def order_number(order)
43
+ @order_numbers ||= {}
44
+ @order_numbers[[order.id, order.completed_at]] ||= previous_orders(order).limit(count_cap).count + 1
45
+ end
46
+
47
+ def min_order_number
48
+ preferred_min_order_number.to_i
49
+ end
50
+
51
+ def max_order_number
52
+ max = preferred_max_order_number.to_i
53
+ max.positive? ? max : nil
54
+ end
55
+
56
+ private
57
+
58
+ # A completed order counts only bookings completed before it, so re-checking it later
59
+ # (recalculation, cancel) isn't changed by bookings the customer made afterwards.
60
+ def previous_orders(order)
61
+ scope = Spree::Order.where(user_id: order.user_id, state: COUNTED_STATES).where.not(id: order.id)
62
+ app_ids = oauth_application_ids
63
+ scope = scope.where(oauth_application_id: app_ids) if app_ids.any?
64
+ order.completed_at ? scope.where(completed_at: ...order.completed_at) : scope
65
+ end
66
+
67
+ # Counting past this can't change the answer, so a customer with 300 bookings costs the
68
+ # same as one with 3.
69
+ def count_cap
70
+ max_order_number || min_order_number
71
+ end
72
+
73
+ def order_number_in_range?(number)
74
+ number >= min_order_number && (max_order_number.nil? || number <= max_order_number)
75
+ end
76
+
77
+ def order_number_range_valid
78
+ if min_order_number < 1
79
+ errors.add(:base, :customer_order_count_min_too_low)
80
+ elsif max_order_number && max_order_number < min_order_number
81
+ errors.add(:base, :customer_order_count_max_below_min)
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,34 @@
1
+ module SpreeCmCommissioner
2
+ module Promotion
3
+ module Rules
4
+ # Limits a promotion to orders placed through chosen OAuth applications — a tenant app or a
5
+ # mini app (Wing, ACLEDA, Vattanac), each of which logs its users in with its own client.
6
+ #
7
+ # Unlike Vendors/GuestAgeGroup, an empty list matches nothing: a channel promotion saved
8
+ # before anyone picked a channel must not leak to every channel. An order with no
9
+ # `oauth_application_id` (older orders, or a path that does not record it) never matches.
10
+ class OauthApplications < Spree::PromotionRule
11
+ include OauthApplicationScoped
12
+
13
+ # Line items too: PromotionHandler::Cart activates a promotion when it is eligible for the
14
+ # line item just added, and a rule that does not apply to line items is skipped there — so
15
+ # an order-only rule let every cart attach the promotion with a dead (ineligible) adjustment.
16
+ def applicable?(promotable)
17
+ promotable.is_a?(Spree::Order) || promotable.is_a?(Spree::LineItem)
18
+ end
19
+
20
+ # Reads the column rather than the association, so eligibility — re-checked on every cart
21
+ # recalculation — costs no query.
22
+ def eligible?(promotable, _options = {})
23
+ order = promotable.is_a?(Spree::LineItem) ? promotable.order : promotable
24
+
25
+ unless oauth_application_ids.include?(order.oauth_application_id)
26
+ eligibility_errors.add(:base, eligibility_error_message(:oauth_application_not_eligible))
27
+ end
28
+
29
+ eligibility_errors.empty?
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,23 @@
1
+ module SpreeCmCommissioner
2
+ # Doorkeeper only refreshes a token for the client it was issued to. A mini app token belongs to its
3
+ # mini app's application (see DoorkeeperServerDecorator), but the client that refreshes it holds no
4
+ # credentials for that application, so the match would always fail.
5
+ #
6
+ # For these tokens the refresh token itself is the proof, as it is for a public client. A wrong client
7
+ # secret is still refused: Doorkeeper checks it before the match, and this only relaxes the match.
8
+ module DoorkeeperRefreshTokenRequestDecorator
9
+ private
10
+
11
+ def validate_client_match
12
+ super || mini_app_token?
13
+ end
14
+
15
+ def mini_app_token?
16
+ refresh_token.application&.provider.present?
17
+ end
18
+ end
19
+ end
20
+
21
+ unless Doorkeeper::OAuth::RefreshTokenRequest.included_modules.include?(SpreeCmCommissioner::DoorkeeperRefreshTokenRequestDecorator)
22
+ Doorkeeper::OAuth::RefreshTokenRequest.prepend(SpreeCmCommissioner::DoorkeeperRefreshTokenRequestDecorator)
23
+ end
@@ -0,0 +1,28 @@
1
+ module SpreeCmCommissioner
2
+ # A mini app login is issued its token for that mini app's OAuth application, found by
3
+ # MiniAppLoginApplication, whatever client credentials the request carries. Doorkeeper would otherwise
4
+ # take the client from those credentials (the default BookMe+ client, or none), and the order made with
5
+ # the token would not say which mini app it came from.
6
+ #
7
+ # The login itself is the proof: UserAuthenticator only accepts a session key the bank signed for the
8
+ # user, so no client secret is needed to say which bank it is.
9
+ module DoorkeeperServerDecorator
10
+ def client
11
+ mini_app_client || super
12
+ end
13
+
14
+ private
15
+
16
+ # Only the password grant: a refresh keeps the application its token was issued for.
17
+ def mini_app_client
18
+ return @mini_app_client if defined?(@mini_app_client)
19
+
20
+ application = MiniAppLoginApplication.for(parameters) if parameters[:grant_type] == Doorkeeper::OAuth::PASSWORD
21
+ @mini_app_client = application && Doorkeeper::OAuth::Client.new(application)
22
+ end
23
+ end
24
+ end
25
+
26
+ unless Doorkeeper::Server.included_modules.include?(SpreeCmCommissioner::DoorkeeperServerDecorator)
27
+ Doorkeeper::Server.prepend(SpreeCmCommissioner::DoorkeeperServerDecorator)
28
+ end
@@ -0,0 +1,32 @@
1
+ module SpreeCmCommissioner
2
+ # Finds the OAuth application a mini app login belongs to, from the login itself: the parameter the
3
+ # client sends already names the bank (`session_key` → ACLEDA, `wing_session_key` → Wing, `session_id` →
4
+ # Vattanac), so the client needs no client id or secret of its own. The application is the one whose
5
+ # `provider` is that bank (see MiniAppProvider::Base.providers).
6
+ #
7
+ # Nil for any other login (web, Telegram, password), or when no application carries that provider yet.
8
+ class MiniAppLoginApplication
9
+ FLOW_SUFFIX = '_web_app_auth'.freeze
10
+
11
+ def self.for(params)
12
+ provider = provider_of(params)
13
+ return nil if provider.nil?
14
+
15
+ application = Spree::OauthApplication.find_by(provider: provider)
16
+ CmAppLogger.log(label: 'Mini app login: no OAuth application with this provider', data: { provider: provider }) if application.nil?
17
+ application
18
+ end
19
+
20
+ def self.provider_of(params)
21
+ flow = UserAuthenticator.flow_type(params)
22
+ return nil unless flow.end_with?(FLOW_SUFFIX)
23
+
24
+ provider = flow.delete_suffix(FLOW_SUFFIX)
25
+ provider if MiniAppProvider::Base.providers.map(&:provider).include?(provider)
26
+ rescue Doorkeeper::Errors::DoorkeeperError
27
+ # Params no flow recognises: the login fails on its own further down, with its own message.
28
+ nil
29
+ end
30
+ private_class_method :provider_of
31
+ end
32
+ end
@@ -6,6 +6,8 @@
6
6
  # - outbound_legs: [Array<SpreeCmCommissioner::Transit::Leg>] Outbound transit legs (one for direct, multiple for connecting)
7
7
  # - inbound_legs: [Array<SpreeCmCommissioner::Transit::Leg>] Inbound transit legs (one for direct, multiple for connecting)
8
8
  # - user: [User] The user for whom the order is being created
9
+ # - oauth_application_id: [Integer, nil] The client app of the request's access token (nil without a token or
10
+ # from a path that has none), recorded on the order for promotions limited to one app.
9
11
  #
10
12
  # Each line item is built with precise from_date and to_date based on legs and trip stop times.
11
13
  # Each line item has guests built corresponding to seat selection blocks.
@@ -17,13 +19,13 @@ module SpreeCmCommissioner
17
19
  prepend ::Spree::ServiceModule::Base
18
20
 
19
21
  def call(outbound_date:, inbound_date:, outbound_legs:, inbound_legs: [], user: nil, created_by: nil, # rubocop:disable Metrics/ParameterLists
20
- include_return: false, channel: SpreeCmCommissioner::OrderChannelBitwise::SPREE_CHANNEL
22
+ include_return: false, channel: SpreeCmCommissioner::OrderChannelBitwise::SPREE_CHANNEL, oauth_application_id: nil
21
23
  )
22
24
  return failure(nil, 'Outbound legs are missing') if outbound_legs.blank?
23
25
 
24
26
  begin
25
27
  order = create_order!(outbound_date, inbound_date, outbound_legs, inbound_legs, user, include_return,
26
- created_by: created_by, channel: channel
28
+ created_by: created_by, channel: channel, oauth_application_id: oauth_application_id
27
29
  )
28
30
  success(order: order)
29
31
  rescue StandardError => e
@@ -32,9 +34,12 @@ module SpreeCmCommissioner
32
34
  end
33
35
 
34
36
  def create_order!(outbound_date, inbound_date, outbound_legs, inbound_legs, user, include_return, # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/ParameterLists
35
- created_by: nil, channel: SpreeCmCommissioner::OrderChannelBitwise::SPREE_CHANNEL
37
+ created_by: nil, channel: SpreeCmCommissioner::OrderChannelBitwise::SPREE_CHANNEL, oauth_application_id: nil
36
38
  )
37
- order = Spree::Order.new(state: 'cart', use_billing: true, user: user, created_by: created_by, channel: channel)
39
+ order = Spree::Order.new(
40
+ state: 'cart', use_billing: true, user: user, created_by: created_by, channel: channel,
41
+ oauth_application_id: oauth_application_id
42
+ )
38
43
 
39
44
  all_legs = outbound_legs + inbound_legs
40
45
  preload_trips_and_stops!(all_legs)
@@ -0,0 +1,20 @@
1
+ <div class="card-body">
2
+ <div class="row mb-0">
3
+ <div class="form-group col-6 mb-0">
4
+ <%= label_tag "#{param_prefix}_preferred_min_order_number", Spree.t('customer_order_count_rule.from') %>
5
+ <%= number_field_tag "#{param_prefix}[preferred_min_order_number]", promotion_rule.min_order_number,
6
+ min: 1, step: 1, class: 'form-control', id: "#{param_prefix}_preferred_min_order_number" %>
7
+ </div>
8
+ <div class="form-group col-6 mb-0">
9
+ <%= label_tag "#{param_prefix}_preferred_max_order_number", Spree.t('customer_order_count_rule.to') %>
10
+ <%= number_field_tag "#{param_prefix}[preferred_max_order_number]", promotion_rule.max_order_number,
11
+ min: 1, step: 1, class: 'form-control', id: "#{param_prefix}_preferred_max_order_number" %>
12
+ </div>
13
+ </div>
14
+ <div class="mt-3">
15
+ <%= render 'spree/admin/promotions/rules/oauth_application_select',
16
+ param_prefix: param_prefix, promotion_rule: promotion_rule,
17
+ label: Spree.t('customer_order_count_rule.oauth_applications'),
18
+ hint: Spree.t('customer_order_count_rule.oauth_applications_hint') %>
19
+ </div>
20
+ </div>
@@ -0,0 +1,15 @@
1
+ <%# Labelled with the tenant or mini-app provider, because app names alone ("Client app",
2
+ "Storefront") do not say which channel an admin is picking.
3
+ Locals: param_prefix, promotion_rule, label, hint. %>
4
+ <% applications = Spree::OauthApplication.includes(:tenant).order(:name) %>
5
+ <div class="form-group mb-0">
6
+ <%= label_tag "#{param_prefix}_oauth_application_ids", label %>
7
+ <%= select_tag("#{param_prefix}[oauth_application_ids][]",
8
+ options_for_select(
9
+ applications.map { |app| [[app.name, app.tenant&.name || app.provider.presence].compact.join(' — '), app.id] },
10
+ promotion_rule.oauth_application_ids
11
+ ),
12
+ { class: 'select2 form-control', multiple: true, id: "#{param_prefix}_oauth_application_ids" }
13
+ ) %>
14
+ <small class="form-text text-muted"><%= hint %></small>
15
+ </div>
@@ -0,0 +1,6 @@
1
+ <div class="card-body">
2
+ <%= render 'spree/admin/promotions/rules/oauth_application_select',
3
+ param_prefix: param_prefix, promotion_rule: promotion_rule,
4
+ label: Spree.t('oauth_applications_rule.choose_oauth_applications'),
5
+ hint: Spree.t('oauth_applications_rule.hint') %>
6
+ </div>
@@ -424,6 +424,12 @@ en:
424
424
  guest_age_group:
425
425
  name: "Guest Age Group"
426
426
  description: "Guest Age Group"
427
+ oauth_applications:
428
+ name: "OAuth Applications"
429
+ description: "Order must be placed through one of the selected apps (tenant app or mini app)"
430
+ customer_order_count:
431
+ name: "Customer Order Count"
432
+ description: "Order must be the customer's Nth booking (e.g. 2nd only, or 5th and later)"
427
433
  customers:
428
434
  name: "Customers"
429
435
  description: "Customers"
@@ -464,6 +470,19 @@ en:
464
470
  choose_guest_age_group: "Choose Guest Age Group"
465
471
  match_any: "Some line items must from selected age group"
466
472
  match_all: "All line items must from selected age group"
473
+ oauth_applications_rule:
474
+ choose_oauth_applications: "Choose apps"
475
+ hint: "Leave empty and the promotion applies to no app."
476
+ customer_order_count_rule:
477
+ from: "Eligible from the customer's booking number"
478
+ to: "Up to booking number (empty = no limit)"
479
+ oauth_applications: "Count only bookings from these apps"
480
+ oauth_applications_hint: "Empty = bookings from every app are counted."
481
+ eligibility_errors:
482
+ messages:
483
+ oauth_application_not_eligible: "This coupon is for a special offer and can't be used here."
484
+ customer_order_count_login_required: "Please log in to use this promotion."
485
+ customer_order_count_not_eligible: "This promotion is not available for this booking."
467
486
  billing:
468
487
  six_month_discount: "6 Months discount"
469
488
  twelve_month_discount: "12 Months discount"
@@ -940,6 +959,11 @@ en:
940
959
  place: "Place"
941
960
  errors:
942
961
  models:
962
+ spree_cm_commissioner/promotion/rules/customer_order_count:
963
+ attributes:
964
+ base:
965
+ customer_order_count_min_too_low: "'Eligible from the customer's booking number' must be 1 or more."
966
+ customer_order_count_max_below_min: "'Up to booking number' must be empty or not less than the 'from' number."
943
967
  spree_cm_commissioner/reschedule_policy:
944
968
  attributes:
945
969
  base:
@@ -368,6 +368,12 @@ km:
368
368
  guest_age_group:
369
369
  name: "Guest Age Group"
370
370
  description: "Guest Age Group"
371
+ oauth_applications:
372
+ name: "OAuth Applications"
373
+ description: "Order must be placed through one of the selected apps (tenant app or mini app)"
374
+ customer_order_count:
375
+ name: "Customer Order Count"
376
+ description: "Order must be the customer's Nth booking (e.g. 2nd only, or 5th and later)"
371
377
  promotion_action_types:
372
378
  create_date_specific_item_adjustments:
373
379
  name: "Create date specific item adjustment"
@@ -402,6 +408,19 @@ km:
402
408
  choose_guest_age_group: "Choose Guest Age Group"
403
409
  match_any: "Some line items must from selected age group"
404
410
  match_all: "All line items must from selected age group"
411
+ oauth_applications_rule:
412
+ choose_oauth_applications: "Choose apps"
413
+ hint: "Leave empty and the promotion applies to no app."
414
+ customer_order_count_rule:
415
+ from: "Eligible from the customer's booking number"
416
+ to: "Up to booking number (empty = no limit)"
417
+ oauth_applications: "Count only bookings from these apps"
418
+ oauth_applications_hint: "Empty = bookings from every app are counted."
419
+ eligibility_errors:
420
+ messages:
421
+ oauth_application_not_eligible: "គូប៉ុងនេះសម្រាប់ការផ្តល់ជូនពិសេស ហើយមិនអាចប្រើនៅទីនេះបានទេ។"
422
+ customer_order_count_login_required: "Please log in to use this promotion."
423
+ customer_order_count_not_eligible: "This promotion is not available for this booking."
405
424
  billing:
406
425
  six_month_discount: "Six Month discount"
407
426
  twelve_month_discount: "Twelve Month discount"
@@ -555,6 +574,11 @@ km:
555
574
  cashier: "អ្នកគិតលុយ"
556
575
  errors:
557
576
  models:
577
+ spree_cm_commissioner/promotion/rules/customer_order_count:
578
+ attributes:
579
+ base:
580
+ customer_order_count_min_too_low: "'Eligible from the customer's booking number' must be 1 or more."
581
+ customer_order_count_max_below_min: "'Up to booking number' must be empty or not less than the 'from' number."
558
582
  spree_cm_commissioner/inventory_item:
559
583
  attributes:
560
584
  variant_id:
@@ -0,0 +1,16 @@
1
+ class CreateCmOauthApplicationPromotionRules < ActiveRecord::Migration[7.0]
2
+ def change
3
+ create_table :cm_oauth_application_promotion_rules, if_not_exists: true do |t|
4
+ t.references :promotion_rule, null: false, foreign_key: { to_table: :spree_promotion_rules, on_delete: :cascade }, index: false
5
+ t.references :oauth_application, null: false, foreign_key: { to_table: :spree_oauth_applications, on_delete: :cascade },
6
+ index: { name: 'idx_cm_oauth_app_promotion_rules_on_app' }
7
+
8
+ t.timestamps
9
+ end
10
+
11
+ # One row per rule and app; also serves "which apps does this rule use". The index on
12
+ # oauth_application_id above serves the report direction, "which promotions use this app".
13
+ add_index :cm_oauth_application_promotion_rules, %i[promotion_rule_id oauth_application_id],
14
+ unique: true, name: 'idx_cm_oauth_app_promotion_rules_on_rule_and_app', if_not_exists: true
15
+ end
16
+ end
@@ -31,7 +31,9 @@ module SpreeCmCommissioner
31
31
  SpreeCmCommissioner::Promotion::Rules::Weekend,
32
32
  SpreeCmCommissioner::Promotion::Rules::CustomDates,
33
33
  SpreeCmCommissioner::Promotion::Rules::GuestOccupations,
34
- SpreeCmCommissioner::Promotion::Rules::GuestAgeGroup
34
+ SpreeCmCommissioner::Promotion::Rules::GuestAgeGroup,
35
+ SpreeCmCommissioner::Promotion::Rules::OauthApplications,
36
+ SpreeCmCommissioner::Promotion::Rules::CustomerOrderCount
35
37
  ]
36
38
 
37
39
  Rails.application.config.spree.promotions.actions.concat [
@@ -1,5 +1,5 @@
1
1
  module SpreeCmCommissioner
2
- VERSION = '2.15.0'.freeze
2
+ VERSION = '2.15.1'.freeze
3
3
 
4
4
  module_function
5
5
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spree_cm_commissioner
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.15.0
4
+ version: 2.15.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - You
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-11-10 00:00:00.000000000 Z
11
+ date: 2026-11-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: spree
@@ -1514,6 +1514,7 @@ files:
1514
1514
  - app/models/concerns/spree_cm_commissioner/product_delegation.rb
1515
1515
  - app/models/concerns/spree_cm_commissioner/product_relation_type.rb
1516
1516
  - app/models/concerns/spree_cm_commissioner/product_type.rb
1517
+ - app/models/concerns/spree_cm_commissioner/promotion/rules/oauth_application_scoped.rb
1517
1518
  - app/models/concerns/spree_cm_commissioner/purchase_limitable.rb
1518
1519
  - app/models/concerns/spree_cm_commissioner/rate_plan_options_concern.rb
1519
1520
  - app/models/concerns/spree_cm_commissioner/reschedule_request_state_machine.rb
@@ -1665,6 +1666,7 @@ files:
1665
1666
  - app/models/spree_cm_commissioner/notification_taxon.rb
1666
1667
  - app/models/spree_cm_commissioner/oauth_access_token_decorator.rb
1667
1668
  - app/models/spree_cm_commissioner/oauth_application_decorator.rb
1669
+ - app/models/spree_cm_commissioner/oauth_application_promotion_rule.rb
1668
1670
  - app/models/spree_cm_commissioner/option_type_decorator.rb
1669
1671
  - app/models/spree_cm_commissioner/option_value_decorator.rb
1670
1672
  - app/models/spree_cm_commissioner/option_value_rate_plan.rb
@@ -1724,12 +1726,14 @@ files:
1724
1726
  - app/models/spree_cm_commissioner/promotion/actions/create_date_specific_item_adjustments.rb
1725
1727
  - app/models/spree_cm_commissioner/promotion/actions/create_guest_item_adjustments.rb
1726
1728
  - app/models/spree_cm_commissioner/promotion/rules/custom_dates.rb
1729
+ - app/models/spree_cm_commissioner/promotion/rules/customer_order_count.rb
1727
1730
  - app/models/spree_cm_commissioner/promotion/rules/customers.rb
1728
1731
  - app/models/spree_cm_commissioner/promotion/rules/date.rb
1729
1732
  - app/models/spree_cm_commissioner/promotion/rules/fixed_date.rb
1730
1733
  - app/models/spree_cm_commissioner/promotion/rules/guest.rb
1731
1734
  - app/models/spree_cm_commissioner/promotion/rules/guest_age_group.rb
1732
1735
  - app/models/spree_cm_commissioner/promotion/rules/guest_occupations.rb
1736
+ - app/models/spree_cm_commissioner/promotion/rules/oauth_applications.rb
1733
1737
  - app/models/spree_cm_commissioner/promotion/rules/vendors.rb
1734
1738
  - app/models/spree_cm_commissioner/promotion/rules/weekend.rb
1735
1739
  - app/models/spree_cm_commissioner/promotion_category_decorator.rb
@@ -2363,6 +2367,8 @@ files:
2363
2367
  - app/services/spree_cm_commissioner/customer_notifications/clone.rb
2364
2368
  - app/services/spree_cm_commissioner/distribution_agreements/create.rb
2365
2369
  - app/services/spree_cm_commissioner/distribution_agreements/update.rb
2370
+ - app/services/spree_cm_commissioner/doorkeeper_refresh_token_request_decorator.rb
2371
+ - app/services/spree_cm_commissioner/doorkeeper_server_decorator.rb
2366
2372
  - app/services/spree_cm_commissioner/draft_orders/create.rb
2367
2373
  - app/services/spree_cm_commissioner/events/clone.rb
2368
2374
  - app/services/spree_cm_commissioner/events/publish_stock_statuses_to_firestore.rb
@@ -2553,6 +2559,7 @@ files:
2553
2559
  - app/services/spree_cm_commissioner/load_test/generate_booking_ticket_data.rb
2554
2560
  - app/services/spree_cm_commissioner/load_test/generate_voting_fixture_data.rb
2555
2561
  - app/services/spree_cm_commissioner/load_test/login_token.rb
2562
+ - app/services/spree_cm_commissioner/mini_app_login_application.rb
2556
2563
  - app/services/spree_cm_commissioner/mini_app_provider/api_key_history.rb
2557
2564
  - app/services/spree_cm_commissioner/mini_app_provider/ip_rules.rb
2558
2565
  - app/services/spree_cm_commissioner/mini_app_provider/session_gate.rb
@@ -2959,10 +2966,13 @@ files:
2959
2966
  - app/views/spree/admin/promotions/actions/_create_date_specific_item_adjustments.html.erb
2960
2967
  - app/views/spree/admin/promotions/actions/_create_guest_item_adjustments.html.erb
2961
2968
  - app/views/spree/admin/promotions/rules/_custom_dates.html.erb
2969
+ - app/views/spree/admin/promotions/rules/_customer_order_count.html.erb
2962
2970
  - app/views/spree/admin/promotions/rules/_customers.html.erb
2963
2971
  - app/views/spree/admin/promotions/rules/_fixed_date.html.erb
2964
2972
  - app/views/spree/admin/promotions/rules/_guest_age_group.html.erb
2965
2973
  - app/views/spree/admin/promotions/rules/_guest_occupations.html.erb
2974
+ - app/views/spree/admin/promotions/rules/_oauth_application_select.html.erb
2975
+ - app/views/spree/admin/promotions/rules/_oauth_applications.html.erb
2966
2976
  - app/views/spree/admin/promotions/rules/_product.html.erb
2967
2977
  - app/views/spree/admin/promotions/rules/_user.html.erb
2968
2978
  - app/views/spree/admin/promotions/rules/_vendors.html.erb
@@ -3789,6 +3799,7 @@ files:
3789
3799
  - db/migrate/20261105000002_add_index_to_spree_orders_waiting_room_session_id.rb
3790
3800
  - db/migrate/20261105000003_add_queue_fields_to_cm_waiting_room_sessions.rb
3791
3801
  - db/migrate/20261105000004_add_oauth_application_id_to_spree_orders.rb
3802
+ - db/migrate/20261110000001_create_cm_oauth_application_promotion_rules.rb
3792
3803
  - docker-compose.yml
3793
3804
  - docs/api/scoped-access-token-endpoints.md
3794
3805
  - docs/option_types/attr_types.md