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:
co-authored by
Claude Fable 5
parent
e7e3c35f9e
commit
b6332e9ff4
+164
-1
@@ -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
@@ -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",
|
||||
]
|
||||
`;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' }
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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" \\
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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' },
|
||||
})
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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)})`,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user