hr_lite 0.23.1 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +49 -0
  3. data/README.md +4 -1
  4. data/app/assets/javascripts/hr_lite/tax_preview.js +38 -0
  5. data/app/assets/stylesheets/hr_lite/hr_lite.css +12 -1
  6. data/app/controllers/hr_lite/admin/ca_reviews_controller.rb +76 -0
  7. data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +14 -4
  8. data/app/controllers/hr_lite/admin/salary_slips_controller.rb +4 -0
  9. data/app/controllers/hr_lite/admin/salary_structures_controller.rb +6 -16
  10. data/app/controllers/hr_lite/admin/tax_declarations_controller.rb +46 -0
  11. data/app/controllers/hr_lite/tax_declarations_controller.rb +19 -1
  12. data/app/controllers/hr_lite/timelines_controller.rb +2 -1
  13. data/app/helpers/hr_lite/application_helper.rb +7 -0
  14. data/app/jobs/hr_lite/ca_review_overdue_job.rb +19 -0
  15. data/app/jobs/hr_lite/ca_review_reminder_job.rb +21 -0
  16. data/app/jobs/hr_lite/payroll_auto_approve_job.rb +21 -0
  17. data/app/jobs/hr_lite/payroll_auto_draft_job.rb +1 -1
  18. data/app/models/hr_lite/audit_log.rb +1 -1
  19. data/app/models/hr_lite/ca_review.rb +66 -0
  20. data/app/models/hr_lite/payroll_run.rb +47 -3
  21. data/app/models/hr_lite/role.rb +3 -0
  22. data/app/models/hr_lite/salary_structure.rb +22 -8
  23. data/app/models/hr_lite/statutory_rate_card_record.rb +6 -6
  24. data/app/models/hr_lite/tax_declaration.rb +24 -0
  25. data/app/models/hr_lite/tax_declaration_item.rb +56 -4
  26. data/app/services/hr_lite/calculators/tds.rb +7 -6
  27. data/app/services/hr_lite/payroll_run_processor.rb +9 -0
  28. data/app/services/hr_lite/slip_builder.rb +1 -1
  29. data/app/services/hr_lite/tax_computation.rb +61 -0
  30. data/app/services/hr_lite/timeline.rb +10 -1
  31. data/app/views/hr_lite/admin/ca_reviews/index.html.erb +94 -0
  32. data/app/views/hr_lite/admin/employees/show.html.erb +3 -1
  33. data/app/views/hr_lite/admin/payroll_runs/show.html.erb +17 -7
  34. data/app/views/hr_lite/admin/salary_structures/_form.html.erb +15 -7
  35. data/app/views/hr_lite/admin/statutory_rate_cards/_form.html.erb +1 -1
  36. data/app/views/hr_lite/admin/tax_declarations/index.html.erb +1 -0
  37. data/app/views/hr_lite/admin/tax_declarations/overview.html.erb +39 -0
  38. data/app/views/hr_lite/admin/tax_declarations/person.html.erb +10 -0
  39. data/app/views/hr_lite/admin/tax_declarations/show.html.erb +19 -2
  40. data/app/views/hr_lite/leave_requests/_balance_chips.html.erb +6 -4
  41. data/app/views/hr_lite/tax/_computation.html.erb +56 -0
  42. data/app/views/hr_lite/tax_declarations/show.html.erb +61 -59
  43. data/app/views/hr_lite/timelines/show.html.erb +1 -0
  44. data/config/routes.rb +8 -2
  45. data/db/migrate/20261010100000_add_ctc_to_hr_lite_salary_structures.rb +9 -0
  46. data/db/migrate/20261010100100_add_approval_to_hr_lite_payroll_runs.rb +10 -0
  47. data/db/migrate/20261010100200_grant_hr_lite_payroll_approve.rb +22 -0
  48. data/db/migrate/20261010100300_seed_hr_lite_fy2026_rate_cards.rb +16 -0
  49. data/db/migrate/20261010100400_add_tax_fields_to_hr_lite_tax_declaration_items.rb +9 -0
  50. data/db/migrate/20261010100500_grant_hr_lite_tax_to_hr.rb +12 -0
  51. data/db/migrate/20261010110000_create_hr_lite_ca_reviews.rb +21 -0
  52. data/db/migrate/20261010110100_seed_hr_lite_ca_role.rb +14 -0
  53. data/lib/hr_lite/notifications.rb +5 -0
  54. data/lib/hr_lite/permissions.rb +2 -0
  55. data/lib/hr_lite/role_seeds.rb +9 -3
  56. data/lib/hr_lite/statutory_rate_card.rb +65 -43
  57. data/lib/hr_lite/statutory_seeds.rb +4 -2
  58. data/lib/hr_lite/version.rb +1 -1
  59. data/lib/hr_lite.rb +9 -1
  60. metadata +20 -1
@@ -9,6 +9,11 @@ module HrLite
9
9
  belongs_to :created_by, class_name: HrLite.config.user_class, optional: true
10
10
  belongs_to :finalized_by, class_name: HrLite.config.user_class, optional: true
11
11
  belongs_to :published_by, class_name: HrLite.config.user_class, optional: true
12
+ belongs_to :first_approved_by, class_name: HrLite.config.user_class, optional: true
13
+
14
+ # A run with one of these is not paid on the 3rd without a person looking:
15
+ # someone missing, a state levying ₹0, or last year's / unverified rates.
16
+ BLOCKING_WARNING = /\ANo (salary structure|professional-tax slabs)|statutory card/
12
17
 
13
18
  validates :period_month, presence: true, uniqueness: true
14
19
  validates :status, inclusion: { in: STATUSES }
@@ -40,7 +45,8 @@ module HrLite
40
45
  update!(status: "processing")
41
46
  PayrollRunProcessor.call(self)
42
47
  transaction do
43
- update!(status: "review", processed_at: Time.current)
48
+ # New numbers: an approval of the old ones no longer counts.
49
+ update!(status: "review", processed_at: Time.current, first_approved_by_id: nil, first_approved_at: nil)
44
50
  audit!("payroll.computed", actor,
45
51
  "slips" => salary_slips.count, "warnings" => warnings.length,
46
52
  "from_status" => previous)
@@ -60,7 +66,7 @@ module HrLite
60
66
  raise ActiveRecord::RecordInvalid.new(self), "no slips" if salary_slips.none?
61
67
 
62
68
  transaction do
63
- update!(status: "finalized", finalized_at: Time.current, finalized_by_id: actor.id)
69
+ update!(status: "finalized", finalized_at: Time.current, finalized_by_id: actor&.id)
64
70
  # Finalizing freezes every slip in the run. Who did it, to how many
65
71
  # people, is the row an investigation starts from.
66
72
  audit!("payroll.finalized", actor, "slips" => salary_slips.count)
@@ -74,6 +80,34 @@ module HrLite
74
80
  true
75
81
  end
76
82
 
83
+ # Two people approve a reviewed run, at least one of them able to run
84
+ # payroll; the second approval finalizes and publishes it.
85
+ def approve!(actor:)
86
+ raise_unless %w[review]
87
+ unless HrLite.can?(actor, "payroll.approve", scope: :all)
88
+ raise ActiveRecord::RecordInvalid.new(self), "not an approver"
89
+ end
90
+ return stamp_first_approval!(actor) if first_approved_by_id.nil?
91
+ if first_approved_by_id == actor.id || [ first_approved_by, actor ].none? { |u| HrLite.can?(u, "payroll.manage", scope: :all) }
92
+ raise ActiveRecord::RecordInvalid.new(self), "needs a second, different approver"
93
+ end
94
+
95
+ finalize!(actor: actor) && publish!(actor: actor)
96
+ end
97
+
98
+ def blocking_warnings = warnings.grep(BLOCKING_WARNING)
99
+
100
+ # The 3rd-of-month backstop: a clean run nobody finished approving is
101
+ # approved by the system and says so; a run with blocking warnings waits.
102
+ def auto_approve!
103
+ raise_unless %w[review]
104
+ return false if blocking_warnings.any?
105
+
106
+ update!(auto_approved_at: Time.current)
107
+ audit!("payroll.auto_approved", nil, "slips" => salary_slips.count)
108
+ finalize!(actor: nil) && publish!(actor: nil)
109
+ end
110
+
77
111
  def unlock!(actor:)
78
112
  raise_unless %w[finalized]
79
113
  transaction do
@@ -92,7 +126,7 @@ module HrLite
92
126
  def publish!(actor:)
93
127
  raise_unless %w[finalized]
94
128
  transaction do
95
- update!(status: "published", published_at: Time.current, published_by_id: actor.id)
129
+ update!(status: "published", published_at: Time.current, published_by_id: actor&.id)
96
130
  audit!("payroll.published", actor, "slips" => salary_slips.count)
97
131
  end
98
132
 
@@ -145,6 +179,16 @@ module HrLite
145
179
 
146
180
  private
147
181
 
182
+ def stamp_first_approval!(actor)
183
+ update!(first_approved_by_id: actor.id, first_approved_at: Time.current)
184
+ audit!("payroll.approved", actor, "slips" => salary_slips.count, "step" => 1)
185
+ others = HrLite.users_holding("payroll.approve", scope: :all).reject { |u| u.id == actor.id }
186
+ Notifications.publish("payroll.approval_needed",
187
+ title: "Payroll #{label}: #{HrLite.display_name(actor)} approved — one more approval publishes it",
188
+ path: "/admin/payroll_runs/#{id}", bell_to: others, email_to: others)
189
+ true
190
+ end
191
+
148
192
  # A repayment is booked when the run is FINALIZED, never at compute: a
149
193
  # draft is recomputed as often as the operator likes, and each pass would
150
194
  # otherwise take another instalment off the loan.
@@ -12,6 +12,9 @@ module HrLite
12
12
  FINANCE = "Finance".freeze
13
13
  LEADERSHIP = "Leadership".freeze
14
14
  SUPER_ADMIN = "Super Admin".freeze
15
+ # An outside chartered accountant: reads pay and tax to verify them at
16
+ # year end. Not an employee — see HrLite.employees.
17
+ CA = "CA".freeze
15
18
 
16
19
  has_many :role_grants, dependent: :destroy
17
20
  has_many :role_assignments, dependent: :destroy
@@ -10,9 +10,15 @@ module HrLite
10
10
  belongs_to :user, class_name: HrLite.config.user_class
11
11
  belongs_to :created_by, class_name: HrLite.config.user_class, optional: true
12
12
 
13
- encrypted_money :basic, :hra, :special_allowance, :other_earnings
14
- # Typed on the form to fill the lines; CTC is always derived, never stored.
15
- attribute :annual_ctc, :decimal
13
+ encrypted_money :basic, :hra, :special_allowance, :other_earnings, :annual_ctc
14
+
15
+ # Gratuity accrues at 15/26 days of Basic a year (4.81%), inside the CTC.
16
+ GRATUITY_RATE = BigDecimal("0.0481")
17
+
18
+ # The CTC is the input: saving one splits it, so no line is typed by hand.
19
+ before_validation :fill_from_ctc, if: -> { annual_ctc.present? }
20
+ # The admin form saves in this context: people only ever type the CTC.
21
+ validates :annual_ctc, presence: true, on: :ctc_form
16
22
 
17
23
  validates :effective_from, presence: true, uniqueness: { scope: :user_id }
18
24
  validates :basic, presence: true
@@ -49,7 +55,7 @@ module HrLite
49
55
  earnings, deductions, employer = [
50
56
  { "Basic" => basic, "HRA" => hra, "Special allowance" => special_allowance, "Other" => other_earnings },
51
57
  { "PF" => pf&.employee, "ESI" => esi.employee, "Professional tax" => pts.first },
52
- { "PF" => pf && (pf.employer_eps + pf.employer_epf), "ESI" => esi.employer }
58
+ { "PF" => pf && (pf.employer_eps + pf.employer_epf), "ESI" => esi.employer, "Gratuity" => gratuity }
53
59
  ].map { |rows| rows.select { |_, amount| Money.d(amount).positive? } }
54
60
  monthly_ctc = gross + employer.values.sum(BigDecimal(0))
55
61
  in_hand = gross - deductions.values.sum(BigDecimal(0))
@@ -76,14 +82,22 @@ module HrLite
76
82
  (earlier || self).monthly_gross
77
83
  end
78
84
 
79
- # Owner's split of annual_ctc: Basic 50% of the monthly CTC, HRA 40% of
80
- # Basic, employer PF and ESI paid out of the CTC; Special takes the rest.
85
+ # Only a CTC-split structure carries gratuity in its CTC; older hand-typed
86
+ # ones never did, and their CTC must not move.
87
+ def gratuity
88
+ Money.round_rupee(Money.d(basic) * GRATUITY_RATE) if annual_ctc.present? && basic
89
+ end
90
+
91
+ # Owner's split of annual_ctc (the labour codes' 50% wage floor): Basic 50%
92
+ # of the monthly CTC, HRA 50% of Basic in a metro and 40% elsewhere,
93
+ # employer PF, ESI and gratuity paid out of the CTC; Special takes the rest.
81
94
  def fill_from_ctc(on: effective_from || Date.current)
82
95
  monthly_ctc = Money.round_rupee(Money.d(annual_ctc) / 12)
83
96
  self.basic = Money.round_rupee(monthly_ctc / 2)
84
- self.hra = Money.round_rupee(basic * BigDecimal("0.4"))
97
+ self.hra = Money.round_rupee(basic * BigDecimal(metro ? "0.5" : "0.4"))
98
+ self.other_earnings = nil
85
99
  self.special_allowance = 0
86
- rest = monthly_ctc - Money.d(breakup(on: on).dig(:employer, "PF"))
100
+ rest = monthly_ctc - Money.d(breakup(on: on).dig(:employer, "PF")) - Money.d(gratuity)
87
101
  # Employer ESI is a share of the gross beside it: gross + ESI = rest.
88
102
  gross = (rest / (1 + StatutoryRateCard.for(on.beginning_of_month)[:esi][:employer_rate])).floor
89
103
  self.special_allowance = gross - basic - hra - Money.d(other_earnings)
@@ -53,14 +53,14 @@ module HrLite
53
53
  hash.to_h { |key, value| [ key, Money.d(value) ] }
54
54
  end
55
55
 
56
- # A card dated mid-year would mean two sets of slabs inside one financial
57
- # year, and the TDS projector works on an annual figure — there is no
58
- # sensible answer to "which slabs applied to this year's income" then.
56
+ # Cards take effect on the 1st of a month. Most open a financial year on
57
+ # 1 April; a mid-year card exists for a statutory change notified inside a
58
+ # year (the EPF wage ceiling, 17 Sep 2026) and repeats that year's tax
59
+ # slabs, because TDS projects one annual figure.
59
60
  def effective_from_opens_a_financial_year
60
- return if effective_from.blank?
61
- return if effective_from == FinancialYear.start_for(effective_from)
61
+ return if effective_from.blank? || effective_from.day == 1
62
62
 
63
- errors.add(:effective_from, "must be 1 April — a card covers a whole financial year")
63
+ errors.add(:effective_from, "must be the 1st of a month (1 April for a new financial year)")
64
64
  end
65
65
 
66
66
  REQUIRED = {
@@ -47,6 +47,30 @@ module HrLite
47
47
  tax_declaration_items.sum(BigDecimal(0)) { |i| i.verified_amount || BigDecimal(0) }
48
48
  end
49
49
 
50
+ Deduction = Struct.new(:section, :label, :claimed, :allowed, :cap, keyword_init: true)
51
+
52
+ # Old-regime lines with the law applied: each section's limit, and HRA
53
+ # worked out from the rent against the salary structure — never typed.
54
+ def deduction_rows(structure:)
55
+ tax_declaration_items.reject { |item| item.declared_amount.nil? || item.marked_for_destruction? }.map do |item|
56
+ allowed = item.section == "hra" ? self.class.hra_exemption(item.allowed(verified: verified?), structure) : item.allowed(verified: verified?)
57
+ Deduction.new(section: item.section, label: item.section_label, claimed: Money.d(item.declared_amount),
58
+ allowed: allowed, cap: item.cap)
59
+ end
60
+ end
61
+
62
+ def old_regime_deductions(structure:) = deduction_rows(structure: structure).sum(BigDecimal(0), &:allowed)
63
+
64
+ # Annual HRA exemption: the least of the HRA received, rent less 10% of
65
+ # Basic, and 50% of Basic in a metro (40% elsewhere).
66
+ def self.hra_exemption(rent, structure)
67
+ return BigDecimal(0) if structure.nil? || Money.d(rent).zero?
68
+
69
+ basic = Money.d(structure.basic) * 12
70
+ [ Money.d(structure.hra) * 12, Money.d(rent) - basic / 10, basic * BigDecimal(structure.metro ? "0.5" : "0.4") ]
71
+ .min.then { |amount| Money.round_rupee([ amount, BigDecimal(0) ].max) }
72
+ end
73
+
50
74
  def submit!(actor:)
51
75
  raise ActiveRecord::RecordInvalid.new(self), "not a draft" unless draft? || rejected?
52
76
 
@@ -4,22 +4,55 @@ module HrLite
4
4
  # `verified_amount` is what the proof actually supported, which is often
5
5
  # less than what was claimed and is the number payroll uses once HR has
6
6
  # looked. Both are encrypted: what somebody invests is their business.
7
+ # The legal limit is applied on top, so nobody's arithmetic decides it.
7
8
  class TaxDeclarationItem < ApplicationRecord
8
9
  include EncryptedMoney
9
10
 
10
- SECTIONS = %w[80c 80d 80ccd_1b 24b hra other].freeze
11
+ SECTIONS = %w[80c 80ccd_1b 80d 80d_parents 24b hra other].freeze
12
+
13
+ # Annual limits (old regime). 80D doubles for a senior; HRA is computed
14
+ # from rent; "other" has no single limit.
15
+ CAPS = { "80c" => 150_000, "80ccd_1b" => 50_000, "24b" => 200_000,
16
+ "80d" => 25_000, "80d_parents" => 25_000 }.freeze
17
+ SENIOR_80D_CAP = 50_000
18
+ RENT_NEEDS_LANDLORD_PAN = 100_000
19
+ PROOF_TYPES = %w[application/pdf image/jpeg image/png].freeze
20
+ PROOF_MAX_BYTES = 5.megabytes
11
21
 
12
22
  encrypted_money :declared_amount, :verified_amount
23
+ encrypts :landlord_pan
24
+ normalizes :landlord_pan, with: ->(pan) { pan.to_s.strip.upcase.presence }
13
25
 
14
26
  belongs_to :declaration, class_name: "HrLite::TaxDeclaration"
27
+ has_many_attached :proofs
15
28
 
16
29
  validates :section, inclusion: { in: SECTIONS }
17
30
  validate :amounts_are_not_negative
31
+ validate :landlord_pan_for_large_rent
32
+ validate :proofs_are_documents
18
33
 
34
+ # Labels name the Income-tax Act 2025 section with the 1961 one people know.
19
35
  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)
36
+ { "80c" => "Sec 123 (old 80C) — PF, PPF, ELSS, LIC, tuition fees, home-loan principal",
37
+ "80ccd_1b" => "Sec 124 (old 80CCD(1B)) — your own extra NPS",
38
+ "80d" => "Sec 126 (old 80D) — health insurance for you and family",
39
+ "80d_parents" => "Sec 126 (old 80D) — health insurance for parents",
40
+ "24b" => "Home-loan interest (old 24(b))",
41
+ "hra" => "Rent paid in the year (for the HRA exemption)",
42
+ "other" => "Other deductions" }.fetch(section, section)
43
+ end
44
+
45
+ def cap
46
+ return SENIOR_80D_CAP if senior && section.start_with?("80d")
47
+
48
+ CAPS[section]
49
+ end
50
+
51
+ # What payroll counts for this line: the claim until HR has checked the
52
+ # proof, then what the proof supported — never above the legal limit.
53
+ def allowed(verified:)
54
+ base = verified ? Money.d(verified_amount) : Money.d(declared_amount)
55
+ cap ? [ base, BigDecimal(cap) ].min : base
23
56
  end
24
57
 
25
58
  private
@@ -28,5 +61,24 @@ module HrLite
28
61
  errors.add(:declared_amount, "cannot be negative") if declared_amount&.negative?
29
62
  errors.add(:verified_amount, "cannot be negative") if verified_amount&.negative?
30
63
  end
64
+
65
+ def landlord_pan_for_large_rent
66
+ return unless section == "hra" && Money.d(declared_amount) > RENT_NEEDS_LANDLORD_PAN
67
+
68
+ if landlord_pan.blank?
69
+ errors.add(:landlord_pan, "is needed when rent is above ₹1,00,000 a year")
70
+ elsif !landlord_pan.match?(/\A[A-Z]{5}[0-9]{4}[A-Z]\z/)
71
+ errors.add(:landlord_pan, "is not a valid PAN (like ABCDE1234F)")
72
+ end
73
+ end
74
+
75
+ def proofs_are_documents
76
+ proofs.each do |proof|
77
+ next if PROOF_TYPES.include?(proof.blob.content_type) && proof.blob.byte_size <= PROOF_MAX_BYTES
78
+
79
+ errors.add(:proofs, "must be PDF, JPG or PNG files of up to 5 MB")
80
+ break
81
+ end
82
+ end
31
83
  end
32
84
  end
@@ -67,15 +67,16 @@ module HrLite
67
67
  )
68
68
  end
69
69
 
70
- def self.slab_tax_for(taxable, slabs)
71
- slabs.sum(BigDecimal(0)) do |lower, upper, rate|
72
- next BigDecimal(0) if taxable <= lower
70
+ def self.slab_tax_for(taxable, slabs) = slab_rows(taxable, slabs).sum(BigDecimal(0)) { |row| row[:tax] }
71
+ private_class_method :slab_tax_for
73
72
 
74
- span_top = upper && taxable > upper ? upper : taxable
75
- (span_top - lower) * rate
73
+ # Each slab with the income that falls in it and the tax on that part.
74
+ def self.slab_rows(taxable, slabs)
75
+ slabs.map do |lower, upper, rate|
76
+ span = taxable <= lower ? BigDecimal(0) : (upper && taxable > upper ? upper : taxable) - lower
77
+ { from: lower, to: upper, rate: rate, income: span, tax: span * rate }
76
78
  end
77
79
  end
78
- private_class_method :slab_tax_for
79
80
  end
80
81
  end
81
82
  end
@@ -61,6 +61,15 @@ module HrLite
61
61
  "it genuinely levies no PT."
62
62
  end
63
63
 
64
+ # Only approved leave and fixes reach DayStatus; one still waiting is paid as loss of pay.
65
+ month = @run.period_month.all_month
66
+ pending = LeaveRequest.pending.overlapping_range(month.begin, month.end).count +
67
+ RegularizationRequest.pending.where(date: month).count
68
+ if pending.positive?
69
+ warnings << "#{pending} leave or attendance #{pending == 1 ? 'request is' : 'requests are'} still pending for " \
70
+ "#{@run.label} — they count as loss of pay until approved; approve or reject, then recompute."
71
+ end
72
+
64
73
  @run.salary_slips.where.not(id: keep_ids).destroy_all
65
74
  @run.update!(warnings: warnings)
66
75
  end
@@ -138,7 +138,7 @@ module HrLite
138
138
  # A rejected declaration lowers nothing: its self-declared total stays editable and unchecked.
139
139
  return @profile.declared_annual_deductions if declaration.draft? || declaration.rejected?
140
140
 
141
- declaration.allowable_total
141
+ declaration.old_regime_deductions(structure: @structure)
142
142
  end
143
143
 
144
144
  # Splits this month's one-off lines into earnings and deductions, in the
@@ -0,0 +1,61 @@
1
+ module HrLite
2
+ # Somebody's income tax for the financial year, worked out now from what
3
+ # is true now: the salary structure in force, the slips already paid, and
4
+ # their declaration with the legal limits applied. Both regimes, with every
5
+ # step shown, so "why is my TDS X" answers itself. Uses the same TDS
6
+ # calculator payroll uses, so the screen and the slip never disagree.
7
+ class TaxComputation
8
+ Sheet = Struct.new(:regime, :gross, :hra_exemption, :standard_deduction, :deductions, :taxable,
9
+ :slabs, :annual_tax, :tds_paid, :remaining, :months_left, :monthly, keyword_init: true)
10
+
11
+ attr_reader :user, :month, :structure, :declaration, :paid
12
+
13
+ def initialize(user:, on: Date.current, declaration: nil)
14
+ @user = user
15
+ @month = on.beginning_of_month
16
+ @profile = EmployeeProfile.find_by(user_id: user.id)
17
+ @structure = SalaryStructure.effective_for(user, @month)
18
+ @declaration = declaration || TaxDeclaration.for(user, @month)
19
+ @paid = SalarySlip.fy_to_date(user, @month)
20
+ end
21
+
22
+ def regime = declaration&.regime || @profile&.tax_regime || "new"
23
+
24
+ def months_left = month.month >= 4 ? 16 - month.month : 4 - month.month
25
+
26
+ def paid_gross = paid[:gross] + Money.d(@profile&.fy_opening_gross)
27
+
28
+ def projected_gross = Money.d(structure&.monthly_gross) * months_left
29
+
30
+ def sheets = @sheets ||= TaxDeclaration::REGIMES.to_h { |name| [ name, sheet(name) ] }
31
+
32
+ def chosen = sheets[regime]
33
+
34
+ # The regime that costs less this year, and by how much.
35
+ def saving
36
+ new_tax, old_tax = sheets.values_at("new", "old").map(&:annual_tax)
37
+ new_tax <= old_tax ? [ "new", old_tax - new_tax ] : [ "old", new_tax - old_tax ]
38
+ end
39
+
40
+ private
41
+
42
+ def sheet(name)
43
+ rates = StatutoryRateCard.for(month)[:income_tax]
44
+ table = rates[name]
45
+ rows = name == "old" && declaration && structure ? declaration.deduction_rows(structure: structure) : []
46
+ hra = rows.find { |row| row.section == "hra" }&.allowed || BigDecimal(0)
47
+ tds_paid = paid[:tds] + Money.d(@profile&.fy_opening_tds)
48
+ result = Calculators::Tds.call(
49
+ regime: name, structure_monthly_gross: Money.d(structure&.monthly_gross),
50
+ gross_earned_this_month: Money.d(structure&.monthly_gross), fy_gross_paid: paid_gross,
51
+ fy_tds_paid: tds_paid, months_remaining: months_left,
52
+ declared_annual_deductions: rows.sum(BigDecimal(0), &:allowed), rates: rates
53
+ )
54
+ Sheet.new(regime: name, gross: result.projected_annual_gross, hra_exemption: hra,
55
+ standard_deduction: table[:standard_deduction], deductions: rows.reject { |row| row.section == "hra" },
56
+ taxable: result.taxable, slabs: Calculators::Tds.slab_rows(result.taxable, table[:slabs]),
57
+ annual_tax: result.annual_tax, tds_paid: tds_paid,
58
+ remaining: [ result.annual_tax - tds_paid, BigDecimal(0) ].max, months_left: months_left, monthly: result.monthly)
59
+ end
60
+ end
61
+ end
@@ -18,7 +18,7 @@ module HrLite
18
18
 
19
19
  def private_events
20
20
  newest_first(pay_items + loans + expenses + appraisals + plans + attendance_fixes + comp_offs +
21
- assets + documents + resignations + probation + hr_requests)
21
+ assets + documents + resignations + probation + hr_requests + open_leave)
22
22
  end
23
23
 
24
24
  private
@@ -124,6 +124,15 @@ module HrLite
124
124
  end
125
125
  end
126
126
 
127
+ # Leave that never happened: still waiting, or turned down.
128
+ def open_leave
129
+ return [] unless visible?("leave.view")
130
+
131
+ LeaveRequest.where(**mine, status: %w[pending rejected]).overlapping_range(@range.begin, @range.end).includes(:leave_type).map do |leave|
132
+ event(leave.start_date, "Leave", "#{leave.leave_type.name} — #{leave.status}", leave.date_range_label)
133
+ end
134
+ end
135
+
127
136
  def attendance_fixes
128
137
  return [] unless visible?("attendance.view")
129
138
 
@@ -0,0 +1,94 @@
1
+ <% content_for(:page_title) { "CA review" } %>
2
+ <% fy_label = HrLite::FinancialYear.label(@fy) %>
3
+ <% reviewer = hr_can?("payroll.verify", scope: :all) %>
4
+ <% resolver = hr_can?("payroll.approve", scope: :all) || hr_can?("payroll.manage", scope: :all) %>
5
+ <% keep = ->(extra = {}) { { fy: @fy, tab: @tab, status: @status }.merge(extra).compact } %>
6
+ <div class="hrl-page__head">
7
+ <h1 class="hrl-page__title">CA review — FY <%= fy_label %></h1>
8
+ </div>
9
+ <p class="hrl-small hrl-muted">Every published salary slip and every filed tax declaration, verified by the CA before 31 March <%= @fy.year + 1 %>. A flag goes to HR and payroll; the fix is a correcting line on a later slip or reopening the declaration — a published slip is never edited.</p>
10
+
11
+ <div class="hrl-kpis">
12
+ <% %w[slips declarations].each do |kind| %>
13
+ <% total = @totals[kind] %>
14
+ <% done = total - @left[kind] %>
15
+ <div class="hrl-kpi">
16
+ <div class="hrl-kpi__value"><%= done %> / <%= total %></div>
17
+ <div class="hrl-kpi__label"><%= kind == "slips" ? "Salary slips" : "Tax declarations" %> verified</div>
18
+ <progress class="hrl-tax-cap" max="<%= [ total, 1 ].max %>" value="<%= done %>" aria-hidden="true"></progress>
19
+ </div>
20
+ <% end %>
21
+ <div class="hrl-kpi"><div class="hrl-kpi__value"><%= @left["flagged"] %></div><div class="hrl-kpi__label">Flags open</div></div>
22
+ </div>
23
+
24
+ <div class="hrl-row hrl-row--mb" role="tablist">
25
+ <a class="hrl-btn <%= "hrl-btn--primary" if @tab == "slips" %>" href="<%= hr_lite.admin_ca_reviews_path(keep.(tab: "slips", status: @status)) %>" aria-current="<%= @tab == "slips" ? "page" : nil %>">Salary slips</a>
26
+ <a class="hrl-btn <%= "hrl-btn--primary" if @tab == "declarations" %>" href="<%= hr_lite.admin_ca_reviews_path(keep.(tab: "declarations", status: @status)) %>" aria-current="<%= @tab == "declarations" ? "page" : nil %>">Tax declarations</a>
27
+ </div>
28
+ <div class="hrl-row hrl-row--mb">
29
+ <% [ [ nil, "All" ], [ "unreviewed", "To review" ], [ "flagged", "Flagged" ], [ "verified", "Verified" ], [ "resolved", "Resolved" ] ].each do |value, label| %>
30
+ <a class="hrl-btn <%= "hrl-btn--primary" if @status == value %>" href="<%= hr_lite.admin_ca_reviews_path(keep.(status: value)) %>"><%= label %></a>
31
+ <% end %>
32
+ </div>
33
+
34
+ <section class="hrl-card">
35
+ <% if @rows.empty? %>
36
+ <p class="hrl-card--empty">Nothing here.</p>
37
+ <% else %>
38
+ <% subject_type = @rows.first.class.name %>
39
+ <% if reviewer && @rows.any? { |row| @reviews[row.id].nil? } %>
40
+ <%= button_to "Verify all shown", hr_lite.verify_all_admin_ca_reviews_path, method: :post, class: "hrl-btn",
41
+ params: keep.(subject_type: subject_type, ids: @rows.map(&:id)),
42
+ form: { data: { turbo_confirm: "Verify every unreviewed item on this page?" } } %>
43
+ <% end %>
44
+ <ul class="hrl-feed">
45
+ <% @rows.each do |row| %>
46
+ <% review = @reviews[row.id] %>
47
+ <li class="hrl-feed__item">
48
+ <div class="hrl-feed__body">
49
+ <div class="hrl-row">
50
+ <% if row.is_a?(HrLite::SalarySlip) %>
51
+ <a class="hrl-card__link" href="<%= hr_lite.admin_salary_slip_path(row) %>"><%= hr_display_name(row.user) %> — <%= row.period_month.strftime("%B %Y") %></a>
52
+ <span class="hrl-small hrl-muted">net <%= hrl_money(row.net_pay) %></span>
53
+ <% else %>
54
+ <a class="hrl-card__link" href="<%= hr_lite.admin_tax_declaration_path(row) %>"><%= hr_display_name(row.user) %> — <%= row.label %></a>
55
+ <span class="hrl-small hrl-muted"><%= row.regime.humanize %> regime · <%= row.status.humanize %></span>
56
+ <% end %>
57
+ <span class="hrl-right">
58
+ <% badge = { "verified" => [ "hrl-badge--ok", "Verified" ], "flagged" => [ "hrl-badge--bad", "Flagged" ],
59
+ "resolved" => [ "", "Resolved" ] }.fetch(review&.status, [ "hrl-badge--muted", "To review" ]) %>
60
+ <span class="hrl-badge <%= badge.first %>"><%= badge.last %></span>
61
+ </span>
62
+ </div>
63
+ <% if review&.note.present? %><p class="hrl-small">CA: <%= review.note %></p><% end %>
64
+ <% if review&.resolution_note.present? %><p class="hrl-small hrl-muted">Resolved by <%= hr_display_name(review.resolved_by) %>: <%= review.resolution_note %></p><% end %>
65
+ <div class="hrl-row hrl-mt">
66
+ <% if reviewer && review&.status != "verified" %>
67
+ <%= button_to "Verify", hr_lite.verify_admin_ca_reviews_path, method: :post, class: "hrl-btn",
68
+ params: keep.(subject_type: subject_type, subject_id: row.id) %>
69
+ <% end %>
70
+ <% if reviewer %>
71
+ <details>
72
+ <summary class="hrl-btn">Flag…</summary>
73
+ <%= form_with url: hr_lite.flag_admin_ca_reviews_path, method: :post, local: true do %>
74
+ <% keep.(subject_type: subject_type, subject_id: row.id).each do |key, value| %><%= hidden_field_tag key, value %><% end %>
75
+ <div class="hrl-field"><%= label_tag "note_#{row.id}", "What is wrong" %><%= text_area_tag :note, nil, id: "note_#{row.id}", required: true, rows: 2 %></div>
76
+ <%= submit_tag "Flag it", class: "hrl-btn hrl-btn--primary" %>
77
+ <% end %>
78
+ </details>
79
+ <% end %>
80
+ <% if resolver && review&.status == "flagged" %>
81
+ <%= form_with url: hr_lite.resolve_admin_ca_review_path(review, keep.()), method: :post, local: true, class: "hrl-row" do %>
82
+ <%= label_tag "resolution_#{review.id}", "How it was fixed", class: "hrl-sr-only" %>
83
+ <%= text_field_tag :note, nil, id: "resolution_#{review.id}", placeholder: "How it was fixed" %>
84
+ <%= submit_tag "Mark resolved", class: "hrl-btn" %>
85
+ <% end %>
86
+ <% end %>
87
+ </div>
88
+ </div>
89
+ </li>
90
+ <% end %>
91
+ </ul>
92
+ <%= hrl_pagination %>
93
+ <% end %>
94
+ </section>
@@ -9,6 +9,8 @@
9
9
  <a class="hrl-btn" href="<%= hr_lite.new_admin_employee_appraisal_path(@profile) %>">New appraisal</a>
10
10
  <a class="hrl-btn" href="<%= hr_lite.new_admin_employee_designation_change_path(@profile) %>">Role change</a>
11
11
  <a class="hrl-btn" href="<%= hr_lite.new_admin_performance_plan_path(user_id: @profile.user_id) %>">Start PIP</a>
12
+ <% end %>
13
+ <% if hr_can?("salary.manage", scope: :all) %>
12
14
  <a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_admin_employee_salary_structure_path(@profile) %>">New structure</a>
13
15
  <% end %>
14
16
  </div>
@@ -84,7 +86,7 @@
84
86
  </dl>
85
87
  </section>
86
88
 
87
- <% if hr_superadmin? %>
89
+ <% if hr_can?("salary.view", scope: :all) %>
88
90
  <section class="hrl-card">
89
91
  <h2 class="hrl-card__title">Salary structures</h2>
90
92
  <% if @structures.any? %>
@@ -26,28 +26,38 @@
26
26
  <span class="hrl-badge"><%= @run.status.humanize %></span>
27
27
  <%# `processing` is offered too: a run stranded there by a killed process
28
28
  would otherwise have no control at all and block that month forever. %>
29
- <% if @run.draft? || @run.review? || @run.processing? %>
29
+ <% manager = hr_can?("payroll.manage", scope: :all) %>
30
+ <% if manager && (@run.draft? || @run.review? || @run.processing?) %>
30
31
  <%= button_to @run.draft? ? "Compute" : "Recompute", hr_lite.compute_admin_payroll_run_path(@run),
31
- method: :post, class: "hrl-btn hrl-btn--primary" %>
32
+ method: :post, class: "hrl-btn" %>
32
33
  <% end %>
33
- <% if @run.review? %>
34
- <%= button_to "Finalize", hr_lite.finalize_admin_payroll_run_path(@run), method: :post, class: "hrl-btn" %>
34
+ <% if @run.review? && hr_can?("payroll.approve", scope: :all) && @run.first_approved_by_id != hr_current_user.id %>
35
+ <%= button_to @run.first_approved_by_id ? "Approve and publish" : "Approve", hr_lite.approve_admin_payroll_run_path(@run),
36
+ method: :post, class: "hrl-btn hrl-btn--primary",
37
+ form: { data: { turbo_confirm: @run.first_approved_by_id ? "Approve #{@run.label}? This finalizes the run and publishes the slips." : "Approve #{@run.label}?" } } %>
35
38
  <% end %>
36
- <% if @run.finalized? %>
39
+ <% if manager && @run.finalized? %>
37
40
  <%= button_to "Unlock", hr_lite.unlock_admin_payroll_run_path(@run), method: :post, class: "hrl-btn" %>
38
41
  <%= button_to "Publish to employees", hr_lite.publish_admin_payroll_run_path(@run), method: :post,
39
42
  class: "hrl-btn hrl-btn--primary",
40
43
  form: { data: { turbo_confirm: "Publish #{@run.label}? Employees will be notified." } } %>
41
44
  <% end %>
42
- <% if @slips.any? %>
45
+ <% if @slips.any? && hr_can?("payroll.export", scope: :all) %>
43
46
  <a class="hrl-btn" href="<%= hr_lite.register_admin_payroll_run_path(@run, format: :csv) %>">Register CSV</a>
44
47
  <% end %>
45
- <% if @run.draft? %>
48
+ <% if manager && @run.draft? %>
46
49
  <%= button_to "Delete draft", hr_lite.admin_payroll_run_path(@run), method: :delete,
47
50
  class: "hrl-btn hrl-btn--danger",
48
51
  form: { data: { turbo_confirm: "Delete this draft run?" } } %>
49
52
  <% end %>
50
53
  </div>
54
+ <p class="hrl-small hrl-muted hrl-mt">
55
+ <% if @run.review? %>
56
+ <%= @run.first_approved_by ? "Approved by #{hr_display_name(@run.first_approved_by)} on #{@run.first_approved_at.strftime('%-d %b')} — one more approver (HR or Super Admin) publishes it." : "Needs two approvals (HR and Super Admin). If nobody approves by the 3rd, a run with no blocking warnings is approved automatically." %>
57
+ <% elsif @run.auto_approved_at %>
58
+ Approved automatically on <%= @run.auto_approved_at.strftime("%-d %b") %> — nobody had approved it by the 3rd.
59
+ <% end %>
60
+ </p>
51
61
  </section>
52
62
 
53
63
  <section class="hrl-card">
@@ -2,15 +2,16 @@
2
2
  <%= form_with model: structure, url: url, method: method, scope: :salary_structure, local: true do |f| %>
3
3
  <%= render "hr_lite/shared/form_errors", record: structure %>
4
4
  <% breakup = structure.breakup(on: structure.effective_from || Date.current) if structure.basic %>
5
- <div class="hrl-field"><%= f.label :annual_ctc, "Annual CTC (₹ a year)" %><%= f.number_field :annual_ctc, step: 1, value: (structure.annual_ctc || breakup&.dig(:annual_ctc))&.round %></div>
5
+ <div class="hrl-field">
6
+ <%= f.label :annual_ctc, "Annual CTC (₹ a year)" %>
7
+ <%= f.number_field :annual_ctc, step: 1, min: 1, required: true, value: (structure.annual_ctc || breakup&.dig(:annual_ctc))&.round %>
8
+ <p class="hrl-hint">The only amount you type. Saving splits it: Basic 50%, HRA 50% of Basic in a metro (40% elsewhere), employer PF, ESI and gratuity (4.81% of Basic) inside the CTC, the rest as special allowance.</p>
9
+ </div>
6
10
  <div class="hrl-field">
7
11
  <%= f.label :effective_from, "Effective from (1st of month)" %>
8
12
  <%= f.date_field :effective_from %>
9
13
  </div>
10
- <div class="hrl-field"><%= f.label :basic, "Basic (monthly)" %><%= f.number_field :basic, step: 0.01, value: structure.basic&.to_s("F") %></div>
11
- <div class="hrl-field"><%= f.label :hra, "HRA" %><%= f.number_field :hra, step: 0.01, value: structure.hra&.to_s("F") %></div>
12
- <div class="hrl-field"><%= f.label :special_allowance %><%= f.number_field :special_allowance, step: 0.01, value: structure.special_allowance&.to_s("F") %></div>
13
- <div class="hrl-field"><%= f.label :other_earnings %><%= f.number_field :other_earnings, step: 0.01, value: structure.other_earnings&.to_s("F") %></div>
14
+ <div class="hrl-field hrl-field--check"><%= f.check_box :metro %><%= f.label :metro, "Works in a metro (Delhi, Mumbai, Kolkata, Chennai)" %></div>
14
15
  <div class="hrl-field hrl-field--check"><%= f.check_box :pf_applicable %><%= f.label :pf_applicable, "PF applicable" %></div>
15
16
  <div class="hrl-field hrl-field--check"><%= f.check_box :pf_on_full_basic %><%= f.label :pf_on_full_basic, "PF on full basic (no ₹15k ceiling)" %></div>
16
17
  <div class="hrl-field hrl-field--check"><%= f.check_box :esi_applicable %><%= f.label :esi_applicable, "ESI applicable (auto-exempt above gross ceiling)" %></div>
@@ -23,10 +24,17 @@
23
24
  <%= f.select :pt_state, pt_states.map { |s| [ s.humanize, s ] } %>
24
25
  </div>
25
26
  <div class="hrl-field"><%= f.label :notes %><%= f.text_area :notes %></div>
26
- <% if breakup %><p class="hrl-muted hrl-small">Monthly gross <%= hrl_money(breakup[:monthly_gross]) %> · CTC <%= hrl_money(breakup[:monthly_ctc]) %> a month</p><% end %>
27
+ <% if breakup %>
28
+ <h3 class="hrl-card__title">Monthly split</h3>
29
+ <dl class="hrl-deflist">
30
+ <% breakup[:earnings].each do |label, amount| %><dt><%= label %></dt><dd><%= hrl_money(amount) %></dd><% end %>
31
+ <dt><strong>Gross</strong></dt><dd><strong><%= hrl_money(breakup[:monthly_gross]) %></strong></dd>
32
+ <% breakup[:employer].each do |label, amount| %><dt>Employer <%= label %></dt><dd><%= hrl_money(amount) %></dd><% end %>
33
+ <dt><strong>CTC</strong></dt><dd><strong><%= hrl_money(breakup[:monthly_ctc]) %></strong> a month</dd>
34
+ </dl>
35
+ <% end %>
27
36
  <div class="hrl-form-actions">
28
37
  <%= f.submit "Save structure", class: "hrl-btn hrl-btn--primary" %>
29
- <%= f.submit "Fill from CTC", name: "fill_from_ctc", class: "hrl-btn" %>
30
38
  <a class="hrl-btn" href="<%= hr_lite.admin_employee_path(@profile) %>">Cancel</a>
31
39
  </div>
32
40
  <% end %>