Bug/document linking (#688)

* feat: enhance supplier invoice payment process and settings handling

- Implemented linking of invoice documents to journal entries for cash payments in the supplier invoice payment process.
- Refactored settings fetching logic to improve loading states and error handling across various settings components.
- Introduced a new SettingsLoadError component to handle cases where settings fetch fails or returns no data.
- Updated useSettings hook to manage loading and error states more effectively, allowing for retries on failure.
- Enhanced tests for supplier invoice creation to ensure document IDs are persisted correctly for cash method payments.

* feat(salary): enable monthly salary edits in draft runs and handle zero-total declarations
This commit is contained in:
Mattsson
2026-06-08 07:37:24 +02:00
committed by GitHub
parent 32af88f9c4
commit 809120c4b8
24 changed files with 1244 additions and 131 deletions
+5
View File
@@ -1738,6 +1738,11 @@ const SALARY_OVERRIDE_MAX = 10_000_000
export const SalaryEmployeeOverrideSchema = z
.object({
// Per-run monthly salary for this employee, editable while the run is a
// draft. 0 is allowed (an intentional nollkörning). This is NOT a review
// override — it sets the base the engine uses for this month only and does
// not require a reason. The route gates this field to `draft` status.
monthly_salary: z.number().nonnegative().max(SALARY_OVERRIDE_MAX).optional(),
tax_withheld_override: z.number().nonnegative().max(SALARY_OVERRIDE_MAX).nullable().optional(),
avgifter_amount_override: z.number().nonnegative().max(SALARY_OVERRIDE_MAX).nullable().optional(),
avgifter_basis_override: z.number().nonnegative().max(SALARY_OVERRIDE_MAX).nullable().optional(),
@@ -277,6 +277,59 @@ describe('commitPendingOperation: create_supplier_invoice_from_inbox', () => {
expect(linkToJournalEntry).not.toHaveBeenCalled()
})
it('persists document_id on the supplier_invoices row (so it can carry to the payment verifikat under cash method)', async () => {
let capturedInsert: Record<string, unknown> | null = null
const { supabase, enqueue } = createQueuedMockSupabase()
const originalFrom = supabase.from
;(supabase as { from: unknown }).from = vi.fn().mockImplementation((table: string) => {
if (table === 'supplier_invoices') {
return {
insert: (row: Record<string, unknown>) => {
capturedInsert = row
return {
select: () => ({
single: () =>
Promise.resolve({
data: makeSupplierInvoice({ id: 'inv-cash', supplier_invoice_number: 'INV-100' }),
error: null,
}),
}),
}
},
}
}
return (originalFrom as (t: string) => unknown)(table)
})
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({
data: { id: 'inbox-1', created_supplier_invoice_id: null, status: 'ready' },
error: null,
}) // inbox fetch
enqueue({
data: { id: 'supplier-1', name: 'Acme AB', supplier_type: 'swedish_business' },
error: null,
}) // supplier fetch
enqueue({ data: 42, error: null }) // arrival number
// supplier_invoices insert handled by the override above
enqueue({ data: null, error: null }) // items insert
enqueue({ data: { accounting_method: 'cash' }, error: null }) // company_settings → cash
enqueue({ data: null, error: null }) // invoice_inbox_items update
enqueue({ data: null, error: null }) // dispatcher's commit update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp(),
)
expect(result.status).toBe('committed')
// The inbox document id from the staged params must land on the row so
// mark-paid can attach it to the kontantmetoden cash verifikat.
expect(capturedInsert).toMatchObject({ document_id: 'doc-1' })
})
it('rolls back the parent invoice when item insert fails (no orphan supplier_invoices row)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null })
+1
View File
@@ -1685,6 +1685,7 @@ async function commitCreateSupplierInvoiceFromInbox(
total_sek: totalSek,
paid_amount: 0,
remaining_amount: totalRounded,
document_id: documentId,
notes,
})
.select()
+35
View File
@@ -484,3 +484,38 @@ describe('generateAGIXml — Frånvarouppgift', () => {
expect(xml).not.toContain('Franvarouppgift')
})
})
describe('generateAGIXml — nolldeklaration (HU-only, no IU)', () => {
const zeroTotals: AGITotals = {
totalTax: 0,
totalAvgifterBasis: 0,
totalAvgifterAmount: 0,
totalSjuklonekostnad: 0,
avgifterByCategory: {},
}
it('produces a valid declaration with an HU but no individuppgifter when the roster is empty', () => {
const xml = generateAGIXml(company, [], zeroTotals)
// Root + HU present
expect(xml).toContain('<Skatteverket omrade="Arbetsgivardeklaration"')
expect(xml).toContain('</Skatteverket>')
expect(xml).toContain('<gem:RedovisningsPeriod faltkod="006">202604</gem:RedovisningsPeriod>')
expect(xml).toContain('<gem:AgRegistreradId faltkod="201">165561234567</gem:AgRegistreradId>')
// No individuppgifter and no absence section
expect(xml).not.toContain('<gem:IU>')
expect(xml).not.toContain('Franvarouppgift')
})
it('omits every zero HU total field (FK497/FK487/FK499)', () => {
const xml = generateAGIXml(company, [], zeroTotals)
expect(xml).not.toContain('faltkod="497"') // SummaSkatteavdr
expect(xml).not.toContain('faltkod="487"') // SummaArbAvgSlf
expect(xml).not.toContain('faltkod="499"') // TotalSjuklonekostnad
})
it('still validates required company data for a nolldeklaration', () => {
expect(() =>
generateAGIXml({ ...company, orgNumber: '' }, [], zeroTotals),
).toThrow(AGIIncompleteDataError)
})
})
+30 -2
View File
@@ -62,6 +62,10 @@ const LineItemSchema = z
const SalaryRunEmployeeRowSchema = z
.object({
employee_id: z.string().uuid(),
// Per-run snapshot of the monthly salary (authoritative for this run; the
// engine reads it, not the employee master). Used for the FK499
// sjuklönekostnad daily-rate below.
monthly_salary: z.number().nullable().optional(),
gross_salary: z.number(),
tax_withheld: z.number(),
tax_withheld_override: z.number().nullable().optional(),
@@ -180,7 +184,10 @@ export async function generateAgiDeclaration(
)
.eq('salary_run_id', salaryRunId)
if (!runEmployees || runEmployees.length === 0) {
// An empty roster is valid — a registered employer must file a
// nolldeklaration (HU-only, no individuppgifter) for months without payroll.
// Only a genuine query failure (null) is treated as an error here.
if (!runEmployees) {
return { ok: false, code: 'SALARY_RUN_NO_EMPLOYEES' }
}
@@ -351,6 +358,27 @@ export async function generateAgiDeclaration(
}
},
)
// Drop individuppgifter with nothing to report. An employee who took 0 kr
// and had no benefits, tax or absence this month is simply omitted (you
// only file an IU for a person who received something). This yields a clean
// HU-only nolldeklaration for a full nollkörning, and omits zero-paid
// employees in a mixed run. Borttag (removed) tombstones are always kept.
.filter(
(e) =>
e.removed === true ||
(e.grossSalary ?? 0) > 0 ||
(e.taxWithheld ?? 0) > 0 ||
(e.fSkattPayment ?? 0) > 0 ||
(e.benefitCar ?? 0) > 0 ||
(e.benefitFuel ?? 0) > 0 ||
(e.benefitMeals ?? 0) > 0 ||
(e.benefitOther ?? 0) > 0 ||
e.housingBenefit !== undefined ||
(e.sickDays ?? 0) > 0 ||
(e.vabDays ?? 0) > 0 ||
(e.parentalDays ?? 0) > 0 ||
(e.absenceEvents?.length ?? 0) > 0,
)
// 5. Build totals: avgifter by category (with rate-heuristic fallback for legacy runs).
// Removed-from-AGI rows (FK205 borttag) are tombstones — they must not
@@ -392,7 +420,7 @@ export async function generateAgiDeclaration(
const sjuklonRate = calcParams.sjuklonRate ?? calcParams.sjuklon_rate ?? 0.8
let totalSjuklonekostnad = 0
for (const sre of activeEmployees) {
const monthly = sre.employee?.monthly_salary ?? 0
const monthly = sre.monthly_salary ?? 0
if (!monthly) continue
const dailyRate = monthly / 21
const lineItems = (sre.line_items ?? []) as Array<{ item_type: string; amount?: number | null; quantity?: number | null }>
+33 -8
View File
@@ -148,15 +148,20 @@ export async function runSalaryCalculation(
// foreign key. RLS already constrains the table per-company, but per
// CLAUDE.md every query carries the company_id filter explicitly so a
// future RLS lapse can't surface cross-tenant rows.
const { data: runEmployees, error: empError } = await supabase
const { data: runEmployeesData, error: empError } = await supabase
.from('salary_run_employees')
.select('*, employee:employees(*), line_items:salary_line_items(*)')
.eq('salary_run_id', id)
.eq('company_id', companyId)
if (empError || !runEmployees || runEmployees.length === 0) {
return { ok: false, code: 'SALARY_RUN_NO_EMPLOYEES' }
if (empError) {
return { ok: false, code: 'DATABASE_ERROR', details: empError }
}
// An empty roster is valid — a registered employer must still file a
// nolldeklaration (HU-only AGI) for months without payroll. Calculation
// then yields all-zero totals plus a frozen calculation_params snapshot,
// and every downstream loop simply iterates zero times.
const runEmployees = runEmployeesData ?? []
// 4. Pre-calculation validation — ensure every employee has the data the
// engine needs. We accumulate ALL errors so the caller sees a complete
@@ -167,8 +172,12 @@ export async function runSalaryCalculation(
if (!emp) continue
const name = `${emp.first_name} ${emp.last_name}`
if (emp.salary_type === 'monthly' && (!emp.monthly_salary || emp.monthly_salary <= 0)) {
validationErrors.push(`${name}: Månadslön saknas eller är 0`)
// A per-run monthly salary of 0 is allowed: it represents an intentional
// nollkörning (the user edited this month's salary down to 0). Only a
// negative value is rejected. New employees still require monthly_salary > 0
// at creation (CreateEmployeeSchema), so a stray 0 cannot arise by accident.
if (emp.salary_type === 'monthly' && sre.monthly_salary < 0) {
validationErrors.push(`${name}: Månadslön kan inte vara negativ`)
}
if (emp.salary_type === 'hourly' && (!emp.hourly_rate || emp.hourly_rate <= 0)) {
validationErrors.push(`${name}: Timlön saknas eller är 0`)
@@ -295,7 +304,7 @@ export async function runSalaryCalculation(
supabase,
companyId,
employeeId: emp.id,
monthlySalary: emp.monthly_salary || 0,
monthlySalary: sre.monthly_salary || 0,
payrollConfig: config,
periodStart,
periodEnd,
@@ -360,6 +369,22 @@ export async function runSalaryCalculation(
}
}
// Refresh the monthly 'Grundlön' line so the displayed Lönerader table
// matches the per-run monthly salary the engine actually uses. The engine
// recomputes baseSalary from sre.monthly_salary (not from this line item),
// so this update is display-only — it keeps the row consistent after the
// user edits this month's salary on the draft.
if (emp.salary_type === 'monthly') {
const baseAmount =
Math.round((sre.monthly_salary || 0) * (emp.employment_degree / 100) * 100) / 100
await supabase
.from('salary_line_items')
.update({ amount: baseAmount })
.eq('salary_run_employee_id', sre.id)
.eq('company_id', companyId)
.eq('item_type', 'monthly_salary')
}
const employeeName = `${emp.first_name} ${emp.last_name}`
if (absenceResult.flagLakarintyg) lakarintygEmployees.push(employeeName)
if (absenceResult.flagFkReporting) fkReportingEmployees.push(employeeName)
@@ -485,7 +510,7 @@ export async function runSalaryCalculation(
const baseHourlyRate = effectiveHourlyRate({
salary_type: emp.salary_type,
hourly_rate: emp.hourly_rate,
monthly_salary: emp.monthly_salary,
monthly_salary: sre.monthly_salary,
})
const shifts: WorkedDayShift[] = workedDayRows.map((row) => ({
work_date: row.work_date,
@@ -576,7 +601,7 @@ export async function runSalaryCalculation(
{
employmentType: emp.employment_type,
salaryType: emp.salary_type,
monthlySalary: emp.monthly_salary || 0,
monthlySalary: sre.monthly_salary || 0,
hourlyRate: emp.hourly_rate || undefined,
hoursWorked:
derivedHoursWorked !== null && derivedHoursWorked > 0