hr_lite 0.9.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +123 -1
- data/app/jobs/hr_lite/document_expiry_job.rb +28 -0
- data/app/models/hr_lite/approval_flow.rb +1 -1
- data/app/models/hr_lite/benefit.rb +37 -0
- data/app/models/hr_lite/benefit_enrolment.rb +30 -0
- data/app/models/hr_lite/document.rb +95 -0
- data/app/models/hr_lite/expense.rb +142 -0
- data/app/models/hr_lite/expense_category.rb +33 -0
- data/app/models/hr_lite/hr_request.rb +75 -0
- data/app/models/hr_lite/loan.rb +65 -0
- data/app/models/hr_lite/loan_repayment.rb +14 -0
- data/app/models/hr_lite/payroll_line_item.rb +57 -0
- data/app/models/hr_lite/payroll_run.rb +34 -0
- data/app/models/hr_lite/policy.rb +59 -0
- data/app/models/hr_lite/policy_acknowledgement.rb +13 -0
- data/app/models/hr_lite/salary_component.rb +59 -0
- data/app/models/hr_lite/tax_declaration.rb +93 -0
- data/app/models/hr_lite/tax_declaration_item.rb +32 -0
- data/app/services/hr_lite/slip_builder.rb +76 -3
- data/db/migrate/20260818002627_create_hr_lite_payroll_components_and_loans.rb +72 -0
- data/db/migrate/20260818003616_create_hr_lite_documents_and_declarations.rb +73 -0
- data/db/migrate/20260818004707_create_hr_lite_employee_services.rb +108 -0
- data/lib/hr_lite/notifications.rb +15 -0
- data/lib/hr_lite/permissions.rb +13 -0
- data/lib/hr_lite/role_seeds.rb +13 -3
- data/lib/hr_lite/version.rb +1 -1
- data/lib/tasks/hr_lite_tasks.rake +2 -1
- metadata +19 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a035599be75b81c0d2f3d1fc1b8f95328df845445f6c93d997a9906f9b43846e
|
|
4
|
+
data.tar.gz: 8a840cc6c1af2ef49af660859fc6edf0f2ccec4b59377017c9cc0467793e8396
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9bef92ea728cc960bc548c37fe3a8a34090ad531bec3ab248dc572ce9931f44fb5bf2591b1578df0c22f68ffd5ab7c02e6e7a8ed43445b1087524fc59584889b
|
|
7
|
+
data.tar.gz: 2598075e1f67e8e2c9e7fa4142db5e7561f42f17e32a9be6207ca560116a8e9d3ac7b7876c02e4bc77c250df6ef0ec0b05592ad9c073b22aaa8f01158129276f
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,125 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.12.0] - 2026-08-18
|
|
11
|
+
|
|
12
|
+
The four things an employee had to leave the system to do. **Adds one
|
|
13
|
+
migration.**
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **Expenses**, with categories carrying a monthly cap and a receipt rule.
|
|
18
|
+
The cap is checked when the claim is MADE, not at approval — telling
|
|
19
|
+
somebody they are over it after a week of waiting is the wrong moment to
|
|
20
|
+
find out. A claim awaiting a decision counts against the cap; a rejected
|
|
21
|
+
one gives the room back.
|
|
22
|
+
- Expenses route through the approval engine from 0.9.0 rather than growing a
|
|
23
|
+
fifth approve/reject. Configure a flow and a claim gets manager-then-finance
|
|
24
|
+
for free. `reimbursed` is a separate state from `approved`, because agreeing
|
|
25
|
+
to pay somebody and paying them are different days and the person waiting
|
|
26
|
+
cares about the second one.
|
|
27
|
+
- **Benefits and enrolments.** Dependants are a COUNT, not a list — names and
|
|
28
|
+
dates of birth of somebody's family are data this engine has no reason to
|
|
29
|
+
hold, and holding them would mean protecting them.
|
|
30
|
+
- **An HR help desk**: raise, assign, resolve, with the answer going back to
|
|
31
|
+
the person who asked instead of dying in somebody's inbox.
|
|
32
|
+
- **Policies, versioned.** Re-issuing asks everybody again, because an
|
|
33
|
+
acknowledgement of v1 says nothing about v2 — that is the whole point of
|
|
34
|
+
versioning it. Acknowledgements are `readonly?` once written: evidence that
|
|
35
|
+
can be edited afterwards is not evidence. Clicking twice is not an error.
|
|
36
|
+
- Permissions for all four, granted to the roles that should hold them, and
|
|
37
|
+
nine notification events.
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
|
|
41
|
+
- `HrLite::Expense` is the second module on the approval engine, which is the
|
|
42
|
+
first real test of the claim made in 0.9.0 that a new module gets
|
|
43
|
+
multi-level approval without touching the engine. It did.
|
|
44
|
+
|
|
45
|
+
## [0.11.0] - 2026-08-18
|
|
46
|
+
|
|
47
|
+
Documents, and a tax declaration that is more than one number. **Adds one
|
|
48
|
+
migration.** Requires Active Storage on the host (`rails active_storage:install`).
|
|
49
|
+
|
|
50
|
+
### Added
|
|
51
|
+
|
|
52
|
+
- **`hr_lite_documents`** — there was nowhere to keep an employee's documents
|
|
53
|
+
at all. Files attach through Active Storage, which content-sniffs via
|
|
54
|
+
Marcel, so the type allowlist checks real bytes rather than what the client
|
|
55
|
+
claimed. SVG and HTML stay out: both carry script and both render in a
|
|
56
|
+
browser. 10 MB cap.
|
|
57
|
+
- **Visibility is a property of the row**, and an identity document defaults
|
|
58
|
+
to the tightest setting rather than the convenient one — an Aadhaar, PAN,
|
|
59
|
+
passport or bank mandate is money-tier by default, not HR's to browse. A
|
|
60
|
+
default nobody thinks about is the one that leaks. An explicit choice always
|
|
61
|
+
wins, which is why the column carries no default of its own.
|
|
62
|
+
- Verification with a named checker, and expiry with a job that warns at 30
|
|
63
|
+
days and again at 7 — not daily, because a warning that arrives every day
|
|
64
|
+
is one nobody reads by the third.
|
|
65
|
+
- **`hr_lite_tax_declarations`** and items, replacing
|
|
66
|
+
`declared_annual_deductions`: ONE opaque figure an admin typed in, that
|
|
67
|
+
nobody could see the makeup of, that the employee could not submit
|
|
68
|
+
themselves, and that had nowhere to keep the proof. It is the number the
|
|
69
|
+
whole year's TDS is projected from.
|
|
70
|
+
- Sections (80C, 80D, 80CCD(1B), 24(b), HRA), each with what was **declared**
|
|
71
|
+
and what the proof actually **supported**. Until HR has looked, the declared
|
|
72
|
+
figure stands — asking somebody to overpay tax all year because paperwork is
|
|
73
|
+
slow is its own kind of wrong. Once verified, only the supported figure
|
|
74
|
+
counts.
|
|
75
|
+
- The regime follows the declaration, because choosing one is a per-YEAR
|
|
76
|
+
decision and the profile column cannot express that.
|
|
77
|
+
- Permissions `document.view`, `document.manage`, `tax.view`, `tax.manage`,
|
|
78
|
+
granted to the roles that should hold them.
|
|
79
|
+
|
|
80
|
+
### Changed
|
|
81
|
+
|
|
82
|
+
- `SlipBuilder` reads the declaration when one exists for the run's financial
|
|
83
|
+
year, falling back to the profile figure otherwise. A DRAFT declaration is
|
|
84
|
+
ignored — a draft is not a claim.
|
|
85
|
+
|
|
86
|
+
## [0.10.0] - 2026-08-18
|
|
87
|
+
|
|
88
|
+
Payroll gets heads, arrears and loans. **Adds one migration.** Existing
|
|
89
|
+
payslips compute identically — every new head is opt-in data.
|
|
90
|
+
|
|
91
|
+
### Added
|
|
92
|
+
|
|
93
|
+
- **`hr_lite_salary_components`** — earning and deduction heads as DATA.
|
|
94
|
+
Payroll knew exactly four earnings because they were columns on the salary
|
|
95
|
+
structure, so a bonus, an incentive, an LTA or a reimbursement had nowhere
|
|
96
|
+
to go but "other": one number on a payslip that should have said what it
|
|
97
|
+
was for. Seeded with bonus, incentive, arrears, LTA, reimbursement, loan
|
|
98
|
+
repayment and other-deduction; an install adds its own.
|
|
99
|
+
- Each head declares whether it is **prorated** (a bonus is not halved
|
|
100
|
+
because somebody joined on the 16th), **taxable**, and whether it
|
|
101
|
+
**counts toward the ESI gross** — a reimbursement is not a wage, and
|
|
102
|
+
counting it could push somebody over the ceiling and out of cover.
|
|
103
|
+
- **`hr_lite_payroll_line_items`** — one-offs for a single month, including
|
|
104
|
+
arrears after a backdated revision. Dated to the MONTH rather than the run,
|
|
105
|
+
so deleting a draft and computing it again does not lose them. Refused on a
|
|
106
|
+
month already published: that slip is immutable, and a later line would
|
|
107
|
+
show on no payslip while quietly moving the year-to-date the TDS projector
|
|
108
|
+
reads.
|
|
109
|
+
- **`hr_lite_loans`** and repayments. The outstanding balance is DERIVED from
|
|
110
|
+
the repayments actually taken, never stored — a stored balance and a
|
|
111
|
+
recomputed run disagree the first time somebody deletes a draft. The last
|
|
112
|
+
instalment takes only what is left.
|
|
113
|
+
- Repayments are booked when a run is **finalized**, not at compute: a draft
|
|
114
|
+
is recomputed as often as the operator likes, and each pass would otherwise
|
|
115
|
+
take another instalment. Unlocking a run gives them back, and reopens a
|
|
116
|
+
loan that had closed.
|
|
117
|
+
|
|
118
|
+
### Changed
|
|
119
|
+
|
|
120
|
+
- Branch coverage is now a **floor at 90%, not a ratchet**, with the reasoning
|
|
121
|
+
written into `spec_helper.rb`. Its denominator grows with every subsystem,
|
|
122
|
+
so ratcheting it charges each feature PR a tax for branches in files it
|
|
123
|
+
never touched. Line coverage remains the ratchet at 100% overall and 90%
|
|
124
|
+
per file — that is the gate that has actually caught defects.
|
|
125
|
+
- Paid down some of the branch debt named in 0.9.0: the status predicates
|
|
126
|
+
across six models, the UAN format validation, and the holiday bulk-import
|
|
127
|
+
paths (blank lines, an empty paste, a line that cannot be read).
|
|
128
|
+
|
|
10
129
|
## [0.9.0] - 2026-08-18
|
|
11
130
|
|
|
12
131
|
A reusable approval engine. **Adds one migration** (flows, steps, approvals,
|
|
@@ -610,7 +729,10 @@ Initial release.
|
|
|
610
729
|
bus with per-event channel matrix (bell, email, leadership email/bell),
|
|
611
730
|
daily leadership digest and an append-only audit trail.
|
|
612
731
|
|
|
613
|
-
[Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.
|
|
732
|
+
[Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.12.0...HEAD
|
|
733
|
+
[0.12.0]: https://github.com/kshtzkr/hr_lite/compare/v0.11.0...v0.12.0
|
|
734
|
+
[0.11.0]: https://github.com/kshtzkr/hr_lite/compare/v0.10.0...v0.11.0
|
|
735
|
+
[0.10.0]: https://github.com/kshtzkr/hr_lite/compare/v0.9.0...v0.10.0
|
|
614
736
|
[0.9.0]: https://github.com/kshtzkr/hr_lite/compare/v0.8.0...v0.9.0
|
|
615
737
|
[0.8.0]: https://github.com/kshtzkr/hr_lite/compare/v0.7.0...v0.8.0
|
|
616
738
|
[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,37 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# An insurance policy or other benefit the company provides. Employees can
|
|
3
|
+
# finally see what they are covered for without emailing somebody.
|
|
4
|
+
class Benefit < ApplicationRecord
|
|
5
|
+
include EncryptedMoney
|
|
6
|
+
include Audited
|
|
7
|
+
|
|
8
|
+
KINDS = %w[health life accident other].freeze
|
|
9
|
+
|
|
10
|
+
encrypted_money :coverage, :employer_premium, :employee_premium
|
|
11
|
+
|
|
12
|
+
has_many :benefit_enrolments, class_name: "HrLite::BenefitEnrolment", dependent: :destroy
|
|
13
|
+
has_many :enrolled_users, through: :benefit_enrolments, source: :user
|
|
14
|
+
|
|
15
|
+
validates :name, presence: true
|
|
16
|
+
validates :kind, inclusion: { in: KINDS }
|
|
17
|
+
validate :expiry_is_after_the_start
|
|
18
|
+
|
|
19
|
+
scope :active, -> { where(active: true) }
|
|
20
|
+
scope :live_on, ->(date) {
|
|
21
|
+
active.where("effective_from IS NULL OR effective_from <= ?", date)
|
|
22
|
+
.where("expires_on IS NULL OR expires_on >= ?", date)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
def expiring?(within: 30, on: Date.current)
|
|
26
|
+
expires_on.present? && expires_on <= on + within && expires_on >= on
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def expiry_is_after_the_start
|
|
32
|
+
return if effective_from.nil? || expires_on.nil? || expires_on >= effective_from
|
|
33
|
+
|
|
34
|
+
errors.add(:expires_on, "must be on or after the start date")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Who is covered, from when. Dependants are a COUNT, not a list — names and
|
|
3
|
+
# dates of birth of somebody's family are data this engine has no reason to
|
|
4
|
+
# hold, and holding them would mean protecting them.
|
|
5
|
+
class BenefitEnrolment < ApplicationRecord
|
|
6
|
+
include Audited
|
|
7
|
+
|
|
8
|
+
belongs_to :benefit, class_name: "HrLite::Benefit"
|
|
9
|
+
belongs_to :user, class_name: HrLite.config.user_class
|
|
10
|
+
|
|
11
|
+
validates :enrolled_on, presence: true
|
|
12
|
+
validates :user_id, uniqueness: { scope: :benefit_id }
|
|
13
|
+
validates :dependants, numericality: { greater_than_or_equal_to: 0 }
|
|
14
|
+
validate :ended_after_it_started
|
|
15
|
+
|
|
16
|
+
scope :live_on, ->(date) {
|
|
17
|
+
where(enrolled_on: ..date).where("ended_on IS NULL OR ended_on >= ?", date)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
def live?(on = Date.current) = enrolled_on <= on && (ended_on.nil? || ended_on >= on)
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def ended_after_it_started
|
|
25
|
+
return if ended_on.nil? || enrolled_on.nil? || ended_on >= enrolled_on
|
|
26
|
+
|
|
27
|
+
errors.add(:ended_on, "must be on or after the enrolment date")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
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,142 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# A claim. Routed through the approval engine rather than growing a fifth
|
|
3
|
+
# approve/reject of its own — configure a flow for it and it gets manager
|
|
4
|
+
# then finance for free.
|
|
5
|
+
class Expense < ApplicationRecord
|
|
6
|
+
include EncryptedMoney
|
|
7
|
+
include Audited
|
|
8
|
+
include Approvable
|
|
9
|
+
|
|
10
|
+
STATUSES = %w[draft submitted approved rejected reimbursed cancelled].freeze
|
|
11
|
+
|
|
12
|
+
encrypted_money :amount
|
|
13
|
+
|
|
14
|
+
belongs_to :category, class_name: "HrLite::ExpenseCategory"
|
|
15
|
+
belongs_to :user, class_name: HrLite.config.user_class
|
|
16
|
+
|
|
17
|
+
has_one_attached :receipt
|
|
18
|
+
|
|
19
|
+
validates :status, inclusion: { in: STATUSES }
|
|
20
|
+
validates :description, presence: true
|
|
21
|
+
validates :spent_on, presence: true
|
|
22
|
+
validate :amount_is_positive
|
|
23
|
+
validate :spent_on_is_not_in_the_future
|
|
24
|
+
validate :within_the_category_cap, on: :create
|
|
25
|
+
validate :receipt_is_present_when_the_category_demands_one
|
|
26
|
+
|
|
27
|
+
scope :awaiting_reimbursement, -> { where(status: "approved") }
|
|
28
|
+
scope :recent_first, -> { order(spent_on: :desc, id: :desc) }
|
|
29
|
+
|
|
30
|
+
STATUSES.each { |s| define_method("#{s}?") { status == s } }
|
|
31
|
+
|
|
32
|
+
def submit!(actor:)
|
|
33
|
+
raise ActiveRecord::RecordInvalid.new(self), "not a draft" unless draft? || rejected?
|
|
34
|
+
|
|
35
|
+
update!(status: "submitted", submitted_at: Time.current)
|
|
36
|
+
notify_deciders
|
|
37
|
+
true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def approve!(actor:, note: nil)
|
|
41
|
+
return record_routed!(actor, note, :approved) if awaiting?(actor)
|
|
42
|
+
|
|
43
|
+
settle!("approved", actor, note)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def reject!(actor:, note:)
|
|
47
|
+
raise ArgumentError, "a rejection needs a reason" if note.blank?
|
|
48
|
+
return record_routed!(actor, note, :rejected) if awaiting?(actor)
|
|
49
|
+
|
|
50
|
+
settle!("rejected", actor, note)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Paid out with a payroll month. Kept separate from `approved` because
|
|
54
|
+
# agreeing to pay somebody and actually paying them are different days,
|
|
55
|
+
# and the person waiting cares about the second one.
|
|
56
|
+
def reimburse!(actor:, period_month:)
|
|
57
|
+
raise ActiveRecord::RecordInvalid.new(self), "not approved" unless approved?
|
|
58
|
+
|
|
59
|
+
transaction do
|
|
60
|
+
update!(status: "reimbursed", reimbursed_in: period_month.beginning_of_month)
|
|
61
|
+
AuditLog.record!(action: "expense.reimbursed", subject: self, actor: actor,
|
|
62
|
+
changes: { "employee" => HrLite.display_name(user),
|
|
63
|
+
"period" => period_month.strftime("%B %Y") })
|
|
64
|
+
end
|
|
65
|
+
Notifications.publish(
|
|
66
|
+
"expense.reimbursed",
|
|
67
|
+
title: "Your #{category.name} claim was reimbursed with #{period_month.strftime('%B %Y')} payroll",
|
|
68
|
+
path: "/expenses", bell_to: [ user ], email_to: [ user ]
|
|
69
|
+
)
|
|
70
|
+
true
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def record_routed!(actor, note, intent)
|
|
76
|
+
approval = approval_for(actor)
|
|
77
|
+
result = approval_route.decide!(approval, status: intent.to_s, actor: actor, note: note)
|
|
78
|
+
|
|
79
|
+
case result.outcome
|
|
80
|
+
when :approved then settle!("approved", actor, note)
|
|
81
|
+
# `reject!` already refuses a blank note, so there is nothing to
|
|
82
|
+
# default to here.
|
|
83
|
+
when :rejected then settle!("rejected", actor, note)
|
|
84
|
+
else
|
|
85
|
+
notify_deciders
|
|
86
|
+
true
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def settle!(new_status, actor, note)
|
|
91
|
+
update!(status: new_status, decision_note: note.presence)
|
|
92
|
+
Notifications.publish(
|
|
93
|
+
new_status == "approved" ? "expense.approved" : "expense.rejected",
|
|
94
|
+
title: "Your #{category.name} claim was #{new_status}",
|
|
95
|
+
body: note.presence, path: "/expenses", bell_to: [ user ], email_to: [ user ]
|
|
96
|
+
)
|
|
97
|
+
true
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def notify_deciders
|
|
101
|
+
deciders = pending_approvals.map(&:approver).compact.uniq
|
|
102
|
+
deciders = HrLite.users_holding("expense.approve").to_a if deciders.empty?
|
|
103
|
+
return if deciders.empty?
|
|
104
|
+
|
|
105
|
+
Notifications.publish(
|
|
106
|
+
"expense.submitted",
|
|
107
|
+
title: "#{HrLite.display_name(user)} claimed #{Money.format(amount)} — #{category.name}",
|
|
108
|
+
body: description, path: "/admin/expenses", bell_to: deciders
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def amount_is_positive
|
|
113
|
+
errors.add(:amount, "must be more than zero") unless amount&.positive?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def spent_on_is_not_in_the_future
|
|
117
|
+
return if spent_on.nil? || spent_on <= Date.current
|
|
118
|
+
|
|
119
|
+
errors.add(:spent_on, "cannot be in the future")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Checked at claim time rather than at approval: telling somebody they
|
|
123
|
+
# are over the cap after they have waited a week for an answer is the
|
|
124
|
+
# wrong moment to find out.
|
|
125
|
+
def within_the_category_cap
|
|
126
|
+
return if category.nil? || amount.nil? || spent_on.nil?
|
|
127
|
+
|
|
128
|
+
remaining = category.remaining_for(user, spent_on.beginning_of_month)
|
|
129
|
+
return if remaining.nil? || amount <= remaining
|
|
130
|
+
|
|
131
|
+
errors.add(:amount, "is over the #{category.name} cap for #{spent_on.strftime('%B')} " \
|
|
132
|
+
"— #{Money.format(remaining)} left")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def receipt_is_present_when_the_category_demands_one
|
|
136
|
+
return unless category&.receipt_required
|
|
137
|
+
return if receipt.attached?
|
|
138
|
+
|
|
139
|
+
errors.add(:receipt, "is required for #{category.name}")
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# "Travel", "Client entertainment", "Home office" — with the cap and the
|
|
3
|
+
# receipt rule that go with each.
|
|
4
|
+
class ExpenseCategory < ApplicationRecord
|
|
5
|
+
include EncryptedMoney
|
|
6
|
+
include Audited
|
|
7
|
+
|
|
8
|
+
encrypted_money :monthly_cap
|
|
9
|
+
|
|
10
|
+
has_many :expenses, class_name: "HrLite::Expense", foreign_key: :category_id,
|
|
11
|
+
dependent: :restrict_with_error
|
|
12
|
+
|
|
13
|
+
validates :name, presence: true, uniqueness: { case_sensitive: false }
|
|
14
|
+
|
|
15
|
+
scope :active, -> { where(active: true) }
|
|
16
|
+
scope :alphabetical, -> { order(:name) }
|
|
17
|
+
|
|
18
|
+
def uncapped? = monthly_cap.nil?
|
|
19
|
+
|
|
20
|
+
# What is left of this month's cap for one person, or nil when uncapped.
|
|
21
|
+
# Counts everything not refused: a claim awaiting a decision has still
|
|
22
|
+
# been spent against the cap.
|
|
23
|
+
def remaining_for(user, month = Date.current.beginning_of_month)
|
|
24
|
+
return nil if uncapped?
|
|
25
|
+
|
|
26
|
+
spent = expenses.where(user_id: user.id)
|
|
27
|
+
.where(spent_on: month..month.end_of_month)
|
|
28
|
+
.where.not(status: %w[rejected cancelled])
|
|
29
|
+
.sum(BigDecimal(0)) { |e| e.amount || BigDecimal(0) }
|
|
30
|
+
[ monthly_cap - spent, BigDecimal(0) ].max
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# "Can I have a salary certificate?" — the questions that were going to
|
|
3
|
+
# somebody's inbox and getting lost there.
|
|
4
|
+
class HrRequest < ApplicationRecord
|
|
5
|
+
include Audited
|
|
6
|
+
|
|
7
|
+
STATUSES = %w[open in_progress resolved closed cancelled].freeze
|
|
8
|
+
|
|
9
|
+
CATEGORIES = %w[
|
|
10
|
+
salary_certificate employment_certificate address_change bank_change
|
|
11
|
+
document_request payroll_query tax_query insurance_query policy_query other
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
belongs_to :user, class_name: HrLite.config.user_class
|
|
15
|
+
belongs_to :assigned_to, class_name: HrLite.config.user_class, optional: true
|
|
16
|
+
|
|
17
|
+
validates :subject, presence: true
|
|
18
|
+
validates :category, inclusion: { in: CATEGORIES }
|
|
19
|
+
validates :status, inclusion: { in: STATUSES }
|
|
20
|
+
|
|
21
|
+
scope :open_requests, -> { where(status: %w[open in_progress]) }
|
|
22
|
+
scope :recent_first, -> { order(created_at: :desc) }
|
|
23
|
+
|
|
24
|
+
STATUSES.each { |s| define_method("#{s}?") { status == s } }
|
|
25
|
+
|
|
26
|
+
after_create_commit :notify_desk
|
|
27
|
+
|
|
28
|
+
def category_label = category.humanize
|
|
29
|
+
|
|
30
|
+
def assign!(actor:, assignee:)
|
|
31
|
+
update!(assigned_to_id: assignee.id, status: "in_progress")
|
|
32
|
+
Notifications.publish(
|
|
33
|
+
"hr_request.assigned",
|
|
34
|
+
title: "#{category_label}: #{subject}",
|
|
35
|
+
body: "Assigned to you by #{HrLite.display_name(actor)}.",
|
|
36
|
+
path: "/admin/hr_requests/#{id}", bell_to: [ assignee ]
|
|
37
|
+
)
|
|
38
|
+
true
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def resolve!(actor:, resolution:)
|
|
42
|
+
raise ArgumentError, "a resolution needs an answer" if resolution.blank?
|
|
43
|
+
|
|
44
|
+
update!(status: "resolved", resolution: resolution, resolved_at: Time.current,
|
|
45
|
+
assigned_to_id: assigned_to_id || actor.id)
|
|
46
|
+
Notifications.publish(
|
|
47
|
+
"hr_request.resolved",
|
|
48
|
+
title: "Your request was answered — #{subject}",
|
|
49
|
+
body: resolution, path: "/hr_requests/#{id}",
|
|
50
|
+
bell_to: [ user ], email_to: [ user ]
|
|
51
|
+
)
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def cancel!(actor:)
|
|
56
|
+
raise ActiveRecord::RecordInvalid.new(self), "already settled" unless open? || in_progress?
|
|
57
|
+
|
|
58
|
+
update!(status: "cancelled")
|
|
59
|
+
true
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def notify_desk
|
|
65
|
+
desk = HrLite.users_holding("hr_request.manage").to_a
|
|
66
|
+
return if desk.empty?
|
|
67
|
+
|
|
68
|
+
Notifications.publish(
|
|
69
|
+
"hr_request.raised",
|
|
70
|
+
title: "#{HrLite.display_name(user)} asked: #{subject}",
|
|
71
|
+
body: body.presence, path: "/admin/hr_requests/#{id}", bell_to: desk
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
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
|