hr_lite 0.23.1 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +49 -0
- data/README.md +4 -1
- data/app/assets/javascripts/hr_lite/tax_preview.js +38 -0
- data/app/assets/stylesheets/hr_lite/hr_lite.css +12 -1
- data/app/controllers/hr_lite/admin/ca_reviews_controller.rb +76 -0
- data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +14 -4
- data/app/controllers/hr_lite/admin/salary_slips_controller.rb +4 -0
- data/app/controllers/hr_lite/admin/salary_structures_controller.rb +6 -16
- data/app/controllers/hr_lite/admin/tax_declarations_controller.rb +46 -0
- data/app/controllers/hr_lite/tax_declarations_controller.rb +19 -1
- data/app/controllers/hr_lite/timelines_controller.rb +2 -1
- data/app/helpers/hr_lite/application_helper.rb +7 -0
- data/app/jobs/hr_lite/ca_review_overdue_job.rb +19 -0
- data/app/jobs/hr_lite/ca_review_reminder_job.rb +21 -0
- data/app/jobs/hr_lite/payroll_auto_approve_job.rb +21 -0
- data/app/jobs/hr_lite/payroll_auto_draft_job.rb +1 -1
- data/app/models/hr_lite/audit_log.rb +1 -1
- data/app/models/hr_lite/ca_review.rb +66 -0
- data/app/models/hr_lite/payroll_run.rb +47 -3
- data/app/models/hr_lite/role.rb +3 -0
- data/app/models/hr_lite/salary_structure.rb +22 -8
- data/app/models/hr_lite/statutory_rate_card_record.rb +6 -6
- data/app/models/hr_lite/tax_declaration.rb +24 -0
- data/app/models/hr_lite/tax_declaration_item.rb +56 -4
- data/app/services/hr_lite/calculators/tds.rb +7 -6
- data/app/services/hr_lite/payroll_run_processor.rb +9 -0
- data/app/services/hr_lite/slip_builder.rb +1 -1
- data/app/services/hr_lite/tax_computation.rb +61 -0
- data/app/services/hr_lite/timeline.rb +10 -1
- data/app/views/hr_lite/admin/ca_reviews/index.html.erb +94 -0
- data/app/views/hr_lite/admin/employees/show.html.erb +3 -1
- data/app/views/hr_lite/admin/payroll_runs/show.html.erb +17 -7
- data/app/views/hr_lite/admin/salary_structures/_form.html.erb +15 -7
- data/app/views/hr_lite/admin/statutory_rate_cards/_form.html.erb +1 -1
- data/app/views/hr_lite/admin/tax_declarations/index.html.erb +1 -0
- data/app/views/hr_lite/admin/tax_declarations/overview.html.erb +39 -0
- data/app/views/hr_lite/admin/tax_declarations/person.html.erb +10 -0
- data/app/views/hr_lite/admin/tax_declarations/show.html.erb +19 -2
- data/app/views/hr_lite/leave_requests/_balance_chips.html.erb +6 -4
- data/app/views/hr_lite/tax/_computation.html.erb +56 -0
- data/app/views/hr_lite/tax_declarations/show.html.erb +61 -59
- data/app/views/hr_lite/timelines/show.html.erb +1 -0
- data/config/routes.rb +8 -2
- data/db/migrate/20261010100000_add_ctc_to_hr_lite_salary_structures.rb +9 -0
- data/db/migrate/20261010100100_add_approval_to_hr_lite_payroll_runs.rb +10 -0
- data/db/migrate/20261010100200_grant_hr_lite_payroll_approve.rb +22 -0
- data/db/migrate/20261010100300_seed_hr_lite_fy2026_rate_cards.rb +16 -0
- data/db/migrate/20261010100400_add_tax_fields_to_hr_lite_tax_declaration_items.rb +9 -0
- data/db/migrate/20261010100500_grant_hr_lite_tax_to_hr.rb +12 -0
- data/db/migrate/20261010110000_create_hr_lite_ca_reviews.rb +21 -0
- data/db/migrate/20261010110100_seed_hr_lite_ca_role.rb +14 -0
- data/lib/hr_lite/notifications.rb +5 -0
- data/lib/hr_lite/permissions.rb +2 -0
- data/lib/hr_lite/role_seeds.rb +9 -3
- data/lib/hr_lite/statutory_rate_card.rb +65 -43
- data/lib/hr_lite/statutory_seeds.rb +4 -2
- data/lib/hr_lite/version.rb +1 -1
- data/lib/hr_lite.rb +9 -1
- metadata +20 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 54c475874de00a0db443998c37e4caeb3340e202584e094db29ef423c4902698
|
|
4
|
+
data.tar.gz: 849526338af6b16a5648a5163d140f0cda40b2fa3c34ef2a63a3d2f454761132
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 38bf1cd87f1e283d7e8a54e60a18a19020fae7ff52feca24941f94dc40673c29d3a08a99d8c82699353f9958a99ffaf39ab8ae0a6c2e5e237ee1d96a567ae0c7
|
|
7
|
+
data.tar.gz: 390843351f21b51a9f2f17f1159214035349ecf8a689722d8dd429b803775715b322d6914fc251bb1808ad03c09c569f55beb0c85b634de8fefa41069b6317f1
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.26.0] - 2026-10-10
|
|
11
|
+
|
|
12
|
+
Two migrations: `hr_lite_ca_reviews`, and a data migration creating the CA role and giving Super Admin the new `payroll.verify` permission. Schedule `CaReviewReminderJob` weekly and `CaReviewOverdueJob` on 31 March.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- CA year-end review: a new **CA** role for an outside chartered accountant (reads every slip, salary structure and tax declaration with its proofs; verifies; nothing else). CA logins without an HR profile are not staff — `HrLite.employees` leaves them out, so attendance, payroll, team screens and broadcasts never count them.
|
|
16
|
+
- "CA review" (`/admin/ca_reviews`): progress for the financial year (slips and declarations verified, flags open), tabs for salary slips and tax declarations, filters (to review, flagged, verified, resolved), Verify, Flag with a required note, and "Verify all shown". A flag tells approvers and payroll (`ca.flagged`); they fix it the normal way and Mark resolved with a note. A published slip is never edited. `HrLite::CaReview`.
|
|
17
|
+
- `CaReviewReminderJob` (weekly, acts only in March) and `CaReviewOverdueJob` (31 March): what is left, to the CA reviewers and then to payroll and HR.
|
|
18
|
+
- A CA (`payroll.view`, all) can open any slip read-only.
|
|
19
|
+
|
|
20
|
+
## [0.25.0] - 2026-10-10
|
|
21
|
+
|
|
22
|
+
Three migrations: `senior` and `landlord_pan` (encrypted) on declaration lines; a data migration adding the FY 2026-27 and October 2026 rate cards to installs that already have cards; a data migration giving HR `tax.view` and `tax.manage` (all).
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
- Statutory cards ship for FY 2026-27 (Finance Act 2026 left slabs, standard deduction and the §87A rebate unchanged; the Income-tax Act 2025 is in force) and from 1 October 2026, when the EPF/EPS/EDLI wage ceiling rose to ₹25,000 (Notification S.O. 5109(E), 17 Sep 2026; applied from October payroll, so September stays on ₹15,000). Both are seeded already verified, with the source in the card's notes. Cards may now take effect on the 1st of any month for a change notified mid-year. `StatutoryRateCard::SOURCES`.
|
|
26
|
+
- `HrLite::TaxComputation`: a person's income tax worked out now — the structure in force, the slips already paid and the declaration — for both regimes, using payroll's own TDS calculator. Each step is shown: salary paid and expected, HRA exemption, standard deduction, every deduction with what was claimed, what counts and the limit, taxable income, each slab, tax with rebate and cess, TDS deducted and each remaining month. `Calculators::Tds.slab_rows`.
|
|
27
|
+
- The legal limits apply by themselves (old regime): Sec 123 / 80C ₹1,50,000, Sec 124 / 80CCD(1B) ₹50,000, home-loan interest ₹2,00,000, Sec 126 / 80D ₹25,000 or ₹50,000 for a senior — now with a separate line for parents. HRA is the least of the HRA received, rent less 10% of Basic and 50% (metro) / 40% of Basic. A landlord's PAN is required above ₹1,00,000 of rent. Payroll's TDS uses the same capped figures.
|
|
28
|
+
- Proof on every declaration line: PDF, JPG or PNG up to 5 MB each. HR opens each file through an audited download (`tax_proof.downloaded`) and can "Accept all and verify" in one click.
|
|
29
|
+
- The employee's Tax page leads with this year's tax, TDS a month, deducted and still to deduct, the two regimes side by side with the cheaper one named, and the full working. It re-works the tax as you type (`POST /tax_declaration/preview`, `hr_lite/tax_preview.js`), nothing saved; each line's limit bar follows the amount.
|
|
30
|
+
- HR and admin: the full working on every declaration, both regimes; "Everyone's tax" (`/admin/tax_declarations/overview`) lists each person's regime, tax, TDS so far and a month, the cheaper regime, and declaration status; each name opens that person's working whether or not they filed. HR gets a "Tax checks" link under Manage.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
- Section labels name the Income-tax Act 2025 section beside the old one ("Sec 123 (old 80C) — …").
|
|
34
|
+
- A claim above its legal limit no longer lowers TDS beyond the limit.
|
|
35
|
+
|
|
36
|
+
## [0.24.0] - 2026-10-10
|
|
37
|
+
|
|
38
|
+
Three migrations: `annual_ctc` (encrypted) and `metro` on salary structures; `first_approved_by_id`, `first_approved_at` and `auto_approved_at` on payroll runs; and a data migration granting the new `payroll.approve` permission to HR and Super Admin, plus `payroll.view`, `salary.view`, `salary.manage` and `profile.manage` to HR. Schedule `HrLite::PayrollAutoApproveJob` on the 3rd.
|
|
39
|
+
|
|
40
|
+
### Added
|
|
41
|
+
- Salary structures take the Annual CTC only. Saving splits it — Basic 50% of CTC (the labour codes' wage floor), HRA 50% of Basic for a metro (new "Works in a metro" box) and 40% elsewhere, employer PF, ESI and gratuity (4.81% of Basic) inside the CTC, special allowance the rest — and stores the CTC, encrypted. The form shows the monthly split read-only; Basic, HRA, special and other are no longer typed. Existing structures keep their numbers until next saved, and their CTC is unchanged (gratuity is counted only on a CTC-split structure). `SalaryStructure::GRATUITY_RATE`, `#gratuity`.
|
|
42
|
+
- Payroll needs two approvals: anyone with `payroll.approve` approves a reviewed run (Approve), and a second, different person — one of the two able to run payroll — approves it again, which finalizes and publishes it. Recomputing clears the first approval. The run page says who approved and what is left. Approvers without `payroll.manage` (HR) get a "Payroll approval" link under Manage, read every run and approve, but cannot compute, unlock or delete.
|
|
43
|
+
- `HrLite::PayrollAutoApproveJob`: on the 3rd, a run still in review is approved and published by the system (`auto_approved_at`, audit `payroll.auto_approved`), unless it has blocking warnings — no salary structure for someone, a state with no PT slabs, or a statutory rate card that is last year's or not yet verified — when approvers hear `payroll.overdue` instead.
|
|
44
|
+
- Payroll warns when leave or attendance fixes for the month are still pending: they are paid as loss of pay until approved.
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
- HR (role defaults and, on existing installs, the data migration) can now set salary structures, read and approve payroll, and edit employee profiles — which includes the screens gated on `profile.manage`: employees, resignations, awards, leave types, holidays, offices, company settings and the audit trail. Roles stay `role.manage`.
|
|
48
|
+
- Salary structures are gated on `salary.manage` (was `payroll.manage`), and the employee page shows them to `salary.view`.
|
|
49
|
+
- The single-person Finalize button and route are gone: a run is finalized by its second approval. Publish stays for a run unlocked and re-approved.
|
|
50
|
+
- `PayrollAutoDraftJob` notifies `payroll.approve` holders as well as `payroll.manage`.
|
|
51
|
+
|
|
52
|
+
## [0.23.2] - 2026-10-10
|
|
53
|
+
|
|
54
|
+
No migrations.
|
|
55
|
+
|
|
56
|
+
### Added
|
|
57
|
+
- Timeline "Private records" card: a Leave balance line per leave type for the current leave year ("Casual leave · 7 of 10 left"), and pending or rejected leave requests ("Casual leave — pending"), for the employee and anyone `leave.view` reaches (their manager, HR). `leave_requests/_balance_chips` takes optional `user:` and `bare:` locals.
|
|
58
|
+
|
|
10
59
|
## [0.23.1] - 2026-10-10
|
|
11
60
|
|
|
12
61
|
No migrations.
|
data/README.md
CHANGED
|
@@ -381,7 +381,10 @@ whatever you run). Each is idempotent and sends nothing on a quiet day.
|
|
|
381
381
|
| Job | Schedule | Purpose |
|
|
382
382
|
|---|---|---|
|
|
383
383
|
| `HrLite::DailyDigestJob` | each morning | Leadership digest: who is out today, pending approvals, flagged punches, missing checkouts |
|
|
384
|
-
| `HrLite::PayrollAutoDraftJob` | monthly, on the 1st | Draft and compute the previous month's payroll from attendance, then notify
|
|
384
|
+
| `HrLite::PayrollAutoDraftJob` | monthly, on the 1st | Draft and compute the previous month's payroll from attendance, then notify the approvers (`payroll.approve`) and payroll operators |
|
|
385
|
+
| `HrLite::CaReviewReminderJob` | weekly (it acts only in March) | Tell CA reviewers how many slips and declarations are still unverified before 31 March |
|
|
386
|
+
| `HrLite::CaReviewOverdueJob` | 31 March, evening | Tell payroll and HR what the CA has not verified, and any flags left open |
|
|
387
|
+
| `HrLite::PayrollAutoApproveJob` | monthly, on the 3rd | Approve and publish last month's run if nobody finished approving it — unless it has blocking warnings (no salary structure, no PT slabs, a stale or unverified statutory card), when the approvers are told it is overdue instead |
|
|
385
388
|
| `HrLite::LeaveYearRolloverJob` | leave year's first day (Jan 1, or Jul 1 for a July–June year) | Materialize carry-forward into the new year's balances |
|
|
386
389
|
| `HrLite::AttendanceCloseJob` | each night, `55 23 * * * Asia/Kolkata` | Close the day: an open punch is checked out at 23:59 and marked half day; a run before noon (a retry, or a morning schedule that lets night shifts check out first) closes the previous day instead; nobody checked in stays absent. Each is emailed how to fix it. Skips weekends, holidays and approved leave. Run it once a day — a re-run tells the no-shows again. |
|
|
387
390
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Live tax: a form with data-hrl-tax-preview="<url>" posts what is on screen
|
|
2
|
+
// (files left out) as it is typed, and the server's worked-out tax replaces
|
|
3
|
+
// #hrl-tax-preview. Nothing is saved; the Save button still does that.
|
|
4
|
+
(function () {
|
|
5
|
+
"use strict";
|
|
6
|
+
var form = document.querySelector("form[data-hrl-tax-preview]");
|
|
7
|
+
var panel = document.getElementById("hrl-tax-preview");
|
|
8
|
+
if (!form || !panel) return;
|
|
9
|
+
|
|
10
|
+
var timer;
|
|
11
|
+
function refresh() {
|
|
12
|
+
var data = new FormData(form);
|
|
13
|
+
Array.from(data.keys()).forEach(function (key) { if (/\[proofs\]/.test(key)) data.delete(key); });
|
|
14
|
+
data.delete("_method");
|
|
15
|
+
fetch(form.dataset.hrlTaxPreview, { method: "POST", body: data, credentials: "same-origin", headers: { "Accept": "text/html" } })
|
|
16
|
+
.then(function (response) { return response.ok ? response.text() : null; })
|
|
17
|
+
.then(function (html) { if (html) panel.innerHTML = html; });
|
|
18
|
+
}
|
|
19
|
+
var rupees = new Intl.NumberFormat("en-IN", { style: "currency", currency: "INR", maximumFractionDigits: 0 });
|
|
20
|
+
// The limit bar and "₹X of the ₹Y limit counts" follow the amount as typed.
|
|
21
|
+
function updateCap(input) {
|
|
22
|
+
var field = input.closest(".hrl-field");
|
|
23
|
+
var hint = field && field.querySelector("[data-hrl-cap]");
|
|
24
|
+
if (!hint) return;
|
|
25
|
+
var cap = Number(hint.dataset.hrlCap);
|
|
26
|
+
var counts = Math.min(Math.max(Number(input.value.replace(/[^0-9.]/g, "")) || 0, 0), cap);
|
|
27
|
+
field.querySelector("progress").value = counts;
|
|
28
|
+
hint.textContent = rupees.format(counts) + " of the " + rupees.format(cap) + " limit counts";
|
|
29
|
+
}
|
|
30
|
+
function schedule(event) {
|
|
31
|
+
if (event.target.type === "file") return;
|
|
32
|
+
if (/declared_amount/.test(event.target.name)) updateCap(event.target);
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
timer = setTimeout(refresh, 400);
|
|
35
|
+
}
|
|
36
|
+
form.addEventListener("input", schedule);
|
|
37
|
+
form.addEventListener("change", schedule);
|
|
38
|
+
})();
|
|
@@ -318,7 +318,8 @@ summary.hrl-btn::-webkit-details-marker { display: none; } /* Safari before 18.4
|
|
|
318
318
|
|
|
319
319
|
/* Forms */
|
|
320
320
|
.hrl-field { margin-bottom: .8rem; }
|
|
321
|
-
.hrl-field label { display: block; font-size: var(--hrl-t-small); font-weight: 600; margin-bottom: .3rem; }
|
|
321
|
+
.hrl-field label, .hrl-field legend { display: block; font-size: var(--hrl-t-small); font-weight: 600; margin-bottom: .3rem; }
|
|
322
|
+
fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
|
|
322
323
|
.hrl-hint { color: var(--hrl-muted); font-size: var(--hrl-t-small); margin-top: .25rem; }
|
|
323
324
|
/* Full-width text-ish controls only — checkboxes/radios/hidden must keep
|
|
324
325
|
their natural size (a bare [type] selector out-ranked .hrl-field--check
|
|
@@ -390,6 +391,16 @@ summary.hrl-btn::-webkit-details-marker { display: none; } /* Safari before 18.4
|
|
|
390
391
|
.hrl-card--private .hrl-timeline__item::before { background: var(--hrl-bg); border: 2px solid var(--hrl-field); }
|
|
391
392
|
.hrl-card--private .hrl-badge--muted { background: var(--hrl-card); }
|
|
392
393
|
|
|
394
|
+
/* Tax: the two regimes side by side, then the working as a plain table. */
|
|
395
|
+
.hrl-tax-compare { display: grid; grid-template-columns: 1fr 1fr; gap: var(--hrl-s-3); margin: var(--hrl-s-4) 0 var(--hrl-s-2); }
|
|
396
|
+
.hrl-tax-compare__col { border: 1px solid var(--hrl-line); border-radius: var(--hrl-radius-sm); padding: var(--hrl-s-3); min-width: 0; }
|
|
397
|
+
.hrl-tax-compare__col--chosen { border: 2px solid var(--hrl-accent); }
|
|
398
|
+
.hrl-tax-compare__amount { font-size: var(--hrl-t-title); font-weight: 700; margin-top: var(--hrl-s-1); overflow-wrap: anywhere; }
|
|
399
|
+
.hrl-tax-working { margin-top: var(--hrl-s-3); }
|
|
400
|
+
.hrl-tax-working__table td { vertical-align: top; overflow-wrap: anywhere; }
|
|
401
|
+
.hrl-tax-working__total td { font-weight: 700; border-top: 1px solid var(--hrl-line); }
|
|
402
|
+
.hrl-tax-cap { display: block; width: 100%; height: 8px; margin-top: var(--hrl-s-1); accent-color: var(--hrl-accent); }
|
|
403
|
+
|
|
393
404
|
/* Mention autocomplete menu */
|
|
394
405
|
.hrl-mention-wrap { position: relative; }
|
|
395
406
|
.hrl-mention-menu {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# The CA's year-end pass over every slip and declaration. Reviewers
|
|
4
|
+
# (payroll.verify) verify or flag; approvers and payroll resolve flags.
|
|
5
|
+
class CaReviewsController < BaseController
|
|
6
|
+
skip_before_action :require_operations_access!
|
|
7
|
+
before_action -> { require_any!("payroll.verify", "payroll.approve", "payroll.manage") }, only: :index
|
|
8
|
+
before_action -> { require_any!("payroll.verify") }, only: %i[verify flag verify_all]
|
|
9
|
+
before_action -> { require_any!("payroll.approve", "payroll.manage") }, only: :resolve
|
|
10
|
+
|
|
11
|
+
def index
|
|
12
|
+
@fy = FinancialYear.start_for(params[:fy].present? ? parse_date_param(params[:fy]) : Date.current)
|
|
13
|
+
@tab = params[:tab] == "declarations" ? "declarations" : "slips"
|
|
14
|
+
@status = params[:status].presence_in(%w[unreviewed verified flagged resolved])
|
|
15
|
+
scope = @tab == "slips" ? CaReview.slips_for(@fy).includes(:user, :payroll_run).order("hr_lite_payroll_runs.period_month", :user_id) : CaReview.declarations_for(@fy).includes(:user).order(:user_id)
|
|
16
|
+
type = scope.klass.name
|
|
17
|
+
reviewed = CaReview.where(subject_type: type)
|
|
18
|
+
scope = if @status == "unreviewed"
|
|
19
|
+
scope.where.not(id: reviewed.select(:subject_id))
|
|
20
|
+
elsif @status
|
|
21
|
+
scope.where(id: reviewed.where(status: @status).select(:subject_id))
|
|
22
|
+
else
|
|
23
|
+
scope
|
|
24
|
+
end
|
|
25
|
+
@rows = paginate(scope)
|
|
26
|
+
@reviews = reviewed.where(subject_id: @rows.map(&:id)).index_by(&:subject_id)
|
|
27
|
+
@left = CaReview.outstanding(@fy)
|
|
28
|
+
@totals = { "slips" => CaReview.slips_for(@fy).count, "declarations" => CaReview.declarations_for(@fy).count }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def verify
|
|
32
|
+
CaReview.verify!(subject, actor: hr_current_user)
|
|
33
|
+
redirect_back_to_list notice: "Verified."
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def flag
|
|
37
|
+
return redirect_back_to_list(alert: "Say what is wrong — HR has to know what to fix.") if params[:note].blank?
|
|
38
|
+
|
|
39
|
+
CaReview.flag!(subject, actor: hr_current_user, note: params[:note])
|
|
40
|
+
redirect_back_to_list notice: "Flagged — HR and payroll have been told."
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# "Verify all shown": the unreviewed rows on the page the CA was looking at.
|
|
44
|
+
def verify_all
|
|
45
|
+
model = params[:subject_type] == "HrLite::TaxDeclaration" ? TaxDeclaration : SalarySlip
|
|
46
|
+
reviewed = CaReview.where(subject_type: model.name, subject_id: params[:ids]).pluck(:subject_id)
|
|
47
|
+
model.where(id: Array(params[:ids]) - reviewed.map(&:to_s)).find_each { |s| CaReview.verify!(s, actor: hr_current_user) }
|
|
48
|
+
redirect_back_to_list notice: "Verified everything shown."
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def resolve
|
|
52
|
+
CaReview.find(params[:id]).resolve!(actor: hr_current_user, note: params[:note])
|
|
53
|
+
redirect_back_to_list notice: "Marked resolved."
|
|
54
|
+
rescue ActiveRecord::RecordInvalid
|
|
55
|
+
redirect_back_to_list alert: "Only a flagged item can be resolved."
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def require_any!(*keys)
|
|
61
|
+
return if keys.any? { |key| hr_can?(key, scope: :all) }
|
|
62
|
+
|
|
63
|
+
hr_require_permission!(keys.first, scope: :all)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def subject
|
|
67
|
+
model = params[:subject_type] == "HrLite::TaxDeclaration" ? TaxDeclaration : SalarySlip
|
|
68
|
+
model.find(params[:subject_id])
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def redirect_back_to_list(**flash)
|
|
72
|
+
redirect_to admin_ca_reviews_path(params.permit(:fy, :tab, :status, :page).to_h), **flash
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
module HrLite
|
|
2
2
|
module Admin
|
|
3
3
|
class PayrollRunsController < SuperadminController
|
|
4
|
+
# Approvers (HR) read runs and approve them without running payroll.
|
|
5
|
+
skip_before_action :require_money_access!, only: %i[index show approve]
|
|
6
|
+
before_action :require_approver_or_money!, only: %i[index show approve]
|
|
4
7
|
# The register carries every bank account: its own key, not just payroll.manage.
|
|
5
8
|
before_action -> { hr_require_permission!("payroll.export", scope: :all) }, only: :register
|
|
6
9
|
def index
|
|
@@ -41,8 +44,8 @@ module HrLite
|
|
|
41
44
|
transition { |run| run.compute!(actor: hr_current_user) && "Computed — review the slips." }
|
|
42
45
|
end
|
|
43
46
|
|
|
44
|
-
def
|
|
45
|
-
transition { |run| run.
|
|
47
|
+
def approve
|
|
48
|
+
transition { |run| run.approve!(actor: hr_current_user) && (run.published? ? "Second approval — run finalized and published." : "Approved — one more approver publishes it.") }
|
|
46
49
|
end
|
|
47
50
|
|
|
48
51
|
def unlock
|
|
@@ -74,12 +77,19 @@ module HrLite
|
|
|
74
77
|
|
|
75
78
|
private
|
|
76
79
|
|
|
80
|
+
def require_approver_or_money!
|
|
81
|
+
return if hr_can?("payroll.manage", scope: :all)
|
|
82
|
+
|
|
83
|
+
hr_require_permission!("payroll.approve", scope: :all)
|
|
84
|
+
end
|
|
85
|
+
|
|
77
86
|
def transition
|
|
78
87
|
run = PayrollRun.find(params[:id])
|
|
79
88
|
notice = yield(run)
|
|
80
89
|
redirect_to admin_payroll_run_path(run), notice: notice
|
|
81
|
-
rescue ActiveRecord::RecordInvalid
|
|
82
|
-
|
|
90
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
91
|
+
alert = e.message.match?(/approver/) ? e.message.sub(/\AValidation failed: /, "").capitalize + "." : "That step is not available from #{run.status}."
|
|
92
|
+
redirect_to admin_payroll_run_path(run), alert: alert
|
|
83
93
|
end
|
|
84
94
|
|
|
85
95
|
def register_csv(run)
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
module HrLite
|
|
2
2
|
module Admin
|
|
3
3
|
class SalarySlipsController < SuperadminController
|
|
4
|
+
# A CA reads a slip to verify it (payroll.view, all) without running payroll.
|
|
5
|
+
skip_before_action :require_money_access!, only: :show
|
|
6
|
+
before_action -> { hr_require_permission!("payroll.view", scope: :all) }, only: :show
|
|
7
|
+
|
|
4
8
|
def show
|
|
5
9
|
@slip = SalarySlip.includes(:payroll_run).find(params[:id])
|
|
6
10
|
@profile = @slip.user_profile
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
module HrLite
|
|
2
2
|
module Admin
|
|
3
|
+
# salary.manage, not payroll.manage: HR sets structures without running payroll.
|
|
3
4
|
class SalaryStructuresController < SuperadminController
|
|
5
|
+
skip_before_action :require_money_access!
|
|
6
|
+
before_action -> { hr_require_permission!("salary.manage", scope: :all) }
|
|
4
7
|
before_action :set_profile
|
|
5
8
|
|
|
6
9
|
def new
|
|
@@ -12,9 +15,7 @@ module HrLite
|
|
|
12
15
|
@structure = SalaryStructure.new(structure_params.merge(
|
|
13
16
|
user_id: @profile.user_id, created_by_id: hr_current_user.id
|
|
14
17
|
))
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
if @structure.save
|
|
18
|
+
if @structure.save(context: :ctc_form)
|
|
18
19
|
redirect_to admin_employee_path(@profile), notice: "Salary structure saved."
|
|
19
20
|
else
|
|
20
21
|
render :new, status: :unprocessable_entity
|
|
@@ -28,9 +29,7 @@ module HrLite
|
|
|
28
29
|
def update
|
|
29
30
|
@structure = SalaryStructure.where(user_id: @profile.user_id).find(params[:id])
|
|
30
31
|
@structure.assign_attributes(structure_params)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if @structure.save
|
|
32
|
+
if @structure.save(context: :ctc_form)
|
|
34
33
|
redirect_to admin_employee_path(@profile), notice: "Salary structure updated."
|
|
35
34
|
else
|
|
36
35
|
render :edit, status: :unprocessable_entity
|
|
@@ -39,22 +38,13 @@ module HrLite
|
|
|
39
38
|
|
|
40
39
|
private
|
|
41
40
|
|
|
42
|
-
# Shows the split for a check; only the Save button writes it.
|
|
43
|
-
def fill_from_ctc(view)
|
|
44
|
-
@structure.fill_from_ctc
|
|
45
|
-
return render(view, status: :unprocessable_entity) unless @structure.valid?
|
|
46
|
-
|
|
47
|
-
flash.now[:notice] = "Filled from CTC — check the lines, then Save."
|
|
48
|
-
render view
|
|
49
|
-
end
|
|
50
|
-
|
|
51
41
|
def set_profile
|
|
52
42
|
@profile = EmployeeProfile.find(params[:employee_id])
|
|
53
43
|
end
|
|
54
44
|
|
|
55
45
|
def structure_params
|
|
56
46
|
params.require(:salary_structure).permit(
|
|
57
|
-
:annual_ctc, :effective_from, :
|
|
47
|
+
:annual_ctc, :effective_from, :metro,
|
|
58
48
|
:pf_applicable, :pf_on_full_basic, :esi_applicable, :pt_state, :notes
|
|
59
49
|
)
|
|
60
50
|
end
|
|
@@ -17,6 +17,51 @@ module HrLite
|
|
|
17
17
|
|
|
18
18
|
def show
|
|
19
19
|
@declaration = find_visible
|
|
20
|
+
@tax = TaxComputation.new(user: @declaration.user, declaration: @declaration)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Everyone's tax this year, worked out now, whether or not they declared.
|
|
24
|
+
def overview
|
|
25
|
+
visible = hr_access.visible_user_ids("tax.view")
|
|
26
|
+
profiles = EmployeeProfile.active_for(Date.current).includes(:user).order(:employee_code)
|
|
27
|
+
profiles = profiles.where(user_id: visible) if visible
|
|
28
|
+
@profiles = paginate(profiles)
|
|
29
|
+
@declarations = TaxDeclaration.where(user_id: @profiles.map(&:user_id),
|
|
30
|
+
financial_year: FinancialYear.start_for(Date.current)).index_by(&:user_id)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Anyone's full working, filed or not.
|
|
34
|
+
def person
|
|
35
|
+
@person = HrLite.user_klass.find(params[:user_id])
|
|
36
|
+
hr_require_reach!("tax.view", @person)
|
|
37
|
+
@tax = TaxComputation.new(user: @person)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The proof, through a permission check and an audit row — never a bare blob link.
|
|
41
|
+
def proof
|
|
42
|
+
declaration = find_visible
|
|
43
|
+
file = declaration.tax_declaration_items.flat_map { |item| item.proofs.to_a }.find { |p| p.id == params[:proof_id].to_i }
|
|
44
|
+
raise ActiveRecord::RecordNotFound unless file
|
|
45
|
+
|
|
46
|
+
AuditLog.record!(action: "tax_proof.downloaded", subject: declaration, actor: hr_current_user,
|
|
47
|
+
changes: { "file" => file.filename.to_s, "owner" => HrLite.display_name(declaration.user) })
|
|
48
|
+
redirect_to Rails.application.routes.url_helpers.rails_blob_path(file, disposition: "attachment", only_path: true),
|
|
49
|
+
allow_other_host: false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# One click when the proof matches the claim: every unchecked line is
|
|
53
|
+
# accepted at what was claimed (the legal limit still applies), then verified.
|
|
54
|
+
def accept_all
|
|
55
|
+
declaration = find_manageable
|
|
56
|
+
TaxDeclaration.transaction do
|
|
57
|
+
declaration.tax_declaration_items.each do |item|
|
|
58
|
+
item.update!(verified_amount: item.declared_amount) if item.verified_amount.nil?
|
|
59
|
+
end
|
|
60
|
+
declaration.verify!(actor: hr_current_user, note: "Proof accepted")
|
|
61
|
+
end
|
|
62
|
+
redirect_to admin_tax_declarations_path, notice: "Accepted and verified — payroll uses these amounts now."
|
|
63
|
+
rescue ActiveRecord::RecordInvalid
|
|
64
|
+
redirect_to admin_tax_declaration_path(declaration), alert: "Only a submitted declaration can be accepted."
|
|
20
65
|
end
|
|
21
66
|
|
|
22
67
|
def update
|
|
@@ -26,6 +71,7 @@ module HrLite
|
|
|
26
71
|
if @declaration.update(verification_params)
|
|
27
72
|
redirect_to admin_tax_declaration_path(@declaration), notice: "Amounts recorded."
|
|
28
73
|
else
|
|
74
|
+
@tax = TaxComputation.new(user: @declaration.user, declaration: @declaration)
|
|
29
75
|
render :show, status: :unprocessable_entity
|
|
30
76
|
end
|
|
31
77
|
end
|
|
@@ -8,6 +8,16 @@ module HrLite
|
|
|
8
8
|
# A saved draft holds only its claims; the rest still need a line to fill.
|
|
9
9
|
fill_missing_sections(@declaration)
|
|
10
10
|
@regime = @declaration.regime
|
|
11
|
+
@tax = TaxComputation.new(user: hr_current_user, declaration: @declaration)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# The form posts here as it is typed in: the tax worked out on what is on
|
|
15
|
+
# screen, nothing saved.
|
|
16
|
+
def preview
|
|
17
|
+
declaration = current_declaration || TaxDeclaration.new(user_id: hr_current_user.id, financial_year: financial_year)
|
|
18
|
+
declaration.regime = declaration_params[:regime] if declaration_params[:regime]
|
|
19
|
+
declaration.tax_declaration_items_attributes = preview_items
|
|
20
|
+
render partial: "hr_lite/tax/computation", locals: { tax: TaxComputation.new(user: hr_current_user, declaration: declaration), both: false }
|
|
11
21
|
end
|
|
12
22
|
|
|
13
23
|
def update
|
|
@@ -32,6 +42,7 @@ module HrLite
|
|
|
32
42
|
# Re-render needs the full set of lines back, or the form loses the
|
|
33
43
|
# sections the person had not filled in yet.
|
|
34
44
|
fill_missing_sections(@declaration)
|
|
45
|
+
@tax = TaxComputation.new(user: hr_current_user, declaration: @declaration)
|
|
35
46
|
render :show, status: :unprocessable_entity
|
|
36
47
|
end
|
|
37
48
|
end
|
|
@@ -75,6 +86,13 @@ module HrLite
|
|
|
75
86
|
end
|
|
76
87
|
end
|
|
77
88
|
|
|
89
|
+
# Lines for a preview: amounts only, never files (a blanked line is marked
|
|
90
|
+
# for removal, which the computation skips). Already filtered by the
|
|
91
|
+
# permit in declaration_params, so plain hashes are safe here.
|
|
92
|
+
def preview_items
|
|
93
|
+
(declaration_params[:tax_declaration_items_attributes] || {}).to_unsafe_h.transform_values { |row| row.except("proofs") }
|
|
94
|
+
end
|
|
95
|
+
|
|
78
96
|
def employee_regime
|
|
79
97
|
EmployeeProfile.find_by(user_id: hr_current_user.id)&.tax_regime.presence || "new"
|
|
80
98
|
end
|
|
@@ -87,7 +105,7 @@ module HrLite
|
|
|
87
105
|
def declaration_params
|
|
88
106
|
permitted = params.require(:tax_declaration).permit(
|
|
89
107
|
:regime,
|
|
90
|
-
tax_declaration_items_attributes:
|
|
108
|
+
tax_declaration_items_attributes: [ :id, :section, :label, :declared_amount, :senior, :landlord_pan, :_destroy, { proofs: [] } ]
|
|
91
109
|
)
|
|
92
110
|
items = permitted[:tax_declaration_items_attributes]
|
|
93
111
|
return permitted if items.blank?
|
|
@@ -13,7 +13,8 @@ module HrLite
|
|
|
13
13
|
@public_events = timeline.public_events
|
|
14
14
|
@private_events = timeline.private_events
|
|
15
15
|
# Shown only to someone who may see private rows at all: the person, or a reach-holder.
|
|
16
|
-
@
|
|
16
|
+
@show_balance = HrLite.reaches?(hr_current_user, "leave.view", @person)
|
|
17
|
+
@private_card = @person.id == hr_current_user.id || @show_balance || @private_events.any?
|
|
17
18
|
end
|
|
18
19
|
end
|
|
19
20
|
end
|
|
@@ -18,6 +18,8 @@ module HrLite
|
|
|
18
18
|
{ label: "ID card", path: :id_card_path, match: [ "/id_card" ], group: "Me" },
|
|
19
19
|
{ label: "Career", path: :career_path, match: [ "/career", "/appraisals" ], group: "Me" },
|
|
20
20
|
{ label: "Timeline", path: :timeline_path, match: [ "/timeline" ], group: "Me" },
|
|
21
|
+
{ label: "CA review", path: :admin_ca_reviews_path, match: [ "/admin/ca_reviews" ], group: "Company",
|
|
22
|
+
if: -> { hr_can?("payroll.verify", scope: :all) || hr_can?("payroll.approve", scope: :all) } },
|
|
21
23
|
{ label: "Policies", path: :policies_path, match: [ "/policies" ], group: "Company" },
|
|
22
24
|
{ label: "Ask HR", path: :hr_requests_path, match: [ "/hr_requests" ], group: "Company" },
|
|
23
25
|
{ label: "Org", path: :org_path, match: [ "/org" ], group: "Company" },
|
|
@@ -38,6 +40,11 @@ module HrLite
|
|
|
38
40
|
{ label: "Help desk", path: :admin_hr_requests_path, match: [ "/admin/hr_requests" ] },
|
|
39
41
|
{ label: "Assets", path: :admin_assets_path, match: [ "/admin/assets" ] },
|
|
40
42
|
{ label: "Benefits", path: :admin_benefits_path, match: [ "/admin/benefits" ] },
|
|
43
|
+
# HR approves payroll without running it; Super Admin reaches it under Organisation.
|
|
44
|
+
{ label: "Payroll approval", path: :admin_payroll_runs_path, match: [ "/admin/payroll_runs" ],
|
|
45
|
+
if: -> { hr_can?("payroll.approve", scope: :all) && !hr_superadmin? } },
|
|
46
|
+
{ label: "Tax checks", path: :admin_tax_declarations_path, match: [ "/admin/tax_declarations" ],
|
|
47
|
+
if: -> { hr_can?("tax.view", scope: :all) && !hr_superadmin? } },
|
|
41
48
|
{ label: "Joining & exits", path: :admin_checklists_path, match: [ "/admin/checklists" ] }
|
|
42
49
|
].freeze
|
|
43
50
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Schedule on 31 March evening: anything the CA has not verified, or a flag
|
|
3
|
+
# nobody resolved, goes to payroll and HR as overdue.
|
|
4
|
+
class CaReviewOverdueJob < ApplicationJob
|
|
5
|
+
queue_as :default
|
|
6
|
+
|
|
7
|
+
def perform(on: Date.current)
|
|
8
|
+
left = CaReview.outstanding(FinancialYear.start_for(on))
|
|
9
|
+
return if left.values.sum.zero?
|
|
10
|
+
|
|
11
|
+
people = HrLite.users_holding("payroll.approve", scope: :all).to_a | HrLite.users_holding("payroll.manage", scope: :all).to_a
|
|
12
|
+
Notifications.publish(
|
|
13
|
+
"ca.overdue",
|
|
14
|
+
title: "CA review overdue: #{left['slips']} slips, #{left['declarations']} declarations unverified, #{left['flagged']} flags open",
|
|
15
|
+
path: "/admin/ca_reviews?status=unreviewed", bell_to: people, email_to: people
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Schedule weekly in March: tells the CA reviewers what is left before 31 March.
|
|
3
|
+
class CaReviewReminderJob < ApplicationJob
|
|
4
|
+
queue_as :default
|
|
5
|
+
|
|
6
|
+
def perform(on: Date.current)
|
|
7
|
+
return unless on.month == 3
|
|
8
|
+
|
|
9
|
+
left = CaReview.outstanding(FinancialYear.start_for(on))
|
|
10
|
+
return if left.values.sum.zero?
|
|
11
|
+
|
|
12
|
+
reviewers = HrLite.users_holding("payroll.verify", scope: :all).to_a
|
|
13
|
+
Notifications.publish(
|
|
14
|
+
"ca.reminder",
|
|
15
|
+
title: "CA review: #{left['slips']} slips and #{left['declarations']} declarations left before 31 March" \
|
|
16
|
+
"#{", #{left['flagged']} flagged" if left['flagged'].positive?}",
|
|
17
|
+
path: "/admin/ca_reviews", bell_to: reviewers, email_to: reviewers
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# The 3rd-of-month backstop (schedule it on the 3rd): last month's run that
|
|
3
|
+
# is still in review is approved and published by the system, unless it has
|
|
4
|
+
# blocking warnings — then the approvers are told it is overdue instead.
|
|
5
|
+
class PayrollAutoApproveJob < ApplicationJob
|
|
6
|
+
queue_as :default
|
|
7
|
+
|
|
8
|
+
def perform(month: Date.current.prev_month.beginning_of_month)
|
|
9
|
+
run = PayrollRun.find_by(period_month: month, status: "review")
|
|
10
|
+
return unless run
|
|
11
|
+
return if run.auto_approve!
|
|
12
|
+
|
|
13
|
+
people = HrLite.users_holding("payroll.approve", scope: :all).to_a
|
|
14
|
+
Notifications.publish(
|
|
15
|
+
"payroll.overdue",
|
|
16
|
+
title: "Payroll #{run.label} is overdue — not auto-approved: #{run.blocking_warnings.join('; ')}",
|
|
17
|
+
path: "/admin/payroll_runs/#{run.id}", bell_to: people, email_to: people
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -16,7 +16,7 @@ module HrLite
|
|
|
16
16
|
return unless run.draft? && run.salary_slips.none?
|
|
17
17
|
|
|
18
18
|
run.compute!(actor: nil)
|
|
19
|
-
payroll = HrLite.users_holding("payroll.manage", scope: :all).to_a
|
|
19
|
+
payroll = HrLite.users_holding("payroll.approve", scope: :all).to_a | HrLite.users_holding("payroll.manage", scope: :all).to_a
|
|
20
20
|
Notifications.publish(
|
|
21
21
|
"payroll.draft_ready",
|
|
22
22
|
title: "Payroll #{run.label} computed from attendance — #{run.salary_slips.count} slips await review",
|
|
@@ -15,7 +15,7 @@ module HrLite
|
|
|
15
15
|
MONEY_TIER_TYPES = %w[
|
|
16
16
|
HrLite::Appraisal HrLite::DesignationChange HrLite::SalaryStructure
|
|
17
17
|
HrLite::PayrollRun HrLite::SalarySlip HrLite::PayrollLineItem HrLite::Loan
|
|
18
|
-
HrLite::PerformancePlan
|
|
18
|
+
HrLite::PerformancePlan HrLite::CaReview
|
|
19
19
|
].freeze
|
|
20
20
|
|
|
21
21
|
scope :recent, -> { order(created_at: :desc) }
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# A chartered accountant's year-end mark on one salary slip or tax
|
|
3
|
+
# declaration. Verifying is final; a flag says what is wrong and stays open
|
|
4
|
+
# until HR or payroll fixes it the normal way (a correcting line on a later
|
|
5
|
+
# slip, or reopening the declaration) and resolves it. The slip itself is
|
|
6
|
+
# never edited.
|
|
7
|
+
class CaReview < ApplicationRecord
|
|
8
|
+
include Audited
|
|
9
|
+
|
|
10
|
+
SUBJECTS = %w[HrLite::SalarySlip HrLite::TaxDeclaration].freeze
|
|
11
|
+
STATUSES = %w[verified flagged resolved].freeze
|
|
12
|
+
|
|
13
|
+
belongs_to :subject, polymorphic: true
|
|
14
|
+
belongs_to :reviewed_by, class_name: HrLite.config.user_class
|
|
15
|
+
belongs_to :resolved_by, class_name: HrLite.config.user_class, optional: true
|
|
16
|
+
|
|
17
|
+
validates :subject_type, inclusion: { in: SUBJECTS }
|
|
18
|
+
validates :status, inclusion: { in: STATUSES }
|
|
19
|
+
validates :note, presence: true, if: -> { status == "flagged" }
|
|
20
|
+
|
|
21
|
+
# What the CA reviews for a financial year: every published slip, and
|
|
22
|
+
# every declaration somebody actually filed.
|
|
23
|
+
def self.slips_for(fy)
|
|
24
|
+
SalarySlip.joins(:payroll_run).where(hr_lite_payroll_runs: { status: "published", period_month: fy...fy.next_year })
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.declarations_for(fy) = TaxDeclaration.where(financial_year: fy).where.not(status: "draft")
|
|
28
|
+
|
|
29
|
+
# Still to do before 31 March: never reviewed, or flagged and not resolved.
|
|
30
|
+
def self.outstanding(fy)
|
|
31
|
+
{ "slips" => slips_for(fy), "declarations" => declarations_for(fy) }.transform_values do |scope|
|
|
32
|
+
done = where(subject: scope, status: %w[verified resolved]).count
|
|
33
|
+
scope.count - done
|
|
34
|
+
end.merge("flagged" => where(subject_type: SUBJECTS, status: "flagged").count)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.verify!(subject, actor:)
|
|
38
|
+
review = find_or_initialize_by(subject: subject)
|
|
39
|
+
review.update!(status: "verified", note: nil, reviewed_by_id: actor.id, reviewed_at: Time.current)
|
|
40
|
+
review
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.flag!(subject, actor:, note:)
|
|
44
|
+
review = find_or_initialize_by(subject: subject)
|
|
45
|
+
review.update!(status: "flagged", note: note.to_s.strip, reviewed_by_id: actor.id, reviewed_at: Time.current,
|
|
46
|
+
resolved_by_id: nil, resolved_at: nil, resolution_note: nil)
|
|
47
|
+
people = (HrLite.users_holding("payroll.approve", scope: :all).to_a | HrLite.users_holding("payroll.manage", scope: :all).to_a)
|
|
48
|
+
Notifications.publish("ca.flagged", title: "CA flagged #{review.subject_label}", body: review.note,
|
|
49
|
+
path: "/admin/ca_reviews?status=flagged", bell_to: people, email_to: people)
|
|
50
|
+
review
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def resolve!(actor:, note:)
|
|
54
|
+
raise ActiveRecord::RecordInvalid.new(self), "not flagged" unless status == "flagged"
|
|
55
|
+
|
|
56
|
+
update!(status: "resolved", resolved_by_id: actor.id, resolved_at: Time.current, resolution_note: note.to_s.strip.presence)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def subject_label
|
|
60
|
+
case subject
|
|
61
|
+
when SalarySlip then "#{HrLite.display_name(subject.user)}'s #{subject.period_month.strftime('%B %Y')} slip"
|
|
62
|
+
else "#{HrLite.display_name(subject.user)}'s #{subject.label} tax declaration"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|