Fix/skv connection flow (#1015)

* feat(salary): one-click AGI submission with filing state machine and success feedback

The AGI panel required users to know that "Ladda ner AGI-fil" was the
generate step, then click submit, signing link, and kvittens manually.
A nollkorning filing stalled on "AGI-XML saknas" pointing at a UI path
that does not exist.

- New primary button "Lamna in till Skatteverket" chains the existing
  endpoints client-side: generate XML if missing, POST underlag, poll
  kontrollresultat, create signing link, open Mina Sidor in a tab opened
  synchronously at click (popup-blocker safe). Inline stepper shows each
  step; the four old buttons become collapsed advanced/recovery actions,
  auto-expanded in stale-draft and rejected states. XML download stays
  visible and free for manual filing.
- deriveAgiFilingState() + useAgiSubmission() lift the per-period
  submission record to the run page: the progress rail and salary hero
  now render the real state machine (generated, underlag inskickat,
  vantar pa BankID-signatur, inlamnad med kvittensnummer) instead of
  telling users to "lamna in" an already-submitted declaration.
- Success card with kvittensnummer and signature metadata once signed,
  plus a toast when a poll flips the state while the page is open.
- AGI kvittens cron every 15 min instead of every 2 h so filings signed
  on another device get stamped and emailed promptly.
- Advanced submit also auto-generates, and the stale "Lon -> AGI ->
  Generera" error text now points at the real buttons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(enable-banking): instant OAuth callback feedback and dead-attempt cleanup

The bank redirect landed on a blank page for the several seconds the
callback spent exchanging the PSD2 session and mirroring accounts, and
every failed connect attempt left a status='error' row that rendered
forever as an "Atgard kravs" card next to a successful retry, showing
duplicate connections to the same bank.

- Stream a branded "Slutfor bankanslutningen" progress page from the
  callback: the shell flushes before the session exchange starts and a
  script/meta redirect follows when the work completes, with a 30s
  slow-work escape hatch. Fast outcomes (denial, bad params, unknown
  state) keep their plain redirects.
- Delete never-activated connection rows (no session_id, no
  accounts_data) on denial or exchange failure, and sweep leftovers for
  the same bank on the next connect. Established connections keep their
  "Atgard krävs" card via the accounts_data guard; FKs are ON DELETE
  SET NULL so deletion has no dependents.
- Show "Banken ar ansluten: hamtar dina konton" while the settings
  panel loads after the callback instead of an anonymous spinner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invoices): reject re-send of issued invoices and gate bookkeeping on the sent flip

A direct POST to /api/invoices/[id]/send against an already-issued
invoice re-emailed the customer and posted a second revenue verifikat
(createInvoiceJournalEntry has no dedup), overwriting journal_entry_id
and orphaning the first entry. Only the UI hid the button; the v1 route
and the MCP commit executor already rejected non-drafts.

- Non-draft invoices now return 409 INVOICE_ALREADY_SENT.
- The draft to sent status flip is an optimistic lock (status guard plus
  row-count check); journal entry, accrual schedules, PDF archival and
  the invoice.sent event only run for the request that won the flip.
- On a flip failure the journal entry is deferred: the row stays draft
  and a retry re-runs the pipeline, ending with exactly one verifikat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invoices): payment links, failure visibility and sandbox guard for recurring auto-send

- sendInvoiceFromSchedule now auto-creates an online payment link via
  applyPaymentLinkToInvoice before rendering and passes the payment
  link QR to the PDF: parity with the dashboard and v1 send routes,
  which recurring invoices silently lacked.
- The recurring cron persists last_run_warning both when a claimed run
  throws (hourly retries stay visible on the schedule) and when a stale
  schedule is rolled forward, so a deterministic failure can no longer
  skip a month silently.
- Auto-send is blocked for sandbox companies at the email chokepoint
  (freeze-and-retain: the invoice is still generated as a draft),
  covering both the cron and the run-now route with one guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(salary): close the Fortnox payroll API gaps (phases 1-4)

Payroll now runs end-to-end through the open API, including onboarding a
client from another payroll system, with every write staged for approval.

- v1: per-employee payslips (list/detail/PDF), payslip line writes,
  run roster attach/remove, absence ranges (per-day storage), jamkning
  fields, cutover opening balances (single + atomic bulk PUT), vacation
  balance + vacation-year-close. PUT added to the wrapper's idempotency/
  test-key set (test keys could otherwise write through PUT).
- MCP: 10 new tools (get_employee/get_payslip/list_absence/
  get_vacation_balance reads + staged update_payslip_line,
  register_absence, create_employee, update_employee,
  set_employee_opening_balances, close_vacation_year), executors, risk
  tiers, op-type CHECK expansions. create_employee encrypts personnummer
  at staging: pending_operations never holds plaintext.
- Scope-map audit retrofit: 11 formerly unmapped tools now scoped;
  BREAKING for keys that relied on the 4 default-allow writes.
- Cutover: employee_opening_balances (derived lock trigger, self-unlocks
  on run correction), engine YTD/karens/liability integration,
  Ingaende saldon section in the employee editor.
- Arbetsschema-lite: employees.hours_per_week/workdays_per_week drive the
  hourly/daily divisors; legacy 173/21 preserved exactly at defaults so
  existing pay math is byte-identical.
- Vacation ledger + semesterberedning/arsavslut: recomputed per-year day
  balances (synced on book/correct, non-fatal), year-close with the
  min-20 floor, 5-year sparade-dagar expiry to forced payout, and a
  2920/2940 drift adjustment via the bookkeeping engine; Semester
  dashboard card with preview-then-confirm dialog.
- Fix: Zod 4 defaults leak through .partial(), which made every sparse
  employee PATCH fail validation and reset defaulted columns.

Migrations 20260713100000/101000/110000/121000/122000 (applied to
staging with version rows; prod via merge). vacation_ledger renamed from
20260713120000 to avoid colliding with vat_declaration_totals_rpc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf: cut dashboard page-load latency (region, round trips, caching, VAT RPC)

The dominant cost was infrastructure: Vercel functions ran in iad1
(Washington D.C.) while Supabase (DB + auth) lives in eu-north-1
(Stockholm), so every request paid 4-5 transatlantic round trips of
auth + company resolution before doing any real work (measured
530-1900ms for single-query GETs in prod logs). Pin functions to arn1
and cut the redundant work on top:

- vercel.json: functions to arn1, same city as the database
- getActiveCompanyId: preference + first-membership queries run in
  parallel; the fallback result doubles as validation in the common
  single-company case (one round trip instead of two sequential)
- withRouteContext: Server-Timing header and authMs/companyMs/handlerMs
  in the op-completed log, so latency is attributable per phase
- dashboard layout: nav badge counts off the critical path; DashboardNav
  loads them client-side via the new use-worklist-badges SWR hook with
  debounced realtime revalidation
- swr (new dependency, approved): global provider; useCompanySettings
  shares one cache entry across consumers and renders from cache on
  back-navigation instead of re-showing skeletons
- /pending: realtime refetch debounced; bulk operations previously
  fired 4 requests per row-change event
- VAT declaration: new get_vat_declaration_totals RPC returns
  per-account totals, settlement-shape detection (#984) and
  source_type counts in ONE round trip instead of paging every
  entry+line through PostgREST. Account lists stay TS-side parameters
  so ACCOUNT_RUTA remains the single source of truth. Shape-exclusion
  coverage moved to tests/pg/vat-declaration-totals-rpc.pg.test.ts;
  DDL already applied to staging.
- bundle: CommandPalette lazy-mounts on first Ctrl/Cmd+K, AgentChat
  dynamic-imports the markdown parser, @vercel/speed-insights (new
  dependency, approved) added for real-user timings

The /salary fetch-waterfall fix from the same effort already landed
inside 2084a756.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invoices): settle öre-rounded payments from the mark-paid flow

An invoice with öresavrundning shows a rounded "Att betala" on the PDF;
the customer pays that amount (up to 50 öre off the stored öre total) and
the invoice-page mark-paid flow rejected it with
MATCH_AMOUNT_EXCEEDS_REMAINING: a dead end, while the bank-transaction
match flow already absorbed the residual to 3740.

- PaymentBookingDialog now proposes the rounded bank leg plus the 3740
  residual line (credit when rounded up, debit when rounded down),
  resolved via getDisplayTotal from the per-invoice override and
  company_settings.ore_rounding.
- settleInvoicePayment and the v1 mark-paid route absorb the sub-krona
  residual, gated by planInvoicePaymentForLines: absorption applies ONLY
  when the caller lines carry the exact residual on 3740; otherwise the
  strict plan applies (sub-krona partials stay partial, no-3740
  overshoots keep the 400), so the GL can never diverge from the AR
  sub-ledger.
- planInvoicePayment absorb-band boundary tightened to >= 1 kr: an
  exactly-1-kr overshoot used to slip past both the guard and the absorb
  branch and silently over-record paid_amount (pre-existing on the
  bank-match path).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): resolve all 7 PR compliance findings

- ASVS V3.3: per-request CSP nonce on the enable-banking finalize page
  (mirrors the mcp-oauth consent page); inline scripts are nonce-bound
- ASVS V16: decouple callback finalize work from the response stream
  (eager promise + next/server after()) so a client disconnect cannot
  drop session persistence or the consent_granted audit emit
- ISO 27001 A.8.15: failed audit-event emits log through the structured
  logger with a stable message for log-based alerting
- ASVS V2.3: recurring-invoice cron and run-now routes resolve
  isSandboxCompany themselves and pass an explicit suppressAutoSend flag
  (defence in depth around the email chokepoint, freeze-and-retain kept)
- ISO 27001 A.8.11: stagePendingOperation rejects plaintext
  personnummer-bearing keys in params/preview_data (key-based guard;
  EF org numbers make value-matching unsafe)
- ASVS V4.5: employee PATCH body is truly sparse; cleared number fields
  are omitted instead of resetting DB values to hardcoded fallbacks
- ASVS V8.2.1: route-level tests pin the v1 cross-company deny (404 by
  convention, not 403) on the payslip PDF endpoint

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: implement vacation-year basis change validation and error handling

- Added tests to block vacation-year basis changes when open balances exist.
- Implemented error handling for open-balances guard query failures in the settings route.
- Enhanced absence route to reject reversed date ranges with a validation error.
- Updated absence handling to use atomic upserts instead of delete+insert for better performance and reliability.
- Refactored salary calculation logic to correctly handle age-based avgifter rates according to Skatteverket's rules.
- Improved error messaging for vacation year closure adjustments.
- Adjusted employee opening balances handling to preserve audit information during upserts.

* feat(settings): add validation to block vacation-year basis change with open balances

feat(absence): reject reversed date ranges in absence queries

fix(absence): update absence handling to use atomic upserts instead of delete+insert

fix(employee): improve validation for jamkning dates in employee updates

fix(opening-balances): ensure created_by field is preserved during upserts

test(absence): enhance tests for absence range and date validations

test(calculation): add tests for age-based avgifter rates and edge cases

test(semesterberedning): validate vacation year closure adjustments and error handling

test(employee-opening-balances): update tests to reflect changes in salary_run_employees schema

* fix(migrations): implement NOT VALID constraints for pending_operations and add validation migration

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-13 22:54:33 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent e7e3c35f9e
commit b6332e9ff4
154 changed files with 18364 additions and 1867 deletions
+164 -1
View File
@@ -1521,6 +1521,10 @@ export const UpdateSettingsSchema = z.object({
.enum(['swedbank', 'seb', 'handelsbanken', 'nordea', 'other'])
.nullable()
.optional(),
// Vacation year basis (payroll gap-closure 3.1): sammanfallande calendar
// year (default) or the statutory Apr 1 - Mar 31 split. The settings route
// blocks changing this while open vacation-ledger rows exist.
salary_vacation_year_basis: z.enum(['calendar', 'statutory_apr_mar']).optional(),
}).refine(
(data) => {
// BFL 3 kap.: Enskild firma must have fiscal year starting January
@@ -1959,6 +1963,11 @@ const EmployeeSchemaBase = z.object({
employment_start: isoDate,
employment_end: isoDate.optional(),
employment_degree: z.number().min(1).max(100).default(100),
// Arbetsschema-lite: weekly schedule driving the hourly/daily divisors
// (legacy 173/21 at the defaults). employment_degree keeps prorating base
// salary; these ONLY drive divisors.
hours_per_week: z.number().positive().max(80).default(40),
workdays_per_week: z.number().min(1).max(7).default(5),
salary_type: SalaryTypeSchema.default('monthly'),
monthly_salary: z.number().nonnegative().optional(),
hourly_rate: z.number().nonnegative().optional(),
@@ -1980,6 +1989,14 @@ const EmployeeSchemaBase = z.object({
vaxa_stod_eligible: z.boolean().default(false),
vaxa_stod_start: isoDate.optional(),
vaxa_stod_end: isoDate.optional(),
// Jämkning (Skatteverket beslut om ändrad beräkning av skatteavdrag):
// overrides the tax-table lookup with a fixed percentage for a bounded
// period. Fields have existed on the employees table since the salary
// module shipped; this exposes the write path (payroll gap-closure 1.5).
// Setting jamkning_percentage to null clears the beslut.
jamkning_percentage: z.number().min(0).max(100).nullable().optional(),
jamkning_valid_from: isoDate.nullable().optional(),
jamkning_valid_to: isoDate.nullable().optional(),
// Dimensions PR8: bag applied to the employee's P&L cost lines when a
// salary run is booked. {} clears (the UI always sends the field).
default_dimensions: DimensionsBagSchema.optional(),
@@ -2046,6 +2063,32 @@ export const CreateEmployeeSchema = EmployeeSchemaBase.superRefine((data, ctx) =
})
}
// Jämkning: a percentage without a start date is meaningless (the engine
// gates on jamkning_valid_from <= payment_date). End date is optional
// (beslut often run until year-end implicitly).
if (
data.jamkning_percentage !== null &&
data.jamkning_percentage !== undefined &&
!data.jamkning_valid_from
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Jämkningens startdatum måste anges när jämkningsprocent sätts',
path: ['jamkning_valid_from'],
})
}
if (
data.jamkning_valid_from &&
data.jamkning_valid_to &&
data.jamkning_valid_to < data.jamkning_valid_from
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Jämkningens slutdatum måste vara efter startdatumet',
path: ['jamkning_valid_to'],
})
}
// Bank details: validate clearing/kontonummer structure at entry so a typo is
// caught here rather than at Bankgirot LB generation. Both empty is allowed.
// Update path is validated in the PATCH route (only when the fields actually
@@ -2060,7 +2103,28 @@ export const CreateEmployeeSchema = EmployeeSchemaBase.superRefine((data, ctx) =
}
})
export const UpdateEmployeeSchema = EmployeeSchemaBase.partial().superRefine((data, ctx) => {
// PATCH base: the create-schema defaults are stripped first. Zod 4 applies
// .default() even through .partial() (absent key -> default value), which
// would (a) make sparse PATCH bodies fail the salary-type refinement below
// (salary_type materializes as 'monthly' without monthly_salary present) and
// (b) leak default values into routes that spread the parsed body into the
// UPDATE (silently resetting e.g. is_sidoinkomst on unrelated edits).
const EmployeeSchemaPatchBase = EmployeeSchemaBase.extend({
employment_type: EmploymentTypeSchema,
employment_degree: z.number().min(1).max(100),
hours_per_week: z.number().positive().max(80),
workdays_per_week: z.number().min(1).max(7),
salary_type: SalaryTypeSchema,
tax_column: z.number().int().min(1).max(6),
is_sidoinkomst: z.boolean(),
f_skatt_status: FSkattStatusSchema,
vacation_rule: VacationRuleSchema,
vacation_days_per_year: z.number().int().min(25).max(40),
semestertillagg_rate: z.number().min(0).max(0.05),
vaxa_stod_eligible: z.boolean(),
})
export const UpdateEmployeeSchema = EmployeeSchemaPatchBase.partial().superRefine((data, ctx) => {
// Only validate salary when salary_type is being changed in this update
if (data.salary_type === 'monthly' && data.monthly_salary !== undefined && data.monthly_salary <= 0) {
ctx.addIssue({
@@ -2127,6 +2191,23 @@ export const UpdateEmployeeSchema = EmployeeSchemaBase.partial().superRefine((da
path: ['vaxa_stod_end'],
})
}
// Jämkning: same schema-visibility caveat as växa-stöd above. What the
// schema CAN see: a non-null percentage sent WITHOUT any start date in the
// same body is only valid if a start date already exists on the row: the
// route layer does the merged-state check. Within-body date ordering is
// checkable here.
if (
data.jamkning_valid_from &&
data.jamkning_valid_to &&
data.jamkning_valid_to < data.jamkning_valid_from
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Jämkningens slutdatum måste vara efter startdatumet',
path: ['jamkning_valid_to'],
})
}
})
export const EmployeeBenefitTypeSchema = z.enum(['bike', 'car', 'meals', 'housing', 'wellness', 'other'])
@@ -2249,6 +2330,88 @@ export const AbsenceRangeQuerySchema = z.object({
path: ['from'],
})
// ── Employee opening balances (payroll cutover) ─────────────────────
//
// Per-employee state a mid-year switcher brings from the previous payroll
// system: YTD accumulators, vacation balances (incl. sparade dagar by origin
// year per the Semesterlagen 5-year rule), the opening semesterlöneskuld SEK
// (feeds vacation-liability report only; the 2920/2940 balance arrived via
// SIE), and the högriskskydd karens-count adjustment. See migration
// 20260713101000.
const openingBalancesShape = {
cutover_date: isoDate,
ytd_gross: z.number().min(0).default(0),
ytd_tax: z.number().min(0).default(0),
ytd_net: z.number().min(0).default(0),
vacation_paid_days_remaining: z.number().min(0).max(40).default(0),
vacation_saved_days_by_year: z
.record(z.string().regex(/^\d{4}$/, 'Nyckel måste vara ett fyrsiffrigt år'), z.number().min(0).max(40))
.default({}),
opening_semester_liability: z.number().min(0).default(0),
opening_semester_liability_avgifter: z.number().min(0).default(0),
karens_periods_adjustment: z.number().int().min(0).max(10).default(0),
}
const openingBalancesRefine = (
data: {
cutover_date: string
ytd_gross: number
ytd_tax: number
vacation_saved_days_by_year: Record<string, number>
},
ctx: z.RefinementCtx,
) => {
if (!data.cutover_date.endsWith('-01')) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'cutover_date måste vara den första dagen i en månad',
path: ['cutover_date'],
})
}
const cutoverYear = Number(data.cutover_date.slice(0, 4))
const currentYear = new Date().getFullYear()
if (cutoverYear < currentYear - 1 || cutoverYear > currentYear) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'cutover_date måste ligga i innevarande eller föregående år',
path: ['cutover_date'],
})
}
if (data.ytd_tax > data.ytd_gross) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'ytd_tax kan inte överstiga ytd_gross',
path: ['ytd_tax'],
})
}
// Sparade dagar: max 5 years back, never the cutover year itself.
for (const yearKey of Object.keys(data.vacation_saved_days_by_year)) {
const originYear = Number(yearKey)
if (originYear < cutoverYear - 5 || originYear > cutoverYear - 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Sparade dagar för ${yearKey}: ursprungsåret måste ligga inom 5 år före cutover (${cutoverYear - 5}-${cutoverYear - 1})`,
path: ['vacation_saved_days_by_year', yearKey],
})
}
}
}
/** Body for the per-employee PUT (employee id comes from the path). */
export const OpeningBalancesFieldsSchema = z
.object(openingBalancesShape)
.superRefine(openingBalancesRefine)
/** One item in the bulk PUT (employee id inline). */
export const OpeningBalancesItemSchema = z
.object({ employee_id: uuid, ...openingBalancesShape })
.superRefine(openingBalancesRefine)
export const OpeningBalancesBulkSchema = z.object({
items: z.array(OpeningBalancesItemSchema).min(1).max(200),
})
// ── Worked-hours per-day records (hourly employees) ─────────────────
//
// Drives base salary calculation for hourly (timanställd) employees:
+17 -1
View File
@@ -1,13 +1,16 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `107`;
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `123`;
exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-keys 1`] = `
[
"DELETE /api/v1/companies/:companyId/customers/:id",
"DELETE /api/v1/companies/:companyId/dimensions/:id/values/:valueId",
"DELETE /api/v1/companies/:companyId/employees/:id",
"DELETE /api/v1/companies/:companyId/employees/:id/absence",
"DELETE /api/v1/companies/:companyId/salary-runs/:id",
"DELETE /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId",
"DELETE /api/v1/companies/:companyId/salary-runs/:id/lines/:lineId",
"DELETE /api/v1/companies/:companyId/suppliers/:id",
"DELETE /api/v1/companies/:companyId/webhooks/:id",
"GET /api/v1/companies",
@@ -20,6 +23,9 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
"GET /api/v1/companies/:companyId/documents/:id/download",
"GET /api/v1/companies/:companyId/employees",
"GET /api/v1/companies/:companyId/employees/:id",
"GET /api/v1/companies/:companyId/employees/:id/absence",
"GET /api/v1/companies/:companyId/employees/:id/opening-balances",
"GET /api/v1/companies/:companyId/employees/:id/vacation-balance",
"GET /api/v1/companies/:companyId/fiscal-periods",
"GET /api/v1/companies/:companyId/invoices",
"GET /api/v1/companies/:companyId/invoices/:id",
@@ -43,6 +49,9 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
"GET /api/v1/companies/:companyId/reports/vat-declaration",
"GET /api/v1/companies/:companyId/salary-runs",
"GET /api/v1/companies/:companyId/salary-runs/:id",
"GET /api/v1/companies/:companyId/salary-runs/:id/employees",
"GET /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId",
"GET /api/v1/companies/:companyId/salary-runs/:id/payslips/:employeeId/pdf",
"GET /api/v1/companies/:companyId/supplier-invoices",
"GET /api/v1/companies/:companyId/supplier-invoices/:id",
"GET /api/v1/companies/:companyId/suppliers",
@@ -59,6 +68,7 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
"PATCH /api/v1/companies/:companyId/employees/:id",
"PATCH /api/v1/companies/:companyId/invoices/:id",
"PATCH /api/v1/companies/:companyId/salary-runs/:id",
"PATCH /api/v1/companies/:companyId/salary-runs/:id/lines/:lineId",
"PATCH /api/v1/companies/:companyId/supplier-invoices/:id",
"PATCH /api/v1/companies/:companyId/suppliers/:id",
"PATCH /api/v1/companies/:companyId/webhooks/:id",
@@ -92,8 +102,11 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
"POST /api/v1/companies/:companyId/salary-runs/:id/approve",
"POST /api/v1/companies/:companyId/salary-runs/:id/book",
"POST /api/v1/companies/:companyId/salary-runs/:id/calculate",
"POST /api/v1/companies/:companyId/salary-runs/:id/employees",
"POST /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId/lines",
"POST /api/v1/companies/:companyId/salary-runs/:id/generate-agi",
"POST /api/v1/companies/:companyId/salary-runs/:id/mark-paid",
"POST /api/v1/companies/:companyId/salary/vacation-year-close",
"POST /api/v1/companies/:companyId/supplier-invoices",
"POST /api/v1/companies/:companyId/supplier-invoices/:id/approve",
"POST /api/v1/companies/:companyId/supplier-invoices/:id/credit",
@@ -111,6 +124,9 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
"POST /api/v1/companies/:companyId/webhooks/:id/rotate-secret",
"POST /api/v1/companies/:companyId/webhooks/:id/test",
"POST /api/v1/webhook-deliveries/:id/retry",
"PUT /api/v1/companies/:companyId/employees/:id/absence",
"PUT /api/v1/companies/:companyId/employees/:id/opening-balances",
"PUT /api/v1/companies/:companyId/employees/opening-balances",
]
`;
+20
View File
@@ -97,6 +97,26 @@ import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/mark-paid/route'
import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route'
import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/generate-agi/route'
// Payroll gap-closure 1.1: per-employee payslip reads (list + detail + PDF).
import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/employees/route'
import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/employees/[employeeId]/route'
import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/payslips/[employeeId]/pdf/route'
// Payroll gap-closure 1.2: payslip line writes (draft runs only).
import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/employees/[employeeId]/lines/route'
import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/lines/[lineId]/route'
// Payroll gap-closure 1.4: absence (frånvaro) range endpoints.
import '@/app/api/v1/companies/[companyId]/employees/[id]/absence/route'
// Payroll gap-closure 2.3: cutover opening balances (single + atomic bulk).
import '@/app/api/v1/companies/[companyId]/employees/[id]/opening-balances/route'
import '@/app/api/v1/companies/[companyId]/employees/opening-balances/route'
// Payroll gap-closure 3.4: vacation ledger + year close.
import '@/app/api/v1/companies/[companyId]/employees/[id]/vacation-balance/route'
import '@/app/api/v1/companies/[companyId]/salary/vacation-year-close/route'
// Phase 5 PR-3: Reports + import async. All reports wrap existing
// lib/reports/* generators. Imports run inline today but record their
// progress on the `operations` table for consistent polling-shape. KPI,
+5 -1
View File
@@ -65,7 +65,11 @@ import { API_V1_VERSION, API_V1_VERSION_HEADER } from './version'
const IDEMPOTENCY_HEADER = 'Idempotency-Key'
const DRY_RUN_HEADER = 'X-Dry-Run'
const REQUIRES_IDEMPOTENCY = new Set(['POST', 'PATCH', 'DELETE'])
// Every state-changing method. PUT is included even though most v1 writes are
// POST/PATCH: the set drives THREE behaviors (test-key dry-run forcing,
// idempotency replay, requireIdempotencyKey enforcement), and omitting PUT
// would let test keys write through PUT routes for real.
const REQUIRES_IDEMPOTENCY = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
export interface ApiV1Context {
/** Stable id for this HTTP request: appears in logs, error envelope, X-Request-Id. */
+19
View File
@@ -102,7 +102,9 @@ export function withRouteContext<P extends DynamicParams = { params: Promise<Rec
let errLog = log
try {
const authStart = Date.now()
const auth = await requireAuth()
const authMs = Date.now() - authStart
if (auth.error) {
log.warn('auth failed', { status: auth.error.status })
// Pass through requireAuth's response unchanged for backwards-compat
@@ -119,11 +121,13 @@ export function withRouteContext<P extends DynamicParams = { params: Promise<Rec
errLog = userLog
let companyId: string | null = null
const companyStart = Date.now()
try {
companyId = await getActiveCompanyId(supabase, user.id)
} catch (err) {
userLog.error('failed to resolve active company', err as Error)
}
const companyMs = Date.now() - companyStart
if (!companyId) {
return errorResponseFromCode('COMPANY_CONTEXT_MISSING', userLog, { requestId })
@@ -152,15 +156,30 @@ export function withRouteContext<P extends DynamicParams = { params: Promise<Rec
}
errLog = ctx.log
const handlerStart = Date.now()
const response = await handler(request, ctx, params)
const handlerMs = Date.now() - handlerStart
if (response instanceof Response && !response.headers.get('X-Request-Id')) {
response.headers.set('X-Request-Id', requestId)
}
// Per-phase breakdown, visible in browser devtools (Timing tab) and in
// the op-completed log: separates the wrapper's own overhead (auth
// round trip + company resolution) from the handler's real work, so
// latency regressions can be attributed without guessing.
if (response instanceof Response && !response.headers.get('Server-Timing')) {
response.headers.set(
'Server-Timing',
`auth;dur=${authMs}, company;dur=${companyMs}, handler;dur=${handlerMs}`,
)
}
ctx.log.info('op completed', {
durationMs: Date.now() - start,
status: response.status,
authMs,
companyMs,
handlerMs,
})
return response
} catch (err) {
+34
View File
@@ -227,6 +227,17 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
gnubok_create_salary_run: 'payroll:write',
gnubok_calculate_salary_run: 'payroll:write',
gnubok_generate_agi: 'payroll:write',
// Payroll gap-closure: reads + staged writes (1.6-1.8, 2.4)
gnubok_get_employee: 'payroll:read',
gnubok_get_payslip: 'payroll:read',
gnubok_list_absence: 'payroll:read',
gnubok_update_payslip_line: 'payroll:write',
gnubok_register_absence: 'payroll:write',
gnubok_create_employee: 'payroll:write',
gnubok_update_employee: 'payroll:write',
gnubok_set_employee_opening_balances: 'payroll:write',
gnubok_get_vacation_balance: 'payroll:read',
gnubok_close_vacation_year: 'payroll:write',
// Bookkeeping write (Stream 1 Phase 1): high-risk, always staged
gnubok_close_period: 'bookkeeping:write',
gnubok_lock_period: 'bookkeeping:write',
@@ -283,6 +294,29 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
gnubok_agi_status: 'compliance:read',
gnubok_vat_declaration_submit: 'skatteverket:write',
gnubok_agi_submit: 'skatteverket:write',
// ── Audit retrofit (agent-native audit P0: unmapped = default-allow) ──
// These tools shipped without a scope mapping, making them callable by ANY
// authenticated key. Mapping them is accept-the-break by decision
// (2026-07-13): keys that relied on the default-allow hole lose access
// until granted the proper scope. Release-note callout required for the
// four WRITES below.
gnubok_link_invoice_to_voucher: 'invoices:write',
gnubok_undo_sie_import: 'bookkeeping:write',
gnubok_post_annual_depreciation: 'bookkeeping:write',
gnubok_import_rot_rut_beslut: 'invoices:write',
gnubok_list_verifikat_without_documents: 'transactions:read',
gnubok_find_voucher_candidates_for_invoice: 'invoices:read',
gnubok_propose_dispositioner: 'reports:read',
gnubok_propose_accruals: 'reports:read',
gnubok_propose_annual_depreciation: 'reports:read',
gnubok_preview_arsredovisning: 'reports:read',
gnubok_preview_ef_declaration: 'reports:read',
// Deliberately UNSCOPED (available to any authenticated key):
// gnubok_search_tools, gnubok_list_skills, gnubok_load_skill,
// gnubok_feedback. Discovery + static skill bodies + feedback channel
// carry no per-company data; keeping them open is what lets an agent
// orient itself before its key's scopes are known.
}
export function validateScopes(scopes: unknown): ApiKeyScope[] | null {
+24
View File
@@ -180,6 +180,30 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
'POST /api/v1/companies/:companyId/salary-runs/:id/mark-paid': 'payroll:write',
'POST /api/v1/companies/:companyId/salary-runs/:id/book': 'payroll:write',
'POST /api/v1/companies/:companyId/salary-runs/:id/generate-agi': 'payroll:write',
// Payroll gap-closure 1.1: per-employee payslip reads. Personnummer is
// masked on all payslip-shaped responses (GDPR Art.5(1)(c)); the employee
// detail endpoint is the identity drill-in.
'GET /api/v1/companies/:companyId/salary-runs/:id/employees': 'payroll:read',
'GET /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId': 'payroll:read',
'GET /api/v1/companies/:companyId/salary-runs/:id/payslips/:employeeId/pdf': 'payroll:read',
// Payroll gap-closure 1.2: payslip line writes (draft runs only).
'POST /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId/lines': 'payroll:write',
'PATCH /api/v1/companies/:companyId/salary-runs/:id/lines/:lineId': 'payroll:write',
'DELETE /api/v1/companies/:companyId/salary-runs/:id/lines/:lineId': 'payroll:write',
// Payroll gap-closure 1.3: run roster attach/remove (draft runs only).
'POST /api/v1/companies/:companyId/salary-runs/:id/employees': 'payroll:write',
'DELETE /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId': 'payroll:write',
// Payroll gap-closure 1.4: absence (frånvaro) per-day register via ranges.
'GET /api/v1/companies/:companyId/employees/:id/absence': 'payroll:read',
'PUT /api/v1/companies/:companyId/employees/:id/absence': 'payroll:write',
'DELETE /api/v1/companies/:companyId/employees/:id/absence': 'payroll:write',
// Payroll gap-closure 2.3: cutover opening balances (mid-year migration).
'GET /api/v1/companies/:companyId/employees/:id/opening-balances': 'payroll:read',
'PUT /api/v1/companies/:companyId/employees/:id/opening-balances': 'payroll:write',
'PUT /api/v1/companies/:companyId/employees/opening-balances': 'payroll:write',
// Payroll gap-closure 3.4: vacation ledger + year close.
'GET /api/v1/companies/:companyId/employees/:id/vacation-balance': 'payroll:read',
'POST /api/v1/companies/:companyId/salary/vacation-year-close': 'payroll:write',
// Dimensions (kostnadsställe/projekt): dimensions PR2. Reads ride
// reports:read (registry data feeds report filters/pickers); value creation
@@ -211,6 +211,118 @@ describe('proposePaymentLines', () => {
})
})
describe('proposePaymentLines: öresavrundning (3740)', () => {
it('accrual: rounded-up total → bank leg at "Att betala", 3740 credit carries the residual', () => {
const lines = proposePaymentLines({
invoice: makeInvoiceInput({
total: 1234.75,
subtotal: 987.8,
vat_amount: 246.95,
items: [makeItem({ line_total: 987.8, vat_amount: 246.95, unit_price: 987.8 })],
}),
accountingMethod: 'accrual',
entityType: 'enskild_firma',
companyOreRounding: true,
})
expect(lines).toHaveLength(3)
expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '1235' })
expect(lines[1]).toMatchObject({ account_number: '1510', credit_amount: '1234.75' })
expect(lines[2]).toEqual({
account_number: '3740',
debit_amount: '',
credit_amount: '0.25',
line_description: 'Öresavrundning',
})
})
it('accrual: rounded-down total → 3740 debit', () => {
const lines = proposePaymentLines({
invoice: makeInvoiceInput({ total: 1234.25 }),
accountingMethod: 'accrual',
entityType: 'enskild_firma',
companyOreRounding: true,
})
expect(lines).toHaveLength(3)
expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '1234' })
expect(lines[1]).toMatchObject({ account_number: '1510', credit_amount: '1234.25' })
expect(lines[2]).toMatchObject({ account_number: '3740', debit_amount: '0.25', credit_amount: '' })
})
it('cash: bank leg is the rounded amount and 3740 balances the exact revenue + VAT credits', () => {
const lines = proposePaymentLines({
invoice: makeInvoiceInput({
total: 1234.75,
subtotal: 987.8,
vat_amount: 246.95,
items: [makeItem({ line_total: 987.8, vat_amount: 246.95, unit_price: 987.8 })],
}),
accountingMethod: 'cash',
entityType: 'enskild_firma',
companyOreRounding: true,
})
expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '1235' })
const last = lines[lines.length - 1]
expect(last).toMatchObject({ account_number: '3740', credit_amount: '0.25' })
const debit = lines.reduce((s, l) => s + (parseFloat(l.debit_amount) || 0), 0)
const credit = lines.reduce((s, l) => s + (parseFloat(l.credit_amount) || 0), 0)
expect(Math.round((debit - credit) * 100)).toBe(0)
})
it('company setting off and no invoice override → unchanged 2-line proposal', () => {
const lines = proposePaymentLines({
invoice: makeInvoiceInput({ total: 1234.75 }),
accountingMethod: 'accrual',
entityType: 'enskild_firma',
companyOreRounding: false,
})
expect(lines).toHaveLength(2)
expect(lines[0].debit_amount).toBe('1234.75')
})
it('per-invoice override wins over the company setting', () => {
const lines = proposePaymentLines({
invoice: { ...makeInvoiceInput({ total: 1234.75 }), ore_rounding: true },
accountingMethod: 'accrual',
entityType: 'enskild_firma',
companyOreRounding: false,
})
expect(lines).toHaveLength(3)
expect(lines[2].account_number).toBe('3740')
})
it('whole-krona total → no 3740 line even when rounding is on', () => {
const lines = proposePaymentLines({
invoice: makeInvoiceInput({ total: 12500 }),
accountingMethod: 'accrual',
entityType: 'enskild_firma',
companyOreRounding: true,
})
expect(lines).toHaveLength(2)
})
it('non-SEK invoice → rounding never applies', () => {
const lines = proposePaymentLines({
invoice: makeInvoiceInput({
total: 1000.4,
total_sek: 10004,
currency: 'EUR',
exchange_rate: 10,
}),
accountingMethod: 'accrual',
entityType: 'enskild_firma',
companyOreRounding: true,
})
expect(lines.every((l) => l.account_number !== '3740')).toBe(true)
})
})
describe('proposePaymentLines: dimensions propagation (PR7)', () => {
const bag = { '1': 'KS01', '6': 'P001' }
+49 -6
View File
@@ -7,6 +7,7 @@
import { resolveSekAmount } from './currency-utils'
import { getRevenueAccount, getOutputVatAccount } from './invoice-entries'
import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
import type { EntityType, InvoiceItem, VatTreatment } from '@/types'
@@ -23,6 +24,8 @@ export interface ProposePaymentLinesInput {
exchange_rate?: number | null
vat_treatment: VatTreatment
items?: InvoiceItem[]
/** Per-invoice öresavrundning override; null = inherit the company setting. */
ore_rounding?: boolean | null
/**
* Dimensions PR7: the invoice's default bag. Stamped on every proposed
* line: the payment dialog always submits its (editable) lines, so the
@@ -36,6 +39,13 @@ export interface ProposePaymentLinesInput {
entityType: EntityType
paymentAccount?: string
exchangeRateDifference?: number
/**
* company_settings.ore_rounding. Combined with the per-invoice override via
* getDisplayTotal (SEK only, default-on) to decide whether the proposal
* expects the customer to pay the rounded "Att betala" from the PDF: then
* the bank leg is the rounded amount and 3740 carries the residual.
*/
companyOreRounding?: boolean
}
function toFormAmount(n: number): string {
@@ -73,9 +83,19 @@ export function proposePaymentLines(input: ProposePaymentLinesInput): FormLine[]
const paymentAccount = input.paymentAccount || '1930'
const desc = invoice.invoice_number ? `Betalning faktura ${invoice.invoice_number}` : 'Betalning faktura'
// Öresavrundning: when it applies (SEK, enabled, non-integer total) the
// customer pays the rounded "Att betala" from the PDF, not the stored öre
// total. Propose the bank leg at the rounded amount and let 3740 carry the
// residual, so the default booking matches what actually hits the bank.
// getDisplayTotal returns delta 0 whenever rounding does not apply.
const roundingDelta = getDisplayTotal(
{ total: invoice.total, currency: invoice.currency, ore_rounding: invoice.ore_rounding },
input.companyOreRounding === undefined ? undefined : { ore_rounding: input.companyOreRounding },
).roundingDelta
const lines = accountingMethod === 'accrual'
? proposeAccrualLines(invoice, paymentAccount, desc, exchangeRateDifference)
: proposeCashLines(invoice, paymentAccount, desc, entityType)
? proposeAccrualLines(invoice, paymentAccount, desc, exchangeRateDifference, roundingDelta)
: proposeCashLines(invoice, paymentAccount, desc, entityType, roundingDelta)
// Dimensions PR7: re-propagate the invoice default onto every proposed leg
// (matches createInvoicePaymentJournalEntry/createInvoiceCashEntry).
@@ -86,11 +106,26 @@ export function proposePaymentLines(input: ProposePaymentLinesInput): FormLine[]
return lines
}
/**
* The 3740 (öres- och kronutjämning) residual line. Customer paid over the
* stored total (rounded up) → credit (vinst); under (rounded down) → debit
* (förlust). Same polarity as buildInvoicePaymentClearingLines.
*/
function oreRoundingLine(roundingDelta: number): FormLine {
return {
account_number: '3740',
debit_amount: roundingDelta < 0 ? toFormAmount(Math.abs(roundingDelta)) : '',
credit_amount: roundingDelta > 0 ? toFormAmount(roundingDelta) : '',
line_description: 'Öresavrundning',
}
}
function proposeAccrualLines(
invoice: ProposePaymentLinesInput['invoice'],
paymentAccount: string,
desc: string,
exchangeRateDifference?: number
exchangeRateDifference?: number,
roundingDelta = 0
): FormLine[] {
const bookedSekAmount = resolveSekAmount(
invoice.total,
@@ -136,7 +171,7 @@ function proposeAccrualLines(
const amount = Math.round(bookedSekAmount * 100) / 100
lines.push({
account_number: paymentAccount,
debit_amount: toFormAmount(amount),
debit_amount: toFormAmount(amount + roundingDelta),
credit_amount: '',
line_description: desc,
})
@@ -146,6 +181,9 @@ function proposeAccrualLines(
credit_amount: toFormAmount(amount),
line_description: desc,
})
if (roundingDelta !== 0) {
lines.push(oreRoundingLine(roundingDelta))
}
}
return lines
@@ -155,7 +193,8 @@ function proposeCashLines(
invoice: ProposePaymentLinesInput['invoice'],
paymentAccount: string,
desc: string,
entityType: EntityType
entityType: EntityType,
roundingDelta = 0
): FormLine[] {
const lines: FormLine[] = []
const isForeign = invoice.currency !== 'SEK'
@@ -264,12 +303,16 @@ function proposeCashLines(
lines.push({
account_number: paymentAccount,
debit_amount: toFormAmount(debitAmount),
debit_amount: toFormAmount(debitAmount + roundingDelta),
credit_amount: '',
line_description: desc,
})
lines.push(...creditLines)
if (roundingDelta !== 0) {
lines.push(oreRoundingLine(roundingDelta))
}
return lines
}
+78 -3
View File
@@ -6,17 +6,22 @@ vi.mock('next/headers', () => ({
cookies: vi.fn(async () => ({ set: mockCookieSet })),
}))
import { setActiveCompany, CompanyContextError, getCompanyDisplayName } from '../context'
import { setActiveCompany, CompanyContextError, getCompanyDisplayName, getActiveCompanyId } from '../context'
type CapturedCall = { table: string; method: string; args: unknown[] }
type TerminalResult = { data?: unknown; error?: unknown }
/**
* Chainable Supabase mock (same approach as actions.test.ts): a chain method
* terminates with `results[table][method]` when seeded, otherwise keeps
* chaining. setActiveCompany ends both its queries on `.single()`, on
* different tables, so seeding `single` per table drives each branch.
* A terminal seeded as an ARRAY is consumed in call order, for functions
* that query the same table twice (getActiveCompanyId's fallback fetch +
* preference validation both end on company_members.maybeSingle()).
*/
function buildSupabase(results: Record<string, Record<string, { data?: unknown; error?: unknown }>>) {
function buildSupabase(results: Record<string, Record<string, TerminalResult | TerminalResult[]>>) {
const calls: CapturedCall[] = []
function makeChain(table: string) {
@@ -25,7 +30,8 @@ function buildSupabase(results: Record<string, Record<string, { data?: unknown;
for (const m of methods) {
chain[m] = (...args: unknown[]) => {
calls.push({ table, method: m, args })
const terminal = results[table]?.[m]
const seeded = results[table]?.[m]
const terminal = Array.isArray(seeded) ? seeded.shift() : seeded
if (terminal) {
return Promise.resolve({ data: terminal.data ?? null, error: terminal.error ?? null })
}
@@ -110,6 +116,75 @@ describe('setActiveCompany', () => {
})
})
describe('getActiveCompanyId', () => {
it('resolves the preferred company with ONE company_members query when it is the first membership', async () => {
const { supabase, calls } = buildSupabase({
user_preferences: { maybeSingle: { data: { active_company_id: 'company-1' } } },
company_members: { maybeSingle: { data: { company_id: 'company-1' } } },
})
const id = await getActiveCompanyId(supabase as never, 'user-1')
expect(id).toBe('company-1')
// The parallel fallback fetch doubles as validation in the common
// single-company case: no second, sequential round trip.
const memberQueries = calls.filter((c) => c.table === 'company_members' && c.method === 'maybeSingle')
expect(memberQueries).toHaveLength(1)
})
it('validates a preference that differs from the first membership', async () => {
const { supabase, calls } = buildSupabase({
user_preferences: { maybeSingle: { data: { active_company_id: 'company-2' } } },
company_members: {
maybeSingle: [
{ data: { company_id: 'company-1' } }, // first membership (parallel fetch)
{ data: { company_id: 'company-2' } }, // validation of the preference
],
},
})
const id = await getActiveCompanyId(supabase as never, 'user-1')
expect(id).toBe('company-2')
const memberQueries = calls.filter((c) => c.table === 'company_members' && c.method === 'maybeSingle')
expect(memberQueries).toHaveLength(2)
})
it('falls back to the first membership when the preference is stale', async () => {
const { supabase } = buildSupabase({
user_preferences: { maybeSingle: { data: { active_company_id: 'company-archived' } } },
company_members: {
maybeSingle: [
{ data: { company_id: 'company-1' } }, // first membership
{ data: null }, // validation: preference archived / membership gone
],
},
})
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBe('company-1')
})
it('falls back to the first membership when there is no preference row', async () => {
const { supabase, calls } = buildSupabase({
user_preferences: { maybeSingle: { data: null } },
company_members: { maybeSingle: { data: { company_id: 'company-1' } } },
})
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBe('company-1')
const memberQueries = calls.filter((c) => c.table === 'company_members' && c.method === 'maybeSingle')
expect(memberQueries).toHaveLength(1)
})
it('returns null when the user has no non-archived memberships', async () => {
const { supabase } = buildSupabase({
user_preferences: { maybeSingle: { data: null } },
company_members: { maybeSingle: { data: null } },
})
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBeNull()
})
})
describe('getCompanyDisplayName', () => {
it('returns company_settings.company_name and never reads companies when set', async () => {
const { supabase, calls } = buildSupabase({
+31 -18
View File
@@ -37,16 +37,38 @@ export async function getActiveCompanyId(
supabase: SupabaseClient,
userId: string
): Promise<string | null> {
// 1. user_preferences: authoritative
const { data: prefs } = await supabase
.from('user_preferences')
.select('active_company_id')
.eq('user_id', userId)
.maybeSingle()
// user_preferences (authoritative) + first membership, fetched in parallel:
// the fallback query result doubles as validation when the preferred
// company happens to be the first membership, which is the common
// single-company case. Most requests pay one round trip instead of two
// sequential ones. This runs on every withRouteContext API request and
// every dashboard layout render, so the sequential version was pure
// wall-clock cost. Mirrors resolveCompanyForMiddleware, minus the
// write-back (read paths shouldn't write).
const [{ data: prefs }, { data: firstCompany }] = await Promise.all([
supabase
.from('user_preferences')
.select('active_company_id')
.eq('user_id', userId)
.maybeSingle(),
supabase
.from('company_members')
.select('company_id, companies!inner(archived_at)')
.eq('user_id', userId)
.is('companies.archived_at', null)
.order('created_at', { ascending: true })
.limit(1)
.maybeSingle(),
])
if (prefs?.active_company_id) {
// Validate the preference still points to a non-archived company the
// user is a member of.
if (firstCompany && prefs.active_company_id === firstCompany.company_id) {
return firstCompany.company_id
}
// Preference points at a different company than the first membership:
// validate it still resolves to a non-archived company the user is a
// member of before trusting it.
const { data: membership } = await supabase
.from('company_members')
.select('company_id, companies!inner(archived_at)')
@@ -58,16 +80,7 @@ export async function getActiveCompanyId(
if (membership) return membership.company_id
}
// 2. Fallback: first non-archived membership by created_at
const { data: firstCompany } = await supabase
.from('company_members')
.select('company_id, companies!inner(archived_at)')
.eq('user_id', userId)
.is('companies.archived_at', null)
.order('created_at', { ascending: true })
.limit(1)
.maybeSingle()
// Fallback: first non-archived membership by created_at (already fetched)
return firstCompany?.company_id ?? null
}
@@ -2,7 +2,7 @@ export const COOKBOOK_PAYROLL_AGI_MD = `# Cookbook: run payroll and generate the
> Drive a Swedish salary run from draft to booked, then generate the arbetsgivardeklaration på individnivå (AGI) XML for manual submission to Skatteverket. Five-step lifecycle, every state transition idempotent and dry-runnable (generate-agi is idempotent but not dry-runnable).
This is the operational companion to the [Salary-runs reference](/docs/api/reference/salary-runs). Most of the payroll lifecycle is API-callable, but one step is still dashboard-only: **attaching employees to a run** (the \`/salary-runs/{id}/employees\` v1 endpoint is forthcoming), so a run can't yet be populated end-to-end from the public API. Everything after that — calculate, approve, mark paid, book, generate AGI — is.
This is the operational companion to the [Salary-runs reference](/docs/api/reference/salary-runs). The whole lifecycle is API-callable end-to-end: create the run, attach employees (\`POST /salary-runs/{id}/employees\`), add manual payslip lines if needed, calculate, approve, mark paid, book, generate AGI. Per-employee results are readable via \`GET /salary-runs/{id}/employees\` (list) and \`GET /salary-runs/{id}/employees/{employeeId}\` (payslip detail incl. line items and the step-by-step calculation breakdown); the rendered payslip PDF is at \`GET /salary-runs/{id}/payslips/{employeeId}/pdf\`.
## What you'll need
@@ -12,7 +12,7 @@ This is the operational companion to the [Salary-runs reference](/docs/api/refer
## 1. Create a salary run (draft)
\`POST /salary-runs\` opens an **empty** run in \`draft\` status: it takes only the period + payment metadata (\`period_year\`, \`period_month\`, \`payment_date\`, optional \`voucher_series\` and \`notes\`). Employees are attached in a separate step (via the dashboard, or the forthcoming \`/salary-runs/{id}/employees\` surface) before you \`:calculate\`.
\`POST /salary-runs\` opens an **empty** run in \`draft\` status: it takes only the period + payment metadata (\`period_year\`, \`period_month\`, \`payment_date\`, optional \`voucher_series\` and \`notes\`). Attach each employee with \`POST /salary-runs/{id}/employees\` (body \`{ "employee_id": "..." }\`, plus \`hours_worked\` for hourly staff) before you \`:calculate\`. The attach snapshots the employee's pay config onto the run and seeds the base salary line; one-off components (bonus, deduction, reimbursement) go through \`POST /salary-runs/{id}/employees/{employeeId}/lines\` while the run is still a draft. Line edits never recompute tax: always \`:calculate\` afterwards.
\`\`\`bash
curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/salary-runs" \\
+60
View File
@@ -1792,6 +1792,66 @@ const SALARY: Record<string, StructuredErrorEntry> = {
message_sv: 'Inga aktiva anställda finns i företaget.',
message_en: 'No active employees in the company.',
},
SALARY_RUN_LINE_NOT_DRAFT: {
httpStatus: 400,
message_sv: 'Lönebeskedets rader kan bara redigeras medan lönekörningen är ett utkast.',
message_en: 'Payslip lines can only be edited while the salary run is a draft.',
},
SALARY_RUN_EMPLOYEE_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Anställd finns inte i denna lönekörning.',
message_en: 'Employee is not part of this salary run.',
},
SALARY_LINE_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Lönebeskedsraden kunde inte hittas.',
message_en: 'Payslip line not found.',
},
SALARY_RUN_EMPLOYEE_DUPLICATE: {
httpStatus: 409,
message_sv: 'Den anställda finns redan i lönekörningen.',
message_en: 'Employee is already part of this salary run.',
},
SALARY_RUN_EMPLOYEES_NOT_DRAFT: {
httpStatus: 400,
message_sv: 'Anställda kan bara läggas till eller tas bort medan lönekörningen är ett utkast.',
message_en: 'Employees can only be added or removed while the salary run is a draft.',
},
ABSENCE_RANGE_TOO_LARGE: {
httpStatus: 400,
message_sv: 'Frånvarointervallet är för stort. Max 92 dagar per anrop.',
message_en: 'Absence range too large. Maximum 92 days per request.',
},
ABSENCE_HOURS_CONFLICT: {
httpStatus: 409,
message_sv: 'Total frånvarotid för dagen överstiger 24 timmar.',
message_en: 'Total absence hours for the day exceed 24 hours.',
},
OPENING_BALANCES_LOCKED: {
httpStatus: 409,
message_sv: 'Ingående saldon är låsta: den anställda har en bokförd lönekörning.',
message_en: 'Opening balances are locked: the employee has a booked salary run.',
},
VACATION_YEAR_NOT_ENDED: {
httpStatus: 400,
message_sv: 'Semesteråret kan inte stängas innan det har tagit slut.',
message_en: 'The vacation year cannot be closed before it has ended.',
},
VACATION_YEAR_ALREADY_CLOSED: {
httpStatus: 409,
message_sv: 'Semesteråret är redan stängt.',
message_en: 'The vacation year is already closed.',
},
VACATION_CLOSE_ADJUSTMENT_FAILED: {
httpStatus: 500,
message_sv: 'Semestersaldon rullades men justeringsverifikationen kunde inte bokföras. Bokför justeringen manuellt från rapporten.',
message_en: 'Vacation balances rolled but the adjustment entry failed to post. Book the adjustment manually from the report.',
},
VACATION_BALANCE_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Inget semestersaldo finns för den anställda ännu.',
message_en: 'No vacation balance exists for the employee yet.',
},
SALARY_RUN_TAX_TABLE_MISSING: {
httpStatus: 400,
message_sv: 'Skattetabellen saknas för perioden. Importera skattetabellen först.',
+42
View File
@@ -0,0 +1,42 @@
'use client'
import { useEffect } from 'react'
import { useFetch } from './use-fetch'
import type { AgiSubmissionState } from '@/lib/salary/agi-submission-state'
/**
* Fetches the Skatteverket extension's per-period AGI submission record
* (`agi_submission_{period}`, period = YYYYMM) so the run page, progress
* rail, and salary hero can render the real filing state machine
* (underlag submitted / awaiting BankID signature / signed).
*
* Returns `submission: null` when the extension is disabled (503, e.g.
* self-hosted), the user isn't connected, or nothing has been submitted
* yet: callers fall back to the run row's own agi_* timestamps. Pass a
* null period to skip fetching entirely (e.g. a run that isn't booked).
*
* Refetches when the tab regains focus: the user typically signs in
* Skatteverket's Mina Sidor in another tab (or on their phone) and comes
* back here expecting the state to have caught up.
*/
export function useAgiSubmission(period: string | null): {
submission: AgiSubmissionState | null
refresh: () => void
} {
const { data, refetch } = useFetch<
{ data?: AgiSubmissionState | null },
AgiSubmissionState | null
>(period ? `/api/extensions/ext/skatteverket/agi/status?period=${period}` : null, {
select: body => body?.data ?? null,
})
useEffect(() => {
function onVisible() {
if (document.visibilityState === 'visible') refetch()
}
document.addEventListener('visibilitychange', onVisible)
return () => document.removeEventListener('visibilitychange', onVisible)
}, [refetch])
return { submission: data, refresh: refetch }
}
+56
View File
@@ -0,0 +1,56 @@
'use client'
import useSWR from 'swr'
import { createClient } from '@/lib/supabase/client'
export interface WorklistBadges {
/** Unbooked bank transactions: same predicate as lib/worklist countUnbookedTransactions. */
uncategorized: number
/** Agent-staged operations awaiting review: same predicate as countPendingOperations. */
pendingOperations: number
}
/**
* Client-side nav badge counts. These used to be fetched by the dashboard
* layout on the critical path of every server navigation; two head-count
* queries nobody needs before first paint. Now they load (and revalidate)
* after mount, and SWR dedupes the realtime-triggered refreshes that
* previously stampeded during bulk operations.
*
* The predicates deliberately mirror lib/worklist/categories.ts so the badge
* shows the same number as every other "att göra" surface; RLS scopes both
* tables, and the explicit company_id filter is defense in depth.
*/
export function useWorklistBadges(companyId: string | null | undefined) {
const { data, mutate } = useSWR<WorklistBadges>(
companyId ? ['worklist-badges', companyId] : null,
async ([, id]: [string, string]) => {
const supabase = createClient()
const [tx, ops] = await Promise.all([
supabase
.from('transactions')
.select('id', { count: 'exact', head: true })
.eq('company_id', id)
.is('is_business', null)
.eq('is_ignored', false),
supabase
.from('pending_operations')
.select('id', { count: 'exact', head: true })
.eq('company_id', id)
.eq('status', 'pending'),
])
return {
uncategorized: tx.error ? 0 : (tx.count ?? 0),
pendingOperations: ops.error ? 0 : (ops.count ?? 0),
}
},
)
return {
uncategorized: data?.uncategorized ?? 0,
pendingOperations: data?.pendingOperations ?? 0,
// SWR's bound mutate is referentially stable, so consumers can list it in
// effect deps without re-subscribing on every render.
refresh: mutate,
}
}
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest'
import {
planInvoicePayment,
planInvoicePaymentForLines,
PAYMENT_OVERSHOOT_TOLERANCE,
} from '@/lib/invoices/apply-invoice-payment'
@@ -65,4 +66,184 @@ describe('planInvoicePayment', () => {
it('overshoot tolerance is half an öre', () => {
expect(PAYMENT_OVERSHOOT_TOLERANCE).toBe(0.005)
})
describe('absorbOreRounding', () => {
it('settles in full when the customer paid the rounded-up "Att betala"', () => {
// Invoice total 1234.75, PDF shows 1235.00 (öresavrundning), customer pays that.
const r = planInvoicePayment(
{ total: 1234.75, paid_amount: 0, remaining_amount: 1234.75 },
1235,
{ absorbOreRounding: true },
)
expect(r.ok).toBe(true)
if (r.ok) {
expect(r.plan).toEqual({
newPaidAmount: 1234.75,
newRemaining: 0,
isFullyPaid: true,
newStatus: 'paid',
oreSettled: true,
})
}
})
it('settles a sub-krona short payment in full (rounded-down total)', () => {
const r = planInvoicePayment(
{ total: 1000.4, paid_amount: 0, remaining_amount: 1000.4 },
1000,
{ absorbOreRounding: true },
)
expect(r.ok).toBe(true)
if (r.ok) {
expect(r.plan.newStatus).toBe('paid')
expect(r.plan.newPaidAmount).toBe(1000.4)
expect(r.plan.newRemaining).toBe(0)
expect(r.plan.oreSettled).toBe(true)
}
})
it('still rejects an overshoot beyond the öre band', () => {
const r = planInvoicePayment(
{ total: 1000, paid_amount: 0, remaining_amount: 1000 },
1001.25,
{ absorbOreRounding: true },
)
expect(r.ok).toBe(false)
})
it('rejects an overshoot of exactly 1 kr (band boundary must not over-record)', () => {
const r = planInvoicePayment(
{ total: 1000, paid_amount: 0, remaining_amount: 1000 },
1001,
{ absorbOreRounding: true },
)
expect(r.ok).toBe(false)
})
it('a >=1 kr short payment stays a real partial', () => {
const r = planInvoicePayment(
{ total: 1000, paid_amount: 0, remaining_amount: 1000 },
999,
{ absorbOreRounding: true },
)
expect(r.ok).toBe(true)
if (r.ok) {
expect(r.plan.newStatus).toBe('partially_paid')
expect(r.plan.newRemaining).toBe(1)
expect(r.plan.oreSettled).toBe(false)
}
})
it('without the opt-in the sub-krona overshoot is still rejected', () => {
const r = planInvoicePayment(
{ total: 1234.75, paid_amount: 0, remaining_amount: 1234.75 },
1235,
)
expect(r.ok).toBe(false)
})
})
})
describe('planInvoicePaymentForLines', () => {
const INV = { total: 1234.75, paid_amount: 0, remaining_amount: 1234.75 }
it('absorbs when the lines carry the exact residual on 3740', () => {
const r = planInvoicePaymentForLines(
INV,
1235,
[
{ account_number: '1930', debit_amount: 1235, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 1234.75 },
{ account_number: '3740', debit_amount: 0, credit_amount: 0.25 },
],
'SEK',
)
expect(r.ok).toBe(true)
if (r.ok) {
expect(r.plan.newStatus).toBe('paid')
expect(r.plan.newPaidAmount).toBe(1234.75)
expect(r.plan.oreSettled).toBe(true)
}
})
it('short-payment lines with a 3740 debit residual settle in full', () => {
const r = planInvoicePaymentForLines(
{ total: 1000.4, paid_amount: 0, remaining_amount: 1000.4 },
1000,
[
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 1000.4 },
{ account_number: '3740', debit_amount: 0.4, credit_amount: 0 },
],
'SEK',
)
expect(r.ok).toBe(true)
if (r.ok) expect(r.plan.newStatus).toBe('paid')
})
it('a sub-krona short WITHOUT a 3740 line stays a real partial (no silent write-off)', () => {
// Deliberate partial: user lowered both legs; 1510 is only cleared by the
// paid amount, so flipping to paid would orphan the residual on 1510.
const r = planInvoicePaymentForLines(
INV,
1234,
[
{ account_number: '1930', debit_amount: 1234, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 1234 },
],
'SEK',
)
expect(r.ok).toBe(true)
if (r.ok) {
expect(r.plan.newStatus).toBe('partially_paid')
expect(r.plan.newRemaining).toBe(0.75)
expect(r.plan.oreSettled).toBe(false)
}
})
it('a sub-krona overshoot WITHOUT a 3740 line is rejected (1510 would over-credit)', () => {
const r = planInvoicePaymentForLines(
INV,
1235.25,
[
{ account_number: '1930', debit_amount: 1235.25, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 1235.25 },
],
'SEK',
)
expect(r.ok).toBe(false)
})
it('a 3740 amount that does not match the residual falls back to the strict plan', () => {
const r = planInvoicePaymentForLines(
INV,
1235.25, // 0.50 over, but the lines only book 0.25 on 3740
[
{ account_number: '1930', debit_amount: 1235.25, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 1235 },
{ account_number: '3740', debit_amount: 0, credit_amount: 0.25 },
],
'SEK',
)
expect(r.ok).toBe(false)
})
it('never absorbs for non-SEK invoices', () => {
const r = planInvoicePaymentForLines(
{ total: 100, paid_amount: 0, remaining_amount: 100 },
100.25,
[
{ account_number: '1930', debit_amount: 100.25, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 100 },
{ account_number: '3740', debit_amount: 0, credit_amount: 0.25 },
],
'EUR',
)
expect(r.ok).toBe(false)
})
it('without lines it behaves exactly like the strict plan', () => {
expect(planInvoicePaymentForLines(INV, 1234.75, undefined, 'SEK').ok).toBe(true)
expect(planInvoicePaymentForLines(INV, 1235, undefined, 'SEK').ok).toBe(false)
})
})
@@ -1,9 +1,80 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import {
computeNextRunDate,
computeInitialRunDate,
getStockholmDateHour,
executeRecurringSchedule,
} from '@/lib/invoices/recurring-schedule-service'
import { createQueuedMockSupabase, makeCustomer, makeCompanySettings } from '@/tests/helpers'
import { eventBus } from '@/lib/events'
// ── Mocks for the executeRecurringSchedule auto-send path ─────────────
// The pure date-helper tests below don't touch any of these modules.
const mockRenderToBuffer = vi.fn()
vi.mock('@react-pdf/renderer', () => ({
renderToBuffer: (...args: unknown[]) => mockRenderToBuffer(...args),
}))
const mockInvoicePDF = vi.fn()
vi.mock('@/lib/invoices/pdf-template', () => ({
InvoicePDF: (...args: unknown[]) => mockInvoicePDF(...args),
}))
const mockPrepareRender = vi.fn()
const mockSwishQr = vi.fn()
const mockPaymentLinkQr = vi.fn()
vi.mock('@/lib/invoices/pdf-render-helpers', () => ({
prepareInvoicePdfRender: (...args: unknown[]) => mockPrepareRender(...args),
buildSwishQrDataUrl: (...args: unknown[]) => mockSwishQr(...args),
buildPaymentLinkQrDataUrl: (...args: unknown[]) => mockPaymentLinkQr(...args),
}))
const mockApplyPaymentLink = vi.fn()
vi.mock('@/lib/extensions/payment-links', () => ({
applyPaymentLinkToInvoice: (...args: unknown[]) => mockApplyPaymentLink(...args),
}))
const mockSendEmail = vi.fn()
const mockIsConfigured = vi.fn()
vi.mock('@/lib/email/service', () => ({
getEmailService: () => ({
sendEmail: (...args: unknown[]) => mockSendEmail(...args),
isConfigured: () => mockIsConfigured(),
}),
}))
vi.mock('@/lib/email/invoice-templates', () => ({
generateInvoiceEmailHtml: vi.fn().mockReturnValue('<html>Invoice</html>'),
generateInvoiceEmailText: vi.fn().mockReturnValue('Invoice text'),
generateInvoiceEmailSubject: vi.fn().mockReturnValue('Faktura F-1'),
}))
const mockIsSandbox = vi.fn()
vi.mock('@/lib/sandbox/guard', () => ({
isSandboxCompany: (...args: unknown[]) => mockIsSandbox(...args),
}))
const mockHasCapability = vi.fn()
vi.mock('@/lib/entitlements/has-capability', () => ({
hasCapability: (...args: unknown[]) => mockHasCapability(...args),
}))
const mockEnsureNumber = vi.fn()
vi.mock('@/lib/invoices/ensure-invoice-number', () => ({
ensureInvoiceNumber: (...args: unknown[]) => mockEnsureNumber(...args),
}))
const mockCreateJE = vi.fn()
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
createInvoiceJournalEntry: (...args: unknown[]) => mockCreateJE(...args),
}))
const mockUploadDocument = vi.fn()
vi.mock('@/lib/core/documents/document-service', () => ({
uploadDocument: (...args: unknown[]) => mockUploadDocument(...args),
}))
describe('computeNextRunDate', () => {
it('advances day 15 from January to February', () => {
@@ -98,3 +169,190 @@ describe('getStockholmDateHour', () => {
})
})
})
describe('executeRecurringSchedule auto-send', () => {
const { supabase, enqueue, reset } = createQueuedMockSupabase()
const client = supabase as unknown as SupabaseClient
const today = new Date('2026-07-06T06:30:00Z')
const customer = makeCustomer({ id: 'cust-1', email: 'kund@test.se' })
const company = makeCompanySettings({ accounting_method: 'accrual' })
function makeSchedule() {
return {
id: 'sched-1',
company_id: 'company-1',
user_id: 'user-1',
customer_id: 'cust-1',
name: 'Monthly retainer',
day_of_month: 6,
send_hour: 8,
payment_terms_days: 30,
currency: 'SEK',
your_reference: null,
our_reference: null,
notes: null,
auto_send: true,
status: 'active',
next_run_date: '2026-07-06',
last_run_at: null,
last_invoice_id: null,
last_run_warning: null,
generated_count: 0,
items: [
{
id: 'si-1',
schedule_id: 'sched-1',
sort_order: 0,
description: 'Konsulttimmar',
quantity: 10,
unit: 'tim',
unit_price: 1000,
vat_rate: 25,
},
],
} as unknown as Parameters<typeof executeRecurringSchedule>[1]
}
// Fresh objects per test: ensureInvoiceNumber and applyPaymentLinkToInvoice
// mutate the invoice they receive, so shared fixtures would leak state.
function makeInsertedInvoice() {
return { id: 'inv-1', invoice_number: null, document_type: 'invoice' }
}
function makeCompleteInvoice() {
return {
id: 'inv-1',
invoice_number: 'F-1',
status: 'draft',
document_type: 'invoice',
currency: 'SEK',
total: 12500,
credited_invoice_id: null,
payment_link_url: null,
customer,
items: [{ id: 'item-1', sort_order: 0 }],
}
}
/** Queue for the full happy path (see call order in the service). */
function enqueueHappyPath() {
enqueue({ data: customer, error: null }) // customers select
enqueue({ data: makeInsertedInvoice(), error: null }) // invoices insert
enqueue({ data: null, error: null }) // invoice_items insert
enqueue({ data: makeCompleteInvoice(), error: null }) // re-fetch with relations
enqueue({ data: company, error: null }) // company_settings (auto-send)
enqueue({ data: null, error: null }) // status flip to sent
enqueue({ data: null, error: null }) // journal_entry_id write-back
}
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockIsConfigured.mockReturnValue(true)
mockIsSandbox.mockResolvedValue(false)
mockHasCapability.mockResolvedValue(true)
mockApplyPaymentLink.mockResolvedValue({ failure: null })
mockEnsureNumber.mockImplementation(
async (_supabase: unknown, _companyId: unknown, inv: { invoice_number: string | null }) => {
inv.invoice_number = 'F-1'
return 'F-1'
},
)
mockPrepareRender.mockResolvedValue({ branding: {}, company })
mockSwishQr.mockResolvedValue(null)
mockPaymentLinkQr.mockResolvedValue(null)
mockRenderToBuffer.mockResolvedValue(Buffer.from('fake-pdf'))
mockInvoicePDF.mockReturnValue('pdf-element')
mockSendEmail.mockResolvedValue({ success: true, messageId: 'm-1' })
mockCreateJE.mockResolvedValue({ id: 'je-1' })
mockUploadDocument.mockResolvedValue({})
})
it('creates a payment link before rendering and passes its QR to the PDF', async () => {
enqueueHappyPath()
mockApplyPaymentLink.mockImplementation(
async (_s: unknown, _c: unknown, _u: unknown, inv: { payment_link_url: string | null }) => {
inv.payment_link_url = 'https://pay.example/x'
return { failure: null }
},
)
mockPaymentLinkQr.mockResolvedValue('data:image/png;base64,QR')
const result = await executeRecurringSchedule(client, makeSchedule(), today)
expect(result.autoSent).toBe(true)
expect(result.warning).toBeNull()
expect(mockApplyPaymentLink).toHaveBeenCalledTimes(1)
expect(mockApplyPaymentLink).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({ id: 'inv-1' }),
expect.anything(),
)
// Link applied BEFORE the render so the email button and PDF QR carry it.
expect(mockApplyPaymentLink.mock.invocationCallOrder[0]).toBeLessThan(
mockRenderToBuffer.mock.invocationCallOrder[0],
)
// QR built from the renderable copy (status overridden to 'sent').
expect(mockPaymentLinkQr).toHaveBeenCalledWith(
expect.objectContaining({ payment_link_url: 'https://pay.example/x', status: 'sent' }),
)
expect(mockInvoicePDF).toHaveBeenCalledWith(
expect.objectContaining({ paymentLinkQrDataUrl: 'data:image/png;base64,QR' }),
)
})
it('a payment link failure never blocks the send', async () => {
enqueueHappyPath()
mockApplyPaymentLink.mockResolvedValue({ failure: 'Stripe nere' })
const result = await executeRecurringSchedule(client, makeSchedule(), today)
expect(result.autoSent).toBe(true)
expect(result.warning).toBeNull()
expect(mockSendEmail).toHaveBeenCalledTimes(1)
})
it('never auto-sends from a sandbox company; invoice stays a numbered draft', async () => {
mockIsSandbox.mockResolvedValue(true)
// Sandbox bails before company_settings/payment-link/render/email, so the
// queue only covers invoice creation.
enqueue({ data: customer, error: null })
enqueue({ data: makeInsertedInvoice(), error: null })
enqueue({ data: null, error: null })
enqueue({ data: makeCompleteInvoice(), error: null })
const result = await executeRecurringSchedule(client, makeSchedule(), today)
expect(result.invoiceId).toBe('inv-1')
expect(result.autoSent).toBe(false)
expect(result.warning).toContain('Auto-utskick misslyckades')
expect(mockSendEmail).not.toHaveBeenCalled()
expect(mockApplyPaymentLink).not.toHaveBeenCalled()
expect(mockCreateJE).not.toHaveBeenCalled()
})
it('route-level suppressAutoSend skips the send path without relying on the internal chokepoint', async () => {
// Defence in depth (ASVS V2.3): the flag comes from the route's own
// isSandboxCompany resolution, so sending is suppressed even before the
// service-internal sandbox check runs. Invoice creation is unaffected.
enqueue({ data: customer, error: null })
enqueue({ data: makeInsertedInvoice(), error: null })
enqueue({ data: null, error: null })
enqueue({ data: makeCompleteInvoice(), error: null })
const result = await executeRecurringSchedule(client, makeSchedule(), today, {
suppressAutoSend: true,
})
expect(result.invoiceId).toBe('inv-1')
expect(result.autoSent).toBe(false)
expect(result.warning).toContain('Auto-utskick misslyckades')
expect(mockSendEmail).not.toHaveBeenCalled()
// The suppress branch bails before the email chokepoint entirely.
expect(mockIsSandbox).not.toHaveBeenCalled()
expect(mockCreateJE).not.toHaveBeenCalled()
})
})
@@ -19,6 +19,7 @@ import {
createInvoicePaymentJournalEntry,
createInvoiceCashEntry,
} from '@/lib/bookkeeping/invoice-entries'
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
import { settleInvoicePayment } from '@/lib/invoices/settle-invoice-payment'
import { eventBus } from '@/lib/events'
@@ -100,6 +101,152 @@ describe('settleInvoicePayment', () => {
expect(vi.mocked(createInvoicePaymentJournalEntry)).not.toHaveBeenCalled()
})
it('absorbs a sub-krona öresavrundning overshoot on SEK custom lines', async () => {
vi.mocked(findFiscalPeriod).mockResolvedValue('fp-1')
vi.mocked(createJournalEntry).mockResolvedValue({ id: 'je-ore' } as never)
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [{ id: 'inv-1' }] }) // CAS update matched
// Invoice total 1234.75, PDF "Att betala" 1235.00: the customer pays the
// rounded amount and the 3740 line carries the residual.
const invoice = payableInvoice({
total: 1234.75,
remaining_amount: 1234.75,
journal_entry_id: 'je-orig',
} as Partial<Invoice>)
const result = await settleInvoicePayment(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
{
...BASE_PARAMS,
invoice,
paymentAmountInInvoiceCurrency: 1235,
customLines: [
{ account_number: '1930', debit_amount: 1235, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 1234.75 },
{ account_number: '3740', debit_amount: 0, credit_amount: 0.25 },
],
},
)
expect(result).toMatchObject({
ok: true,
newStatus: 'paid',
newPaidAmount: 1234.75,
newRemaining: 0,
journalEntryId: 'je-ore',
})
})
it('keeps a sub-krona short partial WITHOUT a 3740 line partially paid', async () => {
vi.mocked(findFiscalPeriod).mockResolvedValue('fp-1')
vi.mocked(createJournalEntry).mockResolvedValue({ id: 'je-partial' } as never)
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [{ id: 'inv-1' }] }) // CAS update matched
// Deliberate partial: both legs lowered, no 3740. Absorbing here would
// flip the invoice to paid while 1510 keeps the 0.75 residual.
const invoice = payableInvoice({
total: 1234.75,
remaining_amount: 1234.75,
journal_entry_id: 'je-orig',
} as Partial<Invoice>)
const result = await settleInvoicePayment(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
{
...BASE_PARAMS,
invoice,
paymentAmountInInvoiceCurrency: 1234,
customLines: [
{ account_number: '1930', debit_amount: 1234, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 1234 },
],
},
)
expect(result).toMatchObject({
ok: true,
newStatus: 'partially_paid',
newPaidAmount: 1234,
newRemaining: 0.75,
})
})
it('rejects a sub-krona custom-line overshoot WITHOUT a 3740 line', async () => {
const { supabase } = createQueuedMockSupabase()
const invoice = payableInvoice({
total: 1234.75,
remaining_amount: 1234.75,
} as Partial<Invoice>)
const result = await settleInvoicePayment(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
{
...BASE_PARAMS,
invoice,
paymentAmountInInvoiceCurrency: 1235.25,
customLines: [
{ account_number: '1930', debit_amount: 1235.25, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 1235.25 },
],
},
)
expect(result).toMatchObject({ ok: false, code: 'MATCH_AMOUNT_EXCEEDS_REMAINING' })
expect(vi.mocked(createJournalEntry)).not.toHaveBeenCalled()
})
it('rejects a custom-line overshoot beyond the öre band', async () => {
const { supabase } = createQueuedMockSupabase()
const invoice = payableInvoice({
total: 1234.75,
remaining_amount: 1234.75,
} as Partial<Invoice>)
const result = await settleInvoicePayment(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
{
...BASE_PARAMS,
invoice,
paymentAmountInInvoiceCurrency: 1236,
customLines: [
{ account_number: '1930', debit_amount: 1236, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 1236 },
],
},
)
expect(result).toMatchObject({ ok: false, code: 'MATCH_AMOUNT_EXCEEDS_REMAINING' })
expect(vi.mocked(createJournalEntry)).not.toHaveBeenCalled()
})
it('does not absorb öre overshoot for non-SEK invoices', async () => {
const { supabase } = createQueuedMockSupabase()
const invoice = payableInvoice({
total: 100,
remaining_amount: 100,
currency: 'EUR',
} as Partial<Invoice>)
const result = await settleInvoicePayment(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
{
...BASE_PARAMS,
invoice,
paymentAmountInInvoiceCurrency: 100.25,
customLines: [
{ account_number: '1930', debit_amount: 100.25, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 100.25 },
],
},
)
expect(result).toMatchObject({ ok: false, code: 'MATCH_AMOUNT_EXCEEDS_REMAINING' })
})
it('rejects overpayment before creating any journal entry', async () => {
const { supabase } = createQueuedMockSupabase()
const result = await settleInvoicePayment(
+56 -5
View File
@@ -61,11 +61,18 @@ export function planInvoicePayment(
const currentRemaining =
invoice.remaining_amount ?? invoice.total - (invoice.paid_amount || 0)
// A rounded-up whole-krona payment is not an overpayment: widen the reject
// band to one krona when absorbing öre; otherwise keep the strict half-öre
// float tolerance the three legacy callers rely on.
const overshootTolerance = absorbOre ? ORE_ROUNDING_SETTLEMENT_MAX : PAYMENT_OVERSHOOT_TOLERANCE
if (paymentAmountInInvoiceCurrency > currentRemaining + overshootTolerance) {
// A rounded-up whole-krona payment is not an overpayment: when absorbing öre,
// accept only an overshoot strictly inside the settlement band. The boundary
// must be >= : with a strict > guard an exact 1 kr overshoot passed the guard
// AND missed the |diff| < 1 absorb branch, falling through to record
// paid_amount = total + 1 kr with remaining clamped to 0 (silent over-credit).
// Without absorb, keep the strict half-öre float tolerance the legacy callers
// rely on.
const overshoot = roundOre(paymentAmountInInvoiceCurrency - currentRemaining)
const isOverpayment = absorbOre
? overshoot >= ORE_ROUNDING_SETTLEMENT_MAX
: paymentAmountInInvoiceCurrency > currentRemaining + PAYMENT_OVERSHOOT_TOLERANCE
if (isOverpayment) {
return {
ok: false,
code: 'MATCH_AMOUNT_EXCEEDS_REMAINING',
@@ -109,3 +116,47 @@ export function planInvoicePayment(
},
}
}
/** BAS öres- och kronutjämning: the only account that may carry an absorbed residual. */
const ORE_ROUNDING_ACCOUNT = '3740'
/**
* `planInvoicePayment` for caller-supplied booking lines (the mark-paid
* dialog and the v1 API), where the server does NOT build the verifikat.
*
* Absorbing an öre residual is only safe when the lines actually book it:
* in the server-built bank-match flow `buildInvoicePaymentClearingLines`
* guarantees 1510 is credited the full remaining and 3740 carries the exact
* residual, so plan and GL absorb together. Here the lines are caller-owned,
* so absorption is granted only when the net 3740 amount (debit − credit)
* equals the signed residual (remaining − payment). Otherwise fall back to
* the strict plan: a sub-krona short payment stays a real partial and a
* sub-krona overshoot is rejected, exactly as before absorption existed.
* Without this gate an invoice could flip to paid while the posted lines
* under-clear 1510, diverging the GL from the AR sub-ledger.
*/
export function planInvoicePaymentForLines(
invoice: InvoicePaymentTotals,
paymentAmountInInvoiceCurrency: number,
lines:
| Array<{ account_number: string; debit_amount: number; credit_amount: number }>
| undefined,
invoiceCurrency: string,
): PlanInvoicePaymentResult {
const absorbEligible = !!lines && invoiceCurrency === 'SEK'
const payment = planInvoicePayment(invoice, paymentAmountInInvoiceCurrency, {
absorbOreRounding: absorbEligible,
})
if (!absorbEligible || !payment.ok || !payment.plan.oreSettled) return payment
const currentRemaining =
invoice.remaining_amount ?? invoice.total - (invoice.paid_amount || 0)
const residual = roundOre(currentRemaining - paymentAmountInInvoiceCurrency)
const net3740 = roundOre(
lines!
.filter((l) => l.account_number === ORE_ROUNDING_ACCOUNT)
.reduce((s, l) => s + l.debit_amount - l.credit_amount, 0),
)
if (net3740 === residual) return payment
return planInvoicePayment(invoice, paymentAmountInInvoiceCurrency)
}
+62 -2
View File
@@ -19,10 +19,16 @@ import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
import {
prepareInvoicePdfRender,
buildSwishQrDataUrl,
buildPaymentLinkQrDataUrl,
} from '@/lib/invoices/pdf-render-helpers'
import { applyPaymentLinkToInvoice } from '@/lib/extensions/payment-links'
import { getEmailService } from '@/lib/email/service'
import { hasCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { isSandboxCompany } from '@/lib/sandbox/guard'
import {
generateInvoiceEmailHtml,
generateInvoiceEmailText,
@@ -138,6 +144,17 @@ export function getStockholmDateHour(instant: Date): { date: string; hour: numbe
}
}
export interface ExecuteScheduleOptions {
/**
* Defence-in-depth sandbox suppression (ASVS V2.3): callers that resolved
* `isSandboxCompany` at the route level pass true to skip the auto-send
* path outright, so the sandbox invariant does not hinge solely on the
* chokepoint inside sendInvoiceFromSchedule. Freeze-and-retain semantics
* are unchanged: the invoice is still created as a numbered draft.
*/
suppressAutoSend?: boolean
}
/**
* Spawn one invoice from a schedule. Always creates the invoice; auto_send
* additionally renders + emails + flips status + creates JE + archives PDF.
@@ -149,6 +166,7 @@ export async function executeRecurringSchedule(
supabase: SupabaseClient,
schedule: RecurringInvoiceSchedule & { items: RecurringInvoiceScheduleItem[] },
today: Date = new Date(),
options: ExecuteScheduleOptions = {},
): Promise<ExecuteResult> {
const opLog = log.child({ scheduleId: schedule.id, companyId: schedule.company_id })
@@ -329,7 +347,15 @@ export async function executeRecurringSchedule(
// 9. Auto-send path. If anything below fails, we keep the invoice (now a
// numbered draft) and surface a Swedish warning on the schedule: the
// user can manually send from /invoices/[id].
if (schedule.auto_send) {
if (schedule.auto_send && options.suppressAutoSend) {
// Route-level sandbox suppression: same outcome as the internal sandbox
// chokepoint below (no email, invoice retained as draft, manual-send
// warning), reached without entering the send path at all.
opLog.warn('auto-send suppressed by route-level sandbox guard', {
invoiceId: invoice.id,
})
warning = 'Auto-utskick misslyckades: fakturan finns som utkast och kan skickas manuellt.'
} else if (schedule.auto_send) {
try {
autoSent = await sendInvoiceFromSchedule(
supabase,
@@ -386,6 +412,18 @@ async function sendInvoiceFromSchedule(
})
return false
}
// The sandbox must never deliver a real email to a real address. The
// interactive send routes enforce this with guardSandbox, but cron and
// run-now reach this function without any route-level guard, so the
// invariant is enforced here at the email chokepoint. Freeze-and-retain
// like the paywall path below: the invoice is still generated as a draft.
if (await isSandboxCompany(supabase, companyId)) {
log.warn('sandbox company; recurring schedule cannot auto-send', {
invoiceId: invoice.id,
companyId,
})
return false
}
// Paywall: email sending is a paid capability. The invoice itself is still
// created (bookkeeping stays free); it just isn't emailed, and the schedule
// surfaces the standard manual-send warning (freeze-and-retain).
@@ -416,11 +454,32 @@ async function sendInvoiceFromSchedule(
const items = (invoice.items || []).slice().sort((a, b) => a.sort_order - b.sort_order)
// Auto-create an online payment link (extension-provided, e.g. Stripe) so
// the email button and PDF QR carry it: parity with the manual and v1 send
// routes. Best-effort: the faktura is legally valid without a link, so a
// failure only logs and the send proceeds. On success the helper mirrors
// payment_link_url onto this invoice object, which the email template and
// QR builder below read.
const { failure: paymentLinkFailure } = await applyPaymentLinkToInvoice(
supabase,
companyId,
userId,
invoice,
log,
)
if (paymentLinkFailure) {
log.warn('payment link creation failed for recurring invoice; sending without it', {
invoiceId: invoice.id,
reason: paymentLinkFailure,
})
}
// Render PDF with status overridden to 'sent' so the customer doesn't
// receive a "UTKAST" stamp.
const renderableInvoice = { ...invoice, status: 'sent' as const }
const { branding, company: renderCompany } = await prepareInvoicePdfRender(company)
const swishQrDataUrl = await buildSwishQrDataUrl(company, renderableInvoice)
const paymentLinkQrDataUrl = await buildPaymentLinkQrDataUrl(renderableInvoice)
const pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: renderableInvoice,
@@ -429,6 +488,7 @@ async function sendInvoiceFromSchedule(
company: renderCompany,
branding,
swishQrDataUrl,
paymentLinkQrDataUrl,
}),
)
+14 -2
View File
@@ -7,7 +7,7 @@ import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { resolveInvoicePaymentSourceType } from '@/lib/bookkeeping/propose-payment-lines'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
import { planInvoicePayment } from '@/lib/invoices/apply-invoice-payment'
import { planInvoicePaymentForLines } from '@/lib/invoices/apply-invoice-payment'
import { eventBus } from '@/lib/events'
import type { CreateJournalEntryInput, Customer, EntityType, Invoice } from '@/types'
@@ -116,7 +116,19 @@ export async function settleInvoicePayment(
// Ledger math + overpayment guard. Runs BEFORE any journal entry is
// created so a doomed overpayment never burns a voucher number.
const payment = planInvoicePayment(invoice, paymentAmountInInvoiceCurrency)
// Custom-line SEK settlements absorb a sub-krona öresavrundning residual
// (customer paid the rounded "Att betala" from the PDF, up to 1 kr off the
// stored öre total) ONLY when the lines actually carry the residual on
// 3740, mirroring the bank-transaction match flow; lines that don't (e.g.
// a deliberate sub-krona partial) get the strict plan instead. The
// generated-entry paths (Stripe sync, no-body mark-paid) always pay the
// exact remaining, so absorption is a no-op there.
const payment = planInvoicePaymentForLines(
invoice,
paymentAmountInInvoiceCurrency,
customLines,
invoice.currency,
)
if (!payment.ok) {
return {
ok: false,
@@ -0,0 +1,437 @@
/**
* Executor tests for the payroll gap-closure pending operations:
* update_payslip_line + register_absence (1.7).
*
* Executors are private to commit.ts and reached through
* commitPendingOperation (same pattern as dimension-value-executor.test.ts).
* Staging-side coverage lives in
* extensions/general/mcp-server/__tests__/payroll-staged-tools.test.ts.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { eventBus } from '@/lib/events'
import type { PendingOperation } from '@/types'
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
createServiceClient: vi.fn(),
}))
const mockCloseYear = vi.fn()
vi.mock('@/lib/salary/semesterberedning', () => ({
previewVacationYearClose: vi.fn(),
commitVacationYearClose: (...a: unknown[]) => mockCloseYear(...a),
}))
import { commitPendingOperation } from '../commit'
function makePendingOp(overrides: Partial<PendingOperation>): PendingOperation {
return {
id: 'op-1',
user_id: 'user-1',
company_id: 'company-1',
operation_type: 'update_payslip_line',
status: 'pending',
title: 'test',
params: {},
preview_data: {},
result_data: null,
actor_type: 'user',
actor_id: null,
actor_label: null,
risk_level: 'medium',
created_at: '2026-07-13T00:00:00Z',
resolved_at: null,
updated_at: '2026-07-13T00:00:00Z',
...overrides,
} as PendingOperation
}
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
describe('commitPendingOperation: update_payslip_line', () => {
const LINE_ROW = {
id: 'line-1',
salary_run_employee_id: 'sre-1',
company_id: 'company-1',
item_type: 'bonus',
description: 'Kvartalsbonus',
quantity: null,
unit_price: null,
amount: 5000,
is_taxable: true,
is_avgift_basis: true,
is_vacation_basis: true,
is_gross_deduction: false,
is_net_deduction: false,
account_number: '7210',
sort_order: 0,
created_at: '',
updated_at: '',
salary_run_employee: { salary_run_id: 'run-1' },
}
it('applies the patch through the shared service (happy path)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: { id: 'run-1', status: 'draft' } }) // service draft gate
enqueue({ data: LINE_ROW }) // service loadLineInRun
enqueue({ data: { ...LINE_ROW, amount: 5500, salary_run_employee: undefined } }) // update
enqueue({ data: null, error: null }) // finalize
const op = makePendingOp({
params: { salary_run_id: 'run-1', salary_line_item_id: 'line-1', patch: { amount: 5500 } },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({
salary_line_item_id: 'line-1',
salary_run_id: 'run-1',
amount: 5500,
})
})
it('fails cleanly when the run advanced between staging and approval', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: { id: 'run-1', status: 'approved' } }) // draft gate trips
enqueue({ data: null, error: null }) // finalize (failed)
const op = makePendingOp({
params: { salary_run_id: 'run-1', salary_line_item_id: 'line-1', patch: { amount: 5500 } },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.error).toMatch(/utkast/)
})
it('rejects an empty patch with 400', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // finalize (failed)
const op = makePendingOp({
params: { salary_run_id: 'run-1', salary_line_item_id: 'line-1', patch: {} },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
})
})
describe('commitPendingOperation: register_absence', () => {
it('upserts the expanded range through the shared service (happy path)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: { id: 'emp-1' } }) // service assertEmployee
enqueue({
data: [
{ id: 'a1', absence_date: '2026-03-02', absence_type: 'sick', hours: 8, notes: null, salary_run_employee_id: null, created_at: '', updated_at: '' },
{ id: 'a2', absence_date: '2026-03-03', absence_type: 'sick', hours: 8, notes: null, salary_run_employee_id: null, created_at: '', updated_at: '' },
],
}) // bulk upsert
enqueue({ data: null, error: null }) // finalize
const op = makePendingOp({
operation_type: 'register_absence',
params: {
employee_id: 'emp-1',
from: '2026-03-02',
to: '2026-03-03',
absence_type: 'sick',
hours_per_day: 8,
notes: null,
include_weekends: false,
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({
employee_id: 'emp-1',
absence_type: 'sick',
day_count: 2,
})
})
it('maps the 24h-cap trigger to a clean 409 failure', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: { id: 'emp-1' } }) // assertEmployee
enqueue({ data: null, error: { code: '23514', message: 'Total tid över 24h' } }) // upsert trips trigger
enqueue({ data: null, error: null }) // finalize (failed)
const op = makePendingOp({
operation_type: 'register_absence',
params: { employee_id: 'emp-1', from: '2026-03-02', to: '2026-03-02', absence_type: 'sick' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
// 409 is a client-state conflict: the dispatcher rejects the op (fix the
// day's hours and re-stage) rather than marking it transiently failed.
expect(result.status).toBe('rejected')
expect(result.http_status).toBe(409)
})
})
describe('commitPendingOperation: create_employee', () => {
it('inserts via the shared service with the pre-encrypted personnummer', async () => {
const { encryptPersonnummer } = await import('@/lib/salary/personnummer')
const encrypted = encryptPersonnummer('190001010000')
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: { entity_type: 'ab' } }) // getCompanyEntityType (company_settings)
enqueue({
data: {
id: 'emp-new',
first_name: 'Anna',
last_name: 'Andersson',
personnummer: encrypted,
is_active: true,
},
}) // insert
enqueue({ data: null, error: null }) // finalize
const op = makePendingOp({
operation_type: 'create_employee',
params: {
first_name: 'Anna',
last_name: 'Andersson',
personnummer_encrypted: encrypted,
personnummer_last4: '0000',
employment_type: 'employee',
employment_start: '2026-01-15',
salary_type: 'monthly',
monthly_salary: 35000,
tax_table_number: 33,
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({
employee_id: 'emp-new',
personnummer_masked: '19000101-XXXX',
})
// Result data never carries the plaintext or ciphertext.
expect(JSON.stringify(result.data)).not.toContain('190001010000')
expect(JSON.stringify(result.data)).not.toContain(encrypted)
})
it('maps duplicate personnummer to a clean rejection', async () => {
const { encryptPersonnummer } = await import('@/lib/salary/personnummer')
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: { entity_type: 'ab' } }) // entity type
enqueue({
data: null,
error: { code: '23505', message: 'duplicate', constraint: 'employees_company_id_personnummer_key' },
}) // insert conflict
enqueue({ data: null, error: null }) // finalize
const op = makePendingOp({
operation_type: 'create_employee',
params: {
first_name: 'Anna',
last_name: 'Andersson',
personnummer_encrypted: encryptPersonnummer('190001010000'),
personnummer_last4: '0000',
employment_start: '2026-01-15',
salary_type: 'monthly',
monthly_salary: 35000,
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('rejected')
expect(result.http_status).toBe(409)
})
})
describe('commitPendingOperation: update_employee', () => {
it('applies the patch through merged-state validation (happy path)', async () => {
const { encryptPersonnummer } = await import('@/lib/salary/personnummer')
const encrypted = encryptPersonnummer('190001010000')
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({
data: {
id: 'emp-1',
first_name: 'Anna',
last_name: 'Andersson',
personnummer: encrypted,
salary_type: 'monthly',
monthly_salary: 35000,
tax_table_number: 33,
is_sidoinkomst: false,
f_skatt_status: 'a_skatt',
vaxa_stod_eligible: false,
jamkning_percentage: null,
is_active: true,
},
}) // fetch existing
enqueue({
data: {
id: 'emp-1',
first_name: 'Anna',
last_name: 'Andersson',
personnummer: encrypted,
is_active: true,
},
}) // update
enqueue({ data: null, error: null }) // finalize
const op = makePendingOp({
operation_type: 'update_employee',
params: { employee_id: 'emp-1', patch: { monthly_salary: 38000 } },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({ employee_id: 'emp-1' })
})
it('upserts opening balances atomically (set_employee_opening_balances)', async () => {
const CURRENT_YEAR = new Date().getFullYear()
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: [{ id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', employment_start: '2024-01-15', is_active: true }] }) // employees
enqueue({ data: [] }) // locks
enqueue({ data: [] }) // existing created_by lookup
enqueue({
data: [
{
id: 'ob-1',
employee_id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
cutover_date: `${CURRENT_YEAR}-07-01`,
ytd_gross: 210000,
ytd_tax: 48000,
ytd_net: 162000,
vacation_paid_days_remaining: 12.5,
vacation_saved_days_by_year: {},
opening_semester_liability: 42000,
opening_semester_liability_avgifter: 13196.4,
karens_periods_adjustment: 1,
created_at: '',
updated_at: '',
},
],
}) // upsert
enqueue({ data: null, error: null }) // finalize
const op = makePendingOp({
operation_type: 'set_employee_opening_balances',
params: {
items: [
{
employee_id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
cutover_date: `${CURRENT_YEAR}-07-01`,
ytd_gross: 210000,
ytd_tax: 48000,
ytd_net: 162000,
vacation_paid_days_remaining: 12.5,
opening_semester_liability: 42000,
opening_semester_liability_avgifter: 13196.4,
karens_periods_adjustment: 1,
},
],
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({
employee_count: 1,
employee_opening_balances_ids: ['ob-1'],
})
})
it('closes the vacation year through the shared service (vacation_year_close)', async () => {
mockCloseYear.mockResolvedValue({
ok: true,
data: {
closure_id: 'closure-1',
adjustment_entry_id: 'je-1',
report: { rows: [{ employee_id: 'emp-1' }], sek: { drift_2920: 8690.84 } },
},
})
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // finalize
const op = makePendingOp({
operation_type: 'vacation_year_close',
params: { vacation_year_start: '2025-01-01', book_adjustment: true },
risk_level: 'high',
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({
vacation_year_closure_id: 'closure-1',
adjustment_entry_id: 'je-1',
employee_count: 1,
})
expect(mockCloseYear).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
'2025-01-01',
{ bookAdjustment: true },
)
})
it('rejects a vacation_year_close replay cleanly', async () => {
mockCloseYear.mockResolvedValue({ ok: false, code: 'VACATION_YEAR_ALREADY_CLOSED' })
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // finalize
const op = makePendingOp({
operation_type: 'vacation_year_close',
params: { vacation_year_start: '2025-01-01' },
risk_level: 'high',
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('rejected')
expect(result.http_status).toBe(409)
})
it('fails merged validation when clearing salary below zero-state', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({
data: {
id: 'emp-1',
first_name: 'Anna',
last_name: 'Andersson',
salary_type: 'monthly',
monthly_salary: 35000,
tax_table_number: 33,
is_sidoinkomst: false,
f_skatt_status: 'a_skatt',
vaxa_stod_eligible: false,
jamkning_percentage: null,
},
}) // fetch existing
enqueue({ data: null, error: null }) // finalize
const op = makePendingOp({
operation_type: 'update_employee',
params: { employee_id: 'emp-1', patch: { monthly_salary: 0 } },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).not.toBe('committed')
expect(result.error).toMatch(/Månadslön/)
})
})
+264
View File
@@ -3384,6 +3384,252 @@ async function commitGenerateAgi(
}
}
async function commitUpdatePayslipLine(
supabase: SupabaseClient,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
const salaryRunId = params.salary_run_id as string
const lineId = params.salary_line_item_id as string
const patch = params.patch as Record<string, unknown> | undefined
if (!salaryRunId || !lineId || !patch || Object.keys(patch).length === 0) {
return { error: 'salary_run_id, salary_line_item_id and patch are required', status: 400 }
}
try {
const { updatePayslipLine } = await import('@/lib/salary/payslip-lines')
const { getErrorEntry } = await import('@/lib/errors/structured-errors')
const result = await updatePayslipLine(supabase, {
companyId,
salaryRunId,
lineId,
patch: patch as never,
})
if (!result.ok) {
const entry = getErrorEntry(result.code)
return {
error: entry?.message_sv ?? `Kunde inte uppdatera lönebeskedsraden: ${result.code}`,
status: entry?.httpStatus ?? 500,
}
}
return {
data: {
salary_line_item_id: lineId,
salary_run_id: salaryRunId,
item_type: result.data.item_type,
description: result.data.description,
amount: result.data.amount,
},
}
} catch (err) {
return {
error: err instanceof Error ? err.message : 'Failed to update payslip line',
status: 500,
}
}
}
async function commitCreateEmployee(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
try {
const { createEmployee } = await import('@/lib/salary/employee-commands')
const { getErrorEntry } = await import('@/lib/errors/structured-errors')
const result = await createEmployee(supabase, {
companyId,
userId,
input: params as never,
})
if (!result.ok) {
const entry = getErrorEntry(result.code)
const detailMessage =
(result.details?.message as string | undefined) ?? entry?.message_sv
return {
error: detailMessage ?? `Kunde inte skapa anställd: ${result.code}`,
status: entry?.httpStatus ?? 500,
}
}
return { data: { ...result.data } }
} catch (err) {
return {
error: err instanceof Error ? err.message : 'Failed to create employee',
status: 500,
}
}
}
async function commitUpdateEmployee(
supabase: SupabaseClient,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
const employeeId = params.employee_id as string
const patch = params.patch as Record<string, unknown> | undefined
if (!employeeId || !patch) {
return { error: 'employee_id and patch are required', status: 400 }
}
try {
const { updateEmployee } = await import('@/lib/salary/employee-commands')
const { getErrorEntry } = await import('@/lib/errors/structured-errors')
const result = await updateEmployee(supabase, { companyId, employeeId, patch })
if (!result.ok) {
const entry = getErrorEntry(result.code)
const detailMessage =
(result.details?.message as string | undefined) ?? entry?.message_sv
return {
error: detailMessage ?? `Kunde inte uppdatera anställd: ${result.code}`,
status: entry?.httpStatus ?? 500,
}
}
return { data: { ...result.data } }
} catch (err) {
return {
error: err instanceof Error ? err.message : 'Failed to update employee',
status: 500,
}
}
}
async function commitRegisterAbsence(
supabase: SupabaseClient,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
const employeeId = params.employee_id as string
const from = params.from as string
const to = params.to as string
const absenceType = params.absence_type as string
if (!employeeId || !from || !to || !absenceType) {
return { error: 'employee_id, from, to and absence_type are required', status: 400 }
}
try {
const { upsertAbsenceRange } = await import('@/lib/salary/absence')
const { getErrorEntry } = await import('@/lib/errors/structured-errors')
const result = await upsertAbsenceRange(supabase, {
companyId,
employeeId,
from,
to,
absenceType,
hoursPerDay: (params.hours_per_day as number | undefined) ?? 8,
notes: (params.notes as string | null | undefined) ?? null,
includeWeekends: (params.include_weekends as boolean | undefined) ?? false,
})
if (!result.ok) {
const entry = getErrorEntry(result.code)
return {
error: entry?.message_sv ?? `Kunde inte registrera frånvaron: ${result.code}`,
status: entry?.httpStatus ?? 500,
}
}
return {
data: {
employee_id: employeeId,
absence_type: absenceType,
from,
to,
day_count: result.data.count,
},
}
} catch (err) {
return {
error: err instanceof Error ? err.message : 'Failed to register absence',
status: 500,
}
}
}
async function commitSetEmployeeOpeningBalances(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
const items = params.items as Array<Record<string, unknown>> | undefined
if (!items || !Array.isArray(items) || items.length === 0) {
return { error: 'items are required', status: 400 }
}
try {
const { OpeningBalancesBulkSchema } = await import('@/lib/api/schemas')
const parsed = OpeningBalancesBulkSchema.safeParse({ items })
if (!parsed.success) {
return { error: 'Ogiltiga ingående saldon i den godkända operationen', status: 400 }
}
const { setOpeningBalancesBulk } = await import('@/lib/salary/opening-balances')
const { getErrorEntry } = await import('@/lib/errors/structured-errors')
const result = await setOpeningBalancesBulk(supabase, {
companyId,
userId,
items: parsed.data.items,
})
if (!result.ok) {
const itemSummary = result.itemErrors
?.map((e) => `${e.employee_id}: ${e.message}`)
.join('; ')
const entry = getErrorEntry(result.code)
return {
error: itemSummary ?? entry?.message_sv ?? `Kunde inte spara ingående saldon: ${result.code}`,
status: entry?.httpStatus ?? 400,
}
}
return {
data: {
employee_count: result.data.count,
employee_opening_balances_ids: result.data.rows.map((r) => r.employee_opening_balances_id),
},
}
} catch (err) {
return {
error: err instanceof Error ? err.message : 'Failed to set opening balances',
status: 500,
}
}
}
async function commitVacationYearClose(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
const yearStart = params.vacation_year_start as string
if (!yearStart) return { error: 'vacation_year_start is required', status: 400 }
const bookAdjustment = params.book_adjustment !== false
try {
const { commitVacationYearClose: runClose } = await import('@/lib/salary/semesterberedning')
const { getErrorEntry } = await import('@/lib/errors/structured-errors')
const result = await runClose(supabase, companyId, userId, yearStart, { bookAdjustment })
if (!result.ok) {
const entry = getErrorEntry(result.code)
return {
error: entry?.message_sv ?? `Semesterårsavslutet misslyckades: ${result.code}`,
status: entry?.httpStatus ?? 500,
}
}
return {
data: {
vacation_year_closure_id: result.data.closure_id,
adjustment_entry_id: result.data.adjustment_entry_id,
vacation_year_start: yearStart,
employee_count: result.data.report.rows.length,
drift_2920: result.data.report.sek.drift_2920,
},
}
} catch (err) {
return {
error: err instanceof Error ? err.message : 'Failed to close vacation year',
status: 500,
}
}
}
// ── Skatteverket filing commit handlers (PR5) ─────────────────────
//
// Core cannot import @/extensions (CI guard), so these reach the Skatteverket
@@ -3875,6 +4121,24 @@ async function commitPendingOperationInner(
case 'generate_agi':
result = await commitGenerateAgi(supabase, userId, companyId, pendingOp.params)
break
case 'update_payslip_line':
result = await commitUpdatePayslipLine(supabase, companyId, pendingOp.params)
break
case 'register_absence':
result = await commitRegisterAbsence(supabase, companyId, pendingOp.params)
break
case 'create_employee':
result = await commitCreateEmployee(supabase, userId, companyId, pendingOp.params)
break
case 'update_employee':
result = await commitUpdateEmployee(supabase, companyId, pendingOp.params)
break
case 'set_employee_opening_balances':
result = await commitSetEmployeeOpeningBalances(supabase, userId, companyId, pendingOp.params)
break
case 'vacation_year_close':
result = await commitVacationYearClose(supabase, userId, companyId, pendingOp.params)
break
case 'match_batch_allocate':
result = await commitMatchBatchAllocate(supabase, companyId, pendingOp.params)
break
+21
View File
@@ -116,6 +116,27 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
// staged.
create_salary_run: 'medium',
generate_agi: 'high',
// Payslip line edits are draft-run-only (BFL: once the run advances its
// numbers feed a verifikation) and re-editable until then, but they change
// a pay outcome: human review at medium, never silent.
update_payslip_line: 'medium',
// Absence rows drive sjuklön math and the statutory AGI Frånvarouppgift.
// Reversible via delete, but not audit-free: medium.
register_absence: 'medium',
// Employee master data carries PII (personnummer, encrypted at staging
// time: pending_operations.params never holds the plaintext) plus bank
// payment-routing fields: same BEC rationale as create_supplier.
create_employee: 'medium',
update_employee: 'medium',
// Cutover state for mid-year migrations (YTD, vacation balances, karens
// adjustment). Editable until the employee has a booked run; wrong values
// skew payslips and the vacation-liability report, so human review.
set_employee_opening_balances: 'medium',
// Semesterårsavslut: closes every employee's vacation year, rolls sparade
// dagar (5-year expiry -> forced payout), and may post a 2920/2940
// adjustment verifikation. Irreversible in practice (no reopen flow):
// never auto-committed.
vacation_year_close: 'high',
// ── Multi-tx flows (PRs #603/#606/#608/#610) ───────────────────────
// Allocate 1 bank tx across N customer or supplier invoices into one
@@ -0,0 +1,179 @@
/**
* Vacation-liability report: cutover opening-balance terms
* (payroll gap-closure 2.2).
*
* A mid-year switcher's semesterlöneskuld arrived via SIE opening balances
* on 2920/2940; the per-employee report must include the opening SEK terms,
* start remaining days from the imported balance, and add saved-days from
* the origin-year map.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { generateVacationLiability } from '@/lib/reports/vacation-liability'
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const EMPLOYEE = {
id: EMPLOYEE_ID,
first_name: 'Anna',
last_name: 'Andersson',
personnummer_last4: '0000',
vacation_rule: 'procentregeln',
vacation_days_per_year: 25,
vacation_days_saved: 0,
}
let mock: ReturnType<typeof createQueuedMockSupabase>
let supabase: SupabaseClient
beforeEach(() => {
vi.clearAllMocks()
mock = createQueuedMockSupabase()
supabase = mock.supabase as unknown as SupabaseClient
})
describe('generateVacationLiability with opening balances', () => {
it('adds opening SEK terms and starts days from the imported balance', async () => {
mock.enqueue({ data: [EMPLOYEE] }) // employees page
mock.enqueue({
data: [
{
employee_id: EMPLOYEE_ID,
vacation_accrual: 4200,
vacation_accrual_avgifter: 1319.64,
avgifter_rate: 0.3142,
vacation_days_taken: 3,
salary_run: { period_year: 2026, status: 'booked' },
},
],
}) // booked sre page
mock.enqueue({ data: [] }) // vacation ledger (none yet)
mock.enqueue({
data: [
{
employee_id: EMPLOYEE_ID,
cutover_date: '2026-07-01',
vacation_paid_days_remaining: 12.5,
vacation_saved_days_by_year: { '2025': 5, '2024': 2 },
opening_semester_liability: 42000,
opening_semester_liability_avgifter: 13196.4,
},
],
}) // opening balances
const report = await generateVacationLiability(supabase, COMPANY_ID, 2026)
expect(report.rows).toHaveLength(1)
const row = report.rows[0]
// Opening SEK + in-system accrual.
expect(row.accruedAmount).toBe(46200)
expect(row.accruedAvgifter).toBe(14516.04)
expect(row.totalLiability).toBe(60716.04)
// Remaining starts from the imported 12.5, not the 25-day entitlement.
expect(row.vacationDaysRemaining).toBe(9.5)
// Saved days: master-row 0 + origin-year map 5 + 2.
expect(row.vacationDaysSaved).toBe(7)
expect(report.totals.totalLiability).toBe(60716.04)
})
it('ignores opening rows for report years before the cutover year', async () => {
mock.enqueue({ data: [EMPLOYEE] })
mock.enqueue({ data: [] }) // no booked runs in 2025
mock.enqueue({ data: [] }) // vacation ledger
mock.enqueue({
data: [
{
employee_id: EMPLOYEE_ID,
cutover_date: '2026-07-01',
vacation_paid_days_remaining: 12.5,
vacation_saved_days_by_year: {},
opening_semester_liability: 42000,
opening_semester_liability_avgifter: 13196.4,
},
],
})
const report = await generateVacationLiability(supabase, COMPANY_ID, 2025)
expect(report.rows[0].accruedAmount).toBe(0)
expect(report.rows[0].vacationDaysRemaining).toBe(25)
})
it('is a no-op for companies without opening rows', async () => {
mock.enqueue({ data: [EMPLOYEE] })
mock.enqueue({
data: [
{
employee_id: EMPLOYEE_ID,
vacation_accrual: 4200,
vacation_accrual_avgifter: 1319.64,
avgifter_rate: 0.3142,
vacation_days_taken: 0,
salary_run: { period_year: 2026, status: 'booked' },
},
],
})
mock.enqueue({ data: [] }) // vacation ledger
mock.enqueue({ data: [] }) // no opening rows
const report = await generateVacationLiability(supabase, COMPANY_ID, 2026)
expect(report.rows[0].accruedAmount).toBe(4200)
expect(report.rows[0].vacationDaysRemaining).toBe(25)
expect(report.rows[0].vacationDaysSaved).toBe(0)
})
it('prefers the vacation ledger for DAYS when a row exists (v2)', async () => {
mock.enqueue({ data: [EMPLOYEE] })
mock.enqueue({
data: [
{
employee_id: EMPLOYEE_ID,
vacation_accrual: 4200,
vacation_accrual_avgifter: 1319.64,
avgifter_rate: 0.3142,
// The sre says 3 taken, but the ledger (recomputed, incl. cutover
// seed) is authoritative for days.
vacation_days_taken: 3,
salary_run: { period_year: 2026, status: 'booked' },
},
],
})
mock.enqueue({
data: [
{
employee_id: EMPLOYEE_ID,
vacation_year_start: '2026-01-01',
entitled_days: 12.5,
taken_days: 4,
saved_days: { '2025': 5, '2024': 2 },
},
],
}) // vacation ledger row wins for days
mock.enqueue({
data: [
{
employee_id: EMPLOYEE_ID,
cutover_date: '2026-07-01',
vacation_paid_days_remaining: 12.5,
vacation_saved_days_by_year: { '2025': 5 },
opening_semester_liability: 42000,
opening_semester_liability_avgifter: 13196.4,
},
],
})
const report = await generateVacationLiability(supabase, COMPANY_ID, 2026)
const row = report.rows[0]
// Days come from the ledger, not entitled-minus-sre-taken.
expect(row.vacationDaysEntitled).toBe(12.5)
expect(row.vacationDaysTaken).toBe(4)
expect(row.vacationDaysRemaining).toBe(8.5)
expect(row.vacationDaysSaved).toBe(7)
// SEK still derives from runs + opening terms.
expect(row.accruedAmount).toBe(46200)
})
})
File diff suppressed because it is too large Load Diff
+75 -106
View File
@@ -2,19 +2,20 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { buildVatSettlementProposal } from '../vat-settlement'
// ============================================================
// Mock: results routed by table + applied filters (the builder runs its two
// ledger queries and the existing-entries lookup concurrently, so a
// sequential result queue would be order-fragile).
// Mock: fetchVatAccountTotals now goes through the
// get_vat_declaration_totals RPC (aggregation + settlement-shape detection
// in SQL, verified by tests/pg/vat-declaration-totals-rpc.pg.test.ts), so
// the mock seeds the RPC payload directly: per-account totals as the SQL
// GROUP BY returns them (already excluding settlement entries) plus the
// shaped entries the RPC surfaces. The existing-entries lookup and the
// fiscal-period resolution still go through from().
// ============================================================
interface MockData {
/**
* journal_entries rows for the entry-scope query (fetchEntryLines step 1).
* Shape-detection reads status/entry_date/source_type/voucher_* off these.
*/
entries?: Array<Record<string, unknown>>
/** journal_entry_lines rows (fetchEntryLines step 2). */
lines?: Array<Record<string, unknown>>
/** Per-account totals as returned by the RPC (post settlement-exclusion). */
totals?: Array<{ account_number: string; debit: number; credit: number }>
/** Settlement-shaped entries surfaced by the RPC. */
shaped?: Array<Record<string, unknown>>
/** Existing vat_settlement entries in the period. */
existing?: Array<Record<string, unknown>>
/** Error returned by the existing-settlement lookup. */
@@ -23,73 +24,59 @@ interface MockData {
fiscalPeriod?: { period_start: string; period_end: string } | null
}
let neqCalls: Array<[string, unknown]>
let rpcCalls: Array<{ fn: string; params: Record<string, unknown> }>
function makeClient(data: MockData) {
neqCalls = []
rpcCalls = []
return {
from: vi.fn().mockImplementation((table: string) => {
const eqCalls: Array<[string, unknown]> = []
rpc: vi.fn().mockImplementation(async (fn: string, params: Record<string, unknown>) => {
rpcCalls.push({ fn, params })
return {
data: {
totals: data.totals ?? [],
settlement_shaped_entries: data.shaped ?? [],
source_type_counts: {},
},
error: null,
}
}),
from: vi.fn().mockImplementation(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const b: Record<string, any> = {}
for (const m of ['select', 'in', 'gte', 'lte', 'order', 'range', 'limit']) {
for (const m of ['select', 'eq', 'neq', 'in', 'gte', 'lte', 'order', 'range', 'limit']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.eq = vi.fn().mockImplementation((col: string, val: unknown) => {
eqCalls.push([col, val])
return b
})
b.neq = vi.fn().mockImplementation((col: string, val: unknown) => {
neqCalls.push([col, val])
return b
})
b.maybeSingle = vi.fn().mockResolvedValue({ data: data.fiscalPeriod ?? null, error: null })
b.then = (resolve: (v: unknown) => void) => {
if (table === 'journal_entry_lines') return resolve({ data: data.lines ?? [], error: null })
// journal_entries serves two queries: the entry scope for the ledger
// totals (filters vat_settlement OUT via .neq) and the tagged
// existing-settlement lookup (filters it IN via .eq).
if (eqCalls.some(([col, val]) => col === 'source_type' && val === 'vat_settlement')) {
return resolve(
data.existingError
? { data: null, error: data.existingError }
: { data: data.existing ?? [], error: null },
)
}
return resolve({ data: data.entries ?? [], error: null })
}
// The only awaited from() query left in the proposal builder is the
// tagged existing-settlement lookup.
b.then = (resolve: (v: unknown) => void) =>
resolve(
data.existingError
? { data: null, error: data.existingError }
: { data: data.existing ?? [], error: null },
)
return b
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
}
let lineId = 0
function vatLine(account: string, debit: number, credit: number, entryId = 'e1') {
lineId += 1
return {
id: `l${lineId}`,
journal_entry_id: entryId,
account_number: account,
debit_amount: debit,
credit_amount: credit,
}
function total(account_number: string, debit: number, credit: number) {
return { account_number, debit, credit }
}
beforeEach(() => {
vi.clearAllMocks()
lineId = 0
})
describe('buildVatSettlementProposal', () => {
it('clears the 26xx accounts, books the filed whole-krona net on 2650 and the öre gap on 3740', async () => {
const supabase = makeClient({
entries: [{ id: 'e1' }],
lines: [
vatLine('2611', 0, 2500.75),
vatLine('2641', 1000.5, 0),
totals: [
total('2611', 0, 2500.75),
total('2641', 1000.5, 0),
// Revenue feeds ruta05 but is never part of the settlement entry.
vatLine('3001', 0, 10003.0),
total('3001', 0, 10003.0),
],
})
@@ -123,16 +110,19 @@ describe('buildVatSettlementProposal', () => {
expect(debits).toBeCloseTo(credits, 2)
// The projection must ignore already-booked settlements, or booking once
// would change the next proposal.
expect(neqCalls).toContainEqual(['source_type', 'vat_settlement'])
// would change the next proposal: the RPC receives the settlement net
// accounts so it can shape-detect and exclude them.
expect(rpcCalls).toHaveLength(1)
expect(rpcCalls[0].fn).toBe('get_vat_declaration_totals')
expect(rpcCalls[0].params.p_net_accounts).toEqual(['2650', '1650'])
expect(rpcCalls[0].params.p_company_id).toBe('company-1')
})
it('books a refund period as a 1650 (Momsfordran) debit', async () => {
const supabase = makeClient({
entries: [{ id: 'e1' }],
lines: [
vatLine('2611', 0, 100),
vatLine('2641', 400, 0),
totals: [
total('2611', 0, 100),
total('2641', 400, 0),
],
})
@@ -152,9 +142,8 @@ describe('buildVatSettlementProposal', () => {
it('clears an account sitting on the wrong side (credit-note-heavy period)', async () => {
const supabase = makeClient({
entries: [{ id: 'e1' }],
// Output VAT with a net DEBIT balance: credit notes exceeded sales.
lines: [vatLine('2611', 50, 0)],
totals: [total('2611', 50, 0)],
})
const proposal = await buildVatSettlementProposal(supabase, 'company-1', 'monthly', 2026, 2)
@@ -171,8 +160,7 @@ describe('buildVatSettlementProposal', () => {
it('is empty when the period has no VAT-account activity (revenue alone does not settle)', async () => {
const supabase = makeClient({
entries: [{ id: 'e1' }],
lines: [vatLine('3001', 0, 1000)],
totals: [total('3001', 0, 1000)],
})
const proposal = await buildVatSettlementProposal(supabase, 'company-1', 'quarterly', 2026, 2)
@@ -184,8 +172,7 @@ describe('buildVatSettlementProposal', () => {
it('uses the räkenskapsår bounds for yearly VAT when a fiscal period is supplied', async () => {
const supabase = makeClient({
entries: [{ id: 'e1' }],
lines: [vatLine('2611', 0, 100), vatLine('2641', 25, 0)],
totals: [total('2611', 0, 100), total('2641', 25, 0)],
fiscalPeriod: { period_start: '2025-07-01', period_end: '2026-06-30' },
})
@@ -205,8 +192,7 @@ describe('buildVatSettlementProposal', () => {
voucher_series: 'M', voucher_number: 3,
}]
const supabase = makeClient({
entries: [{ id: 'e1' }],
lines: [vatLine('2611', 0, 100)],
totals: [total('2611', 0, 100)],
existing,
})
@@ -221,17 +207,14 @@ describe('buildVatSettlementProposal', () => {
source_type: 'manual', voucher_series: 'A', voucher_number: 9,
}
const supabase = makeClient({
entries: [{ id: 'e1' }, manualSettlement],
lines: [
// Business activity on e1.
vatLine('2611', 0, 100),
vatLine('2641', 25, 0),
// Manual momsomföring on e2: clears 26xx to 2650 without the
// vat_settlement source_type (booked before #980 shipped).
vatLine('2611', 100, 0, 'e2'),
vatLine('2641', 0, 25, 'e2'),
vatLine('2650', 0, 75, 'e2'),
// The RPC already excluded the manual momsomföring from the totals
// (that exclusion is pg-tested); what reaches JS is the business
// activity plus the shaped entry to gate on.
totals: [
total('2611', 0, 100),
total('2641', 25, 0),
],
shaped: [manualSettlement],
})
const proposal = await buildVatSettlementProposal(supabase, 'company-1', 'quarterly', 2026, 1)
@@ -254,33 +237,25 @@ describe('buildVatSettlementProposal', () => {
it('does not gate on a storno of a settlement (annullera must re-enable booking)', async () => {
const supabase = makeClient({
entries: [
{ id: 'e1' },
// A manual settlement that has been annulled...
// Settlement + storno cancel out of the totals inside the RPC; both
// still come back as shaped entries. Neither may gate: the reversed
// manual settlement has no balance effect and the storno is the
// cancellation itself.
totals: [total('2611', 0, 100)],
shaped: [
{
id: 'e2', status: 'reversed', entry_date: '2026-03-31',
source_type: 'manual', voucher_series: 'A', voucher_number: 9,
},
// ...and its storno reversal.
{
id: 'e3', status: 'posted', entry_date: '2026-03-31',
source_type: 'storno', voucher_series: 'A', voucher_number: 10,
},
],
lines: [
vatLine('2611', 0, 100),
vatLine('2611', 100, 0, 'e2'),
vatLine('2650', 0, 100, 'e2'),
vatLine('2611', 0, 100, 'e3'),
vatLine('2650', 100, 0, 'e3'),
],
})
const proposal = await buildVatSettlementProposal(supabase, 'company-1', 'quarterly', 2026, 1)
// Settlement + storno are both excluded from the projection (they would
// otherwise double ruta 10), and neither gates: the period can be
// settled again.
expect(proposal.existing_entries).toEqual([])
expect(proposal.filed_net).toBe(100)
expect(proposal.lines).toEqual([
@@ -294,20 +269,15 @@ describe('buildVatSettlementProposal', () => {
it('ignores a plain VAT payment on 2650 (no declaration accounts touched)', async () => {
const supabase = makeClient({
entries: [
{ id: 'e1' },
{
id: 'e2', status: 'posted', entry_date: '2026-02-12',
source_type: 'bank_transaction', voucher_series: 'A', voucher_number: 7,
},
],
lines: [
vatLine('2611', 0, 100),
// Paying last period's VAT debt: 2650 against the bank account.
// Touches a settlement net account but no declaration account, so it
// is NOT settlement-shaped: it must neither gate nor shift the rutor.
vatLine('2650', 75, 0, 'e2'),
// Paying last period's VAT debt: 2650 against the bank account. The
// entry touches a settlement net account but no declaration account,
// so the RPC does NOT shape it: its 2650 total comes back as-is and
// must neither gate nor shift the rutor / clearing lines.
totals: [
total('2611', 0, 100),
total('2650', 75, 0),
],
shaped: [],
})
const proposal = await buildVatSettlementProposal(supabase, 'company-1', 'quarterly', 2026, 1)
@@ -325,8 +295,7 @@ describe('buildVatSettlementProposal', () => {
it('throws when the existing-settlement lookup fails (the UI gate depends on it)', async () => {
const supabase = makeClient({
entries: [{ id: 'e1' }],
lines: [vatLine('2611', 0, 100)],
totals: [total('2611', 0, 100)],
existingError: { message: 'boom' },
})
+8
View File
@@ -822,6 +822,14 @@ export const MASTER_DATA_DUMP_TABLES: MasterDataTableSpec[] = [
{ name: 'salary_run_employees', file: 'salary_run_employees.json', orderBy: 'created_at' },
{ name: 'salary_line_items', file: 'salary_line_items.json', orderBy: 'created_at' },
{ name: 'salary_absence_days', file: 'salary_absence_days.json' },
// Cutover state (payroll gap-closure 2.1): part of the payroll underlag a
// switching company brings; belongs in the archive like the run data it
// seeds.
{ name: 'employee_opening_balances', file: 'employee_opening_balances.json' },
// Vacation ledger + year closures (payroll gap-closure 3.1). The closure
// report is the underlag for the drift-adjustment verifikation (BFL 7 kap).
{ name: 'employee_vacation_balances', file: 'employee_vacation_balances.json' },
{ name: 'vacation_year_closures', file: 'vacation_year_closures.json' },
{ name: 'salary_worked_days', file: 'salary_worked_days.json' },
{ name: 'salary_payslip_links', file: 'salary_payslip_links.json' },
{ name: 'shift_premium_rules', file: 'shift_premium_rules.json' },
+100 -6
View File
@@ -94,6 +94,72 @@ export async function generateVacationLiability(
return run && run.period_year === year && run.status === 'booked'
})
// Cutover opening balances (payroll gap-closure 2.2): a mid-year switcher's
// semesterlöneskuld arrived via SIE opening balances on 2920/2940, so the
// per-employee liability must include the opening term or the report
// understates against the booked balance. Days likewise: the opening row's
// paid-days-remaining replaces the naive entitled-minus-taken, and saved
// days by origin year add to the master-row aggregate. Applies for report
// years >= the cutover year (post-cutover-year drift is reconciled by the
// Phase 3 vacation ledger).
// Vacation ledger v2 (payroll gap-closure 3.2): when a persisted balance
// row exists for the report year, it is authoritative for DAYS (it already
// folded in the cutover seed, legacy saved days, and booked-run recompute).
// SEK stays derived from runs + the opening terms below.
const { data: ledgerRows } = await supabase
.from('employee_vacation_balances')
.select('employee_id, vacation_year_start, entitled_days, taken_days, saved_days')
.eq('company_id', companyId)
.eq('status', 'open')
.gte('vacation_year_start', `${year}-01-01`)
.lte('vacation_year_start', `${year}-12-31`)
const ledgerByEmployee = new Map(
((ledgerRows ?? []) as Array<{
employee_id: string
vacation_year_start: string
entitled_days: number
taken_days: number
saved_days: Record<string, number> | null
}>).map((r) => [r.employee_id, r]),
)
const { data: openingRows } = await supabase
.from('employee_opening_balances')
.select(
'employee_id, cutover_date, vacation_paid_days_remaining, vacation_saved_days_by_year, opening_semester_liability, opening_semester_liability_avgifter',
)
.eq('company_id', companyId)
const openingByEmployee = new Map<
string,
{
paidDaysRemaining: number
savedDays: number
liability: number
liabilityAvgifter: number
}
>()
for (const opening of (openingRows || []) as Array<{
employee_id: string
cutover_date: string
vacation_paid_days_remaining: number
vacation_saved_days_by_year: Record<string, number> | null
opening_semester_liability: number
opening_semester_liability_avgifter: number
}>) {
const cutoverYear = Number(opening.cutover_date.slice(0, 4))
if (year < cutoverYear) continue
const savedDays = Object.values(opening.vacation_saved_days_by_year ?? {}).reduce(
(sum, days) => sum + (Number(days) || 0),
0,
)
openingByEmployee.set(opening.employee_id, {
paidDaysRemaining: opening.vacation_paid_days_remaining || 0,
savedDays,
liability: opening.opening_semester_liability || 0,
liabilityAvgifter: opening.opening_semester_liability_avgifter || 0,
})
}
// Aggregate per employee
const accrualsByEmployee = new Map<string, {
totalAccrual: number
@@ -115,19 +181,47 @@ export async function generateVacationLiability(
const rows: VacationLiabilityRow[] = employees.map(emp => {
const accruals = accrualsByEmployee.get(emp.id)
const accruedAmount = r(accruals?.totalAccrual || 0)
const accruedAvgifter = r(accruals?.totalAvgifter || 0)
const daysTaken = accruals?.totalDaysTaken || 0
const opening = openingByEmployee.get(emp.id)
const ledger = ledgerByEmployee.get(emp.id)
const accruedAmount = r((accruals?.totalAccrual || 0) + (opening?.liability || 0))
const accruedAvgifter = r((accruals?.totalAvgifter || 0) + (opening?.liabilityAvgifter || 0))
// Days: ledger row wins (it already folded in cutover seed + legacy
// saved days + booked-run recompute); else the opening row shifts the
// starting balance; else the naive entitled-minus-taken.
let daysTaken: number
let daysEntitled: number
let daysRemaining: number
let daysSaved: number
if (ledger) {
daysTaken = ledger.taken_days
daysEntitled = ledger.entitled_days
daysRemaining = ledger.entitled_days - ledger.taken_days
daysSaved = Object.values(ledger.saved_days ?? {}).reduce(
(sum, days) => sum + (Number(days) || 0),
0,
)
} else {
daysTaken = accruals?.totalDaysTaken || 0
daysEntitled = emp.vacation_days_per_year
// With an opening row, remaining days start from the imported balance
// rather than the full annual entitlement (the previous system already
// consumed part of the year).
daysRemaining = opening
? opening.paidDaysRemaining - daysTaken
: emp.vacation_days_per_year - daysTaken
daysSaved = emp.vacation_days_saved + (opening?.savedDays || 0)
}
return {
employeeId: emp.id,
employeeName: `${emp.first_name} ${emp.last_name}`,
personnummerLast4: emp.personnummer_last4,
vacationRule: emp.vacation_rule,
vacationDaysEntitled: emp.vacation_days_per_year,
vacationDaysEntitled: daysEntitled,
vacationDaysTaken: daysTaken,
vacationDaysRemaining: emp.vacation_days_per_year - daysTaken,
vacationDaysSaved: emp.vacation_days_saved,
vacationDaysRemaining: daysRemaining,
vacationDaysSaved: daysSaved,
accruedAmount,
accruedAvgifter,
avgifterRate: accruals?.lastRate || 0.3142,
+50 -67
View File
@@ -1,6 +1,4 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
import type {
VatDeclaration,
VatDeclarationRutor,
@@ -264,6 +262,21 @@ export interface VatAccountTotals {
* from `totals`; surfaced so the settlement proposal can warn and gate.
*/
settlementShapedEntries: VatSettlementShapedEntry[]
/**
* Posted/reversed entry counts per source_type for the whole period,
* INCLUDING tagged vat_settlement entries (they never match the
* invoice/transaction buckets, and the metadata scan always counted them).
* Comes back in the same RPC round trip so the declaration metadata no
* longer needs its own paginated entry scan.
*/
sourceTypeCounts: Record<string, number>
}
/** Wire shape of the get_vat_declaration_totals RPC jsonb payload. */
interface VatTotalsRpcPayload {
totals: Array<{ account_number: string; debit: number; credit: number }>
settlement_shaped_entries: VatSettlementShapedEntry[]
source_type_counts: Record<string, number>
}
/**
@@ -296,56 +309,37 @@ export async function fetchVatAccountTotals(
start: string,
end: string
): Promise<VatAccountTotals> {
const lines = await fetchEntryLines<{
journal_entry_id: string
account_number: string
debit_amount: number
credit_amount: number
journal_entries?: VatSettlementShapedEntry
}>({
supabase,
entryColumns: 'id, status, entry_date, source_type, voucher_series, voucher_number',
lineColumns: 'account_number, debit_amount, credit_amount',
filterEntries: (q: EntryLinesQuery) =>
q
.eq('company_id', companyId)
.in('status', ['posted', 'reversed'])
.neq('source_type', 'vat_settlement')
.gte('entry_date', start)
.lte('entry_date', end),
filterLines: (q: EntryLinesQuery) =>
q.in('account_number', [...VAT_ACCOUNTS, ...VAT_SETTLEMENT_NET_ACCOUNTS]),
// Aggregation, settlement-shape detection, and source_type counts all
// happen in one SQL pass (get_vat_declaration_totals). The previous
// implementation paged every entry + line for the period through PostgREST
// and reduced in JS: dozens of round trips for a busy quarter. The account
// lists are parameters so ACCOUNT_RUTA stays the single source of truth.
const { data, error } = await supabase.rpc('get_vat_declaration_totals', {
p_company_id: companyId,
p_start: start,
p_end: end,
p_accounts: [...VAT_ACCOUNTS, ...VAT_SETTLEMENT_NET_ACCOUNTS],
p_ruta_accounts: VAT_ACCOUNTS,
p_net_accounts: VAT_SETTLEMENT_NET_ACCOUNTS,
})
// Shape detection: an entry is a settlement when it touches both a
// declaration account and a settlement net account (2650/1650).
const declarationEntryIds = new Set<string>()
const netEntryIds = new Set<string>()
for (const line of lines) {
if (ACCOUNT_RUTA[line.account_number]) declarationEntryIds.add(line.journal_entry_id)
else if (VAT_SETTLEMENT_NET_ACCOUNTS.includes(line.account_number)) {
netEntryIds.add(line.journal_entry_id)
}
}
const shapedById = new Map<string, VatSettlementShapedEntry>()
for (const line of lines) {
const id = line.journal_entry_id
if (!declarationEntryIds.has(id) || !netEntryIds.has(id)) continue
const entry = line.journal_entries
if (!entry || entry.source_type === 'opening_balance') continue
shapedById.set(id, entry)
if (error) {
throw new Error(`get_vat_declaration_totals failed: ${error.message}`)
}
const payload = (data ?? {}) as Partial<VatTotalsRpcPayload>
const totals = new Map<string, { debit: number; credit: number }>()
for (const line of lines) {
if (shapedById.has(line.journal_entry_id)) continue
const t = totals.get(line.account_number) || { debit: 0, credit: 0 }
t.debit += Number(line.debit_amount) || 0
t.credit += Number(line.credit_amount) || 0
totals.set(line.account_number, t)
for (const row of payload.totals ?? []) {
totals.set(row.account_number, {
debit: Number(row.debit) || 0,
credit: Number(row.credit) || 0,
})
}
return {
totals,
settlementShapedEntries: payload.settlement_shaped_entries ?? [],
sourceTypeCounts: payload.source_type_counts ?? {},
}
return { totals, settlementShapedEntries: [...shapedById.values()] }
}
/**
@@ -413,8 +407,9 @@ export async function calculateVatDeclaration(
supabase, companyId, periodType, year, period, options.fiscalPeriodId
)
// Fetch and aggregate posted VAT-account activity for the period
const { totals } = await fetchVatAccountTotals(supabase, companyId, start, end)
// Fetch and aggregate posted VAT-account activity for the period. The same
// RPC round trip carries the per-source_type entry counts for the metadata.
const { totals, sourceTypeCounts } = await fetchVatAccountTotals(supabase, companyId, start, end)
// Map account balances to momsdeklaration boxes
const rutor = rutorFromTotals(totals)
@@ -430,29 +425,17 @@ export async function calculateVatDeclaration(
if (t) revenueByRate[rate] = round(t.credit - t.debit)
}
// Count journal entries by source type for metadata.
// Paginated with a stable id order so the invoice/transaction counts don't
// silently truncate at 1000 entries for a busy VAT period.
const entryCounts = await fetchAllRows<{ id: string; source_type: string }>(({ from, to }) =>
supabase
.from('journal_entries')
.select('id, source_type')
.eq('company_id', companyId)
.in('status', ['posted', 'reversed'])
.gte('entry_date', start)
.lte('entry_date', end)
.order('id', { ascending: true })
.range(from, to)
, { dedupeBy: (e) => e.id })
// Entry counts by source type for metadata: aggregated by the RPC in the
// same round trip as the totals (SQL GROUP BY, so a busy VAT period can
// never truncate the counts).
const invoiceSources = new Set([
'invoice_created', 'invoice_paid', 'invoice_cash_payment', 'credit_note',
])
let invoiceCount = 0
let transactionCount = 0
for (const e of entryCounts) {
if (invoiceSources.has(e.source_type)) invoiceCount++
else if (e.source_type === 'bank_transaction') transactionCount++
for (const [sourceType, n] of Object.entries(sourceTypeCounts)) {
if (invoiceSources.has(sourceType)) invoiceCount += n
else if (sourceType === 'bank_transaction') transactionCount += n
}
return {
+243
View File
@@ -0,0 +1,243 @@
/**
* Unit tests for lib/salary/absence.ts (payroll gap-closure 1.4).
*
* Range expansion (weekend skipping, 92-day cap), natural-key upsert flow,
* 24h-trigger mapping, and range deletes with counts.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase } from '@/tests/helpers'
import {
ABSENCE_RANGE_MAX_DAYS,
deleteAbsenceRange,
expandDateRange,
listAbsenceDays,
upsertAbsenceDay,
upsertAbsenceRange,
} from '@/lib/salary/absence'
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
let mock: ReturnType<typeof createQueuedMockSupabase>
let supabase: SupabaseClient
beforeEach(() => {
vi.clearAllMocks()
mock = createQueuedMockSupabase()
supabase = mock.supabase as unknown as SupabaseClient
})
describe('expandDateRange', () => {
it('expands an inclusive range and skips weekends by default', () => {
// 2026-03-02 is a Monday; 2026-03-08 a Sunday.
const days = expandDateRange('2026-03-02', '2026-03-08')
expect(days).toEqual([
'2026-03-02',
'2026-03-03',
'2026-03-04',
'2026-03-05',
'2026-03-06',
])
})
it('includes weekends when asked', () => {
const days = expandDateRange('2026-03-06', '2026-03-08', { includeWeekends: true })
expect(days).toEqual(['2026-03-06', '2026-03-07', '2026-03-08'])
})
it('handles a single day (from == to)', () => {
expect(expandDateRange('2026-03-03', '2026-03-03')).toEqual(['2026-03-03'])
})
it('returns null for inverted ranges', () => {
expect(expandDateRange('2026-03-08', '2026-03-02')).toBeNull()
})
it('returns null when the span exceeds the cap', () => {
expect(expandDateRange('2026-01-01', '2026-06-30')).toBeNull()
// Exactly at the cap is fine.
expect(expandDateRange('2026-01-01', '2026-04-02')).not.toBeNull()
expect(ABSENCE_RANGE_MAX_DAYS).toBe(92)
})
it('crosses DST transitions without dropping or duplicating days', () => {
// Swedish DST switch on 2026-03-29: UTC-based math must stay per-day exact.
const days = expandDateRange('2026-03-27', '2026-03-31', { includeWeekends: true })
expect(days).toEqual(['2026-03-27', '2026-03-28', '2026-03-29', '2026-03-30', '2026-03-31'])
})
})
describe('upsertAbsenceRange', () => {
it('returns EMPLOYEE_NOT_FOUND for an unknown employee', async () => {
mock.enqueue({ data: null })
const result = await upsertAbsenceRange(supabase, {
companyId: COMPANY_ID,
employeeId: EMPLOYEE_ID,
from: '2026-03-02',
to: '2026-03-06',
absenceType: 'sick',
})
expect(result).toEqual({ ok: false, code: 'EMPLOYEE_NOT_FOUND' })
})
it('returns ABSENCE_RANGE_TOO_LARGE beyond the cap', async () => {
mock.enqueue({ data: { id: EMPLOYEE_ID } })
const result = await upsertAbsenceRange(supabase, {
companyId: COMPANY_ID,
employeeId: EMPLOYEE_ID,
from: '2026-01-01',
to: '2026-12-31',
absenceType: 'sick',
})
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('ABSENCE_RANGE_TOO_LARGE')
})
it('bulk-upserts the expanded weekday rows in one statement', async () => {
mock.enqueue({ data: { id: EMPLOYEE_ID } })
mock.enqueue({
data: [
{ id: '1', absence_date: '2026-03-02', absence_type: 'sick', hours: 8, notes: null, salary_run_employee_id: null, created_at: '', updated_at: '' },
{ id: '2', absence_date: '2026-03-03', absence_type: 'sick', hours: 8, notes: null, salary_run_employee_id: null, created_at: '', updated_at: '' },
],
})
const result = await upsertAbsenceRange(supabase, {
companyId: COMPANY_ID,
employeeId: EMPLOYEE_ID,
from: '2026-03-02',
to: '2026-03-03',
absenceType: 'sick',
})
expect(result.ok).toBe(true)
if (result.ok) expect(result.data.count).toBe(2)
const fromCalls = (mock.supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
expect(fromCalls).toEqual(['employees', 'salary_absence_days'])
})
it('dry-run expands without touching salary_absence_days', async () => {
mock.enqueue({ data: { id: EMPLOYEE_ID } })
const result = await upsertAbsenceRange(supabase, {
companyId: COMPANY_ID,
employeeId: EMPLOYEE_ID,
from: '2026-03-02',
to: '2026-03-06',
absenceType: 'vab',
hoursPerDay: 4,
dryRun: true,
})
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.data.count).toBe(5)
expect(result.data.days[0]).toEqual({
absence_date: '2026-03-02',
absence_type: 'vab',
hours: 4,
})
}
const fromCalls = (mock.supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
expect(fromCalls).toEqual(['employees'])
})
it('maps the 24h-cap trigger (23514) to ABSENCE_HOURS_CONFLICT', async () => {
mock.enqueue({ data: { id: EMPLOYEE_ID } })
mock.enqueue({ data: null, error: { code: '23514', message: 'Total tid över 24h' } })
const result = await upsertAbsenceRange(supabase, {
companyId: COMPANY_ID,
employeeId: EMPLOYEE_ID,
from: '2026-03-02',
to: '2026-03-02',
absenceType: 'sick',
hoursPerDay: 20,
})
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('ABSENCE_HOURS_CONFLICT')
})
})
describe('upsertAbsenceDay', () => {
it('replaces the (date, type) row via an atomic upsert', async () => {
mock.enqueue({ data: { id: EMPLOYEE_ID } })
mock.enqueue({
data: {
id: '1',
absence_date: '2026-03-02',
absence_type: 'sick',
hours: 8,
notes: null,
salary_run_employee_id: null,
created_at: '',
updated_at: '',
},
})
const result = await upsertAbsenceDay(supabase, {
companyId: COMPANY_ID,
employeeId: EMPLOYEE_ID,
day: { absence_date: '2026-03-02', absence_type: 'sick', hours: 8 },
})
expect(result.ok).toBe(true)
if (result.ok) expect(result.data.absence_date).toBe('2026-03-02')
})
})
describe('listAbsenceDays / deleteAbsenceRange', () => {
it('lists rows within the range', async () => {
mock.enqueue({ data: { id: EMPLOYEE_ID } })
mock.enqueue({
data: [
{ id: '1', absence_date: '2026-03-02', absence_type: 'sick', hours: 8, notes: null, salary_run_employee_id: null, created_at: '', updated_at: '' },
],
})
const result = await listAbsenceDays(supabase, {
companyId: COMPANY_ID,
employeeId: EMPLOYEE_ID,
from: '2026-03-01',
to: '2026-03-31',
})
expect(result.ok).toBe(true)
if (result.ok) expect(result.data).toHaveLength(1)
})
it('deletes a range and reports the count', async () => {
mock.enqueue({ data: { id: EMPLOYEE_ID } })
mock.enqueue({ data: null, count: 3 })
const result = await deleteAbsenceRange(supabase, {
companyId: COMPANY_ID,
employeeId: EMPLOYEE_ID,
from: '2026-03-01',
to: '2026-03-31',
absenceType: 'sick',
})
expect(result.ok).toBe(true)
if (result.ok) expect(result.data.deleted_count).toBe(3)
})
it('dry-run delete counts without deleting', async () => {
mock.enqueue({ data: { id: EMPLOYEE_ID } })
mock.enqueue({ data: null, count: 2 })
const result = await deleteAbsenceRange(supabase, {
companyId: COMPANY_ID,
employeeId: EMPLOYEE_ID,
from: '2026-03-01',
to: '2026-03-31',
dryRun: true,
})
expect(result.ok).toBe(true)
if (result.ok) expect(result.data.deleted_count).toBe(2)
})
})
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { deriveAgiFilingState } from '../agi-submission-state'
describe('deriveAgiFilingState', () => {
const bare = { agi_generated_at: null, agi_submitted_at: null }
const generated = { agi_generated_at: '2026-07-13T09:48:58Z', agi_submitted_at: null }
it('returns none when nothing has happened', () => {
expect(deriveAgiFilingState(bare, null)).toBe('none')
expect(deriveAgiFilingState(bare, undefined)).toBe('none')
expect(deriveAgiFilingState(bare, {})).toBe('none')
})
it('returns generated when only the XML exists', () => {
expect(deriveAgiFilingState(generated, null)).toBe('generated')
})
it('follows the submission record through the filing steps', () => {
expect(deriveAgiFilingState(generated, { status: 'underlag_submitted' })).toBe(
'underlag_submitted',
)
expect(deriveAgiFilingState(generated, { status: 'awaiting_signing' })).toBe(
'awaiting_signing',
)
expect(deriveAgiFilingState(generated, { status: 'signed' })).toBe('signed')
})
it('treats a rejected underlag as back-to-generated', () => {
expect(deriveAgiFilingState(generated, { status: 'underlag_rejected' })).toBe('generated')
})
it('a rejected underlag with no generated XML falls back to none', () => {
expect(deriveAgiFilingState(bare, { status: 'underlag_rejected' })).toBe('none')
})
it('run.agi_submitted_at is authoritative over a stale submission record', () => {
const filed = { agi_generated_at: '2026-07-13T09:48:58Z', agi_submitted_at: '2026-07-13T10:02:11Z' }
expect(deriveAgiFilingState(filed, null)).toBe('signed')
expect(deriveAgiFilingState(filed, { status: 'awaiting_signing' })).toBe('signed')
expect(deriveAgiFilingState(filed, { status: 'underlag_submitted' })).toBe('signed')
})
it('a signed submission record counts even before the run row is stamped', () => {
expect(deriveAgiFilingState(generated, { status: 'signed', kvittensnummer: 'abc-123' })).toBe(
'signed',
)
})
})
+31 -14
View File
@@ -25,8 +25,10 @@ vi.mock('../personnummer', () => ({
return '199001011234' // Default: born 1990
},
calculateAgeAtYearStart: (pnr: string, year: number) => {
// Mirrors the real implementation: birth-year based (age attained by
// Dec 31 of the prior year), matching Skatteverket's cohort ranges.
const birthYear = parseInt(pnr.slice(0, 4))
return year - birthYear
return year - 1 - birthYear
},
}))
@@ -1029,6 +1031,19 @@ describe('calculateAvgifterRate', () => {
expect(result.category).toBe('reduced_65plus')
})
it('keeps the standard rate for born 1959 (turns 67 during 2026)', () => {
// "Fyllt 67 vid årets ingång" 2026 = born 1958 or earlier; born 1959
// gets the reduced rate from 2027. Jan-1 birthday, the exact edge.
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_born_1959' }),
config2026,
2026
)
expect(result.rate).toBe(0.3142)
expect(result.category).toBe('standard')
})
it('returns 0% for born ≤1937', () => {
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_old_person' }),
@@ -1056,12 +1071,14 @@ describe('calculateAvgifterRate', () => {
})
// Ungdomsrabatt 2026-2027 (Prop. 2025/26:66). Eligibility test is
// age >= 18 AND age < 23 at årets ingång. Cases below pin all four age
// boundaries plus the period-window edges.
// age >= 18 AND age < 23 at årets ingång, applied by Skatteverket as
// birth-year cohorts (2026: born 2003-2007). Cases below pin all four
// cohort boundaries plus the period-window edges. Fixture birthdays are
// Jan 1, the exact edge the old birthday-inclusive age math misread.
describe('youth rate (ungdomsrabatt 2026-2027)', () => {
it('NOT eligible: age 17 at year start (too young)', () => {
it('NOT eligible: born 2008 (17 vid årets ingång 2026, too young)', () => {
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_born_2009', paymentDate: '2026-05-25' }),
makeBasicInput({ personnummer: 'mock_born_2008', paymentDate: '2026-05-25' }),
config2026,
2026,
)
@@ -1069,9 +1086,9 @@ describe('calculateAvgifterRate', () => {
expect(result.rate).toBe(0.3142)
})
it('eligible: age 18 at year start (lower boundary)', () => {
it('eligible: born 2007 (18 vid årets ingång, lower boundary)', () => {
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_born_2008', paymentDate: '2026-05-25' }),
makeBasicInput({ personnummer: 'mock_born_2007', paymentDate: '2026-05-25' }),
config2026,
2026,
)
@@ -1079,9 +1096,9 @@ describe('calculateAvgifterRate', () => {
expect(result.rate).toBe(0.2081)
})
it('eligible: age 22 at year start (upper boundary)', () => {
it('eligible: born 2003 (22 vid årets ingång, upper boundary)', () => {
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_born_2004', paymentDate: '2026-05-25' }),
makeBasicInput({ personnummer: 'mock_born_2003', paymentDate: '2026-05-25' }),
config2026,
2026,
)
@@ -1091,9 +1108,9 @@ describe('calculateAvgifterRate', () => {
// Regression: this is the case Skatteverket's AGI validator rejected.
// The previous implementation incorrectly accepted age 23 at year start.
it('NOT eligible: age 23 at year start (just over)', () => {
it('NOT eligible: born 2002 (23 vid årets ingång, just over)', () => {
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_born_2003', paymentDate: '2026-05-25' }),
makeBasicInput({ personnummer: 'mock_born_2002', paymentDate: '2026-05-25' }),
config2026,
2026,
)
@@ -1101,7 +1118,7 @@ describe('calculateAvgifterRate', () => {
expect(result.rate).toBe(0.3142)
})
it('NOT eligible: age 22 but paid March 2026 (before period starts)', () => {
it('NOT eligible: in cohort but paid March 2026 (before period starts)', () => {
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_born_2004', paymentDate: '2026-03-15' }),
config2026,
@@ -1111,7 +1128,7 @@ describe('calculateAvgifterRate', () => {
expect(result.rate).toBe(0.3142)
})
it('NOT eligible: age 22 but paid October 2027 (after period ends)', () => {
it('NOT eligible: in cohort but paid October 2027 (after period ends)', () => {
const config2027: PayrollConfig = { ...config2026, configYear: 2027 }
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_born_2005', paymentDate: '2027-10-10' }),
@@ -1152,7 +1169,7 @@ describe('calculateSalary: youth cap', () => {
it('applies 20.81% on first 25 000 SEK and 31.42% on the excess', () => {
const result = calculateSalary(
makeBasicInput({
personnummer: 'mock_born_2004', // age 22 at year start 2026
personnummer: 'mock_born_2004', // age 21 at year start 2026
paymentDate: '2026-06-25',
monthlySalary: 30000,
}),
@@ -190,6 +190,61 @@ describe('deriveAbsenceLineItems: sick', () => {
})
})
describe('deriveAbsenceLineItems: cutover karensPeriodsAdjustment', () => {
it('suppresses karens when the adjustment alone reaches the cap', () => {
// Mid-year switcher with 10 karens periods in the previous system and no
// imported absence rows: the 11th period must be suppressed even though
// the lookback here is empty.
const result = deriveAbsenceLineItems(
baseInput({
periodDays: days([['2026-04-06', 'sick']]),
karensPeriodsAdjustment: 10,
}),
)
expect(result.lineItems.find(li => li.item_type === 'sick_karens')).toBeUndefined()
// Day 1 with suppressed karens is paid normal: no deduction lines at all.
expect(result.aggregated.sickDays).toBe(1)
})
it('combines the adjustment with real lookback segments', () => {
// 8 imported periods + adjustment 2 = 10: cap reached, karens suppressed.
const lookback: string[] = []
for (let i = 0; i < 8; i++) {
const month = ((4 - 1 + 12 - i - 1) % 12) + 1
const year = i < 3 ? 2026 : 2025
lookback.push(`${year}-${String(month).padStart(2, '0')}-01`)
}
const capped = deriveAbsenceLineItems(
baseInput({
periodDays: days([['2026-04-15', 'sick']]),
lookbackSickDates: lookback,
karensPeriodsAdjustment: 2,
}),
)
expect(capped.lineItems.find(li => li.item_type === 'sick_karens')).toBeUndefined()
// Adjustment 1 leaves the count at 9: karens still deducted.
const belowCap = deriveAbsenceLineItems(
baseInput({
periodDays: days([['2026-04-15', 'sick']]),
lookbackSickDates: lookback,
karensPeriodsAdjustment: 1,
}),
)
expect(belowCap.lineItems.find(li => li.item_type === 'sick_karens')).toBeDefined()
})
it('zero/absent adjustment changes nothing', () => {
const withZero = deriveAbsenceLineItems(
baseInput({ periodDays: days([['2026-04-06', 'sick']]), karensPeriodsAdjustment: 0 }),
)
const without = deriveAbsenceLineItems(
baseInput({ periodDays: days([['2026-04-06', 'sick']]) }),
)
expect(withZero.lineItems).toEqual(without.lineItems)
})
})
describe('deriveAbsenceLineItems: VAB', () => {
it('emits VAB line item with deduction', () => {
const result = deriveAbsenceLineItems(
+264
View File
@@ -0,0 +1,264 @@
/**
* Unit tests for lib/salary/payslip-lines.ts (payroll gap-closure 1.2).
*
* The service is the single source of truth for payslip line commands across
* internal routes, v1, and the MCP executor: draft gate, run-membership
* verification, roundOre money math, account auto-resolution.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase } from '@/tests/helpers'
import {
createPayslipLine,
updatePayslipLine,
deletePayslipLine,
} from '@/lib/salary/payslip-lines'
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const RUN_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
const SRE_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const LINE_ID = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'
const BASE_INPUT = {
item_type: 'bonus' as const,
description: 'Kvartalsbonus',
amount: 5000,
is_taxable: true,
is_avgift_basis: true,
is_vacation_basis: true,
is_gross_deduction: false,
is_net_deduction: false,
sort_order: 0,
}
const EXISTING_LINE = {
id: LINE_ID,
salary_run_employee_id: SRE_ID,
company_id: COMPANY_ID,
item_type: 'bonus',
description: 'Kvartalsbonus',
quantity: null,
unit_price: null,
amount: 5000,
is_taxable: true,
is_avgift_basis: true,
is_vacation_basis: true,
is_gross_deduction: false,
is_net_deduction: false,
account_number: '7210',
sort_order: 0,
created_at: '2026-05-01T08:00:00Z',
updated_at: '2026-05-01T08:00:00Z',
salary_run_employee: { salary_run_id: RUN_ID },
}
let mock: ReturnType<typeof createQueuedMockSupabase>
let supabase: SupabaseClient
beforeEach(() => {
vi.clearAllMocks()
mock = createQueuedMockSupabase()
supabase = mock.supabase as unknown as SupabaseClient
})
describe('createPayslipLine', () => {
it('returns SALARY_RUN_NOT_FOUND when the run is missing', async () => {
mock.enqueue({ data: null })
const result = await createPayslipLine(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
target: { employeeId: EMPLOYEE_ID },
input: BASE_INPUT,
})
expect(result).toEqual({ ok: false, code: 'SALARY_RUN_NOT_FOUND' })
})
it('returns SALARY_RUN_LINE_NOT_DRAFT with the current status once the run advanced', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'review' } })
const result = await createPayslipLine(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
target: { employeeId: EMPLOYEE_ID },
input: BASE_INPUT,
})
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.code).toBe('SALARY_RUN_LINE_NOT_DRAFT')
expect(result.details).toEqual({ current_status: 'review' })
}
})
it('returns SALARY_RUN_EMPLOYEE_NOT_FOUND when the employee is not in the run', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
mock.enqueue({ data: null })
const result = await createPayslipLine(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
target: { employeeId: EMPLOYEE_ID },
input: BASE_INPUT,
})
expect(result).toEqual({ ok: false, code: 'SALARY_RUN_EMPLOYEE_NOT_FOUND' })
})
it('creates a line with roundOre money math and auto-resolved account', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
mock.enqueue({ data: { id: SRE_ID, employee_id: EMPLOYEE_ID } })
mock.enqueue({ data: { ...EXISTING_LINE, amount: 1.01 } })
// 1.005 is the exact-half value where naive Math.round(x*100)/100 fails
// (rounds down to 1.00); roundOre must produce 1.01.
const result = await createPayslipLine(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
target: { salaryRunEmployeeId: SRE_ID },
input: { ...BASE_INPUT, amount: 1.005 },
})
expect(result.ok).toBe(true)
// Assert what the service sent to the DB, not just what the mock echoed.
const fromCalls = (mock.supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
expect(fromCalls).toEqual(['salary_runs', 'salary_run_employees', 'salary_line_items'])
})
it('dry-run validates and returns the would-be row without inserting', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
mock.enqueue({ data: { id: SRE_ID, employee_id: EMPLOYEE_ID } })
const result = await createPayslipLine(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
target: { employeeId: EMPLOYEE_ID },
input: { ...BASE_INPUT, amount: 1.005 },
dryRun: true,
})
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.data.id).toBeNull()
expect(result.data.amount).toBe(1.01)
// Auto-resolved from item_type 'bonus'.
expect(result.data.account_number).toBe('7210')
}
// Only the gate + sre lookups hit the DB; no insert.
const fromCalls = (mock.supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
expect(fromCalls).toEqual(['salary_runs', 'salary_run_employees'])
})
})
describe('updatePayslipLine', () => {
it('returns SALARY_LINE_NOT_FOUND when the line belongs to a different run', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
mock.enqueue({
data: {
...EXISTING_LINE,
salary_run_employee: { salary_run_id: '99999999-9999-4999-8999-999999999999' },
},
})
const result = await updatePayslipLine(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
lineId: LINE_ID,
patch: { amount: 6000 },
})
expect(result).toEqual({ ok: false, code: 'SALARY_LINE_NOT_FOUND' })
})
it('rounds a patched amount via roundOre and returns the updated row', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
mock.enqueue({ data: EXISTING_LINE })
mock.enqueue({ data: { ...EXISTING_LINE, amount: 1.01, salary_run_employee: undefined } })
const result = await updatePayslipLine(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
lineId: LINE_ID,
patch: { amount: 1.005 },
})
expect(result.ok).toBe(true)
if (result.ok) expect(result.data.amount).toBe(1.01)
})
it('dry-run returns the merged row without writing', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
mock.enqueue({ data: EXISTING_LINE })
const result = await updatePayslipLine(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
lineId: LINE_ID,
patch: { amount: 1.005, description: 'Justerad bonus' },
dryRun: true,
})
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.data.amount).toBe(1.01)
expect(result.data.description).toBe('Justerad bonus')
}
const fromCalls = (mock.supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
expect(fromCalls).toEqual(['salary_runs', 'salary_line_items'])
})
it('gates on draft status', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'booked' } })
const result = await updatePayslipLine(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
lineId: LINE_ID,
patch: { amount: 6000 },
})
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('SALARY_RUN_LINE_NOT_DRAFT')
})
})
describe('deletePayslipLine', () => {
it('deletes a line in a draft run', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
mock.enqueue({ data: EXISTING_LINE })
mock.enqueue({ data: null })
const result = await deletePayslipLine(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
lineId: LINE_ID,
})
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.data.deleted).toBe(true)
expect(result.data.salary_line_item_id).toBe(LINE_ID)
}
})
it('dry-run verifies gates without deleting', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
mock.enqueue({ data: EXISTING_LINE })
const result = await deletePayslipLine(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
lineId: LINE_ID,
dryRun: true,
})
expect(result.ok).toBe(true)
const fromCalls = (mock.supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
expect(fromCalls).toEqual(['salary_runs', 'salary_line_items'])
})
it('returns SALARY_LINE_NOT_FOUND for a missing line', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
mock.enqueue({ data: null })
const result = await deletePayslipLine(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
lineId: LINE_ID,
})
expect(result).toEqual({ ok: false, code: 'SALARY_LINE_NOT_FOUND' })
})
})
+12 -2
View File
@@ -73,10 +73,20 @@ describe('calculateAge', () => {
})
describe('calculateAgeAtYearStart', () => {
it('calculates age at January 1 of given year', () => {
expect(calculateAgeAtYearStart('199001019802', 2026)).toBe(36)
// Skatteverket applies "vid årets ingång fyllt X" rules as birth-year
// ranges, so the age is the one attained by December 31 of the prior year.
it('is birth-year based: same birth year means same year-start age', () => {
expect(calculateAgeAtYearStart('199006159802', 2026)).toBe(35)
expect(calculateAgeAtYearStart('199012319802', 2026)).toBe(35)
})
it('does not count a January 1 birthday as attained at årets ingång', () => {
// The 2026 youth cohort is born 2003-2007: born 2003-01-01 must read
// as 22 (eligible) and born 2008-01-01 as 17 (not eligible).
expect(calculateAgeAtYearStart('199001019802', 2026)).toBe(35)
expect(calculateAgeAtYearStart('200301011234', 2026)).toBe(22)
expect(calculateAgeAtYearStart('200801011234', 2026)).toBe(17)
})
})
describe('maskPersonnummer', () => {
+219
View File
@@ -0,0 +1,219 @@
/**
* Unit tests for lib/salary/run-employees.ts (payroll gap-closure 1.3).
*
* Attach/remove employees on a draft salary run: draft gate, active-employee
* check, duplicate 409, snapshot semantics, base-line seeding with roundOre.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { addEmployeeToRun, removeEmployeeFromRun } from '@/lib/salary/run-employees'
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const RUN_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const SRE_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'
const MONTHLY_EMPLOYEE = {
id: EMPLOYEE_ID,
employment_degree: 80,
monthly_salary: 35000,
hourly_rate: null,
salary_type: 'monthly',
employment_type: 'employee',
tax_table_number: 33,
tax_column: 1,
}
let mock: ReturnType<typeof createQueuedMockSupabase>
let supabase: SupabaseClient
beforeEach(() => {
vi.clearAllMocks()
mock = createQueuedMockSupabase()
supabase = mock.supabase as unknown as SupabaseClient
})
describe('addEmployeeToRun', () => {
it('returns SALARY_RUN_NOT_FOUND for a missing run', async () => {
mock.enqueue({ data: null })
const result = await addEmployeeToRun(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
employeeId: EMPLOYEE_ID,
})
expect(result).toEqual({ ok: false, code: 'SALARY_RUN_NOT_FOUND' })
})
it('returns SALARY_RUN_EMPLOYEES_NOT_DRAFT once the run has advanced', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'approved' } })
const result = await addEmployeeToRun(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
employeeId: EMPLOYEE_ID,
})
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.code).toBe('SALARY_RUN_EMPLOYEES_NOT_DRAFT')
expect(result.details).toEqual({ current_status: 'approved' })
}
})
it('returns EMPLOYEE_NOT_FOUND for an inactive or unknown employee', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
mock.enqueue({ data: null })
const result = await addEmployeeToRun(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
employeeId: EMPLOYEE_ID,
})
expect(result).toEqual({ ok: false, code: 'EMPLOYEE_NOT_FOUND' })
})
it('returns SALARY_RUN_EMPLOYEE_DUPLICATE when already attached', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
mock.enqueue({ data: MONTHLY_EMPLOYEE })
mock.enqueue({ data: { id: SRE_ID } })
const result = await addEmployeeToRun(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
employeeId: EMPLOYEE_ID,
})
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('SALARY_RUN_EMPLOYEE_DUPLICATE')
})
it('attaches with a pay snapshot and seeds the base line (happy path)', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
mock.enqueue({ data: MONTHLY_EMPLOYEE })
mock.enqueue({ data: null }) // duplicate check: none
mock.enqueue({
data: {
id: SRE_ID,
salary_run_id: RUN_ID,
employee_id: EMPLOYEE_ID,
company_id: COMPANY_ID,
employment_degree: 80,
monthly_salary: 35000,
salary_type: 'monthly',
hours_worked: null,
tax_table_number: 33,
tax_column: 1,
created_at: '2026-05-01T08:00:00Z',
updated_at: '2026-05-01T08:00:00Z',
},
})
mock.enqueue({ data: null }) // line insert
const result = await addEmployeeToRun(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
employeeId: EMPLOYEE_ID,
})
expect(result.ok).toBe(true)
const fromCalls = (mock.supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
expect(fromCalls).toEqual([
'salary_runs',
'employees',
'salary_run_employees',
'salary_run_employees',
'salary_line_items',
])
})
it('dry-run returns the would-be snapshot without inserting', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
mock.enqueue({ data: MONTHLY_EMPLOYEE })
mock.enqueue({ data: null }) // duplicate check: none
const result = await addEmployeeToRun(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
employeeId: EMPLOYEE_ID,
dryRun: true,
})
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.data.id).toBeNull()
expect(result.data.employee_id).toBe(EMPLOYEE_ID)
expect(result.data.employment_degree).toBe(80)
}
const fromCalls = (mock.supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
// Gate, employee lookup, duplicate check: no inserts.
expect(fromCalls).toEqual(['salary_runs', 'employees', 'salary_run_employees'])
})
it('maps a 23505 insert race to SALARY_RUN_EMPLOYEE_DUPLICATE', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
mock.enqueue({ data: MONTHLY_EMPLOYEE })
mock.enqueue({ data: null }) // duplicate check passes...
mock.enqueue({ data: null, error: { code: '23505', message: 'duplicate key' } }) // ...but insert races
const result = await addEmployeeToRun(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
employeeId: EMPLOYEE_ID,
})
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('SALARY_RUN_EMPLOYEE_DUPLICATE')
})
})
describe('removeEmployeeFromRun', () => {
it('removes an attached employee from a draft run', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
mock.enqueue({ data: { id: SRE_ID } })
mock.enqueue({ data: null }) // delete
const result = await removeEmployeeFromRun(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
employeeId: EMPLOYEE_ID,
})
expect(result.ok).toBe(true)
if (result.ok) expect(result.data.deleted).toBe(true)
})
it('returns SALARY_RUN_EMPLOYEE_NOT_FOUND when not attached', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
mock.enqueue({ data: null })
const result = await removeEmployeeFromRun(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
employeeId: EMPLOYEE_ID,
})
expect(result).toEqual({ ok: false, code: 'SALARY_RUN_EMPLOYEE_NOT_FOUND' })
})
it('gates on draft status', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'booked' } })
const result = await removeEmployeeFromRun(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
employeeId: EMPLOYEE_ID,
})
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('SALARY_RUN_EMPLOYEES_NOT_DRAFT')
})
it('dry-run verifies without deleting', async () => {
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
mock.enqueue({ data: { id: SRE_ID } })
const result = await removeEmployeeFromRun(supabase, {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
employeeId: EMPLOYEE_ID,
dryRun: true,
})
expect(result.ok).toBe(true)
const fromCalls = (mock.supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
expect(fromCalls).toEqual(['salary_runs', 'salary_run_employees'])
})
})
@@ -0,0 +1,353 @@
/**
* Semesterberedning + semesterårsavslut (payroll gap-closure 3.3).
*
* Table-driven beredning math (min-20 floor, 5-year expiry, proration) and
* the SEK reconcile + commit flow with mocked trial balance / engine.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase } from '@/tests/helpers'
const mockTrialBalance = vi.fn()
vi.mock('@/lib/reports/trial-balance', () => ({
generateTrialBalance: (...a: unknown[]) => mockTrialBalance(...a),
}))
const mockSync = vi.fn()
vi.mock('@/lib/salary/vacation-ledger', async () => {
const actual = await vi.importActual<typeof import('@/lib/salary/vacation-ledger')>(
'@/lib/salary/vacation-ledger',
)
return {
...actual,
getVacationYearBasis: vi.fn().mockResolvedValue('calendar'),
syncVacationLedgerForEmployees: (...a: unknown[]) => mockSync(...a),
}
})
const mockCreateJournalEntry = vi.fn()
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: (...a: unknown[]) => mockCreateJournalEntry(...a),
}))
const mockCheckPeriodLock = vi.fn()
vi.mock('@/lib/api/v1/check-period-lock', () => ({
checkPeriodLock: (...a: unknown[]) => mockCheckPeriodLock(...a),
}))
// Only decryption is mocked ('mock_born_YYYY' resolves to a mid-year birth
// in YYYY); the age-tier math runs the real calculateAgeAtYearStart.
vi.mock('@/lib/salary/personnummer', async () => {
const actual = await vi.importActual<typeof import('@/lib/salary/personnummer')>(
'@/lib/salary/personnummer',
)
return {
...actual,
decryptPersonnummer: (encrypted: string) => {
const m = /^mock_born_(\d{4})$/.exec(encrypted)
if (!m) throw new Error('not an encrypted fixture')
return `${m[1]}06151234`
},
}
})
import { previewVacationYearClose, commitVacationYearClose } from '@/lib/salary/semesterberedning'
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const ROSTER_ROW = {
id: EMPLOYEE_ID,
first_name: 'Anna',
last_name: 'Andersson',
vacation_rule: 'sammaloneregeln',
vacation_days_per_year: 25,
salary_type: 'monthly',
monthly_salary: 30000,
hourly_rate: null,
hours_per_week: 40,
workdays_per_week: 5,
employment_start: '2024-01-01',
employment_end: null,
}
let mock: ReturnType<typeof createQueuedMockSupabase>
let supabase: SupabaseClient
beforeEach(() => {
vi.clearAllMocks()
mock = createQueuedMockSupabase()
supabase = mock.supabase as unknown as SupabaseClient
mockSync.mockResolvedValue({ ok: true })
mockCheckPeriodLock.mockResolvedValue({ locked: false })
mockTrialBalance.mockResolvedValue({
rows: [
{ account_number: '2920', closing_credit: 10000, closing_debit: 0 },
{ account_number: '2940', closing_credit: 3142, closing_debit: 0 },
],
totalDebit: 0,
totalCredit: 0,
isBalanced: true,
})
})
/** Queue the preview's supabase calls: closure check, roster, ledger rows,
* fiscal period (for the booked-balance read). */
function queuePreview(over: {
ledger?: Array<Record<string, unknown>>
closure?: { id: string } | null
} = {}) {
mock.enqueue({ data: over.closure ?? null }) // vacation_year_closures check
mock.enqueue({ data: [ROSTER_ROW] }) // roster
mock.enqueue({ data: over.ledger ?? [] }) // ledger rows for the closing year
mock.enqueue({ data: { id: 'fp-2025', period_start: '2025-01-01', period_end: '2025-12-31' } }) // fiscal period
}
describe('previewVacationYearClose: beredning math', () => {
it('rolls only days above the 20-day floor and flags the rest', async () => {
queuePreview({
ledger: [
{
id: 'vb-1',
employee_id: EMPLOYEE_ID,
vacation_year_start: '2025-01-01',
entitled_days: 25,
accrued_days: 0,
taken_days: 15,
saved_days: {},
forced_payout_days: 0,
status: 'open',
},
],
})
const result = await previewVacationYearClose(supabase, COMPANY_ID, '2025-01-01')
expect(result.ok).toBe(true)
if (!result.ok) return
const row = result.data.rows[0]
// remaining 10, but only entitled - 20 = 5 are saveable.
expect(row.remaining_days).toBe(10)
expect(row.saveable_days).toBe(5)
expect(row.untaken_below_floor_days).toBe(5)
expect(row.saved_days_after).toEqual({ '2025': 5 })
expect(row.next_year_entitled).toBe(25)
})
it('expires saved days older than 5 years into forced payout', async () => {
queuePreview({
ledger: [
{
id: 'vb-1',
employee_id: EMPLOYEE_ID,
vacation_year_start: '2025-01-01',
entitled_days: 25,
accrued_days: 0,
taken_days: 25,
// 2020 was saveable through 2025: expires at THIS close.
saved_days: { '2020': 3, '2022': 2 },
forced_payout_days: 0,
status: 'open',
},
],
})
const result = await previewVacationYearClose(supabase, COMPANY_ID, '2025-01-01')
expect(result.ok).toBe(true)
if (!result.ok) return
const row = result.data.rows[0]
expect(row.expiring_days).toBe(3)
expect(row.saved_days_after).toEqual({ '2022': 2 })
})
it('computes the SEK drift against the booked 2920/2940', async () => {
queuePreview({
ledger: [
{
id: 'vb-1',
employee_id: EMPLOYEE_ID,
vacation_year_start: '2025-01-01',
entitled_days: 25,
accrued_days: 0,
taken_days: 15,
saved_days: { '2023': 2 },
forced_payout_days: 0,
status: 'open',
},
],
})
const result = await previewVacationYearClose(supabase, COMPANY_ID, '2025-01-01')
expect(result.ok).toBe(true)
if (!result.ok) return
const { sek, rows } = result.data
// Day value sammalöneregeln: 30000/21 + 30000 x 0.0043 = 1428.57 + 129 = 1557.57.
expect(rows[0].day_value_sek).toBe(1557.57)
// Liability days = remaining 10 + saved 2 = 12.
expect(rows[0].computed_liability_sek).toBe(18690.84)
expect(sek.computed_liability).toBe(18690.84)
expect(sek.booked_2920).toBe(10000)
expect(sek.drift_2920).toBe(8690.84)
expect(sek.adjustment_needed).toBe(true)
})
it('applies per-employee age-tier avgifter to the 2940 target', async () => {
const EMPLOYEE_2 = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
const EMPLOYEE_3 = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'
const ledgerRow = (employeeId: string) => ({
id: `vb-${employeeId}`,
employee_id: employeeId,
vacation_year_start: '2025-01-01',
entitled_days: 25,
accrued_days: 0,
taken_days: 15,
saved_days: {},
forced_payout_days: 0,
status: 'open',
})
mock.enqueue({ data: null }) // closure check
mock.enqueue({
data: [
{ ...ROSTER_ROW, personnummer: 'mock_born_1990' },
{ ...ROSTER_ROW, id: EMPLOYEE_2, first_name: 'Sven', personnummer: 'mock_born_1957' },
{ ...ROSTER_ROW, id: EMPLOYEE_3, first_name: 'Ulla', personnummer: 'mock_born_1935' },
],
}) // roster
mock.enqueue({
data: [ledgerRow(EMPLOYEE_ID), ledgerRow(EMPLOYEE_2), ledgerRow(EMPLOYEE_3)],
}) // ledger rows
mock.enqueue({ data: { id: 'fp-2025', period_start: '2025-01-01', period_end: '2025-12-31' } })
const result = await previewVacationYearClose(supabase, COMPANY_ID, '2025-01-01')
expect(result.ok).toBe(true)
if (!result.ok) return
const { rows, sek } = result.data
// Settlement year 2026: born 1990 standard, born 1957 fyllt 67 vid
// årets ingång (10.21%), born 1935 exempt (0%).
expect(rows.map((r) => r.avgifter_rate)).toEqual([0.3142, 0.1021, 0])
// Each employee: 10 remaining days x 1557.57 = 15 575.70 liability.
// 15575.70 x 0.3142 = 4893.88, 15575.70 x 0.1021 = 1590.28, exempt 0.
expect(sek.computed_avgifter).toBe(6484.16)
expect(sek.drift_2940).toBe(3342.16)
})
it('refuses to close a year that has not ended', async () => {
const currentYear = new Date().getFullYear()
const result = await previewVacationYearClose(supabase, COMPANY_ID, `${currentYear}-01-01`)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('VACATION_YEAR_NOT_ENDED')
})
it('refuses to preview an already-closed year', async () => {
queuePreview({ closure: { id: 'closure-1' } })
const result = await previewVacationYearClose(supabase, COMPANY_ID, '2025-01-01')
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('VACATION_YEAR_ALREADY_CLOSED')
})
})
describe('commitVacationYearClose', () => {
function queueCommitAfterPreview() {
mock.enqueue({ data: { id: 'closure-1' } }) // closure insert
mock.enqueue({ data: null }) // ledger close update
mock.enqueue({ data: null }) // next-year upsert
mock.enqueue({ data: { id: 'fp-2025' } }) // fiscal period for adjustment
mock.enqueue({ data: null }) // closure update with entry id
}
it('closes the year, rolls balances, and books the drift adjustment', async () => {
queuePreview({
ledger: [
{
id: 'vb-1',
employee_id: EMPLOYEE_ID,
vacation_year_start: '2025-01-01',
entitled_days: 25,
accrued_days: 0,
taken_days: 15,
saved_days: {},
forced_payout_days: 0,
status: 'open',
},
],
})
queueCommitAfterPreview()
mockCreateJournalEntry.mockResolvedValue({ id: 'je-adjust-1' })
const result = await commitVacationYearClose(supabase, COMPANY_ID, 'user-1', '2025-01-01', {
bookAdjustment: true,
})
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.data.closure_id).toBe('closure-1')
expect(result.data.adjustment_entry_id).toBe('je-adjust-1')
// The adjustment entry goes through the bookkeeping engine with balanced
// 7290/2920 + 7519/2940 legs.
const input = mockCreateJournalEntry.mock.calls[0][3] as {
entry_date: string
source_type: string
lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>
}
expect(input.entry_date).toBe('2025-12-31')
expect(input.source_type).toBe('salary_payment')
const totalDebit = input.lines.reduce((s, l) => s + l.debit_amount, 0)
const totalCredit = input.lines.reduce((s, l) => s + l.credit_amount, 0)
expect(totalDebit).toBeCloseTo(totalCredit, 2)
expect(input.lines.map((l) => l.account_number).sort()).toEqual(['2920', '2940', '7290', '7519'])
})
it('fails cleanly when the adjustment period is locked (before any writes)', async () => {
queuePreview({
ledger: [
{
id: 'vb-1',
employee_id: EMPLOYEE_ID,
vacation_year_start: '2025-01-01',
entitled_days: 25,
accrued_days: 0,
taken_days: 15,
saved_days: {},
forced_payout_days: 0,
status: 'open',
},
],
})
mockCheckPeriodLock.mockResolvedValue({ locked: true, reason: 'period_closed' })
const result = await commitVacationYearClose(supabase, COMPANY_ID, 'user-1', '2025-01-01', {
bookAdjustment: true,
})
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('PERIOD_LOCKED')
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
})
it('maps a closure-insert conflict to VACATION_YEAR_ALREADY_CLOSED (replay)', async () => {
queuePreview({
ledger: [
{
id: 'vb-1',
employee_id: EMPLOYEE_ID,
vacation_year_start: '2025-01-01',
entitled_days: 25,
accrued_days: 0,
taken_days: 25,
saved_days: {},
forced_payout_days: 0,
status: 'open',
},
],
})
mock.enqueue({ data: null, error: { code: '23505', message: 'duplicate' } }) // closure insert races
const result = await commitVacationYearClose(supabase, COMPANY_ID, 'user-1', '2025-01-01', {
bookAdjustment: false,
})
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('VACATION_YEAR_ALREADY_CLOSED')
})
})
@@ -0,0 +1,181 @@
/**
* Vacation-year boundaries + ledger sync (payroll gap-closure 3.2).
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase } from '@/tests/helpers'
import {
getClosableYearStart,
getVacationYearBounds,
getVacationYearStart,
} from '@/lib/salary/vacation-year'
import { syncVacationLedgerForEmployees } from '@/lib/salary/vacation-ledger'
describe('vacation-year helpers', () => {
it('calendar basis: Jan 1 boundary', () => {
expect(getVacationYearStart('2026-07-13', 'calendar')).toBe('2026-01-01')
expect(getVacationYearStart('2026-01-01', 'calendar')).toBe('2026-01-01')
expect(getVacationYearBounds('2026-01-01')).toEqual({ start: '2026-01-01', end: '2027-01-01' })
expect(getClosableYearStart('2026-07-13', 'calendar')).toBe('2025-01-01')
})
it('statutory basis: Apr 1 boundary, Jan-Mar belongs to the previous start', () => {
expect(getVacationYearStart('2026-07-13', 'statutory_apr_mar')).toBe('2026-04-01')
expect(getVacationYearStart('2026-03-31', 'statutory_apr_mar')).toBe('2025-04-01')
expect(getVacationYearStart('2026-04-01', 'statutory_apr_mar')).toBe('2026-04-01')
expect(getVacationYearBounds('2025-04-01')).toEqual({ start: '2025-04-01', end: '2026-04-01' })
expect(getClosableYearStart('2026-02-15', 'statutory_apr_mar')).toBe('2024-04-01')
})
})
describe('syncVacationLedgerForEmployees', () => {
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
let mock: ReturnType<typeof createQueuedMockSupabase>
let supabase: SupabaseClient
let upserted: Array<Record<string, unknown>> | null
beforeEach(() => {
vi.clearAllMocks()
upserted = null
mock = createQueuedMockSupabase()
// Wrap from() to capture the upsert payload while keeping queue behavior.
const originalFrom = mock.supabase.from
mock.supabase.from = vi.fn((table: string) => {
const chain = originalFrom(table) as Record<string, unknown>
return new Proxy(chain as object, {
get(target, prop) {
if (prop === 'upsert' && table === 'employee_vacation_balances') {
return (rows: Array<Record<string, unknown>>) => {
upserted = rows
return (target as Record<string, (...a: unknown[]) => unknown>).upsert?.(rows) ?? target
}
}
return (target as Record<string | symbol, unknown>)[prop]
},
})
}) as never
supabase = mock.supabase as unknown as SupabaseClient
})
const queueBase = (over: {
basis?: string
booked?: Array<{ employee_id: string; vacation_days_taken: number; salary_run: { period_year: number; period_month: number; status: string } }>
openRows?: Array<Record<string, unknown>>
opening?: Array<Record<string, unknown>>
savedLegacy?: number
}) => {
mock.enqueue({ data: { salary_vacation_year_basis: over.basis ?? 'calendar' } }) // company_settings
mock.enqueue({
data: [
{
id: EMPLOYEE_ID,
vacation_days_per_year: 25,
vacation_days_saved: over.savedLegacy ?? 0,
vacation_rule: 'procentregeln',
},
],
}) // employees
mock.enqueue({ data: over.opening ?? [] }) // opening balances
mock.enqueue({ data: over.openRows ?? [] }) // existing open ledger rows
mock.enqueue({ data: over.booked ?? [] }) // booked sre rows
mock.enqueue({ data: null }) // upsert result
}
it('lazy-seeds the current year and recomputes taken from booked runs', async () => {
queueBase({
booked: [
{ employee_id: EMPLOYEE_ID, vacation_days_taken: 3, salary_run: { period_year: 2026, period_month: 6, status: 'booked' } },
{ employee_id: EMPLOYEE_ID, vacation_days_taken: 2, salary_run: { period_year: 2026, period_month: 7, status: 'booked' } },
// Prior year: outside the current vacation year bounds.
{ employee_id: EMPLOYEE_ID, vacation_days_taken: 5, salary_run: { period_year: 2025, period_month: 7, status: 'booked' } },
],
})
const result = await syncVacationLedgerForEmployees(supabase, COMPANY_ID, [EMPLOYEE_ID], '2026-07-13')
expect(result.ok).toBe(true)
expect(upserted).toHaveLength(1)
const row = upserted![0]
expect(row.vacation_year_start).toBe('2026-01-01')
expect(row.entitled_days).toBe(25)
expect(row.taken_days).toBe(5)
// Calendar basis: sammanfallande year, accrued stays 0.
expect(row.accrued_days).toBe(0)
})
it('seeds entitled + saved days from the cutover opening row', async () => {
queueBase({
opening: [
{
employee_id: EMPLOYEE_ID,
cutover_date: '2026-07-01',
vacation_paid_days_remaining: 12.5,
vacation_saved_days_by_year: { '2025': 5 },
},
],
})
const result = await syncVacationLedgerForEmployees(supabase, COMPANY_ID, [EMPLOYEE_ID], '2026-07-13')
expect(result.ok).toBe(true)
const row = upserted![0]
expect(row.entitled_days).toBe(12.5)
expect(row.saved_days).toEqual({ '2025': 5 })
})
it('seeds legacy vacation_days_saved under the previous year when no cutover row exists', async () => {
queueBase({ savedLegacy: 4 })
const result = await syncVacationLedgerForEmployees(supabase, COMPANY_ID, [EMPLOYEE_ID], '2026-07-13')
expect(result.ok).toBe(true)
const row = upserted![0]
expect(row.saved_days).toEqual({ '2025': 4 })
})
it('recomputes existing open rows instead of duplicating them', async () => {
queueBase({
openRows: [
{
id: 'row-1',
employee_id: EMPLOYEE_ID,
vacation_year_start: '2026-01-01',
entitled_days: 25,
accrued_days: 0,
taken_days: 99, // stale: recompute must overwrite from booked runs
saved_days: { '2025': 2 },
forced_payout_days: 0,
status: 'open',
},
],
booked: [
{ employee_id: EMPLOYEE_ID, vacation_days_taken: 1, salary_run: { period_year: 2026, period_month: 5, status: 'booked' } },
],
})
const result = await syncVacationLedgerForEmployees(supabase, COMPANY_ID, [EMPLOYEE_ID], '2026-07-13')
expect(result.ok).toBe(true)
expect(upserted).toHaveLength(1)
const row = upserted![0]
expect(row.taken_days).toBe(1)
expect(row.saved_days).toEqual({ '2025': 2 })
})
it('accrues toward next year on the statutory basis (elapsed months / 12)', async () => {
queueBase({ basis: 'statutory_apr_mar' })
const result = await syncVacationLedgerForEmployees(supabase, COMPANY_ID, [EMPLOYEE_ID], '2026-10-15')
expect(result.ok).toBe(true)
const row = upserted![0]
expect(row.vacation_year_start).toBe('2026-04-01')
// Apr -> Oct = 6 whole months: 6/12 x 25 = 12.5.
expect(row.accrued_days).toBe(12.5)
})
it('never throws: DB errors return ok:false (non-fatal contract)', async () => {
mock.enqueue({ data: null }) // company_settings (defaults calendar)
mock.enqueue({ data: null, error: { message: 'boom' } }) // employees fails
const result = await syncVacationLedgerForEmployees(supabase, COMPANY_ID, [EMPLOYEE_ID], '2026-07-13')
expect(result.ok).toBe(false)
})
})
@@ -0,0 +1,104 @@
/**
* Non-default work-schedule threading (payroll gap-closure 4.2).
*
* The default-schedule regression proof is the EXISTING absence/accrual
* suites passing unchanged (they exercise the legacy 21 divisor). These
* tests cover the new path: a part-time schedule's divisor flows into sick,
* VAB, parental, unpaid-leave, and sammalöneregeln day valuations.
*/
import { describe, expect, it } from 'vitest'
import {
calculateVabDeduction,
calculateParentalLeaveDeduction,
calculateSjuklon,
} from '@/lib/salary/absence-calculator'
import { calculateVacationAccrual } from '@/lib/salary/calculation-engine'
import { deriveAbsenceLineItems, type AbsenceDay, type DeriveInput } from '@/lib/salary/derive-absence-line-items'
import { dailyDivisor } from '@/lib/salary/work-schedule'
import type { PayrollConfig } from '@/lib/salary/payroll-config'
const config = {
sjuklonRate: 0.8,
karensavdragFactor: 0.2,
maxKarensavdragPerYear: 10,
} as PayrollConfig
const days = (entries: Array<[string, AbsenceDay['absence_type']]>): AbsenceDay[] =>
entries.map(([d, t]) => ({ absence_date: d, absence_type: t, hours: 8 }))
const baseInput = (over: Partial<DeriveInput> = {}): DeriveInput => ({
monthlySalary: 30000,
payrollConfig: config,
periodDays: [],
lookbackSickDates: [],
vabDaysYtd: 0,
parentalDaysPregnancyYtd: 0,
...over,
})
describe('non-default schedule: 4-day week (divisor 17.33)', () => {
const divisor = dailyDivisor(4)
it('VAB deduction uses the schedule divisor', () => {
const fourDay = calculateVabDeduction(30000, 2, 0, divisor)
const fiveDay = calculateVabDeduction(30000, 2, 0)
// 30000 / 17.33 = 1731.1 per day vs 30000 / 21 = 1428.57.
expect(fourDay.deduction).toBe(3462.2)
expect(fiveDay.deduction).toBe(2857.14)
expect(fourDay.deduction).toBeGreaterThan(fiveDay.deduction)
})
it('parental leave deduction uses the schedule divisor', () => {
const result = calculateParentalLeaveDeduction(30000, 1, 0, divisor)
expect(result.deduction).toBe(1731.1)
})
it('sjuklön daily rate scales while the weekly karens base does not', () => {
const fourDay = calculateSjuklon(30000, 3, config, false, divisor)
const fiveDay = calculateSjuklon(30000, 3, config, false)
expect(fourDay.dailyRate).toBe(1731.1)
expect(fiveDay.dailyRate).toBe(1428.57)
// Karensavdrag derives from the WEEKLY rate (monthly x 12/52 x 80%),
// which is schedule-independent by construction.
expect(fourDay.karensavdrag).toBe(fiveDay.karensavdrag)
})
it('derived sick + unpaid-leave line items use the schedule divisor', () => {
const result = deriveAbsenceLineItems(
baseInput({
periodDays: days([
['2026-04-06', 'sick'],
['2026-04-07', 'sick'],
['2026-04-08', 'unpaid_leave'],
]),
dailyDivisor: divisor,
}),
)
const day2 = result.lineItems.find((li) => li.item_type === 'sick_day2_14')!
// Day 2 net deduction = dailyRate - dailyRate x 80% = 20% of 1731.1.
expect(Math.abs(day2.amount)).toBeCloseTo(1731.1 - 1731.1 * 0.8, 1)
const unpaid = result.lineItems.find((li) => li.item_type === 'unpaid_leave')!
expect(unpaid.amount).toBe(-1731.1)
})
it('sammalöneregeln accrual values days by the schedule divisor', () => {
const fourDay = calculateVacationAccrual({
monthlySalary: 30000,
vacationRule: 'sammaloneregeln',
vacationDaysPerYear: 25,
semestertillaggRate: 0.0043,
vacationBasis: 30000,
dailyDivisor: divisor,
})
const fiveDay = calculateVacationAccrual({
monthlySalary: 30000,
vacationRule: 'sammaloneregeln',
vacationDaysPerYear: 25,
semestertillaggRate: 0.0043,
vacationBasis: 30000,
})
// dailyRate 1731.1 vs 1428.57; tillägg = dailyRate x 0.43% x 25 days.
expect(fourDay.accrual).toBe(186.09)
expect(fiveDay.accrual).toBe(153.57)
})
})
@@ -0,0 +1,47 @@
/**
* Divisor helpers for arbetsschema-lite (payroll gap-closure 4.1).
*
* The load-bearing assertion is the LEGACY-CONSTANT contract: at the default
* schedule the helpers return 173/21 exactly (not the exact formulas), so
* existing companies' pay math is byte-identical after the feature lands.
*/
import { describe, expect, it } from 'vitest'
import { dailyDivisor, hourlyDivisor } from '@/lib/salary/work-schedule'
describe('hourlyDivisor', () => {
it('returns the legacy constant 173 at the 40h default (compat contract)', () => {
expect(hourlyDivisor(40)).toBe(173)
// Exact formula would be 173.33: asserting the difference keeps the
// discontinuity deliberate rather than accidental.
expect(hourlyDivisor(40)).not.toBeCloseTo((40 * 52) / 12, 2)
})
it('uses the exact formula for non-default schedules', () => {
expect(hourlyDivisor(32)).toBe(138.67)
expect(hourlyDivisor(20)).toBe(86.67)
expect(hourlyDivisor(60)).toBe(260)
})
it('treats null/undefined as the default schedule', () => {
expect(hourlyDivisor(null)).toBe(173)
expect(hourlyDivisor(undefined)).toBe(173)
})
})
describe('dailyDivisor', () => {
it('returns the legacy constant 21 at the 5-day default (compat contract)', () => {
expect(dailyDivisor(5)).toBe(21)
expect(dailyDivisor(5)).not.toBeCloseTo((5 * 52) / 12, 2)
})
it('uses the exact formula for non-default schedules', () => {
expect(dailyDivisor(4)).toBe(17.33)
expect(dailyDivisor(3)).toBe(13)
expect(dailyDivisor(6)).toBe(26)
})
it('treats null/undefined as the default schedule', () => {
expect(dailyDivisor(null)).toBe(21)
expect(dailyDivisor(undefined)).toBe(21)
})
})
+13 -8
View File
@@ -36,16 +36,19 @@ export function calculateSjuklon(
monthlySalary: number,
sickDays: number,
config: PayrollConfig,
isAterinsjuknande: boolean = false
isAterinsjuknande: boolean = false,
// Arbetsschema-lite: legacy 21 (5-day week) unless the employee's schedule
// says otherwise (dailyDivisor(workdays_per_week) from work-schedule.ts).
dailyDivisor: number = 21
): SjuklonResult {
const steps: AbsenceStep[] = []
const r = (x: number) => Math.round(x * 100) / 100
// Daily rate = monthly / 21 working days
const dailyRate = r(monthlySalary / 21)
// Daily rate = monthly / workdays-per-month divisor
const dailyRate = r(monthlySalary / dailyDivisor)
steps.push({
label: 'Dagslön',
formula: 'monthly_salary / 21',
formula: `monthly_salary / ${dailyDivisor}`,
input: { monthly_salary: monthlySalary },
output: dailyRate,
})
@@ -119,10 +122,11 @@ export function calculateSjuklon(
export function calculateVabDeduction(
monthlySalary: number,
vabDays: number,
totalVabDaysThisYear: number = 0
totalVabDaysThisYear: number = 0,
dailyDivisor: number = 21
): { deduction: number; semesterGrundande: boolean; steps: AbsenceStep[] } {
const r = (x: number) => Math.round(x * 100) / 100
const dailyRate = r(monthlySalary / 21)
const dailyRate = r(monthlySalary / dailyDivisor)
const deduction = r(dailyRate * vabDays)
const semesterGrundande = totalVabDaysThisYear + vabDays <= 120
@@ -145,10 +149,11 @@ export function calculateVabDeduction(
export function calculateParentalLeaveDeduction(
monthlySalary: number,
parentalDays: number,
totalParentalDaysThisPregnancy: number = 0
totalParentalDaysThisPregnancy: number = 0,
dailyDivisor: number = 21
): { deduction: number; semesterGrundande: boolean; steps: AbsenceStep[] } {
const r = (x: number) => Math.round(x * 100) / 100
const dailyRate = r(monthlySalary / 21)
const dailyRate = r(monthlySalary / dailyDivisor)
const deduction = r(dailyRate * parentalDays)
const semesterGrundande = totalParentalDaysThisPregnancy + parentalDays <= 120
+301
View File
@@ -0,0 +1,301 @@
/**
* Shared absence (frånvaro) commands.
*
* Single source of truth for reading and writing salary_absence_days rows,
* consumed by the internal dashboard route
* (app/api/salary/employees/[id]/absence), the v1 REST routes, and the MCP
* staged-operation executor (register_absence).
*
* Storage is strictly PER-DAY rows: sjuklönelagen mechanics (karensavdrag
* boundary, återinsjuknande 5-day merge, högriskskydd 12-month cap, day 14/15
* FK transition) and AGI 2025+ per-event Frånvarouppgift are all derived from
* day rows by the calculation engine. The API accepts ranges for ergonomics
* and expands them server-side.
*
* Upserts use a native ON CONFLICT upsert on the natural key (employee,
* date, type): atomic and truly idempotent, so PUT retries are safe and a
* rejected write (e.g. the 24h cap) never drops existing rows.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
export type AbsenceResult<T> =
| { ok: true; data: T }
| { ok: false; code: string; details?: Record<string, unknown> }
export interface AbsenceDayRow {
id: string
absence_date: string
absence_type: string
hours: number
notes: string | null
salary_run_employee_id: string | null
created_at: string
updated_at: string
}
/** Hard cap on range size: one quarter + buffer. Keeps payloads bounded and
* makes the range the pagination (no cursor needed on the GET). */
export const ABSENCE_RANGE_MAX_DAYS = 92
const ABSENCE_COLUMNS =
'id, absence_date, absence_type, hours, notes, salary_run_employee_id, created_at, updated_at'
async function assertEmployee(
supabase: SupabaseClient,
companyId: string,
employeeId: string,
): Promise<AbsenceResult<{ id: string }>> {
const { data, error } = await supabase
.from('employees')
.select('id')
.eq('id', employeeId)
.eq('company_id', companyId)
.maybeSingle()
if (error) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
if (!data) {
return { ok: false, code: 'EMPLOYEE_NOT_FOUND' }
}
return { ok: true, data: data as { id: string } }
}
/** Expand [from, to] (inclusive, ISO dates) to per-day ISO strings.
* Returns null when the range is inverted or exceeds the cap. */
export function expandDateRange(
from: string,
to: string,
opts: { includeWeekends?: boolean } = {},
): string[] | null {
const start = Date.parse(`${from}T00:00:00Z`)
const end = Date.parse(`${to}T00:00:00Z`)
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end) return null
const DAY_MS = 86_400_000
const spanDays = Math.round((end - start) / DAY_MS) + 1
if (spanDays > ABSENCE_RANGE_MAX_DAYS) return null
const dates: string[] = []
for (let t = start; t <= end; t += DAY_MS) {
const d = new Date(t)
const dow = d.getUTCDay() // 0 = Sunday, 6 = Saturday
if (!opts.includeWeekends && (dow === 0 || dow === 6)) continue
dates.push(d.toISOString().slice(0, 10))
}
return dates
}
function mapInsertError(error: { code?: string; message?: string }): {
code: string
details?: Record<string, unknown>
} {
// The 24h cap trigger raises check_violation when worked + absence > 24h
// for the same date.
if (error.code === '23514' || error.message?.includes('Total tid')) {
return { code: 'ABSENCE_HOURS_CONFLICT', details: { message: error.message } }
}
return { code: 'INTERNAL_ERROR', details: { message: error.message } }
}
export async function listAbsenceDays(
supabase: SupabaseClient,
args: {
companyId: string
employeeId: string
from: string
to: string
absenceType?: string
},
): Promise<AbsenceResult<AbsenceDayRow[]>> {
const emp = await assertEmployee(supabase, args.companyId, args.employeeId)
if (!emp.ok) return emp
let query = supabase
.from('salary_absence_days')
.select(ABSENCE_COLUMNS)
.eq('company_id', args.companyId)
.eq('employee_id', args.employeeId)
.gte('absence_date', args.from)
.lte('absence_date', args.to)
.order('absence_date', { ascending: true })
if (args.absenceType) {
query = query.eq('absence_type', args.absenceType)
}
const { data, error } = await query
if (error) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
return { ok: true, data: (data ?? []) as unknown as AbsenceDayRow[] }
}
export async function upsertAbsenceDay(
supabase: SupabaseClient,
args: {
companyId: string
employeeId: string
day: {
absence_date: string
absence_type: string
hours: number
notes?: string | null
salary_run_employee_id?: string | null
}
},
): Promise<AbsenceResult<AbsenceDayRow>> {
const emp = await assertEmployee(supabase, args.companyId, args.employeeId)
if (!emp.ok) return emp
// Atomic upsert on the natural-key unique index: a rejected write (24h
// cap, constraint) leaves any existing row untouched.
const { data, error } = await supabase
.from('salary_absence_days')
.upsert(
{
company_id: args.companyId,
employee_id: args.employeeId,
absence_date: args.day.absence_date,
absence_type: args.day.absence_type,
hours: args.day.hours,
notes: args.day.notes ?? null,
salary_run_employee_id: args.day.salary_run_employee_id ?? null,
},
{ onConflict: 'employee_id,absence_date,absence_type' },
)
.select(ABSENCE_COLUMNS)
.single()
if (error) {
const mapped = mapInsertError(error)
return { ok: false, ...mapped }
}
return { ok: true, data: data as unknown as AbsenceDayRow }
}
export async function upsertAbsenceRange(
supabase: SupabaseClient,
args: {
companyId: string
employeeId: string
from: string
to: string
absenceType: string
hoursPerDay?: number
notes?: string | null
includeWeekends?: boolean
/** Validate + expand only; return the would-be days without writing. */
dryRun?: boolean
},
): Promise<AbsenceResult<{ count: number; days: AbsenceDayRow[] | Array<{ absence_date: string; absence_type: string; hours: number }> }>> {
const emp = await assertEmployee(supabase, args.companyId, args.employeeId)
if (!emp.ok) return emp
const dates = expandDateRange(args.from, args.to, { includeWeekends: args.includeWeekends })
if (dates === null) {
return {
ok: false,
code: 'ABSENCE_RANGE_TOO_LARGE',
details: { from: args.from, to: args.to, max_days: ABSENCE_RANGE_MAX_DAYS },
}
}
const hours = args.hoursPerDay ?? 8
const rows = dates.map((absence_date) => ({
company_id: args.companyId,
employee_id: args.employeeId,
absence_date,
absence_type: args.absenceType,
hours,
notes: args.notes ?? null,
salary_run_employee_id: null,
}))
if (args.dryRun) {
return {
ok: true,
data: {
count: rows.length,
days: rows.map((r) => ({
absence_date: r.absence_date,
absence_type: r.absence_type,
hours: r.hours,
})),
},
}
}
if (rows.length === 0) {
return { ok: true, data: { count: 0, days: [] } }
}
// Bulk atomic upsert on the natural-key unique index (employee, date,
// type). One statement: a retry converges on the same end state, and a
// rejected write (e.g. the 24h cap on one day) rolls back the whole range
// without dropping the previously stored rows.
const { data, error } = await supabase
.from('salary_absence_days')
.upsert(rows, { onConflict: 'employee_id,absence_date,absence_type' })
.select(ABSENCE_COLUMNS)
if (error) {
const mapped = mapInsertError(error)
return { ok: false, ...mapped }
}
const inserted = (data ?? []) as unknown as AbsenceDayRow[]
return { ok: true, data: { count: inserted.length, days: inserted } }
}
export async function deleteAbsenceRange(
supabase: SupabaseClient,
args: {
companyId: string
employeeId: string
from: string
to: string
absenceType?: string
/** Validate only; do not delete. */
dryRun?: boolean
},
): Promise<AbsenceResult<{ deleted_count: number }>> {
const emp = await assertEmployee(supabase, args.companyId, args.employeeId)
if (!emp.ok) return emp
if (args.dryRun) {
// Count what WOULD be deleted so the preview is informative.
let countQuery = supabase
.from('salary_absence_days')
.select('id', { count: 'exact', head: true })
.eq('company_id', args.companyId)
.eq('employee_id', args.employeeId)
.gte('absence_date', args.from)
.lte('absence_date', args.to)
if (args.absenceType) countQuery = countQuery.eq('absence_type', args.absenceType)
const { count, error } = await countQuery
if (error) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
return { ok: true, data: { deleted_count: count ?? 0 } }
}
let query = supabase
.from('salary_absence_days')
.delete({ count: 'exact' })
.eq('company_id', args.companyId)
.eq('employee_id', args.employeeId)
.gte('absence_date', args.from)
.lte('absence_date', args.to)
if (args.absenceType) {
query = query.eq('absence_type', args.absenceType)
}
const { count, error } = await query
if (error) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
return { ok: true, data: { deleted_count: count ?? 0 } }
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Derived AGI filing state for a salary run.
*
* Combines the run row's authoritative timestamps (agi_generated_at,
* agi_submitted_at) with the Skatteverket extension's per-period submission
* record (extension_data key `agi_submission_{period}`, surfaced via
* GET /api/extensions/ext/skatteverket/agi/status).
*
* The submission record is optional: self-hosted installs without the
* Skatteverket extension, users without the capability, and periods that
* were never submitted have none. The derivation then falls back to what
* the run row alone can tell ('none' | 'generated' | 'signed').
*/
/**
* Per-period submission state mirrored in extension_data under
* `agi_submission_{period}`. Matches the status enum the Skatteverket
* extension handlers write back.
*/
export interface AgiSubmissionState {
status?:
| 'underlag_submitted' // POST /underlag returned an inlamningId
| 'underlag_rejected' // kontrollresultat surfaced stoppande fel
| 'awaiting_signing' // skapaGranskningsunderlag returned a link
| 'signed' // kvittenser shows uuidKvittens for the period
signeringslank?: string
kvittensnummer?: string
signeradAv?: string
signeradTid?: string
inlamningId?: number
tillstand?: string
meddelande?: string
/** ISO timestamp the submission record was last written by the extension. */
updatedAt?: string
}
export type AgiFilingState =
| 'none'
| 'generated'
| 'underlag_submitted'
| 'awaiting_signing'
| 'signed'
export function deriveAgiFilingState(
run: { agi_generated_at?: string | null; agi_submitted_at?: string | null },
submission: AgiSubmissionState | null | undefined,
): AgiFilingState {
// agi_submitted_at is stamped when a kvittens is observed (the canonical
// filing receipt), so it is authoritative over the cached submission state.
if (run.agi_submitted_at || submission?.status === 'signed') return 'signed'
if (submission?.status === 'awaiting_signing') return 'awaiting_signing'
if (submission?.status === 'underlag_submitted') return 'underlag_submitted'
// underlag_rejected: the underlag at Skatteverket is dead; the user starts
// over from the generated XML, so it renders the same as plain 'generated'.
if (run.agi_generated_at) return 'generated'
return 'none'
}
+15 -4
View File
@@ -34,6 +34,11 @@ export interface SalaryCalculationInput {
vacationRule: 'procentregeln' | 'sammaloneregeln' | 'none' | 'semesterersattning'
vacationDaysPerYear: number
semestertillaggRate: number
/** Work-schedule daily-rate divisor (arbetsschema-lite). Defaults to the
* legacy 21 (5-day week); callers with a part-time schedule pass
* dailyDivisor(workdays_per_week) from lib/salary/work-schedule. Used by
* the sammalöneregeln day valuation. */
dailyDivisor?: number
/** Växa-stöd */
vaxaStodEligible: boolean
@@ -546,7 +551,7 @@ export function calculateSalary(
// since the base salary is expensed monthly regardless of vacation.
// Use baseSalary (degree-adjusted): a 50% part-timer's tillägg should be
// half a full-timer's, not the same.
const dailyRate = r(baseSalary / 21)
const dailyRate = r(baseSalary / (input.dailyDivisor ?? 21))
const tillagg = r(dailyRate * input.semestertillaggRate * input.vacationDaysPerYear)
vacationAccrual = tillagg
steps.push({
@@ -674,6 +679,8 @@ export function calculateAvgifterRate(
// eligible at year start (18-22) become 19-23 during the year. We test the
// year-start age, not the during-year age. Skatteverket's AGI validator
// rejects 23-year-olds at year start as not eligible.
// calculateAgeAtYearStart is birth-year based (2026: born 2003-2007), so
// January 1 birthdays land in the correct Skatteverket cohort.
// Active period: 1 April 2026 - 30 September 2027.
if (config.avgifterYouthRate !== null && ageAtYearStart >= 18 && ageAtYearStart <= 22) {
const [, monthStr] = input.paymentDate.split('-')
@@ -720,10 +727,12 @@ export function calculateKarensavdrag(monthlySalary: number, config: PayrollConf
export function calculateSjuklon(
monthlySalary: number,
sickDays: number,
config: PayrollConfig
config: PayrollConfig,
// Arbetsschema-lite: legacy 21 unless the employee's schedule differs.
dailyDivisor: number = 21
): { karensavdrag: number; sjuklon: number; totalDeduction: number; steps: CalculationStep[] } {
const steps: CalculationStep[] = []
const dailyRate = r(monthlySalary / 21)
const dailyRate = r(monthlySalary / dailyDivisor)
// Karensavdrag
const karensavdrag = calculateKarensavdrag(monthlySalary, config)
@@ -766,6 +775,8 @@ export function calculateVacationAccrual(params: {
vacationDaysPerYear: number
semestertillaggRate: number
vacationBasis: number
/** Arbetsschema-lite daily-rate divisor; legacy 21 when omitted. */
dailyDivisor?: number
}): { accrual: number; steps: CalculationStep[] } {
const steps: CalculationStep[] = []
@@ -803,7 +814,7 @@ export function calculateVacationAccrual(params: {
// Sammalöneregeln: tillägg per vacation day. Use vacationBasis as the
// degree-adjusted reference: callers must pass the part-time-adjusted
// monthly amount, never the raw full-time monthlySalary.
const dailyRate = r(params.vacationBasis / 21)
const dailyRate = r(params.vacationBasis / (params.dailyDivisor ?? 21))
const accrual = r(dailyRate * params.semestertillaggRate * params.vacationDaysPerYear)
steps.push({
label: `Semesteravsättning (sammalöneregeln ${fmtPct(params.semestertillaggRate)})`,
+26 -4
View File
@@ -163,6 +163,16 @@ export interface DeriveInput {
/** Parental leave days in the current pregnancy window (best-effort:
* defaults to calendar-year aggregate). */
parentalDaysPregnancyYtd: number
/** Cutover state (payroll gap-closure 2.2): karens periods in the 12
* months before cutover NOT represented by imported salary_absence_days
* rows. Added to the högriskskydd window count so a mid-year switcher's
* cap position carries over. The caller zeroes this once the lookback
* window no longer overlaps pre-cutover time. */
karensPeriodsAdjustment?: number
/** Work-schedule daily-rate divisor (arbetsschema-lite). Defaults to the
* legacy 21 (5-day week); part-time schedules pass
* dailyDivisor(workdays_per_week) from lib/salary/work-schedule. */
dailyDivisor?: number
}
export function deriveAbsenceLineItems(input: DeriveInput): DeriveResult {
@@ -209,9 +219,14 @@ export function deriveAbsenceLineItems(input: DeriveInput): DeriveResult {
const lookbackOnlySegments = buildSjukloneperioder(
input.lookbackSickDates.filter(d => d >= cutoff),
)
let karensInWindow = lookbackOnlySegments.length
// Cutover adjustment: karens periods from the previous payroll system
// that were never imported as day rows. Over-suppression of karens is
// the softer error (consistent with the period-count reading above).
let karensInWindow = lookbackOnlySegments.length + (input.karensPeriodsAdjustment ?? 0)
const dailyRate = r(monthlySalary / 21)
// weeklyRate stays monthly x 12/52 by construction (schedule-independent);
// only the DAILY rate scales with the workday schedule.
const dailyRate = r(monthlySalary / (input.dailyDivisor ?? 21))
const weeklyRate = r(monthlySalary * 12 / 52 * payrollConfig.sjuklonRate)
const karensAmount = r(weeklyRate * payrollConfig.karensavdragFactor)
@@ -304,7 +319,7 @@ export function deriveAbsenceLineItems(input: DeriveInput): DeriveResult {
// ── VAB ────────────────────────────────────────────────────────────────
const vabCount = vabDays.length
if (vabCount > 0) {
const vab = calculateVabDeduction(monthlySalary, vabCount, input.vabDaysYtd)
const vab = calculateVabDeduction(monthlySalary, vabCount, input.vabDaysYtd, input.dailyDivisor)
lineItems.push({
item_type: 'vab',
description: `VAB (${vabCount} dagar)`,
@@ -324,6 +339,7 @@ export function deriveAbsenceLineItems(input: DeriveInput): DeriveResult {
monthlySalary,
parentalCount,
input.parentalDaysPregnancyYtd,
input.dailyDivisor,
)
lineItems.push({
item_type: 'parental_leave',
@@ -347,7 +363,7 @@ export function deriveAbsenceLineItems(input: DeriveInput): DeriveResult {
// the flag would double-count the amount in Step 4's gross_deduction sum.
const unpaidLeaveCount = unpaidLeaveDays.length
if (unpaidLeaveCount > 0) {
const dailyRate = r(monthlySalary / 21)
const dailyRate = r(monthlySalary / (input.dailyDivisor ?? 21))
const deduction = r(dailyRate * unpaidLeaveCount)
lineItems.push({
item_type: 'unpaid_leave',
@@ -386,6 +402,10 @@ export async function loadAndDeriveAbsence(params: {
payrollConfig: PayrollConfig
periodStart: string
periodEnd: string
/** See DeriveInput.karensPeriodsAdjustment. */
karensPeriodsAdjustment?: number
/** See DeriveInput.dailyDivisor. */
dailyDivisor?: number
}): Promise<DeriveResult> {
const { supabase, companyId, employeeId, periodStart, periodEnd } = params
@@ -440,5 +460,7 @@ export async function loadAndDeriveAbsence(params: {
lookbackSickDates,
vabDaysYtd,
parentalDaysPregnancyYtd,
karensPeriodsAdjustment: params.karensPeriodsAdjustment,
dailyDivisor: params.dailyDivisor,
})
}
+297
View File
@@ -0,0 +1,297 @@
/**
* Shared employee master-data commands for the MCP staged-operation
* executors (create_employee / update_employee).
*
* PII contract: these functions NEVER receive a plaintext personnummer. The
* staging tool validates the caller's input with CreateEmployeeSchema,
* encrypts the personnummer at staging time, and pending_operations.params
* carries only { personnummer_encrypted, personnummer_last4 }. Masked forms
* for previews/results are derived by decrypting in-process.
*
* The internal dashboard route (app/api/salary/employees) and the v1 routes
* have their own request-shaped handlers today; this module is the executor-
* facing command layer. (Future dedup opportunity noted in the gap-closure
* plan: fold all three onto this service.)
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer'
import { getCompanyEntityType } from '@/lib/company/context'
import { isEmploymentTypeAllowedForEntity, EF_OWNER_EMPLOYMENT_ERROR } from '@/lib/salary/employment-rules'
import { validateEmployeeBankAccount } from '@/lib/salary/payment/bank-account'
export type EmployeeCommandResult<T> =
| { ok: true; data: T }
| { ok: false; code: string; details?: Record<string, unknown> }
export interface EmployeeSummaryResult {
employee_id: string
first_name: string
last_name: string
personnummer_masked: string
is_active: boolean
}
/** Columns an executor is allowed to write. Anything else in params is
* ignored (defense in depth against a tampered pending_operations row). */
const WRITABLE_COLUMNS = new Set([
'first_name',
'last_name',
'employment_type',
'employment_start',
'employment_end',
'employment_degree',
'hours_per_week',
'workdays_per_week',
'salary_type',
'monthly_salary',
'hourly_rate',
'tax_table_number',
'tax_column',
'tax_municipality',
'is_sidoinkomst',
'f_skatt_status',
'clearing_number',
'bank_account_number',
'vacation_rule',
'vacation_days_per_year',
'semestertillagg_rate',
'email',
'phone',
'address_line1',
'postal_code',
'city',
'vaxa_stod_eligible',
'vaxa_stod_start',
'vaxa_stod_end',
'jamkning_percentage',
'jamkning_valid_from',
'jamkning_valid_to',
'default_dimensions',
])
function pickWritable(fields: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {}
for (const [key, value] of Object.entries(fields)) {
if (WRITABLE_COLUMNS.has(key) && value !== undefined) {
out[key] = value
}
}
return out
}
export async function createEmployee(
supabase: SupabaseClient,
args: {
companyId: string
userId: string
/** Validated CreateEmployeeSchema fields, personnummer replaced by the
* encrypted pair at staging time. */
input: Record<string, unknown> & {
personnummer_encrypted: string
personnummer_last4: string
}
},
): Promise<EmployeeCommandResult<EmployeeSummaryResult>> {
const { personnummer_encrypted, personnummer_last4, ...rest } = args.input
if (!personnummer_encrypted || !personnummer_last4) {
return { ok: false, code: 'VALIDATION_ERROR', details: { field: 'personnummer_encrypted' } }
}
const fields = pickWritable(rest)
if (!fields.first_name || !fields.last_name || !fields.employment_start) {
return {
ok: false,
code: 'VALIDATION_ERROR',
details: { message: 'first_name, last_name and employment_start are required' },
}
}
// EF owners cannot be on payroll (egna uttag, not lön). The DB trigger is
// the all-paths backstop; checking here gives a clean error. #782
const entityType = await getCompanyEntityType(supabase, args.companyId)
const employmentType = (fields.employment_type as string | undefined) ?? 'employee'
if (!isEmploymentTypeAllowedForEntity(entityType, employmentType as never)) {
return {
ok: false,
code: 'VALIDATION_ERROR',
details: { field: 'employment_type', message: EF_OWNER_EMPLOYMENT_ERROR },
}
}
const bankIssues = validateEmployeeBankAccount(
fields.clearing_number as string | undefined,
fields.bank_account_number as string | undefined,
)
if (bankIssues.length > 0) {
return {
ok: false,
code: 'VALIDATION_ERROR',
details: { issues: bankIssues.map((i) => ({ field: i.field, message: i.message })) },
}
}
const { data, error } = await supabase
.from('employees')
.insert({
company_id: args.companyId,
user_id: args.userId,
personnummer: personnummer_encrypted,
personnummer_last4,
employment_type: employmentType,
...fields,
})
.select('id, first_name, last_name, personnummer, is_active')
.single()
if (error) {
if (error.code === '23505') {
const constraint = (error as { constraint?: string }).constraint
if (!constraint || constraint.includes('personnummer')) {
return { ok: false, code: 'EMPLOYEE_DUPLICATE_PERSONNUMMER' }
}
}
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
const row = data as { id: string; first_name: string; last_name: string; personnummer: string; is_active: boolean }
return {
ok: true,
data: {
employee_id: row.id,
first_name: row.first_name,
last_name: row.last_name,
personnummer_masked: maskPersonnummer(decryptPersonnummer(row.personnummer)),
is_active: row.is_active,
},
}
}
export async function updateEmployee(
supabase: SupabaseClient,
args: {
companyId: string
employeeId: string
/** Validated UpdateEmployeeSchema fields. Personnummer is rejected at the
* tool boundary (identity immutable); it is never accepted here either. */
patch: Record<string, unknown>
},
): Promise<EmployeeCommandResult<EmployeeSummaryResult>> {
if ('personnummer' in args.patch || 'personnummer_encrypted' in args.patch) {
return {
ok: false,
code: 'VALIDATION_ERROR',
details: { field: 'personnummer', message: 'Identity is immutable post-create.' },
}
}
const updates = pickWritable(args.patch)
if (Object.keys(updates).length === 0) {
return {
ok: false,
code: 'VALIDATION_ERROR',
details: { message: 'At least one updatable field is required.' },
}
}
const { data: existing, error: fetchError } = await supabase
.from('employees')
.select('*')
.eq('id', args.employeeId)
.eq('company_id', args.companyId)
.maybeSingle()
if (fetchError) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: fetchError.message } }
}
if (!existing) {
return { ok: false, code: 'EMPLOYEE_NOT_FOUND' }
}
// Merged-state validation (same rules as the internal PATCH route).
const merged = { ...(existing as Record<string, unknown>), ...updates }
const issues: string[] = []
if (merged.salary_type === 'monthly' && (!merged.monthly_salary || (merged.monthly_salary as number) <= 0)) {
issues.push('Månadslön krävs och måste vara större än 0 för månadslöneform')
}
if (merged.salary_type === 'hourly' && (!merged.hourly_rate || (merged.hourly_rate as number) <= 0)) {
issues.push('Timlön krävs och måste vara större än 0 för timlöneform')
}
if (merged.f_skatt_status === 'a_skatt' && !merged.is_sidoinkomst && !merged.tax_table_number) {
issues.push('Skattetabell krävs för A-skatt anställda')
}
if (merged.vaxa_stod_eligible && !merged.vaxa_stod_start) {
issues.push('Startdatum för Växa-stöd måste anges när Växa-stöd är aktiverat')
}
if (
merged.jamkning_percentage !== null &&
merged.jamkning_percentage !== undefined &&
!merged.jamkning_valid_from
) {
issues.push('Jämkningens startdatum måste anges när jämkningsprocent sätts')
}
if (
merged.jamkning_valid_from &&
merged.jamkning_valid_to &&
(merged.jamkning_valid_to as string) < (merged.jamkning_valid_from as string)
) {
issues.push('Jämkningens slutdatum måste vara efter startdatumet')
}
if (issues.length > 0) {
return { ok: false, code: 'VALIDATION_ERROR', details: { message: issues.join('. ') } }
}
const clearingChanged =
'clearing_number' in updates && updates.clearing_number !== (existing as Record<string, unknown>).clearing_number
const accountChanged =
'bank_account_number' in updates &&
updates.bank_account_number !== (existing as Record<string, unknown>).bank_account_number
if (clearingChanged || accountChanged) {
const bankIssues = validateEmployeeBankAccount(
merged.clearing_number as string | undefined,
merged.bank_account_number as string | undefined,
)
if (bankIssues.length > 0) {
return {
ok: false,
code: 'VALIDATION_ERROR',
details: { issues: bankIssues.map((i) => ({ field: i.field, message: i.message })) },
}
}
}
if ('employment_type' in updates) {
const entityType = await getCompanyEntityType(supabase, args.companyId)
if (!isEmploymentTypeAllowedForEntity(entityType, updates.employment_type as never)) {
return {
ok: false,
code: 'VALIDATION_ERROR',
details: { field: 'employment_type', message: EF_OWNER_EMPLOYMENT_ERROR },
}
}
}
const { data, error } = await supabase
.from('employees')
.update(updates)
.eq('id', args.employeeId)
.eq('company_id', args.companyId)
.select('id, first_name, last_name, personnummer, is_active')
.single()
if (error) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
const row = data as { id: string; first_name: string; last_name: string; personnummer: string; is_active: boolean }
return {
ok: true,
data: {
employee_id: row.id,
first_name: row.first_name,
last_name: row.last_name,
personnummer_masked: maskPersonnummer(decryptPersonnummer(row.personnummer)),
is_active: row.is_active,
},
}
}
+314
View File
@@ -0,0 +1,314 @@
/**
* Employee opening balances (payroll cutover) commands.
*
* Shared by the v1 REST routes, the internal UI route, and the MCP
* staged-operation executor (set_employee_opening_balances). See migration
* 20260713101000 for the data model rationale.
*
* Lifecycle: one row per (company, employee), full-replace upsert, editable
* until the employee appears in a BOOKED salary run. The lock is derived
* (checked here for a clean 409; the DB trigger is the all-paths backstop).
*
* Bulk semantics are ATOMIC all-or-nothing: byrå onboarding wants "all
* imported or fix the file"; partial success would force callers to diff.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { roundOre } from '@/lib/money'
export type OpeningBalancesResult<T> =
| { ok: true; data: T }
| { ok: false; code: string; details?: Record<string, unknown> }
export interface OpeningBalancesInput {
employee_id: string
cutover_date: string
ytd_gross: number
ytd_tax: number
ytd_net: number
vacation_paid_days_remaining: number
vacation_saved_days_by_year: Record<string, number>
opening_semester_liability: number
opening_semester_liability_avgifter: number
karens_periods_adjustment: number
}
export interface OpeningBalancesRow extends OpeningBalancesInput {
employee_opening_balances_id: string
locked: boolean
locked_by_run_id: string | null
created_at: string
updated_at: string
}
const ROW_COLUMNS =
'id, employee_id, cutover_date, ytd_gross, ytd_tax, ytd_net, ' +
'vacation_paid_days_remaining, vacation_saved_days_by_year, ' +
'opening_semester_liability, opening_semester_liability_avgifter, ' +
'karens_periods_adjustment, created_at, updated_at'
/** Booked-run lock lookup for a set of employees. Returns a map of
* employee_id -> blocking booked run id (absent = unlocked). */
export async function getLockingRuns(
supabase: SupabaseClient,
companyId: string,
employeeIds: string[],
): Promise<OpeningBalancesResult<Map<string, string>>> {
if (employeeIds.length === 0) return { ok: true, data: new Map() }
const { data, error } = await supabase
.from('salary_run_employees')
.select('employee_id, salary_run:salary_runs!inner(id, status)')
.eq('company_id', companyId)
.eq('salary_run.status', 'booked')
.in('employee_id', employeeIds)
if (error) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
const locks = new Map<string, string>()
for (const row of (data ?? []) as unknown as Array<{
employee_id: string
salary_run: { id: string; status: string } | null
}>) {
if (row.salary_run && !locks.has(row.employee_id)) {
locks.set(row.employee_id, row.salary_run.id)
}
}
return { ok: true, data: locks }
}
function toRow(
raw: Record<string, unknown>,
locks: Map<string, string>,
): OpeningBalancesRow {
const { id, ...rest } = raw as { id: string } & Record<string, unknown>
const employeeId = rest.employee_id as string
return {
...(rest as unknown as OpeningBalancesInput),
employee_opening_balances_id: id,
locked: locks.has(employeeId),
locked_by_run_id: locks.get(employeeId) ?? null,
created_at: raw.created_at as string,
updated_at: raw.updated_at as string,
}
}
export async function getOpeningBalances(
supabase: SupabaseClient,
args: { companyId: string; employeeId: string },
): Promise<OpeningBalancesResult<OpeningBalancesRow | null>> {
const { data: employee, error: empErr } = await supabase
.from('employees')
.select('id')
.eq('id', args.employeeId)
.eq('company_id', args.companyId)
.maybeSingle()
if (empErr) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: empErr.message } }
}
if (!employee) {
return { ok: false, code: 'EMPLOYEE_NOT_FOUND' }
}
const { data, error } = await supabase
.from('employee_opening_balances')
.select(ROW_COLUMNS)
.eq('company_id', args.companyId)
.eq('employee_id', args.employeeId)
.maybeSingle()
if (error) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
if (!data) {
return { ok: true, data: null }
}
const locks = await getLockingRuns(supabase, args.companyId, [args.employeeId])
if (!locks.ok) return locks
return { ok: true, data: toRow(data as unknown as Record<string, unknown>, locks.data) }
}
export interface BulkItemError {
index: number
employee_id: string
code: string
message: string
}
/**
* Atomic bulk upsert. Validates EVERY item against live state first
* (employee exists + active, employment_start <= cutover_date, not locked);
* any failure returns the full per-item error list with ZERO writes.
*/
export async function setOpeningBalancesBulk(
supabase: SupabaseClient,
args: {
companyId: string
userId: string
items: OpeningBalancesInput[]
/** Validate everything, return the would-be rows, write nothing. */
dryRun?: boolean
},
): Promise<
OpeningBalancesResult<{ count: number; rows: OpeningBalancesRow[] }> & {
itemErrors?: BulkItemError[]
}
> {
if (args.items.length === 0) {
return { ok: true, data: { count: 0, rows: [] } }
}
const employeeIds = args.items.map((i) => i.employee_id)
const duplicateIds = employeeIds.filter((id, idx) => employeeIds.indexOf(id) !== idx)
if (duplicateIds.length > 0) {
return {
ok: false,
code: 'VALIDATION_ERROR',
details: { message: 'Duplicate employee_id in items', duplicates: duplicateIds },
}
}
const { data: employees, error: empErr } = await supabase
.from('employees')
.select('id, employment_start, is_active')
.eq('company_id', args.companyId)
.in('id', employeeIds)
if (empErr) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: empErr.message } }
}
const employeeById = new Map(
((employees ?? []) as Array<{ id: string; employment_start: string; is_active: boolean }>).map(
(e) => [e.id, e],
),
)
const locks = await getLockingRuns(supabase, args.companyId, employeeIds)
if (!locks.ok) return locks
const itemErrors: BulkItemError[] = []
args.items.forEach((item, index) => {
const employee = employeeById.get(item.employee_id)
if (!employee) {
itemErrors.push({
index,
employee_id: item.employee_id,
code: 'EMPLOYEE_NOT_FOUND',
message: 'Employee not found in this company.',
})
return
}
if (!employee.is_active) {
itemErrors.push({
index,
employee_id: item.employee_id,
code: 'EMPLOYEE_NOT_FOUND',
message: 'Employee is inactive; opening balances are for active employees.',
})
return
}
if (employee.employment_start > item.cutover_date) {
itemErrors.push({
index,
employee_id: item.employee_id,
code: 'VALIDATION_ERROR',
message: `cutover_date must be on or after employment_start (${employee.employment_start}).`,
})
return
}
if (locks.data.has(item.employee_id)) {
itemErrors.push({
index,
employee_id: item.employee_id,
code: 'OPENING_BALANCES_LOCKED',
message: `Locked by booked salary run ${locks.data.get(item.employee_id)}.`,
})
}
})
if (itemErrors.length > 0) {
return {
ok: false,
code: 'VALIDATION_ERROR',
details: { item_errors: itemErrors },
itemErrors,
}
}
const rows = args.items.map((item) => ({
company_id: args.companyId,
employee_id: item.employee_id,
cutover_date: item.cutover_date,
ytd_gross: roundOre(item.ytd_gross),
ytd_tax: roundOre(item.ytd_tax),
ytd_net: roundOre(item.ytd_net),
vacation_paid_days_remaining: item.vacation_paid_days_remaining,
vacation_saved_days_by_year: item.vacation_saved_days_by_year,
opening_semester_liability: roundOre(item.opening_semester_liability),
opening_semester_liability_avgifter: roundOre(item.opening_semester_liability_avgifter),
karens_periods_adjustment: item.karens_periods_adjustment,
updated_by: args.userId,
}))
if (args.dryRun) {
return {
ok: true,
data: {
count: rows.length,
rows: rows.map((r) =>
toRow(
{
id: null as unknown as string,
...r,
created_at: null as unknown as string,
updated_at: null as unknown as string,
},
locks.data,
),
),
},
}
}
// created_by is an audit column: it must survive a re-upsert of an
// existing row, so carry the stored value forward and only stamp the
// caller on genuinely new rows.
const { data: existingRows, error: existingErr } = await supabase
.from('employee_opening_balances')
.select('employee_id, created_by')
.eq('company_id', args.companyId)
.in('employee_id', employeeIds)
if (existingErr) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: existingErr.message } }
}
const createdByByEmployee = new Map(
((existingRows ?? []) as Array<{ employee_id: string; created_by: string | null }>).map(
(r) => [r.employee_id, r.created_by],
),
)
// Single multi-row upsert on the natural key: atomic by construction.
const { data: upserted, error } = await supabase
.from('employee_opening_balances')
.upsert(
rows.map((r) => ({
...r,
created_by: createdByByEmployee.get(r.employee_id) ?? args.userId,
})),
{ onConflict: 'company_id,employee_id' },
)
.select(ROW_COLUMNS)
if (error) {
// The DB lock trigger is the all-paths backstop for the race where a run
// books between our pre-flight and the write.
if (error.code === '23514' || error.message?.includes('låsta')) {
return { ok: false, code: 'OPENING_BALANCES_LOCKED', details: { message: error.message } }
}
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
const resultRows = ((upserted ?? []) as unknown as Array<Record<string, unknown>>).map((r) =>
toRow(r, locks.data),
)
return { ok: true, data: { count: resultRows.length, rows: resultRows } }
}
+281
View File
@@ -0,0 +1,281 @@
/**
* Shared payslip line-item commands.
*
* Single source of truth for creating, updating, and deleting
* salary_line_items rows, consumed by:
* - the internal dashboard routes (app/api/salary/runs/[id]/lines/**)
* - the v1 REST routes (app/api/v1/.../salary-runs/[id]/.../lines/**)
* - the MCP staged-operation executor (update_payslip_line)
*
* Rules enforced here so they cannot drift between surfaces:
* - Lines are only editable while the run is in `draft` (BFL 5 kap: once
* the run advances, its numbers feed a verifikation).
* - The target salary_run_employee must belong to the given run.
* - Money is rounded via roundOre() (never naive Math.round(x*100)/100).
* - account_number auto-resolves from the item type when not supplied.
*
* Result-object convention mirrors lib/salary/run-calculation.ts:
* `{ ok: true, data } | { ok: false, code, details? }` where `code` is a key
* in lib/errors/structured-errors.ts.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import type { z } from 'zod'
import type { CreateSalaryLineItemSchema, UpdateSalaryLineItemSchema } from '@/lib/api/schemas'
import { getLineItemAccount } from '@/lib/salary/account-mapping'
import { roundOre } from '@/lib/money'
import type { SalaryLineItemType } from '@/types'
export type PayslipLineResult<T> =
| { ok: true; data: T }
| { ok: false; code: string; details?: Record<string, unknown> }
export interface SalaryLineItemRow {
id: string
salary_run_employee_id: string
company_id: string
item_type: string
description: string
quantity: number | null
unit_price: number | null
amount: number
is_taxable: boolean
is_avgift_basis: boolean
is_vacation_basis: boolean
is_gross_deduction: boolean
is_net_deduction: boolean
account_number: string | null
sort_order: number
created_at: string
updated_at: string
}
export type CreatePayslipLineInput = Omit<
z.infer<typeof CreateSalaryLineItemSchema>,
'salary_run_employee_id'
>
export type UpdatePayslipLineInput = z.infer<typeof UpdateSalaryLineItemSchema>
/** Address the target row either by the join-row id (internal UI) or by the
* employee id (v1/MCP callers, who know employee_id but not the sre id). */
export type PayslipLineTarget = { salaryRunEmployeeId: string } | { employeeId: string }
const LINE_COLUMNS =
'id, salary_run_employee_id, company_id, item_type, description, quantity, unit_price, amount, ' +
'is_taxable, is_avgift_basis, is_vacation_basis, is_gross_deduction, is_net_deduction, ' +
'account_number, sort_order, created_at, updated_at'
/**
* Verify the run exists in this company and is still a draft.
* Exported so surfaces that only need the gate (dry-run previews) can reuse it.
*/
export async function assertRunDraft(
supabase: SupabaseClient,
companyId: string,
salaryRunId: string,
): Promise<PayslipLineResult<{ id: string; status: string }>> {
const { data: run, error } = await supabase
.from('salary_runs')
.select('id, status')
.eq('id', salaryRunId)
.eq('company_id', companyId)
.maybeSingle()
if (error) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
if (!run) {
return { ok: false, code: 'SALARY_RUN_NOT_FOUND' }
}
if ((run as { status: string }).status !== 'draft') {
return {
ok: false,
code: 'SALARY_RUN_LINE_NOT_DRAFT',
details: { current_status: (run as { status: string }).status },
}
}
return { ok: true, data: run as { id: string; status: string } }
}
/** Resolve the salary_run_employees row for a target within a run. */
export async function resolveRunEmployee(
supabase: SupabaseClient,
companyId: string,
salaryRunId: string,
target: PayslipLineTarget,
): Promise<PayslipLineResult<{ id: string; employee_id: string }>> {
let query = supabase
.from('salary_run_employees')
.select('id, employee_id')
.eq('salary_run_id', salaryRunId)
.eq('company_id', companyId)
if ('salaryRunEmployeeId' in target) {
query = query.eq('id', target.salaryRunEmployeeId)
} else {
query = query.eq('employee_id', target.employeeId)
}
const { data: sre, error } = await query.maybeSingle()
if (error) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
if (!sre) {
return { ok: false, code: 'SALARY_RUN_EMPLOYEE_NOT_FOUND' }
}
return { ok: true, data: sre as { id: string; employee_id: string } }
}
/** Load a line and verify it belongs to the given run (via its sre). */
async function loadLineInRun(
supabase: SupabaseClient,
companyId: string,
salaryRunId: string,
lineId: string,
): Promise<PayslipLineResult<SalaryLineItemRow>> {
const { data, error } = await supabase
.from('salary_line_items')
.select(`${LINE_COLUMNS}, salary_run_employee:salary_run_employees(salary_run_id)`)
.eq('id', lineId)
.eq('company_id', companyId)
.maybeSingle()
if (error) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
const row = data as (SalaryLineItemRow & { salary_run_employee?: { salary_run_id: string } | null }) | null
if (!row || row.salary_run_employee?.salary_run_id !== salaryRunId) {
return { ok: false, code: 'SALARY_LINE_NOT_FOUND' }
}
const { salary_run_employee: _sre, ...line } = row
return { ok: true, data: line as SalaryLineItemRow }
}
export async function createPayslipLine(
supabase: SupabaseClient,
args: {
companyId: string
salaryRunId: string
target: PayslipLineTarget
input: CreatePayslipLineInput
/** Validate + resolve only; return the would-be row (id null) without writing. */
dryRun?: boolean
},
): Promise<PayslipLineResult<SalaryLineItemRow | (Omit<SalaryLineItemRow, 'id' | 'created_at' | 'updated_at'> & { id: null })>> {
const gate = await assertRunDraft(supabase, args.companyId, args.salaryRunId)
if (!gate.ok) return gate
const sre = await resolveRunEmployee(supabase, args.companyId, args.salaryRunId, args.target)
if (!sre.ok) return sre
const input = args.input
const accountNumber =
input.account_number || getLineItemAccount(input.item_type as SalaryLineItemType)
const row = {
salary_run_employee_id: sre.data.id,
company_id: args.companyId,
item_type: input.item_type,
description: input.description,
quantity: input.quantity ?? null,
unit_price: input.unit_price ?? null,
amount: roundOre(input.amount),
is_taxable: input.is_taxable,
is_avgift_basis: input.is_avgift_basis,
is_vacation_basis: input.is_vacation_basis,
is_gross_deduction: input.is_gross_deduction,
is_net_deduction: input.is_net_deduction,
account_number: accountNumber,
sort_order: input.sort_order,
}
if (args.dryRun) {
return { ok: true, data: { ...row, id: null } }
}
const { data: created, error } = await supabase
.from('salary_line_items')
.insert(row)
.select(LINE_COLUMNS)
.single()
if (error) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
return { ok: true, data: created as unknown as SalaryLineItemRow }
}
export async function updatePayslipLine(
supabase: SupabaseClient,
args: {
companyId: string
salaryRunId: string
lineId: string
patch: UpdatePayslipLineInput
/** Validate + resolve only; return the merged row without writing. */
dryRun?: boolean
},
): Promise<PayslipLineResult<SalaryLineItemRow>> {
const gate = await assertRunDraft(supabase, args.companyId, args.salaryRunId)
if (!gate.ok) return gate
const existing = await loadLineInRun(supabase, args.companyId, args.salaryRunId, args.lineId)
if (!existing.ok) return existing
const updates: Record<string, unknown> = { ...args.patch }
if (typeof updates.amount === 'number') {
updates.amount = roundOre(updates.amount)
}
if (args.dryRun) {
return { ok: true, data: { ...existing.data, ...updates } as SalaryLineItemRow }
}
const { data: updated, error } = await supabase
.from('salary_line_items')
.update(updates)
.eq('id', args.lineId)
.eq('company_id', args.companyId)
.select(LINE_COLUMNS)
.maybeSingle()
if (error) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
if (!updated) {
return { ok: false, code: 'SALARY_LINE_NOT_FOUND' }
}
return { ok: true, data: updated as unknown as SalaryLineItemRow }
}
export async function deletePayslipLine(
supabase: SupabaseClient,
args: {
companyId: string
salaryRunId: string
lineId: string
/** Validate + resolve only; do not delete. */
dryRun?: boolean
},
): Promise<PayslipLineResult<{ deleted: true; salary_line_item_id: string }>> {
const gate = await assertRunDraft(supabase, args.companyId, args.salaryRunId)
if (!gate.ok) return gate
const existing = await loadLineInRun(supabase, args.companyId, args.salaryRunId, args.lineId)
if (!existing.ok) return existing
if (args.dryRun) {
return { ok: true, data: { deleted: true, salary_line_item_id: args.lineId } }
}
const { error } = await supabase
.from('salary_line_items')
.delete()
.eq('id', args.lineId)
.eq('company_id', args.companyId)
if (error) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
return { ok: true, data: { deleted: true, salary_line_item_id: args.lineId } }
}
+10 -3
View File
@@ -159,11 +159,18 @@ export function calculateAge(personnummer: string, atDate: string): number {
}
/**
* Calculate age at the start of a given year.
* Used for avgifter age tier determination.
* Age tier for "vid årets ingång fyllt X" rules (avgifter age tiers).
*
* Skatteverket applies these rules as BIRTH-YEAR ranges (the 2026
* ungdomsrabatt covers born 2003-2007; the 66/67+ reduction for 2026 covers
* born 1958 or earlier), which equals the age attained by December 31 of
* the PRIOR year. Birthday-inclusive age at January 1 (calculateAge
* semantics) misclassifies employees born exactly on January 1 in both
* directions: born 2008-01-01 would get the 2026 youth rate (Skatteverket's
* AGI validation rejects it) and born 2003-01-01 would be denied it.
*/
export function calculateAgeAtYearStart(personnummer: string, year: number): number {
return calculateAge(personnummer, `${year}-01-01`)
return year - 1 - extractBirthDate(personnummer).year
}
/**
+94 -7
View File
@@ -32,6 +32,8 @@ import { fetchAllTaxTableRatesForRun, TaxTableUnavailableError } from './tax-tab
import { loadAndDeriveAbsence } from './derive-absence-line-items'
import { getLineItemAccount } from './account-mapping'
import { computePremiumLines } from './shift-premium-engine'
import { roundOre } from '@/lib/money'
import { dailyDivisor, hourlyDivisor } from './work-schedule'
import type { WorkedDayShift } from './shift-premium-engine'
import type { Logger } from '@/lib/logger'
import type { SalaryLineItemType, ShiftPremiumRule, ShiftPremiumItemType } from '@/types'
@@ -63,19 +65,20 @@ const DERIVED_PREMIUM_TYPES: ShiftPremiumItemType[] = [
/**
* Effective hourly rate used as the base for shift-premium computation.
* - Hourly employees: their stored hourly_rate.
* - Monthly employees: monthly_salary / 173 (common Swedish derivation for
* full-time monthly → hourly, matches the timlön conventions used in
* CBAs). Applied even to part-timers since the engine multiplies by
* actually-worked premium hours.
* - Monthly employees: monthly_salary / hourlyDivisor(hours_per_week):
* 173 at the 40h default (common Swedish derivation for full-time
* monthly → hourly, matches the timlön conventions used in CBAs), the
* exact 52w formula for other schedules (arbetsschema-lite).
*/
function effectiveHourlyRate(emp: {
salary_type: 'monthly' | 'hourly'
hourly_rate: number | null
monthly_salary: number | null
hours_per_week?: number | null
}): number {
if (emp.salary_type === 'hourly') return emp.hourly_rate || 0
const monthly = emp.monthly_salary || 0
return monthly > 0 ? Math.round((monthly / 173) * 100) / 100 : 0
return monthly > 0 ? Math.round((monthly / hourlyDivisor(emp.hours_per_week)) * 100) / 100 : 0
}
/** Benefit-type → line-item-type mapping for the derived benefit rows. */
@@ -247,13 +250,68 @@ export async function runSalaryCalculation(
.eq('salary_run.status', 'booked')
.lt('salary_run.period_month', run.period_month)
// 6b. Cutover opening balances (payroll gap-closure 2.2): a company that
// switched to Accounted mid-year has YTD state from its previous
// payroll system that no booked run here carries. Fetched BEFORE the
// prior-run aggregation because the cutover month also decides which
// booked runs count (see the exclusion in the loop below). YTD is
// payslip display + reporting only: per-month tax lookup and the
// per-month avgifter caps never read it.
const rosterEmployeeIds = runEmployees
.map((sre) => sre.employee?.id)
.filter((id): id is string => !!id)
const openingByEmployee = new Map<
string,
{ cutoverDate: string; karensPeriodsAdjustment: number }
>()
const openingRowsTyped: Array<{
employee_id: string
cutover_date: string
ytd_gross: number
ytd_tax: number
ytd_net: number
karens_periods_adjustment: number
}> = []
if (rosterEmployeeIds.length > 0) {
const { data: openingRows } = await supabase
.from('employee_opening_balances')
.select('employee_id, cutover_date, ytd_gross, ytd_tax, ytd_net, karens_periods_adjustment')
.eq('company_id', companyId)
.in('employee_id', rosterEmployeeIds)
for (const opening of (openingRows || []) as typeof openingRowsTyped) {
openingRowsTyped.push(opening)
openingByEmployee.set(opening.employee_id, {
cutoverDate: opening.cutover_date,
karensPeriodsAdjustment: opening.karens_periods_adjustment ?? 0,
})
}
}
const ytdByEmployee = new Map<string, { gross: number; tax: number; net: number }>()
for (const prior of (priorRuns || []) as Array<{
// Cast via unknown: supabase-js infers the to-one `salary_run` embed as an
// array, but PostgREST returns an object for a many-to-one relationship.
for (const prior of (priorRuns || []) as unknown as Array<{
employee_id: string
gross_salary: number
tax_withheld: number
net_salary: number
salary_run: { period_year: number; period_month: number }
}>) {
// The opening balance is authoritative for pre-cutover YTD: a booked run
// backdated before the cutover month covers a month the opening already
// carries, so counting both would double the YTD.
const opening = openingByEmployee.get(prior.employee_id)
if (opening) {
const cutoverYear = Number(opening.cutoverDate.slice(0, 4))
const cutoverMonth = Number(opening.cutoverDate.slice(5, 7))
if (
prior.salary_run.period_year === cutoverYear &&
prior.salary_run.period_month < cutoverMonth
) {
continue
}
}
const current = ytdByEmployee.get(prior.employee_id) || { gross: 0, tax: 0, net: 0 }
current.gross += prior.gross_salary
current.tax += prior.tax_withheld
@@ -261,6 +319,22 @@ export async function runSalaryCalculation(
ytdByEmployee.set(prior.employee_id, current)
}
// Merge the opening YTD when the run's period is in the cutover year, on
// or after the cutover month (the month gate prevents double-count if
// someone backdates an in-system run before cutover).
for (const opening of openingRowsTyped) {
const cutoverYear = Number(opening.cutover_date.slice(0, 4))
const cutoverMonth = Number(opening.cutover_date.slice(5, 7))
const runOnOrAfterCutover =
run.period_year === cutoverYear && run.period_month >= cutoverMonth
if (!runOnOrAfterCutover) continue
const current = ytdByEmployee.get(opening.employee_id) || { gross: 0, tax: 0, net: 0 }
current.gross = roundOre(current.gross + (opening.ytd_gross || 0))
current.tax = roundOre(current.tax + (opening.ytd_tax || 0))
current.net = roundOre(current.net + (opening.ytd_net || 0))
ytdByEmployee.set(opening.employee_id, current)
}
// 7. Pay period bounds: used to load per-day absence + worked-day records.
const periodYear = run.period_year as number
const periodMonth = run.period_month as number
@@ -299,7 +373,16 @@ export async function runSalaryCalculation(
const emp = sre.employee
if (!emp) continue
// 8a. Derive absence line items from per-day records.
// 8a. Derive absence line items from per-day records. The cutover karens
// adjustment applies only while the 12-month högriskskydd lookback
// still reaches into pre-cutover time; past that horizon the
// adjustment is stale and imported day rows carry the truth.
const opening = openingByEmployee.get(emp.id)
const lookbackStartMs = Date.parse(`${periodStart}T00:00:00Z`) - 365 * 86_400_000
const karensAdjustmentApplies =
opening !== undefined &&
opening.karensPeriodsAdjustment > 0 &&
lookbackStartMs < Date.parse(`${opening.cutoverDate}T00:00:00Z`)
const absenceResult = await loadAndDeriveAbsence({
supabase,
companyId,
@@ -308,6 +391,8 @@ export async function runSalaryCalculation(
payrollConfig: config,
periodStart,
periodEnd,
karensPeriodsAdjustment: karensAdjustmentApplies ? opening.karensPeriodsAdjustment : 0,
dailyDivisor: dailyDivisor(emp.workdays_per_week),
})
// 8b. For hourly employees, derive worked hours from the calendar.
@@ -511,6 +596,7 @@ export async function runSalaryCalculation(
salary_type: emp.salary_type,
hourly_rate: emp.hourly_rate,
monthly_salary: sre.monthly_salary,
hours_per_week: emp.hours_per_week,
})
const shifts: WorkedDayShift[] = workedDayRows.map((row) => ({
work_date: row.work_date,
@@ -620,6 +706,7 @@ export async function runSalaryCalculation(
vacationRule: emp.vacation_rule,
vacationDaysPerYear: emp.vacation_days_per_year,
semestertillaggRate: emp.semestertillagg_rate,
dailyDivisor: dailyDivisor(emp.workdays_per_week),
vaxaStodEligible: emp.vaxa_stod_eligible,
vaxaStodStart: emp.vaxa_stod_start,
vaxaStodEnd: emp.vaxa_stod_end,
+231
View File
@@ -0,0 +1,231 @@
/**
* Shared salary-run roster commands: attach/remove an employee on a DRAFT run.
*
* Single source of truth consumed by the internal dashboard routes
* (app/api/salary/runs/[id]/employees/**) and the v1 REST routes. Attaching
* snapshots the employee's pay config onto salary_run_employees (so later
* employee edits don't retroactively change an open run) and seeds the base
* salary line item.
*
* Result-object convention mirrors lib/salary/run-calculation.ts.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { getLineItemAccount } from '@/lib/salary/account-mapping'
import { roundOre } from '@/lib/money'
import type { SalaryLineItemType } from '@/types'
export type RunEmployeeResult<T> =
| { ok: true; data: T }
| { ok: false; code: string; details?: Record<string, unknown> }
export interface SalaryRunEmployeeRow {
id: string
salary_run_id: string
employee_id: string
company_id: string
employment_degree: number
monthly_salary: number
salary_type: string
hours_worked: number | null
tax_table_number: number | null
tax_column: number | null
created_at: string
updated_at: string
}
async function assertRunDraftForRoster(
supabase: SupabaseClient,
companyId: string,
salaryRunId: string,
): Promise<RunEmployeeResult<{ id: string; status: string }>> {
const { data: run, error } = await supabase
.from('salary_runs')
.select('id, status')
.eq('id', salaryRunId)
.eq('company_id', companyId)
.maybeSingle()
if (error) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
if (!run) {
return { ok: false, code: 'SALARY_RUN_NOT_FOUND' }
}
if ((run as { status: string }).status !== 'draft') {
return {
ok: false,
code: 'SALARY_RUN_EMPLOYEES_NOT_DRAFT',
details: { current_status: (run as { status: string }).status },
}
}
return { ok: true, data: run as { id: string; status: string } }
}
export async function addEmployeeToRun(
supabase: SupabaseClient,
args: {
companyId: string
salaryRunId: string
employeeId: string
hoursWorked?: number | null
/** Validate + resolve only; return the would-be snapshot without writing. */
dryRun?: boolean
},
): Promise<RunEmployeeResult<SalaryRunEmployeeRow | (Omit<SalaryRunEmployeeRow, 'id' | 'created_at' | 'updated_at'> & { id: null })>> {
const gate = await assertRunDraftForRoster(supabase, args.companyId, args.salaryRunId)
if (!gate.ok) return gate
const { data: employee, error: empError } = await supabase
.from('employees')
.select(
'id, employment_degree, monthly_salary, hourly_rate, salary_type, employment_type, tax_table_number, tax_column',
)
.eq('id', args.employeeId)
.eq('company_id', args.companyId)
.eq('is_active', true)
.maybeSingle()
if (empError) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: empError.message } }
}
if (!employee) {
return { ok: false, code: 'EMPLOYEE_NOT_FOUND' }
}
const { data: existing, error: dupError } = await supabase
.from('salary_run_employees')
.select('id')
.eq('salary_run_id', args.salaryRunId)
.eq('employee_id', args.employeeId)
.maybeSingle()
if (dupError) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: dupError.message } }
}
if (existing) {
return {
ok: false,
code: 'SALARY_RUN_EMPLOYEE_DUPLICATE',
details: { salary_run_employee_id: (existing as { id: string }).id },
}
}
const emp = employee as {
id: string
employment_degree: number
monthly_salary: number | null
hourly_rate: number | null
salary_type: string
employment_type: string
tax_table_number: number | null
tax_column: number | null
}
const snapshot = {
salary_run_id: args.salaryRunId,
employee_id: emp.id,
company_id: args.companyId,
employment_degree: emp.employment_degree,
monthly_salary: emp.monthly_salary || 0,
salary_type: emp.salary_type,
hours_worked: args.hoursWorked ?? null,
tax_table_number: emp.tax_table_number,
tax_column: emp.tax_column,
}
if (args.dryRun) {
return { ok: true, data: { ...snapshot, id: null } }
}
const { data: sre, error: sreError } = await supabase
.from('salary_run_employees')
.insert(snapshot)
.select()
.single()
if (sreError) {
// Race with a concurrent attach: the pre-flight passed but the insert
// tripped the unique constraint.
if ((sreError as { code?: string }).code === '23505') {
return { ok: false, code: 'SALARY_RUN_EMPLOYEE_DUPLICATE' }
}
return { ok: false, code: 'INTERNAL_ERROR', details: { message: sreError.message } }
}
// Seed the base salary line so the run displays a sensible gross before
// the first :calculate.
const baseSalaryType: SalaryLineItemType =
emp.salary_type === 'monthly' ? 'monthly_salary' : 'hourly_salary'
const baseAmount =
emp.salary_type === 'monthly'
? roundOre((emp.monthly_salary || 0) * (emp.employment_degree / 100))
: roundOre((emp.hourly_rate || 0) * (args.hoursWorked || 0))
const { error: lineError } = await supabase.from('salary_line_items').insert({
salary_run_employee_id: (sre as { id: string }).id,
company_id: args.companyId,
item_type: baseSalaryType,
description: emp.salary_type === 'monthly' ? 'Grundlön' : 'Timlön',
quantity: emp.salary_type === 'hourly' ? args.hoursWorked ?? null : null,
unit_price: emp.salary_type === 'hourly' ? emp.hourly_rate : null,
amount: baseAmount,
is_taxable: true,
is_avgift_basis: true,
is_vacation_basis: true,
account_number: getLineItemAccount(baseSalaryType, emp.employment_type as never),
sort_order: 0,
})
if (lineError) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: lineError.message } }
}
return { ok: true, data: sre as unknown as SalaryRunEmployeeRow }
}
export async function removeEmployeeFromRun(
supabase: SupabaseClient,
args: {
companyId: string
salaryRunId: string
employeeId: string
/** Validate + resolve only; do not delete. */
dryRun?: boolean
},
): Promise<RunEmployeeResult<{ deleted: true; employee_id: string }>> {
const gate = await assertRunDraftForRoster(supabase, args.companyId, args.salaryRunId)
if (!gate.ok) return gate
const { data: sre, error: sreError } = await supabase
.from('salary_run_employees')
.select('id')
.eq('salary_run_id', args.salaryRunId)
.eq('employee_id', args.employeeId)
.eq('company_id', args.companyId)
.maybeSingle()
if (sreError) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: sreError.message } }
}
if (!sre) {
return { ok: false, code: 'SALARY_RUN_EMPLOYEE_NOT_FOUND' }
}
if (args.dryRun) {
return { ok: true, data: { deleted: true, employee_id: args.employeeId } }
}
// Cascades to salary_line_items via ON DELETE CASCADE.
const { error } = await supabase
.from('salary_run_employees')
.delete()
.eq('salary_run_id', args.salaryRunId)
.eq('employee_id', args.employeeId)
.eq('company_id', args.companyId)
if (error) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
return { ok: true, data: { deleted: true, employee_id: args.employeeId } }
}
+564
View File
@@ -0,0 +1,564 @@
/**
* Semesterberedning + semesterårsavslut (payroll gap-closure 3.3).
*
* ONE workflow, two phases on the same year boundary:
*
* Beredning (days): per employee, roll the closing year's balances into
* the next vacation year. Only days above the 20-day must-take floor are
* saveable (Semesterlagen 18 §); saved days expire after 5 years and move
* to forced_payout_days (paid out as semesterersättning via a normal
* salary run, never booked here). Untaken days at or below the floor are
* FLAGGED for manual handling, not auto-saved.
*
* Årsavslut (SEK): reconcile the day-valued semesterlöneskuld against the
* BOOKED 2920/2940 balances (per-run accruals never relieve 2920 when
* vacation is taken, so drift accumulates by design) and post ONE
* adjustment verifikation via the bookkeeping engine when |drift| > 1 kr.
*
* Day valuation (BFNAR 2016:10 per-employee, simplified and shown in the
* review report before anything commits):
* sammalöneregeln (monthly): monthly/dailyDivisor + monthly x tillägg
* procentregeln (monthly) : monthly x 12 x rate / entitled days
* procentregeln (hourly) : hourly x hours_per_week x 52 x rate / entitled
* Avgifter on the liability use per-employee age tiers at the settlement
* year (born <= 1937 exempt, fyllt 67 vid årets ingång 10.21%, otherwise
* 31.42%), matching the rates the per-run accruals booked on 2940; a flat
* 31.42% target would "correct" a correct booked balance to a wrong one
* for companies with 67+ staff. The temporary youth discount is
* deliberately NOT provisioned: it is payment-month- and cap-dependent and
* expires Sep 2027, so the full rate is the prudent target (ÅRL
* försiktighetsprincipen); youth accruals therefore show a top-up drift.
*
* The frozen report is stored on vacation_year_closures (BFL 7 kap: it is
* the underlag for the adjustment entry). The UNIQUE
* (company_id, vacation_year_start) makes replays a clean conflict.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { roundOre, sumOre } from '@/lib/money'
import { dailyDivisor } from './work-schedule'
import { getVacationYearBounds, type VacationYearBasis } from './vacation-year'
import { getVacationYearBasis, syncVacationLedgerForEmployees, type VacationBalanceRow } from './vacation-ledger'
import { calculateAgeAtYearStart, decryptPersonnummer } from './personnummer'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
const STANDARD_AVGIFTER_RATE = 0.3142
/** Ålderspensionsavgift only, for fyllt 67 vid årets ingång (SAL 2 kap). */
const REDUCED_AVGIFTER_RATE = 0.1021
/** Threshold applies to payment years >= 2026; every close this module can
* run settles 2026 or later, so the pre-2026 66-year threshold never applies. */
const REDUCED_AVGIFT_AGE = 67
/** Book an adjustment only beyond this drift (öre noise is not a bokslut post). */
const DRIFT_TOLERANCE_SEK = 1
export type VacationCloseResult<T> =
| { ok: true; data: T }
| { ok: false; code: string; details?: Record<string, unknown> }
export interface VacationCloseEmployeeRow {
employee_id: string
employee_name: string
vacation_rule: string
entitled_days: number
taken_days: number
remaining_days: number
/** Days above the 20-day must-take floor: rolled into saved_days. */
saveable_days: number
/** Untaken days at/below the floor: flagged for manual handling. */
untaken_below_floor_days: number
/** Saved days whose origin year fell out of the 5-year window: forced payout. */
expiring_days: number
saved_days_before: Record<string, number>
saved_days_after: Record<string, number>
next_year_entitled: number
day_value_sek: number
computed_liability_sek: number
/** Age-tier avgifter rate applied to this employee's liability for the
* 2940 target (0 exempt, 0.1021 fyllt 67, 0.3142 standard). */
avgifter_rate: number
}
export interface VacationCloseReport {
vacation_year_start: string
vacation_year_end: string
next_year_start: string
basis: VacationYearBasis
rows: VacationCloseEmployeeRow[]
sek: {
computed_liability: number
computed_avgifter: number
booked_2920: number
booked_2940: number
drift_2920: number
drift_2940: number
adjustment_needed: boolean
}
adjustment_date: string
}
interface EmployeeRosterRow {
id: string
first_name: string
last_name: string
personnummer: string | null
vacation_rule: string
vacation_days_per_year: number
salary_type: string
monthly_salary: number | null
hourly_rate: number | null
hours_per_week: number | null
workdays_per_week: number | null
employment_start: string
employment_end: string | null
}
function lastDayBefore(dateIso: string): string {
const d = new Date(`${dateIso}T00:00:00Z`)
d.setUTCDate(d.getUTCDate() - 1)
return d.toISOString().slice(0, 10)
}
function dayValueSek(emp: EmployeeRosterRow): number {
const rate = emp.vacation_days_per_year >= 30 ? 0.144 : 0.12
if (emp.salary_type === 'hourly') {
const annualBasis = (emp.hourly_rate || 0) * (emp.hours_per_week ?? 40) * 52
return roundOre((annualBasis * rate) / Math.max(emp.vacation_days_per_year, 1))
}
const monthly = emp.monthly_salary || 0
if (emp.vacation_rule === 'sammaloneregeln') {
// Dagslön + semestertillägg per day. The employee's tillägg rate lives on
// the master row but the statutory floor 0.43% is used when absent.
return roundOre(monthly / dailyDivisor(emp.workdays_per_week) + monthly * 0.0043)
}
return roundOre((monthly * 12 * rate) / Math.max(emp.vacation_days_per_year, 1))
}
/**
* Age-tier avgifter rate for the semesterlöneskuld provision.
*
* The liability settles when vacation is taken during the NEXT vacation
* year, so the tier is evaluated against that settlement year. This matches
* the per-run accruals, which credit 2940 at each employee's actual rate
* (calculation-engine.ts step 10). The temporary youth discount is not
* applied here (see the module header). An absent or undecryptable
* personnummer falls back to the standard rate, mirroring the run engine.
*/
function avgifterRateFor(emp: EmployeeRosterRow, settlementYear: number): number {
if (!emp.personnummer) return STANDARD_AVGIFTER_RATE
let pnr: string
try {
pnr = decryptPersonnummer(emp.personnummer)
} catch {
return STANDARD_AVGIFTER_RATE
}
const birthYear = parseInt(pnr.slice(0, 4))
if (!Number.isFinite(birthYear)) return STANDARD_AVGIFTER_RATE
if (birthYear <= 1937) return 0
if (calculateAgeAtYearStart(pnr, settlementYear) >= REDUCED_AVGIFT_AGE) {
return REDUCED_AVGIFTER_RATE
}
return STANDARD_AVGIFTER_RATE
}
/** Prorated entitlement for the new year (Semesterlagen 3a §: round UP). */
function nextYearEntitled(emp: EmployeeRosterRow, newYearStart: string, newYearEnd: string): number {
if (emp.employment_end && emp.employment_end < newYearStart) return 0
if (emp.employment_start >= newYearEnd) return 0
const yearStartMs = Date.parse(`${newYearStart}T00:00:00Z`)
const yearEndMs = Date.parse(`${newYearEnd}T00:00:00Z`)
const fromMs = Math.max(yearStartMs, Date.parse(`${emp.employment_start}T00:00:00Z`))
const toMs = emp.employment_end
? Math.min(yearEndMs, Date.parse(`${emp.employment_end}T00:00:00Z`) + 86_400_000)
: yearEndMs
const fraction = Math.max(0, toMs - fromMs) / (yearEndMs - yearStartMs)
return Math.min(emp.vacation_days_per_year, Math.ceil(fraction * emp.vacation_days_per_year))
}
async function loadRoster(
supabase: SupabaseClient,
companyId: string,
): Promise<VacationCloseResult<EmployeeRosterRow[]>> {
const { data, error } = await supabase
.from('employees')
.select(
'id, first_name, last_name, personnummer, vacation_rule, vacation_days_per_year, salary_type, monthly_salary, hourly_rate, hours_per_week, workdays_per_week, employment_start, employment_end',
)
.eq('company_id', companyId)
.eq('is_active', true)
.not('vacation_rule', 'in', '(none,semesterersattning)')
if (error) return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
return { ok: true, data: (data ?? []) as unknown as EmployeeRosterRow[] }
}
async function bookedBalance(
supabase: SupabaseClient,
companyId: string,
asOfDate: string,
): Promise<VacationCloseResult<{ booked2920: number; booked2940: number }>> {
const { data: period, error } = await supabase
.from('fiscal_periods')
.select('id, period_start, period_end')
.eq('company_id', companyId)
.lte('period_start', asOfDate)
.gte('period_end', asOfDate)
.maybeSingle()
if (error) return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
if (!period) {
return {
ok: false,
code: 'VALIDATION_ERROR',
details: { message: `Ingen räkenskapsperiod täcker ${asOfDate}.` },
}
}
const tb = await generateTrialBalance(supabase, companyId, (period as { id: string }).id, {
toDate: asOfDate,
})
const balanceOf = (account: string): number => {
const row = tb.rows.find((r) => r.account_number === account)
if (!row) return 0
// 2920/2940 are credit-normal liabilities.
return roundOre(row.closing_credit - row.closing_debit)
}
return { ok: true, data: { booked2920: balanceOf('2920'), booked2940: balanceOf('2940') } }
}
export async function previewVacationYearClose(
supabase: SupabaseClient,
companyId: string,
closingYearStart: string,
): Promise<VacationCloseResult<VacationCloseReport>> {
const basis = await getVacationYearBasis(supabase, companyId)
const bounds = getVacationYearBounds(closingYearStart)
const yearEnd = lastDayBefore(bounds.end)
const newYearStart = bounds.end
const newYearBounds = getVacationYearBounds(newYearStart)
// A year can only be closed once it has ended.
const today = new Date().toISOString().slice(0, 10)
if (today < bounds.end) {
return {
ok: false,
code: 'VACATION_YEAR_NOT_ENDED',
details: { vacation_year_start: closingYearStart, vacation_year_end: yearEnd },
}
}
// Replay guard for previews too: a closed year has a frozen report already.
const { data: existingClosure } = await supabase
.from('vacation_year_closures')
.select('id')
.eq('company_id', companyId)
.eq('vacation_year_start', closingYearStart)
.maybeSingle()
if (existingClosure) {
return {
ok: false,
code: 'VACATION_YEAR_ALREADY_CLOSED',
details: { vacation_year_start: closingYearStart },
}
}
const roster = await loadRoster(supabase, companyId)
if (!roster.ok) return roster
// Make sure the closing year's ledger rows exist and are recomputed as of
// the year end (seeds from cutover/legacy data on first touch).
const sync = await syncVacationLedgerForEmployees(
supabase,
companyId,
roster.data.map((e) => e.id),
yearEnd,
)
if (!sync.ok) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: sync.message } }
}
const { data: ledgerRows, error: ledgerErr } = await supabase
.from('employee_vacation_balances')
.select('id, employee_id, vacation_year_start, entitled_days, accrued_days, taken_days, saved_days, forced_payout_days, status')
.eq('company_id', companyId)
.eq('vacation_year_start', closingYearStart)
if (ledgerErr) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: ledgerErr.message } }
}
const ledgerByEmployee = new Map(
((ledgerRows ?? []) as unknown as VacationBalanceRow[]).map((r) => [r.employee_id, r]),
)
const closingYear = Number(closingYearStart.slice(0, 4))
// The rolled liability is paid out during the new vacation year: that is
// the year the avgifter age tier is evaluated against.
const settlementYear = Number(newYearStart.slice(0, 4))
const rows: VacationCloseEmployeeRow[] = []
for (const emp of roster.data) {
const ledger = ledgerByEmployee.get(emp.id)
const entitled = ledger?.entitled_days ?? emp.vacation_days_per_year
const taken = ledger?.taken_days ?? 0
const savedBefore = (ledger?.saved_days ?? {}) as Record<string, number>
const remaining = Math.max(0, roundOre(entitled - taken))
// Semesterlagen 18 §: only days exceeding the 20-day floor are saveable.
const saveable = Math.max(0, Math.min(remaining, entitled - 20))
const untakenBelowFloor = roundOre(remaining - saveable)
// 5-year expiry: a day saved in origin year X is takeable through X + 5.
// Rolling into the new year, origins <= closingYear - 5 fall out.
let expiring = 0
const savedAfter: Record<string, number> = {}
for (const [originYear, days] of Object.entries(savedBefore)) {
const numDays = Number(days) || 0
if (numDays <= 0) continue
if (Number(originYear) <= closingYear - 5) {
expiring = roundOre(expiring + numDays)
} else {
savedAfter[originYear] = numDays
}
}
if (saveable > 0) {
savedAfter[String(closingYear)] = roundOre((savedAfter[String(closingYear)] ?? 0) + saveable)
}
const dayValue = dayValueSek(emp)
// The liability covers everything still owed: this year's untaken days
// (incl. the below-floor flag) + all saved days + expiring days awaiting
// payout.
const liabilityDays = roundOre(
remaining + Object.values(savedBefore).reduce((s, d) => s + (Number(d) || 0), 0),
)
const liability = roundOre(liabilityDays * dayValue)
rows.push({
employee_id: emp.id,
employee_name: `${emp.first_name} ${emp.last_name}`,
vacation_rule: emp.vacation_rule,
entitled_days: entitled,
taken_days: taken,
remaining_days: remaining,
saveable_days: saveable,
untaken_below_floor_days: untakenBelowFloor,
expiring_days: expiring,
saved_days_before: savedBefore,
saved_days_after: savedAfter,
next_year_entitled: nextYearEntitled(emp, newYearStart, newYearBounds.end),
day_value_sek: dayValue,
computed_liability_sek: liability,
avgifter_rate: avgifterRateFor(emp, settlementYear),
})
}
const computedLiability = sumOre(rows.map((r) => r.computed_liability_sek))
const computedAvgifter = sumOre(
rows.map((r) => roundOre(r.computed_liability_sek * r.avgifter_rate)),
)
const booked = await bookedBalance(supabase, companyId, yearEnd)
if (!booked.ok) return booked
const drift2920 = roundOre(computedLiability - booked.data.booked2920)
const drift2940 = roundOre(computedAvgifter - booked.data.booked2940)
return {
ok: true,
data: {
vacation_year_start: closingYearStart,
vacation_year_end: yearEnd,
next_year_start: newYearStart,
basis,
rows,
sek: {
computed_liability: computedLiability,
computed_avgifter: computedAvgifter,
booked_2920: booked.data.booked2920,
booked_2940: booked.data.booked2940,
drift_2920: drift2920,
drift_2940: drift2940,
adjustment_needed:
Math.abs(drift2920) > DRIFT_TOLERANCE_SEK || Math.abs(drift2940) > DRIFT_TOLERANCE_SEK,
},
adjustment_date: yearEnd,
},
}
}
export async function commitVacationYearClose(
supabase: SupabaseClient,
companyId: string,
userId: string,
closingYearStart: string,
options: { bookAdjustment: boolean },
): Promise<VacationCloseResult<{ closure_id: string; adjustment_entry_id: string | null; report: VacationCloseReport }>> {
const preview = await previewVacationYearClose(supabase, companyId, closingYearStart)
if (!preview.ok) return preview
const report = preview.data
// Period-lock pre-check BEFORE any writes so a locked bokslut period fails
// the whole close cleanly (the DB trigger is the backstop).
if (options.bookAdjustment && report.sek.adjustment_needed) {
const { checkPeriodLock } = await import('@/lib/api/v1/check-period-lock')
const lockVerdict = await checkPeriodLock(supabase, companyId, report.adjustment_date)
if (lockVerdict.locked) {
return {
ok: false,
code: 'PERIOD_LOCKED',
details: {
reason: lockVerdict.reason,
fiscal_period_id: lockVerdict.fiscal_period_id,
adjustment_date: report.adjustment_date,
},
}
}
}
// 1. Closure row: the UNIQUE constraint is the replay anchor.
const { data: closure, error: closureErr } = await supabase
.from('vacation_year_closures')
.insert({
company_id: companyId,
vacation_year_start: closingYearStart,
closed_by: userId,
report: report as unknown as Record<string, unknown>,
})
.select('id')
.single()
if (closureErr) {
if (closureErr.code === '23505') {
return {
ok: false,
code: 'VACATION_YEAR_ALREADY_CLOSED',
details: { vacation_year_start: closingYearStart },
}
}
return { ok: false, code: 'INTERNAL_ERROR', details: { message: closureErr.message } }
}
const closureId = (closure as { id: string }).id
// 2. Close the year's ledger rows.
const { error: closeErr } = await supabase
.from('employee_vacation_balances')
.update({ status: 'closed' })
.eq('company_id', companyId)
.eq('vacation_year_start', closingYearStart)
if (closeErr) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: closeErr.message } }
}
// 3. Next-year rows: entitlement + rolled saved days + forced payouts.
// Upsert MERGES over any lazy-seeded row; taken_days is recomputed by
// the sync below, so seeding it 0 here is safe.
const nextRows = report.rows.map((row) => ({
company_id: companyId,
employee_id: row.employee_id,
vacation_year_start: report.next_year_start,
entitled_days: row.next_year_entitled,
accrued_days: 0,
taken_days: 0,
saved_days: row.saved_days_after,
forced_payout_days: row.expiring_days,
status: 'open',
}))
if (nextRows.length > 0) {
const { error: nextErr } = await supabase
.from('employee_vacation_balances')
.upsert(nextRows, { onConflict: 'company_id,employee_id,vacation_year_start' })
if (nextErr) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: nextErr.message } }
}
}
// Recompute taken_days on the fresh rows from any already-booked runs in
// the new year (non-fatal, same contract as the booking hook).
await syncVacationLedgerForEmployees(
supabase,
companyId,
report.rows.map((r) => r.employee_id),
)
// 4. Drift adjustment verifikation (7290/2920 + 7519/2940).
let adjustmentEntryId: string | null = null
if (options.bookAdjustment && report.sek.adjustment_needed) {
const { data: period } = await supabase
.from('fiscal_periods')
.select('id')
.eq('company_id', companyId)
.lte('period_start', report.adjustment_date)
.gte('period_end', report.adjustment_date)
.maybeSingle()
if (!period) {
return {
ok: false,
code: 'VALIDATION_ERROR',
details: { message: `Ingen räkenskapsperiod täcker ${report.adjustment_date}.` },
}
}
const lines: Array<{ account_number: string; debit_amount: number; credit_amount: number; line_description?: string }> = []
const d2920 = report.sek.drift_2920
if (Math.abs(d2920) > DRIFT_TOLERANCE_SEK) {
if (d2920 > 0) {
lines.push({ account_number: '7290', debit_amount: d2920, credit_amount: 0, line_description: 'Justering semesterlöneskuld' })
lines.push({ account_number: '2920', debit_amount: 0, credit_amount: d2920, line_description: 'Justering semesterlöneskuld' })
} else {
lines.push({ account_number: '2920', debit_amount: -d2920, credit_amount: 0, line_description: 'Justering semesterlöneskuld' })
lines.push({ account_number: '7290', debit_amount: 0, credit_amount: -d2920, line_description: 'Justering semesterlöneskuld' })
}
}
const d2940 = report.sek.drift_2940
if (Math.abs(d2940) > DRIFT_TOLERANCE_SEK) {
if (d2940 > 0) {
lines.push({ account_number: '7519', debit_amount: d2940, credit_amount: 0, line_description: 'Justering upplupna avgifter semester' })
lines.push({ account_number: '2940', debit_amount: 0, credit_amount: d2940, line_description: 'Justering upplupna avgifter semester' })
} else {
lines.push({ account_number: '2940', debit_amount: -d2940, credit_amount: 0, line_description: 'Justering upplupna avgifter semester' })
lines.push({ account_number: '7519', debit_amount: 0, credit_amount: -d2940, line_description: 'Justering upplupna avgifter semester' })
}
}
if (lines.length > 0) {
try {
const { createJournalEntry } = await import('@/lib/bookkeeping/engine')
const entry = await createJournalEntry(supabase, companyId, userId, {
fiscal_period_id: (period as { id: string }).id,
entry_date: report.adjustment_date,
description: `Semesterårsavslut ${closingYearStart.slice(0, 4)}: justering semesterlöneskuld`,
source_type: 'salary_payment',
source_id: closureId,
lines,
})
adjustmentEntryId = entry.id
const { error: linkError } = await supabase
.from('vacation_year_closures')
.update({ adjustment_entry_id: entry.id })
.eq('id', closureId)
if (linkError) {
// The verifikat IS posted; only the closure link failed. Surface
// it with the entry id so nobody re-posts the adjustment manually.
return {
ok: false,
code: 'VACATION_CLOSE_ADJUSTMENT_FAILED',
details: {
closure_id: closureId,
adjustment_entry_id: entry.id,
message: `Justeringsverifikatet är bokfört men kunde inte länkas till semesterårsavslutet: ${linkError.message}`,
},
}
}
} catch (err) {
// The days roll committed; the adjustment did not. Surface loudly:
// the closure row (sans adjustment_entry_id) shows exactly what is
// missing, and the entry can be posted manually from the report.
return {
ok: false,
code: 'VACATION_CLOSE_ADJUSTMENT_FAILED',
details: {
closure_id: closureId,
message: err instanceof Error ? err.message : 'unknown',
},
}
}
}
}
return { ok: true, data: { closure_id: closureId, adjustment_entry_id: adjustmentEntryId, report } }
}
+243
View File
@@ -0,0 +1,243 @@
/**
* Vacation balance ledger sync (payroll gap-closure 3.2).
*
* Keeps employee_vacation_balances (per employee, per vacation year) in step
* with reality after every salary-run booking or correction.
*
* RECOMPUTE, never increment: taken_days is re-derived from the currently
* BOOKED runs inside each open year's bounds on every call. Idempotent and
* self-healing; a corrected run simply drops out of the sum with no special
* casing.
*
* Days only: the SEK side of the liability stays derived (2920/2940 are
* booked per run plus the cutover opening term); see the ledger migration
* header for the rationale.
*
* NON-FATAL CONTRACT: callers (book/correct routes) wrap this in try/catch
* and log a warning on failure. A ledger bug must never block a legally
* required booking; the next successful sync heals any gap.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import {
getVacationYearBounds,
getVacationYearStart,
type VacationYearBasis,
} from './vacation-year'
export interface VacationBalanceRow {
id: string
employee_id: string
vacation_year_start: string
entitled_days: number
accrued_days: number
taken_days: number
saved_days: Record<string, number>
forced_payout_days: number
status: 'open' | 'closed'
}
export async function getVacationYearBasis(
supabase: SupabaseClient,
companyId: string,
): Promise<VacationYearBasis> {
const { data } = await supabase
.from('company_settings')
.select('salary_vacation_year_basis')
.eq('company_id', companyId)
.maybeSingle()
return ((data as { salary_vacation_year_basis?: string } | null)?.salary_vacation_year_basis ===
'statutory_apr_mar'
? 'statutory_apr_mar'
: 'calendar') as VacationYearBasis
}
/**
* Recompute the OPEN ledger rows for the given employees and lazy-seed the
* current vacation year's row where none exists.
*
* `asOf` exists for determinism in tests; production callers omit it.
*/
export async function syncVacationLedgerForEmployees(
supabase: SupabaseClient,
companyId: string,
employeeIds: string[],
asOf?: string,
): Promise<{ ok: true } | { ok: false; message: string }> {
if (employeeIds.length === 0) return { ok: true }
const asOfDate = asOf ?? new Date().toISOString().slice(0, 10)
try {
const basis = await getVacationYearBasis(supabase, companyId)
const currentYearStart = getVacationYearStart(asOfDate, basis)
const { data: employees, error: empErr } = await supabase
.from('employees')
.select('id, vacation_days_per_year, vacation_days_saved, vacation_rule')
.eq('company_id', companyId)
.in('id', employeeIds)
if (empErr) return { ok: false, message: empErr.message }
const employeeById = new Map(
((employees ?? []) as Array<{
id: string
vacation_days_per_year: number
vacation_days_saved: number
vacation_rule: string
}>).map((e) => [e.id, e]),
)
const { data: openings, error: openErr } = await supabase
.from('employee_opening_balances')
.select('employee_id, cutover_date, vacation_paid_days_remaining, vacation_saved_days_by_year')
.eq('company_id', companyId)
.in('employee_id', employeeIds)
if (openErr) return { ok: false, message: openErr.message }
const openingByEmployee = new Map(
((openings ?? []) as Array<{
employee_id: string
cutover_date: string
vacation_paid_days_remaining: number
vacation_saved_days_by_year: Record<string, number> | null
}>).map((o) => [o.employee_id, o]),
)
const { data: ledgerRows, error: ledgerErr } = await supabase
.from('employee_vacation_balances')
.select('id, employee_id, vacation_year_start, entitled_days, accrued_days, taken_days, saved_days, forced_payout_days, status')
.eq('company_id', companyId)
.eq('status', 'open')
.in('employee_id', employeeIds)
if (ledgerErr) return { ok: false, message: ledgerErr.message }
const openRows = (ledgerRows ?? []) as unknown as VacationBalanceRow[]
// Booked vacation days per employee, bucketed later per year bounds.
const { data: bookedRows, error: bookedErr } = await supabase
.from('salary_run_employees')
.select('employee_id, vacation_days_taken, salary_run:salary_runs!inner(period_year, period_month, status)')
.eq('company_id', companyId)
.eq('salary_run.status', 'booked')
.in('employee_id', employeeIds)
if (bookedErr) return { ok: false, message: bookedErr.message }
const booked = ((bookedRows ?? []) as unknown as Array<{
employee_id: string
vacation_days_taken: number
salary_run: { period_year: number; period_month: number; status: string } | null
}>).filter((r) => r.salary_run?.status === 'booked')
const takenInYear = (employeeId: string, yearStart: string): number => {
const bounds = getVacationYearBounds(yearStart)
let sum = 0
for (const row of booked) {
if (row.employee_id !== employeeId) continue
const run = row.salary_run!
const periodDate = `${run.period_year}-${String(run.period_month).padStart(2, '0')}-01`
if (periodDate >= bounds.start && periodDate < bounds.end) {
sum += row.vacation_days_taken || 0
}
}
return sum
}
const upserts: Array<Record<string, unknown>> = []
for (const employeeId of employeeIds) {
const employee = employeeById.get(employeeId)
if (!employee) continue
const rowsForEmployee = openRows.filter((r) => r.employee_id === employeeId)
const hasCurrentYearRow = rowsForEmployee.some(
(r) => r.vacation_year_start === currentYearStart,
)
// Recompute every open year the employee has.
for (const row of rowsForEmployee) {
upserts.push({
company_id: companyId,
employee_id: employeeId,
vacation_year_start: row.vacation_year_start,
entitled_days: row.entitled_days,
accrued_days: computeAccruedDays(basis, row.vacation_year_start, asOfDate, employee.vacation_days_per_year),
taken_days: takenInYear(employeeId, row.vacation_year_start),
saved_days: row.saved_days ?? {},
forced_payout_days: row.forced_payout_days ?? 0,
status: 'open',
})
}
// Lazy-seed the current year on first touch.
if (!hasCurrentYearRow) {
const opening = openingByEmployee.get(employeeId)
const cutoverInThisYear =
!!opening &&
opening.cutover_date >= currentYearStart &&
opening.cutover_date < getVacationYearBounds(currentYearStart).end
let savedDays: Record<string, number>
if (cutoverInThisYear && opening) {
savedDays = opening.vacation_saved_days_by_year ?? {}
} else if ((employee.vacation_days_saved || 0) > 0) {
// Legacy master field has no origin-year data: attribute the whole
// balance to the year before this one (the most conservative choice
// for the 5-year expiry: it expires EARLIER, never later).
const previousYear = String(Number(currentYearStart.slice(0, 4)) - 1)
savedDays = { [previousYear]: employee.vacation_days_saved }
} else {
savedDays = {}
}
upserts.push({
company_id: companyId,
employee_id: employeeId,
vacation_year_start: currentYearStart,
entitled_days:
cutoverInThisYear && opening
? opening.vacation_paid_days_remaining
: employee.vacation_days_per_year,
accrued_days: computeAccruedDays(basis, currentYearStart, asOfDate, employee.vacation_days_per_year),
taken_days: takenInYear(employeeId, currentYearStart),
saved_days: savedDays,
forced_payout_days: 0,
status: 'open',
})
}
}
if (upserts.length === 0) return { ok: true }
const { error: upsertErr } = await supabase
.from('employee_vacation_balances')
.upsert(upserts, { onConflict: 'company_id,employee_id,vacation_year_start' })
if (upsertErr) return { ok: false, message: upsertErr.message }
return { ok: true }
} catch (err) {
return { ok: false, message: err instanceof Error ? err.message : 'ledger sync failed' }
}
}
/**
* Intjänade dagar toward NEXT year: only meaningful on the statutory
* Apr-Mar basis, where intjänandeår (this year) and semesterår (next year)
* are split. Sammanfallande calendar years earn and take in the same year,
* so the live number is entitled - taken and accrued stays 0.
*/
function computeAccruedDays(
basis: VacationYearBasis,
yearStart: string,
asOfDate: string,
vacationDaysPerYear: number,
): number {
if (basis !== 'statutory_apr_mar') return 0
const bounds = getVacationYearBounds(yearStart)
if (asOfDate < bounds.start) return 0
if (asOfDate >= bounds.end) return vacationDaysPerYear
const startYear = Number(yearStart.slice(0, 4))
const startMonth = Number(yearStart.slice(5, 7))
const asOfYear = Number(asOfDate.slice(0, 4))
const asOfMonth = Number(asOfDate.slice(5, 7))
const elapsedMonths = (asOfYear - startYear) * 12 + (asOfMonth - startMonth)
// Whole elapsed months / 12, rounded to half days (Semesterlagen 3a §
// rounds UP to whole days at payout; the running accrual view keeps halves
// for transparency).
return Math.round(((elapsedMonths / 12) * vacationDaysPerYear) * 2) / 2
}
+42
View File
@@ -0,0 +1,42 @@
/**
* Vacation year (semesterår) boundary helpers.
*
* Two bases exist (company_settings.salary_vacation_year_basis):
* 'calendar' : sammanfallande intjänande- och semesterår, Jan 1 to
* Dec 31. The small-company norm and our default.
* 'statutory_apr_mar' : the Semesterlagen 3 § default, Apr 1 to Mar 31.
*/
export type VacationYearBasis = 'calendar' | 'statutory_apr_mar'
/** The vacation-year start date (YYYY-MM-DD) containing `dateIso`. */
export function getVacationYearStart(dateIso: string, basis: VacationYearBasis): string {
const year = Number(dateIso.slice(0, 4))
const month = Number(dateIso.slice(5, 7))
if (basis === 'calendar') {
return `${year}-01-01`
}
// Statutory Apr-Mar: Jan-Mar belongs to the year that started the PREVIOUS
// April.
return month >= 4 ? `${year}-04-01` : `${year - 1}-04-01`
}
/** Inclusive start + exclusive end of the vacation year starting at `yearStartIso`. */
export function getVacationYearBounds(yearStartIso: string): { start: string; end: string } {
const year = Number(yearStartIso.slice(0, 4))
const month = yearStartIso.slice(5, 7)
return {
start: yearStartIso,
end: `${year + 1}-${month}-01`,
}
}
/** The vacation year that has ENDED most recently as of `asOfIso`: the one a
* year-close would target. Returns null while the first-ever year is still
* running (nothing closable). */
export function getClosableYearStart(asOfIso: string, basis: VacationYearBasis): string {
const currentStart = getVacationYearStart(asOfIso, basis)
const year = Number(currentStart.slice(0, 4))
const month = currentStart.slice(5, 7)
return `${year - 1}-${month}-01`
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Work-schedule divisors (arbetsschema-lite).
*
* Converts an employee's weekly schedule into the two divisors the salary
* engine uses:
*
* hourly divisor : monthly salary -> effective hourly rate
* daily divisor : monthly salary -> daily rate (sick/VAB/parental
* deductions, sammalöneregeln day valuation)
*
* BACKWARD-COMPAT CONTRACT (deliberate discontinuity): at the DEFAULT
* schedule (40h / 5d) these return the legacy constants 173 and 21, not the
* exact formulas (which give 173.33 and 21.67). Switching the defaults to
* exact formulas would change every running company's monthly-to-hourly
* derivation by ~0.2% and every sick/VAB daily deduction by ~3% mid-year
* with zero schedule change, which is indefensible in a payroll product.
* Non-default schedules use the exact formula: they have no legacy results
* to preserve. Migrating the defaults to exact formulas is deferred to a
* fiscal-year boundary with release notes.
*/
import { roundOre } from '@/lib/money'
/** Legacy CBA convention: 52 weeks x 40 hours / 12 months, truncated. */
export const LEGACY_HOURLY_DIVISOR = 173
/** Legacy convention: 52 weeks x 5 workdays / 12 months, rounded down. */
export const LEGACY_DAILY_DIVISOR = 21
export const DEFAULT_HOURS_PER_WEEK = 40
export const DEFAULT_WORKDAYS_PER_WEEK = 5
/** Monthly-salary -> hourly-rate divisor for a weekly hours schedule. */
export function hourlyDivisor(hoursPerWeek: number | null | undefined): number {
const hours = hoursPerWeek ?? DEFAULT_HOURS_PER_WEEK
if (hours === DEFAULT_HOURS_PER_WEEK) return LEGACY_HOURLY_DIVISOR
return roundOre((hours * 52) / 12)
}
/** Monthly-salary -> daily-rate divisor for a weekly workdays schedule. */
export function dailyDivisor(workdaysPerWeek: number | null | undefined): number {
const days = workdaysPerWeek ?? DEFAULT_WORKDAYS_PER_WEEK
if (days === DEFAULT_WORKDAYS_PER_WEEK) return LEGACY_DAILY_DIVISOR
return roundOre((days * 52) / 12)
}