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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 42a084897bd2831303410129ecaa25dac81fdb97b711bfe07071cf28496d58d1
4
- data.tar.gz: 014663d43940503d5fa840f5bb0460b8fd3e20bfbaf07a1aa6821b491798d16d
3
+ metadata.gz: a035599be75b81c0d2f3d1fc1b8f95328df845445f6c93d997a9906f9b43846e
4
+ data.tar.gz: 8a840cc6c1af2ef49af660859fc6edf0f2ccec4b59377017c9cc0467793e8396
5
5
  SHA512:
6
- metadata.gz: 40177d861f9579695084b3e0bca3fefd4dd9593d430d800c7c1bfed2c88d1de4820fd12c6c816c24cf29f0d70eb8db26efe2c9cc10870ec1f9be15b262c05f1d
7
- data.tar.gz: 261892abb65e4ebb80f12cdfbdf7763d2e82f9040268efa796870ec6af413cc25079e9e124536e2eb0a4e1a937e0de6a98f98fde84901248a91a3ea944f684bf
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.9.0...HEAD
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
@@ -22,7 +22,7 @@ module HrLite
22
22
  def self.approvable_types
23
23
  %w[
24
24
  HrLite::LeaveRequest HrLite::CompOffRequest
25
- HrLite::RegularizationRequest HrLite::Resignation
25
+ HrLite::RegularizationRequest HrLite::Resignation HrLite::Expense
26
26
  ].freeze
27
27
  end
28
28
 
@@ -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