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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +149 -1
- data/app/controllers/hr_lite/approvals_controller.rb +31 -0
- data/app/helpers/hr_lite/application_helper.rb +21 -0
- data/app/jobs/hr_lite/approval_escalation_job.rb +39 -0
- data/app/jobs/hr_lite/document_expiry_job.rb +28 -0
- data/app/models/concerns/hr_lite/approvable.rb +48 -0
- data/app/models/hr_lite/approval.rb +46 -0
- data/app/models/hr_lite/approval_delegation.rb +38 -0
- data/app/models/hr_lite/approval_flow.rb +39 -0
- data/app/models/hr_lite/approval_step.rb +62 -0
- data/app/models/hr_lite/document.rb +95 -0
- data/app/models/hr_lite/leave_request.rb +72 -17
- data/app/models/hr_lite/loan.rb +65 -0
- data/app/models/hr_lite/loan_repayment.rb +14 -0
- data/app/models/hr_lite/payroll_line_item.rb +57 -0
- data/app/models/hr_lite/payroll_run.rb +34 -0
- data/app/models/hr_lite/salary_component.rb +59 -0
- data/app/models/hr_lite/tax_declaration.rb +93 -0
- data/app/models/hr_lite/tax_declaration_item.rb +32 -0
- data/app/services/hr_lite/approval_route.rb +109 -0
- data/app/services/hr_lite/slip_builder.rb +76 -3
- data/app/views/hr_lite/approvals/index.html.erb +66 -0
- data/config/routes.rb +1 -0
- data/db/migrate/20260818001151_create_hr_lite_approvals.rb +79 -0
- data/db/migrate/20260818002627_create_hr_lite_payroll_components_and_loans.rb +72 -0
- data/db/migrate/20260818003616_create_hr_lite_documents_and_declarations.rb +73 -0
- data/lib/hr_lite/notifications.rb +10 -0
- data/lib/hr_lite/permissions.rb +4 -0
- data/lib/hr_lite/role_seeds.rb +6 -3
- data/lib/hr_lite/version.rb +1 -1
- data/lib/tasks/hr_lite_tasks.rake +2 -1
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|