spree_cm_commissioner 2.5.0.pre.pre13 → 2.5.0.pre.pre14

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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +4 -0
  3. data/Gemfile.lock +1 -1
  4. data/app/controllers/spree/admin/base_controller_decorator.rb +7 -33
  5. data/app/controllers/spree/api/v2/storefront/guests_controller.rb +7 -51
  6. data/app/controllers/spree/api/v2/storefront/intercity_taxi/distance_calculator_controller.rb +47 -0
  7. data/app/controllers/spree/api/v2/storefront/intercity_taxi/draft_orders_controller.rb +7 -5
  8. data/app/controllers/spree/api/v2/storefront/trip_search_controller.rb +2 -2
  9. data/app/controllers/spree/api/v2/tenant/intercity_taxi/distance_calculator_controller.rb +47 -0
  10. data/app/controllers/spree/api/v2/tenant/intercity_taxi/draft_orders_controller.rb +7 -5
  11. data/app/controllers/spree/api/v2/tenant/trip_search_controller.rb +2 -2
  12. data/app/interactors/spree_cm_commissioner/google_routes_distance_calculator.rb +0 -13
  13. data/app/interactors/spree_cm_commissioner/invalidate_cache_request.rb +3 -3
  14. data/app/interactors/spree_cm_commissioner/vehicle_type_updater.rb +41 -0
  15. data/app/jobs/spree_cm_commissioner/invalidate_cache_request_job.rb +1 -1
  16. data/app/mailers/spree/order_mailer_decorator.rb +22 -1
  17. data/app/models/concerns/spree_cm_commissioner/line_item_seat_selection.rb +40 -0
  18. data/app/models/concerns/spree_cm_commissioner/line_item_transitable.rb +1 -1
  19. data/app/models/concerns/spree_cm_commissioner/vehicle_kind.rb +21 -0
  20. data/app/models/spree_cm_commissioner/adjustable/adjuster/pricing_action.rb +25 -0
  21. data/app/models/spree_cm_commissioner/guest.rb +1 -1
  22. data/app/models/spree_cm_commissioner/guest_dynamic_field.rb +3 -0
  23. data/app/models/spree_cm_commissioner/homepage_background_app_image.rb +1 -1
  24. data/app/models/spree_cm_commissioner/invite_guest.rb +2 -5
  25. data/app/models/spree_cm_commissioner/line_item_decorator.rb +2 -2
  26. data/app/models/spree_cm_commissioner/option_type_decorator.rb +4 -1
  27. data/app/models/spree_cm_commissioner/option_value_decorator.rb +4 -1
  28. data/app/models/spree_cm_commissioner/{option_value_vehicle.rb → option_value_vehicle_type.rb} +2 -2
  29. data/app/models/spree_cm_commissioner/trip.rb +7 -6
  30. data/app/models/spree_cm_commissioner/vehicle.rb +8 -20
  31. data/app/models/spree_cm_commissioner/vehicle_type.rb +28 -0
  32. data/app/models/spree_cm_commissioner/vehicle_type_option_type.rb +6 -0
  33. data/app/models/spree_cm_commissioner/vendor_decorator.rb +1 -0
  34. data/app/overrides/spree/admin/products/edit/clear_cache_button.html.erb.deface +1 -1
  35. data/app/overrides/spree/admin/taxons/edit/clear_cache_button.html.erb.deface +3 -2
  36. data/app/queries/spree_cm_commissioner/trip_query.rb +5 -5
  37. data/app/request_schemas/spree_cm_commissioner/intercity_taxi_draft_order_update_schema.rb +2 -0
  38. data/app/serializers/spree/v2/storefront/line_item_serializer_decorator.rb +12 -0
  39. data/app/serializers/spree/v2/tenant/line_item_serializer.rb +14 -1
  40. data/app/serializers/spree_cm_commissioner/v2/storefront/distance_serializer.rb +26 -0
  41. data/app/serializers/spree_cm_commissioner/v2/storefront/intercity_taxi_line_item_serializer.rb +3 -3
  42. data/app/serializers/spree_cm_commissioner/v2/storefront/trip_result_serializer.rb +2 -2
  43. data/app/serializers/spree_cm_commissioner/v2/storefront/trip_serializer.rb +1 -1
  44. data/app/serializers/spree_cm_commissioner/v2/storefront/{trip_vehicle_serializer.rb → trip_vehicle_type_serializer.rb} +2 -4
  45. data/app/services/spree_cm_commissioner/api_caches/invalidate.rb +98 -0
  46. data/app/services/spree_cm_commissioner/calculate_distance.rb +279 -0
  47. data/app/services/spree_cm_commissioner/cart/recalculate_decorator.rb +28 -1
  48. data/app/services/spree_cm_commissioner/guests/claim_invite_guest_service.rb +65 -0
  49. data/app/services/spree_cm_commissioner/intercity_taxi_order/calculate_distance.rb +224 -0
  50. data/app/services/spree_cm_commissioner/intercity_taxi_order/update.rb +30 -3
  51. data/app/services/spree_cm_commissioner/line_items/apply_pricing_models.rb +27 -0
  52. data/app/services/spree_cm_commissioner/pricing_models/apply.rb +4 -12
  53. data/app/services/spree_cm_commissioner/signing/sign_data.rb +42 -0
  54. data/app/services/spree_cm_commissioner/signing/verify_signature.rb +52 -0
  55. data/app/services/spree_cm_commissioner/transit/export_order.rb +146 -0
  56. data/app/services/spree_cm_commissioner/update_guest_service.rb +129 -0
  57. data/app/views/spree/admin/homepage_section/edit.html.erb +7 -2
  58. data/app/views/spree/order_mailer/cancel_email.html.erb +19 -0
  59. data/app/views/spree_cm_commissioner/cancel_order_mailer/_cancel_email.html.erb +12 -0
  60. data/app/views/spree_cm_commissioner/cancel_order_mailer/_footer.html.erb +8 -0
  61. data/app/views/spree_cm_commissioner/cancel_order_mailer/_header.html.erb +3 -0
  62. data/app/views/spree_cm_commissioner/cancel_order_mailer/_mailer_stylesheets.html.erb +139 -0
  63. data/app/views/spree_cm_commissioner/cancel_order_mailer/_your_booking.html.erb +8 -0
  64. data/app/views/spree_cm_commissioner/cancel_order_mailer/purchased_items/_items.html.erb +53 -0
  65. data/app/views/spree_cm_commissioner/cancel_order_mailer/purchased_items/_summary.html.erb +108 -0
  66. data/app/views/spree_cm_commissioner/cancel_order_mailer/tenant/_cancel_email.html.erb +12 -0
  67. data/app/views/spree_cm_commissioner/cancel_order_mailer/tenant/_footer.html.erb +8 -0
  68. data/app/views/spree_cm_commissioner/cancel_order_mailer/tenant/_header.html.erb +15 -0
  69. data/app/views/spree_cm_commissioner/layouts/cancel_order_mailer.html.erb +16 -0
  70. data/config/locales/en.yml +15 -0
  71. data/config/locales/km.yml +27 -2
  72. data/config/routes.rb +2 -0
  73. data/db/migrate/20251127074809_change_guest_dynamic_fields_value_from_jsonb_to_text.rb +40 -0
  74. data/db/migrate/20251219035243_add_migrations_to_support_vehicle_types.rb +36 -0
  75. data/lib/spree_cm_commissioner/distance.rb +88 -0
  76. data/lib/spree_cm_commissioner/engine.rb +3 -0
  77. data/lib/spree_cm_commissioner/test_helper/factories/guest_factory.rb +2 -2
  78. data/lib/spree_cm_commissioner/test_helper/factories/line_item_factory.rb +3 -3
  79. data/lib/spree_cm_commissioner/test_helper/factories/pricing_model_variant_factory.rb +6 -0
  80. data/lib/spree_cm_commissioner/test_helper/factories/seat_layout_factory.rb +1 -1
  81. data/lib/spree_cm_commissioner/test_helper/factories/trip_factory.rb +1 -0
  82. data/lib/spree_cm_commissioner/test_helper/factories/vehicle_factory.rb +4 -1
  83. data/lib/spree_cm_commissioner/test_helper/factories/vehicle_type_factory.rb +7 -0
  84. data/lib/spree_cm_commissioner/trip_query_result.rb +1 -1
  85. data/lib/spree_cm_commissioner/trip_result.rb +7 -1
  86. data/lib/spree_cm_commissioner/version.rb +1 -1
  87. data/lib/spree_cm_commissioner.rb +1 -1
  88. metadata +39 -10
  89. data/app/interactors/spree_cm_commissioner/trip_distance_calculator.rb +0 -165
  90. data/app/interactors/spree_cm_commissioner/vehicle_updater.rb +0 -41
  91. data/app/models/concerns/spree_cm_commissioner/vehicle_type.rb +0 -11
  92. data/app/models/spree_cm_commissioner/vehicle_option_type.rb +0 -6
  93. data/lib/spree_cm_commissioner/intercity_taxi/distance.rb +0 -38
  94. data/lib/spree_cm_commissioner/test_helper/factories/vendor_route_factory.rb +0 -7
@@ -0,0 +1,279 @@
1
+ # Computes trip distance details for intercity taxi trips.
2
+ #
3
+ # == Purpose
4
+ # Calculate total distance, detour distances, and chargeable "extra km"
5
+ # when pickups/dropoffs deviate from the standard intercity route.
6
+ #
7
+ # == INPUT (keyword args)
8
+ # trip_id: [Integer] REQUIRED - SpreeCmCommissioner::Trip id
9
+ # pickups_attributes: [Array] Optional waypoints BEFORE the route (e.g. hotel pickup)
10
+ # dropoffs_attributes: [Array] Required waypoints AT/NEAR destination (e.g. specific address)
11
+ # pickup_oob_confirmed: [Boolean] If true, charge extra km for pickup detours beyond boundary
12
+ # dropoff_oob_confirmed:[Boolean] If true, charge extra km for dropoff detours beyond boundary
13
+ #
14
+ # Waypoint format (understood by GoogleRoutesDistanceCalculator):
15
+ # { lat: Float, lng: Float }
16
+ #
17
+ # == OUTPUT (Result object)
18
+ # result.success? => true/false
19
+ # result.failure? => true/false
20
+ # result.value => SpreeCmCommissioner::Distance (on success)
21
+ # result.error => String error message (on failure)
22
+ #
23
+ # == Distance Value Object Fields
24
+ # distance_km: Total route distance (with all waypoints)
25
+ # ordered_points: Array of { lat:, lng: } in travel order
26
+ # estimated_time_minutes: ETA from Google (nil for simple routes)
27
+ # base_km: Distance without any waypoints (origin → destination)
28
+ # detour_pickup_km: Distance when only pickups are added (nil if not calculated)
29
+ # detour_dropoff_km: Distance when only dropoffs are added (nil if not calculated)
30
+ # extra_pickup_km: Chargeable km beyond pickup boundary (0.0 if within free zone)
31
+ # extra_dropoff_km: Chargeable km beyond dropoff boundary (0.0 if within free zone)
32
+ #
33
+ # == Example Usage
34
+ # result = SpreeCmCommissioner::CalculateDistance.call(
35
+ # trip_id: 123,
36
+ # pickups_attributes: [{ lat: 11.55, lng: 104.91 }],
37
+ # dropoffs_attributes: [{ lat: 13.36, lng: 103.86 }],
38
+ # pickup_oob_confirmed: true,
39
+ # dropoff_oob_confirmed: true
40
+ # )
41
+ # if result.success?
42
+ # distance = result.value
43
+ # puts "Total: #{distance.distance_km} km, Extra pickup charge: #{distance.extra_pickup_km} km"
44
+ # else
45
+ # puts "Error: #{result.error}"
46
+ # end
47
+ #
48
+ module SpreeCmCommissioner
49
+ class CalculateDistance
50
+ prepend ::Spree::ServiceModule::Base
51
+
52
+ def call(
53
+ pickups_attributes: [],
54
+ dropoffs_attributes: [],
55
+ pickup_oob_confirmed: false,
56
+ dropoff_oob_confirmed: false,
57
+ trip_id: nil
58
+ )
59
+ return failure(nil, 'trip_id is required') if trip_id.blank?
60
+
61
+ @trip_id = trip_id
62
+ @pickup_oob_confirmed = pickup_oob_confirmed
63
+ @dropoff_oob_confirmed = dropoff_oob_confirmed
64
+
65
+ @pickups_points = Array(pickups_attributes).compact
66
+ @dropoffs_points = Array(dropoffs_attributes).compact
67
+
68
+ @final_destination = @dropoffs_points.last
69
+
70
+ return failure(nil, 'Trip origin location is not configured') if origin_point.blank?
71
+ return failure(nil, 'Trip destination location is not configured') if destination_point.blank?
72
+ return failure(nil, 'At least one dropoff is required') if @dropoffs_points.empty?
73
+
74
+ # Step 1: Calculate base route (no waypoints) - this is the reference for detour calculations
75
+ base_km = calculate_base_route_km
76
+ return failure(nil, 'Unable to calculate base route') if base_km.nil?
77
+
78
+ # Step 2: Calculate full route with all waypoints (optimized order)
79
+ details_ctx = fetch_full_route_details
80
+ return failure(nil, details_ctx.error || 'Unable to calculate route') unless details_ctx.success?
81
+
82
+ # Step 3: Calculate detour distances and derive extra chargeable km
83
+ detour_distances = calculate_detour_distances
84
+ build_result(details_ctx, base_km, detour_distances)
85
+ rescue StandardError => e
86
+ failure(nil, e.message)
87
+ end
88
+
89
+ private
90
+
91
+ # ===========================================
92
+ # DISTANCE CALCULATIONS
93
+ # ===========================================
94
+
95
+ # Base route: origin → destination (no waypoints)
96
+ # This is the "ideal" shortest route the operator priced for.
97
+ def calculate_base_route_km
98
+ compute_km(origin: origin_point, destination: destination_point)
99
+ end
100
+
101
+ # Full route: origin → pickups → dropoffs → final_destination
102
+ # Google optimizes waypoint order for shortest path.
103
+ def fetch_full_route_details
104
+ SpreeCmCommissioner::GoogleRoutesDistanceCalculator.call(
105
+ origin: origin_point,
106
+ destination: @final_destination,
107
+ pickups: @pickups_points,
108
+ dropoffs: @dropoffs_points,
109
+ optimize: true
110
+ )
111
+ end
112
+
113
+ # Detour distances are calculated separately for pickups and dropoffs.
114
+ # These help us determine how much extra distance each type of waypoint adds.
115
+ def calculate_detour_distances
116
+ {
117
+ pickup: calculate_pickup_detour_km,
118
+ dropoff: calculate_dropoff_detour_km
119
+ }
120
+ end
121
+
122
+ # Pickup detour: origin → pickups → destination (no dropoffs)
123
+ # Only calculated if pickups exist AND user confirmed OOB charge.
124
+ def calculate_pickup_detour_km
125
+ return nil unless @pickups_points.any? && @pickup_oob_confirmed
126
+
127
+ compute_km(
128
+ origin: origin_point,
129
+ destination: destination_point,
130
+ pickups: @pickups_points,
131
+ optimize: false # Keep pickup order as specified
132
+ )
133
+ end
134
+
135
+ # Dropoff detour: origin → dropoffs → final_destination (no pickups)
136
+ # Only calculated if dropoffs exist AND user confirmed OOB charge.
137
+ def calculate_dropoff_detour_km
138
+ return nil unless @dropoffs_points.any? && @dropoff_oob_confirmed
139
+
140
+ compute_km(
141
+ origin: origin_point,
142
+ destination: @final_destination,
143
+ dropoffs: @dropoffs_points,
144
+ optimize: false # Keep dropoff order as specified
145
+ )
146
+ end
147
+
148
+ # ===========================================
149
+ # RESULT BUILDERS
150
+ # ===========================================
151
+
152
+ # Full result with waypoints - includes detour and extra km calculations
153
+ def build_result(details_ctx, base_km, detour_distances)
154
+ # Calculate how many extra km each detour adds compared to base route
155
+ # Example: If base = 50km and pickup_detour = 58km, then increment = 8km
156
+ pickup_increment_km = detour_distances[:pickup].present? ? detour_distances[:pickup] - base_km : 0.0
157
+ dropoff_increment_km = detour_distances[:dropoff].present? ? detour_distances[:dropoff] - base_km : 0.0
158
+
159
+ # Calculate chargeable extra km (beyond free boundary allowance)
160
+ extra_pickup_km = compute_extra_km(pickup_increment_km, pickup_boundary_km, @pickup_oob_confirmed)
161
+ extra_dropoff_km = compute_extra_km(dropoff_increment_km, dropoff_boundary_km, @dropoff_oob_confirmed)
162
+
163
+ distance = SpreeCmCommissioner::Distance.new(
164
+ distance_km: details_ctx.distance_km,
165
+ ordered_points: details_ctx.ordered_points,
166
+ estimated_time_minutes: details_ctx.estimated_time_minutes,
167
+ base_km: base_km,
168
+ detour_pickup_km: detour_distances[:pickup],
169
+ detour_dropoff_km: detour_distances[:dropoff],
170
+ extra_pickup_km: extra_pickup_km,
171
+ extra_dropoff_km: extra_dropoff_km
172
+ )
173
+
174
+ success(distance)
175
+ end
176
+
177
+ # ===========================================
178
+ # EXTRA KM CALCULATION
179
+ #
180
+ # "Extra km" = detour km that exceeds a free boundary allowance.
181
+ # - Operators allow some detour for free (e.g., 5km for pickups)
182
+ # - Only km beyond that boundary is charged
183
+ #
184
+ # Example:
185
+ # base_km = 50, detour_km = 58 → increment = 8km
186
+ # boundary = 5km → extra = 8 - 5 = 3km chargeable
187
+ # ===========================================
188
+
189
+ # Calculate chargeable extra km beyond the free boundary
190
+ #
191
+ # @param increment_km [Float] How much longer the detour is vs base
192
+ # @param boundary_km [Float] Free allowance (no charge below this)
193
+ # @param confirmed [Boolean] Whether OOB charge was confirmed
194
+ # @return [Float] Chargeable km (0.0 if within free zone or not confirmed)
195
+ def compute_extra_km(increment_km, boundary_km, confirmed)
196
+ return 0.0 unless confirmed && increment_km
197
+
198
+ extra = increment_km - boundary_km.to_f
199
+ extra.positive? ? extra.round(2) : 0.0
200
+ end
201
+
202
+ # ===========================================
203
+ # GOOGLE ROUTES INTEGRATION
204
+ # ===========================================
205
+
206
+ # Wrapper to call GoogleRoutesDistanceCalculator and extract distance_km
207
+ # Returns nil if the calculation fails.
208
+ def compute_km(origin:, destination:, pickups: nil, dropoffs: nil, optimize: nil)
209
+ context = SpreeCmCommissioner::GoogleRoutesDistanceCalculator.call(
210
+ origin: origin,
211
+ destination: destination,
212
+ pickups: pickups,
213
+ dropoffs: dropoffs,
214
+ optimize: optimize
215
+ )
216
+ return nil unless context.success?
217
+
218
+ context.distance_km
219
+ end
220
+
221
+ # ===========================================
222
+ # BOUNDARIES (Free Detour Allowance)
223
+ #
224
+ # TODO: Allow operators to configure these per trip/vendor (in the future)
225
+ # ===========================================
226
+
227
+ # Free pickup detour allowance (km)
228
+ def pickup_boundary_km
229
+ 5.0
230
+ end
231
+
232
+ # Free dropoff detour allowance (km)
233
+ def dropoff_boundary_km
234
+ 3.0
235
+ end
236
+
237
+ # ===========================================
238
+ # TRIP HELPERS - Resolve origin/destination from Trip
239
+ # ===========================================
240
+
241
+ def trip
242
+ @trip ||= SpreeCmCommissioner::Trip.find(@trip_id)
243
+ end
244
+
245
+ # Get origin point from trip's vendor location configuration
246
+ # Returns { lat:, lng: } hash or nil
247
+ def origin_point
248
+ @origin_point ||=
249
+ if trip.blank?
250
+ nil
251
+ else
252
+ location = trip.vendor.locations.find_by(place: trip.origin_place)
253
+ to_point(location&.branches&.first&.place)
254
+ end
255
+ end
256
+
257
+ # Get destination point from trip's vendor location configuration
258
+ # Returns { lat:, lng: } hash or nil
259
+ def destination_point
260
+ @destination_point ||=
261
+ if trip.blank?
262
+ nil
263
+ else
264
+ location = trip.vendor.locations.find_by(place: trip.destination_place)
265
+ to_point(location&.branches&.first&.place)
266
+ end
267
+ end
268
+
269
+ # Convert a Place model to { lat:, lng: } hash
270
+ def to_point(place)
271
+ return nil if place.blank?
272
+
273
+ {
274
+ lat: place.lat.to_f,
275
+ lng: place.lon.to_f
276
+ }
277
+ end
278
+ end
279
+ end
@@ -7,7 +7,34 @@ module SpreeCmCommissioner
7
7
  # order should be reload before recaculate to avoid wrong caculation.
8
8
  order.reload
9
9
 
10
- super
10
+ # SPREE: Original Spree::Cart::Recalculate code starts here
11
+ order_updater = ::Spree::OrderUpdater.new(order)
12
+
13
+ order.payments.store_credits.checkout.destroy_all
14
+ order_updater.update
15
+
16
+ shipment = options[:shipment]
17
+ if shipment.present?
18
+ # ADMIN END SHIPMENT RATE FIX
19
+ # refresh shipments to ensure correct shipment amount is calculated when using price sack calculator
20
+ # for calculating shipment rates.
21
+ # Currently shipment rate is calculated on previous order total instead of current order total when updating a shipment from admin end.
22
+ order.refresh_shipment_rates(::Spree::ShippingMethod::DISPLAY_ON_BACK_END)
23
+ shipment.update_amounts
24
+ else
25
+ order.ensure_updated_shipments
26
+ end
27
+ # SPREE: Original Spree::Cart::Recalculate code ends here
28
+
29
+ # CUSTOM: Apply pricing models (intercity taxi, line item pricing) to the line item
30
+ SpreeCmCommissioner::LineItems::ApplyPricingModels.call(order: order, line_item: line_item)
31
+
32
+ # SPREE: Original Spree::Cart::Recalculate code continues here
33
+ ::Spree::PromotionHandler::Cart.new(order, line_item).activate
34
+ ::Spree::Adjustable::AdjustmentsUpdater.update(line_item)
35
+ ::Spree::TaxRate.adjust(order, [line_item.reload]) if line_item_created
36
+ order_updater.update
37
+ success(line_item)
11
38
  end
12
39
  end
13
40
  end
@@ -0,0 +1,65 @@
1
+ module SpreeCmCommissioner
2
+ module Guests
3
+ class ClaimInviteGuestService
4
+ prepend ::Spree::ServiceModule::Base
5
+
6
+ def call(guest:, id_card_params:, line_item:, invite_guest:)
7
+ ApplicationRecord.transaction do
8
+ process_id_cards(guest, id_card_params)
9
+ claim_invite_if_needed(line_item, invite_guest)
10
+ send_claim_alert(guest, invite_guest.order)
11
+
12
+ success(guest: guest.reload)
13
+ end
14
+ rescue StandardError => e
15
+ failure(guest, e.message)
16
+ end
17
+
18
+ private
19
+
20
+ # ID Card Processing
21
+ def process_id_cards(guest, id_card_params)
22
+ return unless id_card_images_present?(id_card_params)
23
+
24
+ id_card = guest.id_card || guest.build_id_card(card_type: id_card_params[:card_type])
25
+ build_id_card_images(id_card, id_card_params)
26
+ id_card.save!
27
+ end
28
+
29
+ def build_id_card_images(id_card, id_card_params)
30
+ id_card.build_front_image(attachment: id_card_params.delete(:front_image)) if id_card_params[:front_image]
31
+ id_card.build_back_image(attachment: id_card_params.delete(:back_image)) if id_card_params[:back_image]
32
+ end
33
+
34
+ def id_card_images_present?(id_card_params)
35
+ id_card_params[:front_image].present? || id_card_params[:back_image].present?
36
+ end
37
+
38
+ # Invite Claim Management
39
+ def claim_invite_if_needed(line_item, invite_guest)
40
+ return unless line_item.guests.count >= invite_guest.quantity
41
+
42
+ invite_guest.update(claimed_status: :claimed)
43
+ end
44
+
45
+ # Telegram Alert
46
+ def send_claim_alert(guest, order)
47
+ vendor = guest.event&.vendor
48
+ return unless vendor&.preferred_telegram_chat_id
49
+
50
+ factory = SpreeCmCommissioner::InviteGuestClaimedTelegramMessageFactory.new(
51
+ title: '📣 --- [NEW GUEST CLAIMED INVITATION] ---',
52
+ order: order,
53
+ guest: guest,
54
+ vendor: vendor
55
+ )
56
+
57
+ SpreeCmCommissioner::TelegramNotificationSenderJob.perform_later(
58
+ chat_id: vendor.preferred_telegram_chat_id,
59
+ message: factory.message,
60
+ parse_mode: factory.parse_mode
61
+ )
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,224 @@
1
+ module SpreeCmCommissioner
2
+ module IntercityTaxiOrder
3
+ # Computes trip distance details for intercity taxi orders
4
+ class CalculateDistance
5
+ prepend ::Spree::ServiceModule::Base
6
+
7
+ def call(
8
+ pickups_attributes: [],
9
+ dropoffs_attributes: [],
10
+ pickup_oob_confirmed: false,
11
+ dropoff_oob_confirmed: false,
12
+ trip_id: nil
13
+ )
14
+ return failure(nil, 'trip_id is required') if trip_id.blank?
15
+
16
+ initialize_context(pickup_oob_confirmed, dropoff_oob_confirmed, trip_id)
17
+ setup_waypoints(pickups_attributes, dropoffs_attributes)
18
+
19
+ return failure(nil, 'Trip origin location is not configured') if origin.blank?
20
+ return failure(nil, 'Trip destination location is not configured') if configured_destination.blank?
21
+
22
+ base_km = calculate_base_route
23
+ return failure(nil, 'Unable to calculate base route') if base_km.nil?
24
+ return build_simple_result(base_km) unless waypoints?
25
+
26
+ details_ctx = fetch_full_route_details
27
+ return failure(nil, details_ctx.message || 'Unable to calculate') unless details_ctx.success?
28
+
29
+ detour_distances = calculate_detour_distances
30
+ build_result(details_ctx, base_km, detour_distances)
31
+ rescue StandardError => e
32
+ failure(nil, e.message)
33
+ end
34
+
35
+ private
36
+
37
+ # -------------------------
38
+ # Setup
39
+ # -------------------------
40
+
41
+ def initialize_context(pickup_oob_confirmed, dropoff_oob_confirmed, trip_id)
42
+ @trip_id = trip_id
43
+ @pickup_oob_confirmed = pickup_oob_confirmed
44
+ @dropoff_oob_confirmed = dropoff_oob_confirmed
45
+ end
46
+
47
+ def setup_waypoints(pickups_attributes, dropoffs_attributes)
48
+ @pickups_points = Array(pickups_attributes).compact
49
+ @dropoffs_points = Array(dropoffs_attributes).compact
50
+ @final_destination = @dropoffs_points.last || configured_destination
51
+
52
+ @has_pickups = @pickups_points.any?
53
+ @has_dropoffs = @dropoffs_points.any?
54
+ end
55
+
56
+ def waypoints?
57
+ @has_pickups || @has_dropoffs
58
+ end
59
+
60
+ # -------------------------
61
+ # Distance calculations
62
+ # -------------------------
63
+
64
+ def calculate_base_route
65
+ compute_km(origin: origin, destination: configured_destination)
66
+ end
67
+
68
+ def fetch_full_route_details
69
+ fetch_details(origin, @final_destination, @pickups_points, @dropoffs_points)
70
+ end
71
+
72
+ def calculate_detour_distances
73
+ {
74
+ pickup: calculate_pickup_detour,
75
+ dropoff: calculate_dropoff_detour
76
+ }
77
+ end
78
+
79
+ def calculate_pickup_detour
80
+ return nil unless @has_pickups && @pickup_oob_confirmed
81
+
82
+ compute_km(
83
+ origin: origin,
84
+ destination: configured_destination,
85
+ pickups: @pickups_points,
86
+ optimize: false
87
+ )
88
+ end
89
+
90
+ def calculate_dropoff_detour
91
+ return nil unless @has_dropoffs && @dropoff_oob_confirmed
92
+
93
+ compute_km(
94
+ origin: origin,
95
+ destination: @final_destination,
96
+ dropoffs: @dropoffs_points,
97
+ optimize: false
98
+ )
99
+ end
100
+
101
+ # -------------------------
102
+ # Result builders
103
+ # -------------------------
104
+
105
+ def build_simple_result(base_km)
106
+ result = SpreeCmCommissioner::IntercityTaxi::DistanceCalculationResult.new(
107
+ distance_km: base_km,
108
+ ordered_points: [origin, configured_destination],
109
+ estimated_time_minutes: nil,
110
+ base_km: base_km,
111
+ detour_pickup_km: nil,
112
+ detour_dropoff_km: nil,
113
+ extra_pickup_km: 0.0,
114
+ extra_dropoff_km: 0.0
115
+ )
116
+ success(result)
117
+ end
118
+
119
+ def build_result(details_ctx, base_km, detour_distances)
120
+ # Incremental km = detour - base
121
+ pickup_increment_km = detour_distances[:pickup]&.-(base_km)
122
+ dropoff_increment_km = detour_distances[:dropoff]&.-(base_km)
123
+
124
+ extra_pickup_km = compute_extra_km(pickup_increment_km, pickup_boundary_km, @pickup_oob_confirmed)
125
+ extra_dropoff_km = compute_extra_km(dropoff_increment_km, dropoff_boundary_km, @dropoff_oob_confirmed)
126
+
127
+ result = SpreeCmCommissioner::IntercityTaxi::DistanceCalculationResult.new(
128
+ distance_km: details_ctx.distance_km,
129
+ ordered_points: details_ctx.ordered_points,
130
+ estimated_time_minutes: details_ctx.estimated_time_minutes,
131
+ base_km: base_km,
132
+ detour_pickup_km: detour_distances[:pickup],
133
+ detour_dropoff_km: detour_distances[:dropoff],
134
+ extra_pickup_km: extra_pickup_km,
135
+ extra_dropoff_km: extra_dropoff_km
136
+ )
137
+
138
+ success(result)
139
+ end
140
+
141
+ # -------------------------
142
+ # Extra km calculation
143
+ # -------------------------
144
+
145
+ # increment_km = detour_km - base_km
146
+ def compute_extra_km(increment_km, boundary_km, confirmed)
147
+ return 0.0 unless confirmed && increment_km
148
+
149
+ extra = increment_km - boundary_km.to_f
150
+ extra.positive? ? extra.round(2) : 0.0
151
+ end
152
+
153
+ # -------------------------
154
+ # Google Routes integration
155
+ # -------------------------
156
+
157
+ def compute_km(origin:, destination:, pickups: nil, dropoffs: nil, optimize: nil)
158
+ ctx = SpreeCmCommissioner::GoogleRoutesDistanceCalculator.call(
159
+ origin: origin,
160
+ destination: destination,
161
+ pickups: pickups,
162
+ dropoffs: dropoffs,
163
+ optimize: optimize
164
+ )
165
+ return nil unless ctx.success?
166
+
167
+ ctx.distance_km
168
+ end
169
+
170
+ def fetch_details(origin, final_destination, pickups_points, dropoffs_points)
171
+ SpreeCmCommissioner::GoogleRoutesDistanceCalculator.call(
172
+ origin: origin,
173
+ destination: final_destination,
174
+ pickups: pickups_points,
175
+ dropoffs: dropoffs_points,
176
+ optimize: true
177
+ )
178
+ end
179
+
180
+ # -------------------------
181
+ # Boundaries
182
+ # -------------------------
183
+
184
+ def pickup_boundary_km
185
+ 5.0
186
+ end
187
+
188
+ def dropoff_boundary_km
189
+ 3.0
190
+ end
191
+
192
+ # -------------------------
193
+ # Trip helpers
194
+ # -------------------------
195
+
196
+ def trip
197
+ @trip ||= SpreeCmCommissioner::Trip.find(@trip_id)
198
+ end
199
+
200
+ def origin
201
+ return if trip.blank?
202
+
203
+ location = trip.vendor.locations.find_by(place: trip.origin_place)
204
+ to_point(location&.branches&.first)
205
+ end
206
+
207
+ def configured_destination
208
+ return if trip.blank?
209
+
210
+ location = trip.vendor.locations.find_by(place: trip.destination_place)
211
+ to_point(location&.branches&.first)
212
+ end
213
+
214
+ def to_point(location)
215
+ return if location.blank? || location.place.blank?
216
+
217
+ {
218
+ lat: location.place.lat.to_f,
219
+ lng: location.place.lon.to_f
220
+ }
221
+ end
222
+ end
223
+ end
224
+ end
@@ -3,6 +3,8 @@ module SpreeCmCommissioner
3
3
  class Update
4
4
  prepend ::Spree::ServiceModule::Base
5
5
 
6
+ include ::Spree::LineItems::Helper
7
+
6
8
  def call(
7
9
  order:,
8
10
  remark: nil,
@@ -26,18 +28,43 @@ module SpreeCmCommissioner
26
28
  )
27
29
  end
28
30
 
31
+ # Verify signature and extract data for distance
29
32
  if distance_attributes.present?
30
- line_item.distance = SpreeCmCommissioner::IntercityTaxi::Distance.new(
31
- distance_attributes
32
- )
33
+ verified_data = verify_and_extract_distance(distance_attributes)
34
+ return failure(nil, verified_data[:error]) unless verified_data[:success]
35
+
36
+ line_item.distance = SpreeCmCommissioner::Distance.new(verified_data[:value])
33
37
  end
34
38
 
35
39
  line_item.update!(remark: remark)
40
+ recalculate_service.call(order: order, line_item: line_item)
36
41
 
37
42
  success(order.reload)
38
43
  rescue StandardError => e
39
44
  failure(nil, e.message)
40
45
  end
46
+
47
+ private
48
+
49
+ # Verifies signed distance data and extracts clean data (without signature).
50
+ # Signature is only used for verification, not stored.
51
+ def verify_and_extract_distance(attributes)
52
+ # Convert to Hash with string keys (works for both Hash and ActionController::Parameters)
53
+ # Controller already called .permit(), so .to_h is safe here
54
+ # HashWithIndifferentAccess.to_json works correctly, so we can use it directly
55
+ attributes = attributes.to_h.stringify_keys
56
+
57
+ return { success: false, error: 'Distance signature is required' } if attributes['signature'].blank?
58
+
59
+ # Verify signature before using for price calculations
60
+ verified = SpreeCmCommissioner::Distance.from_signed_hash(attributes)
61
+ return { success: false, error: 'Invalid distance signature or tampered data' } unless verified
62
+
63
+ # Extract clean data without signature and signed_at
64
+ clean_data = verified.to_h
65
+
66
+ { success: true, value: clean_data }
67
+ end
41
68
  end
42
69
  end
43
70
  end
@@ -0,0 +1,27 @@
1
+ module SpreeCmCommissioner
2
+ module LineItems
3
+ class ApplyPricingModels
4
+ prepend ::Spree::ServiceModule::Base
5
+
6
+ def call(order:, line_item:)
7
+ return success(nil) if line_item.blank?
8
+
9
+ return success(line_item) unless order&.persisted?
10
+ return success(line_item) if line_item.variant.blank?
11
+
12
+ active_pricing_models = line_item.variant.pricing_models.active
13
+ return success(line_item) unless active_pricing_models.exists?
14
+
15
+ line_item.adjustments.pricing_action.delete_all
16
+
17
+ active_pricing_models.each do |pricing_model|
18
+ SpreeCmCommissioner::PricingModels::Apply.new(
19
+ line_item: line_item,
20
+ pricing_model: pricing_model
21
+ ).call
22
+ end
23
+ success(line_item)
24
+ end
25
+ end
26
+ end
27
+ end