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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 42a084897bd2831303410129ecaa25dac81fdb97b711bfe07071cf28496d58d1
4
- data.tar.gz: 014663d43940503d5fa840f5bb0460b8fd3e20bfbaf07a1aa6821b491798d16d
3
+ metadata.gz: 655b4d27557dace6262528eb82af6912b4b7dfcdc3ff6b8274068876b8bb970c
4
+ data.tar.gz: ba15eee422c61704681db9d8d1194851f8c761459524844ade6e4cd054776f81
5
5
  SHA512:
6
- metadata.gz: 40177d861f9579695084b3e0bca3fefd4dd9593d430d800c7c1bfed2c88d1de4820fd12c6c816c24cf29f0d70eb8db26efe2c9cc10870ec1f9be15b262c05f1d
7
- data.tar.gz: 261892abb65e4ebb80f12cdfbdf7763d2e82f9040268efa796870ec6af413cc25079e9e124536e2eb0a4e1a937e0de6a98f98fde84901248a91a3ea944f684bf
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.9.0...HEAD
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: 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: @profile.tax_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: @profile.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 },
@@ -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" ],
@@ -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 => {
@@ -1,3 +1,3 @@
1
1
  module HrLite
2
- VERSION = "0.9.0"
2
+ VERSION = "0.11.0"
3
3
  end
@@ -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.9.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