hr_lite 0.8.0 → 0.11.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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +149 -1
  3. data/app/controllers/hr_lite/approvals_controller.rb +31 -0
  4. data/app/helpers/hr_lite/application_helper.rb +21 -0
  5. data/app/jobs/hr_lite/approval_escalation_job.rb +39 -0
  6. data/app/jobs/hr_lite/document_expiry_job.rb +28 -0
  7. data/app/models/concerns/hr_lite/approvable.rb +48 -0
  8. data/app/models/hr_lite/approval.rb +46 -0
  9. data/app/models/hr_lite/approval_delegation.rb +38 -0
  10. data/app/models/hr_lite/approval_flow.rb +39 -0
  11. data/app/models/hr_lite/approval_step.rb +62 -0
  12. data/app/models/hr_lite/document.rb +95 -0
  13. data/app/models/hr_lite/leave_request.rb +72 -17
  14. data/app/models/hr_lite/loan.rb +65 -0
  15. data/app/models/hr_lite/loan_repayment.rb +14 -0
  16. data/app/models/hr_lite/payroll_line_item.rb +57 -0
  17. data/app/models/hr_lite/payroll_run.rb +34 -0
  18. data/app/models/hr_lite/salary_component.rb +59 -0
  19. data/app/models/hr_lite/tax_declaration.rb +93 -0
  20. data/app/models/hr_lite/tax_declaration_item.rb +32 -0
  21. data/app/services/hr_lite/approval_route.rb +109 -0
  22. data/app/services/hr_lite/slip_builder.rb +76 -3
  23. data/app/views/hr_lite/approvals/index.html.erb +66 -0
  24. data/config/routes.rb +1 -0
  25. data/db/migrate/20260818001151_create_hr_lite_approvals.rb +79 -0
  26. data/db/migrate/20260818002627_create_hr_lite_payroll_components_and_loans.rb +72 -0
  27. data/db/migrate/20260818003616_create_hr_lite_documents_and_declarations.rb +73 -0
  28. data/lib/hr_lite/notifications.rb +10 -0
  29. data/lib/hr_lite/permissions.rb +4 -0
  30. data/lib/hr_lite/role_seeds.rb +6 -3
  31. data/lib/hr_lite/version.rb +1 -1
  32. data/lib/tasks/hr_lite_tasks.rake +2 -1
  33. metadata +21 -1
@@ -1,5 +1,7 @@
1
1
  module HrLite
2
2
  class LeaveRequest < ApplicationRecord
3
+ include Approvable
4
+
3
5
  STATUSES = %w[pending approved rejected cancelled].freeze
4
6
 
5
7
  belongs_to :user, class_name: HrLite.config.user_class
@@ -38,30 +40,23 @@ module HrLite
38
40
 
39
41
  # --- transitions -------------------------------------------------------
40
42
 
43
+ # With a flow configured this is ONE RUNG: the request becomes `approved`
44
+ # only once the last rung is satisfied. With no flow — or from somebody
45
+ # the flow is not waiting on, such as HR overriding — the first approval
46
+ # settles it, exactly as before.
47
+ #
41
48
  # Returns false (leaving the request pending) when the balance no longer
42
- # covers it the re-check runs inside the row lock so two concurrent
49
+ # covers it; the re-check runs inside the row lock so two concurrent
43
50
  # approvals cannot overdraw one balance.
44
51
  def approve!(actor:, note: nil)
45
- insufficient = false
46
- transition!("approved", actor, note) do
47
- # Serialize on the BALANCE row. `transition!`'s own lock is on this
48
- # request, and two pending requests are two different rows — so both
49
- # approvals could read the same untouched balance and overdraw it.
50
- LeaveBalance.lock_for(user, leave_type, LeaveYear.key_for(start_date)) unless leave_type.unlimited?
51
-
52
- if insufficient_balance_now?
53
- insufficient = true
54
- raise ActiveRecord::Rollback
55
- end
56
- end
57
- return false if insufficient
52
+ return record_routed_decision!(actor, note, :approved) if awaiting?(actor)
58
53
 
59
- notify_decision("Leave approved")
60
- notify_team
61
- true
54
+ approve_outright!(actor: actor, note: note)
62
55
  end
63
56
 
64
57
  def reject!(actor:, note:)
58
+ return record_routed_decision!(actor, note, :rejected) if awaiting?(actor)
59
+
65
60
  transition!("rejected", actor, note)
66
61
  notify_decision("Leave rejected")
67
62
  true
@@ -86,6 +81,9 @@ module HrLite
86
81
  # Cancelling an APPROVED leave hands the days back to the balance, so
87
82
  # it is a quota change as much as a status change.
88
83
  audit!("cancelled", actor, was_approved ? "was approved" : nil)
84
+ # And nobody should still be asked to decide a request that has been
85
+ # called off — it would sit in their inbox for ever.
86
+ approval_route.cancel_all!
89
87
  end
90
88
 
91
89
  Notifications.publish(
@@ -112,6 +110,63 @@ module HrLite
112
110
 
113
111
  private
114
112
 
113
+ # One rung answered. The route says whether that settles the request; if
114
+ # it does, the ordinary transition runs and does the real work — crediting
115
+ # or releasing balance, notifying, auditing — so routing never becomes a
116
+ # second path that can drift from the first.
117
+ def record_routed_decision!(actor, note, intent)
118
+ approval = approval_for(actor)
119
+ result = approval_route.decide!(approval, status: intent.to_s, actor: actor, note: note)
120
+
121
+ case result.outcome
122
+ when :approved then approve_outright!(actor: actor, note: note)
123
+ when :rejected then reject_outright!(actor: actor, note: note.presence || "Rejected")
124
+ else
125
+ notify_pending_approvers
126
+ true
127
+ end
128
+ end
129
+
130
+ def approve_outright!(actor:, note:)
131
+ insufficient = false
132
+ transition!("approved", actor, note) do
133
+ # Serialize on the BALANCE row. `transition!`'s own lock is on this
134
+ # request, and two pending requests are two different rows — so both
135
+ # approvals could read the same untouched balance and overdraw it.
136
+ LeaveBalance.lock_for(user, leave_type, LeaveYear.key_for(start_date)) unless leave_type.unlimited?
137
+
138
+ if insufficient_balance_now?
139
+ insufficient = true
140
+ raise ActiveRecord::Rollback
141
+ end
142
+ end
143
+ return false if insufficient
144
+
145
+ notify_decision("Leave approved")
146
+ notify_team
147
+ true
148
+ end
149
+
150
+ def reject_outright!(actor:, note:)
151
+ transition!("rejected", actor, note)
152
+ notify_decision("Leave rejected")
153
+ true
154
+ end
155
+
156
+ # The next rung has just opened; tell the people it opened on.
157
+ def notify_pending_approvers
158
+ approvers = pending_approvals.map(&:approver).compact.uniq
159
+ return if approvers.empty?
160
+
161
+ Notifications.publish(
162
+ "leave.requested",
163
+ title: "#{HrLite.display_name(user)} — #{leave_type.name} (#{date_range_label}) needs your approval",
164
+ body: reason.presence,
165
+ path: "/admin/leave_requests/#{id}",
166
+ bell_to: approvers
167
+ )
168
+ end
169
+
115
170
  def transition!(new_status, actor, note)
116
171
  with_lock do
117
172
  raise ActiveRecord::RecordInvalid.new(self), "not pending" unless pending?
@@ -0,0 +1,65 @@
1
+ module HrLite
2
+ # A salary advance or loan, repaid by a fixed monthly deduction.
3
+ #
4
+ # The outstanding balance is DERIVED from the repayments actually taken,
5
+ # never stored. A stored balance and a recomputed payroll run disagree the
6
+ # first time somebody deletes a draft.
7
+ class Loan < ApplicationRecord
8
+ include EncryptedMoney
9
+ include Audited
10
+
11
+ STATUSES = %w[active closed cancelled].freeze
12
+
13
+ encrypted_money :principal, :monthly_instalment
14
+
15
+ belongs_to :user, class_name: HrLite.config.user_class
16
+ belongs_to :approved_by, class_name: HrLite.config.user_class, optional: true
17
+ has_many :loan_repayments, class_name: "HrLite::LoanRepayment", dependent: :destroy
18
+
19
+ validates :status, inclusion: { in: STATUSES }
20
+ validates :starts_on, presence: true
21
+ validate :amounts_are_positive
22
+ validate :instalment_is_not_larger_than_the_loan
23
+
24
+ scope :active, -> { where(status: "active") }
25
+
26
+ STATUSES.each { |s| define_method("#{s}?") { status == s } }
27
+
28
+ def repaid = loan_repayments.sum(BigDecimal(0)) { |r| r.amount || BigDecimal(0) }
29
+
30
+ def outstanding = [ principal - repaid, BigDecimal(0) ].max
31
+
32
+ # What to deduct this month: the instalment, or whatever is left if that
33
+ # is less — the last instalment is almost never a round one.
34
+ def instalment_for(period_month)
35
+ return BigDecimal(0) unless active?
36
+ return BigDecimal(0) if period_month < starts_on.beginning_of_month
37
+ return BigDecimal(0) if loan_repayments.exists?(period_month: period_month)
38
+
39
+ [ monthly_instalment, outstanding ].min
40
+ end
41
+
42
+ # Called after a run is FINALIZED, not at compute: a draft is recomputed
43
+ # freely and each pass would otherwise book another repayment.
44
+ def record_repayment!(period_month, amount)
45
+ return if amount.nil? || amount <= 0
46
+
47
+ loan_repayments.create!(period_month: period_month, amount: amount)
48
+ update!(status: "closed") if outstanding.zero?
49
+ end
50
+
51
+ private
52
+
53
+ def amounts_are_positive
54
+ errors.add(:principal, "must be more than zero") unless principal&.positive?
55
+ errors.add(:monthly_instalment, "must be more than zero") unless monthly_instalment&.positive?
56
+ end
57
+
58
+ def instalment_is_not_larger_than_the_loan
59
+ return if principal.nil? || monthly_instalment.nil?
60
+ return if monthly_instalment <= principal
61
+
62
+ errors.add(:monthly_instalment, "cannot be more than the loan itself")
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,14 @@
1
+ module HrLite
2
+ # One month's deduction actually taken. Written when the run is finalized,
3
+ # so recomputing a draft never books a repayment twice — and the unique
4
+ # index on [loan, month] means a retry cannot either.
5
+ class LoanRepayment < ApplicationRecord
6
+ include EncryptedMoney
7
+
8
+ encrypted_money :amount
9
+
10
+ belongs_to :loan, class_name: "HrLite::Loan"
11
+
12
+ validates :period_month, presence: true, uniqueness: { scope: :loan_id }
13
+ end
14
+ end
@@ -0,0 +1,57 @@
1
+ module HrLite
2
+ # A one-off on one month's payslip: a bonus, an incentive, arrears after a
3
+ # backdated revision, a reimbursement, a one-time deduction.
4
+ #
5
+ # Dated to the MONTH, not to the run, so deleting a draft run and computing
6
+ # it again does not lose them.
7
+ class PayrollLineItem < ApplicationRecord
8
+ include EncryptedMoney
9
+ include Audited
10
+
11
+ encrypted_money :amount
12
+
13
+ belongs_to :component, class_name: "HrLite::SalaryComponent"
14
+ belongs_to :user, class_name: HrLite.config.user_class
15
+ belongs_to :created_by, class_name: HrLite.config.user_class, optional: true
16
+
17
+ validates :period_month, presence: true
18
+ validate :period_is_first_of_month
19
+ validate :amount_is_positive
20
+ validate :period_is_not_already_paid, on: :create
21
+
22
+ scope :for_month, ->(month) { where(period_month: month) }
23
+
24
+ def self.for_slip(user, period_month)
25
+ where(user_id: user.id, period_month: period_month).includes(:component)
26
+ end
27
+
28
+ private
29
+
30
+ def period_is_first_of_month
31
+ return if period_month.nil? || period_month.day == 1
32
+
33
+ errors.add(:period_month, "must be the 1st of a month")
34
+ end
35
+
36
+ # Amounts are signed by the component's KIND, not by the number: a
37
+ # deduction of -500 and a deduction of 500 would otherwise both be
38
+ # storable and mean opposite things.
39
+ def amount_is_positive
40
+ return if amount.nil? || amount.positive?
41
+
42
+ errors.add(:amount, "must be more than zero — a deduction is a deduction by its component, not by a minus sign")
43
+ end
44
+
45
+ # A published slip is immutable. Adding a line to that month afterwards
46
+ # would show on no payslip and quietly change the year-to-date totals the
47
+ # TDS projector reads.
48
+ def period_is_not_already_paid
49
+ return if user_id.nil? || period_month.nil?
50
+
51
+ settled = SalarySlip.settled.exists?(user_id: user_id, period_month: period_month)
52
+ return unless settled
53
+
54
+ errors.add(:period_month, "has already been paid — put this on the next open month")
55
+ end
56
+ end
57
+ end
@@ -64,6 +64,7 @@ module HrLite
64
64
  # Finalizing freezes every slip in the run. Who did it, to how many
65
65
  # people, is the row an investigation starts from.
66
66
  audit!("payroll.finalized", actor, "slips" => salary_slips.count)
67
+ book_loan_repayments!
67
68
  end
68
69
  Notifications.publish(
69
70
  "payroll.finalized",
@@ -77,6 +78,10 @@ module HrLite
77
78
  raise_unless %w[finalized]
78
79
  transaction do
79
80
  update!(status: "review")
81
+ # The slips are editable again, so the repayments booked at finalize
82
+ # have to come back off the loans — otherwise unlocking and
83
+ # refinalizing takes the instalment twice.
84
+ unbook_loan_repayments!
80
85
  # Reopening frozen slips for editing is the single most sensitive
81
86
  # transition in the module — it is the one that has to leave a trace.
82
87
  audit!("payroll.unlocked", actor, "slips" => salary_slips.count)
@@ -124,6 +129,35 @@ module HrLite
124
129
 
125
130
  private
126
131
 
132
+ # A repayment is booked when the run is FINALIZED, never at compute: a
133
+ # draft is recomputed as often as the operator likes, and each pass would
134
+ # otherwise take another instalment off the loan.
135
+ def book_loan_repayments!
136
+ salary_slips.includes(:user).find_each do |slip|
137
+ taken = slip.deduction_amount("loan_repayment")
138
+ next unless taken.positive?
139
+
140
+ remaining = taken
141
+ Loan.active.where(user_id: slip.user_id).order(:starts_on).each do |loan|
142
+ break unless remaining.positive?
143
+
144
+ share = [ loan.instalment_for(period_month), remaining ].min
145
+ next unless share.positive?
146
+
147
+ loan.record_repayment!(period_month, share)
148
+ remaining -= share
149
+ end
150
+ end
151
+ end
152
+
153
+ def unbook_loan_repayments!
154
+ LoanRepayment.where(period_month: period_month)
155
+ .where(loan_id: Loan.where(user_id: salary_slips.select(:user_id)))
156
+ .destroy_all
157
+ Loan.where(user_id: salary_slips.select(:user_id), status: "closed")
158
+ .each { |loan| loan.update!(status: "active") if loan.outstanding.positive? }
159
+ end
160
+
127
161
  # Amounts stay out of it on purpose: audit_logs is a plaintext table and
128
162
  # every slip amount in this module is encrypted. The row records WHO
129
163
  # moved the run and HOW FAR, never what anyone is paid.
@@ -0,0 +1,59 @@
1
+ module HrLite
2
+ # An earning or deduction head. Data, so an install can add "Night shift
3
+ # allowance" without a gem release — and so a payslip line says what it
4
+ # was for instead of disappearing into "other".
5
+ class SalaryComponent < ApplicationRecord
6
+ include Audited
7
+
8
+ KINDS = %w[earning deduction].freeze
9
+
10
+ # Heads the structure itself owns. A line item may not use these — they
11
+ # already have a column, and two sources for one number is how a payslip
12
+ # stops adding up.
13
+ STRUCTURAL_CODES = %w[basic hra special_allowance other_earnings].freeze
14
+
15
+ has_many :payroll_line_items, class_name: "HrLite::PayrollLineItem",
16
+ foreign_key: :component_id, dependent: :restrict_with_error
17
+
18
+ validates :code, presence: true, uniqueness: { case_sensitive: false },
19
+ format: { with: /\A[a-z0-9_]+\z/, message: "is lowercase letters, numbers and underscores" }
20
+ validates :label, presence: true
21
+ validates :kind, inclusion: { in: KINDS }
22
+ validate :code_is_not_a_structural_one, on: :create
23
+
24
+ scope :active, -> { where(active: true) }
25
+ scope :earnings, -> { where(kind: "earning") }
26
+ scope :deductions, -> { where(kind: "deduction") }
27
+ scope :ordered, -> { order(:position, :label) }
28
+
29
+ KINDS.each { |k| define_method("#{k}?") { kind == k } }
30
+
31
+ def self.seed_defaults!
32
+ [
33
+ { code: "bonus", label: "Bonus", prorated: false, position: 10 },
34
+ { code: "incentive", label: "Incentive", prorated: false, position: 20 },
35
+ { code: "arrears", label: "Arrears", prorated: false, position: 30 },
36
+ { code: "lta", label: "Leave travel allowance", prorated: false, position: 40 },
37
+ { code: "reimbursement", label: "Reimbursement", prorated: false, taxable: false,
38
+ counts_for_esi: false, position: 50 },
39
+ { code: "loan_repayment", label: "Loan repayment", kind: "deduction",
40
+ prorated: false, taxable: false, counts_for_esi: false, position: 60 },
41
+ { code: "other_deduction", label: "Other deduction", kind: "deduction",
42
+ prorated: false, taxable: false, counts_for_esi: false, position: 70 }
43
+ ].filter_map do |attributes|
44
+ next if exists?(code: attributes[:code])
45
+
46
+ create!(attributes)
47
+ attributes[:code]
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def code_is_not_a_structural_one
54
+ return unless STRUCTURAL_CODES.include?(code.to_s)
55
+
56
+ errors.add(:code, "is already a salary-structure field — pick another name")
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,93 @@
1
+ module HrLite
2
+ # What an employee claims for the year, section by section, replacing the
3
+ # single `declared_annual_deductions` figure an admin used to type in.
4
+ #
5
+ # The whole year's TDS is projected from this number, so it matters that it
6
+ # is broken down, that the employee submitted it themselves, and that HR
7
+ # can record what the proof actually supported.
8
+ class TaxDeclaration < ApplicationRecord
9
+ include Audited
10
+
11
+ STATUSES = %w[draft submitted verified rejected].freeze
12
+ REGIMES = %w[new old].freeze
13
+
14
+ has_many :tax_declaration_items, -> { order(:section) },
15
+ class_name: "HrLite::TaxDeclarationItem",
16
+ foreign_key: :declaration_id, dependent: :destroy
17
+ accepts_nested_attributes_for :tax_declaration_items, allow_destroy: true
18
+
19
+ belongs_to :user, class_name: HrLite.config.user_class
20
+ belongs_to :verified_by, class_name: HrLite.config.user_class, optional: true
21
+
22
+ validates :status, inclusion: { in: STATUSES }
23
+ validates :regime, inclusion: { in: REGIMES }
24
+ validates :financial_year, presence: true, uniqueness: { scope: :user_id }
25
+ validate :financial_year_opens_in_april
26
+
27
+ STATUSES.each { |s| define_method("#{s}?") { status == s } }
28
+
29
+ def self.for(user, period_month)
30
+ find_by(user_id: user.id, financial_year: FinancialYear.start_for(period_month))
31
+ end
32
+
33
+ def label = "FY #{FinancialYear.label(financial_year)}"
34
+
35
+ def declared_total = tax_declaration_items.sum(BigDecimal(0)) { |i| i.declared_amount || 0 }
36
+
37
+ # What payroll should actually deduct against. Until HR has verified it,
38
+ # the declared figure stands — asking somebody to overpay tax all year
39
+ # because paperwork is slow is its own kind of wrong — but once verified,
40
+ # only what the proof supported counts.
41
+ def allowable_total
42
+ return declared_total unless verified?
43
+
44
+ tax_declaration_items.sum(BigDecimal(0)) { |i| i.verified_amount || BigDecimal(0) }
45
+ end
46
+
47
+ def submit!(actor:)
48
+ raise ActiveRecord::RecordInvalid.new(self), "not a draft" unless draft? || rejected?
49
+
50
+ update!(status: "submitted", submitted_at: Time.current)
51
+ Notifications.publish(
52
+ "tax.declaration_submitted",
53
+ title: "#{HrLite.display_name(user)} submitted a #{label} tax declaration",
54
+ path: "/admin/tax_declarations/#{id}",
55
+ bell_to: HrLite.users_holding("tax.manage").to_a
56
+ )
57
+ true
58
+ end
59
+
60
+ def verify!(actor:, note: nil)
61
+ raise ActiveRecord::RecordInvalid.new(self), "not submitted" unless submitted?
62
+
63
+ update!(status: "verified", verified_by_id: actor.id, verified_at: Time.current,
64
+ note: note.presence)
65
+ notify_employee("Your #{label} tax declaration was verified")
66
+ true
67
+ end
68
+
69
+ def reject!(actor:, note:)
70
+ raise ArgumentError, "a rejection needs a reason" if note.blank?
71
+
72
+ update!(status: "rejected", verified_by_id: actor.id, verified_at: Time.current, note: note)
73
+ notify_employee("Your #{label} tax declaration needs attention")
74
+ true
75
+ end
76
+
77
+ private
78
+
79
+ def notify_employee(title)
80
+ Notifications.publish(
81
+ "tax.declaration_decided", title: title, body: note.presence,
82
+ path: "/tax_declaration", bell_to: [ user ], email_to: [ user ]
83
+ )
84
+ end
85
+
86
+ def financial_year_opens_in_april
87
+ return if financial_year.blank?
88
+ return if financial_year == FinancialYear.start_for(financial_year)
89
+
90
+ errors.add(:financial_year, "must be 1 April — a declaration covers a whole year")
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,32 @@
1
+ module HrLite
2
+ # One line of a declaration: "80C — ELSS — ₹1,50,000".
3
+ #
4
+ # `verified_amount` is what the proof actually supported, which is often
5
+ # less than what was claimed and is the number payroll uses once HR has
6
+ # looked. Both are encrypted: what somebody invests is their business.
7
+ class TaxDeclarationItem < ApplicationRecord
8
+ include EncryptedMoney
9
+
10
+ SECTIONS = %w[80c 80d 80ccd_1b 24b hra other].freeze
11
+
12
+ encrypted_money :declared_amount, :verified_amount
13
+
14
+ belongs_to :declaration, class_name: "HrLite::TaxDeclaration"
15
+
16
+ validates :section, inclusion: { in: SECTIONS }
17
+ validate :amounts_are_not_negative
18
+
19
+ def section_label
20
+ { "80c" => "80C — investments", "80d" => "80D — health insurance",
21
+ "80ccd_1b" => "80CCD(1B) — NPS", "24b" => "24(b) — home loan interest",
22
+ "hra" => "HRA exemption", "other" => "Other" }.fetch(section, section)
23
+ end
24
+
25
+ private
26
+
27
+ def amounts_are_not_negative
28
+ errors.add(:declared_amount, "cannot be negative") if declared_amount&.negative?
29
+ errors.add(:verified_amount, "cannot be negative") if verified_amount&.negative?
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,109 @@
1
+ module HrLite
2
+ # Drives one record through its flow: opens the first rung, advances when a
3
+ # rung is satisfied, and reports when the whole thing is settled.
4
+ #
5
+ # It decides NOTHING about the subject itself. `Approvable` owns what
6
+ # "approved" means for a leave request; this class only knows whose turn it
7
+ # is. That split is why adding expenses later needs no change here.
8
+ class ApprovalRoute
9
+ Result = Struct.new(:outcome, :approval, keyword_init: true)
10
+
11
+ def initialize(subject)
12
+ @subject = subject
13
+ end
14
+
15
+ attr_reader :subject
16
+
17
+ def flow = @flow ||= ApprovalFlow.for(subject.class.name)
18
+
19
+ def routed? = flow.present?
20
+
21
+ # Opens the first rung that has anybody on it. A rung whose rule resolves
22
+ # to nobody — no manager recorded, say — is SKIPPED rather than left
23
+ # waiting for a person who does not exist.
24
+ def open!
25
+ return Result.new(outcome: :unrouted) unless routed?
26
+
27
+ flow.approval_steps.ordered.each do |step|
28
+ approvers = step.approvers_for(subject)
29
+ next skip!(step) if approvers.empty?
30
+
31
+ create_rows(step, approvers)
32
+ return Result.new(outcome: :pending)
33
+ end
34
+
35
+ # Every rung skipped: nobody in the flow can decide, so the request
36
+ # carries itself. Better than a request that can never be answered.
37
+ Result.new(outcome: :approved)
38
+ end
39
+
40
+ def pending_rows = Approval.pending.where(subject: subject)
41
+
42
+ def current_position = pending_rows.minimum(:position)
43
+
44
+ # Records one decision and works out what it means for the record.
45
+ #
46
+ # :pending — the rung still needs somebody
47
+ # :approved — every rung is satisfied
48
+ # :rejected — one refusal ends it
49
+ # :returned — sent back to the requester for correction
50
+ def decide!(approval, status:, actor:, note: nil)
51
+ approval.decide!(status: status, actor: actor, note: note)
52
+
53
+ case status.to_s
54
+ when "rejected", "returned"
55
+ cancel_remaining!(approval.position)
56
+ Result.new(outcome: status.to_sym, approval: approval)
57
+ else
58
+ advance_from(approval)
59
+ end
60
+ end
61
+
62
+ def cancel_all!
63
+ pending_rows.update_all(status: "cancelled", decided_at: Time.current) # rubocop:disable Rails/SkipsModelValidations
64
+ end
65
+
66
+ private
67
+
68
+ def advance_from(approval)
69
+ rung = Approval.where(subject: subject).at_position(approval.position)
70
+ # A unanimous rung waits for everybody; otherwise the first answer
71
+ # settles it and the rest are stood down.
72
+ if approval.step.unanimous && rung.pending.exists?
73
+ return Result.new(outcome: :pending, approval: approval)
74
+ end
75
+
76
+ rung.pending.each { |other| other.decide!(status: "skipped", actor: nil) }
77
+ open_next_after(approval.position) || Result.new(outcome: :approved, approval: approval)
78
+ end
79
+
80
+ def open_next_after(position)
81
+ flow.approval_steps.ordered.each do |step|
82
+ next if step.position <= position
83
+
84
+ approvers = step.approvers_for(subject)
85
+ next skip!(step) if approvers.empty?
86
+
87
+ create_rows(step, approvers)
88
+ return Result.new(outcome: :pending)
89
+ end
90
+ nil
91
+ end
92
+
93
+ def create_rows(step, approvers)
94
+ approvers.uniq.each do |approver|
95
+ Approval.create!(subject: subject, step: step, position: step.position,
96
+ approver_id: approver.id)
97
+ end
98
+ end
99
+
100
+ # Nothing to record: a rung nobody occupies never happened. Returning nil
101
+ # keeps `each`'s `next` readable at the call sites.
102
+ def skip!(_step) = nil
103
+
104
+ def cancel_remaining!(position)
105
+ Approval.pending.where(subject: subject).where("position >= ?", position)
106
+ .each { |row| row.decide!(status: "cancelled", actor: nil) }
107
+ end
108
+ end
109
+ end