dscf-marketplace 0.13.2 → 0.13.3

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d4c6eb6ecbebefdc74c7654b87e674fba61abc1e8dcbb670510435f31fbd0e32
4
- data.tar.gz: a7b235e3a3334082c5fd21915736348646b505fdf96b26b96225a68e8da46b73
3
+ metadata.gz: 475043853266506246b2775c97fc0fb8a4c361fb7e415ae36b0c30c0a36f6a34
4
+ data.tar.gz: 5e5045ee415293974c10856a416907a1aeb9434a9e51fed26aecf72ed4ef1339
5
5
  SHA512:
6
- metadata.gz: 731973225dec8bcd3fb8ebdf4be9596967d3e48fb987f96e175b0deef38d5532c7839caa09661799308e83fe5f6e541fc87891aa787f57d72853d42b730ebeae
7
- data.tar.gz: 5636304d389272d790ca33aab6e0c336a907e88f79243ec298f6d1a8e6f094839c60c6ac7c29ab2dac139853bdfd5a2b4fb73b53beee384bc8b1b277b36ea983
6
+ metadata.gz: 19eb9d280c9b18f8cf07c1532acff967a2bee651bc930f2c24a5142686999308b176d1622ebdbe9be233662fac3ed99bb4f55f2e50b4e3f4ac3bd29bc2e516ff
7
+ data.tar.gz: a907e0a7fa9aba5f8c14b3eaabc4b47224278bfdc32d758300c20744d12747a546aa846a219386f49e1cc6855fd96adebab43274f6835181fb016bcfbb5e0841
@@ -9,7 +9,7 @@ module Dscf
9
9
 
10
10
  return create_direct_listing_order if direct_listing_request?
11
11
 
12
- obj = @clazz.new(model_params)
12
+ obj = @clazz.new(model_params.except(:retailer_id))
13
13
  if obj.save
14
14
  obj = @clazz.includes(eager_loaded_associations).find(obj.id) if eager_loaded_associations.present?
15
15
  includes = default_serializer_includes[:create] || []
@@ -45,6 +45,8 @@ module Dscf
45
45
  def confirm
46
46
  @obj = find_record
47
47
  authorize @obj, :confirm?
48
+ return render_error("orders.errors.otp_confirmation_required", status: :unprocessable_entity) if @obj.agent_assisted? && !@obj.otp_verified?
49
+
48
50
  if @obj.confirm!
49
51
  render_success("orders.success.confirmed", data: @obj)
50
52
  else
@@ -52,6 +54,46 @@ module Dscf
52
54
  end
53
55
  end
54
56
 
57
+ # The agent enters the OTP the retailer received to confirm an
58
+ # agent-assisted order. This does not touch `status` — it only
59
+ # unblocks `validate`/`confirm` (see the guards there), keeping the
60
+ # OTP gate independent of the existing fulfillment state machine.
61
+ def confirm_agent_order
62
+ @obj = find_record
63
+ authorize @obj, :confirm_agent_order?
64
+ return render_error(errors: "Order was not placed by an agent", status: :unprocessable_entity) unless @obj.agent_assisted?
65
+
66
+ otp = @obj.otp_verifications.order(created_at: :desc).first
67
+ return render_error(errors: "No OTP has been sent for this order", status: :unprocessable_entity) unless otp
68
+
69
+ case otp.verify(params[:otp_code])
70
+ when :verified
71
+ create_notification_for_order(@obj, :agent_order_confirmed)
72
+ notify_agent_order_confirmed(@obj)
73
+ render_success(data: @obj, serializer_options: { include: default_serializer_includes[:show] || [] })
74
+ when :expired
75
+ render_error(errors: "OTP has expired, request a new one", status: :unprocessable_entity)
76
+ when :max_attempts_exceeded
77
+ render_error(errors: "Too many incorrect attempts, request a new OTP", status: :unprocessable_entity)
78
+ else
79
+ render_error(errors: "Incorrect OTP", status: :unprocessable_entity)
80
+ end
81
+ end
82
+
83
+ def resend_agent_order_otp
84
+ @obj = find_record
85
+ authorize @obj, :confirm_agent_order?
86
+ return render_error(errors: "Order was not placed by an agent", status: :unprocessable_entity) unless @obj.agent_assisted?
87
+
88
+ phone = @obj.otp_verifications.order(created_at: :desc).first&.phone ||
89
+ Dscf::Marketplace::Retailer.find_by(user_id: @obj.ordered_by_id)&.phone
90
+ return render_error(errors: "No retailer phone number on file for this order", status: :unprocessable_entity) if phone.blank?
91
+
92
+ otp = Dscf::Marketplace::OtpVerification.generate_for(@obj, phone: phone)
93
+ create_notification_for_order(@obj, :agent_order_placed, otp_code: otp.code)
94
+ render_success(data: @obj, serializer_options: { include: default_serializer_includes[:show] || [] })
95
+ end
96
+
55
97
  def cancel
56
98
  @obj = find_record
57
99
  authorize @obj, :cancel?
@@ -107,6 +149,7 @@ module Dscf
107
149
  def validate
108
150
  obj = find_record
109
151
  authorize obj, :validate?
152
+ return render_error(errors: "Order awaits retailer OTP confirmation", status: :unprocessable_entity) if obj.agent_assisted? && !obj.otp_verified?
110
153
 
111
154
  Dscf::Marketplace::OrderValidationService.validate(obj)
112
155
  create_notification_for_order(obj, :validation_completed)
@@ -283,7 +326,7 @@ module Dscf
283
326
  end
284
327
 
285
328
 
286
- def create_notification_for_order(order, action, reason: nil)
329
+ def create_notification_for_order(order, action, reason: nil, otp_code: nil)
287
330
  recipient = order.ordered_by || order.user
288
331
  return unless recipient
289
332
 
@@ -298,12 +341,16 @@ module Dscf
298
341
  when :adjustment_rejected then "Adjustment rejected on order ##{order.id}"
299
342
  when :retailer_confirmed then "Retailer confirmed order ##{order.id}"
300
343
  when :retailer_rejected then "Retailer cancelled order ##{order.id}"
344
+ when :agent_order_placed then "Your agent placed order ##{order.id}"
345
+ when :agent_order_confirmed then "Order ##{order.id} confirmed"
301
346
  else "Order ##{order.id} update"
302
347
  end
303
348
 
304
349
  body = case action
305
350
  when :supplier_rejected, :retailer_rejected, :supplier_adjusted, :adjustment_rejected
306
351
  "Reason: #{reason}" if reason
352
+ when :agent_order_placed
353
+ "Your agent placed an order on your behalf. Your confirmation code is #{otp_code}. Share it only with your agent to confirm the order."
307
354
  else "Status: #{order.status}"
308
355
  end
309
356
 
@@ -317,6 +364,22 @@ module Dscf
317
364
  Dscf::Core::NotificationService.deliver(notification)
318
365
  end
319
366
 
367
+ # After the retailer's OTP confirms an agent-assisted order, both the
368
+ # agent and the aggregator (the business the order was placed with)
369
+ # get notified, in addition to the retailer's own notification above.
370
+ def notify_agent_order_confirmed(order)
371
+ [ order.agent&.user, order.ordered_to&.user ].compact.each do |recipient|
372
+ notification = Dscf::Core::Notification.create!(
373
+ notifiable: order,
374
+ recipient: recipient,
375
+ notification_type: :general,
376
+ title: "Order ##{order.id} confirmed",
377
+ body: "The retailer confirmed order ##{order.id} via OTP."
378
+ )
379
+ Dscf::Core::NotificationService.deliver(notification)
380
+ end
381
+ end
382
+
320
383
  private
321
384
 
322
385
  # Builds one or more direct-listing orders from a cart of one or more
@@ -327,6 +390,9 @@ module Dscf
327
390
  # transaction (a multi-vendor cart either checks out completely or not
328
391
  # at all — mirrors how the single-item path already behaved).
329
392
  def create_direct_listing_order
393
+ agent_order_retailer = resolve_agent_assisted_retailer!
394
+ return if performed? # resolve_agent_assisted_retailer! already rendered an error
395
+
330
396
  resolved_items = resolve_direct_listing_items
331
397
  return if performed? # resolve_direct_listing_items already rendered an error
332
398
 
@@ -337,6 +403,8 @@ module Dscf
337
403
  end
338
404
  end
339
405
 
406
+ orders.each { |order| send_agent_order_otp(order, agent_order_retailer) } if agent_order_retailer
407
+
340
408
  orders = orders.map { |o| @clazz.includes(eager_loaded_associations).find(o.id) } if eager_loaded_associations.present?
341
409
  includes = default_serializer_includes[:create] || []
342
410
  options = {include: includes} if includes.present?
@@ -354,6 +422,34 @@ module Dscf
354
422
  render_error(error: e.message)
355
423
  end
356
424
 
425
+ # When an agent places this order, ordered_by/user must be the
426
+ # retailer being ordered for — not whatever the client happens to send
427
+ # as ordered_by_id/user_id — so we resolve it server-side from
428
+ # retailer_id and overwrite those params. Returns the Retailer (used
429
+ # afterward for the OTP phone number), or nil for non-agent orders.
430
+ def resolve_agent_assisted_retailer!
431
+ return nil if model_params[:agent_id].blank?
432
+
433
+ retailer = Dscf::Marketplace::Retailer.find_by(id: model_params[:retailer_id])
434
+ if retailer.blank?
435
+ render_error(errors: "Retailer not found", status: :unprocessable_entity)
436
+ return nil
437
+ end
438
+ if retailer.user_id.blank?
439
+ render_error(errors: "Retailer has no linked user account to order on behalf of", status: :unprocessable_entity)
440
+ return nil
441
+ end
442
+
443
+ @model_params[:ordered_by_id] = retailer.user_id
444
+ @model_params[:user_id] = retailer.user_id
445
+ retailer
446
+ end
447
+
448
+ def send_agent_order_otp(order, retailer)
449
+ otp = Dscf::Marketplace::OtpVerification.generate_for(order, phone: retailer.phone)
450
+ create_notification_for_order(order, :agent_order_placed, otp_code: otp.code)
451
+ end
452
+
357
453
  # Resolves each cart line to its Listing or AggregatorListing + quantity +
358
454
  # the business it's ordered to. Falls back to the legacy top-level
359
455
  # listing_id/listing_type params when a line doesn't carry its own
@@ -413,7 +509,7 @@ module Dscf
413
509
  raise ActiveRecord::RecordInvalid.new(source)
414
510
  end
415
511
 
416
- order = @clazz.new(model_params.except(:order_items_attributes, :listing_id, :listing_type))
512
+ order = @clazz.new(model_params.except(:order_items_attributes, :listing_id, :listing_type, :retailer_id))
417
513
  order.order_type = :direct_listing
418
514
  order.status = :pending
419
515
  order.ordered_to = items.first[:ordered_to]
@@ -473,6 +569,7 @@ module Dscf
473
569
  @model_params ||= params.require(:order).permit(
474
570
  :quotation_id, :listing_id, :user_id, :ordered_by_id, :ordered_to_id, :delivery_order_id, :dropoff_address_id,
475
571
  :order_type, :status, :fulfillment_type, :payment_method, :received_bank_name, :transaction_reference, :listing_type,
572
+ :agent_id, :retailer_id,
476
573
  order_items_attributes: [ :id, :quotation_item_id, :listing_id, :listing_type, :product_id, :unit_id, :quantity, :unit_price, :status, :_destroy ]
477
574
  )
478
575
  @model_params[:payment_method] = normalize_payment_method(@model_params[:payment_method]) if @model_params.key?(:payment_method)
@@ -23,8 +23,10 @@ module Dscf::Marketplace
23
23
  belongs_to :ordered_to, class_name: "Dscf::Core::Business"
24
24
  belongs_to :delivery_order, optional: true
25
25
  belongs_to :dropoff_address, class_name: "Dscf::Core::Address", optional: true
26
+ belongs_to :agent, class_name: "Dscf::Marketplace::Agent", optional: true
26
27
  has_many :order_items, dependent: :destroy, autosave: true
27
28
  has_one :order_invoice, class_name: "Dscf::Marketplace::OrderInvoice", dependent: :destroy
29
+ has_many :otp_verifications, as: :verifiable, class_name: "Dscf::Marketplace::OtpVerification", dependent: :destroy
28
30
  accepts_nested_attributes_for :order_items, allow_destroy: true
29
31
 
30
32
  validates :order_type, presence: true
@@ -46,7 +48,7 @@ module Dscf::Marketplace
46
48
 
47
49
  # Ransack configuration for secure filtering
48
50
  def self.ransackable_attributes(_auth_object = nil)
49
- %w[id quotation_id listing_id user_id ordered_by_id ordered_to_id delivery_order_id dropoff_address_id order_type status fulfillment_type payment_method received_bank_name transaction_reference total_amount created_at updated_at]
51
+ %w[id quotation_id listing_id user_id ordered_by_id ordered_to_id delivery_order_id dropoff_address_id agent_id order_type status fulfillment_type payment_method received_bank_name transaction_reference total_amount created_at updated_at]
50
52
  end
51
53
 
52
54
  def self.ransackable_associations(_auth_object = nil)
@@ -225,6 +227,19 @@ module Dscf::Marketplace
225
227
  end
226
228
  end
227
229
 
230
+ # An agent-assisted order (agent_id present) must have its OTP verified
231
+ # by the retailer before it can enter the normal validate/confirm
232
+ # pipeline — this gate is intentionally orthogonal to `status`, which
233
+ # already has an unrelated `waiting_retailer_confirmation` meaning (the
234
+ # retailer signing off on supplier-proposed adjustments mid-fulfillment).
235
+ def agent_assisted?
236
+ agent_id.present?
237
+ end
238
+
239
+ def otp_verified?
240
+ otp_verifications.verified.exists?
241
+ end
242
+
228
243
  private
229
244
 
230
245
  def auto_assign_dropoff_address
@@ -0,0 +1,44 @@
1
+ module Dscf::Marketplace
2
+ class OtpVerification < ApplicationRecord
3
+ self.table_name = "dscf_marketplace_otp_verifications"
4
+
5
+ # Placeholder until a real SMS/OTP provider is wired up (see
6
+ # Dscf::Core::NotificationService::Adapters::SmsStub) — every OTP is the
7
+ # same known code so the flow can be exercised end-to-end without a real
8
+ # phone. Swapping in random generation later only touches generate_for.
9
+ DEFAULT_CODE = "123456"
10
+ TTL = 10.minutes
11
+ MAX_ATTEMPTS = 5
12
+
13
+ belongs_to :verifiable, polymorphic: true
14
+
15
+ enum :status, {pending: 0, verified: 1, expired: 2, failed: 3}, default: :pending
16
+
17
+ validates :phone, :code, presence: true
18
+
19
+ def self.generate_for(verifiable, phone:)
20
+ verifiable.otp_verifications.pending.update_all(status: statuses[:expired])
21
+ create!(verifiable: verifiable, phone: phone, code: DEFAULT_CODE, expires_at: TTL.from_now)
22
+ end
23
+
24
+ def verify(code)
25
+ return :verified if verified?
26
+ return :expired if past_expiry?
27
+ return :max_attempts_exceeded if failed? || attempts >= MAX_ATTEMPTS
28
+
29
+ increment!(:attempts)
30
+
31
+ if code.to_s == self.code
32
+ update!(status: :verified, verified_at: Time.current)
33
+ :verified
34
+ else
35
+ update!(status: :failed) if attempts >= MAX_ATTEMPTS
36
+ :invalid
37
+ end
38
+ end
39
+
40
+ def past_expiry?
41
+ pending? && expires_at < Time.current
42
+ end
43
+ end
44
+ end
@@ -9,6 +9,10 @@ module Dscf
9
9
  user.has_permission?("orders.confirm")
10
10
  end
11
11
 
12
+ def confirm_agent_order?
13
+ user.has_permission?("orders.confirm")
14
+ end
15
+
12
16
  def cancel?
13
17
  user.has_permission?("orders.cancel")
14
18
  end
@@ -1,7 +1,7 @@
1
1
  module Dscf
2
2
  module Marketplace
3
3
  class OrderSerializer < ActiveModel::Serializer
4
- attributes :id, :quotation_id, :listing_id, :user_id, :ordered_by_id, :ordered_to_id, :delivery_order_id, :dropoff_address_id,
4
+ attributes :id, :quotation_id, :listing_id, :user_id, :ordered_by_id, :ordered_to_id, :delivery_order_id, :dropoff_address_id, :agent_id,
5
5
  :order_type, :status, :workflow_status, :has_validation_issues, :fulfillment_type, :payment_method, :received_bank_name, :transaction_reference, :total_amount,
6
6
  :buyer_name, :buyer_phone, :buyer_email, :seller_name, :seller_phone, :seller_email,
7
7
  :created_at, :updated_at
@@ -13,6 +13,7 @@ module Dscf
13
13
  belongs_to :ordered_to
14
14
  belongs_to :delivery_order
15
15
  belongs_to :dropoff_address
16
+ belongs_to :agent
16
17
  has_many :order_items
17
18
 
18
19
  # Retailer's registered display name (users have no name field of their
data/config/routes.rb CHANGED
@@ -137,6 +137,8 @@ Dscf::Marketplace::Engine.routes.draw do
137
137
  post "reject_adjustment"
138
138
  post "reopen_item"
139
139
  post "retailer_confirm"
140
+ post "confirm_agent_order"
141
+ post "resend_agent_order_otp"
140
142
  end
141
143
  collection do
142
144
  get "my_orders"
@@ -0,0 +1,8 @@
1
+ class AddAgentIdToDscfMarketplaceOrders < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_reference :dscf_marketplace_orders, :agent,
4
+ foreign_key: { to_table: :dscf_marketplace_agents },
5
+ index: { name: "agent_on_dm_orders_indx" },
6
+ null: true
7
+ end
8
+ end
@@ -0,0 +1,19 @@
1
+ class CreateDscfMarketplaceOtpVerifications < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :dscf_marketplace_otp_verifications do |t|
4
+ t.references :verifiable, polymorphic: true, null: false, index: false
5
+ t.string :phone, null: false
6
+ t.string :code, null: false
7
+ t.integer :status, default: 0, null: false
8
+ t.integer :attempts, default: 0, null: false
9
+ t.datetime :expires_at, null: false
10
+ t.datetime :verified_at
11
+
12
+ t.timestamps
13
+ end
14
+
15
+ add_index :dscf_marketplace_otp_verifications, %i[verifiable_type verifiable_id],
16
+ name: "verifiable_on_dm_otp_verifications_indx"
17
+ add_index :dscf_marketplace_otp_verifications, :status
18
+ end
19
+ end
@@ -1,5 +1,5 @@
1
1
  module Dscf
2
2
  module Marketplace
3
- VERSION = "0.13.2".freeze
3
+ VERSION = "0.13.3".freeze
4
4
  end
5
5
  end
@@ -0,0 +1,8 @@
1
+ FactoryBot.define do
2
+ factory :dscf_marketplace_otp_verification, class: "Dscf::Marketplace::OtpVerification" do
3
+ phone { Faker::PhoneNumber.phone_number }
4
+ code { Dscf::Marketplace::OtpVerification::DEFAULT_CODE }
5
+ expires_at { Dscf::Marketplace::OtpVerification::TTL.from_now }
6
+ association :verifiable, factory: :dscf_marketplace_order
7
+ end
8
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dscf-marketplace
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.2
4
+ version: 0.13.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Asrat
@@ -459,6 +459,7 @@ files:
459
459
  - app/models/dscf/marketplace/order.rb
460
460
  - app/models/dscf/marketplace/order_invoice.rb
461
461
  - app/models/dscf/marketplace/order_item.rb
462
+ - app/models/dscf/marketplace/otp_verification.rb
462
463
  - app/models/dscf/marketplace/product.rb
463
464
  - app/models/dscf/marketplace/product_inclusion_request.rb
464
465
  - app/models/dscf/marketplace/quotation.rb
@@ -594,6 +595,8 @@ files:
594
595
  - db/migrate/20260622000001_add_validation_and_source_fields_to_dscf_marketplace_order_items.rb
595
596
  - db/migrate/20260702000001_unify_aggregator_listing_source.rb
596
597
  - db/migrate/20260703000001_add_supplier_adjustment_to_dscf_marketplace_order_items.rb
598
+ - db/migrate/20260704000001_add_agent_id_to_dscf_marketplace_orders.rb
599
+ - db/migrate/20260704000002_create_dscf_marketplace_otp_verifications.rb
597
600
  - db/seeds.rb
598
601
  - lib/dscf/marketplace.rb
599
602
  - lib/dscf/marketplace/engine.rb
@@ -617,6 +620,7 @@ files:
617
620
  - spec/factories/dscf/marketplace/order_invoices.rb
618
621
  - spec/factories/dscf/marketplace/order_items.rb
619
622
  - spec/factories/dscf/marketplace/orders.rb
623
+ - spec/factories/dscf/marketplace/otp_verifications.rb
620
624
  - spec/factories/dscf/marketplace/product_inclusion_requests.rb
621
625
  - spec/factories/dscf/marketplace/products.rb
622
626
  - spec/factories/dscf/marketplace/quotation_items.rb