spree_cm_commissioner 2.13.7 → 2.14.0

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 (208) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/app/assets/javascripts/spree_cm_commissioner/backend.js +0 -1
  4. data/app/assets/stylesheets/spree_cm_commissioner/backend/calendar.scss +0 -39
  5. data/app/controllers/concerns/spree/admin/rates_availability_cells.rb +16 -0
  6. data/app/controllers/concerns/spree/api/v2/product_list_includes_decorator.rb +1 -0
  7. data/app/controllers/spree/admin/guests_controller.rb +2 -1
  8. data/app/controllers/spree/admin/integrations_controller.rb +16 -7
  9. data/app/controllers/spree/admin/inventory_items_controller.rb +33 -62
  10. data/app/controllers/spree/admin/product_option_types_controller.rb +88 -0
  11. data/app/controllers/spree/admin/rate_plans_controller.rb +125 -0
  12. data/app/controllers/spree/admin/rates_availabilities_controller.rb +142 -0
  13. data/app/controllers/spree/admin/stock_managements_controller.rb +1 -53
  14. data/app/controllers/spree/api/switch_cm/base_controller.rb +109 -0
  15. data/app/controllers/spree/api/switch_cm/tokens_controller.rb +68 -0
  16. data/app/controllers/spree/api/switch_cm/xml/availabilities_controller.rb +39 -0
  17. data/app/controllers/spree/api/switch_cm/xml/base_controller.rb +37 -0
  18. data/app/controllers/spree/api/switch_cm/xml/reservation_summaries_controller.rb +28 -0
  19. data/app/controllers/spree/api/switch_cm/xml/reservations_controller.rb +57 -0
  20. data/app/controllers/spree/api/switch_cm/xml/room_rate_availabilities_controller.rb +56 -0
  21. data/app/controllers/spree/api/switch_cm/xml/room_rates_controller.rb +45 -0
  22. data/app/controllers/spree/api/v2/operator/guests_controller.rb +1 -1
  23. data/app/controllers/spree/api/v2/storefront/accommodations/variants_controller.rb +31 -5
  24. data/app/controllers/spree/api/v2/storefront/inventory_items_controller.rb +2 -2
  25. data/app/controllers/spree/api/v2/tenant/intercity_taxi/draft_orders_controller.rb +0 -1
  26. data/app/factory/spree_cm_commissioner/order_telegram_message_factory.rb +1 -1
  27. data/app/finders/spree_cm_commissioner/accommodations/find.rb +10 -8
  28. data/app/finders/spree_cm_commissioner/accommodations/find_variant.rb +5 -11
  29. data/app/finders/spree_cm_commissioner/rates_availabilities/find_bookings.rb +116 -0
  30. data/app/helpers/spree_cm_commissioner/admin/inventory_items_helper.rb +36 -0
  31. data/app/helpers/spree_cm_commissioner/admin/rates_availabilities_helper.rb +247 -0
  32. data/app/interactors/spree_cm_commissioner/intercity_taxi_order_creator.rb +0 -1
  33. data/app/jobs/spree_cm_commissioner/option_type_variants_public_metadata_updater_job.rb +1 -0
  34. data/app/jobs/spree_cm_commissioner/option_value_variants_public_metadata_updater_job.rb +1 -0
  35. data/app/jobs/spree_cm_commissioner/product_variants_public_metadata_updater_job.rb +12 -0
  36. data/app/models/concerns/spree_cm_commissioner/kyc_bitwise.rb +4 -0
  37. data/app/models/concerns/spree_cm_commissioner/line_item_durationable.rb +1 -10
  38. data/app/models/concerns/spree_cm_commissioner/line_item_guests_concern.rb +33 -3
  39. data/app/models/concerns/spree_cm_commissioner/line_item_rate_plannable.rb +55 -0
  40. data/app/models/concerns/spree_cm_commissioner/line_item_transitable.rb +0 -16
  41. data/app/models/concerns/spree_cm_commissioner/option_type_attr_type.rb +22 -4
  42. data/app/models/concerns/spree_cm_commissioner/rate_plan_options_concern.rb +62 -0
  43. data/app/models/concerns/spree_cm_commissioner/variant_options_concern.rb +12 -0
  44. data/app/models/concerns/spree_cm_commissioner/variant_rate_plannable.rb +81 -0
  45. data/app/models/spree_cm_commissioner/address_decorator.rb +2 -0
  46. data/app/models/spree_cm_commissioner/configuration.rb +0 -6
  47. data/app/models/spree_cm_commissioner/exports/import_order_guests_csv.rb +2 -1
  48. data/app/models/spree_cm_commissioner/exports/operator_guest_json_gzip.rb +8 -2
  49. data/app/models/spree_cm_commissioner/guest.rb +1 -1
  50. data/app/models/spree_cm_commissioner/integration.rb +15 -0
  51. data/app/models/spree_cm_commissioner/integrations/switch_cm.rb +96 -0
  52. data/app/models/spree_cm_commissioner/inventory_item.rb +32 -3
  53. data/app/models/spree_cm_commissioner/line_item_decorator.rb +7 -7
  54. data/app/models/spree_cm_commissioner/maintenance_tasks/cache_invalidation.rb +20 -4
  55. data/app/models/spree_cm_commissioner/maintenance_tasks/event.rb +3 -2
  56. data/app/models/spree_cm_commissioner/option_type_decorator.rb +2 -1
  57. data/app/models/spree_cm_commissioner/option_value_decorator.rb +3 -0
  58. data/app/models/spree_cm_commissioner/option_value_rate_plan.rb +12 -0
  59. data/app/models/spree_cm_commissioner/order_decorator.rb +1 -0
  60. data/app/models/spree_cm_commissioner/price_decorator.rb +13 -0
  61. data/app/models/spree_cm_commissioner/product_decorator.rb +18 -2
  62. data/app/models/spree_cm_commissioner/product_option_type_decorator.rb +42 -0
  63. data/app/models/spree_cm_commissioner/rate_plan.rb +139 -0
  64. data/app/models/spree_cm_commissioner/rate_plan_options.rb +36 -0
  65. data/app/models/spree_cm_commissioner/rate_restriction.rb +45 -0
  66. data/app/models/spree_cm_commissioner/taxon_decorator.rb +5 -2
  67. data/app/models/spree_cm_commissioner/variant_decorator.rb +36 -5
  68. data/app/models/spree_cm_commissioner/variant_options.rb +3 -2
  69. data/app/models/spree_cm_commissioner/vendor_decorator.rb +1 -0
  70. data/app/overrides/spree/admin/option_types/_form/checkbox.html.erb.deface +0 -8
  71. data/app/overrides/spree/admin/option_types/_form/name.html.erb.deface +1 -1
  72. data/app/overrides/spree/admin/products/_filters/vendor_filter.html.erb.deface +6 -0
  73. data/app/overrides/spree/admin/shared/_product_tabs/rates_availabilities.html.erb.deface +8 -0
  74. data/app/overrides/spree/admin/shared/_product_tabs/stock_managements.html.erb.deface +1 -1
  75. data/app/overrides/spree/admin/variants/index/discontinued_badge.html.erb.deface +13 -0
  76. data/app/overrides/spree/admin/variants/index/option_types_empty_state_link.html.erb.deface +3 -0
  77. data/app/overrides/spree/admin/variants/index/option_types_summary.html.erb.deface +24 -0
  78. data/app/presenters/spree/variants/visible_options_presenter.rb +34 -1
  79. data/app/queries/spree_cm_commissioner/multi_leg_trips_query.rb +40 -22
  80. data/app/queries/spree_cm_commissioner/single_leg_trips_query.rb +28 -14
  81. data/app/request_schemas/spree_cm_commissioner/intercity_taxi_draft_order_update_schema.rb +0 -1
  82. data/app/serializers/spree/v2/storefront/line_item_serializer_decorator.rb +2 -0
  83. data/app/serializers/spree/v2/storefront/option_type_serializer_decorator.rb +1 -1
  84. data/app/serializers/spree/v2/storefront/product_option_type_serializer.rb +15 -0
  85. data/app/serializers/spree/v2/storefront/product_serializer_decorator.rb +15 -0
  86. data/app/serializers/spree/v2/tenant/line_item_serializer.rb +1 -1
  87. data/app/serializers/spree/v2/tenant/option_type_serializer.rb +1 -1
  88. data/app/serializers/spree_cm_commissioner/v2/operator/dynamic_field_option_serializer.rb +9 -0
  89. data/app/serializers/spree_cm_commissioner/v2/operator/dynamic_field_serializer.rb +11 -0
  90. data/app/serializers/spree_cm_commissioner/v2/operator/guest_dynamic_field_serializer.rb +14 -0
  91. data/app/serializers/spree_cm_commissioner/v2/operator/guest_serializer.rb +1 -0
  92. data/app/serializers/spree_cm_commissioner/v2/storefront/accommodation_variant_serializer.rb +44 -0
  93. data/app/serializers/spree_cm_commissioner/v2/storefront/inventory_item_serializer.rb +4 -4
  94. data/app/serializers/spree_cm_commissioner/v2/storefront/rate_plan_serializer.rb +16 -0
  95. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_item_availability.rb +1 -1
  96. data/app/services/spree_cm_commissioner/integrations/larryta/polling/sync_item_availability.rb +1 -1
  97. data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/accept_push.rb +111 -0
  98. data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/accommodation_lock.rb +43 -0
  99. data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/apply_updates.rb +133 -0
  100. data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/availability_snapshot.rb +127 -0
  101. data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/inventory_update.rb +35 -0
  102. data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/pushed_nights.rb +82 -0
  103. data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/unseen_room_nights.rb +49 -0
  104. data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/validate_updates.rb +153 -0
  105. data/app/services/spree_cm_commissioner/integrations/switch_cm/reservations/feed.rb +209 -0
  106. data/app/services/spree_cm_commissioner/integrations/switch_cm/reservations/mark_seen.rb +62 -0
  107. data/app/services/spree_cm_commissioner/integrations/switch_cm/reservations/reservation.rb +171 -0
  108. data/app/services/spree_cm_commissioner/integrations/switch_cm/room_catalog.rb +88 -0
  109. data/app/services/spree_cm_commissioner/integrations/switch_cm/sync_manager.rb +16 -0
  110. data/app/services/spree_cm_commissioner/integrations/switch_cm/sync_strategies/full_sync_strategy.rb +39 -0
  111. data/app/services/spree_cm_commissioner/integrations/switch_cm/sync_strategies/incremental_sync_strategy.rb +51 -0
  112. data/app/services/spree_cm_commissioner/integrations/switch_cm/xml/availability_parser.rb +103 -0
  113. data/app/services/spree_cm_commissioner/integrations/switch_cm/xml/push_response.rb +45 -0
  114. data/app/services/spree_cm_commissioner/integrations/switch_cm/xml/reservation_serializer.rb +83 -0
  115. data/app/services/spree_cm_commissioner/integrations/switch_cm/xml/response_builder.rb +19 -0
  116. data/app/services/spree_cm_commissioner/integrations/vireak_buntham/polling/sync_item_availability.rb +1 -1
  117. data/app/services/spree_cm_commissioner/intercity_taxi_order/update.rb +1 -3
  118. data/app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_by_variant.rb +4 -0
  119. data/app/services/spree_cm_commissioner/inventory_items/reset.rb +4 -0
  120. data/app/services/spree_cm_commissioner/rate_plans/save.rb +65 -0
  121. data/app/services/spree_cm_commissioner/rates_availabilities/bulk_update.rb +225 -0
  122. data/app/services/spree_cm_commissioner/reschedule_requests/split_line_item.rb +5 -0
  123. data/app/services/spree_cm_commissioner/room_types/create.rb +87 -0
  124. data/app/services/spree_cm_commissioner/room_types/update_properties.rb +32 -0
  125. data/app/services/spree_cm_commissioner/room_types.rb +40 -0
  126. data/app/services/spree_cm_commissioner/route_prices/update_price_range.rb +3 -1
  127. data/app/services/spree_cm_commissioner/trips/search.rb +14 -7
  128. data/app/services/spree_cm_commissioner/trips/update_single_leg.rb +5 -1
  129. data/app/views/spree/admin/guests/_check_in_status.html.erb +28 -22
  130. data/app/views/spree/admin/guests/edit.html.erb +29 -26
  131. data/app/views/spree/admin/integrations/_form.html.erb +4 -0
  132. data/app/views/spree/admin/integrations/_switch_cm_fields.html.erb +53 -0
  133. data/app/views/spree/admin/inventory_items/_details.html.erb +56 -0
  134. data/app/views/spree/admin/product_option_types/index.html.erb +79 -0
  135. data/app/views/spree/admin/rate_plans/_form.html.erb +134 -0
  136. data/app/views/spree/admin/rate_plans/_order_form.html.erb +37 -0
  137. data/app/views/spree/admin/rates_availabilities/_block.html.erb +34 -0
  138. data/app/views/spree/admin/rates_availabilities/_bookings.html.erb +52 -0
  139. data/app/views/spree/admin/rates_availabilities/_filters.html.erb +48 -0
  140. data/app/views/spree/admin/rates_availabilities/_legend.html.erb +27 -0
  141. data/app/views/spree/admin/rates_availabilities/_list_pages.html.erb +14 -0
  142. data/app/views/spree/admin/rates_availabilities/_modal_header.html.erb +11 -0
  143. data/app/views/spree/admin/rates_availabilities/_names.html.erb +62 -0
  144. data/app/views/spree/admin/rates_availabilities/_script.html.erb +676 -0
  145. data/app/views/spree/admin/rates_availabilities/_styles.html.erb +159 -0
  146. data/app/views/spree/admin/rates_availabilities/index.html.erb +112 -0
  147. data/app/views/spree/admin/shared/sub_menu/_homepage.html.erb +0 -1
  148. data/app/views/spree/admin/stock_managements/_header.html.erb +0 -14
  149. data/app/views/spree/admin/stock_managements/_inventory_item_stats.html.erb +28 -0
  150. data/app/views/spree/admin/stock_managements/index.html.erb +1 -56
  151. data/config/initializers/spree_permitted_attributes.rb +1 -0
  152. data/config/locales/en.yml +241 -3
  153. data/config/locales/km.yml +241 -4
  154. data/config/routes.rb +36 -17
  155. data/db/migrate/20260928000000_add_oauth_application_id_to_cm_integrations.rb +5 -0
  156. data/db/migrate/20260929000000_create_cm_rate_plans.rb +22 -0
  157. data/db/migrate/20260929000001_add_rate_plan_to_spree_prices.rb +24 -0
  158. data/db/migrate/20260929000002_add_rate_plan_to_spree_line_items.rb +7 -0
  159. data/db/migrate/20260929000003_create_cm_option_value_rate_plans.rb +16 -0
  160. data/db/migrate/20261009000000_create_cm_rate_restrictions.rb +29 -0
  161. data/db/migrate/20261013000001_add_hidden_and_presentation_to_spree_product_option_types.rb +6 -0
  162. data/db/migrate/20261013000002_move_hidden_from_spree_option_types_to_product_option_types.rb +36 -0
  163. data/db/migrate/20261016000001_add_index_to_spree_line_items_number.rb +8 -0
  164. data/lib/spree_cm_commissioner/booking_dates.rb +43 -0
  165. data/lib/spree_cm_commissioner/calendar_event.rb +0 -25
  166. data/lib/spree_cm_commissioner/rate.rb +74 -0
  167. data/lib/spree_cm_commissioner/rate_closings.rb +48 -0
  168. data/lib/spree_cm_commissioner/rates_availability_grid.rb +113 -0
  169. data/lib/spree_cm_commissioner/stay_price.rb +30 -0
  170. data/lib/spree_cm_commissioner/stay_prices.rb +58 -0
  171. data/lib/spree_cm_commissioner/test_helper/factories/integration_factory.rb +6 -0
  172. data/lib/spree_cm_commissioner/test_helper/factories/option_type_factory.rb +6 -0
  173. data/lib/spree_cm_commissioner/test_helper/factories/option_value_rate_plan_factory.rb +6 -0
  174. data/lib/spree_cm_commissioner/test_helper/factories/rate_plan_factory.rb +14 -0
  175. data/lib/spree_cm_commissioner/test_helper/factories/variant_factory.rb +9 -0
  176. data/lib/spree_cm_commissioner/version.rb +1 -1
  177. data/lib/spree_cm_commissioner.rb +6 -0
  178. metadata +94 -32
  179. data/app/assets/javascripts/spree_cm_commissioner/calendar.js +0 -59
  180. data/app/controllers/spree/admin/calendars/base_controller.rb +0 -27
  181. data/app/controllers/spree/admin/calendars/orders_controller.rb +0 -27
  182. data/app/controllers/spree/admin/homepage_feed_controller.rb +0 -36
  183. data/app/controllers/spree/admin/metafields_controller.rb +0 -69
  184. data/app/models/concerns/spree_cm_commissioner/metafield.rb +0 -15
  185. data/app/models/spree_cm_commissioner/homepage_feed.rb +0 -4
  186. data/app/overrides/spree/admin/shared/_main_menu/sidebar_calendars.html.erb.deface +0 -5
  187. data/app/overrides/spree/admin/shared/_product_tabs/metafields.html.erb.deface +0 -15
  188. data/app/serializers/spree_cm_commissioner/v2/storefront/taxon_include_product_serializer.rb +0 -13
  189. data/app/serializers/spree_cm_commissioner/v2/storefront/vendor_include_product_serializer.rb +0 -14
  190. data/app/services/spree_cm_commissioner/feed/taxon_include_product.rb +0 -20
  191. data/app/services/spree_cm_commissioner/feed/taxon_product.rb +0 -54
  192. data/app/services/spree_cm_commissioner/feed/vendor_include_product.rb +0 -20
  193. data/app/services/spree_cm_commissioner/feed/vendor_product.rb +0 -52
  194. data/app/services/spree_cm_commissioner/homepage_data_loader.rb +0 -112
  195. data/app/services/spree_cm_commissioner/metafields/product_metadata_service.rb +0 -32
  196. data/app/views/shared/_calendar.html.erb +0 -27
  197. data/app/views/shared/calendar/_order_popover.html.erb +0 -18
  198. data/app/views/shared/calendar/_orders_sidebar.html.erb +0 -41
  199. data/app/views/spree/admin/calendars/orders/index.html.erb +0 -22
  200. data/app/views/spree/admin/homepage_feed/_form.html.erb +0 -24
  201. data/app/views/spree/admin/homepage_feed/edit.html.erb +0 -17
  202. data/app/views/spree/admin/inventory_items/prices.html.erb +0 -43
  203. data/app/views/spree/admin/inventory_items/stocks.html.erb +0 -34
  204. data/app/views/spree/admin/metafields/_private_metafields_form.html.erb +0 -71
  205. data/app/views/spree/admin/metafields/_public_metafields_form.html.erb +0 -64
  206. data/app/views/spree/admin/metafields/edit.html.erb +0 -27
  207. data/app/views/spree/admin/shared/sub_menu/_calendar.html.erb +0 -3
  208. data/app/views/spree/admin/stock_managements/calendar.html.erb +0 -310
@@ -5,7 +5,7 @@ module Spree
5
5
 
6
6
  before_action :load_parent
7
7
 
8
- helper_method :inventory_item_message, :inventory_item_hold_message
8
+ helper SpreeCmCommissioner::Admin::InventoryItemsHelper
9
9
 
10
10
  def load_parent
11
11
  @product = Spree::Product.find_by(slug: params[:product_id])
@@ -40,58 +40,6 @@ module Spree
40
40
  redirect_back fallback_location: admin_product_stock_managements_path(@product)
41
41
  end
42
42
 
43
- # GET /products/:slug/stock_managements/calendar?year=&selected_option_value_ids[]=
44
- def calendar
45
- @year = params[:year].present? ? params[:year].to_i : Time.zone.today.year
46
-
47
- from_date = Date.new(@year, 1, 1).beginning_of_year
48
- to_date = Date.new(@year, 1, 1).end_of_year
49
-
50
- @variant_kind_option_types = Spree::Variants::OptionTypesFinder.new(variant_ids: @product.variants.ids)
51
- .execute.select(&:variant?)
52
- @selected_option_value_ids = Array(params[:selected_option_value_ids]).map(&:to_i)
53
-
54
- inventory_items = @product.inventory_items.includes(:variant, :prices).where(
55
- inventory_date: from_date..to_date
56
- )
57
-
58
- if @selected_option_value_ids.present?
59
- variant_ids = SpreeCmCommissioner::Variants::FindByOptionValueIds.new(
60
- product: @product, option_value_ids: @selected_option_value_ids
61
- ).execute.ids
62
- inventory_items = inventory_items.where(variant_id: variant_ids)
63
- @matched_variants_count = variant_ids.size
64
- else
65
- @matched_variants_count = @product.variants.count
66
- end
67
-
68
- @inventory_items = inventory_items.to_a
69
-
70
- @cached_inventory_items = ::SpreeCmCommissioner::RedisStock::CachedInventoryItemsBuilder.new(@inventory_items)
71
- .call
72
- .index_by(&:inventory_item_id)
73
- @events = SpreeCmCommissioner::CalendarEvent.from_inventory_items(@inventory_items)
74
- end
75
-
76
- def inventory_item_message(inventory_item, cached_inventory_item)
77
- synced = inventory_item.quantity_available == cached_inventory_item.quantity_available
78
-
79
- if synced
80
- "Synced: Quantity available matches in both DB and Redis (#{cached_inventory_item.quantity_available})."
81
- else
82
- "Out of sync: Redis shows #{cached_inventory_item.quantity_available} available, which doesn't match the database."
83
- end
84
- end
85
-
86
- def inventory_item_hold_message(inventory_item, cached_inventory_item)
87
- synced = inventory_item.quantity_on_hold == cached_inventory_item.quantity_on_hold
88
- if synced
89
- "Synced: Quantity on hold matches in both DB and Redis (#{cached_inventory_item.quantity_on_hold})."
90
- else
91
- "Out of sync: Redis shows #{cached_inventory_item.quantity_on_hold} on hold, which doesn't match the database."
92
- end
93
- end
94
-
95
43
  private
96
44
 
97
45
  def load_inventories
@@ -0,0 +1,109 @@
1
+ module Spree::Api::SwitchCm
2
+ # The Booking.com-compatible API channel managers (switch.cm) call under /api/cm.
3
+ # It's built on ActionController::API rather than Spree's v2 base, whose rescue handlers
4
+ # answer in JSON:API: a Booking.com connector parses every answer, errors included, as its
5
+ # family's XML, which Xml::BaseController renders.
6
+ #
7
+ # One Doorkeeper app per channel manager, presented as a Bearer token from the token
8
+ # exchange or as HTTP Basic with the app's client id and secret. Booking.com accepted Basic
9
+ # until 31 Dec 2025, and an older connector may still send it.
10
+ class BaseController < ActionController::API
11
+ include ActionController::HttpAuthentication::Basic::ControllerMethods
12
+
13
+ # Bodies are parsed whole and pushes applied in the request, so both are capped; the cap on
14
+ # updates is AcceptPush's.
15
+ MAX_BODY_BYTES = 1.megabyte
16
+
17
+ before_action :authenticate_switch_cm!
18
+ before_action :reject_oversized_body!
19
+
20
+ rescue_from Nokogiri::XML::SyntaxError do
21
+ render_fault(:bad_request, 'Request body is not well-formed XML')
22
+ end
23
+
24
+ private
25
+
26
+ def authenticate_switch_cm!
27
+ @oauth_application = application_from_bearer || application_from_basic
28
+ return if @oauth_application.present? && switch_cm_integrations.exists?
29
+
30
+ render_fault(:unauthorized, 'Authorization Required')
31
+ end
32
+
33
+ def reject_oversized_body!
34
+ return if request.raw_post.bytesize <= MAX_BODY_BYTES
35
+
36
+ render_fault(:payload_too_large, "Request body must not exceed #{MAX_BODY_BYTES / 1.megabyte} MB")
37
+ end
38
+
39
+ def application_from_bearer
40
+ doorkeeper_token.application if doorkeeper_token&.accessible?
41
+ end
42
+
43
+ def application_from_basic
44
+ authenticate_with_http_basic do |client_id, client_secret|
45
+ application = Spree::OauthApplication.find_by(uid: client_id)
46
+ application if application&.secret_matches?(client_secret)
47
+ end
48
+ end
49
+
50
+ def switch_cm_integrations
51
+ SpreeCmCommissioner::Integrations::SwitchCm.readable.where(oauth_application_id: @oauth_application.id)
52
+ end
53
+
54
+ # nil unless the requested hotel is one of this channel manager's connected hotels.
55
+ def current_integration
56
+ return @current_integration if defined?(@current_integration)
57
+
58
+ accommodation_id = accommodation_id_from_request
59
+ @current_integration = accommodation_id.present? ? switch_cm_integrations.find_by(vendor_id: accommodation_id) : nil
60
+ end
61
+
62
+ def require_integration!
63
+ render_fault(:forbidden, 'Not authorized for this hotel') if current_integration.nil?
64
+ end
65
+
66
+ # Pushes need an active connection: a paused one is still being set up, and the hotel keeps
67
+ # selling on its own counts until it's activated.
68
+ def require_active_integration!
69
+ return render_fault(:forbidden, 'Not authorized for this hotel') if current_integration.nil?
70
+
71
+ render_fault(:forbidden, 'This hotel is not active on BookMe+ yet') unless current_integration.active?
72
+ end
73
+
74
+ # Pushes may leave out the hotel id (B.XML never sends one); Booking.com derives it from the
75
+ # rooms. A room of another channel manager's hotel resolves to a hotel this app can't access.
76
+ def vendor_id_of_room(room_id)
77
+ Spree::Variant.where(id: room_id.to_i).pick(:vendor_id) if room_id.present?
78
+ end
79
+
80
+ # Reservation pulls take a comma-separated list of hotel ids, or none for all of this
81
+ # channel manager's hotels. Unknown ids come back separately, for a warning each, as at
82
+ # Booking.com; a list with no known hotel at all is refused.
83
+ def requested_integrations(hotel_ids)
84
+ ids = hotel_ids.to_s.split(',').map(&:strip).compact_blank
85
+ scope = switch_cm_integrations.includes(:vendor).order(:vendor_id)
86
+ return [scope.to_a, []] if ids.empty?
87
+
88
+ integrations = scope.where(vendor_id: ids).to_a
89
+ [integrations, ids - integrations.map { |integration| integration.vendor_id.to_s }]
90
+ end
91
+
92
+ def accommodation_id_from_request
93
+ raise NotImplementedError, "#{self.class.name} must implement accommodation_id_from_request"
94
+ end
95
+
96
+ def render_fault(_status, _text, code: nil)
97
+ raise NotImplementedError, "#{self.class.name} must implement render_fault"
98
+ end
99
+
100
+ # Rails parses neither XML nor text/xml bodies into params, so both families read the raw body.
101
+ def request_document
102
+ @request_document ||= Nokogiri::XML(request.raw_post) { |config| config.strict.nonet }.tap(&:remove_namespaces!)
103
+ end
104
+
105
+ def ruid
106
+ request.request_id
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,68 @@
1
+ module Spree::Api::SwitchCm
2
+ class TokensController < BaseController
3
+ # Booking.com's tokens also last an hour; connectors refresh on that schedule.
4
+ TOKEN_LIFETIME = 1.hour
5
+
6
+ skip_before_action :authenticate_switch_cm!
7
+
8
+ # POST /api/cm/token-based-authentication/exchange
9
+ # Client id and secret in (JSON or XML), { jwt, ruid } out, then `Authorization: Bearer` on every
10
+ # other call. The value is a Doorkeeper access token rather than a signed JWT, so a connector
11
+ # that only forwards it works but one that decodes it won't.
12
+ def create
13
+ client_id, client_secret = credentials
14
+ return render_fault(:bad_request, 'client_id and client_secret are required') if client_id.blank? || client_secret.blank?
15
+
16
+ @oauth_application = Spree::OauthApplication.find_by(uid: client_id)
17
+ unless @oauth_application&.secret_matches?(client_secret) && switch_cm_integrations.exists?
18
+ return render_fault(:unauthorized, 'Invalid client credentials')
19
+ end
20
+
21
+ render_token(issue_token.plaintext_token)
22
+ end
23
+
24
+ private
25
+
26
+ def issue_token
27
+ Spree::OauthAccessToken.create_for(
28
+ application: @oauth_application,
29
+ resource_owner: nil,
30
+ scopes: '',
31
+ expires_in: TOKEN_LIFETIME.to_i,
32
+ use_refresh_token: false
33
+ )
34
+ end
35
+
36
+ def credentials
37
+ return [params[:client_id], params[:client_secret]] unless xml_request?
38
+
39
+ %w[client_id client_secret].map { |name| request_document.at_xpath("/request/#{name}")&.text&.strip }
40
+ end
41
+
42
+ def xml_request?
43
+ request.content_mime_type&.symbol == :xml
44
+ end
45
+
46
+ def render_token(token)
47
+ return render json: { jwt: token, ruid: ruid } unless xml_request?
48
+
49
+ document = SpreeCmCommissioner::Integrations::SwitchCm::Xml::ResponseBuilder.build('response', ruid: ruid) do |xml|
50
+ xml.jwt token
51
+ xml.ruid ruid
52
+ end
53
+ render xml: document
54
+ end
55
+
56
+ # override
57
+ def render_fault(status, text, code: nil)
58
+ code ||= Rack::Utils.status_code(status)
59
+
60
+ if xml_request?
61
+ render xml: SpreeCmCommissioner::Integrations::SwitchCm::Xml::ResponseBuilder.fault('response', code: code, text: text, ruid: ruid),
62
+ status: status
63
+ else
64
+ render json: { errors: [{ code: code, message: text }], ruid: ruid }, status: status
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,39 @@
1
+ module Spree::Api::SwitchCm
2
+ module Xml
3
+ class AvailabilitiesController < BaseController
4
+ before_action :require_active_integration!
5
+
6
+ # POST /api/cm/hotels/xml/availability
7
+ # Rooms to sell, prices and closed in one message, validated and applied in the request
8
+ # (Inventory::AcceptPush); the answer is the real outcome.
9
+ def create
10
+ result = SpreeCmCommissioner::Integrations::SwitchCm::Inventory::AcceptPush.call(
11
+ integration: current_integration,
12
+ message: 'availability',
13
+ updates: parser.updates,
14
+ request_id: ruid
15
+ )
16
+
17
+ push_response = SpreeCmCommissioner::Integrations::SwitchCm::Xml::PushResponse.new(result: result.value, ruid: ruid)
18
+ render xml: push_response.to_xml, status: push_response.http_status
19
+ end
20
+
21
+ private
22
+
23
+ # override
24
+ def response_root
25
+ 'availability'
26
+ end
27
+
28
+ def parser
29
+ @parser ||= SpreeCmCommissioner::Integrations::SwitchCm::Xml::AvailabilityParser.new(request_document)
30
+ end
31
+
32
+ # B.XML pushes carry no hotel id; the rooms name it.
33
+ # override
34
+ def accommodation_id_from_request
35
+ vendor_id_of_room(parser.first_room_id)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,37 @@
1
+ module Spree::Api::SwitchCm
2
+ module Xml
3
+ # Booking.com's B.XML family (/hotels/xml/...): a request that fails as a whole answers with
4
+ # a <fault code string/> under the call's root element. Requests carry their parameters
5
+ # in a <request> body.
6
+ class BaseController < Spree::Api::SwitchCm::BaseController
7
+ private
8
+
9
+ def response_root
10
+ raise NotImplementedError, "#{self.class.name} must implement response_root"
11
+ end
12
+
13
+ # override
14
+ def render_fault(status, text, code: nil)
15
+ code ||= Rack::Utils.status_code(status)
16
+ render xml: SpreeCmCommissioner::Integrations::SwitchCm::Xml::ResponseBuilder.fault(response_root, code: code, text: text, ruid: ruid),
17
+ status: status
18
+ end
19
+
20
+ def render_document(&block)
21
+ render xml: SpreeCmCommissioner::Integrations::SwitchCm::Xml::ResponseBuilder.build(response_root, ruid: ruid, &block)
22
+ end
23
+
24
+ # override
25
+ def accommodation_id_from_request
26
+ request_value('hotel_id')
27
+ end
28
+
29
+ # A parameter from the <request> body, falling back to the query string.
30
+ def request_value(name)
31
+ return params[name] if request.raw_post.blank?
32
+
33
+ request_document.at_xpath("/request/#{name}")&.text&.strip.presence || params[name]
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,28 @@
1
+ module Spree::Api::SwitchCm
2
+ module Xml
3
+ class ReservationSummariesController < BaseController
4
+ before_action :require_integration!
5
+
6
+ # POST /api/cm/hotels/xml/reservationssummary with <request><hotel_id/></request>
7
+ # The hotel's confirmed stays checking out from tomorrow on, however long ago they were
8
+ # booked. The channel manager imports these while setting the hotel up, so they count against
9
+ # its availability before its first push. Read-only: nothing is marked seen.
10
+ def create
11
+ reservations = SpreeCmCommissioner::Integrations::SwitchCm::Reservations::Feed.new([current_integration]).summary
12
+
13
+ render_document do |xml|
14
+ reservations.each do |reservation|
15
+ SpreeCmCommissioner::Integrations::SwitchCm::Xml::ReservationSerializer.new(reservation, summary: true).build(xml)
16
+ end
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ # override
23
+ def response_root
24
+ 'reservations'
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,57 @@
1
+ module Spree::Api::SwitchCm
2
+ module Xml
3
+ class ReservationsController < BaseController
4
+ # POST /api/cm/hotels/xml/reservations with <request><hotel_id/><last_change/><limit/><id/></request>
5
+ # New, modified and cancelled reservations in one stream. B.XML has no acknowledgement, so returning a
6
+ # reservation is what marks it seen; `last_change` re-reads recent ones regardless.
7
+ def create
8
+ integrations, = requested_integrations(request_value('hotel_id') || request_value('hotel_ids'))
9
+ return render_fault(:forbidden, 'Not authorized for these hotels') if integrations.empty?
10
+
11
+ reservations = SpreeCmCommissioner::Integrations::SwitchCm::Reservations::Feed.new(integrations).changed_reservations(
12
+ id: request_value('id'),
13
+ last_change: SpreeCmCommissioner::Integrations::SwitchCm::Reservations::Feed.parse_last_change(request_value('last_change')),
14
+ limit: request_value('limit')
15
+ )
16
+
17
+ document = SpreeCmCommissioner::Integrations::SwitchCm::Xml::ResponseBuilder.build(response_root, ruid: ruid) do |xml|
18
+ reservations.each { |reservation| SpreeCmCommissioner::Integrations::SwitchCm::Xml::ReservationSerializer.new(reservation).build(xml) }
19
+ end
20
+
21
+ # Marked only once the document is built. One that fails to mark is simply returned
22
+ # again next time, which is harmless: its status is the same.
23
+ failed = mark_seen(reservations)
24
+ log_mark_seen_failures(failed) if failed.any?
25
+
26
+ render xml: document
27
+ end
28
+
29
+ private
30
+
31
+ # override
32
+ def response_root
33
+ 'reservations'
34
+ end
35
+
36
+ def mark_seen(reservations)
37
+ reservations.each_with_object([]) do |reservation, failed|
38
+ SpreeCmCommissioner::Integrations::SwitchCm::Reservations::MarkSeen.call(
39
+ integration: reservation.integration, order: reservation.order, status: reservation.status,
40
+ modified_at: reservation.modified_at
41
+ )
42
+ rescue StandardError => e
43
+ failed << {
44
+ order_id: reservation.id,
45
+ integration_id: reservation.integration.id,
46
+ error_class: e.class.name,
47
+ error_message: e.message
48
+ }
49
+ end
50
+ end
51
+
52
+ def log_mark_seen_failures(failed)
53
+ CmAppLogger.error(label: "#{self.class.name}#mark_seen failed", data: { ruid: ruid, failed: failed })
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,56 @@
1
+ module Spree::Api::SwitchCm
2
+ module Xml
3
+ class RoomRateAvailabilitiesController < BaseController
4
+ DAYS_RANGE = (1..31)
5
+
6
+ before_action :require_integration!
7
+
8
+ # POST /api/cm/hotels/xml/roomrateavailability with <request><hotel_id/><start_date/><number_of_days/><room_id/></request>
9
+ # What we hold per room and night, so the channel manager can check its pushes landed.
10
+ # number_of_days is 1-31, 31 by default, as at Booking.com.
11
+ def create
12
+ start_date = parse_date(request_value('start_date')) || Time.zone.today
13
+ days = (request_value('number_of_days').presence || DAYS_RANGE.max).to_i
14
+ return render_fault(:bad_request, "number_of_days must be between #{DAYS_RANGE.min} and #{DAYS_RANGE.max}") unless DAYS_RANGE.cover?(days)
15
+
16
+ rows = SpreeCmCommissioner::Integrations::SwitchCm::Inventory::AvailabilitySnapshot.new(
17
+ integration: current_integration,
18
+ dates: (start_date...(start_date + days)).to_a,
19
+ room_id: request_value('room_id')
20
+ ).rows
21
+
22
+ render_document { |xml| rows.each { |row| build_row(xml, row) } }
23
+ end
24
+
25
+ private
26
+
27
+ # override
28
+ def response_root
29
+ 'result'
30
+ end
31
+
32
+ def build_row(xml, row)
33
+ xml.roomrate(
34
+ {
35
+ date: row.date.iso8601,
36
+ room_id: row.room_id,
37
+ rate_id: row.rate_id,
38
+ rooms_to_sell: row.rooms_to_sell,
39
+ booked: row.booked,
40
+ cancelled: row.cancelled,
41
+ closed: row.closed ? 1 : 0,
42
+ price: row.price && format('%.2f', row.price),
43
+ currency: row.currency,
44
+ pushed_at: row.pushed_at&.utc&.iso8601
45
+ }.compact
46
+ )
47
+ end
48
+
49
+ def parse_date(value)
50
+ Date.iso8601(value) if value.present?
51
+ rescue Date::Error
52
+ nil
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,45 @@
1
+ module Spree::Api::SwitchCm
2
+ module Xml
3
+ class RoomRatesController < BaseController
4
+ before_action :require_integration!
5
+
6
+ # POST /api/cm/hotels/xml/roomrates with <request><hotel_id/></request>
7
+ # The hotel's rooms, each with its rates, which the channel manager maps to its own
8
+ # ("Refresh Room" in switch.cm). A derived rate plan is a child rate of the room's base rate.
9
+ def create
10
+ vendor = current_integration.vendor
11
+ rooms = SpreeCmCommissioner::Integrations::SwitchCm::RoomCatalog.new(vendor).rooms
12
+
13
+ render_document do |xml|
14
+ rooms.each do |room|
15
+ xml.room(id: room.id, hotel_id: vendor.id, hotel_name: vendor.name, room_name: room.name, max_children: room.max_children) do
16
+ xml.rates { room.rates.each { |rate| build_rate(xml, room, rate) } }
17
+ end
18
+ end
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def build_rate(xml, room, rate)
25
+ attributes = { id: rate.id, rate_name: rate.name, max_persons: room.max_persons, is_child_rate: rate.child? ? 1 : 0, readonly: 0 }
26
+ return xml.rate(attributes) unless rate.child?
27
+
28
+ # Restrictions other than closing aren't stored, so the child rate follows only its parent's price and closings.
29
+ xml.rate(attributes) do
30
+ xml.rate_relation(
31
+ {
32
+ parent_rate_id: rate.parent_id, percentage: rate.percentage&.to_f, additional: rate.additional&.to_f,
33
+ follows_price: 1, follows_closed: 3, follows_restrictions: 0
34
+ }.compact
35
+ )
36
+ end
37
+ end
38
+
39
+ # override
40
+ def response_root
41
+ 'rooms'
42
+ end
43
+ end
44
+ end
45
+ end
@@ -12,7 +12,7 @@ module Spree
12
12
  end
13
13
 
14
14
  def collection
15
- SpreeCmCommissioner::GuestSearcherQuery.new(params).call
15
+ SpreeCmCommissioner::GuestSearcherQuery.new(params).call.merge(SpreeCmCommissioner::Guest.with_dynamic_fields_and_options)
16
16
  end
17
17
 
18
18
  # override
@@ -7,13 +7,39 @@ module Spree
7
7
 
8
8
  # override
9
9
  def collection
10
- @collection ||= SpreeCmCommissioner::Accommodations::FindVariant.new(
10
+ # Load in batches what every room's JSON reads, instead of per room: the storefront variant's base price
11
+ # (Variant#price_in), relationships and stock ids, and the rate plans and terms its rates are built from.
12
+ @collection ||= finder.execute.includes(
13
+ :product, :images, :option_values, :stock_items, :stock_locations, :default_price,
14
+ active_rate_plans: { option_values: :option_type }
15
+ )
16
+ end
17
+
18
+ def finder
19
+ @finder ||= SpreeCmCommissioner::Accommodations::FindVariant.new(
11
20
  from_date: params[:from_date]&.to_date,
12
21
  to_date: params[:to_date]&.to_date,
13
- vendor_id: params[:accommodation_id],
22
+ vendor_id: Spree::Vendor.by_vendor_id!(params[:accommodation_id]).id,
14
23
  number_of_adults: params[:adult].to_i,
15
24
  number_of_kids: params[:children].to_i
16
- ).execute
25
+ )
26
+ end
27
+
28
+ # override: price every room's rates from one batch of prices, instead of a query per room. Runs only
29
+ # when the response isn't cached, and the batch loads only when a room's JSON isn't cached either.
30
+ def serialize_collection(collection)
31
+ Spree::Variant.preload_stay_prices(
32
+ collection,
33
+ dates: finder.dates,
34
+ currency: current_currency
35
+ )
36
+
37
+ super
38
+ end
39
+
40
+ # override: rates are priced for the nights searched.
41
+ def serializer_params
42
+ super.merge(dates: finder.dates)
17
43
  end
18
44
 
19
45
  # override
@@ -23,12 +49,12 @@ module Spree
23
49
 
24
50
  # override
25
51
  def resource_serializer
26
- Spree::V2::Storefront::VariantSerializer
52
+ SpreeCmCommissioner::V2::Storefront::AccommodationVariantSerializer
27
53
  end
28
54
 
29
55
  # override
30
56
  def collection_serializer
31
- Spree::V2::Storefront::VariantSerializer
57
+ SpreeCmCommissioner::V2::Storefront::AccommodationVariantSerializer
32
58
  end
33
59
 
34
60
  # override
@@ -21,9 +21,9 @@ module Spree
21
21
  return @collection if defined?(@collection)
22
22
 
23
23
  scope = if include_reserved_blocks?
24
- SpreeCmCommissioner::InventoryItem.includes(:prices, :reserved_blocks)
24
+ SpreeCmCommissioner::InventoryItem.includes(:variant_prices, :reserved_blocks)
25
25
  else
26
- SpreeCmCommissioner::InventoryItem.includes(:prices)
26
+ SpreeCmCommissioner::InventoryItem.includes(:variant_prices)
27
27
  end
28
28
 
29
29
  @collection = scope.where(variant_id: params[:variant_ids], inventory_date: params[:inventory_dates])
@@ -31,7 +31,6 @@ module Spree
31
31
  result = SpreeCmCommissioner::IntercityTaxiOrder::Update.call(
32
32
  order: spree_current_order,
33
33
  remark: params[:remark],
34
- passenger_count: params[:passenger_count],
35
34
  pickup_map_place_attributes: params[:pickup_map_place_attributes]&.permit(
36
35
  :place_name,
37
36
  :lat,
@@ -165,7 +165,7 @@ module SpreeCmCommissioner
165
165
  text << bold('🚗 Trip Details')
166
166
  text << "Route: #{route_text}" if route_text.present?
167
167
  text << "Vehicle: #{vehicle_text}" if vehicle_text.present?
168
- text << "Passengers: #{line_item.passenger_count || line_item.quantity}"
168
+ text << "Passengers: #{line_item.number_of_guests}"
169
169
  text << "Departure: #{departure_text}" if departure_text.present?
170
170
  text << seat_labels_text(line_item) if seat_labels_text(line_item).present?
171
171
 
@@ -13,11 +13,8 @@ module SpreeCmCommissioner
13
13
  def execute
14
14
  scope
15
15
  .where(default_state_id: state_id)
16
- .where('spree_variants.track_inventory = FALSE OR cm_inventory_items.inventory_date BETWEEN ? AND ?', from_date, to_date)
17
- .where('spree_variants.track_inventory = FALSE OR cm_inventory_items.quantity_available > 0')
18
- .where('COALESCE(CAST(spree_variants.public_metadata->\'cm_options\'->>\'number-of-adults\' AS INTEGER), 0) +
19
- COALESCE(CAST(spree_variants.public_metadata->\'cm_options\'->>\'number-of-kids\' AS INTEGER), 0) >= ?', number_of_guests
20
- )
16
+ .merge(bookable_variants)
17
+ .merge(Spree::Variant.with_guest_capacity(number_of_guests))
21
18
  .distinct
22
19
  end
23
20
 
@@ -26,12 +23,17 @@ module SpreeCmCommissioner
26
23
  def scope
27
24
  Spree::Vendor
28
25
  .joins(products: :variants)
29
- .joins('LEFT OUTER JOIN cm_inventory_items ON cm_inventory_items.variant_id = spree_variants.id')
30
26
  .where(primary_product_type: :accommodation, state: :active)
31
27
  end
32
28
 
33
- def stay_dates
34
- from_date..to_date
29
+ def dates
30
+ @dates ||= SpreeCmCommissioner::BookingDates.nights(check_in: from_date, check_out: to_date)
31
+ end
32
+
33
+ # Same rule as the rooms endpoint: a hotel is listed only if one of its rooms is bookable every night
34
+ # with a rate open, so a listed hotel always has a room to show. Rooms that don't track inventory always count.
35
+ def bookable_variants
36
+ Spree::Variant.where(track_inventory: false).or(Spree::Variant.bookable_on_every_date(dates)).with_open_rate_on_every_date(dates)
35
37
  end
36
38
  end
37
39
  end