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 +4 -4
- data/Gemfile.lock +1 -1
- data/app/controllers/spree/api/v2/storefront/transit/draft_orders_controller.rb +2 -1
- data/app/controllers/spree/api/v2/tenant/transit/draft_orders_controller.rb +2 -1
- data/app/models/concerns/spree_cm_commissioner/promotion/rules/oauth_application_scoped.rb +44 -0
- data/app/models/spree_cm_commissioner/oauth_application_promotion_rule.rb +8 -0
- data/app/models/spree_cm_commissioner/promotion/rules/customer_order_count.rb +87 -0
- data/app/models/spree_cm_commissioner/promotion/rules/oauth_applications.rb +34 -0
- data/app/services/spree_cm_commissioner/doorkeeper_refresh_token_request_decorator.rb +23 -0
- data/app/services/spree_cm_commissioner/doorkeeper_server_decorator.rb +28 -0
- data/app/services/spree_cm_commissioner/mini_app_login_application.rb +32 -0
- data/app/services/spree_cm_commissioner/transit_order/create.rb +9 -4
- data/app/views/spree/admin/promotions/rules/_customer_order_count.html.erb +20 -0
- data/app/views/spree/admin/promotions/rules/_oauth_application_select.html.erb +15 -0
- data/app/views/spree/admin/promotions/rules/_oauth_applications.html.erb +6 -0
- data/config/locales/en.yml +24 -0
- data/config/locales/km.yml +24 -0
- data/db/migrate/20261110000001_create_cm_oauth_application_promotion_rules.rb +16 -0
- data/lib/spree_cm_commissioner/engine.rb +3 -1
- data/lib/spree_cm_commissioner/version.rb +1 -1
- metadata +13 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7cd929df06e0243dc4cf2b0b4ffc57d3346ced9f9d0cb5bdd3142d6adc153a88
|
|
4
|
+
data.tar.gz: 29a71dceac61e9e6497137c525ff9e76ace8ca1a179f0dc6eab157fd83a30a4d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2bd7890db6844d7057a5e89f8e2cd0285e387772aaccba0ba2806aee4b7f78de3cea566402fb5a97279613bae6be342bbef2b55e2d185c82d8d85fec59401520
|
|
7
|
+
data.tar.gz: 682f9b46dae492ff3f0bdb9f558dccc3185ec4b9a6a0770e4fca4146ecd496e90577b97c049a9a171f7ab6ac76486fc14d4cdefe6bf7098ec96de9b8c09c170b
|
data/Gemfile.lock
CHANGED
|
@@ -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(
|
|
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>
|
data/config/locales/en.yml
CHANGED
|
@@ -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:
|
data/config/locales/km.yml
CHANGED
|
@@ -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 [
|
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.
|
|
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-
|
|
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
|