spree_delhivery 1.0.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 (58) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +175 -0
  3. data/Rakefile +21 -0
  4. data/app/assets/config/spree_delhivery_manifest.js +4 -0
  5. data/app/assets/images/integration_icons/delhivery.png +0 -0
  6. data/app/assets/images/payment_icons/delhivery.svg +12 -0
  7. data/app/assets/images/payment_icons/delhivery_cod.svg +12 -0
  8. data/app/controllers/spree/admin/delhivery_controller.rb +190 -0
  9. data/app/controllers/spree/admin/delhivery_returns_controller.rb +82 -0
  10. data/app/controllers/spree/admin/fulfillments_controller.rb +117 -0
  11. data/app/controllers/spree/admin/shipments_controller_decorator.rb +198 -0
  12. data/app/controllers/spree/admin/stock_locations_controller_decorator.rb +38 -0
  13. data/app/controllers/spree/api/v3/store/delhivery_controller.rb +126 -0
  14. data/app/jobs/spree_delhivery/base_job.rb +5 -0
  15. data/app/models/spree/calculator/shipping/delhivery.rb +97 -0
  16. data/app/models/spree/integrations/delhivery.rb +48 -0
  17. data/app/models/spree/order_decorator.rb +63 -0
  18. data/app/models/spree/page_blocks/products/delhivery_edd.rb +42 -0
  19. data/app/models/spree/page_sections/product_details_decorator.rb +26 -0
  20. data/app/models/spree/payment_method/delhivery_cod.rb +57 -0
  21. data/app/services/spree_delhivery/client.rb +281 -0
  22. data/app/services/spree_delhivery/pickup_service.rb +49 -0
  23. data/app/services/spree_delhivery/shipment_canceler.rb +59 -0
  24. data/app/services/spree_delhivery/shipment_sender.rb +210 -0
  25. data/app/services/spree_delhivery/shipment_tracker.rb +50 -0
  26. data/app/views/spree/admin/fulfillments/new.html.erb +118 -0
  27. data/app/views/spree/admin/integrations/forms/_delhivery.html.erb +51 -0
  28. data/app/views/spree/admin/orders/_shipment.html.erb +180 -0
  29. data/app/views/spree/admin/orders/return_authorizations/_return_authorization.html.erb +157 -0
  30. data/app/views/spree/admin/page_blocks/forms/_delhivery_edd.html.erb +157 -0
  31. data/app/views/spree/admin/payment_methods/configuration_guides/_delhivery_cod.html.erb +71 -0
  32. data/app/views/spree/admin/payment_methods/descriptions/_delhivery_cod.html.erb +7 -0
  33. data/app/views/spree/admin/return_authorizations/index.html.erb +143 -0
  34. data/app/views/spree/admin/shipments/edit.html.erb +40 -0
  35. data/app/views/spree/admin/stock_locations/_delhivery_fields.html.erb +19 -0
  36. data/app/views/spree/admin/stock_locations/_form.html.erb +184 -0
  37. data/app/views/spree/checkout/payment/_delhivery_cod.html.erb +9 -0
  38. data/app/views/spree/page_blocks/products/delhivery_edd/_delhivery_edd.html.erb +239 -0
  39. data/app/views/spree_delhivery/_head.html.erb +0 -0
  40. data/config/importmap.rb +6 -0
  41. data/config/initializers/spree.rb +15 -0
  42. data/config/initializers/spree_permitted_attributes.rb +4 -0
  43. data/config/locales/en.yml +36 -0
  44. data/config/routes.rb +42 -0
  45. data/db/migrate/20250101000001_add_delhivery_fields_to_shipments.rb +10 -0
  46. data/db/migrate/20250101000002_add_tracking_status_to_shipments.rb +13 -0
  47. data/db/migrate/20251227110851_add_delhivery_fields_to_spree_stock_locations.rb +5 -0
  48. data/db/migrate/20251227112401_add_geolocation_to_stock_locations.rb +9 -0
  49. data/db/migrate/20251227123158_add_missing_coordinates_to_stock_locations.rb +18 -0
  50. data/db/migrate/20251228081459_add_delhivery_to_return_authorizations.rb +8 -0
  51. data/lib/generators/spree_delhivery/install/install_generator.rb +139 -0
  52. data/lib/spree_delhivery/configuration.rb +13 -0
  53. data/lib/spree_delhivery/engine.rb +39 -0
  54. data/lib/spree_delhivery/factories.rb +6 -0
  55. data/lib/spree_delhivery/version.rb +7 -0
  56. data/lib/spree_delhivery.rb +13 -0
  57. data/lib/tasks/delhivery.rake +60 -0
  58. metadata +151 -0
@@ -0,0 +1,57 @@
1
+ module Spree
2
+ class PaymentMethod::DelhiveryCod < PaymentMethod
3
+ def method_type
4
+ 'delhivery_cod'
5
+ end
6
+
7
+ def payment_icon_name
8
+ 'delhivery_cod'
9
+ end
10
+
11
+ def description_partial_name
12
+ 'delhivery_cod'
13
+ end
14
+
15
+ def configuration_guide_partial_name
16
+ 'delhivery_cod'
17
+ end
18
+
19
+ def source_required?
20
+ false
21
+ end
22
+
23
+ def auto_capture?
24
+ false
25
+ end
26
+
27
+ def actions
28
+ %w{authorize capture void purchase}
29
+ end
30
+
31
+ def can_capture?(payment)
32
+ ['checkout', 'pending'].include?(payment.state)
33
+ end
34
+
35
+ def can_void?(payment)
36
+ payment.state != 'void'
37
+ end
38
+
39
+ # Satisfies the Spree API and Rails Checkout authorization step
40
+ def authorize(*args)
41
+ ActiveMerchant::Billing::Response.new(true, "Delhivery COD Authorized", {}, {})
42
+ end
43
+
44
+ # Satisfies checkout engines that try to purchase immediately
45
+ def purchase(*args)
46
+ ActiveMerchant::Billing::Response.new(true, "Delhivery COD Purchased", {}, {})
47
+ end
48
+
49
+ def capture(*args)
50
+ ActiveMerchant::Billing::Response.new(true, "Delhivery COD Captured", {}, {})
51
+ end
52
+
53
+ def void(*args)
54
+ ActiveMerchant::Billing::Response.new(true, "Delhivery COD Voided", {}, {})
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,281 @@
1
+ require 'httparty'
2
+ require 'json'
3
+ require 'uri'
4
+
5
+ module SpreeDelhivery
6
+ class Client
7
+ include HTTParty
8
+
9
+ # Base Configuration
10
+ base_uri 'https://track.delhivery.com'
11
+
12
+ attr_reader :integration
13
+
14
+ def initialize
15
+ @integration = Spree::Integrations::Delhivery.active.first
16
+ raise "Delhivery Integration is not active or configured" unless @integration
17
+
18
+ @api_token = @integration.preferred_api_token.to_s.strip
19
+
20
+ # Environment Logic
21
+ if @integration.preferred_production_mode
22
+ self.class.base_uri 'https://track.delhivery.com'
23
+ @env_name = "PRODUCTION"
24
+ else
25
+ self.class.base_uri 'https://staging-express.delhivery.com'
26
+ @env_name = "STAGING"
27
+ end
28
+ end
29
+
30
+ # Fetch Shipping Rate
31
+ def fetch_shipping_rate(source_pin:, dest_pin:, weight_gms:, mode: 'S')
32
+ path = "/api/kinko/v1/invoice/charges/.json"
33
+ api_mode = map_mode(mode)
34
+
35
+ params = {
36
+ md: api_mode,
37
+ ss: 'Delivered',
38
+ d_pin: dest_pin,
39
+ o_pin: source_pin,
40
+ cgm: weight_gms,
41
+ pt: 'Pre-paid'
42
+ }
43
+
44
+ response = self.class.get(path, query: params, headers: auth_headers)
45
+ data = response.parsed_response
46
+
47
+ if data.is_a?(Hash) && data['total_amount']
48
+ return data['total_amount'].to_f
49
+ elsif data.is_a?(Array) && data.first && data.first['total_amount']
50
+ return data.first['total_amount'].to_f
51
+ else
52
+ return nil
53
+ end
54
+ rescue => e
55
+ Rails.logger.error "[Delhivery] Rate Exception: #{e.message}"
56
+ nil
57
+ end
58
+
59
+ # Calculate TAT
60
+ def calculate_tat(source_pin:, dest_pin:, mode: 'S')
61
+ path = "/api/dc/expected_tat"
62
+ api_mode = map_mode(mode)
63
+
64
+ params = {
65
+ origin_pin: source_pin,
66
+ destination_pin: dest_pin,
67
+ mot: api_mode,
68
+ pdt: 'Pre-paid',
69
+ token: @api_token
70
+ }
71
+
72
+ response = self.class.get(path, query: params, headers: auth_headers)
73
+ response.parsed_response.is_a?(Hash) ? response.parsed_response : nil
74
+ rescue => e
75
+ Rails.logger.error "[Delhivery] TAT Error: #{e.message}"
76
+ nil
77
+ end
78
+
79
+ # Fetch Pincode Details
80
+ def fetch_pincode_details(pincode)
81
+ path = "/c/api/pin-codes/json/"
82
+ response = self.class.get(path, query: { filter_codes: pincode }, headers: auth_headers)
83
+ data = response.parsed_response
84
+
85
+ find_city = ->(obj) do
86
+ case obj
87
+ when Hash
88
+ return obj if obj.key?('city') || obj.key?(:city)
89
+ obj.each_value { |v| res = find_city.call(v); return res if res }
90
+ when Array
91
+ obj.each { |v| res = find_city.call(v); return res if res }
92
+ end
93
+ nil
94
+ end
95
+
96
+ find_city.call(data)
97
+ rescue => e
98
+ Rails.logger.error "[Delhivery] City Error: #{e.message}"
99
+ nil
100
+ end
101
+
102
+ # Create Return Shipment
103
+ def create_return_request(return_auth, options = {})
104
+ order = return_auth.order
105
+ stock_location = return_auth.stock_location
106
+ customer_address = order.ship_address
107
+
108
+ # Defaults if not provided
109
+ brand_name = options[:brand].presence || @integration.preferred_client_name
110
+ category_name = options[:category].presence || "General"
111
+
112
+ # Data Cleaning
113
+ clean_phone = ->(p) { p.to_s.gsub(/[^0-9]/, '').last(10) }
114
+ clean_str = ->(s) { s.to_s.gsub(/[^0-9a-zA-Z\s,\.\-]/, ' ').strip.first(100) }
115
+
116
+ c_phone = clean_phone.call(customer_address.phone)
117
+ w_phone = clean_phone.call(stock_location.phone)
118
+
119
+ # Construct Custom QC Items
120
+ custom_qc_items = []
121
+
122
+ return_auth.return_items.each do |ri|
123
+ variant = ri.inventory_unit.variant
124
+
125
+ img_url = "https://via.placeholder.com/150"
126
+ if variant.images.any?
127
+ img_url = variant.images.first.attachment.url(:small) rescue img_url
128
+ elsif variant.product.images.any?
129
+ img_url = variant.product.images.first.attachment.url(:small) rescue img_url
130
+ end
131
+
132
+ reason_text = "Customer Return"
133
+ if ri.respond_to?(:return_reason) && ri.return_reason.present?
134
+ reason_text = ri.return_reason.name
135
+ elsif return_auth.respond_to?(:reason) && return_auth.reason.present?
136
+ reason_text = return_auth.reason.name
137
+ end
138
+
139
+ custom_qc_items << {
140
+ "item" => variant.name.first(30),
141
+ "description" => variant.product.description&.first(50) || variant.name,
142
+ "images" => [img_url],
143
+ "return_reason" => reason_text,
144
+ "quantity" => 1,
145
+ "brand" => brand_name, # <--- DYNAMIC
146
+ "product_category" => category_name, # <--- DYNAMIC
147
+ "questions" => []
148
+ }
149
+ end
150
+
151
+ # Calculate Weight
152
+ total_weight_gms = 0.0
153
+ return_auth.inventory_units.each do |unit|
154
+ w = unit.variant.weight.to_f
155
+ w = (w < 50) ? w * 1000.0 : w
156
+ total_weight_gms += w
157
+ end
158
+ total_weight_gms = 500 if total_weight_gms < 500
159
+
160
+ # Payload
161
+ payload = {
162
+ "shipments" => [
163
+ {
164
+ "client" => @integration.preferred_client_name,
165
+ "order" => return_auth.number,
166
+ "waybill" => "",
167
+ "name" => customer_address.full_name,
168
+ "add" => clean_str.call(customer_address.address1),
169
+ "city" => customer_address.city,
170
+ "state" => customer_address.state&.name || customer_address.state_name,
171
+ "country" => "India",
172
+ "phone" => c_phone,
173
+ "pin" => customer_address.zipcode,
174
+
175
+ "return_name" => stock_location.name,
176
+ "return_add" => clean_str.call(stock_location.address1),
177
+ "return_city" => stock_location.city,
178
+ "return_state" => stock_location.state&.name || stock_location.state_name,
179
+ "return_country" => "India",
180
+ "return_pin" => stock_location.zipcode,
181
+ "return_phone" => w_phone,
182
+
183
+ "payment_mode" => "Pickup",
184
+ "products_desc" => "Return #{order.number}",
185
+ "quantity" => return_auth.return_items.count,
186
+ "weight" => total_weight_gms.to_i,
187
+ "total_amount" => 0,
188
+ "shipping_mode" => "Surface",
189
+ "order_date" => Time.current.strftime("%d-%m-%Y"),
190
+
191
+ "qc_type" => "param",
192
+ "custom_qc" => custom_qc_items
193
+ }
194
+ ],
195
+ "pickup_location" => {
196
+ "name" => stock_location.delhivery_warehouse_name
197
+ }
198
+ }
199
+
200
+ Rails.logger.info "[Delhivery] RVP Payload: #{payload.to_json}"
201
+
202
+ response = self.class.post(
203
+ "/api/cmu/create.json",
204
+ body: { "format" => "json", "data" => payload.to_json },
205
+ headers: { "Authorization" => "Token #{@api_token}" }
206
+ )
207
+
208
+ JSON.parse(response.body) rescue { "error" => "Invalid JSON Response" }
209
+
210
+ rescue StandardError => e
211
+ Rails.logger.error "Delhivery Return Exception: #{e.message}"
212
+ { "error" => e.message }
213
+ end
214
+
215
+ # 5. Forward Shipment
216
+ def create_shipment(payload_data)
217
+ response = self.class.post("/api/cmu/create.json",
218
+ body: { "format" => "json", "data" => payload_data.to_json },
219
+ headers: { "Authorization" => "Token #{@api_token}" }
220
+ )
221
+ JSON.parse(response.body) rescue {}
222
+ end
223
+
224
+ # 6. Fetch Wallet Balance
225
+ def fetch_balance
226
+ # This is the standard endpoint for checking Delhivery wallet balance
227
+ response = self.class.get("/api/client/get_balance_ledger.json", headers: auth_headers)
228
+
229
+ if response.success? && response.parsed_response.is_a?(Hash)
230
+ # Returns format like: { "cash_balance" => "150.00", ... }
231
+ return response.parsed_response['cash_balance']
232
+ end
233
+ nil
234
+ rescue => e
235
+ Rails.logger.error "[Delhivery] Balance Fetch Failed: #{e.message}"
236
+ nil
237
+ end
238
+
239
+ def track_shipment(waybill)
240
+ send_get_request("/api/v1/packages/json/?waybill=#{waybill}")
241
+ end
242
+
243
+ def fetch_label(waybill)
244
+ send_get_request("/api/p/packing_slip?wbns=#{waybill}&pdf=true")
245
+ end
246
+
247
+ def create_pickup_request(location_name:, date:, time:, count: 1)
248
+ payload = { pickup_location: location_name, pickup_date: date, pickup_time: time, expected_package_count: count }
249
+ post_json("/fm/request/new/", payload)
250
+ end
251
+
252
+ def cancel_shipment(waybill)
253
+ post_json("/api/p/edit", { waybill: waybill, cancellation: true })
254
+ end
255
+
256
+ private
257
+
258
+ def map_mode(val)
259
+ str = val.to_s.downcase.strip
260
+ ['express', 'e'].include?(str) ? 'E' : 'S'
261
+ end
262
+
263
+ def send_get_request(path, params = {})
264
+ response = self.class.get(path, query: params, headers: auth_headers)
265
+ response.parsed_response
266
+ rescue => e
267
+ { "error" => "Request Failed", "details" => e.message }
268
+ end
269
+
270
+ def post_json(path, body = {})
271
+ response = self.class.post(path, body: body.to_json, headers: auth_headers.merge('Content-Type' => 'application/json'))
272
+ response.parsed_response
273
+ rescue => e
274
+ { "error" => "Request Failed", "details" => e.message }
275
+ end
276
+
277
+ def auth_headers
278
+ { "Authorization" => "Token #{@api_token}", "Accept" => "application/json" }
279
+ end
280
+ end
281
+ end
@@ -0,0 +1,49 @@
1
+ module SpreeDelhivery
2
+ class PickupService
3
+ Result = Struct.new(:success?, :message, :data)
4
+
5
+ def initialize(stock_location, date: nil, time: "16:00:00", count: 1)
6
+ @stock_location = stock_location
7
+ @date = date || Date.tomorrow.strftime('%Y-%m-%d')
8
+ @time = time
9
+ @count = count
10
+ @client = SpreeDelhivery::Client.new
11
+ end
12
+
13
+ def call
14
+ # 1. Validation
15
+ unless @stock_location.delhivery_warehouse_name.present?
16
+ return Result.new(false, "Stock Location is missing Warehouse Name.")
17
+ end
18
+
19
+ # 2. Call API
20
+ begin
21
+ response = @client.create_pickup_request(
22
+ location_name: @stock_location.delhivery_warehouse_name,
23
+ date: @date,
24
+ time: @time,
25
+ count: @count
26
+ )
27
+
28
+ # --- [DEBUG] START: Add this line ---
29
+ puts "\n\n🔴 [DELHIVERY DEBUG] RAW RESPONSE: #{response.inspect}\n\n"
30
+ # --- [DEBUG] END ---
31
+
32
+ # 3. Parse Response
33
+ if response['pickup_id'].present?
34
+ return Result.new(true, "Pickup Scheduled! ID: #{response['pickup_id']}", response)
35
+ elsif response['error'].present?
36
+ return Result.new(false, response['error'])
37
+ else
38
+ # Fallback
39
+ msg = response['pre_feed_back'] || "Unknown Response: #{response}"
40
+ return Result.new(false, msg)
41
+ end
42
+
43
+ rescue StandardError => e
44
+ Rails.logger.error "[Delhivery] Pickup Error: #{e.message}"
45
+ return Result.new(false, e.message)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,59 @@
1
+ module SpreeDelhivery
2
+ class ShipmentCanceler
3
+ def initialize(shipment)
4
+ @shipment = shipment
5
+ @client = SpreeDelhivery::Client.new
6
+ end
7
+
8
+ def call
9
+ waybill = @shipment.delhivery_waybill
10
+ return error("No Waybill found to cancel") unless waybill.present?
11
+
12
+ # 1. Call API
13
+ begin
14
+ response = @client.cancel_shipment(waybill)
15
+ rescue StandardError => e
16
+ return error("API Connection Error: #{e.message}")
17
+ end
18
+
19
+ # Delhivery success response is usually { "status" => true/false, ... } or "success" => true
20
+ # Sometimes it returns specific codes. We check general success/failure keys.
21
+ api_success = response['success'] || response['status'] == true || response['status'] == "Success"
22
+
23
+ if api_success
24
+ # 2. Clear Data & Revert State in Spree
25
+ # We use update_columns to skip state machine transitions/validations
26
+ @shipment.update_columns(
27
+ tracking: nil,
28
+ delhivery_waybill: nil,
29
+ delhivery_ref_id: nil,
30
+ delhivery_label_url: nil,
31
+ delhivery_response_data: nil,
32
+ state: 'ready', # Move back to Ready
33
+ shipped_at: nil # Clear the shipped timestamp
34
+ )
35
+
36
+ # 3. Update the Order's overall shipment state
37
+ # This ensures the order status bar updates correctly
38
+ @shipment.order.updater.update_shipment_state
39
+ @shipment.order.save
40
+
41
+ return success
42
+ else
43
+ return error(response['error'] || response['message'] || "Failed to cancel at Delhivery")
44
+ end
45
+ rescue StandardError => e
46
+ return error(e.message)
47
+ end
48
+
49
+ private
50
+
51
+ def success
52
+ OpenStruct.new(success?: true)
53
+ end
54
+
55
+ def error(msg)
56
+ OpenStruct.new(success?: false, error: msg)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,210 @@
1
+ module SpreeDelhivery
2
+ class ShipmentSender
3
+ def initialize(shipment)
4
+ @shipment = shipment
5
+ @order = shipment.order
6
+ @address = @order.ship_address
7
+ @client = SpreeDelhivery::Client.new
8
+ @integration = @client.integration
9
+ end
10
+
11
+ def call
12
+ return error("Shipment already has a Waybill") if @shipment.delhivery_waybill.present?
13
+
14
+ payload = build_payload
15
+
16
+ # Send API Request
17
+ response = @client.create_shipment(payload)
18
+
19
+ # Check for explicit success or package success status
20
+ success_status = response['success'] ||
21
+ (response['packages'].present? && response['packages'][0]['status'] == 'Success')
22
+
23
+ if success_status
24
+ pkg_data = response['packages'].first
25
+
26
+ # 1. Update Data Columns Safely
27
+ # Using update_columns to bypass Spree callbacks that might confuse the hash response with a model
28
+ @shipment.update_columns(
29
+ tracking: pkg_data['waybill'],
30
+ delhivery_waybill: pkg_data['waybill'],
31
+ delhivery_ref_id: pkg_data['refnum'],
32
+ delhivery_response_data: response # Rails handles JSON serialization automatically
33
+ )
34
+
35
+ # 2. Reload object to ensure fresh state
36
+ @shipment.reload
37
+
38
+ # 3. Fire Spree Shipment State Machine
39
+ # This moves state from 'ready' -> 'shipped'
40
+ if @shipment.can_ship?
41
+ @shipment.ship!
42
+ end
43
+
44
+ # 4. Fetch Label URL immediately
45
+ begin
46
+ label_res = @client.fetch_label(pkg_data['waybill'])
47
+ if label_res['packages'] && label_res['packages'][0]['pdf_download_link']
48
+ @shipment.update_column(:delhivery_label_url, label_res['packages'][0]['pdf_download_link'])
49
+ end
50
+ rescue => e
51
+ Rails.logger.error("Delhivery Label Fetch Failed: #{e.message}")
52
+ end
53
+
54
+ return success(@shipment)
55
+ else
56
+ # --- IMPROVED ERROR HANDLING ---
57
+ # 1. Extract the deep error message first (it's more accurate than 'rmk')
58
+ raw_error = response.dig('packages', 0, 'remarks')&.join(', ') || response['rmk'] || "Unknown Error"
59
+
60
+ # 2. Map known API errors to friendly user messages
61
+ friendly_error = case raw_error.to_s.downcase
62
+ when /insufficient balance/
63
+ # Fetch live balance to show the user
64
+ current_bal = @client.fetch_balance rescue nil
65
+ msg = "Authorization Failed: Insufficient Delhivery Wallet Balance."
66
+ msg += " Current Balance: ₹#{current_bal}." if current_bal
67
+ msg + " Please recharge."
68
+ when /duplicate/
69
+ "Duplicate Order: This order ID has already been processed."
70
+ when /pincode/
71
+ "Serviceability Error: Pincode (#{@address.zipcode}) not serviceable."
72
+ else
73
+ "Delhivery Error: #{raw_error}"
74
+ end
75
+
76
+ return error(friendly_error)
77
+ end
78
+ rescue StandardError => e
79
+ Rails.logger.error(e.backtrace.join("\n"))
80
+ return error(e.message)
81
+ end
82
+
83
+ private
84
+
85
+ def build_payload
86
+ # --- ROBUST PAYMENT MODE DETECTION ---
87
+ # Instead of relying on order.paid?, look at valid payments assigned to the order
88
+ is_cod_payment = @order.payments.valid.any? do |payment|
89
+ payment.payment_method&.type == 'Spree::PaymentMethod::DelhiveryCod'
90
+ end
91
+
92
+ payment_mode = is_cod_payment ? 'COD' : 'Prepaid'
93
+
94
+ # Sanitization: Ensure phone is exactly 10 digits (removes +91 or 0 prefix)
95
+ phone = @address.phone.to_s.gsub(/[^0-9]/, '').last(10)
96
+
97
+ # 1. Calculate and Convert Weight to Grams
98
+ total_weight_grams = calculate_total_weight
99
+
100
+ # 2. Calculate and Convert Dimensions to CM
101
+ dims = calculate_dimensions # Returns [L, W, H] in CM
102
+
103
+ # 3. Detect Shipping Mode dynamically from Customer Choice
104
+ shipping_method_name = @shipment.shipping_method&.name.to_s.downcase
105
+
106
+ final_shipping_mode = if shipping_method_name.include?('express')
107
+ 'Express'
108
+ elsif shipping_method_name.include?('surface')
109
+ 'Surface'
110
+ else
111
+ # Fallback to Admin Setting if name is generic (e.g. "Free Shipping")
112
+ @integration.preferred_shipping_mode || 'Surface'
113
+ end
114
+
115
+ {
116
+ pickup_location: {
117
+ name: @integration.preferred_pickup_location_name
118
+ },
119
+ shipments: [
120
+ {
121
+ name: @address.full_name,
122
+ add: [@address.address1, @address.address2].compact.join(', ').truncate(250),
123
+ pin: @address.zipcode,
124
+ city: @address.city,
125
+ state: @address.state&.name || @address.state_name,
126
+ country: 'India',
127
+ phone: phone,
128
+ order: @shipment.number,
129
+ payment_mode: payment_mode,
130
+ products_desc: @shipment.line_items.map { |i| i.variant.name }.join(', ').truncate(50),
131
+
132
+ # --- DYNAMIC COD COLLECTION VALUES ---
133
+ cod_amount: payment_mode == 'COD' ? @order.total.to_f : 0.0,
134
+ total_amount: @order.total.to_f,
135
+
136
+ # Use the detected mode ('Express' or 'Surface')
137
+ shipping_mode: final_shipping_mode,
138
+ quantity: @shipment.line_items.sum(&:quantity).to_i,
139
+
140
+ # Dynamic Values
141
+ weight: total_weight_grams,
142
+ shipment_length: dims[0],
143
+ shipment_width: dims[1],
144
+ shipment_height: dims[2],
145
+
146
+ # Client Name (Dynamic based on settings)
147
+ client: @integration.preferred_client_name
148
+ }
149
+ ]
150
+ }
151
+ end
152
+
153
+ def calculate_total_weight
154
+ raw_weight = @shipment.line_items.sum { |li| (li.variant.weight || 0) * li.quantity }
155
+ raw_weight = 0.5 if raw_weight.zero?
156
+
157
+ unit = @integration.preferred_store_weight_unit || 'kg'
158
+
159
+ grams = case unit
160
+ when 'kg' then raw_weight * 1000
161
+ when 'lbs' then raw_weight * 453.592
162
+ when 'oz' then raw_weight * 28.3495
163
+ when 'g' then raw_weight
164
+ else raw_weight * 1000
165
+ end
166
+
167
+ grams.to_i
168
+ end
169
+
170
+ def calculate_dimensions
171
+ max_l = 0
172
+ max_w = 0
173
+ total_h = 0
174
+
175
+ @shipment.line_items.each do |line_item|
176
+ v = line_item.variant
177
+ q = line_item.quantity
178
+
179
+ l = (v.depth || 10).to_f
180
+ w = (v.width || 10).to_f
181
+ h = (v.height || 10).to_f
182
+
183
+ max_l = [max_l, l].max
184
+ max_w = [max_w, w].max
185
+ total_h += (h * q)
186
+ end
187
+
188
+ unit = @integration.preferred_store_dimension_unit || 'cm'
189
+
190
+ [max_l, max_w, total_h].map do |val|
191
+ cm = case unit
192
+ when 'cm' then val
193
+ when 'in' then val * 2.54
194
+ when 'm' then val * 100
195
+ when 'mm' then val / 10.0
196
+ else val
197
+ end
198
+ cm.round(2)
199
+ end
200
+ end
201
+
202
+ def success(shipment)
203
+ OpenStruct.new(success?: true, shipment: shipment)
204
+ end
205
+
206
+ def error(message)
207
+ OpenStruct.new(success?: false, error: message)
208
+ end
209
+ end
210
+ end