dscf-credit 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,77 +0,0 @@
1
- module Dscf
2
- module Core
3
- module JsonResponse
4
- extend ActiveSupport::Concern
5
-
6
- def serialize(data, options = {})
7
- case data
8
- when ActiveRecord::Base, ActiveRecord::Relation
9
- ActiveModelSerializers::SerializableResource.new(data, options)
10
- when Hash
11
- # Handle hash data with potential serializer options
12
- serialized_hash = {}
13
- data.each do |key, value|
14
- if options[key] && value.is_a?(ActiveRecord::Base)
15
- # Use specific serializer for this key if provided
16
- serializer_opts = options[key].is_a?(Hash) ? options[key] : {}
17
- serialized_hash[key] = ActiveModelSerializers::SerializableResource.new(value, serializer_opts)
18
- else
19
- serialized_hash[key] = serialize(value, options)
20
- end
21
- end
22
- serialized_hash
23
- when Array
24
- data.map { |value| serialize(value, options) }
25
- else
26
- data
27
- end
28
- end
29
-
30
- def render_success(message_key = nil, data: nil, status: :ok, serializer_options: {}, **options)
31
- response = {success: true}
32
-
33
- if message_key
34
- response[:message] = I18n.t(message_key)
35
- else
36
- model_key = @clazz&.name&.demodulize&.underscore
37
- action_key = action_name
38
- i18n_key = "#{model_key}.success.#{action_key}"
39
-
40
- response[:message] = I18n.t(i18n_key) if I18n.exists?(i18n_key)
41
- end
42
-
43
- if data
44
- serialized_data = serialize(data, serializer_options)
45
- response[:data] = serialized_data
46
- end
47
-
48
- # Add pagination metadata if present (handled by Common concern)
49
- response[:pagination] = serializer_options[:pagination] if serializer_options[:pagination]
50
-
51
- response.merge!(options)
52
- render json: response, status: status
53
- end
54
-
55
- def render_error(message_key = nil, status: :unprocessable_entity, errors: nil, **options)
56
- response = {
57
- success: false
58
- }
59
-
60
- if message_key
61
- response[:error] = I18n.t(message_key)
62
- else
63
- model_key = @clazz&.name&.demodulize&.underscore
64
- action_key = action_name
65
- i18n_key = "#{model_key}.errors.#{action_key}"
66
-
67
- response[:error] = I18n.t(i18n_key) if I18n.exists?(i18n_key)
68
- end
69
-
70
- response[:errors] = errors if errors
71
- response.merge!(options)
72
-
73
- render json: response, status: status
74
- end
75
- end
76
- end
77
- end
@@ -1,71 +0,0 @@
1
- module Dscf
2
- module Core
3
- module Pagination
4
- extend ActiveSupport::Concern
5
-
6
- def default_per_page
7
- 10
8
- end
9
-
10
- def page_no
11
- params[:page]&.to_i || 1
12
- end
13
-
14
- def per_page
15
- params[:per_page]&.to_i || default_per_page
16
- end
17
-
18
- def paginate_offset
19
- (page_no - 1) * per_page
20
- end
21
-
22
- def order_by
23
- allowed_columns = if respond_to?(:allowed_order_columns, true)
24
- allowed_order_columns
25
- else
26
- %w[id created_at updated_at]
27
- end
28
- requested_column = params.fetch(:order_by, "id").to_s
29
- allowed_columns.include?(requested_column) ? requested_column : "id"
30
- end
31
-
32
- def order_direction
33
- if %w[asc desc].include?(params.fetch(:order_direction, "asc").to_s.downcase)
34
- params.fetch(:order_direction, "asc").to_s.downcase
35
- else
36
- "asc"
37
- end
38
- end
39
-
40
- def paginate
41
- ->(it) { it.limit(per_page).offset(paginate_offset).order("#{order_by}": order_direction) }
42
- end
43
-
44
- # Generate HATEOAS pagination links
45
- def pagination_links(total_pages)
46
- base_path = request.path
47
- current_params = request.query_parameters.except("page")
48
-
49
- links = {}
50
-
51
- links[:first] = build_page_url(base_path, current_params, 1)
52
-
53
- links[:prev] = (build_page_url(base_path, current_params, page_no - 1) if page_no > 1)
54
-
55
- links[:next] = (build_page_url(base_path, current_params, page_no + 1) if page_no < total_pages)
56
-
57
- links[:last] = build_page_url(base_path, current_params, total_pages)
58
-
59
- links
60
- end
61
-
62
- private
63
-
64
- def build_page_url(base_path, params, page)
65
- params_with_page = params.merge(page: page, per_page: per_page)
66
- query_string = params_with_page.to_query
67
- "#{base_path}?#{query_string}"
68
- end
69
- end
70
- end
71
- end
@@ -1,347 +0,0 @@
1
- module Dscf
2
- module Core
3
- module ReviewableController
4
- extend ActiveSupport::Concern
5
- include Dscf::Core::JsonResponse
6
-
7
- included do
8
- class_attribute :review_context_configs, default: {}
9
- class_attribute :reviewable_model_class, default: nil
10
-
11
- # Initialize with default context config for zero-config support
12
- self.review_context_configs = default_context_config.dup
13
-
14
- # Auto-define default actions for zero-config
15
- define_default_review_actions
16
-
17
- before_action :set_reviewable_resource, if: :review_action?
18
- before_action :authorize_review_action!, if: :review_action?
19
- end
20
-
21
- class_methods do
22
- def reviewable_model(model_class)
23
- self.reviewable_model_class = model_class
24
- end
25
-
26
- def reviewable_context(context_name, options = {})
27
- statuses = options[:statuses]&.map(&:to_s) || %w[pending approved rejected modify]
28
- initial_status = options[:initial_status]&.to_s || "pending"
29
-
30
- # Convert linear transitions array to hash if provided
31
- transitions = if options[:transitions].is_a?(Array)
32
- validate_linear_transitions(options[:transitions], statuses, context_name)
33
- build_linear_transitions(options[:transitions])
34
- else
35
- options[:transitions] || {}
36
- end
37
-
38
- defaults = {
39
- statuses: statuses,
40
- initial_status: initial_status,
41
- transitions: transitions,
42
- actions: options[:actions] || default_actions
43
- }
44
-
45
- validate_action_config(defaults[:actions], defaults[:statuses], context_name)
46
- unless defaults[:statuses].include?(defaults[:initial_status])
47
- raise ArgumentError,
48
- "Initial status '#{defaults[:initial_status]}' in context '#{context_name}' must be one of: #{defaults[:statuses].join(', ')}"
49
- end
50
-
51
- # Add to existing configs instead of replacing them
52
- self.review_context_configs = review_context_configs.merge(context_name.to_sym => defaults)
53
-
54
- # Define methods for all actions in this context
55
- defaults[:actions].each_key do |action_name|
56
- # Only define if not already defined
57
- define_review_action_method(action_name) unless method_defined?(action_name)
58
- end
59
- end
60
-
61
- def define_default_review_actions
62
- default_context_config.each do |context_name, config|
63
- validate_action_config(config[:actions], config[:statuses], context_name)
64
- config[:actions].each_key do |action_name|
65
- define_review_action_method(action_name)
66
- end
67
- end
68
- end
69
-
70
- def define_review_action_method(action_name)
71
- define_method(action_name) do
72
- context = current_review_context
73
- action_config = context_config[:actions][action_name.to_sym]
74
- unless action_config
75
- return render_error(
76
- errors: [ "Action '#{action_name}' not defined for context '#{context}'" ],
77
- status: :bad_request
78
- )
79
- end
80
- perform_review_action(**action_config.slice(:status, :require_feedback, :after, :update_model))
81
- end
82
- end
83
-
84
- def default_actions
85
- {
86
- approve: { status: "approved" },
87
- reject: { status: "rejected", require_feedback: true },
88
- request_modification: { status: "modify", require_feedback: true },
89
- resubmit: { status: "pending", update_model: true }
90
- }
91
- end
92
-
93
- def default_context_config
94
- {
95
- default: {
96
- statuses: %w[pending approved rejected modify],
97
- initial_status: "pending",
98
- transitions: { "pending" => %w[approved rejected modify], "modify" => [ "pending" ] },
99
- actions: default_actions
100
- }
101
- }
102
- end
103
-
104
- def validate_action_config(actions, statuses, context_name)
105
- actions.each do |action_name, opts|
106
- unless statuses.include?(opts[:status])
107
- raise ArgumentError,
108
- "Action '#{action_name}' in context '#{context_name}' maps to invalid status '#{opts[:status]}'. Must be one of: #{statuses.join(', ')}"
109
- end
110
- end
111
- end
112
-
113
- def validate_linear_transitions(transitions, statuses, context_name)
114
- transitions.each do |status|
115
- unless statuses.include?(status.to_s)
116
- raise ArgumentError, "Transition status '#{status}' in context '#{context_name}' must be one of: #{statuses.join(', ')}"
117
- end
118
- end
119
- end
120
-
121
- def build_linear_transitions(transitions)
122
- transitions.each_with_object({}).with_index do |(status, hash), index|
123
- next_status = transitions[index + 1]
124
- hash[status.to_s] = next_status ? [ next_status.to_s ] : []
125
- end
126
- end
127
-
128
- def review_action_names
129
- # Get actions from initialized contexts + default fallback
130
- all_actions = review_context_configs.values.flat_map { |config| config[:actions].keys }
131
- all_actions += default_context_config[:default][:actions].keys
132
- all_actions.uniq
133
- end
134
- end
135
-
136
- private
137
-
138
- def review_action?
139
- self.class.review_action_names.include?(action_name.to_sym)
140
- end
141
-
142
- def perform_review_action(status:, require_feedback: false, after: nil, update_model: false)
143
- # Validate context exists before proceeding
144
- begin
145
- context_config
146
- rescue ArgumentError => e
147
- return render_error(errors: [ e.message ], status: :bad_request)
148
- end
149
-
150
- current_review = reviewable_resource.current_review_for(current_review_context)
151
-
152
- feedback = if require_feedback
153
- feedback_data = params[:review_feedback].presence || params.dig(:review_feedback)&.to_unsafe_h
154
- return render_error(errors: [ "Feedback is required for #{status}" ]) if feedback_data.blank?
155
-
156
- feedback_data
157
- else
158
- nil
159
- end
160
-
161
- unless valid_transition?(current_review&.status, status)
162
- return render_error(errors: [ "Cannot transition from '#{current_review&.status || context_config[:initial_status]}' to '#{status}'" ])
163
- end
164
-
165
- begin
166
- ActiveRecord::Base.transaction do
167
- # Update the model if required (e.g., for resubmit with changes)
168
- if update_model
169
- begin
170
- model_updates = model_params
171
- if model_updates.present? && !reviewable_resource.update(model_updates)
172
- return render_error(errors: reviewable_resource.errors.full_messages)
173
- end
174
- rescue ActionController::ParameterMissing => e
175
- return render_error(errors: [ e.message ])
176
- end
177
- end
178
-
179
- # Update existing review or create new one if none exists
180
- review = current_review || reviewable_resource.build_review_for(current_review_context)
181
- review.assign_attributes(
182
- status: status,
183
- feedback: feedback,
184
- reviewed_by: current_user,
185
- reviewed_at: Time.current
186
- )
187
-
188
- before_review_action(review)
189
- review.save!
190
- execute_after_callbacks(review, after)
191
- after_review_action(review)
192
- end
193
- render_success(data: reviewable_resource.reload)
194
- rescue StandardError => e
195
- render_error(errors: [ e.message ], status: :unprocessable_entity)
196
- end
197
- end
198
-
199
- def execute_after_callbacks(review, after_callbacks = nil)
200
- # Get action callbacks from the current action config or passed callbacks
201
- action_config = context_config[:actions][action_name.to_sym]
202
- action_callbacks = after_callbacks || action_config&.[](:after)
203
- return unless action_callbacks
204
-
205
- Array(action_callbacks).each do |callback|
206
- if callback.is_a?(Symbol)
207
- send(callback, review)
208
- elsif callback.respond_to?(:call)
209
- callback.call(review)
210
- else
211
- raise "Invalid callback: #{callback.inspect}"
212
- end
213
- rescue StandardError => e
214
- Rails.logger.error "Callback failed: #{callback.inspect}, error: #{e.message}"
215
- raise
216
- end
217
- end
218
-
219
- def current_review_context
220
- params[:context]&.to_sym || :default
221
- end
222
-
223
- def available_contexts
224
- # Get all configured contexts plus default if not explicitly configured
225
- contexts = self.class.review_context_configs.keys
226
- contexts << :default unless contexts.include?(:default)
227
- contexts
228
- end
229
-
230
- def context_config
231
- context = current_review_context
232
- config = self.class.review_context_configs[context]
233
-
234
- # If requesting default context and it's not configured, use the built-in default
235
- config = self.class.default_context_config[:default] if context == :default && config.nil?
236
-
237
- # If context is not found and it's not the default, it's invalid
238
- raise ArgumentError, "Invalid context '#{context}'. Available contexts: #{available_contexts.join(', ')}" if config.nil?
239
-
240
- Rails.logger.debug "Context config for '#{context}': #{config.inspect}"
241
- config
242
- end
243
-
244
- def allowed_statuses
245
- context_config[:statuses]
246
- end
247
-
248
- def valid_transition?(from, to)
249
- return true if context_config[:transitions].blank?
250
-
251
- allowed_to = context_config[:transitions][from || context_config[:initial_status]] || []
252
- allowed_to.include?(to)
253
- end
254
-
255
- def authorize_review_action!
256
- # Override for custom auth
257
- end
258
-
259
- def before_review_action(review)
260
- # Default implementation - can be overridden
261
- # Check if there's a custom hook method defined
262
- return unless respond_to?(:before_reviewable_action, true)
263
-
264
- before_reviewable_action(review, reviewable_resource, current_review_context)
265
-
266
- # Custom pre-save logic override point
267
- end
268
-
269
- def after_review_action(review)
270
- # New hook for post-action logic
271
- return unless respond_to?(:after_reviewable_action, true)
272
-
273
- after_reviewable_action(review, reviewable_resource, current_review_context)
274
- end
275
-
276
- def current_user
277
- # Default implementation that can be overridden
278
- # Check if there's a custom user method defined
279
- if respond_to?(:current_reviewer, true)
280
- current_reviewer
281
- else
282
- # Default fallback - can be overridden in controllers
283
- # Try common patterns or return nil if none exist
284
- return super if defined?(super)
285
- return @current_user if defined?(@current_user)
286
- return session[:user_id] if session && session[:user_id]
287
-
288
- nil
289
- end
290
- end
291
-
292
- def model_params
293
- # Default implementation that can be overridden
294
- # Try different common parameter patterns
295
- resource_name = controller_name.singularize
296
-
297
- # First try the standard Rails pattern: resource_name_params
298
- params_method = "#{resource_name}_params"
299
- return send(params_method) if respond_to?(params_method, true)
300
-
301
- # Fallback to generic reviewable_params
302
- return reviewable_params if respond_to?(:reviewable_params, true)
303
-
304
- # If neither exists, return empty hash (no model updates)
305
- {}
306
- rescue ActionController::ParameterMissing => e
307
- raise e # Re-raise parameter missing errors
308
- rescue StandardError => e
309
- Rails.logger.warn "Failed to get model params: #{e.message}"
310
- {}
311
- end
312
-
313
- def model_class
314
- return reviewable_model_class if reviewable_model_class
315
-
316
- model_name = controller_name.classify
317
-
318
- controller_namespace = self.class.name.deconstantize
319
- if controller_namespace.present?
320
- namespaced_model = "#{controller_namespace}::#{model_name}"
321
- return namespaced_model.constantize if Object.const_defined?(namespaced_model)
322
- end
323
-
324
- return model_name.constantize if Object.const_defined?(model_name)
325
-
326
- raise NameError, "Could not determine model class for #{self.class.name}. " \
327
- "Please configure it explicitly using 'reviewable_model ModelClass' " \
328
- "in your controller class."
329
- rescue NameError => e
330
- Rails.logger.error "Model resolution failed for #{self.class.name}: #{e.message}"
331
- raise
332
- end
333
-
334
- def set_reviewable_resource
335
- @reviewable_resource = model_class.find(params[:id])
336
- rescue ActiveRecord::RecordNotFound => e
337
- render_error(status: :not_found, errors: [ e.message ])
338
- rescue NameError => e
339
- render_error(status: :internal_server_error, errors: [ "Configuration error: #{e.message}" ])
340
- end
341
-
342
- def reviewable_resource
343
- @reviewable_resource
344
- end
345
- end
346
- end
347
- end
@@ -1,53 +0,0 @@
1
- module Dscf
2
- module Core
3
- module TokenAuthenticatable
4
- extend ActiveSupport::Concern
5
-
6
- included do
7
- before_action :validate_token_expiry
8
- before_action :validate_device_consistency, if: :current_user
9
- end
10
-
11
- def validate_token_expiry
12
- return unless current_user
13
-
14
- access_token = extract_access_token_from_header
15
- return unless access_token
16
-
17
- payload = TokenService.decode(access_token)
18
- return unless payload
19
-
20
- # Check if token is close to expiry (within 5 minutes)
21
- if payload["exp"] && payload["exp"] - Time.current.to_i < 300
22
- Rails.logger.info("Access token close to expiry for user #{current_user.id}")
23
- end
24
- rescue AuthenticationError
25
- handle_expired_token
26
- end
27
-
28
- def validate_device_consistency
29
- return unless current_user && request.params[:device_id]
30
-
31
- refresh_token_record = current_user.refresh_tokens.active.find_by(device: request.params[:device_id])
32
- return if refresh_token_record
33
-
34
- # Device mismatch - could indicate suspicious activity
35
- Rails.logger.warn("Device mismatch for user #{current_user.id}: #{request.params[:device_id]}")
36
- end
37
-
38
- def require_valid_refresh_token
39
- refresh_token_value = extract_refresh_token_from_params
40
- return if refresh_token_value && RefreshToken.active.exists?(refresh_token: refresh_token_value)
41
-
42
- raise AuthenticationError, "Valid refresh token required"
43
- end
44
-
45
- private
46
-
47
- def handle_expired_token
48
- Rails.logger.warn("Expired token detected for request to #{request.path}")
49
- raise AuthenticationError, "Session expired"
50
- end
51
- end
52
- end
53
- end
@@ -1,78 +0,0 @@
1
- module Dscf::Credit
2
- # Background job to generate daily loan accruals
3
- #
4
- # This job should be scheduled to run daily (typically at midnight or early morning)
5
- # to generate interest and penalty accruals for all active loans.
6
- #
7
- # @example Schedule with Sidekiq (config/sidekiq.yml)
8
- # :schedule:
9
- # generate_daily_accruals:
10
- # cron: '0 1 * * *' # Run at 1 AM daily
11
- # class: Dscf::Credit::GenerateDailyAccrualsJob
12
- #
13
- # @example Schedule with whenever gem (config/schedule.rb)
14
- # every 1.day, at: '1:00 am' do
15
- # runner "Dscf::Credit::GenerateDailyAccrualsJob.perform_later"
16
- # end
17
- #
18
- # @example Manual execution
19
- # Dscf::Credit::GenerateDailyAccrualsJob.perform_now
20
- #
21
- # @example With specific parameters
22
- # Dscf::Credit::GenerateDailyAccrualsJob.perform_later(
23
- # loan_ids: [1, 2, 3],
24
- # accrual_date: Date.yesterday
25
- # )
26
- class GenerateDailyAccrualsJob < ApplicationJob
27
- queue_as :default
28
-
29
- # Perform the job to generate daily accruals
30
- #
31
- # @param loan_ids [Array<Integer>, nil] Optional specific loan IDs to process
32
- # @param accrual_date [String, Date] Date for accrual generation (defaults to today)
33
- # @param force_regenerate [Boolean] Whether to regenerate existing accruals
34
- def perform(loan_ids: nil, accrual_date: nil, force_regenerate: false)
35
- date = accrual_date ? Date.parse(accrual_date.to_s) : Date.current
36
-
37
- service = LoanAccrualGeneratorService.new(
38
- loan_ids: loan_ids,
39
- accrual_date: date,
40
- force_regenerate: force_regenerate
41
- )
42
-
43
- result = service.generate_daily_accruals
44
-
45
- log_result(result)
46
-
47
- result
48
- end
49
-
50
- private
51
-
52
- # Log the result of accrual generation
53
- #
54
- # @param result [Hash] The result from the service
55
- def log_result(result)
56
- if result[:success]
57
- Rails.logger.info(
58
- "Loan Accrual Generation: #{result[:message]}"
59
- )
60
-
61
- if result[:errors].any?
62
- Rails.logger.warn(
63
- "Loan Accrual Generation Warnings: #{result[:errors].size} loans had errors"
64
- )
65
- result[:errors].each do |error|
66
- Rails.logger.warn(
67
- "Loan #{error[:loan_id]}: #{error[:error]}"
68
- )
69
- end
70
- end
71
- else
72
- Rails.logger.error(
73
- "Loan Accrual Generation Failed: #{result[:message]}"
74
- )
75
- end
76
- end
77
- end
78
- end
@@ -1,31 +0,0 @@
1
- module Dscf
2
- module Core
3
- module ReviewableModel
4
- extend ActiveSupport::Concern
5
-
6
- included do
7
- has_many :reviews, as: :reviewable, class_name: "Dscf::Core::Review", dependent: :destroy
8
- end
9
-
10
- def review_for(context = :default)
11
- reviews.with_context(context).order(created_at: :desc)
12
- end
13
-
14
- def current_review_for(context = :default)
15
- review_for(context).first
16
- end
17
-
18
- def current_status_for(context = :default)
19
- current_review = current_review_for(context)
20
- return current_review.status if current_review
21
-
22
- # Return default initial status for zero-config
23
- "pending"
24
- end
25
-
26
- def build_review_for(context = :default)
27
- reviews.build(context: context.to_s)
28
- end
29
- end
30
- end
31
- end