bscf-core 0.3.99 → 0.4.1
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 +4 -4
- data/app/models/bscf/core/address.rb +25 -0
- data/app/models/bscf/core/delivery_order.rb +129 -53
- data/app/models/bscf/core/delivery_order_item.rb +9 -1
- data/app/models/bscf/core/virtual_account_transaction.rb +53 -92
- data/app/services/bscf/core/gebeta_maps_service.rb +59 -0
- data/app/services/bscf/core/transaction_service.rb +185 -0
- data/config/master.key +1 -0
- data/db/migrate/20250609180530_add_accounting_fields_to_virtual_account_transactions.rb +20 -0
- data/db/migrate/20250610120351_update_delivery_tables.rb +15 -0
- data/db/migrate/20250616134209_update_address_coordinates_type.rb +6 -0
- data/db/migrate/20250616145522_add_position_to_delivery_order_items.rb +6 -0
- data/lib/bscf/core/version.rb +1 -1
- data/spec/factories/bscf/core/delivery_order_items.rb +2 -1
- data/spec/factories/bscf/core/delivery_orders.rb +3 -26
- data/spec/factories/bscf/core/virtual_account_transactions.rb +36 -21
- metadata +8 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: ac477439e787a6451f0b7d5a92ac75e430ac8b0ddcd7a04bb5f8e57c4bd086dc
|
4
|
+
data.tar.gz: 4bd37eaa9562180049efec4028c438404bce7f08661c27c2c9e4018ba88a724a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 37282dd5fdd0974155a6d4f05000fca63f94d897a61d16d764e3dc047a769957ec7927f99924852e7b6f12e425e71689b18f59a987e984a88d21b31d9fe6ba12
|
7
|
+
data.tar.gz: 7c5d5491af64a7f91ed9a951fa04358e74a7e124d6e3f4fd232c9a8ef1461be00aaa11fc296c0fdf673145618f15035756927e6ee75b1a0e2c83f80a9879ada9
|
@@ -2,6 +2,31 @@ module Bscf
|
|
2
2
|
module Core
|
3
3
|
class Address < ApplicationRecord
|
4
4
|
has_many :user_profiles
|
5
|
+
|
6
|
+
# Add associations for delivery orders and items
|
7
|
+
has_many :delivery_order_pickups, class_name: "Bscf::Core::DeliveryOrder", foreign_key: "pickup_address_id"
|
8
|
+
has_many :delivery_order_dropoffs, class_name: "Bscf::Core::DeliveryOrder", foreign_key: "dropoff_address_id"
|
9
|
+
has_many :delivery_order_item_pickups, class_name: "Bscf::Core::DeliveryOrderItem", foreign_key: "pickup_address_id"
|
10
|
+
has_many :delivery_order_item_dropoffs, class_name: "Bscf::Core::DeliveryOrderItem", foreign_key: "dropoff_address_id"
|
11
|
+
|
12
|
+
validates :latitude, :longitude, presence: true, if: :requires_coordinates?
|
13
|
+
|
14
|
+
def coordinates
|
15
|
+
%i[latitude longitude] if latitude.present? && longitude.present?
|
16
|
+
end
|
17
|
+
|
18
|
+
def full_address
|
19
|
+
%i[house_number woreda sub_city city].compact.join(", ")
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def requires_coordinates?
|
25
|
+
# Determine when coordinates are required
|
26
|
+
# For delivery addresses, they should always be required
|
27
|
+
delivery_order_pickups.any? || delivery_order_dropoffs.any? ||
|
28
|
+
delivery_order_item_pickups.any? || delivery_order_item_dropoffs.any?
|
29
|
+
end
|
5
30
|
end
|
6
31
|
end
|
7
32
|
end
|
@@ -1,63 +1,139 @@
|
|
1
|
-
module Bscf
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
enum :status, {
|
22
|
-
pending: 0,
|
23
|
-
in_transit: 1,
|
24
|
-
picked_up: 2,
|
25
|
-
delivered: 3,
|
26
|
-
received: 4,
|
27
|
-
paid: 5,
|
28
|
-
failed: 6,
|
29
|
-
cancelled: 7
|
30
|
-
}
|
31
|
-
|
32
|
-
def delivery_duration
|
33
|
-
return nil unless delivery_start_time && delivery_end_time
|
34
|
-
((delivery_end_time - delivery_start_time) / 1.hour).round(2)
|
35
|
-
end
|
1
|
+
module Bscf
|
2
|
+
module Core
|
3
|
+
class DeliveryOrder < ApplicationRecord
|
4
|
+
has_many :orders
|
5
|
+
belongs_to :pickup_address, class_name: "Bscf::Core::Address"
|
6
|
+
belongs_to :dropoff_address, class_name: "Bscf::Core::Address"
|
7
|
+
belongs_to :driver, class_name: "Bscf::Core::User", optional: true
|
8
|
+
|
9
|
+
has_many :delivery_order_items, dependent: :destroy
|
10
|
+
has_many :order_items, through: :delivery_order_items
|
11
|
+
has_many :products, through: :delivery_order_items
|
12
|
+
|
13
|
+
validates :driver_phone, :status, :estimated_delivery_time, presence: true
|
14
|
+
validate :end_time_after_start_time, if: -> { delivery_start_time.present? && delivery_end_time.present? }
|
15
|
+
|
16
|
+
before_save :update_delivery_times
|
17
|
+
before_save :calculate_actual_delivery_time
|
18
|
+
|
19
|
+
# after_save :sync_items_status, if: :saved_change_to_status?
|
36
20
|
|
37
|
-
|
21
|
+
enum :status, {
|
22
|
+
pending: 0,
|
23
|
+
in_transit: 1,
|
24
|
+
picked_up: 2,
|
25
|
+
delivered: 3,
|
26
|
+
received: 4,
|
27
|
+
paid: 5,
|
28
|
+
failed: 6,
|
29
|
+
cancelled: 7
|
30
|
+
}
|
38
31
|
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
self.delivery_start_time = Time.current if delivery_start_time.nil?
|
43
|
-
when "delivered", "failed"
|
44
|
-
self.delivery_end_time = Time.current if delivery_end_time.nil?
|
32
|
+
def delivery_duration
|
33
|
+
return nil unless delivery_start_time && delivery_end_time
|
34
|
+
((delivery_end_time - delivery_start_time) / 1.hour).round(2)
|
45
35
|
end
|
46
|
-
end
|
47
36
|
|
48
|
-
|
49
|
-
|
50
|
-
|
37
|
+
def optimized_route
|
38
|
+
return nil unless pickup_address&.coordinates.present?
|
39
|
+
|
40
|
+
# Get all delivery order items with dropoff addresses
|
41
|
+
items_with_dropoffs = delivery_order_items.includes(:dropoff_address)
|
42
|
+
.where.not(dropoff_address: nil)
|
43
|
+
|
44
|
+
dropoff_addresses = items_with_dropoffs.map(&:dropoff_address).compact.uniq
|
45
|
+
return nil if dropoff_addresses.empty?
|
51
46
|
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
47
|
+
# Check if all dropoff addresses have coordinates
|
48
|
+
return nil if dropoff_addresses.any? { |addr| addr.coordinates.blank? }
|
49
|
+
|
50
|
+
# Call Gebeta Maps service
|
51
|
+
gebeta_service = Bscf::Core::GebetaMapsService.new
|
52
|
+
route_data = gebeta_service.optimize_route(pickup_address, dropoff_addresses)
|
53
|
+
|
54
|
+
# Return nil if route_data is empty or doesn't have waypoints
|
55
|
+
return nil if route_data.blank? || !route_data.key?("waypoints")
|
56
|
+
|
57
|
+
# Cache the result if needed
|
58
|
+
# Rails.cache.write("delivery_order_route_#{id}", route_data, expires_in: 1.hour)
|
59
|
+
route_data
|
56
60
|
end
|
57
|
-
end
|
58
61
|
|
59
|
-
|
60
|
-
|
62
|
+
# Reorder delivery items based on optimized route or provided positions
|
63
|
+
def reorder_items_by_route(positions = nil)
|
64
|
+
if positions.is_a?(Hash)
|
65
|
+
# Use provided positions
|
66
|
+
positions.each do |item_id, position|
|
67
|
+
if item = delivery_order_items.find_by(id: item_id)
|
68
|
+
item.update(position: position)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
true
|
72
|
+
else
|
73
|
+
return false if positions && !positions.is_a?(Hash)
|
74
|
+
|
75
|
+
# Use optimized route
|
76
|
+
route_data = optimized_route
|
77
|
+
|
78
|
+
return false unless route_data.present? && route_data["waypoints"].present?
|
79
|
+
|
80
|
+
# Get the optimized order of waypoints
|
81
|
+
waypoint_order = route_data["waypoints"].map { |wp| wp["waypoint_index"] }
|
82
|
+
|
83
|
+
# Get all delivery order items with dropoff addresses
|
84
|
+
items_with_dropoffs = delivery_order_items.includes(:dropoff_address)
|
85
|
+
.where.not(dropoff_address: nil)
|
86
|
+
.to_a
|
87
|
+
|
88
|
+
# Skip if we don't have the same number of items as waypoints
|
89
|
+
return false if items_with_dropoffs.size != waypoint_order.size
|
90
|
+
|
91
|
+
# Reorder items based on waypoint order
|
92
|
+
waypoint_order.each_with_index do |original_index, new_position|
|
93
|
+
if item = items_with_dropoffs[original_index]
|
94
|
+
item.update(position: new_position + 1)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
true
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
# Add this method to the DeliveryOrder class
|
103
|
+
def dropoff_addresses
|
104
|
+
delivery_order_items.includes(:dropoff_address)
|
105
|
+
.where.not(dropoff_address: nil)
|
106
|
+
.map(&:dropoff_address)
|
107
|
+
.compact
|
108
|
+
.uniq
|
109
|
+
end
|
110
|
+
|
111
|
+
|
112
|
+
private
|
113
|
+
|
114
|
+
def update_delivery_times
|
115
|
+
case status
|
116
|
+
when "in_transit"
|
117
|
+
self.delivery_start_time = Time.current if delivery_start_time.nil?
|
118
|
+
when "delivered", "failed"
|
119
|
+
self.delivery_end_time = Time.current if delivery_end_time.nil?
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
def calculate_actual_delivery_time
|
124
|
+
self.actual_delivery_time = delivery_end_time if status == "delivered"
|
125
|
+
end
|
126
|
+
|
127
|
+
def end_time_after_start_time
|
128
|
+
return unless delivery_start_time && delivery_end_time
|
129
|
+
if delivery_end_time <= delivery_start_time
|
130
|
+
errors.add(:delivery_end_time, "must be after delivery start time")
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
def sync_items_status
|
135
|
+
delivery_order_items.update_all(status: status)
|
136
|
+
end
|
61
137
|
end
|
62
138
|
end
|
63
139
|
end
|
@@ -2,7 +2,8 @@ module Bscf::Core
|
|
2
2
|
class DeliveryOrderItem < ApplicationRecord
|
3
3
|
belongs_to :delivery_order
|
4
4
|
belongs_to :order_item
|
5
|
-
belongs_to :
|
5
|
+
belongs_to :pickup_address, class_name: "Bscf::Core::Address", optional: true
|
6
|
+
belongs_to :dropoff_address, class_name: "Bscf::Core::Address", optional: true
|
6
7
|
|
7
8
|
validates :quantity, :status, presence: true
|
8
9
|
validate :quantity_not_exceeding_order_item
|
@@ -10,6 +11,9 @@ module Bscf::Core
|
|
10
11
|
|
11
12
|
after_save :update_delivery_order_status
|
12
13
|
before_save :sync_status_with_delivery_order
|
14
|
+
before_save :set_default_pickup_address, if: -> { pickup_address.nil? && delivery_order.present? }
|
15
|
+
# Add default scope for ordering by position
|
16
|
+
default_scope { order(position: :asc) }
|
13
17
|
|
14
18
|
enum :status, {
|
15
19
|
pending: 0,
|
@@ -21,6 +25,10 @@ module Bscf::Core
|
|
21
25
|
|
22
26
|
private
|
23
27
|
|
28
|
+
def set_default_pickup_address
|
29
|
+
self.pickup_address = delivery_order.pickup_address
|
30
|
+
end
|
31
|
+
|
24
32
|
def quantity_not_exceeding_order_item
|
25
33
|
return unless quantity && order_item&.quantity
|
26
34
|
if quantity > order_item.quantity
|
@@ -1,20 +1,27 @@
|
|
1
1
|
module Bscf
|
2
2
|
module Core
|
3
3
|
class VirtualAccountTransaction < ApplicationRecord
|
4
|
-
belongs_to :
|
5
|
-
belongs_to :
|
4
|
+
belongs_to :account, class_name: "Bscf::Core::VirtualAccount"
|
5
|
+
belongs_to :paired_transaction, class_name: "Bscf::Core::VirtualAccountTransaction", optional: true
|
6
6
|
|
7
|
-
validates :
|
8
|
-
validates :to_account_id, presence: true, if: :requires_to_account?
|
7
|
+
validates :account_id, presence: true
|
9
8
|
validates :amount, presence: true, numericality: { greater_than: 0 }
|
10
9
|
validates :transaction_type, presence: true
|
10
|
+
validates :entry_type, presence: true
|
11
11
|
validates :status, presence: true
|
12
12
|
validates :reference_number, presence: true, uniqueness: true
|
13
13
|
|
14
14
|
enum :transaction_type, {
|
15
15
|
transfer: 0,
|
16
16
|
deposit: 1,
|
17
|
-
withdrawal: 2
|
17
|
+
withdrawal: 2,
|
18
|
+
fee: 3,
|
19
|
+
adjustment: 4
|
20
|
+
}
|
21
|
+
|
22
|
+
enum :entry_type, {
|
23
|
+
debit: 0,
|
24
|
+
credit: 1
|
18
25
|
}
|
19
26
|
|
20
27
|
enum :status, {
|
@@ -27,122 +34,76 @@ module Bscf
|
|
27
34
|
before_validation :generate_reference_number, on: :create
|
28
35
|
validate :validate_transaction, on: :create
|
29
36
|
|
37
|
+
scope :debits, -> { where(entry_type: :debit) }
|
38
|
+
scope :credits, -> { where(entry_type: :credit) }
|
39
|
+
scope :for_account, ->(account_id) { where(account_id: account_id) }
|
40
|
+
scope :by_type, ->(type) { where(transaction_type: type) }
|
41
|
+
scope :by_status, ->(status) { where(status: status) }
|
42
|
+
|
30
43
|
def process!
|
31
44
|
return false unless pending?
|
32
45
|
|
33
46
|
ActiveRecord::Base.transaction do
|
34
|
-
|
47
|
+
update_account_balance
|
48
|
+
|
35
49
|
update!(status: :completed)
|
50
|
+
paired_transaction&.update!(status: :completed)
|
36
51
|
end
|
37
52
|
true
|
38
53
|
rescue StandardError => e
|
39
54
|
update(status: :failed)
|
55
|
+
paired_transaction&.update(status: :failed)
|
56
|
+
Rails.logger.error("Transaction processing failed: #{e.message}")
|
40
57
|
false
|
41
58
|
end
|
42
59
|
|
43
60
|
def cancel!
|
44
61
|
return false unless pending?
|
45
|
-
update(status: :cancelled)
|
46
|
-
end
|
47
|
-
|
48
|
-
private
|
49
|
-
|
50
|
-
def requires_from_account?
|
51
|
-
return false if transaction_type.nil?
|
52
|
-
%w[transfer withdrawal].include?(transaction_type)
|
53
|
-
end
|
54
62
|
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
end
|
59
|
-
|
60
|
-
def validate_transaction
|
61
|
-
case transaction_type.to_sym
|
62
|
-
when :transfer
|
63
|
-
validate_transfer
|
64
|
-
when :withdrawal
|
65
|
-
validate_withdrawal
|
66
|
-
when :deposit
|
67
|
-
validate_deposit
|
63
|
+
ActiveRecord::Base.transaction do
|
64
|
+
update(status: :cancelled)
|
65
|
+
paired_transaction&.update(status: :cancelled)
|
68
66
|
end
|
69
|
-
|
70
|
-
validate_account_requirements
|
71
|
-
end
|
72
|
-
|
73
|
-
def validate_transfer
|
74
|
-
return unless from_account && to_account
|
75
|
-
|
76
|
-
errors.add(:from_account, "must be active") unless from_account.active?
|
77
|
-
errors.add(:to_account, "must be active") unless to_account.active?
|
78
|
-
errors.add(:from_account, "insufficient balance") if from_account.balance.to_d < amount.to_d
|
67
|
+
true
|
79
68
|
end
|
80
69
|
|
81
70
|
private
|
82
71
|
|
83
|
-
def
|
84
|
-
|
85
|
-
when :transfer
|
86
|
-
return if from_account_id.present? && to_account_id.present?
|
87
|
-
errors.add(:base, "Both accounts are required for transfer")
|
88
|
-
when :withdrawal
|
89
|
-
return if from_account_id.present?
|
90
|
-
errors.add(:base, "Source account is required for withdrawal")
|
91
|
-
errors.add(:to_account_id, "must be blank for withdrawal")
|
92
|
-
when :deposit
|
93
|
-
return if to_account_id.present?
|
94
|
-
errors.add(:base, "Destination account is required for deposit")
|
95
|
-
errors.add(:from_account_id, "must be blank for deposit")
|
96
|
-
end
|
97
|
-
end
|
98
|
-
|
99
|
-
def validate_withdrawal
|
100
|
-
return unless from_account
|
101
|
-
errors.add(:from_account, "must be active") unless from_account.active?
|
102
|
-
errors.add(:from_account, "insufficient balance") if from_account.balance.to_d < amount.to_d
|
103
|
-
end
|
72
|
+
def validate_transaction
|
73
|
+
errors.add(:account, "must be active") unless account.active?
|
104
74
|
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
end
|
75
|
+
if debit? && !adjustment? && account.balance.to_d < amount.to_d
|
76
|
+
errors.add(:account, "insufficient balance")
|
77
|
+
end
|
109
78
|
|
110
|
-
|
111
|
-
|
112
|
-
when :transfer
|
113
|
-
process_transfer
|
114
|
-
when :withdrawal
|
115
|
-
process_withdrawal
|
116
|
-
when :deposit
|
117
|
-
process_deposit
|
79
|
+
if transfer? && paired_transaction.blank?
|
80
|
+
errors.add(:paired_transaction, "must be present for transfers")
|
118
81
|
end
|
119
82
|
end
|
120
83
|
|
121
|
-
def
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
from_account.update!(balance: new_from_balance)
|
129
|
-
to_account.update!(balance: new_to_balance)
|
130
|
-
end
|
84
|
+
def update_account_balance
|
85
|
+
account.with_lock do
|
86
|
+
new_balance = if debit?
|
87
|
+
(account.balance - amount).round(2)
|
88
|
+
else
|
89
|
+
(account.balance + amount).round(2)
|
131
90
|
end
|
132
|
-
end
|
133
|
-
end
|
134
91
|
|
135
|
-
|
136
|
-
|
137
|
-
new_balance = (from_account.balance - amount).round(2)
|
138
|
-
from_account.update!(balance: new_balance)
|
92
|
+
account.update!(balance: new_balance)
|
93
|
+
update!(running_balance: new_balance)
|
139
94
|
end
|
140
|
-
end
|
141
95
|
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
96
|
+
if paired_transaction.present?
|
97
|
+
paired_transaction.account.with_lock do
|
98
|
+
new_balance = if paired_transaction.debit?
|
99
|
+
(paired_transaction.account.balance - paired_transaction.amount).round(2)
|
100
|
+
else
|
101
|
+
(paired_transaction.account.balance + paired_transaction.amount).round(2)
|
102
|
+
end
|
103
|
+
|
104
|
+
paired_transaction.account.update!(balance: new_balance)
|
105
|
+
paired_transaction.update!(running_balance: new_balance)
|
106
|
+
end
|
146
107
|
end
|
147
108
|
end
|
148
109
|
|
@@ -0,0 +1,59 @@
|
|
1
|
+
require "httparty"
|
2
|
+
|
3
|
+
module Bscf
|
4
|
+
module Core
|
5
|
+
class GebetaMapsService
|
6
|
+
include HTTParty
|
7
|
+
base_uri "https://api.gebeta.app/api/v1"
|
8
|
+
|
9
|
+
def initialize
|
10
|
+
@api_key = ENV["GEBETA_API_KEY"]
|
11
|
+
end
|
12
|
+
|
13
|
+
# One-to-Many (ONM) API for route optimization
|
14
|
+
def optimize_route(origin_address, destination_addresses)
|
15
|
+
return {} if destination_addresses.empty?
|
16
|
+
|
17
|
+
# Extract coordinates
|
18
|
+
origin = origin_address.coordinates
|
19
|
+
destinations = destination_addresses.map(&:coordinates).compact
|
20
|
+
|
21
|
+
return {} if origin.nil? || destinations.empty?
|
22
|
+
|
23
|
+
# Prepare destinations in the format expected by Gebeta Maps
|
24
|
+
destinations_json = destinations.map.with_index do |coords, index|
|
25
|
+
{
|
26
|
+
id: index,
|
27
|
+
point: {
|
28
|
+
lat: coords[0],
|
29
|
+
lng: coords[1]
|
30
|
+
}
|
31
|
+
}
|
32
|
+
end.to_json
|
33
|
+
|
34
|
+
# Make API request
|
35
|
+
response = self.class.get(
|
36
|
+
"/direction/onm",
|
37
|
+
query: {
|
38
|
+
origin: "#{origin[0]},#{origin[1]}",
|
39
|
+
json: destinations_json,
|
40
|
+
key: @api_key
|
41
|
+
}
|
42
|
+
)
|
43
|
+
|
44
|
+
handle_response(response)
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
def handle_response(response)
|
50
|
+
if response.success?
|
51
|
+
JSON.parse(response.body)
|
52
|
+
else
|
53
|
+
Rails.logger.error("Gebeta Maps API error: #{response.code} - #{response.body}")
|
54
|
+
{}
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,185 @@
|
|
1
|
+
module Bscf
|
2
|
+
module Core
|
3
|
+
class TransactionService
|
4
|
+
# Create a transfer between two accounts
|
5
|
+
# @param from_account_id [Integer] Source account ID
|
6
|
+
# @param to_account_id [Integer] Destination account ID
|
7
|
+
# @param amount [Decimal] Amount to transfer
|
8
|
+
# @param description [String] Optional description
|
9
|
+
# @return [Array<VirtualAccountTransaction>] The created transaction pair
|
10
|
+
def self.create_transfer(from_account_id:, to_account_id:, amount:, description: nil)
|
11
|
+
create_paired_transaction(
|
12
|
+
from_account_id: from_account_id,
|
13
|
+
to_account_id: to_account_id,
|
14
|
+
amount: amount,
|
15
|
+
transaction_type: :transfer,
|
16
|
+
description: description
|
17
|
+
)
|
18
|
+
end
|
19
|
+
|
20
|
+
# Create a deposit to an account
|
21
|
+
# @param to_account_id [Integer] Destination account ID
|
22
|
+
# @param amount [Decimal] Amount to deposit
|
23
|
+
# @param description [String] Optional description
|
24
|
+
# @return [Array<VirtualAccountTransaction>] The created transaction pair
|
25
|
+
def self.create_deposit(to_account_id:, amount:, description: nil)
|
26
|
+
system_account = find_or_create_system_account("DEPOSIT_ACCOUNT")
|
27
|
+
|
28
|
+
# Ensure system account has sufficient balance for deposits
|
29
|
+
ensure_system_account_balance(system_account, amount)
|
30
|
+
|
31
|
+
create_paired_transaction(
|
32
|
+
from_account_id: system_account.id,
|
33
|
+
to_account_id: to_account_id,
|
34
|
+
amount: amount,
|
35
|
+
transaction_type: :deposit,
|
36
|
+
description: description
|
37
|
+
)
|
38
|
+
end
|
39
|
+
|
40
|
+
# Create a withdrawal from an account
|
41
|
+
# @param from_account_id [Integer] Source account ID
|
42
|
+
# @param amount [Decimal] Amount to withdraw
|
43
|
+
# @param description [String] Optional description
|
44
|
+
# @return [Array<VirtualAccountTransaction>] The created transaction pair
|
45
|
+
def self.create_withdrawal(from_account_id:, amount:, description: nil)
|
46
|
+
system_account = find_or_create_system_account("WITHDRAWAL_ACCOUNT")
|
47
|
+
|
48
|
+
create_paired_transaction(
|
49
|
+
from_account_id: from_account_id,
|
50
|
+
to_account_id: system_account.id,
|
51
|
+
amount: amount,
|
52
|
+
transaction_type: :withdrawal,
|
53
|
+
description: description
|
54
|
+
)
|
55
|
+
end
|
56
|
+
|
57
|
+
# Create a fee transaction
|
58
|
+
# @param from_account_id [Integer] Account to charge
|
59
|
+
# @param amount [Decimal] Fee amount
|
60
|
+
# @param description [String] Optional description
|
61
|
+
# @return [Array<VirtualAccountTransaction>] The created transaction pair
|
62
|
+
def self.create_fee(from_account_id:, amount:, description: nil)
|
63
|
+
fee_account = find_or_create_system_account("FEE_ACCOUNT")
|
64
|
+
|
65
|
+
create_paired_transaction(
|
66
|
+
from_account_id: from_account_id,
|
67
|
+
to_account_id: fee_account.id,
|
68
|
+
amount: amount,
|
69
|
+
transaction_type: :fee,
|
70
|
+
description: description
|
71
|
+
)
|
72
|
+
end
|
73
|
+
|
74
|
+
# Create an adjustment to an account
|
75
|
+
# @param account_id [Integer] Account to adjust
|
76
|
+
# @param amount [Decimal] Adjustment amount
|
77
|
+
# @param is_debit [Boolean] Whether this is a debit (true) or credit (false)
|
78
|
+
# @param description [String] Optional description
|
79
|
+
# @return [VirtualAccountTransaction] The created adjustment transaction
|
80
|
+
def self.create_adjustment(account_id:, amount:, is_debit: true, description: nil)
|
81
|
+
reference_number = generate_reference_number
|
82
|
+
|
83
|
+
VirtualAccountTransaction.create!(
|
84
|
+
transaction_type: :adjustment,
|
85
|
+
entry_type: is_debit ? :debit : :credit,
|
86
|
+
account_id: account_id,
|
87
|
+
amount: amount,
|
88
|
+
reference_number: reference_number,
|
89
|
+
description: description,
|
90
|
+
status: :pending
|
91
|
+
)
|
92
|
+
end
|
93
|
+
|
94
|
+
# Process a transaction
|
95
|
+
# @param transaction [VirtualAccountTransaction] Transaction to process
|
96
|
+
# @return [Boolean] Success or failure
|
97
|
+
def self.process(transaction)
|
98
|
+
transaction.process!
|
99
|
+
end
|
100
|
+
|
101
|
+
# Cancel a transaction
|
102
|
+
# @param transaction [VirtualAccountTransaction] Transaction to cancel
|
103
|
+
# @return [Boolean] Success or failure
|
104
|
+
def self.cancel(transaction)
|
105
|
+
transaction.cancel!
|
106
|
+
end
|
107
|
+
|
108
|
+
private
|
109
|
+
|
110
|
+
def self.create_paired_transaction(from_account_id:, to_account_id:, amount:, transaction_type:, description: nil)
|
111
|
+
reference_number = generate_reference_number
|
112
|
+
|
113
|
+
ActiveRecord::Base.transaction do
|
114
|
+
# Debit entry (from account)
|
115
|
+
debit = VirtualAccountTransaction.create!(
|
116
|
+
transaction_type: transaction_type,
|
117
|
+
entry_type: :debit,
|
118
|
+
account_id: from_account_id,
|
119
|
+
amount: amount,
|
120
|
+
reference_number: reference_number,
|
121
|
+
description: description,
|
122
|
+
status: :pending
|
123
|
+
)
|
124
|
+
|
125
|
+
# Credit entry (to account)
|
126
|
+
credit = VirtualAccountTransaction.create!(
|
127
|
+
transaction_type: transaction_type,
|
128
|
+
entry_type: :credit,
|
129
|
+
account_id: to_account_id,
|
130
|
+
amount: amount,
|
131
|
+
reference_number: reference_number,
|
132
|
+
description: description,
|
133
|
+
status: :pending,
|
134
|
+
paired_transaction_id: debit.id
|
135
|
+
)
|
136
|
+
|
137
|
+
# Update the paired_transaction_id for the debit entry
|
138
|
+
debit.update!(paired_transaction_id: credit.id)
|
139
|
+
|
140
|
+
[ debit, credit ]
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
def self.find_or_create_system_account(account_identifier)
|
145
|
+
# Find or create a system account for external transactions
|
146
|
+
account = VirtualAccount.find_or_create_by!(account_number: account_identifier) do |account|
|
147
|
+
# Set default values for a new system account
|
148
|
+
account.user_id = find_or_create_system_user.id
|
149
|
+
account.cbs_account_number = account_identifier
|
150
|
+
account.branch_code = "SYSTEM"
|
151
|
+
account.product_scheme = "SAVINGS"
|
152
|
+
account.voucher_type = "REGULAR"
|
153
|
+
account.interest_rate = 0
|
154
|
+
account.interest_type = :simple
|
155
|
+
account.balance = 100000.00 # Initialize with a large balance
|
156
|
+
account.locked_amount = 0
|
157
|
+
account.status = :active
|
158
|
+
end
|
159
|
+
|
160
|
+
account
|
161
|
+
end
|
162
|
+
|
163
|
+
def self.ensure_system_account_balance(account, required_amount)
|
164
|
+
# Top up the system account if needed
|
165
|
+
if account.balance < required_amount
|
166
|
+
account.update!(balance: account.balance + required_amount * 2)
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
def self.find_or_create_system_user
|
171
|
+
User.find_or_create_by!(phone_number: "SYSTEM_USER") do |user|
|
172
|
+
user.first_name = "System"
|
173
|
+
user.last_name = "User"
|
174
|
+
user.password = SecureRandom.hex(8)
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
def self.generate_reference_number
|
179
|
+
timestamp = Time.current.strftime("%Y%m%d%H%M%S")
|
180
|
+
random = SecureRandom.hex(3)
|
181
|
+
"TXN#{timestamp}#{random}"
|
182
|
+
end
|
183
|
+
end
|
184
|
+
end
|
185
|
+
end
|
data/config/master.key
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
3009aef5af3ca1ddb654f74ad2ac7d5a
|
@@ -0,0 +1,20 @@
|
|
1
|
+
class AddAccountingFieldsToVirtualAccountTransactions < ActiveRecord::Migration[8.0]
|
2
|
+
def change
|
3
|
+
# Add new accounting fields
|
4
|
+
add_column :bscf_core_virtual_account_transactions, :entry_type, :integer
|
5
|
+
add_reference :bscf_core_virtual_account_transactions, :account, null: false, foreign_key: { to_table: :bscf_core_virtual_accounts }
|
6
|
+
add_column :bscf_core_virtual_account_transactions, :running_balance, :decimal, precision: 10, scale: 2
|
7
|
+
add_reference :bscf_core_virtual_account_transactions, :paired_transaction, null: true, foreign_key: { to_table: :bscf_core_virtual_account_transactions }
|
8
|
+
add_column :bscf_core_virtual_account_transactions, :value_date, :datetime
|
9
|
+
|
10
|
+
# Add new indexes
|
11
|
+
add_index :bscf_core_virtual_account_transactions, [ :account_id, :reference_number ]
|
12
|
+
add_index :bscf_core_virtual_account_transactions, :entry_type
|
13
|
+
|
14
|
+
# Remove old fields and indexes
|
15
|
+
remove_index :bscf_core_virtual_account_transactions, [ :from_account_id, :reference_number ], if_exists: true
|
16
|
+
remove_index :bscf_core_virtual_account_transactions, [ :to_account_id, :reference_number ], if_exists: true
|
17
|
+
remove_reference :bscf_core_virtual_account_transactions, :from_account
|
18
|
+
remove_reference :bscf_core_virtual_account_transactions, :to_account
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
class UpdateDeliveryTables < ActiveRecord::Migration[8.0]
|
2
|
+
def up
|
3
|
+
remove_column :bscf_core_delivery_orders, :buyer_phone
|
4
|
+
remove_column :bscf_core_delivery_orders, :seller_phone
|
5
|
+
|
6
|
+
add_column :bscf_core_delivery_orders, :estimated_delivery_price, :float
|
7
|
+
|
8
|
+
rename_column :bscf_core_delivery_orders, :delivery_price, :actual_delivery_price
|
9
|
+
|
10
|
+
add_reference :bscf_core_delivery_order_items, :pickup_address, foreign_key: { to_table: :bscf_core_addresses }
|
11
|
+
add_reference :bscf_core_delivery_order_items, :dropoff_address, foreign_key: { to_table: :bscf_core_addresses }
|
12
|
+
|
13
|
+
remove_reference :bscf_core_delivery_order_items, :product
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,6 @@
|
|
1
|
+
class UpdateAddressCoordinatesType < ActiveRecord::Migration[8.0]
|
2
|
+
def change
|
3
|
+
change_column :bscf_core_addresses, :latitude, :decimal, precision: 10, scale: 6, using: 'latitude::numeric(10,6)'
|
4
|
+
change_column :bscf_core_addresses, :longitude, :decimal, precision: 10, scale: 6, using: 'longitude::numeric(10,6)'
|
5
|
+
end
|
6
|
+
end
|
data/lib/bscf/core/version.rb
CHANGED
@@ -2,7 +2,8 @@ FactoryBot.define do
|
|
2
2
|
factory :delivery_order_item, class: 'Bscf::Core::DeliveryOrderItem' do
|
3
3
|
association :delivery_order
|
4
4
|
association :order_item
|
5
|
-
association :
|
5
|
+
association :pickup_address, factory: :address
|
6
|
+
association :dropoff_address, factory: :address
|
6
7
|
quantity { order_item.quantity }
|
7
8
|
status { :pending }
|
8
9
|
notes { Faker::Lorem.paragraph }
|
@@ -2,38 +2,15 @@ FactoryBot.define do
|
|
2
2
|
factory :delivery_order, class: 'Bscf::Core::DeliveryOrder' do
|
3
3
|
association :pickup_address, factory: :address
|
4
4
|
association :dropoff_address, factory: :address
|
5
|
-
driver
|
5
|
+
association :driver, factory: :user
|
6
6
|
|
7
|
-
buyer_phone { Faker::PhoneNumber.phone_number }
|
8
|
-
seller_phone { Faker::PhoneNumber.phone_number }
|
9
7
|
driver_phone { Faker::PhoneNumber.phone_number }
|
10
8
|
delivery_notes { Faker::Lorem.paragraph }
|
11
9
|
estimated_delivery_time { 2.days.from_now }
|
10
|
+
estimated_delivery_price { Faker::Number.decimal(l_digits: 2, r_digits: 2) }
|
11
|
+
actual_delivery_price { Faker::Number.decimal(l_digits: 2, r_digits: 2) }
|
12
12
|
delivery_start_time { nil }
|
13
13
|
delivery_end_time { nil }
|
14
14
|
status { :pending }
|
15
|
-
|
16
|
-
trait :with_driver do
|
17
|
-
association :driver, factory: :user
|
18
|
-
end
|
19
|
-
|
20
|
-
trait :in_transit do
|
21
|
-
with_driver
|
22
|
-
status { :in_transit }
|
23
|
-
delivery_start_time { Time.current }
|
24
|
-
end
|
25
|
-
|
26
|
-
trait :delivered do
|
27
|
-
with_driver
|
28
|
-
status { :delivered }
|
29
|
-
delivery_start_time { 2.hours.ago }
|
30
|
-
delivery_end_time { Time.current }
|
31
|
-
end
|
32
|
-
|
33
|
-
trait :with_orders do
|
34
|
-
after(:create) do |delivery_order|
|
35
|
-
create_list(:order, 2, delivery_order: delivery_order)
|
36
|
-
end
|
37
|
-
end
|
38
15
|
end
|
39
16
|
end
|
@@ -1,34 +1,49 @@
|
|
1
1
|
FactoryBot.define do
|
2
2
|
factory :virtual_account_transaction, class: 'Bscf::Core::VirtualAccountTransaction' do
|
3
|
-
association :
|
4
|
-
association :to_account, factory: :virtual_account, status: :active, balance: 5000
|
3
|
+
association :account, factory: :virtual_account, status: :active, balance: 10000
|
5
4
|
amount { 100.0 }
|
6
|
-
transaction_type { :
|
5
|
+
transaction_type { :adjustment }
|
6
|
+
entry_type { :debit }
|
7
7
|
status { :pending }
|
8
|
+
value_date { Time.current }
|
8
9
|
description { Faker::Lorem.sentence }
|
10
|
+
running_balance { nil }
|
9
11
|
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
status { :failed }
|
16
|
-
end
|
12
|
+
factory :paired_transaction do
|
13
|
+
transient do
|
14
|
+
paired_account { create(:virtual_account, status: :active, balance: 10000) }
|
15
|
+
is_credit { false }
|
16
|
+
end
|
17
17
|
|
18
|
-
|
19
|
-
|
20
|
-
|
18
|
+
after(:build) do |transaction, evaluator|
|
19
|
+
if transaction.transaction_type == 'transfer'
|
20
|
+
paired_entry_type = transaction.entry_type == 'debit' ? :credit : :debit
|
21
|
+
elsif transaction.transaction_type == 'deposit'
|
22
|
+
paired_entry_type = transaction.entry_type == 'debit' ? :credit : :debit
|
23
|
+
elsif transaction.transaction_type == 'withdrawal'
|
24
|
+
paired_entry_type = transaction.entry_type == 'debit' ? :credit : :debit
|
25
|
+
else
|
26
|
+
paired_entry_type = evaluator.is_credit ? :credit : :debit
|
27
|
+
end
|
21
28
|
|
22
|
-
|
23
|
-
|
24
|
-
|
29
|
+
paired = build(:virtual_account_transaction,
|
30
|
+
transaction_type: transaction.transaction_type,
|
31
|
+
entry_type: paired_entry_type,
|
32
|
+
account: evaluator.paired_account,
|
33
|
+
amount: transaction.amount,
|
34
|
+
reference_number: transaction.reference_number,
|
35
|
+
value_date: transaction.value_date,
|
36
|
+
status: transaction.status
|
37
|
+
)
|
25
38
|
|
26
|
-
|
27
|
-
|
28
|
-
|
39
|
+
transaction.paired_transaction = paired
|
40
|
+
paired.paired_transaction = transaction
|
41
|
+
end
|
29
42
|
|
30
|
-
|
31
|
-
|
43
|
+
after(:create) do |transaction, evaluator|
|
44
|
+
# Save the paired transaction after the main transaction is created
|
45
|
+
transaction.paired_transaction.save! if transaction.paired_transaction&.new_record?
|
46
|
+
end
|
32
47
|
end
|
33
48
|
end
|
34
49
|
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: bscf-core
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.4.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Asrat
|
@@ -310,8 +310,11 @@ files:
|
|
310
310
|
- app/models/bscf/core/virtual_account_transaction.rb
|
311
311
|
- app/models/bscf/core/voucher.rb
|
312
312
|
- app/models/bscf/core/wholesaler_product.rb
|
313
|
+
- app/services/bscf/core/gebeta_maps_service.rb
|
313
314
|
- app/services/bscf/core/token_service.rb
|
315
|
+
- app/services/bscf/core/transaction_service.rb
|
314
316
|
- config/database.yml
|
317
|
+
- config/master.key
|
315
318
|
- config/routes.rb
|
316
319
|
- db/migrate/20250326065606_create_bscf_core_users.rb
|
317
320
|
- db/migrate/20250326075111_create_bscf_core_roles.rb
|
@@ -345,6 +348,10 @@ files:
|
|
345
348
|
- db/migrate/20250524081221_create_bscf_core_vouchers.rb
|
346
349
|
- db/migrate/20250524151346_add_locked_amount_to_virtual_accounts.rb
|
347
350
|
- db/migrate/20250606100158_add_product_to_market_place_listings.rb
|
351
|
+
- db/migrate/20250609180530_add_accounting_fields_to_virtual_account_transactions.rb
|
352
|
+
- db/migrate/20250610120351_update_delivery_tables.rb
|
353
|
+
- db/migrate/20250616134209_update_address_coordinates_type.rb
|
354
|
+
- db/migrate/20250616145522_add_position_to_delivery_order_items.rb
|
348
355
|
- lib/bscf/core.rb
|
349
356
|
- lib/bscf/core/engine.rb
|
350
357
|
- lib/bscf/core/version.rb
|