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,198 @@
1
+ module Spree
2
+ module Admin
3
+ module ShipmentsControllerDecorator
4
+ def self.prepended(base)
5
+ # Ensure we can access these methods inside the controller
6
+ base.helper_method :delhivery_integration
7
+ end
8
+
9
+ # POST /admin/shipments/:id/delhivery_manifest
10
+ def delhivery_manifest
11
+ @shipment = Spree::Shipment.find(params[:id])
12
+
13
+ # 1. Validation
14
+ unless delhivery_integration
15
+ flash[:error] = "Delhivery Integration is not active."
16
+ return redirect_back(fallback_location: admin_orders_path)
17
+ end
18
+
19
+ unless @shipment.stock_location.delhivery_warehouse_name.present?
20
+ flash[:error] = "Stock Location is missing 'Delhivery Warehouse Name'. Please configure it first."
21
+ return redirect_to edit_admin_stock_location_path(@shipment.stock_location)
22
+ end
23
+
24
+ # 2. Build Payload
25
+ # We delegate this complex logic to a dedicated helper method below
26
+ payload = build_delhivery_payload(@shipment)
27
+
28
+ # 3. Call API
29
+ client = SpreeDelhivery::Client.new
30
+ response = client.create_shipment(payload)
31
+
32
+ # 4. Handle Response
33
+ if response['packages'].present? && response['packages'][0]['status'] == 'Success'
34
+ # Success!
35
+ waybill = response['packages'][0]['waybill']
36
+ ref_id = response['packages'][0]['refnum'] # Our shipment number
37
+
38
+ @shipment.update!(
39
+ delhivery_waybill: waybill,
40
+ delhivery_ref_id: ref_id,
41
+ tracking: waybill,
42
+ state: 'shipped', # Mark as shipped in Spree immediately
43
+ shipped_at: Time.current,
44
+ delhivery_response_data: response
45
+ )
46
+
47
+ flash[:success] = "Delhivery Waybill Generated: #{waybill}"
48
+ else
49
+ # Error
50
+ error_msg = response['error'] || response['packages']&.first&.fetch('remarks', nil) || "Unknown API Error"
51
+ flash[:error] = "Delhivery Failed: #{error_msg}"
52
+ end
53
+
54
+ redirect_back(fallback_location: edit_admin_order_path(@shipment.order))
55
+ rescue StandardError => e
56
+ flash[:error] = "System Error: #{e.message}"
57
+ redirect_back(fallback_location: edit_admin_order_path(@shipment.order))
58
+ end
59
+
60
+ # POST /admin/shipments/:id/delhivery_track
61
+ def delhivery_track
62
+ @shipment = Spree::Shipment.find(params[:id])
63
+
64
+ if @shipment.delhivery_waybill.blank?
65
+ flash[:error] = "No Waybill found to track."
66
+ else
67
+ client = SpreeDelhivery::Client.new
68
+ response = client.track_shipment(@shipment.delhivery_waybill)
69
+
70
+ # Parse flexible response (sometimes Array, sometimes Hash)
71
+ data = response.is_a?(Array) ? response.first : response
72
+
73
+ if data && data['ShipmentData']
74
+ status = data['ShipmentData'][0]['Shipment']['Status']['Status'] rescue "Unknown"
75
+ @shipment.update(tracking_status: status)
76
+ flash[:success] = "Tracking Updated: #{status}"
77
+ else
78
+ flash[:warning] = "Tracking info not available yet."
79
+ end
80
+ end
81
+
82
+ redirect_back(fallback_location: edit_admin_order_path(@shipment.order))
83
+ end
84
+
85
+ # POST /admin/shipments/:id/delhivery_cancel
86
+ def delhivery_cancel
87
+ @shipment = Spree::Shipment.find(params[:id])
88
+
89
+ client = SpreeDelhivery::Client.new
90
+ response = client.cancel_shipment(@shipment.delhivery_waybill)
91
+
92
+ # Delhivery cancellation response varies, usually checks for success code
93
+ if response['status'] == "True" || response['success'] == true
94
+ @shipment.update(
95
+ delhivery_waybill: nil,
96
+ tracking: nil,
97
+ state: 'ready' # Revert state so we can ship again
98
+ )
99
+ flash[:success] = "Shipment Cancelled successfully."
100
+ else
101
+ flash[:error] = "Cancellation Failed: #{response['error'] || response['message']}"
102
+ end
103
+
104
+ redirect_back(fallback_location: edit_admin_order_path(@shipment.order))
105
+ end
106
+
107
+ # GET /admin/shipments/:id/delhivery_label
108
+ def delhivery_label
109
+ @shipment = Spree::Shipment.find(params[:id])
110
+
111
+ client = SpreeDelhivery::Client.new
112
+ response = client.fetch_label(@shipment.delhivery_waybill)
113
+
114
+ # API usually returns a JSON with a 'packages' array containing a 'pdf_download_link'
115
+ # Or sometimes raw PDF data depending on endpoint.
116
+ # Assuming the 'packing_slip' endpoint returns JSON with a URL:
117
+ if response['packages'].present? && response['packages'][0]['pdf_download_link'].present?
118
+ redirect_to response['packages'][0]['pdf_download_link'], allow_other_host: true
119
+ else
120
+ flash[:error] = "Label URL not found in response."
121
+ redirect_back(fallback_location: edit_admin_order_path(@shipment.order))
122
+ end
123
+ end
124
+
125
+ private
126
+
127
+ def delhivery_integration
128
+ @delhivery_integration ||= Spree::Integrations::Delhivery.active.first
129
+ end
130
+
131
+ # ---------------------------------------------------------
132
+ # PAYLOAD BUILDER
133
+ # This maps Spree Shipment Data -> Delhivery JSON Format
134
+ # ---------------------------------------------------------
135
+ def build_delhivery_payload(shipment)
136
+ order = shipment.order
137
+ address = order.shipping_address
138
+ location = shipment.stock_location
139
+
140
+ # Calculate Weight (Convert to Grams if needed)
141
+ # Assuming Spree weight is in KG.
142
+ total_weight_kgs = shipment.line_items.sum { |li| li.variant.weight.to_f * li.quantity }
143
+ total_weight_gms = (total_weight_kgs * 1000).to_i
144
+ total_weight_gms = 500 if total_weight_gms < 500 # Minimum 500g
145
+
146
+ payment_mode = order.paid? ? 'Pre-paid' : 'COD'
147
+ cod_amount = payment_mode == 'COD' ? order.total.to_f : 0.0
148
+
149
+ {
150
+ shipments: [
151
+ {
152
+ name: "#{address.firstname} #{address.lastname}",
153
+ add: "#{address.address1} #{address.address2}",
154
+ pin: address.zipcode,
155
+ city: address.city,
156
+ state: address.state&.name || address.state_text,
157
+ country: address.country&.iso || "IN",
158
+ phone: address.phone,
159
+ order: shipment.number, # Ref ID
160
+ payment_mode: payment_mode,
161
+ return_pin: location.zipcode,
162
+ return_city: location.city,
163
+ return_phone: location.phone,
164
+ return_add: location.address1,
165
+ products_desc: shipment.line_items.map { |li| li.product.name }.join(', '),
166
+ hsn_code: "", # Add logic here if you store HSN codes on products
167
+ cod_amount: cod_amount,
168
+ order_date: order.completed_at&.strftime('%Y-%m-%d'),
169
+ total_amount: order.total.to_f,
170
+ seller_inv_date: Time.current.strftime('%Y-%m-%d'),
171
+ seller_name: location.delhivery_warehouse_name, # Critical!
172
+ seller_add: "#{location.address1} #{location.city}",
173
+ seller_inv: shipment.number,
174
+ quantity: shipment.line_items.sum(&:quantity),
175
+ waybill: "", # Blank for creation
176
+ shipment_width: 10, # Default or fetch from products
177
+ shipment_height: 10, # Default
178
+ shipment_depth: 10, # Default
179
+ shipment_weight: total_weight_gms
180
+ }
181
+ ],
182
+ pickup_location: {
183
+ name: location.delhivery_warehouse_name,
184
+ add: location.address1,
185
+ city: location.city,
186
+ pin_code: location.zipcode,
187
+ country: location.country&.iso || "IN",
188
+ phone: location.phone
189
+ }
190
+ }
191
+ end
192
+
193
+ end
194
+ end
195
+ end
196
+
197
+ # Apply the decoration
198
+ Spree::Admin::ShipmentsController.prepend(Spree::Admin::ShipmentsControllerDecorator)
@@ -0,0 +1,38 @@
1
+ module Spree
2
+ module Admin
3
+ module StockLocationsControllerDecorator
4
+ def delhivery_pickup
5
+ @stock_location = Spree::StockLocation.find(params[:id])
6
+
7
+ # 1. Capture Params
8
+ count = params[:count].to_i
9
+ count = 1 if count <= 0
10
+
11
+ date = params[:pickup_date] # Format: "YYYY-MM-DD" from HTML5 input
12
+ time = params[:pickup_time] # Format: "HH:MM"
13
+
14
+ # 2. Call Service
15
+ # passing the named arguments your service expects
16
+ service = SpreeDelhivery::PickupService.new(
17
+ @stock_location,
18
+ date: date,
19
+ time: time,
20
+ count: count
21
+ )
22
+
23
+ result = service.call
24
+
25
+ # 3. Handle Result
26
+ if result.success?
27
+ flash[:success] = result.message
28
+ else
29
+ flash[:error] = "Delhivery Error: #{result.message}"
30
+ end
31
+
32
+ redirect_to edit_admin_stock_location_path(@stock_location)
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ Spree::Admin::StockLocationsController.prepend(Spree::Admin::StockLocationsControllerDecorator)
@@ -0,0 +1,126 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Store
5
+ class DelhiveryController < ::Spree::Api::V3::BaseController
6
+ # V3 best practice: skip size checks for simple GET/POST validation requests
7
+ skip_before_action :ensure_payload_size, raise: false
8
+ skip_before_action :check_payload_size, raise: false
9
+
10
+ def check
11
+ pincode = params[:pincode]
12
+ mode = params[:mode] || 'Surface'
13
+
14
+ cutoff_h = (params[:cutoff_hour] || '14').to_i
15
+ cutoff_m = params[:cutoff_meridiem] || 'PM'
16
+
17
+ return render json: { error: "Invalid Pincode" }, status: 400 if pincode.blank? || pincode.length != 6
18
+
19
+ # Determine Source Pin using Spree 5.4 methods
20
+ stock_location = Spree::StockLocation.where(active: true).where.not(zipcode: nil).first
21
+ source_pin = stock_location&.zipcode || "110001"
22
+
23
+ client = SpreeDelhivery::Client.new
24
+
25
+ # --- 1. IMPROVED LOCATION LOGIC (City + District + State) ---
26
+ location_text = "Valid Location"
27
+ begin
28
+ details = client.fetch_pincode_details(pincode)
29
+ if details
30
+ d = details.with_indifferent_access
31
+
32
+ # Clean City (Remove S.O/B.O suffixes)
33
+ raw_city = d[:city].presence || ""
34
+ city_name = raw_city.to_s.gsub(/\s+(S\.?O|B\.?O|H\.?O)\.?$/i, '').strip.titleize
35
+
36
+ # Clean District
37
+ raw_dist = d[:district].presence || ""
38
+ dist_name = raw_dist.to_s.gsub(/\s+District$/i, '').strip.titleize
39
+
40
+ # Parse State
41
+ raw_state = (d[:state] || d[:state_code] || d[:province]).to_s.strip
42
+ state_name = raw_state.titleize
43
+
44
+ # India State Map Correction
45
+ state_map = {
46
+ 'TS' => 'Telangana', 'TG' => 'Telangana', 'DL' => 'Delhi',
47
+ 'MH' => 'Maharashtra', 'KA' => 'Karnataka', 'TN' => 'Tamil Nadu',
48
+ 'UP' => 'Uttar Pradesh', 'WB' => 'West Bengal', 'AP' => 'Andhra Pradesh',
49
+ 'GJ' => 'Gujarat', 'RJ' => 'Rajasthan', 'KL' => 'Kerala', 'HR' => 'Haryana',
50
+ 'PB' => 'Punjab', 'MP' => 'Madhya Pradesh', 'BR' => 'Bihar',
51
+ 'CG' => 'Chhattisgarh', 'JH' => 'Jharkhand', 'UK' => 'Uttarakhand',
52
+ 'HP' => 'Himachal Pradesh', 'AS' => 'Assam', 'OR' => 'Odisha'
53
+ }
54
+
55
+ # Resolve Official State Name via Spree DB
56
+ india = Spree::Country.find_by(iso: 'IN')
57
+ if india
58
+ state_match = india.states.where("LOWER(abbr) = ? OR LOWER(name) = ?", raw_state.downcase, raw_state.downcase).first
59
+ if state_match
60
+ state_name = state_match.name
61
+ elsif state_map[raw_state.upcase]
62
+ state_name = state_map[raw_state.upcase]
63
+ end
64
+ end
65
+
66
+ # Build Full Location String
67
+ parts = []
68
+ parts << city_name if city_name.present?
69
+ parts << dist_name if dist_name.present? && dist_name.downcase != city_name.downcase
70
+ if state_name.present? && state_name.downcase != city_name.downcase && state_name.downcase != dist_name.downcase
71
+ parts << state_name
72
+ end
73
+
74
+ location_text = parts.join(', ') if parts.any?
75
+ end
76
+ rescue => e
77
+ Rails.logger.error "Delhivery City Parse Error: #{e.message}"
78
+ end
79
+
80
+ # --- 2. TAT & Timer Logic ---
81
+ response = client.calculate_tat(source_pin: source_pin, dest_pin: pincode, mode: mode)
82
+
83
+ if response && (response['success'] == true || response['estimated_delivery_date'])
84
+ if response['data'] && response['data']['tat']
85
+ delivery_date = Date.today + response['data']['tat'].to_i.days
86
+ elsif response['estimated_delivery_date']
87
+ delivery_date = Date.parse(response['estimated_delivery_date'])
88
+ else
89
+ delivery_date = Date.today + 5.days
90
+ end
91
+
92
+ date_text = "Delivery by #{delivery_date.strftime("%A, %B %d")}"
93
+
94
+ # Timer Calculation
95
+ cutoff_24 = cutoff_h
96
+ if cutoff_m.upcase == 'PM' && cutoff_h < 12
97
+ cutoff_24 += 12
98
+ elsif cutoff_m.upcase == 'AM' && cutoff_h == 12
99
+ cutoff_24 = 0
100
+ end
101
+
102
+ now = Time.current
103
+ target_time = now.change(hour: cutoff_24, min: 0, sec: 0)
104
+ target_time += 1.day if now > target_time
105
+
106
+ remaining = (target_time - now).to_i
107
+ timer_text = "#{remaining / 3600} hrs #{(remaining % 3600) / 60} mins"
108
+
109
+ # V3 Standard: Wrap response in a 'data' block
110
+ render json: {
111
+ success: true,
112
+ data: {
113
+ date_text: date_text,
114
+ location_text: location_text,
115
+ timer_text: timer_text
116
+ }
117
+ }
118
+ else
119
+ render json: { success: false, error: "Delivery not available." }
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,5 @@
1
+ module SpreeDelhivery
2
+ class BaseJob < Spree::BaseJob
3
+ queue_as SpreeDelhivery.queue
4
+ end
5
+ end
@@ -0,0 +1,97 @@
1
+ module Spree
2
+ module Calculator::Shipping
3
+ class Delhivery < Spree::ShippingCalculator
4
+
5
+ preference :handling_fee, :decimal, default: 0.0
6
+ preference :service_mode, :string, default: 'Surface' # Full Name
7
+
8
+ def self.description
9
+ "Delhivery Live Rate"
10
+ end
11
+
12
+ def compute_package(package)
13
+ integration = Spree::Integrations::Delhivery.active.first
14
+ return nil unless integration
15
+
16
+ order = package.order
17
+ stock_location = package.stock_location
18
+ return nil unless stock_location&.zipcode && order.ship_address&.zipcode
19
+
20
+ origin_pin = stock_location.zipcode
21
+ dest_pin = order.ship_address.zipcode
22
+
23
+ store_weight_unit = integration.preferred_store_weight_unit.to_s.downcase
24
+ store_dim_unit = integration.preferred_store_dimension_unit.to_s.downcase
25
+
26
+ total_actual_weight_gms = 0.0
27
+ total_volumetric_weight_gms = 0.0
28
+
29
+ package.contents.each do |item|
30
+ variant = item.variant
31
+ qty = item.quantity
32
+
33
+ # Weight to Grams
34
+ w_raw = variant.weight.to_f
35
+ w_gms = case store_weight_unit
36
+ when 'kg', 'kilograms' then w_raw * 1000.0
37
+ when 'lbs', 'pounds' then w_raw * 453.592
38
+ when 'oz', 'ounces' then w_raw * 28.3495
39
+ when 'g', 'grams' then w_raw
40
+ else w_raw * 1000.0
41
+ end
42
+
43
+ total_actual_weight_gms += (w_gms * qty)
44
+
45
+ # Dimensions to CM
46
+ l_raw = variant.depth.to_f
47
+ w_raw = variant.width.to_f
48
+ h_raw = variant.height.to_f
49
+
50
+ if l_raw.zero?
51
+ l_raw = (store_dim_unit == 'mm' ? 100.0 : 10.0)
52
+ w_raw = (store_dim_unit == 'mm' ? 100.0 : 10.0)
53
+ h_raw = (store_dim_unit == 'mm' ? 10.0 : 1.0)
54
+ end
55
+
56
+ to_cm = ->(val) {
57
+ case store_dim_unit
58
+ when 'mm', 'millimeters' then val / 10.0
59
+ when 'm', 'meters' then val * 100.0
60
+ when 'in', 'inches' then val * 2.54
61
+ when 'cm', 'centimeters' then val
62
+ else val
63
+ end
64
+ }
65
+
66
+ vol_weight_kg = (to_cm.call(l_raw) * to_cm.call(w_raw) * to_cm.call(h_raw)) / 5000.0
67
+ total_volumetric_weight_gms += (vol_weight_kg * 1000.0 * qty)
68
+ end
69
+
70
+ chargeable_weight_gms = [total_actual_weight_gms, total_volumetric_weight_gms].max.to_i
71
+ chargeable_weight_gms = 50 if chargeable_weight_gms < 50
72
+
73
+ client = SpreeDelhivery::Client.new
74
+
75
+ begin
76
+ # Mode 'Surface'/'Express' passed directly
77
+ mode = preferred_service_mode
78
+ cache_key = "delhivery_rate_#{origin_pin}_#{dest_pin}_#{chargeable_weight_gms}_#{mode}"
79
+
80
+ rate = Rails.cache.fetch(cache_key, expires_in: 15.minutes) do
81
+ client.fetch_shipping_rate(
82
+ source_pin: origin_pin,
83
+ dest_pin: dest_pin,
84
+ weight_gms: chargeable_weight_gms,
85
+ mode: mode
86
+ )
87
+ end
88
+
89
+ rate ? rate + preferred_handling_fee : nil
90
+ rescue StandardError => e
91
+ Rails.logger.error "Delhivery Calculator Error: #{e.message}"
92
+ return nil
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,48 @@
1
+ module Spree
2
+ module Integrations
3
+ class Delhivery < Spree::Integration
4
+ # Configuration Fields
5
+ preference :api_token, :password
6
+ preference :client_name, :string # "REGULAR" or specific client name
7
+ preference :pickup_location_name, :string # Must match Delhivery Dashboard Warehouse Name EXACTLY
8
+ preference :production_mode, :boolean, default: false
9
+ preference :shipping_mode, :string, default: 'Surface' # Options: Surface, Express
10
+ preference :cod_surcharge_amount, :decimal, default: 0.0
11
+
12
+ # --- NEW UNIT PREFERENCES ---
13
+ preference :store_weight_unit, :string, default: 'kg' # Options: kg, lbs, oz, g
14
+ preference :store_dimension_unit, :string, default: 'cm' # Options: cm, in, m, mm
15
+
16
+ validates :preferred_api_token, presence: true
17
+ validates :preferred_pickup_location_name, presence: true
18
+
19
+ def self.integration_name
20
+ "Delhivery"
21
+ end
22
+
23
+ # This method is required for the Admin Index page sorting
24
+ def self.integration_group
25
+ "Shipping"
26
+ end
27
+ # --- FIX ENDS HERE ---
28
+
29
+ def self.icon_path
30
+ "integration_icons/delhivery.png" # Make sure this image exists in your assets folder
31
+ end
32
+
33
+ # 4. Helper to get the token (used in your Client)
34
+ def preferred_api_token
35
+ # You can add logic here to decrypt if you want extra security
36
+ preferences[:api_token]
37
+ end
38
+
39
+ def api_url
40
+ if preferred_production_mode
41
+ "https://track.delhivery.com"
42
+ else
43
+ "https://staging-express.delhivery.com"
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,63 @@
1
+ module Spree
2
+ module OrderDecorator
3
+ def self.prepended(base)
4
+ # Hook 1: Add/Remove COD Surcharge for both Rails (confirm) and Next.js (complete) flows
5
+ base.state_machine.before_transition to: [:confirm, :complete], do: :manage_cod_surcharge
6
+
7
+ # Hook 2: Auto-void Delhivery waybills if the order is canceled
8
+ base.state_machine.after_transition to: :canceled, do: :cancel_delhivery_shipments
9
+ end
10
+
11
+ def manage_cod_surcharge
12
+ # 1. Check if the customer chose Delhivery COD
13
+ is_cod = payments.valid.any? { |p| p.payment_method&.type == 'Spree::PaymentMethod::DelhiveryCod' }
14
+
15
+ # 2. Fetch your Delhivery configuration
16
+ integration = Spree::Integrations::Delhivery.active.first
17
+ surcharge_amount = integration&.preferred_cod_surcharge_amount.to_f
18
+
19
+ # 3. Apply or Remove the financial adjustment
20
+ if is_cod && surcharge_amount > 0
21
+ # Destroy any old COD fees to prevent duplicate stacking
22
+ adjustments.where(label: 'COD Surcharge').destroy_all
23
+
24
+ # Create the new fee
25
+ adjustments.create!(
26
+ order: self,
27
+ adjustable: self, # Apply to the whole order
28
+ label: 'COD Surcharge',
29
+ amount: surcharge_amount,
30
+ state: 'closed', # Prevents Spree's auto-calculator from zeroing it out
31
+ included: false
32
+ )
33
+ else
34
+ # If they switched back to Prepaid, cleanly remove the fee
35
+ adjustments.where(label: 'COD Surcharge').destroy_all
36
+ end
37
+
38
+ # Force Spree to recalculate the grand total with the new fee
39
+ updater.update
40
+ end
41
+
42
+ private
43
+
44
+ def cancel_delhivery_shipments
45
+ shipments.where.not(delhivery_waybill: nil).each do |shipment|
46
+ client = SpreeDelhivery::Client.new
47
+ response = client.cancel_shipment(shipment.delhivery_waybill)
48
+
49
+ if response['status'] == "True" || response['success'] == true
50
+ Rails.logger.info "[Delhivery] Auto-Cancellation Success for #{shipment.number}"
51
+ shipment.update_columns(delhivery_waybill: nil, tracking: nil)
52
+ else
53
+ Rails.logger.error "[Delhivery] Auto-Cancellation FAILED for #{shipment.number}: #{response}"
54
+ end
55
+ end
56
+ rescue StandardError => e
57
+ Rails.logger.error "[Delhivery] System Error during Auto-Cancellation: #{e.message}"
58
+ end
59
+ end
60
+ end
61
+
62
+ # Apply the decorator
63
+ Spree::Order.prepend(Spree::OrderDecorator)
@@ -0,0 +1,42 @@
1
+ module Spree
2
+ module PageBlocks
3
+ module Products
4
+ class DelhiveryEdd < (defined?(Spree::PageBlock) ? Spree::PageBlock : Object)
5
+ if defined?(Spree::PageBlock)
6
+ preference :heading_text, :string, default: 'Estimated Delivery Date'
7
+ preference :placeholder_text, :string, default: 'Enter PIN Code'
8
+ preference :button_text, :string, default: 'Check'
9
+ preference :default_mode, :string, default: 'Surface'
10
+ preference :cutoff_time, :string, default: '14:00'
11
+ preference :cutoff_hour, :string, default: '2'
12
+ preference :cutoff_meridiem, :string, default: 'PM'
13
+ preference :input_border_color, :string, default: '#E2E8F0'
14
+ preference :button_bg_color, :string, default: '#000000'
15
+ preference :button_text_color, :string, default: '#FFFFFF'
16
+ preference :success_color, :string, default: '#10B981'
17
+ preference :error_color, :string, default: '#EF4444'
18
+
19
+ def self.block_name
20
+ "Delhivery EDD Widget"
21
+ end
22
+
23
+ def self.display_name
24
+ "Delhivery Delivery Checker"
25
+ end
26
+
27
+ def icon_name
28
+ "truck-delivery"
29
+ end
30
+
31
+ def render(view_context, locals = {})
32
+ if respond_to?(:available?, true)
33
+ return '' unless available?(locals)
34
+ end
35
+ view_context.render partial: 'spree/page_blocks/products/delhivery_edd/delhivery_edd',
36
+ locals: locals.merge(block: self)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,26 @@
1
+ module Spree
2
+ module PageSections
3
+ module ProductDetailsDecorator
4
+ def default_blocks
5
+ # Safely add blocks only if they are loaded into memory
6
+ blocks = super
7
+ blocks << Spree::PageBlocks::Products::RazorpayAffordability.new if defined?(Spree::PageBlocks::Products::RazorpayAffordability)
8
+ blocks << Spree::PageBlocks::Products::DelhiveryEdd.new if defined?(Spree::PageBlocks::Products::DelhiveryEdd)
9
+ blocks
10
+ end
11
+
12
+ def available_blocks_to_add
13
+ blocks = super
14
+ blocks << Spree::PageBlocks::Products::RazorpayAffordability if defined?(Spree::PageBlocks::Products::RazorpayAffordability)
15
+ blocks << Spree::PageBlocks::Products::DelhiveryEdd if defined?(Spree::PageBlocks::Products::DelhiveryEdd)
16
+ blocks
17
+ end
18
+ end
19
+ end
20
+ end
21
+
22
+ # ONLY prepend if the core Storefront class exists.
23
+ # This sits OUTSIDE the module definition.
24
+ if defined?(Spree::PageSections::ProductDetails)
25
+ Spree::PageSections::ProductDetails.prepend(Spree::PageSections::ProductDetailsDecorator)
26
+ end