dscf-banking 0.3.5 → 0.3.7

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: 033d842004e5e6c779e391bb9dca2e43925b3858212b50a94db2f3d8853a0dc1
4
- data.tar.gz: eb48ab4b6287afbf75f93801f033c08327d284f127142e7199a7678ee74b3acc
3
+ metadata.gz: 1d9aebfb57294bdeee3240fe4079565174e97a172b44aaf93c4d93f3be94573d
4
+ data.tar.gz: f7acfa8939c35b2ce39f99a787772712ee886be3ed89326247948edf6f4b8a9e
5
5
  SHA512:
6
- metadata.gz: f7bcc179bc00d1f81d16394e521b1d54188bd41efc89a0c0d1f9c076648eebc223a9642d3c21790bcb1270d649741b2692f54b74f7b6ec33cded00c44802d122
7
- data.tar.gz: b915c7adccc0fcde6e40717023d0f9fe849c5e81298d1797371a4876d5c9dce9dec294b1b08065d23873dd3c210d61b54922d33024ea23fbaf6f3adefe5dad04
6
+ metadata.gz: 714c5cdb80e79dea3fd95842f9ef2ddb5e1f39c49af9f08ffee40625157ec2147fb0676430316885f8c28534811982a48ada4c471aef0c91a338d9c3fc068b5a
7
+ data.tar.gz: b1b6079ab0ad3d70f2c9c1076f70b06f6cefe57c118a1fa001710319a29054808bede0b95dfada63702968a6310ca74190d7013444c72889c7a1eca8ac034255
@@ -30,18 +30,12 @@ module Dscf::Banking
30
30
 
31
31
  def full_name
32
32
  account = Dscf::Banking::Account.find_by(account_number: params[:id])
33
- if account && account.application && account.application.user
34
- user = account.application.user
35
- profile = user.respond_to?(:user_profile) ? user.user_profile : nil
36
- if profile
37
- full_name = [profile.first_name, profile.middle_name, profile.last_name].compact.join(" ")
38
- render json: { success: true, data: { full_name: full_name } }
39
- else
40
- render json: { success: false, error: "User profile not found" }, status: :not_found
41
- end
42
- else
43
- render json: { success: false, error: "Account not found" }, status: :not_found
44
- end
33
+ return render json: { success: false, error: "Account not found" }, status: :not_found unless account
34
+
35
+ full_name = account.name.to_s.strip
36
+ full_name = "Unnamed" if full_name.empty?
37
+
38
+ render json: { success: true, data: { full_name: full_name } }
45
39
  end
46
40
 
47
41
  def show
@@ -231,29 +225,29 @@ module Dscf::Banking
231
225
 
232
226
  def transactions
233
227
  account = Dscf::Banking::Account.find(params[:id])
234
-
228
+
235
229
  transactions = Dscf::Banking::Transaction
236
230
  .where("account_id = ? OR debit_account_id = ? OR credit_account_id = ?", account.id, account.id, account.id)
237
231
  .includes(:account, :transaction_type, :debit_account, :credit_account)
238
-
232
+
239
233
  # Apply pagination if requested
240
234
  page = params[:page].to_i
241
235
  per_page = (params[:per_page] || 10).to_i
242
-
236
+
243
237
  # Apply filters
244
238
  transactions = transactions.where(status: params[:status]) if params[:status].present?
245
239
  transactions = transactions.where(transaction_type_id: params[:transaction_type_id]) if params[:transaction_type_id].present?
246
-
240
+
247
241
  # Apply ordering
248
242
  order_column = %w[id reference_number amount currency status created_at updated_at].include?(params[:order_by]) ? params[:order_by] : "created_at"
249
243
  order_direction = %w[asc desc].include?(params[:order_direction]) ? params[:order_direction] : "desc"
250
244
  transactions = transactions.order("#{order_column} #{order_direction}")
251
-
245
+
252
246
  if page.positive?
253
247
  total_count = transactions.count
254
248
  total_pages = (total_count.to_f / per_page).ceil
255
249
  transactions = transactions.offset((page - 1) * per_page).limit(per_page)
256
-
250
+
257
251
  render json: {
258
252
  success: true,
259
253
  message: "Account transactions retrieved successfully",
@@ -24,7 +24,7 @@ module Dscf::Banking
24
24
 
25
25
  private
26
26
 
27
- def application_data(application)
27
+ def application_data(application)
28
28
  {
29
29
  id: application.id,
30
30
  application_number: application.application_number,
@@ -52,7 +52,7 @@ module Dscf::Banking
52
52
  end
53
53
 
54
54
  def eager_loaded_associations
55
- [ :user, :virtual_account_product, :account, :documents, reviews: { reviewed_by: :user_profile } ]
55
+ [ :user, :virtual_account_product, :documents, reviews: { reviewed_by: :user_profile } ]
56
56
  end
57
57
 
58
58
  def allowed_order_columns
@@ -6,7 +6,7 @@ module Dscf::Banking
6
6
  render json: {
7
7
  success: false,
8
8
  error: "Transaction not found",
9
- errors: [ "Transaction with ID #{params[:id]} not found" ]
9
+ errors: ["Transaction with ID #{params[:id]} not found"]
10
10
  }, status: :not_found
11
11
  end
12
12
 
@@ -83,10 +83,10 @@ module Dscf::Banking
83
83
 
84
84
  def default_serializer_includes
85
85
  {
86
- index: [ :product_category, :created_by, :approved_by, reviews: { reviewed_by: :user_profile } ],
87
- show: [ :product_category, :created_by, :approved_by, reviews: { reviewed_by: :user_profile } ],
88
- create: [ :reviews ],
89
- update: [ :product_category, :created_by, :approved_by, reviews: { reviewed_by: :user_profile } ]
86
+ index: [:product_category, :created_by, :approved_by, reviews: { reviewed_by: :user_profile }],
87
+ show: [:product_category, :created_by, :approved_by, reviews: { reviewed_by: :user_profile }],
88
+ create: [:reviews],
89
+ update: [:product_category, :created_by, :approved_by, reviews: { reviewed_by: :user_profile }]
90
90
  }
91
91
  end
92
92
  end
@@ -0,0 +1,133 @@
1
+ module Dscf
2
+ module Banking
3
+ class VouchersController < ApplicationController
4
+ include Dscf::Core::Common
5
+
6
+ def create
7
+ parent_account = Dscf::Banking::Account.find_by(account_number: model_params[:parent_account_number])
8
+
9
+ unless parent_account
10
+ return render_error(errors: "Parent account not found", status: :not_found)
11
+ end
12
+
13
+ service = Dscf::Banking::VoucherService.new
14
+ service.generate_voucher(
15
+ parent_account: parent_account,
16
+ amount: model_params[:amount],
17
+ recipient_mobile: model_params[:recipient_mobile],
18
+ recipient_name: model_params[:recipient_name],
19
+ message: model_params[:message],
20
+ expires_at: model_params[:expires_at]
21
+ )
22
+
23
+ if service.success?
24
+ render_success(data: service.voucher, serializer_options: { include: default_serializer_includes[:default] }, status: :created)
25
+ else
26
+ render_error(errors: service.errors, status: :unprocessable_entity)
27
+ end
28
+ end
29
+
30
+ def redeem
31
+ service = Dscf::Banking::VoucherService.new
32
+ destination_account = nil
33
+
34
+ if redeem_params[:destination_account_number].present?
35
+ destination_account = Dscf::Banking::Account.find_by(account_number: redeem_params[:destination_account_number])
36
+ unless destination_account
37
+ return render_error(errors: "Destination account not found", status: :not_found)
38
+ end
39
+ end
40
+
41
+ service.redeem_voucher(
42
+ code: redeem_params[:code] || params[:code],
43
+ amount: redeem_params[:amount],
44
+ recipient_mobile: redeem_params[:recipient_mobile],
45
+ destination_account: destination_account
46
+ )
47
+
48
+ if service.success?
49
+ render_success(
50
+ data: {
51
+ voucher: service.voucher,
52
+ redemption: service.redemption
53
+ },
54
+ serializer_options: {
55
+ voucher: { include: default_serializer_includes[:default] },
56
+ redemption: { include: [ :destination_account ] }
57
+ }
58
+ )
59
+ else
60
+ render_error(errors: service.errors, status: :unprocessable_entity)
61
+ end
62
+ end
63
+
64
+ def show
65
+ voucher = Dscf::Banking::Voucher.find_by(code: params[:id].to_s.strip.upcase)
66
+ return render_error(errors: "Voucher not found", status: :not_found) unless voucher
67
+
68
+ if voucher.active? && voucher.expired?
69
+ Dscf::Banking::VoucherService.new.expire_voucher(voucher)
70
+ voucher.reload
71
+ end
72
+
73
+ render_success(data: voucher, serializer_options: { include: default_serializer_includes[:default] })
74
+ end
75
+
76
+ def cancel
77
+ service = Dscf::Banking::VoucherService.new
78
+ service.cancel_voucher(code: params[:code])
79
+
80
+ if service.success?
81
+ render_success(data: service.voucher, serializer_options: { include: default_serializer_includes[:default] })
82
+ else
83
+ render_error(errors: service.errors, status: :unprocessable_entity)
84
+ end
85
+ end
86
+
87
+ def account_vouchers
88
+ account = Dscf::Banking::Account.find(params[:account_id])
89
+ vouchers = account.vouchers.order(created_at: :desc)
90
+
91
+ render_success(data: vouchers, serializer_options: { include: default_serializer_includes[:default] })
92
+ rescue ActiveRecord::RecordNotFound
93
+ render_error(errors: "Account not found", status: :not_found)
94
+ end
95
+
96
+ private
97
+
98
+ def model_params
99
+ params.require(:voucher).permit(
100
+ :parent_account_number,
101
+ :amount,
102
+ :recipient_mobile,
103
+ :recipient_name,
104
+ :message,
105
+ :expires_at
106
+ )
107
+ end
108
+
109
+ def redeem_params
110
+ params.require(:redemption).permit(
111
+ :code,
112
+ :amount,
113
+ :recipient_mobile,
114
+ :destination_account_number
115
+ )
116
+ end
117
+
118
+ def eager_loaded_associations
119
+ []
120
+ end
121
+
122
+ def allowed_order_columns
123
+ %w[id code status amount remaining_amount recipient_mobile created_at updated_at]
124
+ end
125
+
126
+ def default_serializer_includes
127
+ {
128
+ default: [ :parent_account, :redemptions ]
129
+ }
130
+ end
131
+ end
132
+ end
133
+ end
@@ -3,6 +3,9 @@ module Dscf::Banking
3
3
  belongs_to :virtual_account_product, optional: true
4
4
  belongs_to :application, optional: true
5
5
 
6
+ has_many :vouchers, class_name: "Dscf::Banking::Voucher", foreign_key: :parent_account_id, dependent: :restrict_with_error
7
+ has_many :voucher_redemptions, through: :vouchers, class_name: "Dscf::Banking::VoucherRedemption"
8
+
6
9
  enum :status, {
7
10
  draft: 0,
8
11
  pending_activation: 1,
@@ -16,7 +19,7 @@ module Dscf::Banking
16
19
  validates :name, presence: true
17
20
  validates :currency, presence: true
18
21
  validates :current_balance, :available_balance, :minimum_balance,
19
- numericality: { greater_than_or_equal_to: 0 }
22
+ numericality: { greater_than_or_equal_to: 0 }
20
23
  validates :system_account, inclusion: { in: [ true, false ] }
21
24
  validate :system_account_associations
22
25
 
@@ -0,0 +1,76 @@
1
+ module Dscf
2
+ module Banking
3
+ class Voucher < ApplicationRecord
4
+ self.table_name = "dscf_banking_vouchers"
5
+
6
+ belongs_to :parent_account, class_name: "Dscf::Banking::Account"
7
+ has_many :redemptions, class_name: "Dscf::Banking::VoucherRedemption", dependent: :restrict_with_error
8
+
9
+ enum :status, {
10
+ pending: 0,
11
+ active: 1,
12
+ redeemed: 2,
13
+ expired: 3,
14
+ cancelled: 4
15
+ }
16
+
17
+ validates :parent_account, presence: true
18
+ validates :amount, presence: true, numericality: { greater_than: 0 }
19
+ validates :remaining_amount, presence: true, numericality: { greater_than_or_equal_to: 0 }
20
+ validates :currency, presence: true
21
+ validates :code, presence: true, uniqueness: { case_sensitive: false }
22
+ validates :recipient_mobile, presence: true
23
+ validate :remaining_amount_not_exceed_amount
24
+ validate :expires_at_in_future, if: -> { expires_at.present? }
25
+
26
+ before_validation :set_defaults, on: :create
27
+ before_validation :normalize_code, if: -> { code.present? }
28
+
29
+ scope :by_status, ->(status) { where(status: status) }
30
+ scope :active_now, -> { where(status: :active).where("expires_at IS NULL OR expires_at > ?", Time.current) }
31
+ scope :expired_now, -> { where(status: :active).where("expires_at IS NOT NULL AND expires_at <= ?", Time.current) }
32
+
33
+ def expired?
34
+ expires_at.present? && expires_at <= Time.current
35
+ end
36
+
37
+ def fully_redeemed?
38
+ remaining_amount.to_f.zero?
39
+ end
40
+
41
+ def mark_expired!
42
+ update!(status: :expired) if expired? && !expired_status?
43
+ end
44
+
45
+ def expired_status?
46
+ status == "expired"
47
+ end
48
+
49
+ private
50
+
51
+ def set_defaults
52
+ self.currency ||= parent_account&.currency
53
+ self.remaining_amount = amount if remaining_amount.nil? && amount
54
+ self.status ||= :pending
55
+ end
56
+
57
+ def normalize_code
58
+ self.code = code.to_s.strip.upcase
59
+ end
60
+
61
+ def remaining_amount_not_exceed_amount
62
+ return if amount.nil? || remaining_amount.nil?
63
+
64
+ if remaining_amount > amount
65
+ errors.add(:remaining_amount, "cannot exceed voucher amount")
66
+ end
67
+ end
68
+
69
+ def expires_at_in_future
70
+ if expires_at <= Time.current
71
+ errors.add(:expires_at, "must be in the future")
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,27 @@
1
+ module Dscf
2
+ module Banking
3
+ class VoucherRedemption < ApplicationRecord
4
+ self.table_name = "dscf_banking_voucher_redemptions"
5
+
6
+ belongs_to :voucher, class_name: "Dscf::Banking::Voucher"
7
+ belongs_to :destination_account, class_name: "Dscf::Banking::Account", optional: true
8
+
9
+ validates :voucher, presence: true
10
+ validates :amount, presence: true, numericality: { greater_than: 0 }
11
+ validates :recipient_mobile, presence: true
12
+ validates :currency, presence: true
13
+ validates :redeemed_at, presence: true
14
+
15
+ before_validation :set_defaults, on: :create
16
+
17
+ scope :by_voucher, ->(voucher_id) { where(voucher_id: voucher_id) }
18
+
19
+ private
20
+
21
+ def set_defaults
22
+ self.currency ||= voucher&.currency
23
+ self.recipient_mobile ||= voucher&.recipient_mobile
24
+ end
25
+ end
26
+ end
27
+ end
@@ -1,11 +1,12 @@
1
1
  module Dscf::Banking
2
2
  class AccountSerializer < ActiveModel::Serializer
3
3
  attributes :id, :account_number, :name, :status, :activation_date, :closure_date,
4
- :account_properties, :current_balance, :available_balance, :minimum_balance,
5
- :currency, :active, :created_at, :updated_at
4
+ :account_properties, :current_balance, :available_balance, :minimum_balance,
5
+ :currency, :active, :created_at, :updated_at
6
6
 
7
7
  belongs_to :virtual_account_product, serializer: VirtualAccountProductSerializer
8
8
 
9
9
  attribute :application_id
10
+ attribute :system_account
10
11
  end
11
12
  end
@@ -9,7 +9,7 @@ module Dscf::Banking
9
9
  belongs_to :assigned_branch_manager, optional: true
10
10
  has_one :account, serializer: AccountSerializer, if: :has_account?
11
11
  has_many :reviews, serializer: Dscf::Core::ReviewSerializer
12
- has_many :documents, serializer: Dscf::Banking::DocumentSerializer
12
+ has_many :documents, serializer: DocumentSerializer
13
13
 
14
14
  def has_account?
15
15
  object.has_account?
@@ -2,6 +2,8 @@ module Dscf::Banking
2
2
  class DocumentSerializer < ActiveModel::Serializer
3
3
  attributes :id, :document_type, :is_verified, :verified_at, :metadata, :file_urls, :created_at, :updated_at
4
4
 
5
+ belongs_to :documentable, polymorphic: true
6
+
5
7
  def file_urls
6
8
  return [] unless object.files.attached?
7
9
 
@@ -14,7 +16,7 @@ module Dscf::Banking
14
16
  object.files.map(&:url)
15
17
  end
16
18
  end
17
- rescue StandardError
19
+ rescue
18
20
  []
19
21
  end
20
22
  end
@@ -0,0 +1,10 @@
1
+ module Dscf
2
+ module Banking
3
+ class VoucherRedemptionSerializer < ActiveModel::Serializer
4
+ attributes :id, :voucher_id, :destination_account_id, :amount, :currency,
5
+ :recipient_mobile, :redeemed_at, :created_at, :updated_at
6
+
7
+ belongs_to :destination_account, serializer: AccountSerializer
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,20 @@
1
+ module Dscf
2
+ module Banking
3
+ class VoucherSerializer < ActiveModel::Serializer
4
+ attributes :id, :parent_account_id, :code, :status, :amount, :remaining_amount,
5
+ :currency, :recipient_mobile, :recipient_name, :message, :expires_at,
6
+ :redeemed_at, :created_at, :updated_at
7
+
8
+ attribute :amount do
9
+ object.amount.to_f
10
+ end
11
+
12
+ attribute :remaining_amount do
13
+ object.remaining_amount.to_f
14
+ end
15
+
16
+ belongs_to :parent_account, serializer: AccountSerializer
17
+ has_many :redemptions, serializer: VoucherRedemptionSerializer
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,317 @@
1
+ module Dscf
2
+ module Banking
3
+ class VoucherService < BaseTransactionService
4
+ attr_reader :voucher, :redemption
5
+
6
+ def initialize
7
+ super
8
+ @voucher = nil
9
+ @redemption = nil
10
+ end
11
+
12
+ def generate_voucher(parent_account:, amount:, recipient_mobile:, recipient_name: nil, message: nil, expires_at: nil)
13
+ amount = amount.to_f
14
+ return self unless validate_generate_params(parent_account, amount, recipient_mobile)
15
+
16
+ expires_at ||= default_expiration
17
+
18
+ settlement_account = find_or_create_voucher_settlement_account(parent_account.currency)
19
+ return self unless success?
20
+
21
+ voucher_code = generate_code
22
+
23
+ ActiveRecord::Base.transaction do
24
+ transaction = create_voucher_transaction(
25
+ debit_account: parent_account,
26
+ credit_account: settlement_account,
27
+ amount: amount,
28
+ description: "Voucher issuance #{voucher_code}"
29
+ )
30
+
31
+ return self unless success?
32
+
33
+ @voucher = Dscf::Banking::Voucher.create(
34
+ parent_account: parent_account,
35
+ amount: amount,
36
+ remaining_amount: amount,
37
+ currency: parent_account.currency,
38
+ recipient_mobile: recipient_mobile,
39
+ recipient_name: recipient_name,
40
+ message: message,
41
+ expires_at: expires_at,
42
+ status: :active,
43
+ code: voucher_code
44
+ )
45
+
46
+ unless @voucher.persisted?
47
+ @voucher.errors.full_messages.each { |msg| add_error(msg) }
48
+ raise ActiveRecord::Rollback
49
+ end
50
+
51
+ transaction&.update(description: "Voucher issuance #{@voucher.code}")
52
+ end
53
+
54
+ self
55
+ end
56
+
57
+ def redeem_voucher(code:, amount:, recipient_mobile:, destination_account: nil)
58
+ amount = amount.to_f
59
+ code = code.to_s.strip.upcase
60
+ @voucher = Dscf::Banking::Voucher.find_by(code: code)
61
+
62
+ unless @voucher
63
+ add_error("Voucher not found")
64
+ return self
65
+ end
66
+
67
+ return self unless validate_redeem_params(amount, recipient_mobile)
68
+ return self unless validate_voucher_state(@voucher, amount, recipient_mobile)
69
+ return self unless ensure_voucher_settlement_funds(@voucher.currency, amount)
70
+
71
+ if destination_account
72
+ return self unless validate_account_active(destination_account, "Destination account")
73
+ unless destination_account.currency == @voucher.currency
74
+ add_error("Currency mismatch between voucher and destination account")
75
+ return self
76
+ end
77
+ end
78
+
79
+ ActiveRecord::Base.transaction do
80
+ @voucher.lock!
81
+ remaining_after = @voucher.remaining_amount.to_f - amount
82
+
83
+ @redemption = Dscf::Banking::VoucherRedemption.create(
84
+ voucher: @voucher,
85
+ amount: amount,
86
+ currency: @voucher.currency,
87
+ recipient_mobile: recipient_mobile,
88
+ destination_account: destination_account,
89
+ redeemed_at: Time.current
90
+ )
91
+
92
+ unless @redemption.persisted?
93
+ @redemption.errors.full_messages.each { |msg| add_error(msg) }
94
+ raise ActiveRecord::Rollback
95
+ end
96
+
97
+ update_voucher_balance_and_status(@voucher, remaining_after)
98
+ return self unless success?
99
+
100
+ if destination_account
101
+ create_voucher_transaction(
102
+ debit_account: find_or_create_voucher_settlement_account(@voucher.currency),
103
+ credit_account: destination_account,
104
+ amount: amount,
105
+ description: "Voucher redemption #{@voucher.code}"
106
+ )
107
+ end
108
+ end
109
+
110
+ self
111
+ end
112
+
113
+ def cancel_voucher(code:)
114
+ @voucher = Dscf::Banking::Voucher.find_by(code: code.to_s.strip.upcase)
115
+
116
+ unless @voucher
117
+ add_error("Voucher not found")
118
+ return self
119
+ end
120
+
121
+ if @voucher.redeemed? || @voucher.cancelled?
122
+ add_error("Voucher cannot be cancelled")
123
+ return self
124
+ end
125
+
126
+ ActiveRecord::Base.transaction do
127
+ @voucher.lock!
128
+ refundable = @voucher.remaining_amount.to_f
129
+ @voucher.update!(status: :cancelled, remaining_amount: 0)
130
+
131
+ if refundable.positive?
132
+ create_voucher_transaction(
133
+ debit_account: find_or_create_voucher_settlement_account(@voucher.currency),
134
+ credit_account: @voucher.parent_account,
135
+ amount: refundable,
136
+ description: "Voucher cancellation #{@voucher.code}"
137
+ )
138
+ end
139
+ end
140
+
141
+ self
142
+ end
143
+
144
+ def expire_voucher(voucher)
145
+ return self unless voucher
146
+
147
+ ActiveRecord::Base.transaction do
148
+ voucher.lock!
149
+ return self unless voucher.active?
150
+ return self unless voucher.expired?
151
+
152
+ refundable = voucher.remaining_amount.to_f
153
+ voucher.update!(status: :expired, remaining_amount: 0)
154
+
155
+ if refundable.positive?
156
+ create_voucher_transaction(
157
+ debit_account: find_or_create_voucher_settlement_account(voucher.currency),
158
+ credit_account: voucher.parent_account,
159
+ amount: refundable,
160
+ description: "Voucher expiration #{voucher.code}"
161
+ )
162
+ end
163
+ end
164
+
165
+ self
166
+ end
167
+
168
+ def expire_due_vouchers
169
+ Dscf::Banking::Voucher.active_now.find_each do |voucher|
170
+ expire_voucher(voucher) if voucher.expired?
171
+ end
172
+
173
+ self
174
+ end
175
+
176
+ private
177
+
178
+ def validate_generate_params(parent_account, amount, recipient_mobile)
179
+ return false unless validate_account_active(parent_account, "Parent account")
180
+
181
+ if amount <= 0
182
+ add_error("Voucher amount must be greater than zero")
183
+ return false
184
+ end
185
+
186
+ if recipient_mobile.to_s.strip.empty?
187
+ add_error("Recipient mobile is required")
188
+ return false
189
+ end
190
+
191
+ return false unless validate_sufficient_funds(parent_account, amount)
192
+
193
+ true
194
+ end
195
+
196
+ def default_expiration
197
+ 30.days.from_now
198
+ end
199
+
200
+ def validate_redeem_params(amount, recipient_mobile)
201
+ if amount <= 0
202
+ add_error("Redemption amount must be greater than zero")
203
+ return false
204
+ end
205
+
206
+ if recipient_mobile.to_s.strip.empty?
207
+ add_error("Recipient mobile is required")
208
+ return false
209
+ end
210
+
211
+ true
212
+ end
213
+
214
+ def validate_voucher_state(voucher, amount, recipient_mobile)
215
+ if voucher.cancelled?
216
+ add_error("Voucher is cancelled")
217
+ return false
218
+ end
219
+
220
+ if voucher.expired?
221
+ add_error("Voucher is expired")
222
+ return false
223
+ end
224
+
225
+ if voucher.redeemed?
226
+ add_error("Voucher already redeemed")
227
+ return false
228
+ end
229
+
230
+ if voucher.recipient_mobile != recipient_mobile
231
+ add_error("Recipient mobile does not match")
232
+ return false
233
+ end
234
+
235
+ if voucher.remaining_amount.to_f < amount
236
+ add_error("Insufficient voucher balance")
237
+ return false
238
+ end
239
+
240
+ true
241
+ end
242
+
243
+ def ensure_voucher_settlement_funds(currency, amount)
244
+ settlement_account = find_or_create_voucher_settlement_account(currency)
245
+ return false unless settlement_account
246
+
247
+ if settlement_account.current_balance.to_f < amount
248
+ add_error("Insufficient voucher settlement funds")
249
+ return false
250
+ end
251
+
252
+ true
253
+ end
254
+
255
+ def update_voucher_balance_and_status(voucher, remaining_after)
256
+ new_status = remaining_after.zero? ? :redeemed : :active
257
+ updates = { remaining_amount: remaining_after, status: new_status }
258
+ updates[:redeemed_at] = Time.current if remaining_after.zero?
259
+
260
+ unless voucher.update(updates)
261
+ voucher.errors.full_messages.each { |msg| add_error(msg) }
262
+ end
263
+ end
264
+
265
+ def generate_code
266
+ loop do
267
+ code = "VCH#{SecureRandom.hex(6).upcase}"
268
+ break code unless Dscf::Banking::Voucher.exists?(code: code)
269
+ end
270
+ end
271
+
272
+ def create_voucher_transaction(debit_account:, credit_account:, amount:, description:)
273
+ return unless debit_account && credit_account
274
+
275
+ transaction_type = find_or_create_transaction_type("VOUCHER")
276
+ unless transaction_type
277
+ raise ActiveRecord::Rollback
278
+ end
279
+
280
+ transaction = create_transaction(
281
+ account: debit_account,
282
+ transaction_type: transaction_type,
283
+ debit_account: debit_account,
284
+ credit_account: credit_account,
285
+ amount: amount,
286
+ currency: debit_account.currency,
287
+ description: description,
288
+ status: :pending
289
+ )
290
+
291
+ unless transaction
292
+ raise ActiveRecord::Rollback
293
+ end
294
+
295
+ unless process_transaction(transaction)
296
+ raise ActiveRecord::Rollback
297
+ end
298
+
299
+ transaction
300
+ end
301
+
302
+ def find_or_create_voucher_settlement_account(currency)
303
+ account = Dscf::Banking::Account.system_accounts
304
+ .where(currency: currency)
305
+ .where("name LIKE ?", "%Voucher Settlement%")
306
+ .first
307
+
308
+ unless account
309
+ add_error("Voucher settlement account not found for currency #{currency}")
310
+ return nil
311
+ end
312
+
313
+ account
314
+ end
315
+ end
316
+ end
317
+ end
data/config/routes.rb CHANGED
@@ -1,6 +1,5 @@
1
1
  Dscf::Banking::Engine.routes.draw do
2
- resources :accounts do
3
- get "full_name", to: "accounts#full_name", on: :member
2
+ resources :accounts do
4
3
  collection do
5
4
  get :me
6
5
  end
@@ -9,6 +8,7 @@ Dscf::Banking::Engine.routes.draw do
9
8
  post :suspend
10
9
  post :close
11
10
  get :transactions
11
+ get :full_name
12
12
  end
13
13
  end
14
14
  resources :product_categories
@@ -0,0 +1,23 @@
1
+ class CreateDscfBankingVouchers < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :dscf_banking_vouchers do |t|
4
+ t.references :parent_account, null: false, foreign_key: { to_table: :dscf_banking_accounts }
5
+ t.string :code, null: false
6
+ t.integer :status, null: false, default: 0
7
+ t.decimal :amount, precision: 20, scale: 4, null: false
8
+ t.decimal :remaining_amount, precision: 20, scale: 4, null: false
9
+ t.string :currency, null: false, default: "ETB"
10
+ t.string :recipient_mobile, null: false
11
+ t.string :recipient_name
12
+ t.text :message
13
+ t.datetime :expires_at
14
+ t.datetime :redeemed_at
15
+
16
+ t.timestamps
17
+ end
18
+
19
+ add_index :dscf_banking_vouchers, :code, unique: true
20
+ add_index :dscf_banking_vouchers, :status
21
+ add_index :dscf_banking_vouchers, :recipient_mobile
22
+ end
23
+ end
@@ -0,0 +1,16 @@
1
+ class CreateDscfBankingVoucherRedemptions < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :dscf_banking_voucher_redemptions do |t|
4
+ t.references :voucher, null: false, foreign_key: { to_table: :dscf_banking_vouchers }
5
+ t.references :destination_account, foreign_key: { to_table: :dscf_banking_accounts }
6
+ t.decimal :amount, precision: 20, scale: 4, null: false
7
+ t.string :currency, null: false, default: "ETB"
8
+ t.string :recipient_mobile, null: false
9
+ t.datetime :redeemed_at, null: false
10
+
11
+ t.timestamps
12
+ end
13
+
14
+ add_index :dscf_banking_voucher_redemptions, :recipient_mobile
15
+ end
16
+ end
data/db/seeds.rb CHANGED
@@ -9,42 +9,42 @@
9
9
 
10
10
  # Seed roles based on RBAC documentation
11
11
  puts "Seeding roles..."
12
- user_role = Role.find_or_create_by(name: 'User')
13
- officer_role = Role.find_or_create_by(name: 'Virtual Account Officer')
14
- manager_role = Role.find_or_create_by(name: 'Virtual Account Manager')
15
- kyc_officer_role = Role.find_or_create_by(name: 'KYC Officer')
16
- branch_manager_role = Role.find_or_create_by(name: 'Branch Manager')
12
+ user_role = Dscf::Core::Role.find_or_create_by(name: "User")
13
+ officer_role = Dscf::Core::Role.find_or_create_by(name: "Virtual Account Officer")
14
+ manager_role = Dscf::Core::Role.find_or_create_by(name: "Virtual Account Manager")
15
+ kyc_officer_role = Dscf::Core::Role.find_or_create_by(name: "KYC Officer")
16
+ branch_manager_role = Dscf::Core::Role.find_or_create_by(name: "Branch Manager")
17
17
  puts "Roles seeded successfully."
18
18
 
19
19
  # Seed users with roles
20
20
  puts "Seeding users..."
21
21
  # Create users and assign roles
22
- user1 = User.create(name: "Abebe Kebede", email: "abebe@example.com", password: "SecurePassword123!")
22
+ user1 = Dscf::Core::User.create(name: "Abebe Kebede", email: "abebe@example.com", password: "SecurePassword123!")
23
23
  user1.roles << user_role
24
24
  puts "Created user: #{user1.name}"
25
25
 
26
- user2 = User.create(name: "Tigist Haile", email: "tigist@example.com", password: "SecurePassword123!")
26
+ user2 = Dscf::Core::User.create(name: "Tigist Haile", email: "tigist@example.com", password: "SecurePassword123!")
27
27
  user2.roles << officer_role
28
28
  puts "Created user: #{user2.name}"
29
29
 
30
- user3 = User.create(name: "Dawit Mekonnen", email: "dawit@example.com", password: "SecurePassword123!")
30
+ user3 = Dscf::Core::User.create(name: "Dawit Mekonnen", email: "dawit@example.com", password: "SecurePassword123!")
31
31
  user3.roles << manager_role
32
32
  puts "Created user: #{user3.name}"
33
33
 
34
34
  # Additional users for testing
35
- user4 = User.create(name: "Hirut Assefa", email: "hirut@example.com", password: "SecurePassword123!")
35
+ user4 = Dscf::Core::User.create(name: "Hirut Assefa", email: "hirut@example.com", password: "SecurePassword123!")
36
36
  user4.roles << [ user_role, officer_role ] # User with multiple roles if supported
37
37
  puts "Created user: #{user4.name}"
38
38
 
39
- user5 = User.create(name: "Solomon Tesfaye", email: "solomon@example.com", password: "SecurePassword123!")
39
+ user5 = Dscf::Core::User.create(name: "Solomon Tesfaye", email: "solomon@example.com", password: "SecurePassword123!")
40
40
  user5.roles << manager_role
41
41
  puts "Created user: #{user5.name}"
42
42
 
43
- user6 = User.create(name: "Mulugeta Bekele", email: "mulugeta@example.com", password: "SecurePassword123!")
43
+ user6 = Dscf::Core::User.create(name: "Mulugeta Bekele", email: "mulugeta@example.com", password: "SecurePassword123!")
44
44
  user6.roles << kyc_officer_role
45
45
  puts "Created user: #{user6.name}"
46
46
 
47
- user7 = User.create(name: "Fikirte Alemayehu", email: "fikirte@example.com", password: "SecurePassword123!")
47
+ user7 = Dscf::Core::User.create(name: "Fikirte Alemayehu", email: "fikirte@example.com", password: "SecurePassword123!")
48
48
  user7.roles << branch_manager_role
49
49
  puts "Created user: #{user7.name}"
50
50
 
@@ -68,6 +68,12 @@ transfer_type = Dscf::Banking::TransactionType.find_or_create_by(code: "TRANSFER
68
68
  end
69
69
  puts "Created transaction type: #{transfer_type.name}"
70
70
 
71
+ voucher_type = Dscf::Banking::TransactionType.find_or_create_by(code: "VOUCHER") do |tt|
72
+ tt.name = "Voucher"
73
+ tt.description = "Voucher issuance and redemption transactions"
74
+ end
75
+ puts "Created transaction type: #{voucher_type.name}"
76
+
71
77
  # Seed system accounts for transaction processing
72
78
  puts "Seeding system accounts..."
73
79
  system_deposit_account = Dscf::Banking::Account.find_or_create_by(
@@ -94,4 +100,16 @@ system_withdrawal_account = Dscf::Banking::Account.find_or_create_by(
94
100
  end
95
101
  puts "Created system withdrawal account: #{system_withdrawal_account.name}"
96
102
 
103
+ voucher_settlement_account = Dscf::Banking::Account.find_or_create_by(
104
+ name: "Voucher Settlement Account",
105
+ system_account: true
106
+ ) do |account|
107
+ account.currency = "ETB"
108
+ account.status = :active
109
+ account.current_balance = 0
110
+ account.available_balance = 0
111
+ account.minimum_balance = 0
112
+ end
113
+ puts "Created voucher settlement account: #{voucher_settlement_account.name}"
114
+
97
115
  puts "Seeding completed successfully."
@@ -1,5 +1,5 @@
1
1
  module Dscf
2
2
  module Banking
3
- VERSION = "0.3.5"
3
+ VERSION = "0.3.7"
4
4
  end
5
5
  end
@@ -43,6 +43,8 @@ FactoryBot.define do
43
43
  trait :settlement_account do
44
44
  system_account { true }
45
45
  name { "Core Banking Settlement Account" }
46
+ current_balance { 0 }
47
+ available_balance { 0 }
46
48
  virtual_account_product { nil }
47
49
  application { nil }
48
50
  end
@@ -0,0 +1,9 @@
1
+ FactoryBot.define do
2
+ factory :voucher_redemption, class: "Dscf::Banking::VoucherRedemption" do
3
+ association :voucher, factory: :voucher
4
+ amount { 25.0 }
5
+ currency { voucher.currency }
6
+ recipient_mobile { voucher.recipient_mobile }
7
+ redeemed_at { Time.current }
8
+ end
9
+ end
@@ -0,0 +1,14 @@
1
+ FactoryBot.define do
2
+ factory :voucher, class: "Dscf::Banking::Voucher" do
3
+ parent_account { create(:dscf_banking_account, :active, :with_balance) }
4
+ amount { 100.0 }
5
+ remaining_amount { amount }
6
+ currency { parent_account.currency }
7
+ recipient_mobile { "0912345678" }
8
+ recipient_name { "Voucher Recipient" }
9
+ message { "Enjoy your voucher" }
10
+ expires_at { 30.days.from_now }
11
+ status { :active }
12
+ sequence(:code) { |n| "VCH#{n.to_s.rjust(6, "0")}" }
13
+ end
14
+ 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.3.5
4
+ version: 0.3.7
5
5
  platform: ruby
6
6
  authors:
7
- - Eyosiyas Mekbib
7
+ - Asrat Efrem
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-10-17 00:00:00.000000000 Z
10
+ date: 2026-02-11 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rails
@@ -219,20 +219,6 @@ dependencies:
219
219
  - - ">="
220
220
  - !ruby/object:Gem::Version
221
221
  version: '0'
222
- - !ruby/object:Gem::Dependency
223
- name: bullet
224
- requirement: !ruby/object:Gem::Requirement
225
- requirements:
226
- - - ">="
227
- - !ruby/object:Gem::Version
228
- version: '0'
229
- type: :development
230
- prerelease: false
231
- version_requirements: !ruby/object:Gem::Requirement
232
- requirements:
233
- - - ">="
234
- - !ruby/object:Gem::Version
235
- version: '0'
236
222
  - !ruby/object:Gem::Dependency
237
223
  name: database_cleaner-active_record
238
224
  requirement: !ruby/object:Gem::Requirement
@@ -431,7 +417,7 @@ dependencies:
431
417
  version: '0'
432
418
  description: An engine which contains core models for Supply Chain Financing.
433
419
  email:
434
- - eyosi9000@gmail.com
420
+ - asratextras77@gmail.com
435
421
  executables: []
436
422
  extensions: []
437
423
  extra_rdoc_files: []
@@ -451,6 +437,7 @@ files:
451
437
  - app/controllers/dscf/banking/transaction_types_controller.rb
452
438
  - app/controllers/dscf/banking/transactions_controller.rb
453
439
  - app/controllers/dscf/banking/virtual_account_products_controller.rb
440
+ - app/controllers/dscf/banking/vouchers_controller.rb
454
441
  - app/jobs/dscf/banking/application_job.rb
455
442
  - app/mailers/dscf/banking/application_mailer.rb
456
443
  - app/models/concerns/dscf/banking/auditable.rb
@@ -466,6 +453,8 @@ files:
466
453
  - app/models/dscf/banking/transaction.rb
467
454
  - app/models/dscf/banking/transaction_type.rb
468
455
  - app/models/dscf/banking/virtual_account_product.rb
456
+ - app/models/dscf/banking/voucher.rb
457
+ - app/models/dscf/banking/voucher_redemption.rb
469
458
  - app/serializers/dscf/banking/account_serializer.rb
470
459
  - app/serializers/dscf/banking/application_serializer.rb
471
460
  - app/serializers/dscf/banking/document_serializer.rb
@@ -477,13 +466,14 @@ files:
477
466
  - app/serializers/dscf/banking/transaction_serializer.rb
478
467
  - app/serializers/dscf/banking/transaction_type_serializer.rb
479
468
  - app/serializers/dscf/banking/virtual_account_product_serializer.rb
469
+ - app/serializers/dscf/banking/voucher_redemption_serializer.rb
470
+ - app/serializers/dscf/banking/voucher_serializer.rb
480
471
  - app/services/dscf/banking/account_creation_service.rb
481
472
  - app/services/dscf/banking/base_transaction_service.rb
482
473
  - app/services/dscf/banking/deposit_service.rb
483
474
  - app/services/dscf/banking/transfer_service.rb
475
+ - app/services/dscf/banking/voucher_service.rb
484
476
  - app/services/dscf/banking/withdrawal_service.rb
485
- - config/initializers/review_callbacks.rb
486
- - config/initializers/review_extensions.rb
487
477
  - config/locales/en.yml
488
478
  - config/routes.rb
489
479
  - db/migrate/20250830211002_create_dscf_banking_product_categories.rb
@@ -501,6 +491,8 @@ files:
501
491
  - db/migrate/20250919084927_make_account_associations_optional.rb
502
492
  - db/migrate/20250919182831_create_dscf_banking_transaction_types.rb
503
493
  - db/migrate/20250919184220_create_dscf_banking_transactions.rb
494
+ - db/migrate/20260210120000_create_dscf_banking_vouchers.rb
495
+ - db/migrate/20260210120010_create_dscf_banking_voucher_redemptions.rb
504
496
  - db/seeds.rb
505
497
  - lib/dscf/banking.rb
506
498
  - lib/dscf/banking/engine.rb
@@ -518,6 +510,8 @@ files:
518
510
  - spec/factories/dscf/banking/transaction_types.rb
519
511
  - spec/factories/dscf/banking/transactions.rb
520
512
  - spec/factories/dscf/banking/virtual_account_products.rb
513
+ - spec/factories/dscf/banking/voucher_redemptions.rb
514
+ - spec/factories/dscf/banking/vouchers.rb
521
515
  homepage: https://mksaddis.com/
522
516
  licenses: []
523
517
  metadata:
@@ -1,23 +0,0 @@
1
- Rails.application.config.to_prepare do
2
- ActiveSupport::Notifications.subscribe("review.created") do |name, start, finish, id, payload|
3
- reviewable = payload[:reviewable]
4
-
5
- if reviewable.is_a?(Dscf::Banking::Application)
6
- reviewable.reload
7
- review_status = reviewable.review_status
8
-
9
- status_map = {
10
- "submitted" => "submitted",
11
- "under_review" => "under_review",
12
- "approved" => "approved",
13
- "rejected" => "rejected",
14
- "request_modification" => "request_modification"
15
- }
16
-
17
- mapped_status = status_map[review_status]
18
- if mapped_status && reviewable.status != mapped_status
19
- reviewable.update!(status: mapped_status)
20
- end
21
- end
22
- end
23
- end
@@ -1,36 +0,0 @@
1
- module Dscf::Banking
2
- module ReviewExtensions
3
- extend ActiveSupport::Concern
4
-
5
- included do
6
- after_create :sync_application_status, if: -> { reviewable.is_a?(Dscf::Banking::Application) }
7
- end
8
-
9
- private
10
-
11
- def sync_application_status
12
- return unless reviewable.is_a?(Dscf::Banking::Application)
13
-
14
- application = reviewable
15
- review_status = application.review_status
16
-
17
- status_map = {
18
- "submitted" => "submitted",
19
- "under_review" => "under_review",
20
- "approved" => "approved",
21
- "rejected" => "rejected",
22
- "request_modification" => "request_modification"
23
- }
24
-
25
- mapped_status = status_map[review_status]
26
- if mapped_status && application.status != mapped_status
27
- application.update!(status: mapped_status)
28
- end
29
- end
30
- end
31
- end
32
-
33
- # Extend the Dscf::Core::Review model with our extensions
34
- Rails.application.config.to_prepare do
35
- Dscf::Core::Review.include(Dscf::Banking::ReviewExtensions) if defined?(Dscf::Core::Review)
36
- end