hr_lite 0.9.0 → 0.12.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 +123 -1
- data/app/jobs/hr_lite/document_expiry_job.rb +28 -0
- data/app/models/hr_lite/approval_flow.rb +1 -1
- data/app/models/hr_lite/benefit.rb +37 -0
- data/app/models/hr_lite/benefit_enrolment.rb +30 -0
- data/app/models/hr_lite/document.rb +95 -0
- data/app/models/hr_lite/expense.rb +142 -0
- data/app/models/hr_lite/expense_category.rb +33 -0
- data/app/models/hr_lite/hr_request.rb +75 -0
- 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/policy.rb +59 -0
- data/app/models/hr_lite/policy_acknowledgement.rb +13 -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/slip_builder.rb +76 -3
- 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/db/migrate/20260818004707_create_hr_lite_employee_services.rb +108 -0
- data/lib/hr_lite/notifications.rb +15 -0
- data/lib/hr_lite/permissions.rb +13 -0
- data/lib/hr_lite/role_seeds.rb +13 -3
- data/lib/hr_lite/version.rb +1 -1
- data/lib/tasks/hr_lite_tasks.rake +2 -1
- metadata +19 -1
|
@@ -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
|
+
# A written policy people are held to. Versioned, because "you agreed to
|
|
3
|
+
# this" is only true of the version somebody actually read.
|
|
4
|
+
class Policy < ApplicationRecord
|
|
5
|
+
include Audited
|
|
6
|
+
|
|
7
|
+
has_many :policy_acknowledgements, class_name: "HrLite::PolicyAcknowledgement",
|
|
8
|
+
dependent: :destroy
|
|
9
|
+
|
|
10
|
+
validates :title, presence: true, uniqueness: { scope: :version }
|
|
11
|
+
validates :body, presence: true
|
|
12
|
+
validates :effective_from, presence: true
|
|
13
|
+
validates :version, numericality: { greater_than: 0 }
|
|
14
|
+
|
|
15
|
+
scope :published, -> { where(published: true) }
|
|
16
|
+
scope :live_on, ->(date) { published.where(effective_from: ..date) }
|
|
17
|
+
scope :newest_first, -> { order(effective_from: :desc, version: :desc) }
|
|
18
|
+
|
|
19
|
+
# The version in force today for each title — an older one stays for the
|
|
20
|
+
# acknowledgements attached to it, but nobody should be reading it.
|
|
21
|
+
def self.current
|
|
22
|
+
live_on(Date.current).group_by(&:title).values.map do |versions|
|
|
23
|
+
versions.max_by(&:version)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Re-issuing asks everybody again, which is the whole point of versioning
|
|
28
|
+
# it: an acknowledgement of v1 says nothing about v2.
|
|
29
|
+
def supersede!(body:, effective_from: Date.current, acknowledgement_required: nil)
|
|
30
|
+
self.class.create!(
|
|
31
|
+
title: title, body: body, version: version + 1, effective_from: effective_from,
|
|
32
|
+
acknowledgement_required: acknowledgement_required.nil? ? self.acknowledgement_required : acknowledgement_required,
|
|
33
|
+
published: true
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def acknowledged_by?(user)
|
|
38
|
+
policy_acknowledgements.exists?(user_id: user.id)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def outstanding_for
|
|
42
|
+
return HrLite.user_klass.none unless acknowledgement_required && published
|
|
43
|
+
|
|
44
|
+
HrLite.user_klass.where(id: HrLite.active_employees.map(&:id))
|
|
45
|
+
.where.not(id: policy_acknowledgements.select(:user_id))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Clicking twice is not an error and must not read as one. The
|
|
49
|
+
# find_or_create handles the second click; the rescue handles two
|
|
50
|
+
# requests arriving at once, which the unique index refuses.
|
|
51
|
+
def acknowledge!(user)
|
|
52
|
+
policy_acknowledgements.find_or_create_by!(user_id: user.id) do |ack|
|
|
53
|
+
ack.acknowledged_at = Time.current
|
|
54
|
+
end
|
|
55
|
+
rescue ActiveRecord::RecordNotUnique
|
|
56
|
+
policy_acknowledgements.find_by!(user_id: user.id)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# "I have read this." Kept per VERSION, and never edited — it is the
|
|
3
|
+
# evidence, and evidence that can be changed afterwards is not evidence.
|
|
4
|
+
class PolicyAcknowledgement < ApplicationRecord
|
|
5
|
+
belongs_to :policy, class_name: "HrLite::Policy"
|
|
6
|
+
belongs_to :user, class_name: HrLite.config.user_class
|
|
7
|
+
|
|
8
|
+
validates :acknowledged_at, presence: true
|
|
9
|
+
validates :user_id, uniqueness: { scope: :policy_id }
|
|
10
|
+
|
|
11
|
+
def readonly? = persisted?
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -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
|
|
@@ -33,8 +33,17 @@ module HrLite
|
|
|
33
33
|
earnings = Calculators::Proration.call(
|
|
34
34
|
structure: @structure, payable_days: payable, days_in_month: days_in_month
|
|
35
35
|
)
|
|
36
|
+
# One-off heads for this month: a bonus, arrears after a backdated
|
|
37
|
+
# revision, a reimbursement. A prorated head is scaled like basic; a
|
|
38
|
+
# bonus is not halved because somebody joined mid-month.
|
|
39
|
+
extra_earnings, extra_deductions = one_off_lines(payable, days_in_month)
|
|
40
|
+
earnings += extra_earnings
|
|
41
|
+
|
|
36
42
|
gross_earned = earnings.sum(BigDecimal(0)) { |row| row[:amount] }
|
|
37
43
|
basic_earned = earnings.find { |row| row[:code] == "basic" }&.fetch(:amount) || BigDecimal(0)
|
|
44
|
+
# ESI is assessed on wages, and a reimbursement is not one. Heads that
|
|
45
|
+
# opt out are excluded from the gross it reads.
|
|
46
|
+
esi_gross = gross_earned - earnings.sum(BigDecimal(0)) { |row| row[:excluded_from_esi] ? row[:amount] : 0 }
|
|
38
47
|
|
|
39
48
|
deductions = []
|
|
40
49
|
employer_costs = {}
|
|
@@ -50,7 +59,7 @@ module HrLite
|
|
|
50
59
|
)
|
|
51
60
|
end
|
|
52
61
|
|
|
53
|
-
esi = Calculators::Esi.call(monthly_gross: esi_reference_gross, gross_earned:
|
|
62
|
+
esi = Calculators::Esi.call(monthly_gross: esi_reference_gross, gross_earned: esi_gross,
|
|
54
63
|
applicable: @structure.esi_applicable, rates: @rates[:esi])
|
|
55
64
|
if esi.applicable?
|
|
56
65
|
deductions << { code: "esi_employee", label: "ESI", amount: esi.employee }
|
|
@@ -61,9 +70,13 @@ module HrLite
|
|
|
61
70
|
period_month: @run.period_month, rates: @rates[:pt])
|
|
62
71
|
deductions << { code: "pt", label: "Professional tax", amount: pt } if pt.positive?
|
|
63
72
|
|
|
73
|
+
deductions.concat(extra_deductions)
|
|
74
|
+
loan = loan_instalment
|
|
75
|
+
deductions << loan if loan
|
|
76
|
+
|
|
64
77
|
fy = SalarySlip.fy_to_date(@user, @run.period_month)
|
|
65
78
|
tds = Calculators::Tds.call(
|
|
66
|
-
regime:
|
|
79
|
+
regime: tax_regime,
|
|
67
80
|
structure_monthly_gross: @structure.monthly_gross,
|
|
68
81
|
gross_earned_this_month: gross_earned,
|
|
69
82
|
# Plus anything paid this FY that this install never ran — a previous
|
|
@@ -73,7 +86,7 @@ module HrLite
|
|
|
73
86
|
fy_gross_paid: fy[:gross] + Money.d(@profile.fy_opening_gross),
|
|
74
87
|
fy_tds_paid: fy[:tds] + Money.d(@profile.fy_opening_tds),
|
|
75
88
|
months_remaining: months_remaining_in_fy,
|
|
76
|
-
declared_annual_deductions:
|
|
89
|
+
declared_annual_deductions: annual_deductions,
|
|
77
90
|
rates: @rates[:income_tax],
|
|
78
91
|
override: @tds_override
|
|
79
92
|
)
|
|
@@ -107,6 +120,66 @@ module HrLite
|
|
|
107
120
|
|
|
108
121
|
private
|
|
109
122
|
|
|
123
|
+
# The declaration if the employee filed one, else the single figure an
|
|
124
|
+
# admin typed on the profile. Old-regime deductions came from that one
|
|
125
|
+
# opaque number, with nothing to show what it was made of and nowhere to
|
|
126
|
+
# keep the proof; a filed declaration supersedes it, and once HR has
|
|
127
|
+
# verified the proof only what the proof supported counts.
|
|
128
|
+
# A declaration names the regime the employee chose for that year, which
|
|
129
|
+
# is a per-YEAR decision; the profile column is only the fallback for
|
|
130
|
+
# somebody who never filed one.
|
|
131
|
+
def tax_regime
|
|
132
|
+
TaxDeclaration.for(@user, @run.period_month)&.regime || @profile.tax_regime
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def annual_deductions
|
|
136
|
+
declaration = TaxDeclaration.for(@user, @run.period_month)
|
|
137
|
+
return @profile.declared_annual_deductions if declaration.nil?
|
|
138
|
+
return @profile.declared_annual_deductions if declaration.draft?
|
|
139
|
+
|
|
140
|
+
declaration.allowable_total
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Splits this month's one-off lines into earnings and deductions, in the
|
|
144
|
+
# component order an install configured. Returns [earnings, deductions].
|
|
145
|
+
def one_off_lines(payable, days_in_month)
|
|
146
|
+
rows = PayrollLineItem.for_slip(@user, @run.period_month)
|
|
147
|
+
.sort_by { |item| [ item.component.position, item.component.label ] }
|
|
148
|
+
earnings = []
|
|
149
|
+
deductions = []
|
|
150
|
+
|
|
151
|
+
rows.each do |item|
|
|
152
|
+
component = item.component
|
|
153
|
+
amount = if component.prorated
|
|
154
|
+
Money.round2(item.amount * (Money.d(payable) / Money.d(days_in_month)))
|
|
155
|
+
else
|
|
156
|
+
item.amount
|
|
157
|
+
end
|
|
158
|
+
next unless amount.positive?
|
|
159
|
+
|
|
160
|
+
row = { code: component.code, label: component.label, amount: amount }
|
|
161
|
+
if component.earning?
|
|
162
|
+
earnings << row.merge(excluded_from_esi: !component.counts_for_esi)
|
|
163
|
+
else
|
|
164
|
+
deductions << row
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
[ earnings, deductions ]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# The month's loan instalment, as one deduction line. Computed here and
|
|
172
|
+
# BOOKED only when the run is finalized — a draft is recomputed freely,
|
|
173
|
+
# and booking at compute would take a repayment on every pass.
|
|
174
|
+
def loan_instalment
|
|
175
|
+
total = Loan.active.where(user_id: @user.id).sum(BigDecimal(0)) do |loan|
|
|
176
|
+
loan.instalment_for(@run.period_month)
|
|
177
|
+
end
|
|
178
|
+
return nil unless total.positive?
|
|
179
|
+
|
|
180
|
+
{ code: "loan_repayment", label: "Loan repayment", amount: total }
|
|
181
|
+
end
|
|
182
|
+
|
|
110
183
|
# Months left in the Indian FY (Apr–Mar) including the run month itself:
|
|
111
184
|
# April = 12, December = 4, January = 3, March = 1. Capped at the exit
|
|
112
185
|
# month for a leaver, so a final settlement is not spread over months
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
class CreateHrLitePayrollComponentsAndLoans < ActiveRecord::Migration[8.1]
|
|
2
|
+
# Payroll knew exactly four earning heads — basic, HRA, special allowance,
|
|
3
|
+
# "other" — because they were columns on the salary structure. A bonus, an
|
|
4
|
+
# incentive, an LTA or a reimbursement had nowhere to go but "other", which
|
|
5
|
+
# is one number on a payslip that should have said what it was for.
|
|
6
|
+
#
|
|
7
|
+
# Heads are DATA now. Amounts stay encrypted for the same reason every
|
|
8
|
+
# amount in this engine is: a payslip line is somebody's pay.
|
|
9
|
+
def change
|
|
10
|
+
create_table :hr_lite_salary_components do |t|
|
|
11
|
+
t.string :code, null: false
|
|
12
|
+
t.string :label, null: false
|
|
13
|
+
# earning | deduction
|
|
14
|
+
t.string :kind, null: false, default: "earning"
|
|
15
|
+
# Prorated by attendance like basic, or paid whole (a bonus is not
|
|
16
|
+
# halved because somebody joined mid-month).
|
|
17
|
+
t.boolean :prorated, null: false, default: true
|
|
18
|
+
t.boolean :taxable, null: false, default: true
|
|
19
|
+
# Counts toward the gross ESI is assessed on.
|
|
20
|
+
t.boolean :counts_for_esi, null: false, default: true
|
|
21
|
+
t.integer :position, null: false, default: 0
|
|
22
|
+
t.boolean :active, null: false, default: true
|
|
23
|
+
t.timestamps
|
|
24
|
+
end
|
|
25
|
+
add_index :hr_lite_salary_components, :code, unique: true
|
|
26
|
+
add_check_constraint :hr_lite_salary_components, "kind IN ('earning', 'deduction')",
|
|
27
|
+
name: "hr_lite_salary_components_kind_check"
|
|
28
|
+
|
|
29
|
+
# A one-off on a single month's payslip: a bonus, an incentive, an
|
|
30
|
+
# arrears line after a backdated revision, a reimbursement. Dated to the
|
|
31
|
+
# month rather than to the run, so it survives a run being deleted and
|
|
32
|
+
# recomputed.
|
|
33
|
+
create_table :hr_lite_payroll_line_items do |t|
|
|
34
|
+
t.bigint :user_id, null: false
|
|
35
|
+
t.date :period_month, null: false
|
|
36
|
+
t.references :component, null: false, index: true,
|
|
37
|
+
foreign_key: { to_table: :hr_lite_salary_components }
|
|
38
|
+
t.text :amount, null: false # encrypted BigDecimal
|
|
39
|
+
t.string :note
|
|
40
|
+
t.bigint :created_by_id
|
|
41
|
+
t.timestamps
|
|
42
|
+
end
|
|
43
|
+
add_index :hr_lite_payroll_line_items, %i[user_id period_month]
|
|
44
|
+
|
|
45
|
+
create_table :hr_lite_loans do |t|
|
|
46
|
+
t.bigint :user_id, null: false
|
|
47
|
+
t.text :principal, null: false # encrypted
|
|
48
|
+
t.text :monthly_instalment, null: false # encrypted
|
|
49
|
+
t.date :starts_on, null: false
|
|
50
|
+
t.string :reason
|
|
51
|
+
# active | closed | cancelled
|
|
52
|
+
t.string :status, null: false, default: "active"
|
|
53
|
+
t.bigint :approved_by_id
|
|
54
|
+
t.timestamps
|
|
55
|
+
end
|
|
56
|
+
add_index :hr_lite_loans, %i[user_id status]
|
|
57
|
+
add_check_constraint :hr_lite_loans, "status IN ('active', 'closed', 'cancelled')",
|
|
58
|
+
name: "hr_lite_loans_status_check"
|
|
59
|
+
|
|
60
|
+
# One row per month actually deducted. The outstanding balance is derived
|
|
61
|
+
# from these rather than stored, so a recompute cannot double-count and a
|
|
62
|
+
# deleted run cannot leave the balance wrong.
|
|
63
|
+
create_table :hr_lite_loan_repayments do |t|
|
|
64
|
+
t.references :loan, null: false, index: true,
|
|
65
|
+
foreign_key: { to_table: :hr_lite_loans, on_delete: :cascade }
|
|
66
|
+
t.date :period_month, null: false
|
|
67
|
+
t.text :amount, null: false # encrypted
|
|
68
|
+
t.timestamps
|
|
69
|
+
end
|
|
70
|
+
add_index :hr_lite_loan_repayments, %i[loan_id period_month], unique: true
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
class CreateHrLiteDocumentsAndDeclarations < ActiveRecord::Migration[8.1]
|
|
2
|
+
# Two gaps that were table stakes and simply absent: nowhere to keep an
|
|
3
|
+
# employee's documents, and a tax declaration that was ONE opaque number.
|
|
4
|
+
#
|
|
5
|
+
# `declared_annual_deductions` on the profile is a single figure an admin
|
|
6
|
+
# types in. Nobody can see what it is made of, the employee cannot submit
|
|
7
|
+
# their own, and there is nowhere to put the proof. That is the number the
|
|
8
|
+
# whole year's TDS is projected from.
|
|
9
|
+
def change
|
|
10
|
+
create_table :hr_lite_documents do |t|
|
|
11
|
+
t.bigint :user_id, null: false
|
|
12
|
+
# aadhaar | pan | passport | bank | offer_letter | ... — data, so an
|
|
13
|
+
# install can add its own without a release.
|
|
14
|
+
t.string :category, null: false
|
|
15
|
+
t.string :title, null: false
|
|
16
|
+
t.string :reference_number
|
|
17
|
+
t.date :issued_on
|
|
18
|
+
t.date :expires_on
|
|
19
|
+
# self | hr | money — who may open the file. A payslip and a passport
|
|
20
|
+
# are not the same kind of secret.
|
|
21
|
+
#
|
|
22
|
+
# No column default on purpose: the model derives one from the category
|
|
23
|
+
# when none was chosen, and a column default would make "unset" and
|
|
24
|
+
# "deliberately hr" indistinguishable.
|
|
25
|
+
t.string :visibility, null: false
|
|
26
|
+
# pending | verified | rejected
|
|
27
|
+
t.string :verification, null: false, default: "pending"
|
|
28
|
+
t.bigint :verified_by_id
|
|
29
|
+
t.datetime :verified_at
|
|
30
|
+
t.string :verification_note
|
|
31
|
+
t.bigint :uploaded_by_id
|
|
32
|
+
t.timestamps
|
|
33
|
+
end
|
|
34
|
+
add_index :hr_lite_documents, %i[user_id category]
|
|
35
|
+
add_index :hr_lite_documents, :expires_on
|
|
36
|
+
add_check_constraint :hr_lite_documents, "visibility IN ('self', 'hr', 'money')",
|
|
37
|
+
name: "hr_lite_documents_visibility_check"
|
|
38
|
+
add_check_constraint :hr_lite_documents,
|
|
39
|
+
"verification IN ('pending', 'verified', 'rejected')",
|
|
40
|
+
name: "hr_lite_documents_verification_check"
|
|
41
|
+
|
|
42
|
+
create_table :hr_lite_tax_declarations do |t|
|
|
43
|
+
t.bigint :user_id, null: false
|
|
44
|
+
# 1 April of the financial year it covers.
|
|
45
|
+
t.date :financial_year, null: false
|
|
46
|
+
t.string :regime, null: false, default: "new"
|
|
47
|
+
# draft | submitted | verified | rejected
|
|
48
|
+
t.string :status, null: false, default: "draft"
|
|
49
|
+
t.datetime :submitted_at
|
|
50
|
+
t.bigint :verified_by_id
|
|
51
|
+
t.datetime :verified_at
|
|
52
|
+
t.string :note
|
|
53
|
+
t.timestamps
|
|
54
|
+
end
|
|
55
|
+
add_index :hr_lite_tax_declarations, %i[user_id financial_year], unique: true
|
|
56
|
+
add_check_constraint :hr_lite_tax_declarations,
|
|
57
|
+
"status IN ('draft', 'submitted', 'verified', 'rejected')",
|
|
58
|
+
name: "hr_lite_tax_declarations_status_check"
|
|
59
|
+
|
|
60
|
+
create_table :hr_lite_tax_declaration_items do |t|
|
|
61
|
+
t.references :declaration, null: false, index: true,
|
|
62
|
+
foreign_key: { to_table: :hr_lite_tax_declarations, on_delete: :cascade }
|
|
63
|
+
# 80c | 80d | hra | 80ccd_1b | 24b | other
|
|
64
|
+
t.string :section, null: false
|
|
65
|
+
t.string :label
|
|
66
|
+
t.text :declared_amount, null: false # encrypted
|
|
67
|
+
# What proof actually supported, once HR has looked. Null until then.
|
|
68
|
+
t.text :verified_amount
|
|
69
|
+
t.timestamps
|
|
70
|
+
end
|
|
71
|
+
add_index :hr_lite_tax_declaration_items, %i[declaration_id section]
|
|
72
|
+
end
|
|
73
|
+
end
|