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:
@@ -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 })
|
||||
|
||||
@@ -1685,6 +1685,7 @@ async function commitCreateSupplierInvoiceFromInbox(
|
||||
total_sek: totalSek,
|
||||
paid_amount: 0,
|
||||
remaining_amount: totalRounded,
|
||||
document_id: documentId,
|
||||
notes,
|
||||
})
|
||||
.select()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user