hr_lite 0.5.2 → 0.5.3
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 +62 -1
- data/app/controllers/hr_lite/admin/attendances_controller.rb +3 -4
- data/app/controllers/hr_lite/admin/leave_balances_controller.rb +4 -5
- data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +11 -2
- data/app/controllers/hr_lite/admin/salary_slips_controller.rb +5 -6
- data/app/models/hr_lite/audit_log.rb +26 -1
- data/app/models/hr_lite/comp_off_request.rb +3 -1
- data/app/models/hr_lite/leave_request.rb +27 -4
- data/app/models/hr_lite/payroll_run.rb +30 -4
- data/app/models/hr_lite/regularization_request.rb +3 -4
- data/app/models/hr_lite/salary_slip.rb +1 -2
- data/app/services/hr_lite/payroll_run_processor.rb +4 -1
- data/db/migrate/20260817182124_add_status_constraints_and_appraisal_fk_to_hr_lite.rb +63 -0
- data/lib/hr_lite/configuration.rb +3 -6
- data/lib/hr_lite/financial_year.rb +26 -0
- data/lib/hr_lite/statutory_rate_card.rb +45 -5
- data/lib/hr_lite/version.rb +1 -1
- data/lib/hr_lite.rb +18 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bdcc58ee5fdba440b0f70b9f22b85d85d20ef02f16c92481d4aad89a2395489f
|
|
4
|
+
data.tar.gz: f705fe091062bbe92246fd19fb1615c9fd9e0f2c6ed34ece293d155cc81879b1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c674e21d22c69a6b2ff5e51cbbb329f166140054212d81a2582c5d0b421350a732c94ae2b8074e4da15f4b510f9a0fa5bfceda84c633b57def295c2066276370
|
|
7
|
+
data.tar.gz: 514bf9fa94450d2ccdd4a7443125ec355dd4d68e99c264eac62fe3b5b3be2034a17a64c94571ef51580b97a8ebd59ba85e0a05a55474e3ec80dd9a4ebf798a7d
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,66 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.3] - 2026-08-17
|
|
11
|
+
|
|
12
|
+
Correctness and traceability pass, ahead of the permissions work in 0.6.0.
|
|
13
|
+
**Adds one migration** — check constraints on every status column, plus an
|
|
14
|
+
index and foreign key on `hr_lite_designation_changes.appraisal_id`. It
|
|
15
|
+
refuses to run, naming the table and the offending values, if a live row
|
|
16
|
+
already holds a status no model declares.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- **Payroll ran silently on an out-of-date statutory card.** The lookup fell
|
|
21
|
+
back to the newest card on or before the run month with no signal, so a run
|
|
22
|
+
in a financial year the gem ships no card for computed PF, ESI, PT and TDS
|
|
23
|
+
on the previous year's figures and said nothing. It still falls back —
|
|
24
|
+
payroll cannot stop dead every 1 April — but `PayrollRunProcessor` now puts
|
|
25
|
+
a warning naming both financial years at the TOP of the run's warnings, on
|
|
26
|
+
every compute. Adding a year remains one new entry in
|
|
27
|
+
`StatutoryRateCard::CARDS`; rates are never inferred forward.
|
|
28
|
+
- **A blank email could be granted the leadership tier.** `leadership_check`
|
|
29
|
+
did not drop empty entries the way `superadmin_check` did, so one stray
|
|
30
|
+
comma in `HR_LEADERSHIP_EMAILS` ("a@x.com,,b@x.com") put `""` on the list
|
|
31
|
+
and any user whose email was blank or nil matched it. Both checks now share
|
|
32
|
+
`HrLite.email_listed?`, which refuses a blank address against any list.
|
|
33
|
+
- Comp-off approval no longer reaches for a plural that cannot occur — a
|
|
34
|
+
credit is 0.5 or 1 day and never more.
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- **The money path leaves a trail.** Payroll compute, finalize, unlock and
|
|
39
|
+
publish, every leave approval, rejection and cancellation, and the payout
|
|
40
|
+
register download now write `AuditLog` rows. Previously a run could be
|
|
41
|
+
computed, finalized, unlocked, edited and republished and the audit screen
|
|
42
|
+
showed none of it.
|
|
43
|
+
- These rows are written inside the same transaction as the change they
|
|
44
|
+
describe and RAISE on failure, unlike the `Audited` concern, which stays
|
|
45
|
+
best-effort for ordinary policy edits. A payroll transition nobody can
|
|
46
|
+
account for afterwards does not happen at all.
|
|
47
|
+
- Amounts are deliberately excluded — `audit_logs` is not encrypted. Rows
|
|
48
|
+
record who moved a run, how far, and over how many people. Leave rows
|
|
49
|
+
carry the approver's note and never the employee's own reason.
|
|
50
|
+
- `HrLite::PayrollRun` and `HrLite::SalarySlip` join `MONEY_TIER_TYPES`, so
|
|
51
|
+
payroll rows stay out of the leadership audit screen.
|
|
52
|
+
- `HrLite::AuditLog.record!` — the five-line `create!` that five call sites
|
|
53
|
+
had each written out.
|
|
54
|
+
- `HrLite::FinancialYear` — the Indian FY (1 April) worked out in one place
|
|
55
|
+
instead of three.
|
|
56
|
+
|
|
57
|
+
### Changed
|
|
58
|
+
|
|
59
|
+
- Statuses are enforced by the database. `update_column`, `update_all`,
|
|
60
|
+
`insert_all` and console fixes can no longer write a value that no screen
|
|
61
|
+
renders and no transition accepts.
|
|
62
|
+
- The suite enforces its own coverage: 100% line, 90% branch, 90% per file.
|
|
63
|
+
The README claimed 100% and nothing checked it; switching the floor on found
|
|
64
|
+
eight untested paths, including all three slip-override money guards (LOP
|
|
65
|
+
above the days in the month, negative LOP, negative TDS) and the offboarding
|
|
66
|
+
error path. All are covered now.
|
|
67
|
+
- `docs/PAYROLL.md` no longer lists ESI contribution-period lock-in as
|
|
68
|
+
unmodelled — 0.5.2 implemented it.
|
|
69
|
+
|
|
10
70
|
## [0.5.2] - 2026-08-08
|
|
11
71
|
|
|
12
72
|
The rest of the audit that produced 0.5.1. **Adds three migrations** — they
|
|
@@ -349,7 +409,8 @@ Initial release.
|
|
|
349
409
|
bus with per-event channel matrix (bell, email, leadership email/bell),
|
|
350
410
|
daily leadership digest and an append-only audit trail.
|
|
351
411
|
|
|
352
|
-
[Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.5.
|
|
412
|
+
[Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.5.3...HEAD
|
|
413
|
+
[0.5.3]: https://github.com/kshtzkr/hr_lite/compare/v0.5.2...v0.5.3
|
|
353
414
|
[0.5.2]: https://github.com/kshtzkr/hr_lite/compare/v0.5.1...v0.5.2
|
|
354
415
|
[0.5.1]: https://github.com/kshtzkr/hr_lite/compare/v0.5.0...v0.5.1
|
|
355
416
|
[0.5.0]: https://github.com/kshtzkr/hr_lite/compare/v0.4.0...v0.5.0
|
|
@@ -75,10 +75,9 @@ module HrLite
|
|
|
75
75
|
private
|
|
76
76
|
|
|
77
77
|
def log_regularization(record, note, removed:)
|
|
78
|
-
AuditLog.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
audited_changes: { "date" => record.date.to_s, "note" => note }
|
|
78
|
+
AuditLog.record!(
|
|
79
|
+
action: removed ? "destroy" : "regularize", subject: record, actor: hr_current_user,
|
|
80
|
+
changes: { "date" => record.date.to_s, "note" => note }
|
|
82
81
|
)
|
|
83
82
|
Notifications.publish(
|
|
84
83
|
"attendance.regularized",
|
|
@@ -23,11 +23,10 @@ module HrLite
|
|
|
23
23
|
|
|
24
24
|
balance = LeaveBalance.adjust!(user, type, year, delta: delta, note: "#{delta.to_f} — #{note}")
|
|
25
25
|
|
|
26
|
-
AuditLog.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"delta" => delta.to_f, "note" => note }
|
|
26
|
+
AuditLog.record!(
|
|
27
|
+
action: "adjust", subject: balance, actor: hr_current_user,
|
|
28
|
+
changes: { "user" => HrLite.display_name(user), "type" => type.code,
|
|
29
|
+
"delta" => delta.to_f, "note" => note }
|
|
31
30
|
)
|
|
32
31
|
redirect_to admin_leave_balances_path(year: year),
|
|
33
32
|
notice: "Balance adjusted for #{HrLite.display_name(user)}."
|
|
@@ -55,8 +55,17 @@ module HrLite
|
|
|
55
55
|
# deliberately leadership-only PII.
|
|
56
56
|
def register
|
|
57
57
|
run = PayrollRun.find(params[:id])
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
csv = register_csv(run)
|
|
59
|
+
# A GET that walks out of the building with every employee's pay and
|
|
60
|
+
# bank account. Nothing else in the module leaks this much in one
|
|
61
|
+
# click, so the download itself is the auditable event.
|
|
62
|
+
AuditLog.record!(
|
|
63
|
+
action: "payroll.register_exported", subject: run, actor: hr_current_user,
|
|
64
|
+
changes: { "period" => run.label, "rows" => run.salary_slips.count,
|
|
65
|
+
"includes_bank_details" => true }
|
|
66
|
+
)
|
|
67
|
+
send_data csv, filename: "payroll-register-#{run.period_month.strftime('%Y-%m')}.csv",
|
|
68
|
+
type: "text/csv"
|
|
60
69
|
end
|
|
61
70
|
|
|
62
71
|
private
|
|
@@ -43,12 +43,11 @@ module HrLite
|
|
|
43
43
|
)
|
|
44
44
|
slip.update!(attributes)
|
|
45
45
|
|
|
46
|
-
AuditLog.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
"tds_override" => tds_override ? "[changed]" : "cleared" }
|
|
46
|
+
AuditLog.record!(
|
|
47
|
+
action: "override", subject: slip, actor: hr_current_user,
|
|
48
|
+
changes: { "period" => run.label, "employee" => HrLite.display_name(slip.user),
|
|
49
|
+
"lop_override" => lop_override || "cleared",
|
|
50
|
+
"tds_override" => tds_override ? "[changed]" : "cleared" }
|
|
52
51
|
)
|
|
53
52
|
redirect_to admin_salary_slip_path(slip), notice: "Slip recomputed with overrides."
|
|
54
53
|
rescue ArgumentError
|
|
@@ -12,11 +12,36 @@ module HrLite
|
|
|
12
12
|
# encrypt their amounts so the diff is already redacted, but appraisal
|
|
13
13
|
# ratings and review text are plain columns — they must not reach the
|
|
14
14
|
# leadership audit screen or the policy.changed email.
|
|
15
|
-
MONEY_TIER_TYPES = %w[
|
|
15
|
+
MONEY_TIER_TYPES = %w[
|
|
16
|
+
HrLite::Appraisal HrLite::DesignationChange HrLite::SalaryStructure
|
|
17
|
+
HrLite::PayrollRun HrLite::SalarySlip
|
|
18
|
+
].freeze
|
|
16
19
|
|
|
17
20
|
scope :recent, -> { order(created_at: :desc) }
|
|
18
21
|
scope :outside_money_tier, -> { where.not(subject_type: MONEY_TIER_TYPES) }
|
|
19
22
|
|
|
23
|
+
# The five-line `create!` that four call sites had each spelled out.
|
|
24
|
+
#
|
|
25
|
+
# This RAISES, unlike the Audited concern, which swallows failures so an
|
|
26
|
+
# audit hiccup cannot roll back an ordinary policy edit. On the money
|
|
27
|
+
# path that trade goes the other way: a payroll transition nobody can
|
|
28
|
+
# explain afterwards should not be allowed to happen at all, so callers
|
|
29
|
+
# run it inside the same transaction as the write it describes.
|
|
30
|
+
#
|
|
31
|
+
# `changes` is a whitelist the caller writes out by hand. Never pass
|
|
32
|
+
# amounts — this table is not encrypted.
|
|
33
|
+
def self.record!(action:, subject:, actor: HrLite::Current.actor, changes: {})
|
|
34
|
+
create!(
|
|
35
|
+
actor: actor,
|
|
36
|
+
action: action,
|
|
37
|
+
subject_type: subject.class.name,
|
|
38
|
+
# A destroyed record has no id left, and the row still has to say
|
|
39
|
+
# what happened.
|
|
40
|
+
subject_id: subject.id || 0,
|
|
41
|
+
audited_changes: changes
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
20
45
|
def money_tier?
|
|
21
46
|
MONEY_TIER_TYPES.include?(subject_type)
|
|
22
47
|
end
|
|
@@ -54,7 +54,9 @@ module HrLite
|
|
|
54
54
|
|
|
55
55
|
Notifications.publish(
|
|
56
56
|
"comp_off.approved",
|
|
57
|
-
|
|
57
|
+
# credit_days is 0.5 or 1 and nothing else, so there is no plural to
|
|
58
|
+
# reach for here.
|
|
59
|
+
title: "Comp-off approved — #{credit_days.to_f} day credited for #{date_worked.strftime('%d %b')}",
|
|
58
60
|
body: decision_note.presence,
|
|
59
61
|
path: "/comp_off_requests",
|
|
60
62
|
bell_to: [ user ], email_to: [ user ]
|
|
@@ -78,10 +78,15 @@ module HrLite
|
|
|
78
78
|
|
|
79
79
|
def cancel!(actor:)
|
|
80
80
|
was_approved = approved?
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
81
|
+
transaction do
|
|
82
|
+
self.status = "cancelled"
|
|
83
|
+
self.decided_by_id = actor.id
|
|
84
|
+
self.decided_at = Time.current
|
|
85
|
+
save!
|
|
86
|
+
# Cancelling an APPROVED leave hands the days back to the balance, so
|
|
87
|
+
# it is a quota change as much as a status change.
|
|
88
|
+
audit!("cancelled", actor, was_approved ? "was approved" : nil)
|
|
89
|
+
end
|
|
85
90
|
|
|
86
91
|
Notifications.publish(
|
|
87
92
|
"leave.cancelled",
|
|
@@ -117,9 +122,27 @@ module HrLite
|
|
|
117
122
|
self.decided_at = Time.current
|
|
118
123
|
self.decision_note = note
|
|
119
124
|
save!
|
|
125
|
+
audit!(new_status, actor, note)
|
|
120
126
|
end
|
|
121
127
|
end
|
|
122
128
|
|
|
129
|
+
# Approve and reject both come through `transition!`, and `with_lock` is
|
|
130
|
+
# already a transaction — so a failed audit row rolls the decision back
|
|
131
|
+
# rather than leaving one that nobody can account for. The employee's own
|
|
132
|
+
# `reason` is deliberately not copied here; the approver's note is.
|
|
133
|
+
def audit!(action, actor, note)
|
|
134
|
+
AuditLog.record!(
|
|
135
|
+
action: "leave.#{action}", subject: self, actor: actor,
|
|
136
|
+
changes: {
|
|
137
|
+
"employee" => HrLite.display_name(user),
|
|
138
|
+
"type" => leave_type.code,
|
|
139
|
+
"dates" => date_range_label,
|
|
140
|
+
"days" => days_count&.to_s("F"),
|
|
141
|
+
"note" => note.presence
|
|
142
|
+
}.compact
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
|
|
123
146
|
# This request is still pending here, so balance.used excludes it:
|
|
124
147
|
# approving is valid iff the request fits the remaining balance.
|
|
125
148
|
def insufficient_balance_now?
|
|
@@ -39,7 +39,12 @@ module HrLite
|
|
|
39
39
|
begin
|
|
40
40
|
update!(status: "processing")
|
|
41
41
|
PayrollRunProcessor.call(self)
|
|
42
|
-
|
|
42
|
+
transaction do
|
|
43
|
+
update!(status: "review", processed_at: Time.current)
|
|
44
|
+
audit!("payroll.computed", actor,
|
|
45
|
+
"slips" => salary_slips.count, "warnings" => warnings.length,
|
|
46
|
+
"from_status" => previous)
|
|
47
|
+
end
|
|
43
48
|
true
|
|
44
49
|
rescue => e
|
|
45
50
|
# Restore what the run WAS. Hardcoding "draft" here used to demote a
|
|
@@ -54,7 +59,12 @@ module HrLite
|
|
|
54
59
|
raise_unless %w[review]
|
|
55
60
|
raise ActiveRecord::RecordInvalid.new(self), "no slips" if salary_slips.none?
|
|
56
61
|
|
|
57
|
-
|
|
62
|
+
transaction do
|
|
63
|
+
update!(status: "finalized", finalized_at: Time.current, finalized_by_id: actor.id)
|
|
64
|
+
# Finalizing freezes every slip in the run. Who did it, to how many
|
|
65
|
+
# people, is the row an investigation starts from.
|
|
66
|
+
audit!("payroll.finalized", actor, "slips" => salary_slips.count)
|
|
67
|
+
end
|
|
58
68
|
Notifications.publish(
|
|
59
69
|
"payroll.finalized",
|
|
60
70
|
title: "Payroll #{label} finalized — #{salary_slips.count} slips, net #{Money.round2(total_net).to_s('F')}",
|
|
@@ -65,13 +75,21 @@ module HrLite
|
|
|
65
75
|
|
|
66
76
|
def unlock!(actor:)
|
|
67
77
|
raise_unless %w[finalized]
|
|
68
|
-
|
|
78
|
+
transaction do
|
|
79
|
+
update!(status: "review")
|
|
80
|
+
# Reopening frozen slips for editing is the single most sensitive
|
|
81
|
+
# transition in the module — it is the one that has to leave a trace.
|
|
82
|
+
audit!("payroll.unlocked", actor, "slips" => salary_slips.count)
|
|
83
|
+
end
|
|
69
84
|
true
|
|
70
85
|
end
|
|
71
86
|
|
|
72
87
|
def publish!(actor:)
|
|
73
88
|
raise_unless %w[finalized]
|
|
74
|
-
|
|
89
|
+
transaction do
|
|
90
|
+
update!(status: "published", published_at: Time.current, published_by_id: actor.id)
|
|
91
|
+
audit!("payroll.published", actor, "slips" => salary_slips.count)
|
|
92
|
+
end
|
|
75
93
|
|
|
76
94
|
slips = salary_slips.includes(:user).to_a
|
|
77
95
|
# Everyone is emailed — a final settlement matters most to the person who
|
|
@@ -106,6 +124,14 @@ module HrLite
|
|
|
106
124
|
|
|
107
125
|
private
|
|
108
126
|
|
|
127
|
+
# Amounts stay out of it on purpose: audit_logs is a plaintext table and
|
|
128
|
+
# every slip amount in this module is encrypted. The row records WHO
|
|
129
|
+
# moved the run and HOW FAR, never what anyone is paid.
|
|
130
|
+
def audit!(action, actor, changes)
|
|
131
|
+
AuditLog.record!(action: action, subject: self, actor: actor,
|
|
132
|
+
changes: changes.merge("period" => label))
|
|
133
|
+
end
|
|
134
|
+
|
|
109
135
|
def sum_slips(attribute)
|
|
110
136
|
salary_slips.sum(BigDecimal(0)) { |slip| slip.public_send(attribute) || BigDecimal(0) }
|
|
111
137
|
end
|
|
@@ -71,10 +71,9 @@ module HrLite
|
|
|
71
71
|
raise InvalidMerge, record.errors.full_messages.to_sentence unless record.valid?
|
|
72
72
|
|
|
73
73
|
record.save!
|
|
74
|
-
AuditLog.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
audited_changes: { "date" => record.date.to_s, "ticket" => id, "note" => reason }
|
|
74
|
+
AuditLog.record!(
|
|
75
|
+
action: "regularize", subject: record, actor: actor,
|
|
76
|
+
changes: { "date" => record.date.to_s, "ticket" => id, "note" => reason }
|
|
78
77
|
)
|
|
79
78
|
end
|
|
80
79
|
|
|
@@ -40,8 +40,7 @@ module HrLite
|
|
|
40
40
|
# Indian FY (Apr 1) to-date sums of SETTLED slips before this period —
|
|
41
41
|
# feeds the TDS projector and the slip's YTD table.
|
|
42
42
|
def self.fy_to_date(user, period_month)
|
|
43
|
-
fy_start =
|
|
44
|
-
: Date.new(period_month.year - 1, 4, 1)
|
|
43
|
+
fy_start = FinancialYear.start_for(period_month)
|
|
45
44
|
slips = settled.where(user_id: user.id)
|
|
46
45
|
.where(period_month: fy_start...period_month)
|
|
47
46
|
{
|
|
@@ -12,7 +12,10 @@ module HrLite
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def call
|
|
15
|
-
|
|
15
|
+
# First in the list on purpose: if the statutory card is from an older
|
|
16
|
+
# financial year, every number below it is computed on last year's
|
|
17
|
+
# rates and that is the thing to read first.
|
|
18
|
+
warnings = Array(StatutoryRateCard.warning_for(@run.period_month))
|
|
16
19
|
|
|
17
20
|
ActiveRecord::Base.transaction do
|
|
18
21
|
eligible = EmployeeProfile.active_for(@run.period_month).includes(:user).to_a
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
class AddStatusConstraintsAndAppraisalFkToHrLite < ActiveRecord::Migration[8.1]
|
|
2
|
+
# Every status column in the engine is a plain string validated only in
|
|
3
|
+
# Ruby, so anything that skips validations — update_column, update_all,
|
|
4
|
+
# insert_all, a console fix, a future migration — can write a value no
|
|
5
|
+
# screen renders and no transition accepts. These constraints put the
|
|
6
|
+
# allowed set somewhere it cannot be bypassed.
|
|
7
|
+
#
|
|
8
|
+
# Deliberately in step with each model's own STATUSES array: adding a
|
|
9
|
+
# status now means writing a migration, which is the point.
|
|
10
|
+
STATUSES = {
|
|
11
|
+
hr_lite_attendance_records: %w[present half_day],
|
|
12
|
+
hr_lite_leave_requests: %w[pending approved rejected cancelled],
|
|
13
|
+
hr_lite_comp_off_requests: %w[pending approved rejected cancelled],
|
|
14
|
+
hr_lite_regularization_requests: %w[pending approved rejected cancelled],
|
|
15
|
+
hr_lite_resignations: %w[pending accepted withdrawn],
|
|
16
|
+
hr_lite_appraisals: %w[draft shared],
|
|
17
|
+
hr_lite_payroll_runs: %w[draft processing review finalized published]
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def up
|
|
21
|
+
# On an install that already holds an off-list value the constraint build
|
|
22
|
+
# fails with a bare integrity error naming neither the row nor the value.
|
|
23
|
+
# Say what is actually in the way instead — this runs on live data.
|
|
24
|
+
offenders = STATUSES.filter_map do |table, statuses|
|
|
25
|
+
found = select_values(
|
|
26
|
+
"SELECT DISTINCT status FROM #{table} WHERE status NOT IN (#{quoted(statuses)})"
|
|
27
|
+
)
|
|
28
|
+
"#{table}: #{found.join(', ')}" if found.any?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
if offenders.any?
|
|
32
|
+
raise ActiveRecord::IrreversibleMigration,
|
|
33
|
+
"Cannot constrain status columns — these rows hold values no model " \
|
|
34
|
+
"declares. Correct them first:\n #{offenders.join("\n ")}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
STATUSES.each do |table, statuses|
|
|
38
|
+
add_check_constraint table, status_sql(statuses), name: constraint_name(table)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# has_one :designation_change, dependent: :nullify — with neither an index
|
|
42
|
+
# nor a foreign key. Destroying an appraisal scanned the whole table to
|
|
43
|
+
# find its promotion row, and nothing stopped that row from being left
|
|
44
|
+
# pointing at an appraisal that no longer exists.
|
|
45
|
+
add_index :hr_lite_designation_changes, :appraisal_id
|
|
46
|
+
add_foreign_key :hr_lite_designation_changes, :hr_lite_appraisals,
|
|
47
|
+
column: :appraisal_id, on_delete: :nullify
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def down
|
|
51
|
+
remove_foreign_key :hr_lite_designation_changes, column: :appraisal_id
|
|
52
|
+
remove_index :hr_lite_designation_changes, :appraisal_id
|
|
53
|
+
STATUSES.each_key { |table| remove_check_constraint table, name: constraint_name(table) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def status_sql(statuses) = "status IN (#{quoted(statuses)})"
|
|
59
|
+
|
|
60
|
+
def quoted(statuses) = statuses.map { |s| connection.quote(s) }.join(", ")
|
|
61
|
+
|
|
62
|
+
def constraint_name(table) = "#{table}_status_check"
|
|
63
|
+
end
|
|
@@ -46,16 +46,13 @@ module HrLite
|
|
|
46
46
|
@currency_symbol = "₹"
|
|
47
47
|
@on_designation_change = ->(user, designation) { }
|
|
48
48
|
@leadership_emails = []
|
|
49
|
-
@leadership_check = ->(user)
|
|
50
|
-
emails = HrLite.config.leadership_emails.map { |e| e.to_s.downcase.strip }
|
|
51
|
-
emails.include?(user.email.to_s.downcase)
|
|
52
|
-
end
|
|
49
|
+
@leadership_check = ->(user) { HrLite.email_listed?(user, HrLite.config.leadership_emails) }
|
|
53
50
|
# Money tier: salary structures, payroll, slips, appraisals. Empty
|
|
54
51
|
# list means "same as leadership" (pre-0.5.0 behaviour).
|
|
55
52
|
@superadmin_emails = []
|
|
56
53
|
@superadmin_check = ->(user) do
|
|
57
|
-
|
|
58
|
-
|
|
54
|
+
list = HrLite.normalize_email_list(HrLite.config.superadmin_emails)
|
|
55
|
+
list.empty? ? HrLite.leadership?(user) : HrLite.email_listed?(user, list)
|
|
59
56
|
end
|
|
60
57
|
@extra_stylesheets = [] # host stylesheets linked AFTER hr_lite.css (CSS-var overrides)
|
|
61
58
|
@mailer_from = "hr@example.com"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# The Indian financial year runs 1 April – 31 March. Three places worked
|
|
3
|
+
# this out for themselves (the slip's year-to-date sums, the TDS projector
|
|
4
|
+
# and the rate card); it lives here once so they cannot drift apart.
|
|
5
|
+
module FinancialYear
|
|
6
|
+
START_MONTH = 4
|
|
7
|
+
|
|
8
|
+
# First day of the FY containing `date`. March 2027 belongs to the year
|
|
9
|
+
# that opened on 1 April 2026.
|
|
10
|
+
def self.start_for(date)
|
|
11
|
+
year = date.month >= START_MONTH ? date.year : date.year - 1
|
|
12
|
+
Date.new(year, START_MONTH, 1)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# "2026-27" — for warnings and screens, never for arithmetic.
|
|
16
|
+
def self.label(date)
|
|
17
|
+
start = start_for(date)
|
|
18
|
+
"#{start.year}-#{format('%02d', (start.year + 1) % 100)}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# True when `date` falls in an earlier FY than `other`.
|
|
22
|
+
def self.before?(date, other)
|
|
23
|
+
start_for(date) < start_for(other)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -3,9 +3,13 @@ module HrLite
|
|
|
3
3
|
# is a one-hash edit that gets code review and a spec diff, never an
|
|
4
4
|
# inline-constant hunt.
|
|
5
5
|
#
|
|
6
|
-
# VERIFY WITH A CA before the first run of any new financial year
|
|
7
|
-
#
|
|
8
|
-
#
|
|
6
|
+
# VERIFY WITH A CA before the first run of any new financial year. Adding
|
|
7
|
+
# a year is one new dated entry in CARDS; nothing else changes.
|
|
8
|
+
#
|
|
9
|
+
# When no card exists for a run's financial year the lookup falls back to
|
|
10
|
+
# the newest one it has — payroll cannot simply stop every April — but
|
|
11
|
+
# `warning_for` says so on the run, and `PayrollRunProcessor` puts that
|
|
12
|
+
# sentence in front of every other warning until a card is added.
|
|
9
13
|
module StatutoryRateCard
|
|
10
14
|
def self.r(value) = BigDecimal(value.to_s)
|
|
11
15
|
private_class_method :r
|
|
@@ -58,8 +62,44 @@ module HrLite
|
|
|
58
62
|
}.freeze
|
|
59
63
|
|
|
60
64
|
def self.for(period_month)
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
CARDS[effective_date_for(period_month)]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Which card a run will actually use. A month older than every card
|
|
69
|
+
# borrows the earliest one — see `predates_cards?`, which says so.
|
|
70
|
+
def self.effective_date_for(period_month)
|
|
71
|
+
CARDS.keys.sort.reverse.find { |date| date <= period_month } || CARDS.keys.min
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The card in force is from an EARLIER financial year than the run. The
|
|
75
|
+
# lookup still returns figures — it has to, or payroll would stop dead
|
|
76
|
+
# every April — so the run carries the warning instead.
|
|
77
|
+
def self.stale_for?(period_month)
|
|
78
|
+
FinancialYear.before?(effective_date_for(period_month), period_month)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The run is older than every card we ship, so it is being computed on
|
|
82
|
+
# rates that had not been announced yet.
|
|
83
|
+
def self.predates_cards?(period_month)
|
|
84
|
+
period_month < CARDS.keys.min
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# One sentence naming both the run's FY and the card's, or nil when they
|
|
88
|
+
# match. Rendered verbatim in the run's warnings list.
|
|
89
|
+
def self.warning_for(period_month)
|
|
90
|
+
card_fy = FinancialYear.label(effective_date_for(period_month))
|
|
91
|
+
run_fy = FinancialYear.label(period_month)
|
|
92
|
+
|
|
93
|
+
if predates_cards?(period_month)
|
|
94
|
+
"Payroll for FY #{run_fy} is being computed on the FY #{card_fy} " \
|
|
95
|
+
"statutory card — no card ships for a year that early. PF, ESI, PT " \
|
|
96
|
+
"and TDS on this run are not the rates that applied. Verify with your CA."
|
|
97
|
+
elsif stale_for?(period_month)
|
|
98
|
+
"Payroll for FY #{run_fy} is being computed on the FY #{card_fy} " \
|
|
99
|
+
"statutory card — no card ships for FY #{run_fy} yet. PF, ESI, PT and " \
|
|
100
|
+
"TDS on this run use last year's rates. Add a CA-verified card for " \
|
|
101
|
+
"FY #{run_fy} before publishing."
|
|
102
|
+
end
|
|
63
103
|
end
|
|
64
104
|
end
|
|
65
105
|
end
|
data/lib/hr_lite/version.rb
CHANGED
data/lib/hr_lite.rb
CHANGED
|
@@ -3,6 +3,7 @@ require "hr_lite/engine"
|
|
|
3
3
|
require "hr_lite/configuration"
|
|
4
4
|
require "hr_lite/current"
|
|
5
5
|
require "hr_lite/leave_year"
|
|
6
|
+
require "hr_lite/financial_year"
|
|
6
7
|
require "hr_lite/mention_parser"
|
|
7
8
|
require "hr_lite/notifications"
|
|
8
9
|
require "hr_lite/seeds"
|
|
@@ -42,11 +43,27 @@ module HrLite
|
|
|
42
43
|
user.present? && !!config.superadmin_check.call(user)
|
|
43
44
|
end
|
|
44
45
|
|
|
46
|
+
# An access list, cleaned. "a@x.com,,b@x.com".split(",") — one stray
|
|
47
|
+
# comma in an ENV var — used to put "" in the list, and a user whose
|
|
48
|
+
# email was blank then MATCHED it and was handed the tier.
|
|
49
|
+
def normalize_email_list(emails)
|
|
50
|
+
Array(emails).map { |e| e.to_s.downcase.strip }.reject(&:empty?)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Whether a user's own email is on a configured access list. A blank
|
|
54
|
+
# email is never on any list, however the list is spelled.
|
|
55
|
+
def email_listed?(user, emails)
|
|
56
|
+
address = user.respond_to?(:email) ? user.email.to_s.downcase.strip : ""
|
|
57
|
+
return false if address.empty?
|
|
58
|
+
|
|
59
|
+
normalize_email_list(emails).include?(address)
|
|
60
|
+
end
|
|
61
|
+
|
|
45
62
|
# Leadership members resolvable to actual user records (for bell
|
|
46
63
|
# notifications). Emails configured but absent from the user table are
|
|
47
64
|
# still reachable by email — see Notifications.
|
|
48
65
|
def leadership_users
|
|
49
|
-
emails = config.leadership_emails
|
|
66
|
+
emails = normalize_email_list(config.leadership_emails)
|
|
50
67
|
return user_klass.none if emails.empty?
|
|
51
68
|
|
|
52
69
|
user_klass.where("LOWER(#{user_klass.table_name}.email) IN (?)", emails)
|
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.5.
|
|
4
|
+
version: 0.5.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- kshitiz sinha
|
|
@@ -242,6 +242,7 @@ files:
|
|
|
242
242
|
- db/migrate/20260807184103_add_live_uniqueness_indexes_to_hr_lite.rb
|
|
243
243
|
- db/migrate/20260807184426_add_fy_opening_to_hr_lite_employee_profiles.rb
|
|
244
244
|
- db/migrate/20260807184939_add_out_of_window_days_to_hr_lite_salary_slips.rb
|
|
245
|
+
- db/migrate/20260817182124_add_status_constraints_and_appraisal_fk_to_hr_lite.rb
|
|
245
246
|
- lib/generators/hr_lite/install/install_generator.rb
|
|
246
247
|
- lib/generators/hr_lite/install/templates/AFTER_INSTALL
|
|
247
248
|
- lib/generators/hr_lite/install/templates/initializer.rb
|
|
@@ -250,6 +251,7 @@ files:
|
|
|
250
251
|
- lib/hr_lite/configuration.rb
|
|
251
252
|
- lib/hr_lite/current.rb
|
|
252
253
|
- lib/hr_lite/engine.rb
|
|
254
|
+
- lib/hr_lite/financial_year.rb
|
|
253
255
|
- lib/hr_lite/geo.rb
|
|
254
256
|
- lib/hr_lite/leave_year.rb
|
|
255
257
|
- lib/hr_lite/mention_parser.rb
|