hr_lite 0.9.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 +87 -1
- data/app/jobs/hr_lite/document_expiry_job.rb +28 -0
- data/app/models/hr_lite/document.rb +95 -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/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/lib/hr_lite/notifications.rb +7 -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 +11 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 655b4d27557dace6262528eb82af6912b4b7dfcdc3ff6b8274068876b8bb970c
|
|
4
|
+
data.tar.gz: ba15eee422c61704681db9d8d1194851f8c761459524844ade6e4cd054776f81
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4b4224f36f2cd4b3c256d440e6f439f59ce86e61436545f4f5a1afab97d827c64db86b4ee3b21f72003ea8864010b0369d6a719cd833a7194855adc1af10602c
|
|
7
|
+
data.tar.gz: 3de774d902610ab761c5794fe4f9eec3c404dac26ccf0fbea553bfe6bafe09e45c576e8264b07c70b6eef07af3f93f54c88aa43d3fd149921d4726456823a890
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,90 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.11.0] - 2026-08-18
|
|
11
|
+
|
|
12
|
+
Documents, and a tax declaration that is more than one number. **Adds one
|
|
13
|
+
migration.** Requires Active Storage on the host (`rails active_storage:install`).
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **`hr_lite_documents`** — there was nowhere to keep an employee's documents
|
|
18
|
+
at all. Files attach through Active Storage, which content-sniffs via
|
|
19
|
+
Marcel, so the type allowlist checks real bytes rather than what the client
|
|
20
|
+
claimed. SVG and HTML stay out: both carry script and both render in a
|
|
21
|
+
browser. 10 MB cap.
|
|
22
|
+
- **Visibility is a property of the row**, and an identity document defaults
|
|
23
|
+
to the tightest setting rather than the convenient one — an Aadhaar, PAN,
|
|
24
|
+
passport or bank mandate is money-tier by default, not HR's to browse. A
|
|
25
|
+
default nobody thinks about is the one that leaks. An explicit choice always
|
|
26
|
+
wins, which is why the column carries no default of its own.
|
|
27
|
+
- Verification with a named checker, and expiry with a job that warns at 30
|
|
28
|
+
days and again at 7 — not daily, because a warning that arrives every day
|
|
29
|
+
is one nobody reads by the third.
|
|
30
|
+
- **`hr_lite_tax_declarations`** and items, replacing
|
|
31
|
+
`declared_annual_deductions`: ONE opaque figure an admin typed in, that
|
|
32
|
+
nobody could see the makeup of, that the employee could not submit
|
|
33
|
+
themselves, and that had nowhere to keep the proof. It is the number the
|
|
34
|
+
whole year's TDS is projected from.
|
|
35
|
+
- Sections (80C, 80D, 80CCD(1B), 24(b), HRA), each with what was **declared**
|
|
36
|
+
and what the proof actually **supported**. Until HR has looked, the declared
|
|
37
|
+
figure stands — asking somebody to overpay tax all year because paperwork is
|
|
38
|
+
slow is its own kind of wrong. Once verified, only the supported figure
|
|
39
|
+
counts.
|
|
40
|
+
- The regime follows the declaration, because choosing one is a per-YEAR
|
|
41
|
+
decision and the profile column cannot express that.
|
|
42
|
+
- Permissions `document.view`, `document.manage`, `tax.view`, `tax.manage`,
|
|
43
|
+
granted to the roles that should hold them.
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
|
|
47
|
+
- `SlipBuilder` reads the declaration when one exists for the run's financial
|
|
48
|
+
year, falling back to the profile figure otherwise. A DRAFT declaration is
|
|
49
|
+
ignored — a draft is not a claim.
|
|
50
|
+
|
|
51
|
+
## [0.10.0] - 2026-08-18
|
|
52
|
+
|
|
53
|
+
Payroll gets heads, arrears and loans. **Adds one migration.** Existing
|
|
54
|
+
payslips compute identically — every new head is opt-in data.
|
|
55
|
+
|
|
56
|
+
### Added
|
|
57
|
+
|
|
58
|
+
- **`hr_lite_salary_components`** — earning and deduction heads as DATA.
|
|
59
|
+
Payroll knew exactly four earnings because they were columns on the salary
|
|
60
|
+
structure, so a bonus, an incentive, an LTA or a reimbursement had nowhere
|
|
61
|
+
to go but "other": one number on a payslip that should have said what it
|
|
62
|
+
was for. Seeded with bonus, incentive, arrears, LTA, reimbursement, loan
|
|
63
|
+
repayment and other-deduction; an install adds its own.
|
|
64
|
+
- Each head declares whether it is **prorated** (a bonus is not halved
|
|
65
|
+
because somebody joined on the 16th), **taxable**, and whether it
|
|
66
|
+
**counts toward the ESI gross** — a reimbursement is not a wage, and
|
|
67
|
+
counting it could push somebody over the ceiling and out of cover.
|
|
68
|
+
- **`hr_lite_payroll_line_items`** — one-offs for a single month, including
|
|
69
|
+
arrears after a backdated revision. Dated to the MONTH rather than the run,
|
|
70
|
+
so deleting a draft and computing it again does not lose them. Refused on a
|
|
71
|
+
month already published: that slip is immutable, and a later line would
|
|
72
|
+
show on no payslip while quietly moving the year-to-date the TDS projector
|
|
73
|
+
reads.
|
|
74
|
+
- **`hr_lite_loans`** and repayments. The outstanding balance is DERIVED from
|
|
75
|
+
the repayments actually taken, never stored — a stored balance and a
|
|
76
|
+
recomputed run disagree the first time somebody deletes a draft. The last
|
|
77
|
+
instalment takes only what is left.
|
|
78
|
+
- Repayments are booked when a run is **finalized**, not at compute: a draft
|
|
79
|
+
is recomputed as often as the operator likes, and each pass would otherwise
|
|
80
|
+
take another instalment. Unlocking a run gives them back, and reopens a
|
|
81
|
+
loan that had closed.
|
|
82
|
+
|
|
83
|
+
### Changed
|
|
84
|
+
|
|
85
|
+
- Branch coverage is now a **floor at 90%, not a ratchet**, with the reasoning
|
|
86
|
+
written into `spec_helper.rb`. Its denominator grows with every subsystem,
|
|
87
|
+
so ratcheting it charges each feature PR a tax for branches in files it
|
|
88
|
+
never touched. Line coverage remains the ratchet at 100% overall and 90%
|
|
89
|
+
per file — that is the gate that has actually caught defects.
|
|
90
|
+
- Paid down some of the branch debt named in 0.9.0: the status predicates
|
|
91
|
+
across six models, the UAN format validation, and the holiday bulk-import
|
|
92
|
+
paths (blank lines, an empty paste, a line that cannot be read).
|
|
93
|
+
|
|
10
94
|
## [0.9.0] - 2026-08-18
|
|
11
95
|
|
|
12
96
|
A reusable approval engine. **Adds one migration** (flows, steps, approvals,
|
|
@@ -610,7 +694,9 @@ Initial release.
|
|
|
610
694
|
bus with per-event channel matrix (bell, email, leadership email/bell),
|
|
611
695
|
daily leadership digest and an append-only audit trail.
|
|
612
696
|
|
|
613
|
-
[Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.
|
|
697
|
+
[Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.11.0...HEAD
|
|
698
|
+
[0.11.0]: https://github.com/kshtzkr/hr_lite/compare/v0.10.0...v0.11.0
|
|
699
|
+
[0.10.0]: https://github.com/kshtzkr/hr_lite/compare/v0.9.0...v0.10.0
|
|
614
700
|
[0.9.0]: https://github.com/kshtzkr/hr_lite/compare/v0.8.0...v0.9.0
|
|
615
701
|
[0.8.0]: https://github.com/kshtzkr/hr_lite/compare/v0.7.0...v0.8.0
|
|
616
702
|
[0.7.0]: https://github.com/kshtzkr/hr_lite/compare/v0.6.0...v0.7.0
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Tells people before a document lapses. A passport or a visa is somebody's
|
|
3
|
+
# right to work, and the whole problem with an expiry date is finding out
|
|
4
|
+
# about it late.
|
|
5
|
+
#
|
|
6
|
+
# Warns at 30 days and again at 7, rather than every day for a month.
|
|
7
|
+
class DocumentExpiryJob < ApplicationJob
|
|
8
|
+
WINDOWS = [ 30, 7 ].freeze
|
|
9
|
+
|
|
10
|
+
def perform(today: Date.current)
|
|
11
|
+
WINDOWS.each do |days|
|
|
12
|
+
Document.where(expires_on: today + days).includes(:user).find_each do |document|
|
|
13
|
+
Notifications.publish(
|
|
14
|
+
"document.expiring",
|
|
15
|
+
title: "#{document.title} expires in #{days} days",
|
|
16
|
+
body: "#{HrLite.display_name(document.user)} — #{document.category.humanize}, " \
|
|
17
|
+
"expiring #{document.expires_on.strftime('%d %b %Y')}.",
|
|
18
|
+
path: "/profile",
|
|
19
|
+
bell_to: [ document.user ],
|
|
20
|
+
email_to: [ document.user ],
|
|
21
|
+
leadership: { title: "#{HrLite.display_name(document.user)}'s #{document.title} " \
|
|
22
|
+
"expires in #{days} days" }
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# An employee's document: an Aadhaar scan, a PAN, an offer letter, a
|
|
3
|
+
# certificate. The FILE is the sensitive part, so visibility is a property
|
|
4
|
+
# of the row and every read goes through `readable_by?`.
|
|
5
|
+
class Document < ApplicationRecord
|
|
6
|
+
include Audited
|
|
7
|
+
|
|
8
|
+
VISIBILITIES = %w[self hr money].freeze
|
|
9
|
+
VERIFICATIONS = %w[pending verified rejected].freeze
|
|
10
|
+
|
|
11
|
+
# Categories the engine knows how to reason about. An install may store
|
|
12
|
+
# any string; these are the ones with an opinion attached.
|
|
13
|
+
SENSITIVE_CATEGORIES = %w[aadhaar pan passport bank].freeze
|
|
14
|
+
|
|
15
|
+
# Active Storage content-sniffs via Marcel, so this checks the real bytes
|
|
16
|
+
# rather than whatever the client claimed. SVG and HTML stay out — both
|
|
17
|
+
# can carry script and both render in a browser.
|
|
18
|
+
ALLOWED_TYPES = %w[
|
|
19
|
+
application/pdf image/jpeg image/png image/heic image/webp
|
|
20
|
+
].freeze
|
|
21
|
+
MAX_BYTES = 10.megabytes
|
|
22
|
+
|
|
23
|
+
has_one_attached :file
|
|
24
|
+
|
|
25
|
+
belongs_to :user, class_name: HrLite.config.user_class
|
|
26
|
+
belongs_to :verified_by, class_name: HrLite.config.user_class, optional: true
|
|
27
|
+
belongs_to :uploaded_by, class_name: HrLite.config.user_class, optional: true
|
|
28
|
+
|
|
29
|
+
validates :category, :title, presence: true
|
|
30
|
+
validates :visibility, inclusion: { in: VISIBILITIES }
|
|
31
|
+
validates :verification, inclusion: { in: VERIFICATIONS }
|
|
32
|
+
validate :file_is_an_allowed_kind
|
|
33
|
+
validate :file_is_not_too_large
|
|
34
|
+
before_validation :default_visibility_from_category, on: :create
|
|
35
|
+
|
|
36
|
+
scope :for_user, ->(user) { where(user_id: user.id) }
|
|
37
|
+
scope :expiring_before, ->(date) { where.not(expires_on: nil).where(expires_on: ..date) }
|
|
38
|
+
scope :recent_first, -> { order(created_at: :desc) }
|
|
39
|
+
|
|
40
|
+
VERIFICATIONS.each { |v| define_method("#{v}?") { verification == v } }
|
|
41
|
+
|
|
42
|
+
def expired?(on = Date.current) = expires_on.present? && expires_on < on
|
|
43
|
+
|
|
44
|
+
# Who may open the FILE. The owner always may; beyond that it is the
|
|
45
|
+
# permission the row's visibility names. A passport is not a payslip and
|
|
46
|
+
# neither is HR's to browse by default.
|
|
47
|
+
def readable_by?(reader)
|
|
48
|
+
return false if reader.nil?
|
|
49
|
+
return true if reader.id == user_id
|
|
50
|
+
|
|
51
|
+
case visibility
|
|
52
|
+
when "self" then false
|
|
53
|
+
when "hr" then HrLite.reaches?(reader, "document.view", user)
|
|
54
|
+
when "money" then HrLite.can?(reader, "document.manage", scope: :all)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def verify!(actor:, note: nil)
|
|
59
|
+
update!(verification: "verified", verified_by_id: actor.id,
|
|
60
|
+
verified_at: Time.current, verification_note: note.presence)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def reject!(actor:, note:)
|
|
64
|
+
raise ArgumentError, "a rejection needs a reason" if note.blank?
|
|
65
|
+
|
|
66
|
+
update!(verification: "rejected", verified_by_id: actor.id,
|
|
67
|
+
verified_at: Time.current, verification_note: note)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# An Aadhaar or a bank mandate defaults to the tightest setting rather
|
|
73
|
+
# than the convenient one — a default nobody thinks about is the one that
|
|
74
|
+
# leaks. An explicit choice always wins, which is why the column carries
|
|
75
|
+
# no default of its own.
|
|
76
|
+
def default_visibility_from_category
|
|
77
|
+
return if visibility.present?
|
|
78
|
+
|
|
79
|
+
self.visibility = SENSITIVE_CATEGORIES.include?(category.to_s) ? "money" : "hr"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def file_is_an_allowed_kind
|
|
83
|
+
return unless file.attached?
|
|
84
|
+
return if ALLOWED_TYPES.include?(file.blob.content_type)
|
|
85
|
+
|
|
86
|
+
errors.add(:file, "must be a PDF or an image — #{file.blob.content_type} is not accepted")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def file_is_not_too_large
|
|
90
|
+
return unless file.attached? && file.blob.byte_size > MAX_BYTES
|
|
91
|
+
|
|
92
|
+
errors.add(:file, "must be under #{MAX_BYTES / 1.megabyte} MB")
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -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
|
|
@@ -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
|
|
@@ -27,6 +27,13 @@ module HrLite
|
|
|
27
27
|
# An approval past its deadline. Leadership hears too — a decision
|
|
28
28
|
# nobody is making is a process problem, not just one person's inbox.
|
|
29
29
|
"approval.escalated" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
|
|
30
|
+
# Tax declarations. The submission bells whoever verifies them; the
|
|
31
|
+
# decision goes back to the employee, whose year's TDS depends on it.
|
|
32
|
+
"tax.declaration_submitted" => { bell: true, email: false, leadership_email: false, leadership_bell: false },
|
|
33
|
+
"tax.declaration_decided" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
|
|
34
|
+
# A document about to lapse — a passport or a visa is somebody's right
|
|
35
|
+
# to work, and finding out late is the whole problem.
|
|
36
|
+
"document.expiring" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
|
|
30
37
|
"digest.daily" => { bell: false, email: false, leadership_email: true, leadership_bell: false },
|
|
31
38
|
"resignation.submitted" => { bell: true, email: false, leadership_email: true, leadership_bell: true },
|
|
32
39
|
"resignation.accepted" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
|
data/lib/hr_lite/permissions.rb
CHANGED
|
@@ -43,6 +43,10 @@ module HrLite
|
|
|
43
43
|
"appraisal.manage" => [ "Growth", "Write, share and act on appraisals and promotions" ],
|
|
44
44
|
"resignation.view" => [ "Lifecycle", "See resignations" ],
|
|
45
45
|
"resignation.manage" => [ "Lifecycle", "Accept resignations and set the last working day" ],
|
|
46
|
+
"document.view" => [ "Documents", "Open other people's documents" ],
|
|
47
|
+
"document.manage" => [ "Documents", "Upload, verify and open identity and bank documents" ],
|
|
48
|
+
"tax.view" => [ "Payroll", "See tax declarations" ],
|
|
49
|
+
"tax.manage" => [ "Payroll", "Verify tax declarations and the proof behind them" ],
|
|
46
50
|
"settings.manage" => [ "Administration", "Change company settings, offices and policy" ],
|
|
47
51
|
"audit.view" => [ "Administration", "Read the audit trail" ],
|
|
48
52
|
"audit.view_money" => [ "Administration", "Read audit rows about pay, appraisals and promotions" ],
|
data/lib/hr_lite/role_seeds.rb
CHANGED
|
@@ -17,7 +17,7 @@ module HrLite
|
|
|
17
17
|
"leave.request" => "self", "leave.view" => "self",
|
|
18
18
|
"attendance.view" => "self", "payroll.view" => "self",
|
|
19
19
|
"profile.view" => "self", "appraisal.view" => "self",
|
|
20
|
-
"resignation.view" => "self"
|
|
20
|
+
"resignation.view" => "self", "document.view" => "self", "tax.view" => "self"
|
|
21
21
|
}
|
|
22
22
|
},
|
|
23
23
|
Role::MANAGER => {
|
|
@@ -35,7 +35,8 @@ module HrLite
|
|
|
35
35
|
"leave.request" => "self", "leave.view" => "all", "leave.approve" => "all",
|
|
36
36
|
"leave.manage" => "all", "attendance.view" => "all", "attendance.manage" => "all",
|
|
37
37
|
"profile.view" => "all", "payroll.view" => "self",
|
|
38
|
-
"appraisal.view" => "self", "resignation.view" => "all"
|
|
38
|
+
"appraisal.view" => "self", "resignation.view" => "all",
|
|
39
|
+
"document.view" => "all", "tax.view" => "self"
|
|
39
40
|
}
|
|
40
41
|
},
|
|
41
42
|
Role::FINANCE => {
|
|
@@ -45,6 +46,7 @@ module HrLite
|
|
|
45
46
|
"attendance.view" => "all", "profile.view" => "all",
|
|
46
47
|
"payroll.view" => "all", "payroll.manage" => "all", "payroll.export" => "all",
|
|
47
48
|
"salary.view" => "all", "salary.manage" => "all",
|
|
49
|
+
"tax.view" => "all", "tax.manage" => "all",
|
|
48
50
|
"audit.view" => "all", "audit.view_money" => "all"
|
|
49
51
|
}
|
|
50
52
|
},
|
|
@@ -55,7 +57,8 @@ module HrLite
|
|
|
55
57
|
"leave.manage" => "all", "attendance.view" => "all", "attendance.manage" => "all",
|
|
56
58
|
"profile.view" => "all", "profile.manage" => "all",
|
|
57
59
|
"resignation.view" => "all", "resignation.manage" => "all",
|
|
58
|
-
"settings.manage" => "all", "audit.view" => "all", "payroll.view" => "self"
|
|
60
|
+
"settings.manage" => "all", "audit.view" => "all", "payroll.view" => "self",
|
|
61
|
+
"document.view" => "all"
|
|
59
62
|
}
|
|
60
63
|
},
|
|
61
64
|
Role::SUPER_ADMIN => {
|
data/lib/hr_lite/version.rb
CHANGED
|
@@ -4,7 +4,8 @@ namespace :hr_lite do
|
|
|
4
4
|
require "hr_lite/seeds"
|
|
5
5
|
require "hr_lite/role_seeds"
|
|
6
6
|
require "hr_lite/statutory_seeds"
|
|
7
|
-
created = HrLite::Seeds.run! + HrLite::RoleSeeds.call + HrLite::StatutorySeeds.call
|
|
7
|
+
created = HrLite::Seeds.run! + HrLite::RoleSeeds.call + HrLite::StatutorySeeds.call +
|
|
8
|
+
HrLite::SalaryComponent.seed_defaults!
|
|
8
9
|
puts created.any? ? "hr_lite:seed created: #{created.join(', ')}" : "hr_lite:seed — nothing to do"
|
|
9
10
|
end
|
|
10
11
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hr_lite
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.11.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- kshitiz sinha
|
|
@@ -102,6 +102,7 @@ files:
|
|
|
102
102
|
- app/jobs/hr_lite/application_job.rb
|
|
103
103
|
- app/jobs/hr_lite/approval_escalation_job.rb
|
|
104
104
|
- app/jobs/hr_lite/daily_digest_job.rb
|
|
105
|
+
- app/jobs/hr_lite/document_expiry_job.rb
|
|
105
106
|
- app/jobs/hr_lite/leave_year_rollover_job.rb
|
|
106
107
|
- app/jobs/hr_lite/payroll_auto_draft_job.rb
|
|
107
108
|
- app/mailers/hr_lite/application_mailer.rb
|
|
@@ -119,6 +120,7 @@ files:
|
|
|
119
120
|
- app/models/hr_lite/audit_log.rb
|
|
120
121
|
- app/models/hr_lite/comp_off_request.rb
|
|
121
122
|
- app/models/hr_lite/designation_change.rb
|
|
123
|
+
- app/models/hr_lite/document.rb
|
|
122
124
|
- app/models/hr_lite/employee_profile.rb
|
|
123
125
|
- app/models/hr_lite/holiday.rb
|
|
124
126
|
- app/models/hr_lite/kudo.rb
|
|
@@ -126,7 +128,10 @@ files:
|
|
|
126
128
|
- app/models/hr_lite/leave_balance.rb
|
|
127
129
|
- app/models/hr_lite/leave_request.rb
|
|
128
130
|
- app/models/hr_lite/leave_type.rb
|
|
131
|
+
- app/models/hr_lite/loan.rb
|
|
132
|
+
- app/models/hr_lite/loan_repayment.rb
|
|
129
133
|
- app/models/hr_lite/office_location.rb
|
|
134
|
+
- app/models/hr_lite/payroll_line_item.rb
|
|
130
135
|
- app/models/hr_lite/payroll_run.rb
|
|
131
136
|
- app/models/hr_lite/professional_tax_slab.rb
|
|
132
137
|
- app/models/hr_lite/regularization_request.rb
|
|
@@ -134,10 +139,13 @@ files:
|
|
|
134
139
|
- app/models/hr_lite/role.rb
|
|
135
140
|
- app/models/hr_lite/role_assignment.rb
|
|
136
141
|
- app/models/hr_lite/role_grant.rb
|
|
142
|
+
- app/models/hr_lite/salary_component.rb
|
|
137
143
|
- app/models/hr_lite/salary_slip.rb
|
|
138
144
|
- app/models/hr_lite/salary_structure.rb
|
|
139
145
|
- app/models/hr_lite/setting.rb
|
|
140
146
|
- app/models/hr_lite/statutory_rate_card_record.rb
|
|
147
|
+
- app/models/hr_lite/tax_declaration.rb
|
|
148
|
+
- app/models/hr_lite/tax_declaration_item.rb
|
|
141
149
|
- app/services/hr_lite/access.rb
|
|
142
150
|
- app/services/hr_lite/approval_route.rb
|
|
143
151
|
- app/services/hr_lite/attendance_puncher.rb
|
|
@@ -273,6 +281,8 @@ files:
|
|
|
273
281
|
- db/migrate/20260817183629_seed_hr_lite_roles_from_email_tiers.rb
|
|
274
282
|
- db/migrate/20260817190159_create_hr_lite_statutory_rate_cards.rb
|
|
275
283
|
- db/migrate/20260818001151_create_hr_lite_approvals.rb
|
|
284
|
+
- db/migrate/20260818002627_create_hr_lite_payroll_components_and_loans.rb
|
|
285
|
+
- db/migrate/20260818003616_create_hr_lite_documents_and_declarations.rb
|
|
276
286
|
- lib/generators/hr_lite/install/install_generator.rb
|
|
277
287
|
- lib/generators/hr_lite/install/templates/AFTER_INSTALL
|
|
278
288
|
- lib/generators/hr_lite/install/templates/initializer.rb
|