Skip to content

Finance: the invoice you preview is the one you just edited

Re-processing a payslip used to hand you back the PDF from before the edit. It doesn't any more, and when an approval reopens a finalised payslip that is now written into the record with a reason. The CT Quarterly Bonus stopped choosing its own cutoff and asks you instead, the export dialog says who is in the file, external contractors gained a Country, and HRIS is moving to a new address.

The invoice preview shows the document you're actually looking at

Edit a payslip that had already been processed, then open its invoice, and HRIS would serve the copy it had archived earlier — no bonus line, no correction, nothing you had just done. The figures on screen and the figures in the PDF disagreed, and the PDF was the one people forwarded.

The archive is now served for exactly one thing: a payslip whose status is Sent, where the archived copy is the document that went out. Anything still in Draft or Processed renders live, every time.

  • Open the invoice on a processed record and it matches the record.
  • A sent record still shows what was sent, unchanged. That's the point of it — and it now says so, in a band across the top of every page giving the date and time it went out, so a figure read off it is never mistaken for a current one.
  • Sent invoices still serves the archive completely untouched. That view answers "what exactly did we send her?", so nothing is added to it.

Guide: Viewing & downloading the invoice PDF

Reopening a finalised payslip now says who and why

Approving a time adjustment, a Zoom or additional reimbursement, or a quiz win against a payslip that was already processed or sent had to send it back to draft. It did — but silently. No history entry, nobody named, no reason, and the employee could be emailed a PDF that predated the approval that caused it.

Every one of those approvals now reopens the payslip the same way Convert to draft does: the period's history gains an entry naming the approver and what reopened it, and the invoice is rebuilt from the current figures rather than served from before the change.

  • The history on a reopened payslip tells you what happened to it.
  • A payslip already in draft is left alone — no spurious entries.

Guide: Convert to draft (fixing after the fact) and When something already finalized would change

External contractors have a Country

People & Culture → External Contractors now records where each contractor is based, as a searchable dropdown on Add contractor and on the contractor's own page.

  • The roster has a sortable Country column, and the CSV export carries it as a standard column — not one of the three optional toggles.
  • It's optional, and blank on every contractor created before now, so expect to fill them in as you go.
  • Converting a contractor to an employee carries the country across for you. The conversion form doesn't ask, and the invitation only asks the new hire for it if the contractor's was left blank.

Guide: External contractors and Convert a contractor to an employee

HRIS is moving to a new address

HRIS now lives at https://hris.aruna-atlas.com. If you open the old hris-staging.aruna-atlas.com address you'll get a banner across the top of every page telling you so, with a link straight across.

  • Update your bookmark. The old address is being retired and will stop working.
  • Nothing about your account changes — same username, same password, same data.

Guide: Log in

AI Marketing is no longer a department you can pick

The AI Marketing (AIM) department has been dissolved. It's gone from every department dropdown in the product — Add Employee, invitations, account mapping, convert-to-employee, the P&C departments page and the management area.

Nobody's history is touched. Anyone who was in AIM keeps the department on their profile, their payroll records and their AIM- staff code, and they can still be moved to another department normally. You just can't put anyone into it any more.

You choose which cutoff a quarterly bonus pays on

The CT Quarterly Bonus used to pick its own cutoff, and it picked badly. It walked forward from the quarter's own cutoff and skipped any that already had finalised payslips on it — which, by the time anyone prepares a finished quarter, is nearly always the quarter's own. On Q3 that was 37 of 40 members, so the money landed a fortnight late every time. Worse, the skip applied to the whole run: one person's sent payslip moved everybody.

Now there is a Pays on dropdown on the run card. It opens on the quarter's own last cutoff and lists every open cutoff from there onward. Change it and the whole quarter moves. Your choice sticks across Recompute the prepared run.

  • A payslip already sent on the cutoff you chose is reopened to take the bonus, rather than the quarter being moved away from it. You are told how many and who, before you approve — each one goes back to Draft and needs re-processing and re-sending, because that person is holding an invoice without the bonus on it.
  • Their original invoice is untouched. It stays in the archive exactly as it was emailed, marked as the sent copy.
  • One member who needs a different cutoff from the rest is set in their own Decide dialog, and is named above the per-member table so the exception is visible.

Guide: The CT Quarterly Bonus

The export dialog says who is in the file

Export CSV on Employees and External Contractors opens with a Who is in the file choice — Active only, Offboarded only, or Everyone. It starts on whatever the page is filtered to and then wins, so you can change the scope without closing the dialog and re-filtering the list behind it. When the two disagree the row count disappears, because it came from the page's query and has stopped being true.

It also settles a disagreement the two pages had: employees defaulted to active-only and contractors to everyone, so the same button meant different things depending which page you were on.

Pay frequency and Monthly payout cutoff are now a single Pay schedule column reading Every cutoff or Monthly · 2nd cutoff — the way the contractor table already reads on screen. The second column only ever meant anything for monthly people, and on an 89-row export it asserted "2nd cutoff (16–end of month)" about 88 people whose pay has no cutoff half at all.

The employee export's columns have changed

This replaces two columns with one. Anything built on the previous employee export — a saved spreadsheet formula, a downstream import — needs updating.

Guide: Exporting the roster

Also

  • Removing a payroll row no longer fails with an unexplained error when that person has a pending ArunaPay payout line. The pending line is released and the removal goes through, and it's named in the period's history. A line that has already been sent, confirmed or failed still blocks removal — money has moved, and that's a reconciliation, not a deletion.

Technical changes

For developers and whoever deploys. None of this needs a guide.

Data model

  • finance.CtQuarterlyBonusDecision.cutoff_period — nullable FK to CutoffPeriod, on_delete=PROTECT. Null means follow the run, not unset: a member left on it moves when run.target_cutoff changes, which is why the default is not stamped in at decision time. Existing rows need no backfill for the same reason.
  • finance.CtQuarterlyBonusRun.target_cutoff keeps its column and gains a second job — Finance's chosen cutoff while the run is pending, not only where an approved one paid. prepare_run no longer clears it.
  • finance.ExternalContractor.country — CharField(max_length=100, blank=True, default=""). A display name, not an ISO code, deliberately matching pnc.Employee.country: the two have to stay comparable because the value is copied across on conversion.
  • pnc.Department.active — BooleanField(default=True). A flag rather than a delete, because EmployeeAssignment.department is on_delete=PROTECT and a soft delete would take the department name off the profile, payroll row and history of everyone who was ever in it.
  • core.access splits ALL_MODULES in two: ALL_MODULES stays the full set and remains the validator, ASSIGNABLE_MODULES is the subset offered in pickers. A retired department's module is still a valid grant, so existing holders keep working; it's simply never offered again.

Behaviour

  • The invoice PDF cache is keyed on content instead of identity: invoices/{layout}/{invoice_id}/{updated_at}.pdf. A line-item change moves updated_at, so an edited payslip lands on a new key and misses rather than serving a stale render.
  • EmployeePayrollInvoicePdfView uses the SentInvoice snapshot only when status == SENT.
  • finance.invoices.stamp.stamp_archived_copy bands a served archive at the top of every page. It works on the response bytes only — SentInvoice.pdf is evidence of what the recipient received and is never rewritten — and it returns its input unchanged on any failure, because an unlabelled payslip is a smaller problem than a payslip that won't open. Applied by the employee and contractor record views; deliberately not by finance.invoices.views.SentInvoicePdfView.
  • CT Quarterly Bonus cutoff selection is gone: target_with_skips and resolve_target_cutoff are removed, and with them the skipped_cutoffs and blocked fields on the run payload. candidate_cutoffs stays and becomes the picker's option list. unpayable_members becomes payslips_to_reopen — same query, opposite meaning: a warning rather than a blocker.
  • default_cutoff_for(run) reads run.target_cutoff first and ignores it once that cutoff has closed, falling back to the quarter's own cutoff, then to the earliest open one after it. A cutoff chosen last week may not take a line today.
  • approve_run pays each accepted member onto their own cutoff, calls reopen_payroll_if_finalised on anything finalised there, and returns reopened. Still @transaction.atomic, so a failure reopens nothing.
  • New POST /api/finance/ct-quarterly-bonus/<id>/cutoff/ sets the run-level cutoff; null clears it.
  • The employee and contractor CSV exports replace Pay frequency + Monthly payout cutoff with one Pay schedule column, and take an is_active override from the export dialog rather than the page's filter. The employee export header changes — a replacement, not an addition.
  • New reopen_payroll_if_finalised(payroll, *, reason, actor) — a no-op on a draft row, convert_payroll_to_draft otherwise. The eight raw payroll.status = DRAFT demotions in employee_requests/services.py and management/quiz_winners/services.py now call it. A test scans for any .status = ...DRAFT outside the helper and the PROCESSING crash-recovery resets, and names offenders by file and line.
  • remove_payroll_row releases a pending arunapay.ArunaPayPayment and blocks on sent/confirmed/failed. A test introspects EmployeePayroll._meta.related_objects and fails on any PROTECT FK missing from the declared handled set — deliberately not a generic auto-detach, since each new FK representing money needs its own release-or-block decision.
  • GET /api/pnc/departments/ and the P&C dashboard filter on active=True; three write serializers reject an inactive department.
  • seed_superuser_modules keeps a retired grant the superuser already holds. It runs on every deploy, and rebuilding from SUPERUSER_MODULES alone would revoke a grant as a silent side effect.

Deploy

  • Four migrations: finance.0136_external_contractor_country, finance.0137_ctquarterlybonusdecision_cutoff_period, pnc.0060_department_active, pnc.0061_retire_ai_marketing_department. The last is a data migration flipping active on the AIM row, and it is reversible. So are the rest.
  • Orphaned cache objects. The invoice PDF key changed shape, so anything written at the old invoices/{layout}/{invoice_id}.pdf path is now unreferenced. Nothing reads it and nothing breaks; it wants a sweep.
  • New Python dependency: pypdf (6.21.2), for banding the archived invoice. The image has to be rebuilt — uv sync alone on a running container won't carry it. reportlab was already present but can only write a PDF, not merge onto an existing one.
  • The site-wide banner is config, not environment. It lives in frontend/src/lib/site-announcements.ts and is scoped by hostname, so taking it down is an edit and a deploy, not a variable. Nothing to set.

Tests

  • Two new guard suites, backend/core/tests/test_retired_departments.py and backend/pnc/tests/test_retired_departments.py, pin the registry and the DB row together — a department marked retired on one side and live on the other would vanish from management surfaces while staying in every combobox.
  • frontend/scripts/check-access-registries.mjs now compares the retired flag across the two registries, so a one-sided retirement fails pnpm lint.