spree_cm_commissioner 2.13.2 → 2.13.3.pre.pre2

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 (278) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +1 -0
  3. data/Gemfile.lock +1 -1
  4. data/app/controllers/concerns/spree_cm_commissioner/operator/event_authorization.rb +19 -0
  5. data/app/controllers/spree/admin/homepage_section_controller.rb +32 -8
  6. data/app/controllers/spree/admin/integrations_controller.rb +5 -7
  7. data/app/controllers/spree/admin/inventory_items_controller.rb +12 -0
  8. data/app/controllers/spree/admin/notification_users_controller.rb +39 -7
  9. data/app/controllers/spree/admin/service_fee_rules_controller.rb +83 -0
  10. data/app/controllers/spree/admin/stock_managements_controller.rb +5 -5
  11. data/app/controllers/spree/api/switch_cm/base_controller.rb +98 -0
  12. data/app/controllers/spree/api/switch_cm/ota/avail_notifs_controller.rb +17 -0
  13. data/app/controllers/spree/api/switch_cm/ota/base_controller.rb +27 -0
  14. data/app/controllers/spree/api/switch_cm/ota/product_notifs_controller.rb +44 -0
  15. data/app/controllers/spree/api/switch_cm/ota/pushes_controller.rb +46 -0
  16. data/app/controllers/spree/api/switch_cm/ota/rate_amount_notifs_controller.rb +17 -0
  17. data/app/controllers/spree/api/switch_cm/ota/res_modify_notifs_controller.rb +31 -0
  18. data/app/controllers/spree/api/switch_cm/ota/res_notifs_controller.rb +29 -0
  19. data/app/controllers/spree/api/switch_cm/ota/reservation_streams_controller.rb +83 -0
  20. data/app/controllers/spree/api/switch_cm/tokens_controller.rb +68 -0
  21. data/app/controllers/spree/api/switch_cm/xml/availabilities_controller.rb +39 -0
  22. data/app/controllers/spree/api/switch_cm/xml/base_controller.rb +37 -0
  23. data/app/controllers/spree/api/switch_cm/xml/reservation_summaries_controller.rb +28 -0
  24. data/app/controllers/spree/api/switch_cm/xml/reservations_controller.rb +56 -0
  25. data/app/controllers/spree/api/switch_cm/xml/room_rate_availabilities_controller.rb +56 -0
  26. data/app/controllers/spree/api/switch_cm/xml/room_rates_controller.rb +32 -0
  27. data/app/controllers/spree/api/v2/operator/check_ins_controller.rb +7 -6
  28. data/app/controllers/spree/api/v2/operator/event_charts_controller.rb +8 -10
  29. data/app/controllers/spree/api/v2/operator/event_ticket_aggregators_controller.rb +14 -4
  30. data/app/controllers/spree/api/v2/operator/pie_chart_event_aggregators_controller.rb +15 -1
  31. data/app/controllers/spree/api/v2/platform/homepage_section_relatable_options_controller.rb +28 -0
  32. data/app/controllers/spree/api/v2/storefront/cross_app_handoff_tokens_controller.rb +41 -0
  33. data/app/controllers/spree/api/v2/storefront/homepage_sections_controller.rb +4 -2
  34. data/app/controllers/spree/api/v2/storefront/transit/item_availabilities_controller.rb +66 -0
  35. data/app/controllers/spree/api/v2/tenant/checkout_controller.rb +11 -0
  36. data/app/controllers/spree/api/v2/tenant/vendor_photos_controller.rb +25 -0
  37. data/app/controllers/spree_cm_commissioner/admin/orders_controller_decorator.rb +9 -1
  38. data/app/controllers/spree_cm_commissioner/api/v2/storefront/checkout_controller_decorator.rb +15 -0
  39. data/app/factory/spree_cm_commissioner/order_telegram_message_factory.rb +6 -2
  40. data/app/helpers/spree/admin/customer_notifications_helper.rb +57 -0
  41. data/app/helpers/spree_cm_commissioner/admin/homepage_segment_helper.rb +45 -23
  42. data/app/interactors/spree_cm_commissioner/customer_notification_sender.rb +18 -9
  43. data/app/interactors/spree_cm_commissioner/user_cross_app_handoff_authenticator.rb +17 -0
  44. data/app/jobs/spree_cm_commissioner/audit_event_job.rb +29 -0
  45. data/app/models/concerns/spree_cm_commissioner/audit_wrapper.rb +139 -0
  46. data/app/models/concerns/spree_cm_commissioner/homepage_section_bitwise.rb +37 -1
  47. data/app/models/concerns/spree_cm_commissioner/integrations/inventory_sync_cachable.rb +9 -0
  48. data/app/models/concerns/spree_cm_commissioner/line_item_open_dated_trippable.rb +2 -2
  49. data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +10 -0
  50. data/app/models/concerns/spree_cm_commissioner/phone_number_sanitizer.rb +5 -1
  51. data/app/models/concerns/spree_cm_commissioner/user_notification.rb +3 -1
  52. data/app/models/concerns/spree_cm_commissioner/variant_metadata.rb +24 -0
  53. data/app/models/concerns/spree_cm_commissioner/vehicle_kind.rb +1 -1
  54. data/app/models/spree_cm_commissioner/adjustable/adjuster/service_fee.rb +31 -0
  55. data/app/models/spree_cm_commissioner/adjustment_decorator.rb +1 -0
  56. data/app/models/spree_cm_commissioner/audit_event.rb +11 -0
  57. data/app/models/spree_cm_commissioner/calculators/service_fee_flat_amount.rb +17 -0
  58. data/app/models/spree_cm_commissioner/calculators/service_fee_percent.rb +20 -0
  59. data/app/models/spree_cm_commissioner/currency_rate.rb +15 -0
  60. data/app/models/spree_cm_commissioner/customer_notification.rb +15 -1
  61. data/app/models/spree_cm_commissioner/homepage_section.rb +25 -0
  62. data/app/models/spree_cm_commissioner/integration.rb +46 -0
  63. data/app/models/spree_cm_commissioner/integrations/book_me_bus_v1.rb +64 -0
  64. data/app/models/spree_cm_commissioner/integrations/larryta.rb +81 -0
  65. data/app/models/spree_cm_commissioner/integrations/stadium_x_v1.rb +4 -10
  66. data/app/models/spree_cm_commissioner/integrations/switch_cm.rb +86 -0
  67. data/app/models/spree_cm_commissioner/integrations/vireak_buntham.rb +82 -0
  68. data/app/models/spree_cm_commissioner/inventory_item.rb +18 -0
  69. data/app/models/spree_cm_commissioner/line_item_decorator.rb +2 -0
  70. data/app/models/spree_cm_commissioner/order_decorator.rb +13 -1
  71. data/app/models/spree_cm_commissioner/order_updater_decorator.rb +31 -0
  72. data/app/models/spree_cm_commissioner/place.rb +2 -0
  73. data/app/models/spree_cm_commissioner/redis_stock/cached_inventory_items_builder.rb +2 -1
  74. data/app/models/spree_cm_commissioner/route.rb +1 -0
  75. data/app/models/spree_cm_commissioner/seat_layout.rb +1 -0
  76. data/app/models/spree_cm_commissioner/service_fee_rule.rb +183 -0
  77. data/app/models/spree_cm_commissioner/stock/availability_checker.rb +1 -1
  78. data/app/models/spree_cm_commissioner/stock/order_availability_checker.rb +1 -1
  79. data/app/models/spree_cm_commissioner/stock_item_decorator.rb +4 -0
  80. data/app/models/spree_cm_commissioner/trip.rb +2 -1
  81. data/app/models/spree_cm_commissioner/trip_stop.rb +2 -0
  82. data/app/models/spree_cm_commissioner/user_decorator.rb +4 -0
  83. data/app/models/spree_cm_commissioner/variant_decorator.rb +5 -0
  84. data/app/models/spree_cm_commissioner/vehicle.rb +3 -0
  85. data/app/models/spree_cm_commissioner/vehicle_type.rb +1 -0
  86. data/app/models/spree_cm_commissioner/vendor_decorator.rb +17 -0
  87. data/app/models/spree_cm_commissioner/vendor_place.rb +2 -0
  88. data/app/overrides/spree/admin/orders/index/line_items_row.html.erb.deface +14 -0
  89. data/app/overrides/spree/admin/orders/index/row_actions.html.erb.deface +17 -0
  90. data/app/overrides/spree/admin/shared/sub_menu/_configuration/menu_items.html.erb.deface +5 -0
  91. data/app/queries/spree_cm_commissioner/users_by_event_fetcher_query.rb +6 -7
  92. data/app/serializables/spree_cm_commissioner/item_availability_sync.rb +10 -0
  93. data/app/serializers/spree/api/v2/platform/service_fee_rule_serializer.rb +17 -0
  94. data/app/serializers/spree/v2/storefront/homepage_section_serializer.rb +1 -1
  95. data/app/serializers/spree/v2/storefront/line_item_serializer_decorator.rb +8 -2
  96. data/app/serializers/spree/v2/tenant/cart_serializer.rb +2 -1
  97. data/app/serializers/spree/v2/tenant/line_item_serializer.rb +7 -1
  98. data/app/serializers/spree_cm_commissioner/v2/operator/guest_serializer.rb +2 -1
  99. data/app/serializers/spree_cm_commissioner/v2/storefront/cart_serializer_decorator.rb +2 -1
  100. data/app/serializers/spree_cm_commissioner/v2/storefront/cross_app_handoff_token_serializer.rb +9 -0
  101. data/app/serializers/spree_cm_commissioner/v2/storefront/item_availability_sync_serializer.rb +10 -0
  102. data/app/services/spree_cm_commissioner/api_caches/invalidate.rb +1 -1
  103. data/app/services/spree_cm_commissioner/audit_logger.rb +59 -0
  104. data/app/services/spree_cm_commissioner/checkout/advance_to_payment.rb +27 -0
  105. data/app/services/spree_cm_commissioner/cross_app_handoff_tokens/decode.rb +42 -0
  106. data/app/services/spree_cm_commissioner/cross_app_handoff_tokens/encode.rb +47 -0
  107. data/app/services/spree_cm_commissioner/currency_converter/create.rb +27 -0
  108. data/app/services/spree_cm_commissioner/currency_rates/manage.rb +99 -0
  109. data/app/services/spree_cm_commissioner/draft_orders/create.rb +1 -0
  110. data/app/services/spree_cm_commissioner/google_maps_geocoder.rb +41 -0
  111. data/app/services/spree_cm_commissioner/integrations/base/sync_manager.rb +1 -2
  112. data/app/services/spree_cm_commissioner/integrations/base/sync_result.rb +11 -0
  113. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/external_client/client.rb +230 -0
  114. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/external_client/connection.rb +147 -0
  115. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/inventory/external_inventory_item_status.rb +52 -0
  116. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/inventory/external_inventory_items.rb +91 -0
  117. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/inventory/unstock_inventory.rb +188 -0
  118. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_inventory.rb +79 -0
  119. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_item_availability.rb +354 -0
  120. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_locations.rb +103 -0
  121. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_nationality_pricing.rb +93 -0
  122. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_product.rb +70 -0
  123. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_route_stops.rb +121 -0
  124. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_routes.rb +128 -0
  125. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_seat_layout.rb +412 -0
  126. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_service_calendars.rb +59 -0
  127. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_trip_stop.rb +224 -0
  128. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_trips.rb +769 -0
  129. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_variant.rb +69 -0
  130. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_vehicle_types.rb +92 -0
  131. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/time_parser.rb +19 -0
  132. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/resources/base.rb +40 -0
  133. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/resources/inventory_item_status.rb +148 -0
  134. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/resources/location.rb +55 -0
  135. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/resources/reservation_cart.rb +85 -0
  136. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/resources/seat.rb +128 -0
  137. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/resources/seat_layout.rb +386 -0
  138. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/resources/stop.rb +84 -0
  139. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/resources/trip.rb +237 -0
  140. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/resources/wallet_payment.rb +48 -0
  141. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/sync_manager.rb +22 -0
  142. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/sync_strategies/full_sync_strategy.rb +116 -0
  143. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/sync_strategies/incremental_sync_strategy.rb +63 -0
  144. data/app/services/spree_cm_commissioner/integrations/larryta/external_client/client.rb +314 -0
  145. data/app/services/spree_cm_commissioner/integrations/larryta/external_client/connection.rb +74 -0
  146. data/app/services/spree_cm_commissioner/integrations/larryta/inventory/external_inventory_item_status.rb +53 -0
  147. data/app/services/spree_cm_commissioner/integrations/larryta/inventory/external_inventory_items.rb +106 -0
  148. data/app/services/spree_cm_commissioner/integrations/larryta/inventory/hold_seats.rb +143 -0
  149. data/app/services/spree_cm_commissioner/integrations/larryta/inventory/release_seats.rb +90 -0
  150. data/app/services/spree_cm_commissioner/integrations/larryta/inventory/unstock_inventory.rb +143 -0
  151. data/app/services/spree_cm_commissioner/integrations/larryta/polling/sync_item_availability.rb +325 -0
  152. data/app/services/spree_cm_commissioner/integrations/larryta/polling/sync_product.rb +72 -0
  153. data/app/services/spree_cm_commissioner/integrations/larryta/polling/sync_routes.rb +142 -0
  154. data/app/services/spree_cm_commissioner/integrations/larryta/polling/sync_seat_layout.rb +255 -0
  155. data/app/services/spree_cm_commissioner/integrations/larryta/polling/sync_stock_item.rb +55 -0
  156. data/app/services/spree_cm_commissioner/integrations/larryta/polling/sync_trip_stop.rb +171 -0
  157. data/app/services/spree_cm_commissioner/integrations/larryta/polling/sync_trips.rb +397 -0
  158. data/app/services/spree_cm_commissioner/integrations/larryta/polling/sync_variant.rb +63 -0
  159. data/app/services/spree_cm_commissioner/integrations/larryta/polling/sync_vehicle_type.rb +79 -0
  160. data/app/services/spree_cm_commissioner/integrations/larryta/resources/base.rb +39 -0
  161. data/app/services/spree_cm_commissioner/integrations/larryta/resources/booking.rb +113 -0
  162. data/app/services/spree_cm_commissioner/integrations/larryta/resources/booking_session.rb +43 -0
  163. data/app/services/spree_cm_commissioner/integrations/larryta/resources/booking_transaction.rb +236 -0
  164. data/app/services/spree_cm_commissioner/integrations/larryta/resources/direction.rb +71 -0
  165. data/app/services/spree_cm_commissioner/integrations/larryta/resources/passenger.rb +48 -0
  166. data/app/services/spree_cm_commissioner/integrations/larryta/resources/schedule.rb +150 -0
  167. data/app/services/spree_cm_commissioner/integrations/larryta/resources/seat.rb +31 -0
  168. data/app/services/spree_cm_commissioner/integrations/larryta/resources/seat_availability.rb +49 -0
  169. data/app/services/spree_cm_commissioner/integrations/larryta/resources/seat_layout.rb +211 -0
  170. data/app/services/spree_cm_commissioner/integrations/larryta/sync_manager.rb +17 -0
  171. data/app/services/spree_cm_commissioner/integrations/larryta/sync_strategies/full_sync_strategy.rb +79 -0
  172. data/app/services/spree_cm_commissioner/integrations/larryta/sync_strategies/incremental_sync_strategy.rb +58 -0
  173. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/external_client/client.rb +14 -15
  174. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/polling/sync_matches.rb +7 -4
  175. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/polling/sync_zones.rb +6 -3
  176. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/sync_manager.rb +0 -3
  177. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/sync_strategies/full_sync_strategy.rb +1 -3
  178. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/sync_strategies/incremental_sync_strategy.rb +1 -3
  179. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/sync_strategies/webhook_sync_strategy.rb +1 -2
  180. data/app/services/spree_cm_commissioner/integrations/switch_cm/accept_push.rb +101 -0
  181. data/app/services/spree_cm_commissioner/integrations/switch_cm/accommodation_lock.rb +41 -0
  182. data/app/services/spree_cm_commissioner/integrations/switch_cm/acknowledge_reservations.rb +43 -0
  183. data/app/services/spree_cm_commissioner/integrations/switch_cm/apply_ari.rb +119 -0
  184. data/app/services/spree_cm_commissioner/integrations/switch_cm/ari_update.rb +24 -0
  185. data/app/services/spree_cm_commissioner/integrations/switch_cm/availability_readback.rb +86 -0
  186. data/app/services/spree_cm_commissioner/integrations/switch_cm/mark_seen.rb +53 -0
  187. data/app/services/spree_cm_commissioner/integrations/switch_cm/ota/avail_notif_parser.rb +60 -0
  188. data/app/services/spree_cm_commissioner/integrations/switch_cm/ota/base_parser.rb +60 -0
  189. data/app/services/spree_cm_commissioner/integrations/switch_cm/ota/push_response.rb +38 -0
  190. data/app/services/spree_cm_commissioner/integrations/switch_cm/ota/rate_amount_notif_parser.rb +46 -0
  191. data/app/services/spree_cm_commissioner/integrations/switch_cm/ota/reservation_presenter.rb +99 -0
  192. data/app/services/spree_cm_commissioner/integrations/switch_cm/ota/response_builder.rb +52 -0
  193. data/app/services/spree_cm_commissioner/integrations/switch_cm/pushed_nights.rb +79 -0
  194. data/app/services/spree_cm_commissioner/integrations/switch_cm/reservation.rb +118 -0
  195. data/app/services/spree_cm_commissioner/integrations/switch_cm/reservation_feed.rb +143 -0
  196. data/app/services/spree_cm_commissioner/integrations/switch_cm/room_catalog.rb +56 -0
  197. data/app/services/spree_cm_commissioner/integrations/switch_cm/sync_manager.rb +16 -0
  198. data/app/services/spree_cm_commissioner/integrations/switch_cm/sync_strategies/full_sync_strategy.rb +39 -0
  199. data/app/services/spree_cm_commissioner/integrations/switch_cm/sync_strategies/incremental_sync_strategy.rb +51 -0
  200. data/app/services/spree_cm_commissioner/integrations/switch_cm/unseen_room_nights.rb +39 -0
  201. data/app/services/spree_cm_commissioner/integrations/switch_cm/validate_ari_updates.rb +136 -0
  202. data/app/services/spree_cm_commissioner/integrations/switch_cm/xml/availability_parser.rb +103 -0
  203. data/app/services/spree_cm_commissioner/integrations/switch_cm/xml/push_response.rb +45 -0
  204. data/app/services/spree_cm_commissioner/integrations/switch_cm/xml/reservation_presenter.rb +74 -0
  205. data/app/services/spree_cm_commissioner/integrations/switch_cm/xml/response_builder.rb +19 -0
  206. data/app/services/spree_cm_commissioner/integrations/vireak_buntham/external_client/client.rb +170 -0
  207. data/app/services/spree_cm_commissioner/integrations/vireak_buntham/external_client/connection.rb +59 -0
  208. data/app/services/spree_cm_commissioner/integrations/vireak_buntham/inventory/external_inventory_item_status.rb +48 -0
  209. data/app/services/spree_cm_commissioner/integrations/vireak_buntham/inventory/external_inventory_items.rb +92 -0
  210. data/app/services/spree_cm_commissioner/integrations/vireak_buntham/inventory/unstock_inventory.rb +230 -0
  211. data/app/services/spree_cm_commissioner/integrations/vireak_buntham/polling/sync_item_availability.rb +270 -0
  212. data/app/services/spree_cm_commissioner/integrations/vireak_buntham/polling/sync_locations.rb +96 -0
  213. data/app/services/spree_cm_commissioner/integrations/vireak_buntham/polling/sync_nationality_pricing.rb +76 -0
  214. data/app/services/spree_cm_commissioner/integrations/vireak_buntham/polling/sync_product.rb +69 -0
  215. data/app/services/spree_cm_commissioner/integrations/vireak_buntham/polling/sync_routes.rb +110 -0
  216. data/app/services/spree_cm_commissioner/integrations/vireak_buntham/polling/sync_seat_layout.rb +207 -0
  217. data/app/services/spree_cm_commissioner/integrations/vireak_buntham/polling/sync_stock_item.rb +43 -0
  218. data/app/services/spree_cm_commissioner/integrations/vireak_buntham/polling/sync_trip_stop.rb +192 -0
  219. data/app/services/spree_cm_commissioner/integrations/vireak_buntham/polling/sync_trips.rb +377 -0
  220. data/app/services/spree_cm_commissioner/integrations/vireak_buntham/polling/sync_variant.rb +56 -0
  221. data/app/services/spree_cm_commissioner/integrations/vireak_buntham/polling/sync_vehicle_type.rb +51 -0
  222. data/app/services/spree_cm_commissioner/integrations/vireak_buntham/resources/base.rb +32 -0
  223. data/app/services/spree_cm_commissioner/integrations/vireak_buntham/resources/booking.rb +48 -0
  224. data/app/services/spree_cm_commissioner/integrations/vireak_buntham/resources/booking_transaction.rb +46 -0
  225. data/app/services/spree_cm_commissioner/integrations/vireak_buntham/resources/cell.rb +53 -0
  226. data/app/services/spree_cm_commissioner/integrations/vireak_buntham/resources/confirm_booking_request.rb +43 -0
  227. data/app/services/spree_cm_commissioner/integrations/vireak_buntham/resources/location.rb +37 -0
  228. data/app/services/spree_cm_commissioner/integrations/vireak_buntham/resources/schedule.rb +138 -0
  229. data/app/services/spree_cm_commissioner/integrations/vireak_buntham/resources/seat.rb +48 -0
  230. data/app/services/spree_cm_commissioner/integrations/vireak_buntham/resources/seat_layout.rb +79 -0
  231. data/app/services/spree_cm_commissioner/integrations/vireak_buntham/sync_manager.rb +17 -0
  232. data/app/services/spree_cm_commissioner/integrations/vireak_buntham/sync_strategies/full_sync_strategy.rb +87 -0
  233. data/app/services/spree_cm_commissioner/integrations/vireak_buntham/sync_strategies/incremental_sync_strategy.rb +53 -0
  234. data/app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_by_variant.rb +4 -0
  235. data/app/services/spree_cm_commissioner/inventory_items/generate_permanent_item.rb +48 -0
  236. data/app/services/spree_cm_commissioner/inventory_items/reset.rb +4 -0
  237. data/app/services/spree_cm_commissioner/order_holds/hold.rb +17 -0
  238. data/app/services/spree_cm_commissioner/order_holds/release.rb +17 -0
  239. data/app/services/spree_cm_commissioner/place_resolver.rb +95 -0
  240. data/app/services/spree_cm_commissioner/transit_order/create.rb +1 -0
  241. data/app/services/spree_cm_commissioner/user_authenticator.rb +20 -12
  242. data/app/views/spree/admin/customer_notifications/_form.html.erb +9 -9
  243. data/app/views/spree/admin/customer_notifications/index.html.erb +36 -19
  244. data/app/views/spree/admin/homepage_section/_form.html.erb +19 -2
  245. data/app/views/spree/admin/homepage_section/_tabs.html.erb +7 -7
  246. data/app/views/spree/admin/homepage_section/index.html.erb +73 -6
  247. data/app/views/spree/admin/integrations/_book_me_bus_v1_fields.html.erb +47 -0
  248. data/app/views/spree/admin/integrations/_form.html.erb +16 -0
  249. data/app/views/spree/admin/integrations/_larryta_fields.html.erb +58 -0
  250. data/app/views/spree/admin/integrations/_switch_cm_fields.html.erb +53 -0
  251. data/app/views/spree/admin/integrations/_vireak_buntham_fields.html.erb +75 -0
  252. data/app/views/spree/admin/integrations/index.html.erb +1 -1
  253. data/app/views/spree/admin/notifications/index.html.erb +2 -1
  254. data/app/views/spree/admin/orders/line_items.html.erb +53 -0
  255. data/app/views/spree/admin/service_fee_rules/_form.html.erb +119 -0
  256. data/app/views/spree/admin/service_fee_rules/edit.html.erb +11 -0
  257. data/app/views/spree/admin/service_fee_rules/index.html.erb +91 -0
  258. data/app/views/spree/admin/service_fee_rules/new.html.erb +11 -0
  259. data/app/views/spree/admin/shared/_send_all_form.html.erb +1 -0
  260. data/app/views/spree/admin/shared/_send_test_form.html.erb +3 -3
  261. data/app/views/spree/admin/stock_managements/index.html.erb +14 -0
  262. data/config/locales/en.yml +36 -0
  263. data/config/locales/km.yml +32 -0
  264. data/config/routes.rb +34 -0
  265. data/db/migrate/20260327143200_create_cm_audit_events.rb +21 -0
  266. data/db/migrate/20260410045815_create_cm_currency_rates.rb +23 -0
  267. data/db/migrate/20260411102139_add_metadata_to_cm_inventory_items.rb +6 -0
  268. data/db/migrate/20260914000000_add_mini_app_to_homepage_sections.rb +5 -0
  269. data/db/migrate/20260917085551_create_cm_service_fee_rules.rb +16 -0
  270. data/db/migrate/20260917085552_add_service_fee_total_to_spree_line_items_and_orders.rb +6 -0
  271. data/db/migrate/20260928000000_add_oauth_application_id_to_cm_integrations.rb +5 -0
  272. data/db/migrate/20260928000001_add_closed_to_cm_inventory_items.rb +5 -0
  273. data/lib/spree_cm_commissioner/cached_inventory_item.rb +7 -1
  274. data/lib/spree_cm_commissioner/engine.rb +3 -0
  275. data/lib/spree_cm_commissioner/test_helper/factories/homepage_section_factory.rb +7 -0
  276. data/lib/spree_cm_commissioner/test_helper/factories/integration_factory.rb +17 -0
  277. data/lib/spree_cm_commissioner/version.rb +1 -1
  278. metadata +187 -4
@@ -0,0 +1,188 @@
1
+ class SpreeCmCommissioner::Integrations::BookMeBusV1::Inventory::UnstockInventory
2
+ prepend ::Spree::ServiceModule::Base
3
+
4
+ # No enclosing DB transaction here (deliberately - see sync_line_item!): BookMeBus has no
5
+ # reservation-cancellation API in production, so once create_reservation! succeeds there is a
6
+ # real, un-undoable external side effect. Wrapping the loop in one transaction used to let a
7
+ # later failure (a different line item, or payment confirmation) roll back the local mapping
8
+ # that recorded an *earlier* line item's already-created BookMeBus reservation - on retry,
9
+ # sync_line_item! would then create a second, duplicate reservation for the same seats.
10
+ def call(integration:, order:, line_items:)
11
+ line_items.each do |line_item|
12
+ raise SpreeCmCommissioner::Integrations::SyncError, 'Invalid guests' if line_item.guests.size != line_item.quantity
13
+
14
+ sync_line_item!(integration, order, line_item)
15
+ end
16
+
17
+ success(order: order, line_items: line_items)
18
+ rescue SpreeCmCommissioner::Integrations::SyncError,
19
+ SpreeCmCommissioner::Integrations::ExternalClientError => e
20
+ failure(nil, e.message)
21
+ end
22
+
23
+ # Sync a single line item with BookMeBus
24
+ def sync_line_item!(integration, order, line_item) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
25
+ existing_mapping = line_item.integration_mappings.find_by(integration_id: integration.id, status: :active)
26
+
27
+ if existing_mapping
28
+ return if existing_mapping.external_payload&.dig('payment_confirmed')
29
+
30
+ # A BookMeBus reservation already exists from a prior attempt (recorded durably, see
31
+ # above), but payment was never confirmed - e.g. confirm_wallet_payment! failed last time
32
+ # and this got retried. We can't safely retry create_reservation! (would duplicate the
33
+ # reservation) and can't cancel the existing one (no API for it), so this needs a human,
34
+ # not another automatic attempt.
35
+ raise SpreeCmCommissioner::Integrations::SyncError,
36
+ "BookMeBus reservation #{existing_mapping.external_id} already exists for line item " \
37
+ "##{line_item.id} but payment was never confirmed - needs manual reconciliation, not an automatic retry"
38
+ end
39
+
40
+ internal_trip_id = line_item.trip_id
41
+ raise SpreeCmCommissioner::Integrations::SyncError, 'trip_id missing from line item metadata' if internal_trip_id.blank?
42
+
43
+ trip_mapping = SpreeCmCommissioner::IntegrationMapping.find_by(
44
+ integration_id: integration.id,
45
+ internal_type: 'SpreeCmCommissioner::Trip',
46
+ internal_id: internal_trip_id,
47
+ status: :active
48
+ )
49
+ raise SpreeCmCommissioner::Integrations::SyncError, "Integration mapping not found for trip ##{internal_trip_id}" if trip_mapping.nil?
50
+
51
+ external_trip_id = trip_mapping.external_id
52
+ raise SpreeCmCommissioner::Integrations::SyncError, 'External trip_id missing from mapping' if external_trip_id.blank?
53
+
54
+ boarding_mapping = find_trip_stop_mapping(integration, line_item.boarding_trip_stop_id)
55
+ drop_off_mapping = find_trip_stop_mapping(integration, line_item.drop_off_trip_stop_id)
56
+
57
+ from_stop_id = boarding_mapping&.external_payload&.dig('stop', 'id') || trip_mapping.external_payload&.dig('from_stop_id')
58
+ to_stop_id = drop_off_mapping&.external_payload&.dig('stop', 'id') || trip_mapping.external_payload&.dig('to_stop_id')
59
+ raise SpreeCmCommissioner::Integrations::SyncError, 'from_stop_id/to_stop_id missing for line item' if from_stop_id.blank? || to_stop_id.blank?
60
+
61
+ from_stop_time_id = boarding_mapping&.external_payload&.dig('id')
62
+ to_stop_time_id = drop_off_mapping&.external_payload&.dig('id')
63
+
64
+ address = order.bill_address || order.ship_address
65
+
66
+ reservation_cart = integration.client.create_reservation!(
67
+ {
68
+ trip_id: external_trip_id.to_i,
69
+ from_stop_id: from_stop_id.to_i,
70
+ to_stop_id: to_stop_id.to_i,
71
+ from_stop_time_id: from_stop_time_id,
72
+ to_stop_time_id: to_stop_time_id,
73
+ departure_date: line_item.from_date.to_date.strftime('%Y-%m-%d'),
74
+ reserved_seats: build_reserved_seats(line_item, from_stop_id, integration),
75
+ contact_name: address&.full_name.presence || order.email,
76
+ contact_email: order.email,
77
+ contact_phone: address&.phone.presence || order.user&.phone,
78
+ internal_order: order
79
+ }
80
+ )
81
+
82
+ # Persist immediately, before anything that can still fail (sync_guests!, confirm_payment!) -
83
+ # this reservation now exists on BookMeBus for real and must be recorded regardless of what
84
+ # happens next (see the no-transaction note on #call).
85
+ line_item_mapping = line_item.integration_mappings.find_or_initialize_by(
86
+ integration: integration,
87
+ external_id: reservation_cart.id
88
+ )
89
+ line_item_mapping.mark_as_active!(external_payload: reservation_cart.to_h)
90
+
91
+ sync_guests!(line_item.guests, reservation_cart, integration)
92
+ confirm_payment!(integration, reservation_cart, order)
93
+
94
+ line_item_mapping.mark_as_active!(external_payload: reservation_cart.to_h.merge('payment_confirmed' => true))
95
+ end
96
+
97
+ # Creates guest mappings and stores QR codes for check-in
98
+ def sync_guests!(guests, reservation_cart, integration)
99
+ reservation = reservation_cart.first_reservation
100
+ return unless reservation
101
+
102
+ guests.each do |guest|
103
+ guest_mapping = guest.integration_mappings.find_or_initialize_by(
104
+ integration: integration,
105
+ external_id: reservation.id
106
+ )
107
+
108
+ guest_mapping.external_qr_data = reservation.reservation_qr_code_url
109
+ guest_mapping.mark_as_active!(external_payload: reservation.to_h)
110
+ end
111
+ end
112
+
113
+ # Create and confirm wallet payment for the reservation
114
+ def confirm_payment!(integration, reservation_cart, order)
115
+ wallet_payment = integration.client.create_wallet_payment!(
116
+ cart_id: reservation_cart.id,
117
+ internal_order: order
118
+ )
119
+
120
+ confirmed_payment = integration.client.confirm_wallet_payment!(
121
+ payment_id: wallet_payment.id,
122
+ internal_order: order
123
+ )
124
+
125
+ raise SpreeCmCommissioner::Integrations::SyncError, 'Payment confirmation failed' unless confirmed_payment.paid?
126
+ end
127
+
128
+ # Find active trip stop mapping for integration
129
+ def find_trip_stop_mapping(integration, trip_stop_id)
130
+ return nil if trip_stop_id.blank?
131
+
132
+ SpreeCmCommissioner::IntegrationMapping.find_by(
133
+ integration_id: integration.id,
134
+ internal_type: 'SpreeCmCommissioner::TripStop',
135
+ internal_id: trip_stop_id,
136
+ status: :active
137
+ )
138
+ end
139
+
140
+ # Build reserved seats hash for BookMeBus API
141
+ #
142
+ # Trips where BookMeBus auto-assigns the seat (Trip#allow_seat_selection false, synced from
143
+ # their auto_assigned_seat flag) never give guests a block - that's expected, not an error.
144
+ # Avocado's own reservation flow (Transit::PassengerSeatCreator/ThirdParty::AutoAssignSeat)
145
+ # expects a full passenger list with label/layer nil in that case, not an empty payload, so it
146
+ # can assign real seats server-side - dropping those guests entirely would undercount
147
+ # number_of_seats and reserve fewer seats than the order was actually paid for.
148
+ def build_reserved_seats(line_item, from_stop_id, integration) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
149
+ seats = line_item.guests.map do |guest|
150
+ seat_label = guest.block&.label
151
+
152
+ if seat_label.blank? && line_item.required_seat_selection?
153
+ raise SpreeCmCommissioner::Integrations::SyncError, "Guest ##{guest.id} on line item ##{line_item.id} has no assigned seat"
154
+ end
155
+
156
+ name = [guest.first_name, guest.last_name].compact.join(' ')
157
+ name = "Guest #{guest.id}" if name.blank?
158
+
159
+ block_type = guest.block&.block_type&.to_s
160
+
161
+ {
162
+ name: name,
163
+ label: seat_label,
164
+ seat_type: block_type == 'seat' || block_type.blank? ? 'normal' : block_type,
165
+ layer: seat_label.present? ? (guest.block&.seat_section&.name || 'BUS') : nil,
166
+ nationality: guest_nationality_code(guest, integration)
167
+ }
168
+ end
169
+
170
+ seats.empty? ? {} : { from_stop_id.to_s => seats }
171
+ end
172
+
173
+ # Mirrors PricingModels::GuestContext's own fallback order (nationality_id, then
174
+ # guest.nationality_group, then guest.saved_guest.nationality_group) so the reservation actually
175
+ # sent to BookMeBus agrees with whatever nationality pricing already used to price the guest.
176
+ # Without this, a guest identified only via nationality_group (e.g. staff selecting it directly
177
+ # in the sell-ticket dashboard, which never sets nationality_id at all) was always reported as
178
+ # 'other' here regardless of what was actually selected.
179
+ def guest_nationality_code(guest, integration)
180
+ code = guest.nationality&.subtitle
181
+ return code.downcase if code.present?
182
+
183
+ group = guest.nationality_group || guest.saved_guest&.nationality_group
184
+ return (integration.vendor.service_origin&.subtitle || 'other').downcase if group == 'local'
185
+
186
+ 'other'
187
+ end
188
+ end
@@ -0,0 +1,79 @@
1
+ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
2
+ # SyncInventory handles inventory synchronization for BookMeBus trips
3
+ #
4
+ # This service is responsible for:
5
+ # - Creating or updating StockItem records
6
+ # - Adjusting inventory counts based on external seat availability
7
+ # - Managing stock locations for vendors
8
+ #
9
+ # Usage:
10
+ # sync_service = SyncInventory.new(sync_result: sync_result, vendor: vendor)
11
+ # sync_service.call(variant: variant, external_trip: external_trip)
12
+ #
13
+ class SyncInventory
14
+ def initialize(sync_result:, vendor:)
15
+ @sync_result = sync_result
16
+ @vendor = vendor
17
+ end
18
+
19
+ def call(variant:, external_trip:)
20
+ return unless variant&.persisted?
21
+
22
+ stock_location = find_or_create_stock_location
23
+ stock_item = find_or_create_stock_item(variant, stock_location)
24
+
25
+ adjust_inventory_if_needed(stock_item, external_trip)
26
+ end
27
+
28
+ private
29
+
30
+ def find_or_create_stock_location
31
+ @vendor.stock_locations.first || @vendor.send(:create_stock_location)
32
+ end
33
+
34
+ def find_or_create_stock_item(variant, stock_location)
35
+ variant.stock_items.find_or_create_by!(stock_location: stock_location) do |item|
36
+ item.count_on_hand = 0
37
+ end
38
+ end
39
+
40
+ def adjust_inventory_if_needed(stock_item, external_trip)
41
+ available_seats = external_trip.available_seats
42
+ return if available_seats.nil? # unknown, not zero - don't zero out real inventory
43
+
44
+ current_stock = stock_item.count_on_hand.to_i
45
+ difference = available_seats.to_i - current_stock
46
+ return if difference.zero?
47
+
48
+ @sync_result.track(:stock_item, stock_item) do
49
+ stock_item.adjust_count_on_hand(difference)
50
+ @sync_result.increment_metric(:inventory, :adjusted, difference.abs)
51
+ end
52
+
53
+ adjust_inventory_item_for_date(stock_item.variant, external_trip, difference)
54
+ end
55
+
56
+ # StockItem.count_on_hand isn't date-aware and isn't read for real availability checks
57
+ # (see docs/concepts/inventory-system.md) - apply the same delta to the specific date's
58
+ # InventoryItem so ongoing BookMeBus availability changes actually reach bookable inventory.
59
+ # If it doesn't exist yet, PermanentInventoryItemsGeneratorJob will seed it later from
60
+ # count_on_hand, so there's nothing to adjust yet.
61
+ def adjust_inventory_item_for_date(variant, external_trip, difference)
62
+ trip_date = parse_trip_date(external_trip.on_date)
63
+ return unless trip_date
64
+
65
+ inventory_item = variant.inventory_items.find_by(inventory_date: trip_date)
66
+ return unless inventory_item
67
+
68
+ inventory_item.adjust_quantity!(difference)
69
+ end
70
+
71
+ def parse_trip_date(date_string)
72
+ return nil if date_string.blank?
73
+
74
+ Date.parse(date_string.to_s)
75
+ rescue ArgumentError, TypeError
76
+ nil
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,354 @@
1
+ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
2
+ # SyncItemAvailability syncs pricing and reserved blocks from BookMeBus API
3
+ #
4
+ # BookMeBus trips have an `is_selectable` flag that determines sync behavior:
5
+ # - is_selectable == true: Sync reserved blocks (booked seats)
6
+ # - is_selectable == false: Only update seats_remaining count
7
+ #
8
+ # Usage:
9
+ # SyncItemAvailability.new(integration: integration, sync_result: sync_result)
10
+ # .call(trip_id: 1, on_date: '2026-04-06')
11
+ class SyncItemAvailability # rubocop:disable Metrics/ClassLength
12
+ include SpreeCmCommissioner::Integrations::InventorySyncCachable
13
+
14
+ # Sentinel returned by fetch_external_trip to mean "we got a real, successful response from
15
+ # BookMeBus, and this trip is confirmed absent/unbookable for this date" - distinct from a
16
+ # plain nil, which means we couldn't determine that (missing local mapping, or the API call
17
+ # itself failed) and must leave existing inventory alone rather than guess.
18
+ UNAVAILABLE = :unavailable
19
+ private_constant :UNAVAILABLE
20
+
21
+ def initialize(integration:, sync_result: nil)
22
+ @integration = integration
23
+ @sync_result = sync_result || SpreeCmCommissioner::Integrations::Base::SyncResult.new
24
+ end
25
+
26
+ def call(trip_id:, on_date:)
27
+ trip = find_trip(trip_id)
28
+ return @sync_result unless trip
29
+
30
+ inventory_items = find_inventory_items_for_trip(trip, on_date)
31
+ return @sync_result if inventory_items.empty?
32
+
33
+ inventory_items.each do |inventory_item|
34
+ sync_item(trip, on_date, inventory_item)
35
+ end
36
+ # return the most recent last_synced_at from the inventory item mappings for this trip and date
37
+ @sync_result.last_synced_at =
38
+ inventory_items.last
39
+ &.integration_mappings
40
+ &.maximum(:last_synced_at)
41
+
42
+ @sync_result
43
+ rescue StandardError => e
44
+ @sync_result.record_error(e)
45
+ CmAppLogger.error(label: '[BookMeBusV1::SyncItemAvailability] sync failed', data: { error: e.message })
46
+ @sync_result
47
+ end
48
+
49
+ def sync_item(trip, on_date, inventory_item)
50
+ inventory_item_mapping = find_or_initialize_item_mapping(inventory_item)
51
+
52
+ return unless should_sync_item?(inventory_item_mapping)
53
+
54
+ ActiveRecord::Base.transaction do
55
+ external_trip = fetch_external_trip(trip, on_date)
56
+
57
+ case external_trip
58
+ when UNAVAILABLE
59
+ # A confirmed response (not a failed/ambiguous lookup - see fetch_external_trip) shows
60
+ # this trip is absent or no longer bookable for this specific date, while an
61
+ # already-synced InventoryItem here may still show real seats from a previous sync.
62
+ # Zero it out so BookMe+ stops selling a date BookMeBus has pulled, rather than only
63
+ # discovering that at the very end of checkout, after payment has been taken.
64
+ zero_out_inventory!(inventory_item)
65
+ when nil
66
+ return # rubocop:disable Rails/TransactionExitStatement -- couldn't determine (missing mapping data) - leave existing inventory untouched rather than guess
67
+ else
68
+ sync_price(inventory_item, external_trip)
69
+
70
+ # Sync strategy depends on trip type and seat availability
71
+ if should_sync_reserved_blocks?(external_trip)
72
+ sync_reserved_blocks(external_trip, on_date, inventory_item)
73
+ else
74
+ sync_stock_item(inventory_item, external_trip)
75
+ end
76
+
77
+ restore_inventory_if_previously_zeroed!(inventory_item, external_trip)
78
+ end
79
+
80
+ update_last_sync_at(inventory_item_mapping)
81
+ end
82
+ rescue StandardError => e
83
+ @sync_result.record_error(e)
84
+ CmAppLogger.error(label: '[BookMeBusV1::SyncItemAvailability] error', data: { inventory_item_id: inventory_item.id, error: e.message })
85
+ end
86
+
87
+ private
88
+
89
+ def client
90
+ @client ||= @integration.client
91
+ end
92
+
93
+ def find_trip(trip_id)
94
+ SpreeCmCommissioner::Trip.find_by(id: trip_id)
95
+ end
96
+
97
+ def should_sync_item?(mapping)
98
+ mapping.new_record? || mapping.last_synced_at.nil? || mapping.last_synced_at < sync_cache_ttl.ago
99
+ end
100
+
101
+ def should_sync_reserved_blocks?(external_trip)
102
+ # is_selectable is the field this class's own doc comment describes, but it was never
103
+ # actually wired up as an accessor - this used to fall back to a route_type heuristic that
104
+ # doesn't capture the real thing we need to know (whether BookMeBus exposes per-seat status
105
+ # for this trip at all, e.g. false for auto-assigned-seat trips). Keep the private_taxi
106
+ # exclusion too, defensively, since seat layouts don't apply there either.
107
+ external_trip.is_selectable && external_trip.route_type != 'private_taxi'
108
+ end
109
+
110
+ def find_inventory_items_for_trip(trip, on_date)
111
+ SpreeCmCommissioner::InventoryItem
112
+ .joins(variant: :product)
113
+ .where(spree_products: { id: trip.product_id })
114
+ .where(cm_inventory_items: { inventory_date: on_date })
115
+ end
116
+
117
+ def find_or_initialize_item_mapping(inventory_item)
118
+ SpreeCmCommissioner::IntegrationMapping.find_or_initialize_by(
119
+ integration_id: @integration.id,
120
+ internal_type: 'SpreeCmCommissioner::InventoryItem',
121
+ internal_id: inventory_item.id,
122
+ external_id: "#{inventory_item.id}-#{inventory_item.inventory_date}"
123
+ )
124
+ end
125
+
126
+ def trip_mapping_data(trip)
127
+ # Retrieves BookMeBus API identifiers (trip_id) from the integration mapping
128
+ # These are required to query the BookMeBus API for seat availability
129
+ mapping = SpreeCmCommissioner::IntegrationMapping.find_by(
130
+ integration_id: @integration.id,
131
+ internal_type: 'SpreeCmCommissioner::Trip',
132
+ internal_id: trip.id,
133
+ status: :active
134
+ )
135
+ return unless mapping
136
+
137
+ trip_id = mapping.external_payload&.dig('trip_id')
138
+ return unless trip_id
139
+
140
+ { trip_id: trip_id }
141
+ end
142
+
143
+ def fetch_external_trip(trip, on_date)
144
+ data = trip_mapping_data(trip)
145
+ return unless data # can't determine - no local trip mapping to look up
146
+
147
+ origin_id = find_external_location_id(trip.origin_place)
148
+ destination_id = find_external_location_id(trip.destination_place)
149
+ return unless origin_id && destination_id # can't determine - location mapping missing
150
+
151
+ # A real ExternalClientError now propagates out of fetch_external_trips (rather than being
152
+ # swallowed into an indistinguishable []) up to sync_item's own rescue, which correctly
153
+ # leaves inventory untouched - only a genuinely successful, possibly-empty response reaches
154
+ # the line below, so an empty result here is a confirmed "not available", not a guess.
155
+ external_trips = fetch_external_trips(origin_id, destination_id, on_date)
156
+ found = external_trips.find { |t| t.trip_id == data[:trip_id] }
157
+
158
+ return UNAVAILABLE if found.nil? || !found.bookable?
159
+
160
+ found
161
+ end
162
+
163
+ def find_external_location_id(place)
164
+ return unless place
165
+
166
+ vendor_place = SpreeCmCommissioner::VendorPlace.location.find_by(place_id: place.id, vendor_id: @integration.vendor.id)
167
+ return unless vendor_place
168
+
169
+ vendor_place.integration_mappings.find_by(integration_id: @integration.id, status: :active)&.external_id
170
+ end
171
+
172
+ def fetch_external_trips(origin_id, destination_id, on_date)
173
+ # No rescue here - a real ExternalClientError must propagate up to sync_item's own rescue
174
+ # (which records/logs it and leaves inventory untouched), not collapse into the same []
175
+ # a genuinely successful-but-empty response produces. Conflating the two would mean a
176
+ # transient API failure gets treated as BookMeBus confirming the trip is unavailable,
177
+ # incorrectly zeroing out real inventory.
178
+ @sync_result.track_api_call('search_trips') do
179
+ client.search_trips!(
180
+ origin_id: origin_id,
181
+ destination_id: destination_id,
182
+ on_date: on_date
183
+ ) || []
184
+ end
185
+ end
186
+
187
+ # Correct a stale-positive InventoryItem for a date BookMeBus has confirmed is no longer
188
+ # available (see fetch_external_trip's UNAVAILABLE sentinel) - without this, a date that was
189
+ # available on a previous sync stays sellable on BookMe+ indefinitely even after BookMeBus
190
+ # pulls it, since nothing else ever corrects an already-synced InventoryItem back down.
191
+ def zero_out_inventory!(inventory_item)
192
+ inventory_item.with_lock do
193
+ next if inventory_item.quantity_available.to_i.zero?
194
+
195
+ set_quantity_available_with_redis_sync!(inventory_item, 0)
196
+ @sync_result.increment_metric(:inventory, :zeroed_unavailable_date)
197
+ end
198
+ end
199
+
200
+ # If a previous sync zeroed this date out (zero_out_inventory!, because the trip was confirmed
201
+ # unavailable then) and BookMeBus now confirms it's bookable again with real seats, restore
202
+ # it. sync_stock_item alone can't be relied on for this: its delta is computed against
203
+ # StockItem#count_on_hand (a date-independent aggregate zero_out_inventory! never touches -
204
+ # deliberately, since it's not specific to this one date), so once available_seats matches
205
+ # that unrelated baseline again the delta comes back 0 and this InventoryItem would otherwise
206
+ # stay stuck at the 0 we forced it to, even though the date is genuinely bookable again.
207
+ def restore_inventory_if_previously_zeroed!(inventory_item, external_trip)
208
+ available_seats = external_trip.available_seats
209
+ return if available_seats.to_i.zero?
210
+
211
+ inventory_item.with_lock do
212
+ next unless inventory_item.quantity_available.to_i.zero?
213
+
214
+ set_quantity_available_with_redis_sync!(inventory_item, available_seats.to_i)
215
+ @sync_result.increment_metric(:inventory, :restored_after_unavailable_date)
216
+ end
217
+ end
218
+
219
+ # The real checkout-time availability check (Stock::AvailabilityChecker ->
220
+ # RedisStock::VariantCachedInventoryItemsBuilder) reads Redis, not the DB row directly - a
221
+ # plain `inventory_item.update!(quantity_available: ...)` looked correct here but left Redis
222
+ # stale, so checkout kept rejecting real purchases even after the DB value was fixed
223
+ # (confirmed live). `adjust_quantity!` is the sanctioned path that keeps both in sync via
224
+ # adjust_quantity_in_redis - but it also shifts max_capacity by the same delta, which isn't
225
+ # wanted here (max_capacity is the seat ceiling for whenever the trip runs, not something
226
+ # that should shrink/grow because today's availability changed), so restore it right after.
227
+ def set_quantity_available_with_redis_sync!(inventory_item, target_quantity)
228
+ original_max_capacity = inventory_item.max_capacity
229
+ delta = target_quantity - inventory_item.quantity_available
230
+ return if delta.zero?
231
+
232
+ inventory_item.adjust_quantity!(delta)
233
+ inventory_item.update_column(:max_capacity, original_max_capacity) # rubocop:disable Rails/SkipsModelValidations
234
+ end
235
+
236
+ def sync_price(inventory_item, external_trip)
237
+ price_amount = external_trip.sellable_price
238
+ return if price_amount.nil?
239
+
240
+ price = inventory_item.prices.find_or_initialize_by(currency: Spree::Store.default.default_currency)
241
+
242
+ @sync_result.track(:price, price) do |tracker|
243
+ price.assign_attributes(
244
+ amount: price_amount,
245
+ variant_id: inventory_item.variant_id,
246
+ inventory_item_id: inventory_item.id
247
+ )
248
+
249
+ tracker.save_if_changed!(price)
250
+ end
251
+
252
+ sync_nationality_pricing(inventory_item, external_trip)
253
+ end
254
+
255
+ # Keeps the vendor's local/non-local discount PricingModel current every time this trip's
256
+ # price is refreshed - additive to sellable_price above (which stays the base everyone pays
257
+ # by default), not a replacement for it. Never lets a pricing-model hiccup break the
258
+ # availability/price sync it's piggybacking on.
259
+ def sync_nationality_pricing(inventory_item, external_trip)
260
+ trip = inventory_item.variant&.trip
261
+ return unless trip&.route
262
+
263
+ SyncNationalityPricing.new(
264
+ route: trip.route,
265
+ vendor: @integration.vendor,
266
+ local_price: external_trip.local_price,
267
+ non_local_price: external_trip.non_local_price
268
+ ).call
269
+ rescue StandardError => e
270
+ @sync_result.record_error(e)
271
+ CmAppLogger.error(label: '[BookMeBusV1::SyncItemAvailability] nationality pricing sync failed', data: { error: e.message })
272
+ end
273
+
274
+ def sync_stock_item(inventory_item, external_trip)
275
+ sync_inventory = SpreeCmCommissioner::Integrations::BookMeBusV1::Polling::SyncInventory.new(
276
+ sync_result: @sync_result,
277
+ vendor: inventory_item.variant.vendor
278
+ )
279
+ sync_inventory.call(variant: inventory_item.variant, external_trip: external_trip)
280
+ end
281
+
282
+ def sync_reserved_blocks(external_trip, _on_date, inventory_item)
283
+ external_seat_layout = fetch_external_seat_layout(external_trip)
284
+ return unless external_seat_layout
285
+
286
+ trip_seat_layout = inventory_item.variant.trip&.seat_layout
287
+ return unless trip_seat_layout
288
+
289
+ sync_reserved_blocks_from_layout(trip_seat_layout, external_seat_layout, inventory_item)
290
+ end
291
+
292
+ def fetch_external_seat_layout(external_trip)
293
+ @sync_result.track_api_call('get_seat_layout') do
294
+ client.get_seat_layout!(
295
+ trip_id: external_trip.id,
296
+ from_stop_id: external_trip.from_stop_id,
297
+ to_stop_id: external_trip.to_stop_id,
298
+ on_date: external_trip.on_date || Time.zone.today.to_s
299
+ )
300
+ end
301
+ rescue SpreeCmCommissioner::Integrations::ExternalClientError => e
302
+ CmAppLogger.error(label: '[BookMeBusV1::SyncItemAvailability] get_seat_layout failed', data: { error: e.message })
303
+ @sync_result.record_error(e)
304
+ nil
305
+ end
306
+
307
+ def sync_reserved_blocks_from_layout(seat_layout, external_seat_layout, inventory_item)
308
+ external_seats_by_label = external_seat_layout.seats.index_by(&:label)
309
+
310
+ seat_layout.blocks.each do |block|
311
+ next unless block.sellable?
312
+
313
+ external_seat = external_seats_by_label[block.label]
314
+ next unless external_seat
315
+
316
+ reserved_block = SpreeCmCommissioner::ReservedBlock.find_or_initialize_by(
317
+ inventory_item: inventory_item,
318
+ block: block
319
+ )
320
+
321
+ if external_seat.booked?
322
+ next if reserved_block.reserved?
323
+
324
+ @sync_result.track(:reserved_block, reserved_block) do |tracker|
325
+ reserved_block.assign_attributes(
326
+ status: :reserved,
327
+ expired_at: nil,
328
+ inventory_date: inventory_item.inventory_date,
329
+ guest: nil,
330
+ updated_by: nil
331
+ )
332
+ tracker.save_if_changed!(reserved_block)
333
+ end
334
+ elsif reserved_block.persisted?
335
+ # Locked, and guest_id re-checked after acquiring the lock, so a real customer hold
336
+ # being created concurrently on this exact block can't be destroyed out from under them.
337
+ reserved_block.with_lock do
338
+ next unless reserved_block.guest_id.nil?
339
+
340
+ @sync_result.track(:reserved_block, reserved_block) do |_tracker|
341
+ reserved_block.destroy!
342
+ @sync_result.increment_metric(:reserved_block, :deleted)
343
+ end
344
+ end
345
+ end
346
+ end
347
+ end
348
+
349
+ def update_last_sync_at(mapping)
350
+ mapping.last_synced_at = Time.current
351
+ mapping.save! if mapping.changed?
352
+ end
353
+ end
354
+ end