dscf-banking 0.1.14 → 0.1.16

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: a4ca1ade5bfdb0a8bf2472275df55b6b4209c0306bcc747a8ca065637b159177
4
- data.tar.gz: 2d5d05152332e6d287dbbff3b1ec9ea255714015d7d59695f67f28986ee4cddc
3
+ metadata.gz: 3be6cb234512da6ec3f02e719d9f3a7315c29711a1510541c4f6431bb7c2934a
4
+ data.tar.gz: 4e1d771356543e224441f1c68b4e8d343c755be3dbc72299e79cec4871e4fabd
5
5
  SHA512:
6
- metadata.gz: b5e05d2e67811e0fb3edc22c2652cf1b0febfd34e3a9bde6c9a51facae604b90df1d14c141bc4a770a169d6b1d6e6fb2fbadbfc8a3a080bdc0ab5f61bf7077fe
7
- data.tar.gz: 05fe6fd1c9d99252eba049c1eda43510d9d3dff737833945cf79c20ba2aaf0daa0e7839ceed42a1226db91a4748f3a2a2dbeaa55c86154589bfc76439eccf918
6
+ metadata.gz: 7b45b884c53a7fa29feb90a0d33aa584b48b961560504eaa97a2802446e32d8508169e180557947195b34fb5d02ee25fe728ae1d881180d45f13238b230ec305
7
+ data.tar.gz: 2f0828d64d4dee6222e155e97dfeae0f1bcfb66f9162ae7566baf34b82cd5c603821def2de710633662206993cf6055817c766a5a9706a595a069e69e16465a8
@@ -0,0 +1,27 @@
1
+ module Dscf::Banking
2
+ class TransactionTypesController < ApplicationController
3
+ include Dscf::Core::Common
4
+
5
+ private
6
+
7
+ def model_params
8
+ params.require(:payload).permit(
9
+ :code,
10
+ :name,
11
+ :description
12
+ )
13
+ end
14
+
15
+ def eager_loaded_associations
16
+ []
17
+ end
18
+
19
+ def allowed_order_columns
20
+ %w[id code name created_at updated_at]
21
+ end
22
+
23
+ def default_serializer_includes
24
+ {}
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,275 @@
1
+ module Dscf::Banking
2
+ class TransactionsController < ApplicationController
3
+ include Dscf::Core::Common
4
+
5
+ # Transfer money between accounts (from test cases)
6
+ def transfer
7
+ debit_account = Dscf::Banking::Account.find_by(account_number: transfer_params[:debit_account_number])
8
+ credit_account = Dscf::Banking::Account.find_by(account_number: transfer_params[:credit_account_number])
9
+
10
+ unless debit_account
11
+ return render json: {
12
+ success: false,
13
+ error: "Debit account not found",
14
+ errors: [ "Account with number #{transfer_params[:debit_account_number]} not found" ]
15
+ }, status: :not_found
16
+ end
17
+
18
+ unless credit_account
19
+ return render json: {
20
+ success: false,
21
+ error: "Credit account not found",
22
+ errors: [ "Account with number #{transfer_params[:credit_account_number]} not found" ]
23
+ }, status: :not_found
24
+ end
25
+
26
+ result = Dscf::Banking::TransferService.new(
27
+ debit_account: debit_account,
28
+ credit_account: credit_account,
29
+ amount: transfer_params[:amount],
30
+ description: transfer_params[:description] || "Transfer between accounts",
31
+ transaction_type_code: transfer_params[:transaction_type_code] || "TRANSFER"
32
+ ).execute
33
+
34
+ if result.success?
35
+ render json: {
36
+ success: true,
37
+ data: transaction_data(result.transaction),
38
+ message: "Transfer completed successfully"
39
+ }
40
+ else
41
+ render json: {
42
+ success: false,
43
+ error: "Transfer failed",
44
+ errors: result.errors
45
+ }, status: :unprocessable_entity
46
+ end
47
+ rescue StandardError => e
48
+ render json: {
49
+ success: false,
50
+ error: "Transfer failed",
51
+ errors: [ e.message ]
52
+ }, status: :internal_server_error
53
+ end
54
+
55
+ # Deposit money to account (from mobile banking - Abole)
56
+ def deposit
57
+ account = Dscf::Banking::Account.find_by(account_number: deposit_params[:account_number])
58
+
59
+ unless account
60
+ return render json: {
61
+ success: false,
62
+ error: "Account not found",
63
+ errors: [ "Account with number #{deposit_params[:account_number]} not found" ]
64
+ }, status: :not_found
65
+ end
66
+
67
+ result = Dscf::Banking::DepositService.new(
68
+ account: account,
69
+ amount: deposit_params[:amount],
70
+ description: deposit_params[:description] || "Deposit to account",
71
+ transaction_type_code: deposit_params[:transaction_type_code] || "DEPOSIT"
72
+ ).execute
73
+
74
+ if result.success?
75
+ render json: {
76
+ success: true,
77
+ data: transaction_data(result.transaction),
78
+ message: "Deposit completed successfully"
79
+ }
80
+ else
81
+ render json: {
82
+ success: false,
83
+ error: "Deposit failed",
84
+ errors: result.errors
85
+ }, status: :unprocessable_entity
86
+ end
87
+ rescue StandardError => e
88
+ render json: {
89
+ success: false,
90
+ error: "Deposit failed",
91
+ errors: [ e.message ]
92
+ }, status: :internal_server_error
93
+ end
94
+
95
+ # Withdraw money from account
96
+ def withdrawal
97
+ account = Dscf::Banking::Account.find_by(account_number: withdrawal_params[:account_number])
98
+
99
+ unless account
100
+ return render json: {
101
+ success: false,
102
+ error: "Account not found",
103
+ errors: [ "Account with number #{withdrawal_params[:account_number]} not found" ]
104
+ }, status: :not_found
105
+ end
106
+
107
+ result = Dscf::Banking::WithdrawalService.new(
108
+ account: account,
109
+ amount: withdrawal_params[:amount],
110
+ description: withdrawal_params[:description] || "Withdrawal from account",
111
+ transaction_type_code: withdrawal_params[:transaction_type_code] || "WITHDRAWAL"
112
+ ).execute
113
+
114
+ if result.success?
115
+ render json: {
116
+ success: true,
117
+ data: transaction_data(result.transaction),
118
+ message: "Withdrawal completed successfully"
119
+ }
120
+ else
121
+ render json: {
122
+ success: false,
123
+ error: "Withdrawal failed",
124
+ errors: result.errors
125
+ }, status: :unprocessable_entity
126
+ end
127
+ rescue StandardError => e
128
+ render json: {
129
+ success: false,
130
+ error: "Withdrawal failed",
131
+ errors: [ e.message ]
132
+ }, status: :internal_server_error
133
+ end
134
+
135
+ # Cancel a transaction
136
+ def cancel
137
+ transaction = Dscf::Banking::Transaction.find(params[:id])
138
+
139
+ unless transaction.status == "pending"
140
+ return render json: {
141
+ success: false,
142
+ error: "Cannot cancel transaction",
143
+ errors: [ "Only pending transactions can be cancelled" ]
144
+ }, status: :unprocessable_entity
145
+ end
146
+
147
+ if transaction.update(status: :cancelled)
148
+ render json: {
149
+ success: true,
150
+ data: transaction_data(transaction),
151
+ message: "Transaction cancelled successfully"
152
+ }
153
+ else
154
+ render json: {
155
+ success: false,
156
+ error: "Failed to cancel transaction",
157
+ errors: transaction.errors.full_messages
158
+ }, status: :unprocessable_entity
159
+ end
160
+ rescue ActiveRecord::RecordNotFound
161
+ render json: {
162
+ success: false,
163
+ error: "Transaction not found"
164
+ }, status: :not_found
165
+ end
166
+
167
+ # Get transaction details with related account information
168
+ def details
169
+ transaction = Dscf::Banking::Transaction.includes(:account, :debit_account, :credit_account, :transaction_type).find(params[:id])
170
+
171
+ render json: {
172
+ success: true,
173
+ data: transaction_data_with_details(transaction)
174
+ }
175
+ rescue ActiveRecord::RecordNotFound
176
+ render json: {
177
+ success: false,
178
+ error: "Transaction not found"
179
+ }, status: :not_found
180
+ end
181
+
182
+ private
183
+
184
+ def transfer_params
185
+ params.require(:transfer).permit(:debit_account_number, :credit_account_number, :amount, :description, :transaction_type_code)
186
+ end
187
+
188
+ def deposit_params
189
+ params.require(:deposit).permit(:account_number, :amount, :description, :transaction_type_code)
190
+ end
191
+
192
+ def withdrawal_params
193
+ params.require(:withdrawal).permit(:account_number, :amount, :description, :transaction_type_code)
194
+ end
195
+
196
+ def transaction_data(transaction)
197
+ {
198
+ id: transaction.id,
199
+ reference_number: transaction.reference_number,
200
+ amount: transaction.amount.to_f,
201
+ currency: transaction.currency,
202
+ description: transaction.description,
203
+ status: transaction.status,
204
+ account_id: transaction.account_id,
205
+ debit_account_id: transaction.debit_account_id,
206
+ credit_account_id: transaction.credit_account_id,
207
+ transaction_type_id: transaction.transaction_type_id,
208
+ created_at: transaction.created_at,
209
+ updated_at: transaction.updated_at
210
+ }
211
+ end
212
+
213
+ def transaction_data_with_details(transaction)
214
+ {
215
+ id: transaction.id,
216
+ reference_number: transaction.reference_number,
217
+ amount: transaction.amount.to_f,
218
+ currency: transaction.currency,
219
+ description: transaction.description,
220
+ status: transaction.status,
221
+ account: account_summary(transaction.account),
222
+ debit_account: account_summary(transaction.debit_account),
223
+ credit_account: account_summary(transaction.credit_account),
224
+ transaction_type: {
225
+ id: transaction.transaction_type.id,
226
+ code: transaction.transaction_type.code,
227
+ name: transaction.transaction_type.name
228
+ },
229
+ created_at: transaction.created_at,
230
+ updated_at: transaction.updated_at
231
+ }
232
+ end
233
+
234
+ def account_summary(account)
235
+ return nil unless account
236
+ {
237
+ id: account.id,
238
+ account_number: account.account_number,
239
+ name: account.name,
240
+ currency: account.currency,
241
+ current_balance: account.current_balance
242
+ }
243
+ end
244
+
245
+ def model_params
246
+ params.require(:payload).permit(
247
+ :account_id,
248
+ :transaction_type_id,
249
+ :debit_account_id,
250
+ :credit_account_id,
251
+ :amount,
252
+ :currency,
253
+ :description,
254
+ :status
255
+ )
256
+ end
257
+
258
+ def eager_loaded_associations
259
+ [ :account, :transaction_type, :debit_account, :credit_account ]
260
+ end
261
+
262
+ def allowed_order_columns
263
+ %w[id reference_number amount currency status created_at updated_at]
264
+ end
265
+
266
+ def default_serializer_includes
267
+ {
268
+ account: {},
269
+ transaction_type: {},
270
+ debit_account: {},
271
+ credit_account: {}
272
+ }
273
+ end
274
+ end
275
+ end
@@ -1,7 +1,7 @@
1
1
  module Dscf::Banking
2
2
  class Account < ApplicationRecord
3
- belongs_to :virtual_account_product
4
- belongs_to :application
3
+ belongs_to :virtual_account_product, optional: true
4
+ belongs_to :application, optional: true
5
5
 
6
6
  enum :status, {
7
7
  draft: 0,
@@ -17,12 +17,16 @@ module Dscf::Banking
17
17
  validates :currency, presence: true
18
18
  validates :current_balance, :available_balance, :minimum_balance,
19
19
  numericality: { greater_than_or_equal_to: 0 }
20
+ validates :system_account, inclusion: { in: [ true, false ] }
21
+ validate :system_account_associations
20
22
 
21
23
  before_validation :generate_account_number, on: :create
22
24
  before_validation :set_defaults, on: :create
23
25
 
24
26
  scope :active_accounts, -> { where(active: true, status: :active) }
25
27
  scope :by_currency, ->(currency) { where(currency: currency) }
28
+ scope :customer_accounts, -> { where(system_account: false) }
29
+ scope :system_accounts, -> { where(system_account: true) }
26
30
 
27
31
  def sufficient_funds_for_withdrawal?(amount)
28
32
  available_balance - amount >= minimum_balance
@@ -84,5 +88,23 @@ module Dscf::Banking
84
88
  self.minimum_balance ||= 0
85
89
  self.name ||= "Account Holder"
86
90
  end
91
+
92
+ def system_account_associations
93
+ if system_account?
94
+ if virtual_account_product.present?
95
+ errors.add(:virtual_account_product, "must be blank for system accounts")
96
+ end
97
+ if application.present?
98
+ errors.add(:application, "must be blank for system accounts")
99
+ end
100
+ else
101
+ unless virtual_account_product.present?
102
+ errors.add(:virtual_account_product, "must be present for customer accounts")
103
+ end
104
+ unless application.present?
105
+ errors.add(:application, "must be present for customer accounts")
106
+ end
107
+ end
108
+ end
87
109
  end
88
110
  end
@@ -0,0 +1,68 @@
1
+ module Dscf::Banking
2
+ class Transaction < ApplicationRecord
3
+ belongs_to :account, class_name: "Dscf::Banking::Account"
4
+ belongs_to :transaction_type, class_name: "Dscf::Banking::TransactionType"
5
+ belongs_to :debit_account, class_name: "Dscf::Banking::Account"
6
+ belongs_to :credit_account, class_name: "Dscf::Banking::Account"
7
+
8
+ enum :status, {
9
+ pending: 0,
10
+ processing: 1,
11
+ completed: 2,
12
+ failed: 3,
13
+ cancelled: 4
14
+ }
15
+
16
+ validates :amount, presence: true, numericality: { greater_than: 0 }
17
+ validates :currency, presence: true
18
+ validates :reference_number, presence: true, uniqueness: true
19
+
20
+ validate :different_debit_credit_accounts
21
+ validate :account_currency_consistency
22
+
23
+ before_validation :set_defaults, on: :create
24
+ before_validation :generate_reference_number, if: -> { reference_number.nil? }
25
+
26
+ scope :by_account, ->(account_id) { where(account_id: account_id) }
27
+ scope :by_type, ->(type_code) { joins(:transaction_type).where(dscf_banking_transaction_types: { code: type_code.upcase }) }
28
+ scope :by_status, ->(status) { where(status: status) }
29
+ scope :recent, -> { order(created_at: :desc) }
30
+
31
+ def settlement_transaction?
32
+ debit_account.system_account? || credit_account.system_account?
33
+ end
34
+
35
+ def customer_transaction?
36
+ !settlement_transaction?
37
+ end
38
+
39
+ private
40
+
41
+ def different_debit_credit_accounts
42
+ return unless debit_account_id && credit_account_id
43
+
44
+ if debit_account_id == credit_account_id
45
+ errors.add(:credit_account, "must be different from debit account")
46
+ end
47
+ end
48
+
49
+ def account_currency_consistency
50
+ return unless account && currency
51
+
52
+ if account.currency != currency
53
+ errors.add(:currency, "must match account currency")
54
+ end
55
+ end
56
+
57
+ def set_defaults
58
+ self.currency ||= account&.currency || "ETB"
59
+ self.status ||= :pending
60
+ end
61
+
62
+ def generate_reference_number
63
+ timestamp = Time.current.strftime("%Y%m%d%H%M%S")
64
+ random_suffix = SecureRandom.hex(4).upcase
65
+ self.reference_number = "TXN#{timestamp}#{random_suffix}"
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,20 @@
1
+ module Dscf::Banking
2
+ class TransactionType < ApplicationRecord
3
+ has_many :transactions, class_name: "Dscf::Banking::Transaction", dependent: :restrict_with_error
4
+
5
+ validates :code, presence: true, uniqueness: { case_sensitive: false }
6
+ validates :name, presence: true
7
+
8
+ before_save :upcase_code
9
+
10
+ def self.find_by_code(code)
11
+ find_by(code: code.to_s.upcase)
12
+ end
13
+
14
+ private
15
+
16
+ def upcase_code
17
+ self.code = code.upcase if code.present?
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,19 @@
1
+ module Dscf::Banking
2
+ class TransactionSerializer < ActiveModel::Serializer
3
+ attributes :id, :reference_number, :amount, :currency, :description,
4
+ :status, :created_at, :updated_at
5
+
6
+ belongs_to :account
7
+ belongs_to :transaction_type
8
+ belongs_to :debit_account, serializer: Dscf::Banking::AccountSerializer
9
+ belongs_to :credit_account, serializer: Dscf::Banking::AccountSerializer
10
+
11
+ def status
12
+ object.status.humanize
13
+ end
14
+
15
+ def amount
16
+ object.amount.to_f
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,9 @@
1
+ module Dscf::Banking
2
+ class TransactionTypeSerializer < ActiveModel::Serializer
3
+ attributes :id, :code, :name, :description, :created_at, :updated_at
4
+
5
+ def code
6
+ object.code.upcase
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,99 @@
1
+ module Dscf::Banking
2
+ class BaseTransactionService
3
+ attr_reader :errors
4
+
5
+ def initialize
6
+ @errors = []
7
+ end
8
+
9
+ def success?
10
+ errors.empty?
11
+ end
12
+
13
+ def failure?
14
+ !success?
15
+ end
16
+
17
+ private
18
+
19
+ def add_error(message)
20
+ @errors << message
21
+ end
22
+
23
+ def validate_account_active(account, account_type = "Account")
24
+ unless account.active? && account.status == "active"
25
+ add_error("#{account_type} is not active")
26
+ return false
27
+ end
28
+ true
29
+ end
30
+
31
+ def validate_sufficient_funds(account, amount)
32
+ unless account.sufficient_funds_for_withdrawal?(amount)
33
+ add_error("Insufficient funds. Available balance: #{account.available_balance}, Required: #{amount}")
34
+ return false
35
+ end
36
+ true
37
+ end
38
+
39
+ def find_or_create_transaction_type(code)
40
+ transaction_type = Dscf::Banking::TransactionType.find_by_code(code)
41
+ unless transaction_type
42
+ add_error("Transaction type '#{code}' not found")
43
+ return nil
44
+ end
45
+ transaction_type
46
+ end
47
+
48
+ def create_transaction(params)
49
+ transaction = Dscf::Banking::Transaction.new(params)
50
+ unless transaction.save
51
+ transaction.errors.full_messages.each { |msg| add_error(msg) }
52
+ return nil
53
+ end
54
+ transaction
55
+ end
56
+
57
+ def update_account_balances(debit_account, credit_account, amount)
58
+ ActiveRecord::Base.transaction do
59
+ # Update debit account (decrease balance)
60
+ new_debit_balance = debit_account.current_balance - amount
61
+ new_debit_available = debit_account.available_balance - amount
62
+
63
+ unless debit_account.update(
64
+ current_balance: new_debit_balance,
65
+ available_balance: new_debit_available
66
+ )
67
+ debit_account.errors.full_messages.each { |msg| add_error("Debit account: #{msg}") }
68
+ raise ActiveRecord::Rollback
69
+ end
70
+
71
+ # Update credit account (increase balance)
72
+ new_credit_balance = credit_account.current_balance + amount
73
+ new_credit_available = credit_account.available_balance + amount
74
+
75
+ unless credit_account.update(
76
+ current_balance: new_credit_balance,
77
+ available_balance: new_credit_available
78
+ )
79
+ credit_account.errors.full_messages.each { |msg| add_error("Credit account: #{msg}") }
80
+ raise ActiveRecord::Rollback
81
+ end
82
+ end
83
+
84
+ success?
85
+ end
86
+
87
+ def process_transaction(transaction)
88
+ transaction.update(status: :processing)
89
+
90
+ if update_account_balances(transaction.debit_account, transaction.credit_account, transaction.amount)
91
+ transaction.update(status: :completed)
92
+ true
93
+ else
94
+ transaction.update(status: :failed)
95
+ false
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,97 @@
1
+ module Dscf::Banking
2
+ class DepositService < BaseTransactionService
3
+ attr_reader :transaction
4
+
5
+ def initialize(account:, amount:, description:, transaction_type_code: "DEPOSIT")
6
+ super()
7
+ @account = account
8
+ @amount = amount.to_f
9
+ @description = description
10
+ @transaction_type_code = transaction_type_code
11
+ @transaction = nil
12
+ end
13
+
14
+ def execute
15
+ return self unless validate_deposit
16
+
17
+ ActiveRecord::Base.transaction do
18
+ create_deposit_transaction
19
+ return self unless success?
20
+
21
+ process_deposit
22
+ return self unless success?
23
+ end
24
+
25
+ self
26
+ end
27
+
28
+ private
29
+
30
+ def validate_deposit
31
+ # Validate account
32
+ return false unless validate_account_active(@account, "Target account")
33
+
34
+ # Validate amount
35
+ if @amount <= 0
36
+ add_error("Deposit amount must be greater than zero")
37
+ return false
38
+ end
39
+
40
+ true
41
+ end
42
+
43
+ def create_deposit_transaction
44
+ transaction_type = find_or_create_transaction_type(@transaction_type_code)
45
+ return unless transaction_type
46
+
47
+ # For deposits, we need a system account as the debit account
48
+ # This represents the external source (like mobile banking - Abole)
49
+ system_account = find_or_create_system_account
50
+
51
+ @transaction = create_transaction(
52
+ account: @account, # Primary account for the transaction
53
+ transaction_type: transaction_type,
54
+ debit_account: system_account, # External source
55
+ credit_account: @account, # Customer account receiving the deposit
56
+ amount: @amount,
57
+ currency: @account.currency,
58
+ description: @description,
59
+ status: :pending
60
+ )
61
+ end
62
+
63
+ def process_deposit
64
+ @transaction.update(status: :processing)
65
+
66
+ # Update account balance (increase for deposit)
67
+ new_balance = @account.current_balance + @amount
68
+ new_available = @account.available_balance + @amount
69
+
70
+ if @account.update(current_balance: new_balance, available_balance: new_available)
71
+ @transaction.update(status: :completed)
72
+ true
73
+ else
74
+ @account.errors.full_messages.each { |msg| add_error(msg) }
75
+ @transaction.update(status: :failed)
76
+ false
77
+ end
78
+ end
79
+
80
+ def find_or_create_system_account
81
+ # Find or create a system account for external deposits
82
+ system_account = Dscf::Banking::Account.system_accounts
83
+ .where(currency: @account.currency)
84
+ .where("name LIKE ?", "%Deposit%")
85
+ .first
86
+
87
+ unless system_account
88
+ # In a real system, this would be pre-created during setup
89
+ # For now, we'll assume it exists or create a placeholder
90
+ add_error("System deposit account not found for currency #{@account.currency}")
91
+ return nil
92
+ end
93
+
94
+ system_account
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,78 @@
1
+ module Dscf::Banking
2
+ class TransferService < BaseTransactionService
3
+ attr_reader :transaction
4
+
5
+ def initialize(debit_account:, credit_account:, amount:, description:, transaction_type_code: "TRANSFER")
6
+ super()
7
+ @debit_account = debit_account
8
+ @credit_account = credit_account
9
+ @amount = amount.to_f
10
+ @description = description
11
+ @transaction_type_code = transaction_type_code
12
+ @transaction = nil
13
+ end
14
+
15
+ def execute
16
+ return self unless validate_transfer
17
+
18
+ ActiveRecord::Base.transaction do
19
+ create_transfer_transaction
20
+ return self unless success?
21
+
22
+ process_transaction(@transaction)
23
+ return self unless success?
24
+ end
25
+
26
+ self
27
+ end
28
+
29
+ private
30
+
31
+ def validate_transfer
32
+ # Validate debit account
33
+ return false unless validate_account_active(@debit_account, "Debit account")
34
+
35
+ # Validate credit account
36
+ return false unless validate_account_active(@credit_account, "Credit account")
37
+
38
+ # Validate amount
39
+ if @amount <= 0
40
+ add_error("Transfer amount must be greater than zero")
41
+ return false
42
+ end
43
+
44
+ # Validate sufficient funds
45
+ return false unless validate_sufficient_funds(@debit_account, @amount)
46
+
47
+ # Validate different accounts
48
+ if @debit_account.id == @credit_account.id
49
+ add_error("Cannot transfer to the same account")
50
+ return false
51
+ end
52
+
53
+ # Validate currency match
54
+ if @debit_account.currency != @credit_account.currency
55
+ add_error("Currency mismatch between accounts")
56
+ return false
57
+ end
58
+
59
+ true
60
+ end
61
+
62
+ def create_transfer_transaction
63
+ transaction_type = find_or_create_transaction_type(@transaction_type_code)
64
+ return unless transaction_type
65
+
66
+ @transaction = create_transaction(
67
+ account: @debit_account, # Primary account for the transaction
68
+ transaction_type: transaction_type,
69
+ debit_account: @debit_account,
70
+ credit_account: @credit_account,
71
+ amount: @amount,
72
+ currency: @debit_account.currency,
73
+ description: @description,
74
+ status: :pending
75
+ )
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,100 @@
1
+ module Dscf::Banking
2
+ class WithdrawalService < BaseTransactionService
3
+ attr_reader :transaction
4
+
5
+ def initialize(account:, amount:, description:, transaction_type_code: "WITHDRAWAL")
6
+ super()
7
+ @account = account
8
+ @amount = amount.to_f
9
+ @description = description
10
+ @transaction_type_code = transaction_type_code
11
+ @transaction = nil
12
+ end
13
+
14
+ def execute
15
+ return self unless validate_withdrawal
16
+
17
+ ActiveRecord::Base.transaction do
18
+ create_withdrawal_transaction
19
+ return self unless success?
20
+
21
+ process_withdrawal
22
+ return self unless success?
23
+ end
24
+
25
+ self
26
+ end
27
+
28
+ private
29
+
30
+ def validate_withdrawal
31
+ # Validate account
32
+ return false unless validate_account_active(@account, "Source account")
33
+
34
+ # Validate amount
35
+ if @amount <= 0
36
+ add_error("Withdrawal amount must be greater than zero")
37
+ return false
38
+ end
39
+
40
+ # Validate sufficient funds
41
+ return false unless validate_sufficient_funds(@account, @amount)
42
+
43
+ true
44
+ end
45
+
46
+ def create_withdrawal_transaction
47
+ transaction_type = find_or_create_transaction_type(@transaction_type_code)
48
+ return unless transaction_type
49
+
50
+ # For withdrawals, we need a system account as the credit account
51
+ # This represents the external destination
52
+ system_account = find_or_create_system_account
53
+
54
+ @transaction = create_transaction(
55
+ account: @account, # Primary account for the transaction
56
+ transaction_type: transaction_type,
57
+ debit_account: @account, # Customer account being debited
58
+ credit_account: system_account, # External destination
59
+ amount: @amount,
60
+ currency: @account.currency,
61
+ description: @description,
62
+ status: :pending
63
+ )
64
+ end
65
+
66
+ def process_withdrawal
67
+ @transaction.update(status: :processing)
68
+
69
+ # Update account balance (decrease for withdrawal)
70
+ new_balance = @account.current_balance - @amount
71
+ new_available = @account.available_balance - @amount
72
+
73
+ if @account.update(current_balance: new_balance, available_balance: new_available)
74
+ @transaction.update(status: :completed)
75
+ true
76
+ else
77
+ @account.errors.full_messages.each { |msg| add_error(msg) }
78
+ @transaction.update(status: :failed)
79
+ false
80
+ end
81
+ end
82
+
83
+ def find_or_create_system_account
84
+ # Find or create a system account for external withdrawals
85
+ system_account = Dscf::Banking::Account.system_accounts
86
+ .where(currency: @account.currency)
87
+ .where("name LIKE ?", "%Withdrawal%")
88
+ .first
89
+
90
+ unless system_account
91
+ # In a real system, this would be pre-created during setup
92
+ # For now, we'll assume it exists or create a placeholder
93
+ add_error("System withdrawal account not found for currency #{@account.currency}")
94
+ return nil
95
+ end
96
+
97
+ system_account
98
+ end
99
+ end
100
+ end
data/config/routes.rb CHANGED
@@ -22,4 +22,18 @@ Dscf::Banking::Engine.routes.draw do
22
22
  post :reject
23
23
  end
24
24
  end
25
+
26
+ resources :transaction_types
27
+
28
+ resources :transactions do
29
+ collection do
30
+ post :transfer
31
+ post :deposit
32
+ post :withdrawal
33
+ end
34
+ member do
35
+ post :cancel
36
+ get :details
37
+ end
38
+ end
25
39
  end
@@ -0,0 +1,6 @@
1
+ class AddAccountTypeToAccounts < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :dscf_banking_accounts, :system_account, :boolean, default: false, null: false
4
+ add_index :dscf_banking_accounts, :system_account
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ class MakeAccountAssociationsOptional < ActiveRecord::Migration[8.0]
2
+ def change
3
+ change_column_null :dscf_banking_accounts, :virtual_account_product_id, true
4
+ change_column_null :dscf_banking_accounts, :application_id, true
5
+ end
6
+ end
@@ -0,0 +1,14 @@
1
+ class CreateDscfBankingTransactionTypes < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :dscf_banking_transaction_types do |t|
4
+ t.string :code, null: false, limit: 20
5
+ t.string :name, null: false, limit: 100
6
+ t.text :description
7
+
8
+ t.timestamps
9
+ end
10
+
11
+ add_index :dscf_banking_transaction_types, :code, unique: true
12
+ add_index :dscf_banking_transaction_types, :name, unique: true
13
+ end
14
+ end
@@ -0,0 +1,21 @@
1
+ class CreateDscfBankingTransactions < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :dscf_banking_transactions do |t|
4
+ t.references :account, null: false, foreign_key: { to_table: :dscf_banking_accounts }
5
+ t.references :transaction_type, null: false, foreign_key: { to_table: :dscf_banking_transaction_types }
6
+ t.decimal :amount, precision: 20, scale: 4, null: false
7
+ t.string :currency, null: false, default: "ETB"
8
+ t.string :reference_number, null: false
9
+ t.text :description
10
+ t.integer :status, default: 0, null: false
11
+ t.references :debit_account, null: false, foreign_key: { to_table: :dscf_banking_accounts }
12
+ t.references :credit_account, null: false, foreign_key: { to_table: :dscf_banking_accounts }
13
+
14
+ t.timestamps
15
+ end
16
+
17
+ add_index :dscf_banking_transactions, :status
18
+ add_index :dscf_banking_transactions, :reference_number, unique: true
19
+ add_index :dscf_banking_transactions, [ :account_id, :created_at ]
20
+ end
21
+ end
data/db/seeds.rb ADDED
@@ -0,0 +1,125 @@
1
+ # db/seeds.rb
2
+
3
+ # DEBUG: Confirm seed file execution and model availability
4
+ puts "Seed file started"
5
+ puts "Dscf::Core::User loaded? => #{defined?(Dscf::Core::User)}"
6
+ puts "Dscf::Core::Role loaded? => #{defined?(Dscf::Core::Role)}"
7
+
8
+ # DSCF Banking Engine Seed Data
9
+ # This file seeds the database with sample data for all models in the correct dependency order
10
+
11
+ puts "Starting DSCF Banking Engine seed data..."
12
+
13
+
14
+ # 1. Seed roles (if model exists)
15
+ puts "Seeding roles..."
16
+ if defined?(Dscf::Core::Role)
17
+ user_role = Dscf::Core::Role.find_or_create_by(code: 'user') do |role|
18
+ role.name = 'User'
19
+ end
20
+ officer_role = Dscf::Core::Role.find_or_create_by(code: 'virtual_account_officer') do |role|
21
+ role.name = 'Virtual Account Officer'
22
+ end
23
+ manager_role = Dscf::Core::Role.find_or_create_by(code: 'virtual_account_manager') do |role|
24
+ role.name = 'Virtual Account Manager'
25
+ end
26
+ kyc_officer_role = Dscf::Core::Role.find_or_create_by(code: 'kyc_officer') do |role|
27
+ role.name = 'KYC Officer'
28
+ end
29
+ branch_manager_role = Dscf::Core::Role.find_or_create_by(code: 'branch_manager') do |role|
30
+ role.name = 'Branch Manager'
31
+ end
32
+ puts "✓ Roles created/found"
33
+ else
34
+ puts "Warning: Dscf::Core::Role model not found. Skipping role seeding."
35
+ end
36
+ # 2. Seed users with roles (if model exists)
37
+ puts "Seeding users..."
38
+ if defined?(Dscf::Core::User)
39
+ user1 = Dscf::Core::User.find_or_create_by(email: "user@banking.com") do |u|
40
+ u.phone = "+251911123456"
41
+ u.password = "SecurePassword123!"
42
+ u.verified_at = Time.current
43
+ u.temp_password = false
44
+ end
45
+ user1.roles << user_role if defined?(user_role)
46
+ puts "Created user: #{user1.email} (user@banking.com)"
47
+
48
+ user2 = Dscf::Core::User.find_or_create_by(email: "virtual_account_officer@banking.com") do |u|
49
+ u.phone = "+251922123456"
50
+ u.password = "SecurePassword123!"
51
+ u.verified_at = Time.current
52
+ u.temp_password = false
53
+ end
54
+ user2.roles << officer_role if defined?(officer_role)
55
+ puts "Created user: #{user2.email} (virtual_account_officer@banking.com)"
56
+
57
+ user3 = Dscf::Core::User.find_or_create_by(email: "virtual_account_manager@banking.com") do |u|
58
+ u.phone = "+251933123456"
59
+ u.password = "SecurePassword123!"
60
+ u.verified_at = Time.current
61
+ u.temp_password = false
62
+ end
63
+ user3.roles << manager_role if defined?(manager_role)
64
+ puts "Created user: #{user3.email} (virtual_account_manager@banking.com)"
65
+
66
+ user4 = Dscf::Core::User.find_or_create_by(email: "user_virtual_account_officer@banking.com") do |u|
67
+ u.phone = "+251944123456"
68
+ u.password = "SecurePassword123!"
69
+ u.verified_at = Time.current
70
+ u.temp_password = false
71
+ end
72
+ user4.roles << user_role if defined?(user_role)
73
+ user4.roles << officer_role if defined?(officer_role)
74
+ puts "Created user: #{user4.email} (user_virtual_account_officer@banking.com)"
75
+
76
+ user5 = Dscf::Core::User.find_or_create_by(email: "virtual_account_manager2@banking.com") do |u|
77
+ u.phone = "+251955123456"
78
+ u.password = "SecurePassword123!"
79
+ u.verified_at = Time.current
80
+ u.temp_password = false
81
+ end
82
+ user5.roles << manager_role if defined?(manager_role)
83
+ puts "Created user: #{user5.email} (virtual_account_manager2@banking.com)"
84
+
85
+ user6 = Dscf::Core::User.find_or_create_by(email: "kyc_officer@banking.com") do |u|
86
+ u.phone = "+251966123456"
87
+ u.password = "SecurePassword123!"
88
+ u.verified_at = Time.current
89
+ u.temp_password = false
90
+ end
91
+ user6.roles << kyc_officer_role if defined?(kyc_officer_role)
92
+ puts "Created user: #{user6.email} (kyc_officer@banking.com)"
93
+
94
+ user7 = Dscf::Core::User.find_or_create_by(email: "branch_manager@banking.com") do |u|
95
+ u.phone = "+251977123456"
96
+ u.password = "SecurePassword123!"
97
+ u.verified_at = Time.current
98
+ u.temp_password = false
99
+ end
100
+ user7.roles << branch_manager_role if defined?(branch_manager_role)
101
+ puts "Created user: #{user7.email} (branch_manager@banking.com)"
102
+ else
103
+ puts "Warning: Dscf::Core::User model not found. Skipping user seeding."
104
+ end# 3. Seed transaction types (engine model)
105
+ puts "Seeding transaction types..."
106
+ if defined?(Dscf::Banking::TransactionType)
107
+ transaction_types = [
108
+ { name: "Deposit", code: "DEP" },
109
+ { name: "Withdrawal", code: "WDL" },
110
+ { name: "Transfer", code: "TRF" },
111
+ { name: "Interest Credit", code: "INTC" },
112
+ { name: "Fee Debit", code: "FEED" }
113
+ ]
114
+
115
+ transaction_types.each do |attrs|
116
+ Dscf::Banking::TransactionType.find_or_create_by(code: attrs[:code]) do |tt|
117
+ tt.name = attrs[:name]
118
+ end
119
+ end
120
+ puts "✓ Transaction types created/found"
121
+ else
122
+ puts "Warning: Dscf::Banking::TransactionType not found. Skipping transaction type seeding."
123
+ end
124
+
125
+ puts "DSCF Banking Engine seed data completed successfully!"
@@ -1,5 +1,5 @@
1
1
  module Dscf
2
2
  module Banking
3
- VERSION = "0.1.14"
3
+ VERSION = "0.1.16"
4
4
  end
5
5
  end
@@ -10,6 +10,7 @@ FactoryBot.define do
10
10
  minimum_balance { 0 }
11
11
  status { :draft }
12
12
  active { true }
13
+ system_account { false }
13
14
  account_properties { {} }
14
15
 
15
16
  trait :active do
@@ -32,6 +33,33 @@ FactoryBot.define do
32
33
  available_balance { 1000 }
33
34
  minimum_balance { 100 }
34
35
  end
36
+
37
+ trait :system_account do
38
+ system_account { true }
39
+ virtual_account_product { nil }
40
+ application { nil }
41
+ end
42
+
43
+ trait :settlement_account do
44
+ system_account { true }
45
+ name { "Core Banking Settlement Account" }
46
+ virtual_account_product { nil }
47
+ application { nil }
48
+ end
49
+
50
+ trait :interest_expense_account do
51
+ system_account { true }
52
+ name { "Interest Expense Account" }
53
+ virtual_account_product { nil }
54
+ application { nil }
55
+ end
56
+
57
+ trait :tax_liability_account do
58
+ system_account { true }
59
+ name { "Interest Tax Payable Account" }
60
+ virtual_account_product { nil }
61
+ application { nil }
62
+ end
35
63
  end
36
64
 
37
65
  # Alias for shared spec compatibility
@@ -4,6 +4,7 @@ FactoryBot.define do
4
4
  association :virtual_account_product
5
5
  applicant_type { :individual }
6
6
  status { :draft }
7
+ sequence(:application_number) { |n| "IND#{Date.current.strftime('%Y%m%d')}#{n.to_s.rjust(4, '0')}" }
7
8
 
8
9
  form_data do
9
10
  {
@@ -23,7 +24,7 @@ FactoryBot.define do
23
24
 
24
25
  trait :business do
25
26
  applicant_type { :business }
26
- application_type { :current }
27
+ sequence(:application_number) { |n| "BUS#{Date.current.strftime('%Y%m%d')}#{n.to_s.rjust(4, '0')}" }
27
28
  form_data do
28
29
  {
29
30
  business_name: Faker::Company.name,
@@ -44,21 +45,21 @@ FactoryBot.define do
44
45
  trait :under_review do
45
46
  status { :under_review }
46
47
  submitted_at { 2.days.ago }
47
- association :assigned_kyc_officer, factory: [ :user, :dscf_core ]
48
+ association :assigned_kyc_officer, factory: :user
48
49
  end
49
50
 
50
51
  trait :approved do
51
52
  status { :approved }
52
53
  submitted_at { 3.days.ago }
53
- association :assigned_kyc_officer, factory: [ :user, :dscf_core ]
54
- association :assigned_branch_manager, factory: [ :user, :dscf_core ]
54
+ association :assigned_kyc_officer, factory: :user
55
+ association :assigned_branch_manager, factory: :user
55
56
  end
56
57
 
57
58
  trait :rejected do
58
59
  status { :rejected }
59
60
  submitted_at { 3.days.ago }
60
61
  rejection_reason { "Incomplete documentation" }
61
- association :assigned_kyc_officer, factory: [ :user, :dscf_core ]
62
+ association :assigned_kyc_officer, factory: :user
62
63
  end
63
64
  end
64
65
  end
@@ -0,0 +1,56 @@
1
+ FactoryBot.define do
2
+ factory :dscf_banking_transaction_type, class: "Dscf::Banking::TransactionType" do
3
+ sequence(:code) { |n| "TXN_TYPE_#{n}" }
4
+ sequence(:name) { |n| "Transaction Type #{n}" }
5
+ description { "Test transaction type description" }
6
+
7
+ trait :deposit do
8
+ code { "DEPOSIT" }
9
+ name { "Deposit" }
10
+ description { "Customer deposit from external source" }
11
+ end
12
+
13
+ trait :withdrawal do
14
+ code { "WITHDRAWAL" }
15
+ name { "Withdrawal" }
16
+ description { "Customer withdrawal to external destination" }
17
+ end
18
+
19
+ trait :transfer do
20
+ code { "TRANSFER" }
21
+ name { "Transfer" }
22
+ description { "Transfer between customer accounts" }
23
+ end
24
+
25
+ trait :interest_credit do
26
+ code { "INTEREST_CREDIT" }
27
+ name { "Interest Credit" }
28
+ description { "Interest payment to customer account" }
29
+ end
30
+
31
+ trait :interest_expense do
32
+ code { "INTEREST_EXPENSE" }
33
+ name { "Interest Expense" }
34
+ description { "Interest expense booking" }
35
+ end
36
+
37
+ trait :tax_withholding do
38
+ code { "TAX_WITHHOLDING" }
39
+ name { "Tax Withholding" }
40
+ description { "Tax withholding on interest payments" }
41
+ end
42
+
43
+ trait :settlement do
44
+ code { "SETTLEMENT" }
45
+ name { "Settlement" }
46
+ description { "Settlement transaction with core banking system" }
47
+ end
48
+ end
49
+
50
+ # Alias for shared spec compatibility
51
+ factory :transaction_type, class: "Dscf::Banking::TransactionType" do
52
+ sequence(:code) { |n| "TXN_TYPE_#{n}" }
53
+ sequence(:name) { |n| "Transaction Type #{n}" }
54
+ description { "Test transaction type description" }
55
+ end
56
+ end
@@ -0,0 +1,52 @@
1
+ FactoryBot.define do
2
+ factory :dscf_banking_transaction, class: "Dscf::Banking::Transaction" do
3
+ association :account, factory: :dscf_banking_account
4
+ association :transaction_type, factory: [ :dscf_banking_transaction_type, :deposit ]
5
+ association :debit_account, factory: :dscf_banking_account
6
+ association :credit_account, factory: :dscf_banking_account
7
+
8
+ amount { 1000.00 }
9
+ currency { "ETB" }
10
+ description { "Test transaction" }
11
+ status { :pending }
12
+
13
+ trait :deposit do
14
+ association :transaction_type, factory: [ :dscf_banking_transaction_type, :deposit ]
15
+
16
+ after(:build) do |transaction|
17
+ transaction.credit_account = transaction.account
18
+ transaction.debit_account = create(:dscf_banking_account, :settlement_account, currency: transaction.currency)
19
+ end
20
+ end
21
+
22
+ trait :withdrawal do
23
+ association :transaction_type, factory: [ :dscf_banking_transaction_type, :withdrawal ]
24
+
25
+ after(:build) do |transaction|
26
+ transaction.debit_account = transaction.account
27
+ transaction.credit_account = create(:dscf_banking_account, :settlement_account, currency: transaction.currency)
28
+ end
29
+ end
30
+
31
+ trait :completed do
32
+ status { :completed }
33
+ end
34
+
35
+ trait :failed do
36
+ status { :failed }
37
+ end
38
+ end
39
+
40
+ # Alias for shared spec compatibility
41
+ factory :transaction, class: "Dscf::Banking::Transaction" do
42
+ association :account, factory: :dscf_banking_account
43
+ association :transaction_type, factory: [ :dscf_banking_transaction_type, :deposit ]
44
+ association :debit_account, factory: :dscf_banking_account
45
+ association :credit_account, factory: :dscf_banking_account
46
+
47
+ amount { 1000.00 }
48
+ currency { "ETB" }
49
+ description { "Test transaction" }
50
+ status { :pending }
51
+ end
52
+ end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dscf-banking
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.14
4
+ version: 0.1.16
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eyosiyas Mekbib
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-09-15 00:00:00.000000000 Z
10
+ date: 2025-09-20 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rails
@@ -447,6 +447,8 @@ files:
447
447
  - app/controllers/dscf/banking/interest_rate_types_controller.rb
448
448
  - app/controllers/dscf/banking/product_approvals_controller.rb
449
449
  - app/controllers/dscf/banking/product_categories_controller.rb
450
+ - app/controllers/dscf/banking/transaction_types_controller.rb
451
+ - app/controllers/dscf/banking/transactions_controller.rb
450
452
  - app/controllers/dscf/banking/virtual_account_products_controller.rb
451
453
  - app/jobs/dscf/banking/application_job.rb
452
454
  - app/mailers/dscf/banking/application_mailer.rb
@@ -460,6 +462,8 @@ files:
460
462
  - app/models/dscf/banking/product_approval.rb
461
463
  - app/models/dscf/banking/product_audit_log.rb
462
464
  - app/models/dscf/banking/product_category.rb
465
+ - app/models/dscf/banking/transaction.rb
466
+ - app/models/dscf/banking/transaction_type.rb
463
467
  - app/models/dscf/banking/virtual_account_product.rb
464
468
  - app/serializers/dscf/banking/account_serializer.rb
465
469
  - app/serializers/dscf/banking/application_serializer.rb
@@ -468,8 +472,14 @@ files:
468
472
  - app/serializers/dscf/banking/interest_rate_type_serializer.rb
469
473
  - app/serializers/dscf/banking/product_approval_serializer.rb
470
474
  - app/serializers/dscf/banking/product_category_serializer.rb
475
+ - app/serializers/dscf/banking/transaction_serializer.rb
476
+ - app/serializers/dscf/banking/transaction_type_serializer.rb
471
477
  - app/serializers/dscf/banking/virtual_account_product_serializer.rb
472
478
  - app/services/dscf/banking/account_creation_service.rb
479
+ - app/services/dscf/banking/base_transaction_service.rb
480
+ - app/services/dscf/banking/deposit_service.rb
481
+ - app/services/dscf/banking/transfer_service.rb
482
+ - app/services/dscf/banking/withdrawal_service.rb
473
483
  - config/routes.rb
474
484
  - db/migrate/20250830211002_create_dscf_banking_product_categories.rb
475
485
  - db/migrate/20250830211027_create_dscf_banking_interest_rate_types.rb
@@ -482,6 +492,11 @@ files:
482
492
  - db/migrate/20250831084706_allow_null_virtual_account_product_id_in_product_audit_logs.rb
483
493
  - db/migrate/20250912193134_create_dscf_banking_applications.rb
484
494
  - db/migrate/20250912203527_create_dscf_banking_accounts.rb
495
+ - db/migrate/20250919084147_add_account_type_to_accounts.rb
496
+ - db/migrate/20250919084927_make_account_associations_optional.rb
497
+ - db/migrate/20250919182831_create_dscf_banking_transaction_types.rb
498
+ - db/migrate/20250919184220_create_dscf_banking_transactions.rb
499
+ - db/seeds.rb
485
500
  - lib/dscf/banking.rb
486
501
  - lib/dscf/banking/engine.rb
487
502
  - lib/dscf/banking/version.rb
@@ -494,6 +509,8 @@ files:
494
509
  - spec/factories/dscf/banking/product_approvals.rb
495
510
  - spec/factories/dscf/banking/product_audit_logs.rb
496
511
  - spec/factories/dscf/banking/product_categories.rb
512
+ - spec/factories/dscf/banking/transaction_types.rb
513
+ - spec/factories/dscf/banking/transactions.rb
497
514
  - spec/factories/dscf/banking/virtual_account_products.rb
498
515
  homepage: https://mksaddis.com/
499
516
  licenses: []