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.
@@ -0,0 +1,14 @@
1
+ module HrLite
2
+ # One month's deduction actually taken. Written when the run is finalized,
3
+ # so recomputing a draft never books a repayment twice — and the unique
4
+ # index on [loan, month] means a retry cannot either.
5
+ class LoanRepayment < ApplicationRecord
6
+ include EncryptedMoney
7
+
8
+ encrypted_money :amount
9
+
10
+ belongs_to :loan, class_name: "HrLite::Loan"
11
+
12
+ validates :period_month, presence: true, uniqueness: { scope: :loan_id }
13
+ end
14
+ end
@@ -0,0 +1,57 @@
1
+ module HrLite
2
+ # A one-off on one month's payslip: a bonus, an incentive, arrears after a
3
+ # backdated revision, a reimbursement, a one-time deduction.
4
+ #
5
+ # Dated to the MONTH, not to the run, so deleting a draft run and computing
6
+ # it again does not lose them.
7
+ class PayrollLineItem < ApplicationRecord
8
+ include EncryptedMoney
9
+ include Audited
10
+
11
+ encrypted_money :amount
12
+
13
+ belongs_to :component, class_name: "HrLite::SalaryComponent"
14
+ belongs_to :user, class_name: HrLite.config.user_class
15
+ belongs_to :created_by, class_name: HrLite.config.user_class, optional: true
16
+
17
+ validates :period_month, presence: true
18
+ validate :period_is_first_of_month
19
+ validate :amount_is_positive
20
+ validate :period_is_not_already_paid, on: :create
21
+
22
+ scope :for_month, ->(month) { where(period_month: month) }
23
+
24
+ def self.for_slip(user, period_month)
25
+ where(user_id: user.id, period_month: period_month).includes(:component)
26
+ end
27
+
28
+ private
29
+
30
+ def period_is_first_of_month
31
+ return if period_month.nil? || period_month.day == 1
32
+
33
+ errors.add(:period_month, "must be the 1st of a month")
34
+ end
35
+
36
+ # Amounts are signed by the component's KIND, not by the number: a
37
+ # deduction of -500 and a deduction of 500 would otherwise both be
38
+ # storable and mean opposite things.
39
+ def amount_is_positive
40
+ return if amount.nil? || amount.positive?
41
+
42
+ errors.add(:amount, "must be more than zero — a deduction is a deduction by its component, not by a minus sign")
43
+ end
44
+
45
+ # A published slip is immutable. Adding a line to that month afterwards
46
+ # would show on no payslip and quietly change the year-to-date totals the
47
+ # TDS projector reads.
48
+ def period_is_not_already_paid
49
+ return if user_id.nil? || period_month.nil?
50
+
51
+ settled = SalarySlip.settled.exists?(user_id: user_id, period_month: period_month)
52
+ return unless settled
53
+
54
+ errors.add(:period_month, "has already been paid — put this on the next open month")
55
+ end
56
+ end
57
+ end
@@ -64,6 +64,7 @@ module HrLite
64
64
  # Finalizing freezes every slip in the run. Who did it, to how many
65
65
  # people, is the row an investigation starts from.
66
66
  audit!("payroll.finalized", actor, "slips" => salary_slips.count)
67
+ book_loan_repayments!
67
68
  end
68
69
  Notifications.publish(
69
70
  "payroll.finalized",
@@ -77,6 +78,10 @@ module HrLite
77
78
  raise_unless %w[finalized]
78
79
  transaction do
79
80
  update!(status: "review")
81
+ # The slips are editable again, so the repayments booked at finalize
82
+ # have to come back off the loans — otherwise unlocking and
83
+ # refinalizing takes the instalment twice.
84
+ unbook_loan_repayments!
80
85
  # Reopening frozen slips for editing is the single most sensitive
81
86
  # transition in the module — it is the one that has to leave a trace.
82
87
  audit!("payroll.unlocked", actor, "slips" => salary_slips.count)
@@ -124,6 +129,35 @@ module HrLite
124
129
 
125
130
  private
126
131
 
132
+ # A repayment is booked when the run is FINALIZED, never at compute: a
133
+ # draft is recomputed as often as the operator likes, and each pass would
134
+ # otherwise take another instalment off the loan.
135
+ def book_loan_repayments!
136
+ salary_slips.includes(:user).find_each do |slip|
137
+ taken = slip.deduction_amount("loan_repayment")
138
+ next unless taken.positive?
139
+
140
+ remaining = taken
141
+ Loan.active.where(user_id: slip.user_id).order(:starts_on).each do |loan|
142
+ break unless remaining.positive?
143
+
144
+ share = [ loan.instalment_for(period_month), remaining ].min
145
+ next unless share.positive?
146
+
147
+ loan.record_repayment!(period_month, share)
148
+ remaining -= share
149
+ end
150
+ end
151
+ end
152
+
153
+ def unbook_loan_repayments!
154
+ LoanRepayment.where(period_month: period_month)
155
+ .where(loan_id: Loan.where(user_id: salary_slips.select(:user_id)))
156
+ .destroy_all
157
+ Loan.where(user_id: salary_slips.select(:user_id), status: "closed")
158
+ .each { |loan| loan.update!(status: "active") if loan.outstanding.positive? }
159
+ end
160
+
127
161
  # Amounts stay out of it on purpose: audit_logs is a plaintext table and
128
162
  # every slip amount in this module is encrypted. The row records WHO
129
163
  # moved the run and HOW FAR, never what anyone is paid.
@@ -0,0 +1,59 @@
1
+ module HrLite
2
+ # A written policy people are held to. Versioned, because "you agreed to
3
+ # this" is only true of the version somebody actually read.
4
+ class Policy < ApplicationRecord
5
+ include Audited
6
+
7
+ has_many :policy_acknowledgements, class_name: "HrLite::PolicyAcknowledgement",
8
+ dependent: :destroy
9
+
10
+ validates :title, presence: true, uniqueness: { scope: :version }
11
+ validates :body, presence: true
12
+ validates :effective_from, presence: true
13
+ validates :version, numericality: { greater_than: 0 }
14
+
15
+ scope :published, -> { where(published: true) }
16
+ scope :live_on, ->(date) { published.where(effective_from: ..date) }
17
+ scope :newest_first, -> { order(effective_from: :desc, version: :desc) }
18
+
19
+ # The version in force today for each title — an older one stays for the
20
+ # acknowledgements attached to it, but nobody should be reading it.
21
+ def self.current
22
+ live_on(Date.current).group_by(&:title).values.map do |versions|
23
+ versions.max_by(&:version)
24
+ end
25
+ end
26
+
27
+ # Re-issuing asks everybody again, which is the whole point of versioning
28
+ # it: an acknowledgement of v1 says nothing about v2.
29
+ def supersede!(body:, effective_from: Date.current, acknowledgement_required: nil)
30
+ self.class.create!(
31
+ title: title, body: body, version: version + 1, effective_from: effective_from,
32
+ acknowledgement_required: acknowledgement_required.nil? ? self.acknowledgement_required : acknowledgement_required,
33
+ published: true
34
+ )
35
+ end
36
+
37
+ def acknowledged_by?(user)
38
+ policy_acknowledgements.exists?(user_id: user.id)
39
+ end
40
+
41
+ def outstanding_for
42
+ return HrLite.user_klass.none unless acknowledgement_required && published
43
+
44
+ HrLite.user_klass.where(id: HrLite.active_employees.map(&:id))
45
+ .where.not(id: policy_acknowledgements.select(:user_id))
46
+ end
47
+
48
+ # Clicking twice is not an error and must not read as one. The
49
+ # find_or_create handles the second click; the rescue handles two
50
+ # requests arriving at once, which the unique index refuses.
51
+ def acknowledge!(user)
52
+ policy_acknowledgements.find_or_create_by!(user_id: user.id) do |ack|
53
+ ack.acknowledged_at = Time.current
54
+ end
55
+ rescue ActiveRecord::RecordNotUnique
56
+ policy_acknowledgements.find_by!(user_id: user.id)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,13 @@
1
+ module HrLite
2
+ # "I have read this." Kept per VERSION, and never edited — it is the
3
+ # evidence, and evidence that can be changed afterwards is not evidence.
4
+ class PolicyAcknowledgement < ApplicationRecord
5
+ belongs_to :policy, class_name: "HrLite::Policy"
6
+ belongs_to :user, class_name: HrLite.config.user_class
7
+
8
+ validates :acknowledged_at, presence: true
9
+ validates :user_id, uniqueness: { scope: :policy_id }
10
+
11
+ def readonly? = persisted?
12
+ end
13
+ end
@@ -0,0 +1,59 @@
1
+ module HrLite
2
+ # An earning or deduction head. Data, so an install can add "Night shift
3
+ # allowance" without a gem release — and so a payslip line says what it
4
+ # was for instead of disappearing into "other".
5
+ class SalaryComponent < ApplicationRecord
6
+ include Audited
7
+
8
+ KINDS = %w[earning deduction].freeze
9
+
10
+ # Heads the structure itself owns. A line item may not use these — they
11
+ # already have a column, and two sources for one number is how a payslip
12
+ # stops adding up.
13
+ STRUCTURAL_CODES = %w[basic hra special_allowance other_earnings].freeze
14
+
15
+ has_many :payroll_line_items, class_name: "HrLite::PayrollLineItem",
16
+ foreign_key: :component_id, dependent: :restrict_with_error
17
+
18
+ validates :code, presence: true, uniqueness: { case_sensitive: false },
19
+ format: { with: /\A[a-z0-9_]+\z/, message: "is lowercase letters, numbers and underscores" }
20
+ validates :label, presence: true
21
+ validates :kind, inclusion: { in: KINDS }
22
+ validate :code_is_not_a_structural_one, on: :create
23
+
24
+ scope :active, -> { where(active: true) }
25
+ scope :earnings, -> { where(kind: "earning") }
26
+ scope :deductions, -> { where(kind: "deduction") }
27
+ scope :ordered, -> { order(:position, :label) }
28
+
29
+ KINDS.each { |k| define_method("#{k}?") { kind == k } }
30
+
31
+ def self.seed_defaults!
32
+ [
33
+ { code: "bonus", label: "Bonus", prorated: false, position: 10 },
34
+ { code: "incentive", label: "Incentive", prorated: false, position: 20 },
35
+ { code: "arrears", label: "Arrears", prorated: false, position: 30 },
36
+ { code: "lta", label: "Leave travel allowance", prorated: false, position: 40 },
37
+ { code: "reimbursement", label: "Reimbursement", prorated: false, taxable: false,
38
+ counts_for_esi: false, position: 50 },
39
+ { code: "loan_repayment", label: "Loan repayment", kind: "deduction",
40
+ prorated: false, taxable: false, counts_for_esi: false, position: 60 },
41
+ { code: "other_deduction", label: "Other deduction", kind: "deduction",
42
+ prorated: false, taxable: false, counts_for_esi: false, position: 70 }
43
+ ].filter_map do |attributes|
44
+ next if exists?(code: attributes[:code])
45
+
46
+ create!(attributes)
47
+ attributes[:code]
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def code_is_not_a_structural_one
54
+ return unless STRUCTURAL_CODES.include?(code.to_s)
55
+
56
+ errors.add(:code, "is already a salary-structure field — pick another name")
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,93 @@
1
+ module HrLite
2
+ # What an employee claims for the year, section by section, replacing the
3
+ # single `declared_annual_deductions` figure an admin used to type in.
4
+ #
5
+ # The whole year's TDS is projected from this number, so it matters that it
6
+ # is broken down, that the employee submitted it themselves, and that HR
7
+ # can record what the proof actually supported.
8
+ class TaxDeclaration < ApplicationRecord
9
+ include Audited
10
+
11
+ STATUSES = %w[draft submitted verified rejected].freeze
12
+ REGIMES = %w[new old].freeze
13
+
14
+ has_many :tax_declaration_items, -> { order(:section) },
15
+ class_name: "HrLite::TaxDeclarationItem",
16
+ foreign_key: :declaration_id, dependent: :destroy
17
+ accepts_nested_attributes_for :tax_declaration_items, allow_destroy: true
18
+
19
+ belongs_to :user, class_name: HrLite.config.user_class
20
+ belongs_to :verified_by, class_name: HrLite.config.user_class, optional: true
21
+
22
+ validates :status, inclusion: { in: STATUSES }
23
+ validates :regime, inclusion: { in: REGIMES }
24
+ validates :financial_year, presence: true, uniqueness: { scope: :user_id }
25
+ validate :financial_year_opens_in_april
26
+
27
+ STATUSES.each { |s| define_method("#{s}?") { status == s } }
28
+
29
+ def self.for(user, period_month)
30
+ find_by(user_id: user.id, financial_year: FinancialYear.start_for(period_month))
31
+ end
32
+
33
+ def label = "FY #{FinancialYear.label(financial_year)}"
34
+
35
+ def declared_total = tax_declaration_items.sum(BigDecimal(0)) { |i| i.declared_amount || 0 }
36
+
37
+ # What payroll should actually deduct against. Until HR has verified it,
38
+ # the declared figure stands — asking somebody to overpay tax all year
39
+ # because paperwork is slow is its own kind of wrong — but once verified,
40
+ # only what the proof supported counts.
41
+ def allowable_total
42
+ return declared_total unless verified?
43
+
44
+ tax_declaration_items.sum(BigDecimal(0)) { |i| i.verified_amount || BigDecimal(0) }
45
+ end
46
+
47
+ def submit!(actor:)
48
+ raise ActiveRecord::RecordInvalid.new(self), "not a draft" unless draft? || rejected?
49
+
50
+ update!(status: "submitted", submitted_at: Time.current)
51
+ Notifications.publish(
52
+ "tax.declaration_submitted",
53
+ title: "#{HrLite.display_name(user)} submitted a #{label} tax declaration",
54
+ path: "/admin/tax_declarations/#{id}",
55
+ bell_to: HrLite.users_holding("tax.manage").to_a
56
+ )
57
+ true
58
+ end
59
+
60
+ def verify!(actor:, note: nil)
61
+ raise ActiveRecord::RecordInvalid.new(self), "not submitted" unless submitted?
62
+
63
+ update!(status: "verified", verified_by_id: actor.id, verified_at: Time.current,
64
+ note: note.presence)
65
+ notify_employee("Your #{label} tax declaration was verified")
66
+ true
67
+ end
68
+
69
+ def reject!(actor:, note:)
70
+ raise ArgumentError, "a rejection needs a reason" if note.blank?
71
+
72
+ update!(status: "rejected", verified_by_id: actor.id, verified_at: Time.current, note: note)
73
+ notify_employee("Your #{label} tax declaration needs attention")
74
+ true
75
+ end
76
+
77
+ private
78
+
79
+ def notify_employee(title)
80
+ Notifications.publish(
81
+ "tax.declaration_decided", title: title, body: note.presence,
82
+ path: "/tax_declaration", bell_to: [ user ], email_to: [ user ]
83
+ )
84
+ end
85
+
86
+ def financial_year_opens_in_april
87
+ return if financial_year.blank?
88
+ return if financial_year == FinancialYear.start_for(financial_year)
89
+
90
+ errors.add(:financial_year, "must be 1 April — a declaration covers a whole year")
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,32 @@
1
+ module HrLite
2
+ # One line of a declaration: "80C — ELSS — ₹1,50,000".
3
+ #
4
+ # `verified_amount` is what the proof actually supported, which is often
5
+ # less than what was claimed and is the number payroll uses once HR has
6
+ # looked. Both are encrypted: what somebody invests is their business.
7
+ class TaxDeclarationItem < ApplicationRecord
8
+ include EncryptedMoney
9
+
10
+ SECTIONS = %w[80c 80d 80ccd_1b 24b hra other].freeze
11
+
12
+ encrypted_money :declared_amount, :verified_amount
13
+
14
+ belongs_to :declaration, class_name: "HrLite::TaxDeclaration"
15
+
16
+ validates :section, inclusion: { in: SECTIONS }
17
+ validate :amounts_are_not_negative
18
+
19
+ def section_label
20
+ { "80c" => "80C — investments", "80d" => "80D — health insurance",
21
+ "80ccd_1b" => "80CCD(1B) — NPS", "24b" => "24(b) — home loan interest",
22
+ "hra" => "HRA exemption", "other" => "Other" }.fetch(section, section)
23
+ end
24
+
25
+ private
26
+
27
+ def amounts_are_not_negative
28
+ errors.add(:declared_amount, "cannot be negative") if declared_amount&.negative?
29
+ errors.add(:verified_amount, "cannot be negative") if verified_amount&.negative?
30
+ end
31
+ end
32
+ end
@@ -33,8 +33,17 @@ module HrLite
33
33
  earnings = Calculators::Proration.call(
34
34
  structure: @structure, payable_days: payable, days_in_month: days_in_month
35
35
  )
36
+ # One-off heads for this month: a bonus, arrears after a backdated
37
+ # revision, a reimbursement. A prorated head is scaled like basic; a
38
+ # bonus is not halved because somebody joined mid-month.
39
+ extra_earnings, extra_deductions = one_off_lines(payable, days_in_month)
40
+ earnings += extra_earnings
41
+
36
42
  gross_earned = earnings.sum(BigDecimal(0)) { |row| row[:amount] }
37
43
  basic_earned = earnings.find { |row| row[:code] == "basic" }&.fetch(:amount) || BigDecimal(0)
44
+ # ESI is assessed on wages, and a reimbursement is not one. Heads that
45
+ # opt out are excluded from the gross it reads.
46
+ esi_gross = gross_earned - earnings.sum(BigDecimal(0)) { |row| row[:excluded_from_esi] ? row[:amount] : 0 }
38
47
 
39
48
  deductions = []
40
49
  employer_costs = {}
@@ -50,7 +59,7 @@ module HrLite
50
59
  )
51
60
  end
52
61
 
53
- esi = Calculators::Esi.call(monthly_gross: esi_reference_gross, gross_earned: gross_earned,
62
+ esi = Calculators::Esi.call(monthly_gross: esi_reference_gross, gross_earned: esi_gross,
54
63
  applicable: @structure.esi_applicable, rates: @rates[:esi])
55
64
  if esi.applicable?
56
65
  deductions << { code: "esi_employee", label: "ESI", amount: esi.employee }
@@ -61,9 +70,13 @@ module HrLite
61
70
  period_month: @run.period_month, rates: @rates[:pt])
62
71
  deductions << { code: "pt", label: "Professional tax", amount: pt } if pt.positive?
63
72
 
73
+ deductions.concat(extra_deductions)
74
+ loan = loan_instalment
75
+ deductions << loan if loan
76
+
64
77
  fy = SalarySlip.fy_to_date(@user, @run.period_month)
65
78
  tds = Calculators::Tds.call(
66
- regime: @profile.tax_regime,
79
+ regime: tax_regime,
67
80
  structure_monthly_gross: @structure.monthly_gross,
68
81
  gross_earned_this_month: gross_earned,
69
82
  # Plus anything paid this FY that this install never ran — a previous
@@ -73,7 +86,7 @@ module HrLite
73
86
  fy_gross_paid: fy[:gross] + Money.d(@profile.fy_opening_gross),
74
87
  fy_tds_paid: fy[:tds] + Money.d(@profile.fy_opening_tds),
75
88
  months_remaining: months_remaining_in_fy,
76
- declared_annual_deductions: @profile.declared_annual_deductions,
89
+ declared_annual_deductions: annual_deductions,
77
90
  rates: @rates[:income_tax],
78
91
  override: @tds_override
79
92
  )
@@ -107,6 +120,66 @@ module HrLite
107
120
 
108
121
  private
109
122
 
123
+ # The declaration if the employee filed one, else the single figure an
124
+ # admin typed on the profile. Old-regime deductions came from that one
125
+ # opaque number, with nothing to show what it was made of and nowhere to
126
+ # keep the proof; a filed declaration supersedes it, and once HR has
127
+ # verified the proof only what the proof supported counts.
128
+ # A declaration names the regime the employee chose for that year, which
129
+ # is a per-YEAR decision; the profile column is only the fallback for
130
+ # somebody who never filed one.
131
+ def tax_regime
132
+ TaxDeclaration.for(@user, @run.period_month)&.regime || @profile.tax_regime
133
+ end
134
+
135
+ def annual_deductions
136
+ declaration = TaxDeclaration.for(@user, @run.period_month)
137
+ return @profile.declared_annual_deductions if declaration.nil?
138
+ return @profile.declared_annual_deductions if declaration.draft?
139
+
140
+ declaration.allowable_total
141
+ end
142
+
143
+ # Splits this month's one-off lines into earnings and deductions, in the
144
+ # component order an install configured. Returns [earnings, deductions].
145
+ def one_off_lines(payable, days_in_month)
146
+ rows = PayrollLineItem.for_slip(@user, @run.period_month)
147
+ .sort_by { |item| [ item.component.position, item.component.label ] }
148
+ earnings = []
149
+ deductions = []
150
+
151
+ rows.each do |item|
152
+ component = item.component
153
+ amount = if component.prorated
154
+ Money.round2(item.amount * (Money.d(payable) / Money.d(days_in_month)))
155
+ else
156
+ item.amount
157
+ end
158
+ next unless amount.positive?
159
+
160
+ row = { code: component.code, label: component.label, amount: amount }
161
+ if component.earning?
162
+ earnings << row.merge(excluded_from_esi: !component.counts_for_esi)
163
+ else
164
+ deductions << row
165
+ end
166
+ end
167
+
168
+ [ earnings, deductions ]
169
+ end
170
+
171
+ # The month's loan instalment, as one deduction line. Computed here and
172
+ # BOOKED only when the run is finalized — a draft is recomputed freely,
173
+ # and booking at compute would take a repayment on every pass.
174
+ def loan_instalment
175
+ total = Loan.active.where(user_id: @user.id).sum(BigDecimal(0)) do |loan|
176
+ loan.instalment_for(@run.period_month)
177
+ end
178
+ return nil unless total.positive?
179
+
180
+ { code: "loan_repayment", label: "Loan repayment", amount: total }
181
+ end
182
+
110
183
  # Months left in the Indian FY (Apr–Mar) including the run month itself:
111
184
  # April = 12, December = 4, January = 3, March = 1. Capped at the exit
112
185
  # month for a leaver, so a final settlement is not spread over months
@@ -0,0 +1,72 @@
1
+ class CreateHrLitePayrollComponentsAndLoans < ActiveRecord::Migration[8.1]
2
+ # Payroll knew exactly four earning heads — basic, HRA, special allowance,
3
+ # "other" — because they were columns on the salary structure. A bonus, an
4
+ # incentive, an LTA or a reimbursement had nowhere to go but "other", which
5
+ # is one number on a payslip that should have said what it was for.
6
+ #
7
+ # Heads are DATA now. Amounts stay encrypted for the same reason every
8
+ # amount in this engine is: a payslip line is somebody's pay.
9
+ def change
10
+ create_table :hr_lite_salary_components do |t|
11
+ t.string :code, null: false
12
+ t.string :label, null: false
13
+ # earning | deduction
14
+ t.string :kind, null: false, default: "earning"
15
+ # Prorated by attendance like basic, or paid whole (a bonus is not
16
+ # halved because somebody joined mid-month).
17
+ t.boolean :prorated, null: false, default: true
18
+ t.boolean :taxable, null: false, default: true
19
+ # Counts toward the gross ESI is assessed on.
20
+ t.boolean :counts_for_esi, null: false, default: true
21
+ t.integer :position, null: false, default: 0
22
+ t.boolean :active, null: false, default: true
23
+ t.timestamps
24
+ end
25
+ add_index :hr_lite_salary_components, :code, unique: true
26
+ add_check_constraint :hr_lite_salary_components, "kind IN ('earning', 'deduction')",
27
+ name: "hr_lite_salary_components_kind_check"
28
+
29
+ # A one-off on a single month's payslip: a bonus, an incentive, an
30
+ # arrears line after a backdated revision, a reimbursement. Dated to the
31
+ # month rather than to the run, so it survives a run being deleted and
32
+ # recomputed.
33
+ create_table :hr_lite_payroll_line_items do |t|
34
+ t.bigint :user_id, null: false
35
+ t.date :period_month, null: false
36
+ t.references :component, null: false, index: true,
37
+ foreign_key: { to_table: :hr_lite_salary_components }
38
+ t.text :amount, null: false # encrypted BigDecimal
39
+ t.string :note
40
+ t.bigint :created_by_id
41
+ t.timestamps
42
+ end
43
+ add_index :hr_lite_payroll_line_items, %i[user_id period_month]
44
+
45
+ create_table :hr_lite_loans do |t|
46
+ t.bigint :user_id, null: false
47
+ t.text :principal, null: false # encrypted
48
+ t.text :monthly_instalment, null: false # encrypted
49
+ t.date :starts_on, null: false
50
+ t.string :reason
51
+ # active | closed | cancelled
52
+ t.string :status, null: false, default: "active"
53
+ t.bigint :approved_by_id
54
+ t.timestamps
55
+ end
56
+ add_index :hr_lite_loans, %i[user_id status]
57
+ add_check_constraint :hr_lite_loans, "status IN ('active', 'closed', 'cancelled')",
58
+ name: "hr_lite_loans_status_check"
59
+
60
+ # One row per month actually deducted. The outstanding balance is derived
61
+ # from these rather than stored, so a recompute cannot double-count and a
62
+ # deleted run cannot leave the balance wrong.
63
+ create_table :hr_lite_loan_repayments do |t|
64
+ t.references :loan, null: false, index: true,
65
+ foreign_key: { to_table: :hr_lite_loans, on_delete: :cascade }
66
+ t.date :period_month, null: false
67
+ t.text :amount, null: false # encrypted
68
+ t.timestamps
69
+ end
70
+ add_index :hr_lite_loan_repayments, %i[loan_id period_month], unique: true
71
+ end
72
+ end
@@ -0,0 +1,73 @@
1
+ class CreateHrLiteDocumentsAndDeclarations < ActiveRecord::Migration[8.1]
2
+ # Two gaps that were table stakes and simply absent: nowhere to keep an
3
+ # employee's documents, and a tax declaration that was ONE opaque number.
4
+ #
5
+ # `declared_annual_deductions` on the profile is a single figure an admin
6
+ # types in. Nobody can see what it is made of, the employee cannot submit
7
+ # their own, and there is nowhere to put the proof. That is the number the
8
+ # whole year's TDS is projected from.
9
+ def change
10
+ create_table :hr_lite_documents do |t|
11
+ t.bigint :user_id, null: false
12
+ # aadhaar | pan | passport | bank | offer_letter | ... — data, so an
13
+ # install can add its own without a release.
14
+ t.string :category, null: false
15
+ t.string :title, null: false
16
+ t.string :reference_number
17
+ t.date :issued_on
18
+ t.date :expires_on
19
+ # self | hr | money — who may open the file. A payslip and a passport
20
+ # are not the same kind of secret.
21
+ #
22
+ # No column default on purpose: the model derives one from the category
23
+ # when none was chosen, and a column default would make "unset" and
24
+ # "deliberately hr" indistinguishable.
25
+ t.string :visibility, null: false
26
+ # pending | verified | rejected
27
+ t.string :verification, null: false, default: "pending"
28
+ t.bigint :verified_by_id
29
+ t.datetime :verified_at
30
+ t.string :verification_note
31
+ t.bigint :uploaded_by_id
32
+ t.timestamps
33
+ end
34
+ add_index :hr_lite_documents, %i[user_id category]
35
+ add_index :hr_lite_documents, :expires_on
36
+ add_check_constraint :hr_lite_documents, "visibility IN ('self', 'hr', 'money')",
37
+ name: "hr_lite_documents_visibility_check"
38
+ add_check_constraint :hr_lite_documents,
39
+ "verification IN ('pending', 'verified', 'rejected')",
40
+ name: "hr_lite_documents_verification_check"
41
+
42
+ create_table :hr_lite_tax_declarations do |t|
43
+ t.bigint :user_id, null: false
44
+ # 1 April of the financial year it covers.
45
+ t.date :financial_year, null: false
46
+ t.string :regime, null: false, default: "new"
47
+ # draft | submitted | verified | rejected
48
+ t.string :status, null: false, default: "draft"
49
+ t.datetime :submitted_at
50
+ t.bigint :verified_by_id
51
+ t.datetime :verified_at
52
+ t.string :note
53
+ t.timestamps
54
+ end
55
+ add_index :hr_lite_tax_declarations, %i[user_id financial_year], unique: true
56
+ add_check_constraint :hr_lite_tax_declarations,
57
+ "status IN ('draft', 'submitted', 'verified', 'rejected')",
58
+ name: "hr_lite_tax_declarations_status_check"
59
+
60
+ create_table :hr_lite_tax_declaration_items do |t|
61
+ t.references :declaration, null: false, index: true,
62
+ foreign_key: { to_table: :hr_lite_tax_declarations, on_delete: :cascade }
63
+ # 80c | 80d | hra | 80ccd_1b | 24b | other
64
+ t.string :section, null: false
65
+ t.string :label
66
+ t.text :declared_amount, null: false # encrypted
67
+ # What proof actually supported, once HR has looked. Null until then.
68
+ t.text :verified_amount
69
+ t.timestamps
70
+ end
71
+ add_index :hr_lite_tax_declaration_items, %i[declaration_id section]
72
+ end
73
+ end