From 809120c4b83805bc2bb2e7c04b2674f8a443f250 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Mon, 8 Jun 2026 07:37:24 +0200 Subject: [PATCH] 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 --- app/(dashboard)/salary/runs/[id]/page.tsx | 91 +++++++- .../runs/[id]/book/__tests__/route.test.ts | 105 ++++++++++ app/api/salary/runs/[id]/book/route.ts | 45 +++- .../[employeeId]/__tests__/route.test.ts | 125 +++++++++++ .../runs/[id]/employees/[employeeId]/route.ts | 77 ++++++- .../[id]/mark-paid/__tests__/route.test.ts | 93 ++++++++ .../supplier-invoices/[id]/mark-paid/route.ts | 22 ++ components/reports/views/index.tsx | 10 +- components/settings/SettingsLoadError.tsx | 24 +++ .../settings/SettingsLoadingSkeleton.tsx | 35 ++-- components/settings/SettingsShell.tsx | 51 +++-- .../sections/BookkeepingSettingsContent.tsx | 6 +- .../sections/CompanySettingsContent.tsx | 6 +- .../sections/InvoicingSettingsContent.tsx | 6 +- .../settings/sections/TaxSettingsContent.tsx | 33 +-- components/settings/useSettings.ts | 102 +++++++-- .../lib/__tests__/api-client.test.ts | 179 ++++++++++++++++ .../general/enable-banking/lib/api-client.ts | 198 ++++++++++++++++-- lib/api/schemas.ts | 5 + ...create-supplier-invoice-from-inbox.test.ts | 53 +++++ lib/pending-operations/commit.ts | 1 + lib/salary/__tests__/agi-xml.test.ts | 35 ++++ lib/salary/agi/generate-declaration.ts | 32 ++- lib/salary/run-calculation.ts | 41 +++- 24 files changed, 1244 insertions(+), 131 deletions(-) create mode 100644 app/api/salary/runs/[id]/book/__tests__/route.test.ts create mode 100644 app/api/salary/runs/[id]/employees/[employeeId]/__tests__/route.test.ts create mode 100644 components/settings/SettingsLoadError.tsx diff --git a/app/(dashboard)/salary/runs/[id]/page.tsx b/app/(dashboard)/salary/runs/[id]/page.tsx index 07ba06fb..d2af7456 100644 --- a/app/(dashboard)/salary/runs/[id]/page.tsx +++ b/app/(dashboard)/salary/runs/[id]/page.tsx @@ -7,6 +7,7 @@ import { Badge } from '@/components/ui/badge' import { Skeleton } from "@/components/ui/skeleton" import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { @@ -166,6 +167,33 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: setActionLoading(null) } + // Edit this month's monthly salary for one employee (draft only). The engine + // reads this per-run value at calc time, so each month's gross can differ + // without changing the employee's standard pay. Saved on blur; the user then + // clicks Beräkna to refresh the outcome. + async function handleSalaryEdit(employeeId: string, raw: string, previous: number) { + const monthly = Number(raw.replace(/\s/g, '').replace(',', '.')) + if (!Number.isFinite(monthly) || monthly < 0 || monthly === previous) return + setActionLoading(`salary-${employeeId}`) + const res = await fetch(`/api/salary/runs/${id}/employees/${employeeId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ monthly_salary: monthly }), + }) + if (res.ok) { + await loadRun() + toast({ title: 'Månadslön uppdaterad', description: 'Klicka Beräkna för att uppdatera utfallet.' }) + } else { + const result = await res.json() + toast({ + title: 'Kunde inte uppdatera månadslön', + description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), + variant: 'destructive', + }) + } + setActionLoading(null) + } + async function handleCalculate() { setActionLoading('calculate') const res = await fetch(`/api/salary/runs/${id}/calculate`, { method: 'POST' }) @@ -290,6 +318,26 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: const addedEmployeeIds = new Set(employees.map(e => e.employee_id)) const notAdded = availableEmployees.filter(e => !addedEmployeeIds.has(e.id)) + // calculation_params is frozen only when the run has been calculated, so it + // distinguishes "not yet calculated" from "calculated to 0" (a nollkörning). + const isCalculated = run.calculation_params != null + const isNollkorning = isCalculated && Math.round((run.total_gross ?? 0) * 100) === 0 + + // Advancing a draft to review. For a nollkörning confirm first — an empty + // declaration is filed to Skatteverket, which should be deliberate. + function handleToReview() { + if ( + isNollkorning && + !confirm( + 'Detta är en nollkörning — ingen lön rapporteras för perioden. ' + + 'En nolldeklaration (huvuduppgift utan individuppgifter) lämnas till Skatteverket. Vill du fortsätta?', + ) + ) { + return + } + handleAction('review') + } + return (
{/* Header */} @@ -346,6 +394,19 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: ))}
+ {isNollkorning && ( + + +

Nollkörning

+

+ Ingen lön rapporteras för {periodLabel}. En nolldeklaration (huvuduppgift utan + individuppgifter) lämnas till Skatteverket — en registrerad arbetsgivare måste lämna + arbetsgivardeklaration varje månad, även månader utan lön. +

+
+
+ )} + {/* Employees */} @@ -397,7 +458,7 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: Anställd - Brutto + {run.status === 'draft' ? 'Månadslön' : 'Brutto'} Skatt Netto Avgifter @@ -413,6 +474,8 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: : `Anställd ${sre.employee_id.slice(0, 8)}...` const taxValue = sre.tax_withheld_override ?? sre.tax_withheld const avgifterValue = sre.avgifter_amount_override ?? sre.avgifter_amount + // Monthly salary is editable per run while the run is a draft. + const editableSalary = run.status === 'draft' && canWrite && sre.salary_type === 'monthly' return ( - Brutto {formatCurrency(sre.gross_salary)} + {run.status === 'draft' + ? `Månadslön ${formatCurrency(sre.monthly_salary)}` + : `Brutto ${formatCurrency(sre.gross_salary)}`} - {formatCurrency(sre.gross_salary)} + + {editableSalary ? ( + e.stopPropagation()} + onBlur={(e) => handleSalaryEdit(sre.employee_id, e.target.value, sre.monthly_salary)} + disabled={actionLoading === `salary-${sre.employee_id}`} + aria-label={`Månadslön för ${name}`} + className="h-8 w-32 ml-auto text-right tabular-nums" + /> + ) : ( + formatCurrency(sre.gross_salary) + )} + {formatCurrency(taxValue)} {formatCurrency(sre.net_salary + (sre.tax_withheld - taxValue))} {formatCurrency(avgifterValue)} @@ -597,7 +678,7 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: {actionLoading === 'delete' ? : } Radera utkast - @@ -605,7 +686,7 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: {actionLoading === 'preview' ? : } Förhandsgranska - diff --git a/app/api/salary/runs/[id]/book/__tests__/route.test.ts b/app/api/salary/runs/[id]/book/__tests__/route.test.ts new file mode 100644 index 00000000..6236cfb0 --- /dev/null +++ b/app/api/salary/runs/[id]/book/__tests__/route.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createQueuedMockSupabase, + createMockRequest, + parseJsonResponse, + createMockRouteParams, +} from '@/tests/helpers' + +// ── Mocks ──────────────────────────────────────────────────── +// The route is wrapped in withRouteContext (auth via requireAuth, company via +// getActiveCompanyId, write-gate via requireWritePermission). createSalaryRunEntries +// is mocked so we can assert it is NOT called for a nollkörning (zero-amount run), +// where the bookkeeping engine would otherwise reject a zero voucher. + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) +vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() })) +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) +vi.mock('@/lib/events', () => ({ + eventBus: { emit: vi.fn().mockResolvedValue(undefined) }, +})) +vi.mock('@/lib/salary/salary-entries', () => ({ createSalaryRunEntries: vi.fn() })) + +import { POST } from '../route' +import { requireAuth } from '@/lib/auth/require-auth' +import { eventBus } from '@/lib/events' +import { createSalaryRunEntries } from '@/lib/salary/salary-entries' + +const mockUser = { id: 'user-1', email: 'test@test.se' } + +const makePaidRun = (overrides = {}) => ({ + id: 'run-1', + company_id: 'company-1', + status: 'paid', + period_year: 2026, + period_month: 4, + payment_date: '2026-04-25', + voucher_series: 'A', + total_gross: 0, + total_tax: 0, + total_net: 0, + total_avgifter: 0, + total_vacation_accrual: 0, + ...overrides, +}) + +describe('POST /api/salary/runs/[id]/book — nollkörning', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('books an empty-roster zero-total run without creating journal entries', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + vi.mocked(requireAuth).mockResolvedValue({ + user: mockUser as never, + supabase: supabase as never, + error: null, + }) + + enqueueMany([ + { data: makePaidRun() }, // salary_runs (paid) lookup + { data: [] }, // salary_run_employees roster (empty) + { data: { id: 'run-1', status: 'booked' } }, // salary_runs update → booked + ]) + + const request = createMockRequest('/api/salary/runs/run-1/book', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'run-1' })) + const { status, body } = await parseJsonResponse<{ data: { id: string; status: string } }>( + response, + ) + + expect(status).toBe(200) + expect(body.data.status).toBe('booked') + expect(createSalaryRunEntries).not.toHaveBeenCalled() + expect(eventBus.emit).toHaveBeenCalledWith( + expect.objectContaining({ type: 'salary_run.booked' }), + ) + }) + + it('books a run whose employees were all set to 0 kr as a nollkörning (no vouchers)', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + vi.mocked(requireAuth).mockResolvedValue({ + user: mockUser as never, + supabase: supabase as never, + error: null, + }) + + enqueueMany([ + { data: makePaidRun() }, // salary_runs (paid) lookup + { data: [{ employee_id: 'e1', gross_salary: 0, line_items: [] }] }, // roster present but zero + { data: { id: 'run-1', status: 'booked' } }, // salary_runs update → booked + ]) + + const request = createMockRequest('/api/salary/runs/run-1/book', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'run-1' })) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(createSalaryRunEntries).not.toHaveBeenCalled() + }) +}) diff --git a/app/api/salary/runs/[id]/book/route.ts b/app/api/salary/runs/[id]/book/route.ts index fd44256c..6e099db6 100644 --- a/app/api/salary/runs/[id]/book/route.ts +++ b/app/api/salary/runs/[id]/book/route.ts @@ -36,8 +36,47 @@ export const POST = withRouteContext( .select('*, employee:employees(employment_type), line_items:salary_line_items(*)') .eq('salary_run_id', id) - if (empError || !employees || employees.length === 0) { - return errorResponseFromCode('SALARY_RUN_NO_EMPLOYEES', opLog, { requestId }) + if (empError) { + return errorResponse(empError, opLog, { requestId }) + } + const roster = employees ?? [] + + // Nollkörning: a run with no monetary effect (employees set to 0 kr, or no + // roster at all) has nothing to post. The bookkeeping engine forbids + // zero-amount vouchers (every entry must balance with debit & credit > 0), + // so we skip journal-entry creation entirely and just advance to 'booked'. + // The AGI nolldeklaration is then the only artefact for the period. + const nothingToBook = + Math.round((run.total_gross ?? 0) * 100) === 0 && + Math.round((run.total_tax ?? 0) * 100) === 0 && + Math.round((run.total_avgifter ?? 0) * 100) === 0 && + Math.round((run.total_vacation_accrual ?? 0) * 100) === 0 + + if (nothingToBook) { + const { data: bookedRun, error: updateError } = await supabase + .from('salary_runs') + .update({ + status: 'booked', + booked_at: new Date().toISOString(), + booked_by: user.id, + }) + .eq('id', id) + .eq('company_id', companyId) + .select() + .single() + + if (updateError) { + return errorResponse(updateError, opLog, { requestId }) + } + + await eventBus.emit({ + type: 'salary_run.booked', + payload: { salaryRunId: id, entryIds: [], userId: user.id, companyId: companyId! }, + }) + + opLog.info('salary run booked as nollkörning (no journal entries)', { salaryRunId: id }) + + return NextResponse.json({ data: bookedRun }) } try { @@ -56,7 +95,7 @@ export const POST = withRouteContext( total_net: run.total_net, total_avgifter: run.total_avgifter, total_vacation_accrual: run.total_vacation_accrual, - employees: employees.map((sre) => ({ + employees: roster.map((sre) => ({ employee_id: sre.employee_id, employment_type: sre.employee?.employment_type || 'employee', gross_salary: sre.gross_salary, diff --git a/app/api/salary/runs/[id]/employees/[employeeId]/__tests__/route.test.ts b/app/api/salary/runs/[id]/employees/[employeeId]/__tests__/route.test.ts new file mode 100644 index 00000000..73eb0bcd --- /dev/null +++ b/app/api/salary/runs/[id]/employees/[employeeId]/__tests__/route.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createQueuedMockSupabase, + createMockRequest, + parseJsonResponse, + createMockRouteParams, +} from '@/tests/helpers' + +// ── Mocks ──────────────────────────────────────────────────── +// This route hand-rolls auth (createClient + getUser) rather than +// withRouteContext, so we mock createClient and the write/company helpers. + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +const mockCreateClient = vi.fn() +vi.mock('@/lib/supabase/server', () => ({ createClient: () => mockCreateClient() })) + +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) +vi.mock('@/lib/salary/personnummer', () => ({ + decryptPersonnummer: (x: string) => x, + maskPersonnummer: (x: string) => x, +})) + +import { PATCH } from '../route' + +const mockUser = { id: 'user-1', email: 'test@test.se' } + +function authedSupabase() { + const { supabase, enqueueMany } = createQueuedMockSupabase() + supabase.auth = { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) } + mockCreateClient.mockResolvedValue(supabase) + return { supabase, enqueueMany } +} + +describe('PATCH /api/salary/runs/[id]/employees/[employeeId] — monthly salary edit', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('updates the per-run monthly salary while the run is a draft', async () => { + const { enqueueMany } = authedSupabase() + enqueueMany([ + { data: { id: 'run-1', status: 'draft' } }, // salary_runs lookup + { + data: { id: 'sre-1', employment_degree: 100, salary_type: 'monthly', monthly_salary: 30000 }, + }, // salary_run_employees update + { data: null }, // salary_line_items Grundlön refresh + ]) + + const request = createMockRequest('/api/salary/runs/run-1/employees/emp-1', { + method: 'PATCH', + body: { monthly_salary: 30000 }, + }) + const response = await PATCH( + request, + createMockRouteParams({ id: 'run-1', employeeId: 'emp-1' }), + ) + const { status, body } = await parseJsonResponse<{ data: { monthly_salary: number } }>(response) + + expect(status).toBe(200) + expect(body.data.monthly_salary).toBe(30000) + }) + + it('allows a zero monthly salary (nollkörning) on a draft', async () => { + const { enqueueMany } = authedSupabase() + enqueueMany([ + { data: { id: 'run-1', status: 'draft' } }, + { data: { id: 'sre-1', employment_degree: 100, salary_type: 'monthly', monthly_salary: 0 } }, + { data: null }, + ]) + + const request = createMockRequest('/api/salary/runs/run-1/employees/emp-1', { + method: 'PATCH', + body: { monthly_salary: 0 }, + }) + const response = await PATCH( + request, + createMockRouteParams({ id: 'run-1', employeeId: 'emp-1' }), + ) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + }) + + it('rejects a monthly salary edit when the run is no longer a draft', async () => { + const { enqueueMany } = authedSupabase() + enqueueMany([ + { data: { id: 'run-1', status: 'review' } }, // not a draft + ]) + + const request = createMockRequest('/api/salary/runs/run-1/employees/emp-1', { + method: 'PATCH', + body: { monthly_salary: 30000 }, + }) + const response = await PATCH( + request, + createMockRouteParams({ id: 'run-1', employeeId: 'emp-1' }), + ) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toContain('utkast') + }) + + it('rejects mixing a salary edit with a tax override in one request', async () => { + authedSupabase() + + const request = createMockRequest('/api/salary/runs/run-1/employees/emp-1', { + method: 'PATCH', + body: { monthly_salary: 30000, tax_withheld_override: 5000, reason: 'test' }, + }) + const response = await PATCH( + request, + createMockRouteParams({ id: 'run-1', employeeId: 'emp-1' }), + ) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(400) + }) +}) diff --git a/app/api/salary/runs/[id]/employees/[employeeId]/route.ts b/app/api/salary/runs/[id]/employees/[employeeId]/route.ts index f9b06eb5..7f738e8b 100644 --- a/app/api/salary/runs/[id]/employees/[employeeId]/route.ts +++ b/app/api/salary/runs/[id]/employees/[employeeId]/route.ts @@ -53,13 +53,19 @@ export async function GET( } /** - * Apply per-employee override on tax/avgifter (advanced mode). + * Per-employee edits within a salary run. Two operations, gated to different + * statuses (never combined in one request): * - * Only allowed in `review` status — the calculation engine has run, but the - * run hasn't been approved or booked yet. After approval, vouchers and AGI - * lock in the effective values; further changes require correction flows. + * • monthly_salary — set this month's base salary for the employee. Allowed + * only in `draft`. 0 is valid (an intentional nollkörning). The engine reads + * this per-run value (not the employee master) when the run is calculated, so + * each month's gross can differ without touching the employee's standard pay. * - * Pass `null` for any field to clear a previously-set override. + * • tax_withheld_override / avgifter_*_override — manual tax/avgifter + * adjustment (advanced mode). Allowed only in `review`: the engine has run + * but the run isn't approved/booked. After approval, vouchers and AGI lock in + * the effective values; further changes require correction flows. Pass `null` + * for any override field to clear it. */ export async function PATCH( request: Request, @@ -78,7 +84,24 @@ export async function PATCH( const parsed = await validateBody(request, SalaryEmployeeOverrideSchema) if (!parsed.success) return parsed.response - // Gate on run status. Override is only valid mid-review. + // Two distinct operations share this endpoint, gated to different statuses: + // • monthly_salary → edit this month's base salary (draft only) + // • *_override → manual tax/avgifter adjustment (review only) + // They must not be mixed in one request. + const wantsSalaryEdit = parsed.data.monthly_salary !== undefined + const wantsOverride = + parsed.data.tax_withheld_override !== undefined || + parsed.data.avgifter_amount_override !== undefined || + parsed.data.avgifter_basis_override !== undefined || + parsed.data.reason !== undefined + + if (wantsSalaryEdit && wantsOverride) { + return NextResponse.json( + { error: 'Kan inte ändra månadslön och skatte-/avgiftsjustering i samma anrop.' }, + { status: 400 }, + ) + } + const { data: run } = await supabase .from('salary_runs') .select('id, status') @@ -87,6 +110,48 @@ export async function PATCH( .single() if (!run) return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) + + // ── Draft-stage edit of this month's base salary ── + if (wantsSalaryEdit) { + if (run.status !== 'draft') { + return NextResponse.json( + { error: 'Månadslönen kan bara redigeras medan lönekörningen är ett utkast.' }, + { status: 400 }, + ) + } + const monthly = Math.round((parsed.data.monthly_salary as number) * 100) / 100 + + const { data: sre, error: sreErr } = await supabase + .from('salary_run_employees') + .update({ monthly_salary: monthly }) + .eq('salary_run_id', id) + .eq('employee_id', employeeId) + .eq('company_id', companyId) + .select('id, employment_degree, salary_type, monthly_salary') + .maybeSingle() + + if (sreErr) return NextResponse.json({ error: sreErr.message }, { status: 400 }) + if (!sre) { + return NextResponse.json({ error: 'Anställd hittades inte i lönekörningen' }, { status: 404 }) + } + + // Keep the displayed 'Grundlön' line consistent with the new salary. This is + // display-only — the engine recomputes baseSalary from monthly_salary at + // calc time — but it avoids a stale row before the user clicks Beräkna. + if (sre.salary_type === 'monthly') { + const baseAmount = Math.round(monthly * (sre.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') + } + + return NextResponse.json({ data: sre }) + } + + // ── Review-stage override of tax/avgifter ── if (run.status !== 'review') { return NextResponse.json( { error: 'Justering av skatt/avgifter är bara tillåten i granskningsläge (review).' }, diff --git a/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts index dc593816..c7f749a3 100644 --- a/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts +++ b/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts @@ -35,7 +35,18 @@ vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({ mockCreateSupplierInvoiceCashEntry(...args), })) +vi.mock('@/lib/core/documents/document-service', async () => { + const actual = await vi.importActual( + '@/lib/core/documents/document-service' + ) + return { + ...actual, + linkToJournalEntry: vi.fn(), + } +}) + import { eventBus } from '@/lib/events' +import { linkToJournalEntry } from '@/lib/core/documents/document-service' import { POST } from '../route' @@ -241,6 +252,88 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => { expect(mockCreateSupplierInvoicePaymentEntry).not.toHaveBeenCalled() }) + it('cash method: links the inbox document to the cash payment verifikat (BFL 5 kap 6 §)', async () => { + const supplier = makeSupplier() + const invoice = makeSupplierInvoice({ + id: 'si-1', + status: 'approved', + total: 10000, + remaining_amount: 10000, + paid_amount: 0, + document_id: 'doc-1', + supplier, + items: [], + }) + + enqueue({ data: invoice, error: null }) + // Duplicate-payment guard: no candidate transactions + enqueue({ data: [], error: null }) + enqueue({ data: { accounting_method: 'cash' }, error: null }) + + mockCreateSupplierInvoiceCashEntry.mockResolvedValue({ id: 'je-cash' }) + + // Update invoice (CAS guard: returns matched row) + enqueue({ data: [{ id: 'si-1' }], error: null }) + // Record payment + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', { + method: 'POST', + body: {}, + }) + const response = await POST(request, createMockRouteParams({ id: 'si-1' })) + const { status, body } = await parseJsonResponse<{ journal_entry_id: string }>(response) + + expect(status).toBe(200) + expect(body.journal_entry_id).toBe('je-cash') + // The cash entry is the ONLY booking, so its underlag must hang on it. + expect(linkToJournalEntry).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'doc-1', + 'je-cash', + ) + }) + + it('accrual method: does NOT re-link the document at payment (stays on the registration verifikat)', async () => { + const supplier = makeSupplier() + const invoice = makeSupplierInvoice({ + id: 'si-1', + status: 'approved', + total: 10000, + remaining_amount: 10000, + paid_amount: 0, + document_id: 'doc-1', + registration_journal_entry_id: 'je-reg', + supplier, + items: [], + }) + + enqueue({ data: invoice, error: null }) + // Duplicate-payment guard: no candidate transactions + enqueue({ data: [], error: null }) + enqueue({ data: { accounting_method: 'accrual' }, error: null }) + + mockCreateSupplierInvoicePaymentEntry.mockResolvedValue({ id: 'je-pay' }) + + // Update invoice (CAS guard: returns matched row) + enqueue({ data: [{ id: 'si-1' }], error: null }) + // Record payment + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', { + method: 'POST', + body: {}, + }) + const response = await POST(request, createMockRouteParams({ id: 'si-1' })) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + // The document already lives on the registration verifikat — re-linking + // here would move it off the primary booking. + expect(linkToJournalEntry).not.toHaveBeenCalled() + }) + it('returns 500 when journal entry creation fails (blocking — GL must succeed for payment)', async () => { const supplier = makeSupplier() const invoice = makeSupplierInvoice({ diff --git a/app/api/supplier-invoices/[id]/mark-paid/route.ts b/app/api/supplier-invoices/[id]/mark-paid/route.ts index e6441e94..8fe15ce0 100644 --- a/app/api/supplier-invoices/[id]/mark-paid/route.ts +++ b/app/api/supplier-invoices/[id]/mark-paid/route.ts @@ -7,6 +7,7 @@ import { } from '@/lib/bookkeeping/supplier-invoice-entries' import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine' import { isBookkeepingError } from '@/lib/bookkeeping/errors' +import { linkToJournalEntry } from '@/lib/core/documents/document-service' import { validateBody } from '@/lib/api/validate' import { MarkSupplierInvoicePaidSchema } from '@/lib/api/schemas' import { withRouteContext } from '@/lib/api/with-route-context' @@ -279,6 +280,27 @@ export const POST = withRouteContext( opLog.warn('failed to record supplier_invoice_payments row', paymentError) } + // Under kontantmetoden the cash payment entry is the ONLY booking of the + // affärshändelse, so its underlag (the document from the inbox) must hang on + // THIS verifikat per BFL 5 kap 6 §. Under faktureringsmetoden the document + // is already linked to the registration verifikat at receipt — re-linking + // here would move it off that primary booking, so we attach only for the + // cash entry. Non-fatal: the payment is already committed and immutable, so + // a link failure is logged and the invoice stays usable (mirrors the + // registration-time linking in commitCreateSupplierInvoiceFromInbox). + const invoiceDocumentId = (invoice as { document_id?: string | null }).document_id + if (useCashEntry && invoiceDocumentId && journalEntryId) { + try { + await linkToJournalEntry(supabase, companyId!, invoiceDocumentId, journalEntryId) + } catch (linkErr) { + opLog.warn('failed to link supplier invoice document to cash payment JE', { + documentId: invoiceDocumentId, + journalEntryId, + error: linkErr instanceof Error ? linkErr.message : String(linkErr), + }) + } + } + try { await eventBus.emit({ type: 'supplier_invoice.paid', diff --git a/components/reports/views/index.tsx b/components/reports/views/index.tsx index 29dc41e6..7c97501f 100644 --- a/components/reports/views/index.tsx +++ b/components/reports/views/index.tsx @@ -17,7 +17,7 @@ import { roundOre } from '@/lib/money' import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import { AccountNumber } from '@/components/ui/account-number' import { ReportExportMenu } from '@/components/reports/ReportExportMenu' -import { useSettings } from '@/components/settings/useSettings' +import { useCompanySettings } from '@/components/settings/useSettings' import { TrialBalanceChart } from '@/components/reports/TrialBalanceChart' import { VatCompositionChart } from '@/components/reports/VatCompositionChart' import { SkatteverketPanel } from '@/components/reports/SkatteverketPanel' @@ -1011,10 +1011,10 @@ export function VatDeclarationView({ // (moms_period in Inställningar) so the picker mirrors the setting instead of // always starting on quarterly. Applied once per company the first time its // settings load; a later manual change to the picker is preserved, and a - // company switch re-applies the new company's setting. `useSettings` only - // refetches when the active company changes, so this never clobbers a manual - // selection mid-session. - const { settings } = useSettings() + // company switch re-applies the new company's setting. `useCompanySettings` + // only refetches when the active company changes, so this never clobbers a + // manual selection mid-session. + const { settings } = useCompanySettings() const appliedForCompany = useRef(null) useEffect(() => { const momsPeriod = settings?.moms_period diff --git a/components/settings/SettingsLoadError.tsx b/components/settings/SettingsLoadError.tsx new file mode 100644 index 00000000..518a394a --- /dev/null +++ b/components/settings/SettingsLoadError.tsx @@ -0,0 +1,24 @@ +'use client' + +import { useTranslations } from 'next-intl' +import { AlertCircle } from 'lucide-react' +import { Button } from '@/components/ui/button' + +/** + * Shown when a settings section's fetch settled without a row (or errored). + * Replaces the old behaviour where a null `settings` object left the loading + * skeleton on screen indefinitely — a settled fetch always resolves to either + * content or this retryable state. + */ +export function SettingsLoadError({ onRetry }: { onRetry: () => void }) { + const t = useTranslations('common') + return ( +
+ +

{t('load_error')}

+ +
+ ) +} diff --git a/components/settings/SettingsLoadingSkeleton.tsx b/components/settings/SettingsLoadingSkeleton.tsx index 17cb9ce8..6ebd13aa 100644 --- a/components/settings/SettingsLoadingSkeleton.tsx +++ b/components/settings/SettingsLoadingSkeleton.tsx @@ -1,25 +1,26 @@ import { Skeleton } from '@/components/ui/skeleton' +/** + * Placeholder shown while a settings section's data loads. Mirrors the real shape + * of the section forms — an uppercase section heading followed by stacked + * label/field rows, with a hairline divider between blocks — so the swap to live + * content doesn't jump from a mismatched layout. + */ export function SettingsLoadingSkeleton() { return ( -
- {[1, 2].map(i => ( -
- -
-
- - +
+ {[0, 1].map((block) => ( +
+ + {[0, 1, 2].map((row) => ( +
+ +
-
- - -
-
-
- - -
+ ))}
))}
diff --git a/components/settings/SettingsShell.tsx b/components/settings/SettingsShell.tsx index 26f16ccc..fca6ef6b 100644 --- a/components/settings/SettingsShell.tsx +++ b/components/settings/SettingsShell.tsx @@ -3,6 +3,7 @@ import { Suspense } from 'react' import { SettingsRail } from './SettingsRail' import { SettingsLoadingSkeleton } from './SettingsLoadingSkeleton' +import { SettingsProvider } from './useSettings' import { SETTINGS_SECTIONS } from './sections' interface SettingsShellProps { @@ -23,33 +24,39 @@ export function SettingsShell({ variant, activeSection, children }: SettingsShel if (variant === 'modal') { const Section = activeSection ? SETTINGS_SECTIONS[activeSection] : undefined return ( -
- -
-
- + // One provider per open shell: the section it wraps is swapped on tab change + // without remounting the shell, so the settings fetch is shared across tabs. + +
+ +
+
+ +
+ }> + {Section ?
: null} +
- }> - {Section ?
: null} -
-
+ ) } return ( -
- -
{children}
-
+ +
+ +
{children}
+
+
) } diff --git a/components/settings/sections/BookkeepingSettingsContent.tsx b/components/settings/sections/BookkeepingSettingsContent.tsx index d565a502..9fbeaa59 100644 --- a/components/settings/sections/BookkeepingSettingsContent.tsx +++ b/components/settings/sections/BookkeepingSettingsContent.tsx @@ -4,6 +4,7 @@ import Link from 'next/link' import { useState } from 'react' import { useTranslations } from 'next-intl' import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper' +import { SettingsLoadError } from '@/components/settings/SettingsLoadError' import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton' import { PeriodLockingSettings } from '@/components/settings/PeriodLockingSettings' import { VoucherSeriesManager } from '@/components/settings/VoucherSeriesManager' @@ -20,7 +21,7 @@ const SERIES_OPTIONS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('') export function BookkeepingSettingsContent() { const t = useTranslations('settings_bookkeeping') - const { settings, isLoading, updateSettings } = useSettings() + const { settings, isLoading, updateSettings, refetch } = useSettings() const { company } = useCompany() // Local mirror of the company-level accounting_framework so the K2/K3 // selector can reflect its own saves without waiting for the layout to @@ -30,7 +31,8 @@ export function BookkeepingSettingsContent() { company?.accounting_framework ?? 'k2', ) - if (isLoading || !settings) return + if (isLoading) return + if (!settings) return function handleSave(formData: FormData) { const autoLockValue = formData.get('auto_lock_period_days') as string diff --git a/components/settings/sections/CompanySettingsContent.tsx b/components/settings/sections/CompanySettingsContent.tsx index 99694a22..6a8cb087 100644 --- a/components/settings/sections/CompanySettingsContent.tsx +++ b/components/settings/sections/CompanySettingsContent.tsx @@ -8,15 +8,17 @@ import { CompanyProfileSection } from '@/components/settings/CompanyProfileSecti import { FiscalPeriodEditor } from '@/components/settings/FiscalPeriodEditor' import { LogoUpload } from '@/components/settings/LogoUpload' import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper' +import { SettingsLoadError } from '@/components/settings/SettingsLoadError' import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton' import { useSettings } from '@/components/settings/useSettings' import type { CompanySettings } from '@/types' export function CompanySettingsContent() { const router = useRouter() - const { settings, isLoading, updateSettings } = useSettings() + const { settings, isLoading, updateSettings, refetch } = useSettings() - if (isLoading || !settings) return + if (isLoading) return + if (!settings) return function handleSave(formData: FormData) { const updates: Record = { diff --git a/components/settings/sections/InvoicingSettingsContent.tsx b/components/settings/sections/InvoicingSettingsContent.tsx index 826a1370..e4638ae2 100644 --- a/components/settings/sections/InvoicingSettingsContent.tsx +++ b/components/settings/sections/InvoicingSettingsContent.tsx @@ -6,6 +6,7 @@ import { InvoiceSettingsForm } from '@/components/settings/InvoiceSettingsForm' import { InvoicePreviewCard } from '@/components/settings/InvoicePreviewCard' import { PdfPrintSettings } from '@/components/settings/PdfPrintSettings' import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper' +import { SettingsLoadError } from '@/components/settings/SettingsLoadError' import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton' import { useSettings } from '@/components/settings/useSettings' import { useToast } from '@/components/ui/use-toast' @@ -14,10 +15,11 @@ import type { CompanySettings } from '@/types' export function InvoicingSettingsContent() { const t = useTranslations('settings_invoicing') - const { settings, isLoading, updateSettings } = useSettings() + const { settings, isLoading, updateSettings, refetch } = useSettings() const { toast } = useToast() - if (isLoading || !settings) return + if (isLoading) return + if (!settings) return function handleSave(formData: FormData) { const bankErrors = validateBankFields(formData) diff --git a/components/settings/sections/TaxSettingsContent.tsx b/components/settings/sections/TaxSettingsContent.tsx index dad0b855..f1cd4af6 100644 --- a/components/settings/sections/TaxSettingsContent.tsx +++ b/components/settings/sections/TaxSettingsContent.tsx @@ -1,46 +1,27 @@ 'use client' -import { useEffect, useState } from 'react' +import { useEffect } from 'react' import { useTranslations } from 'next-intl' import { useSearchParams, useRouter } from 'next/navigation' import { TaxSettingsForm } from '@/components/settings/TaxSettingsForm' import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper' +import { SettingsLoadError } from '@/components/settings/SettingsLoadError' import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton' import { SkatteverketConnectPanel } from '@/components/settings/SkatteverketConnectPanel' import { useSettings } from '@/components/settings/useSettings' import { useToast } from '@/components/ui/use-toast' -import { useCompany } from '@/contexts/CompanyContext' -import { createClient } from '@/lib/supabase/client' import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' import type { CompanySettings } from '@/types' export function TaxSettingsContent() { - const { settings, isLoading, updateSettings } = useSettings() - const { company } = useCompany() + const { settings, isLoading, updateSettings, refetch } = useSettings() const t = useTranslations('settings_skatteverket') const searchParams = useSearchParams() const router = useRouter() const { toast } = useToast() - const [isSandbox, setIsSandbox] = useState(false) - const hasSkatteverketExtension = ENABLED_EXTENSION_IDS.has('skatteverket') - // Sandbox companies don't connect to the real Skatteverket — hide the panel, - // matching the old Skatteverket tab's visibility gate. - useEffect(() => { - if (!company?.id) return - const supabase = createClient() - supabase - .from('company_settings') - .select('is_sandbox') - .eq('company_id', company.id) - .single() - .then(({ data }) => { - if (data?.is_sandbox) setIsSandbox(true) - }) - }, [company?.id]) - // Skatteverket OAuth callback — the connect flow returns to /settings/tax with // a status query param (returnTo set in SkatteverketConnectPanel). useEffect(() => { @@ -61,7 +42,8 @@ export function TaxSettingsContent() { } }, [searchParams, router, toast, t]) - if (isLoading || !settings) return + if (isLoading) return + if (!settings) return function handleSave(formData: FormData) { const vatRegistered = formData.get('vat_registered') === 'true' @@ -88,7 +70,10 @@ export function TaxSettingsContent() { } } - const showSkatteverket = hasSkatteverketExtension && !isSandbox + // Sandbox companies don't connect to the real Skatteverket — hide the panel, + // matching the old Skatteverket tab's visibility gate. Read straight off the + // already-loaded settings row (no separate query needed). + const showSkatteverket = hasSkatteverketExtension && !settings.is_sandbox return (
diff --git a/components/settings/useSettings.ts b/components/settings/useSettings.ts index cd95e81c..b85285b5 100644 --- a/components/settings/useSettings.ts +++ b/components/settings/useSettings.ts @@ -1,41 +1,107 @@ 'use client' -import { useState, useEffect, useCallback } from 'react' -import { useRouter } from 'next/navigation' +import { + createContext, + createElement, + useCallback, + useContext, + useEffect, + useState, + type ReactNode, +} from 'react' import { createClient } from '@/lib/supabase/client' import { useCompany } from '@/contexts/CompanyContext' import type { CompanySettings } from '@/types' -export function useSettings() { - const router = useRouter() +export interface SettingsState { + settings: CompanySettings | null + /** True while the fetch for the active company is in flight. */ + isLoading: boolean + /** True once a fetch finished without a row (or errored) — distinct from loading. */ + error: boolean + updateSettings: (updates: Partial) => void + refetch: () => Promise +} + +/** + * Standalone settings fetcher: loads `company_settings` for the active company + * (resolved from CompanyContext). Use this OUTSIDE the settings surface (e.g. the + * reports VAT view). Inside the settings surface, read the shared instance with + * `useSettings()` instead — `SettingsProvider` mounts exactly one of these so + * switching sections reuses the loaded data rather than refetching. + * + * Auth is already enforced by middleware before any authenticated page renders, + * so this no longer round-trips `auth.getUser()` — it gates purely on the + * resolved company id, removing a request from the path the skeleton waits on. + */ +export function useCompanySettings(): SettingsState { const { company } = useCompany() const [settings, setSettings] = useState(null) const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(false) const fetchSettings = useCallback(async () => { - const supabase = createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) { router.push('/login'); return } - - if (company?.id) { - const { data } = await supabase - .from('company_settings') - .select('*') - .eq('company_id', company.id) - .single() - setSettings(data) + if (!company?.id) { + // No active company (the no-company escape hatch). Nothing to load; surface + // a settled empty state rather than a perpetual spinner. + setSettings(null) + setError(false) + setIsLoading(false) + return } + setIsLoading(true) + setError(false) + + const supabase = createClient() + // maybeSingle() so a missing row resolves to { data: null } instead of + // throwing PGRST116 — a company created outside the onboarding flow may have + // no company_settings row yet, and that must not be treated as a hard error + // mid-query (it's surfaced as `error` below once the fetch settles). + const { data, error: queryError } = await supabase + .from('company_settings') + .select('*') + .eq('company_id', company.id) + .maybeSingle() + + setSettings(data) + setError(Boolean(queryError) || !data) setIsLoading(false) - }, [company?.id, router]) + }, [company?.id]) useEffect(() => { fetchSettings() }, [fetchSettings]) const updateSettings = useCallback((updates: Partial) => { - setSettings(prev => prev ? { ...prev, ...updates } as CompanySettings : null) + setSettings((prev) => (prev ? ({ ...prev, ...updates } as CompanySettings) : prev)) }, []) - return { settings, isLoading, updateSettings, refetch: fetchSettings } + return { settings, isLoading, error, updateSettings, refetch: fetchSettings } +} + +const SettingsContext = createContext(null) + +/** + * Hosts one shared settings fetch for the whole settings surface. Mounted once by + * `SettingsShell`, it survives section swaps (the shell re-renders rather than + * remounting when the active section changes), so moving between settings tabs + * reuses the loaded data instead of refetching and re-flashing the skeleton. + */ +export function SettingsProvider({ children }: { children: ReactNode }) { + const value = useCompanySettings() + return createElement(SettingsContext.Provider, { value }, children) +} + +/** + * Read the shared settings instance. Must be rendered within a `SettingsProvider` + * (every settings section is, via `SettingsShell`). Outside the settings surface, + * use `useCompanySettings()` instead. + */ +export function useSettings(): SettingsState { + const ctx = useContext(SettingsContext) + if (!ctx) { + throw new Error('useSettings must be used within a SettingsProvider') + } + return ctx } diff --git a/extensions/general/enable-banking/lib/__tests__/api-client.test.ts b/extensions/general/enable-banking/lib/__tests__/api-client.test.ts index 99ca854d..05358316 100644 --- a/extensions/general/enable-banking/lib/__tests__/api-client.test.ts +++ b/extensions/general/enable-banking/lib/__tests__/api-client.test.ts @@ -220,6 +220,185 @@ describe('api-client', () => { warnSpy.mockRestore() }) + + // Danske Bank rejects a history window beyond its ~90-day PSD2 limit with a + // blanket ASPSP_ERROR rather than clamping. The window must be narrowed. + const ASPSP_ERROR_BODY = + '{"code":400,"message":"Error interacting with ASPSP","detail":"Unknown error","error":"ASPSP_ERROR"}' + + it('narrows date_from when the ASPSP rejects the history window', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + fetchSpy + // strategy=longest, full 120-day window → ASPSP_ERROR + .mockResolvedValueOnce(new Response(ASPSP_ERROR_BODY, { status: 400 })) + // strategy dropped, still full window → ASPSP_ERROR (window is the problem) + .mockResolvedValueOnce(new Response(ASPSP_ERROR_BODY, { status: 400 })) + // narrowed to 90 days before date_to → success + .mockResolvedValueOnce( + new Response( + JSON.stringify({ transactions: [{ transaction_amount: { amount: '42', currency: 'SEK' } }] }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + + const result = await getAllTransactionsWithRaw('acc-1', '2026-02-07', '2026-06-07', 'longest') + + expect(result.transactions).toHaveLength(1) + expect(fetchSpy).toHaveBeenCalledTimes(3) + + const urls = fetchSpy.mock.calls.map((c: unknown[]) => c[0] as string) + expect(urls[0]).toContain('date_from=2026-02-07') + expect(urls[0]).toContain('strategy=longest') + expect(urls[1]).toContain('date_from=2026-02-07') + expect(urls[1]).not.toContain('strategy=') + // 90 days before 2026-06-07 + expect(urls[2]).toContain('date_from=2026-03-09') + expect(urls[2]).toContain('date_to=2026-06-07') + + expect(warnSpy).toHaveBeenCalledWith( + '[enable-banking] ASPSP rejected history window, retrying with narrower date_from', + expect.objectContaining({ previousDateFrom: '2026-02-07', nextDateFrom: '2026-03-09' }) + ) + + warnSpy.mockRestore() + }) + + it('steps through successive narrower windows until one succeeds', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + fetchSpy + .mockResolvedValueOnce(new Response(ASPSP_ERROR_BODY, { status: 400 })) // full window + .mockResolvedValueOnce(new Response(ASPSP_ERROR_BODY, { status: 400 })) // 90 days + .mockResolvedValueOnce(new Response(ASPSP_ERROR_BODY, { status: 400 })) // 60 days + .mockResolvedValueOnce( + new Response(JSON.stringify({ transactions: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) // 30 days → success + + await getAllTransactionsWithRaw('acc-1', '2026-02-07', '2026-06-07') + + expect(fetchSpy).toHaveBeenCalledTimes(4) + const urls = fetchSpy.mock.calls.map((c: unknown[]) => c[0] as string) + expect(urls[0]).toContain('date_from=2026-02-07') + expect(urls[1]).toContain('date_from=2026-03-09') // 90 days before date_to + expect(urls[2]).toContain('date_from=2026-04-08') // 60 days + expect(urls[3]).toContain('date_from=2026-05-08') // 30 days + + warnSpy.mockRestore() + }) + + it('does not narrow the window on a non-ASPSP 400', async () => { + fetchSpy.mockResolvedValueOnce(new Response('{"error":"INVALID_REQUEST"}', { status: 400 })) + + await expect( + getAllTransactionsWithRaw('acc-1', '2026-02-07', '2026-06-07') + ).rejects.toThrow('Failed to get transactions (400)') + + // No strategy to drop + not an ASPSP error → fail fast, no retries. + expect(fetchSpy).toHaveBeenCalledTimes(1) + }) + + it('throws once every narrower window is exhausted', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + // Fresh Response per call — a body can only be read once. + fetchSpy.mockImplementation(() => Promise.resolve(new Response(ASPSP_ERROR_BODY, { status: 400 }))) + + await expect( + getAllTransactionsWithRaw('acc-1', '2026-02-07', '2026-06-07') + ).rejects.toThrow('Failed to get transactions (400)') + + // full window + 90 + 60 + 30 = 4 attempts, then give up + expect(fetchSpy).toHaveBeenCalledTimes(4) + + warnSpy.mockRestore() + errorSpy.mockRestore() + }) + }) + + // ------------------------------------------------------------------------- + // getAllTransactions — same first-page fallbacks via the paginated path + // ------------------------------------------------------------------------- + describe('getAllTransactions fallbacks', () => { + const ASPSP_ERROR_BODY = + '{"code":400,"message":"Error interacting with ASPSP","detail":"Unknown error","error":"ASPSP_ERROR"}' + + it('narrows the window when the ASPSP rejects the history range', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + fetchSpy + .mockResolvedValueOnce(new Response(ASPSP_ERROR_BODY, { status: 400 })) // full window + .mockResolvedValueOnce( + new Response( + JSON.stringify({ transactions: [{ transaction_amount: { amount: '10', currency: 'SEK' } }] }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) // narrowed to 90 days → success + + const result = await getAllTransactions('acc-1', '2026-02-07', '2026-06-07') + + expect(result).toHaveLength(1) + expect(fetchSpy).toHaveBeenCalledTimes(2) + const urls = fetchSpy.mock.calls.map((c: unknown[]) => c[0] as string) + expect(urls[0]).toContain('date_from=2026-02-07') + expect(urls[1]).toContain('date_from=2026-03-09') // 90 days before date_to + + warnSpy.mockRestore() + }) + + it('drops the strategy then narrows the window (Danske flow)', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + fetchSpy + .mockResolvedValueOnce(new Response(ASPSP_ERROR_BODY, { status: 400 })) // strategy=longest + .mockResolvedValueOnce(new Response(ASPSP_ERROR_BODY, { status: 400 })) // no strategy, full window + .mockResolvedValueOnce( + new Response(JSON.stringify({ transactions: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) // narrowed to 90 days → success + + await getAllTransactions('acc-1', '2026-02-07', '2026-06-07', 'longest') + + expect(fetchSpy).toHaveBeenCalledTimes(3) + const urls = fetchSpy.mock.calls.map((c: unknown[]) => c[0] as string) + expect(urls[0]).toContain('strategy=longest') + expect(urls[1]).not.toContain('strategy=') + expect(urls[1]).toContain('date_from=2026-02-07') + expect(urls[2]).toContain('date_from=2026-03-09') + + warnSpy.mockRestore() + }) + + it('does not rewrite the query mid-pagination', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + fetchSpy + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + transactions: [{ transaction_amount: { amount: '5', currency: 'SEK' } }], + continuation_key: 'page2', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) // page 1 ok, hands back a continuation_key + .mockResolvedValueOnce(new Response(ASPSP_ERROR_BODY, { status: 400 })) // page 2 fails + + // A continuation_key is scoped to its window, so page 2 must not narrow — + // it fails fast instead. + await expect( + getAllTransactions('acc-1', '2026-02-07', '2026-06-07') + ).rejects.toThrow('Failed to get transactions (400)') + expect(fetchSpy).toHaveBeenCalledTimes(2) + + errorSpy.mockRestore() + }) }) }) diff --git a/extensions/general/enable-banking/lib/api-client.ts b/extensions/general/enable-banking/lib/api-client.ts index 963d4c1e..51da34d4 100644 --- a/extensions/general/enable-banking/lib/api-client.ts +++ b/extensions/general/enable-banking/lib/api-client.ts @@ -165,6 +165,22 @@ const RETRY_DELAY_MS = 1000 const MAX_PAGINATION_PAGES = 100 const DEFAULT_PAGE_SIZE = 500 +/** + * Thrown by getAccountTransactions on a non-OK response. Carries the HTTP + * status and raw body so the pagination caller (getAllTransactions) can run the + * same first-page strategy/window fallbacks as getAllTransactionsWithRaw. The + * message is identical to the previous plain Error for back-compat. + */ +class TransactionsFetchError extends Error { + constructor( + readonly status: number, + readonly body: string + ) { + super(`Failed to get transactions (${status}): ${body}`) + this.name = 'TransactionsFetchError' + } +} + // API Helper async function authenticatedFetch( @@ -505,7 +521,7 @@ export async function getAccountTransactions( strategy, hasContinuationKey: !!continuationKey, }) - throw new Error(`Failed to get transactions (${response.status}): ${body}`) + throw new TransactionsFetchError(response.status, body) } return response.json() @@ -523,15 +539,57 @@ export async function getAllTransactions( const allTransactions: Transaction[] = [] let continuationKey: string | undefined let page = 0 + let activeStrategy = strategy + // date_from is narrowed in place when the ASPSP rejects the window (below). + let activeDateFrom = dateFrom - do { - const response = await getAccountTransactions( - accountUid, - dateFrom, - dateTo, - continuationKey, - strategy - ) + while (true) { + let response: TransactionsResponse + try { + response = await getAccountTransactions( + accountUid, + activeDateFrom, + dateTo, + continuationKey, + activeStrategy + ) + } catch (err) { + // Apply the same first-page recovery as getAllTransactionsWithRaw. Only + // TransactionsFetchError carries the status/body needed to decide; + // network errors and the like propagate untouched. + if (err instanceof TransactionsFetchError) { + const recovery = planFirstPageRecovery({ + status: err.status, + body: err.body, + page, + hasContinuationKey: !!continuationKey, + activeStrategy, + activeDateFrom, + dateTo, + }) + if (recovery.type === 'drop-strategy') { + console.warn('[enable-banking] strategy rejected by API, retrying without strategy', { + accountUid, + strategy: activeStrategy, + body: err.body, + }) + activeStrategy = undefined + continue + } + if (recovery.type === 'narrow') { + console.warn('[enable-banking] ASPSP rejected history window, retrying with narrower date_from', { + accountUid, + previousDateFrom: activeDateFrom, + nextDateFrom: recovery.dateFrom, + dateTo, + body: err.body, + }) + activeDateFrom = recovery.dateFrom + continue + } + } + throw err + } allTransactions.push(...response.transactions) continuationKey = response.continuation_key @@ -541,11 +599,95 @@ export async function getAllTransactions( console.warn(`[enable-banking] Pagination cap reached (${MAX_PAGINATION_PAGES} pages) for account ${accountUid}`) break } - } while (continuationKey) + if (!continuationKey) break + } return allTransactions } +/** + * Lookback windows (days before date_to) we step through when an ASPSP rejects + * the requested transaction history. Descending so each fallback yields a + * strictly narrower window. PSD2 obliges banks to ~90 days without fresh SCA; + * the smaller rungs cover banks that cap below that. + */ +const ASPSP_HISTORY_FALLBACK_DAYS = [90, 60, 30] as const + +/** + * Enable Banking wraps upstream-bank failures in a generic envelope, e.g. + * {"code":400,"message":"Error interacting with ASPSP","error":"ASPSP_ERROR"}. + * A too-wide history window is the most common trigger — see the date-narrowing + * fallback in getAllTransactionsWithRaw. + */ +function isAspspError(body: string): boolean { + return body.includes('ASPSP_ERROR') || body.includes('interacting with ASPSP') +} + +/** + * Given the date_from we just tried, return the next strictly-narrower + * date_from from ASPSP_HISTORY_FALLBACK_DAYS, anchored to date_to. Returns + * undefined when no narrower window remains (or date_to is missing), which + * ends the retry loop. The "strictly narrower" guard keeps the loop monotonic + * and terminating even if the original window was already short. + */ +function nextNarrowerDateFrom( + currentDateFrom: string | undefined, + dateTo: string | undefined +): string | undefined { + if (!dateTo) return undefined + const anchor = new Date(`${dateTo}T00:00:00Z`) + if (!Number.isFinite(anchor.getTime())) return undefined + + for (const days of ASPSP_HISTORY_FALLBACK_DAYS) { + const candidate = new Date(anchor.getTime() - days * 24 * 60 * 60 * 1000) + .toISOString() + .split('T')[0] + if (!currentDateFrom || candidate > currentDateFrom) { + return candidate + } + } + return undefined +} + +/** + * First-page recovery policy shared by getAllTransactions and + * getAllTransactionsWithRaw, so the two pagination loops can't drift. Fallbacks + * apply only to the very first request (page 0, no continuation_key) — a + * continuation_key is scoped to the window/strategy that produced it, so the + * query is never rewritten mid-pagination. + * + * - 'drop-strategy' — an unsupported strategy enum: retry the same window. + * - 'narrow' — the ASPSP rejected the history window: retry with a + * narrower date_from (the bank caps history below the ask). + * - 'give-up' — nothing left to try; the caller should rethrow. + */ +type FirstPageRecovery = + | { type: 'drop-strategy' } + | { type: 'narrow'; dateFrom: string } + | { type: 'give-up' } + +function planFirstPageRecovery(args: { + status: number + body: string + page: number + hasContinuationKey: boolean + activeStrategy: TransactionsFetchStrategy | undefined + activeDateFrom: string | undefined + dateTo: string | undefined +}): FirstPageRecovery { + const { status, body, page, hasContinuationKey, activeStrategy, activeDateFrom, dateTo } = args + if (status !== 400 || page !== 0 || hasContinuationKey) return { type: 'give-up' } + // Drop an unsupported strategy first — preserves the full requested window. + if (activeStrategy) return { type: 'drop-strategy' } + // Then handle the ASPSP rejecting the window itself (e.g. Danske past ~90 + // days): step date_from toward date_to so a partial sync survives. + if (isAspspError(body)) { + const dateFrom = nextNarrowerDateFrom(activeDateFrom, dateTo) + if (dateFrom) return { type: 'narrow', dateFrom } + } + return { type: 'give-up' } +} + /** * Get all transactions with raw JSON responses for archival. * Returns both parsed transactions and the raw response strings. @@ -553,6 +695,12 @@ export async function getAllTransactions( * If `strategy` is provided and the API rejects it with a 400 on the first * request, retry once without `strategy` so unknown enum values can't break * the sync. Logs a warning when the fallback fires. + * + * If the ASPSP then still rejects the first page with an ASPSP_ERROR (typically + * a history window beyond the bank's PSD2 limit, e.g. Danske past ~90 days), + * progressively narrow date_from toward date_to (90→60→30 days) so a partial + * sync of the recent window survives instead of failing outright. Logs a + * warning on each narrowing. */ export async function getAllTransactionsWithRaw( accountUid: string, @@ -565,10 +713,12 @@ export async function getAllTransactionsWithRaw( let continuationKey: string | undefined let page = 0 let activeStrategy = strategy + // date_from is narrowed in place when the ASPSP rejects the window (below). + let activeDateFrom = dateFrom while (true) { const params = new URLSearchParams() - if (dateFrom) params.set('date_from', dateFrom) + if (activeDateFrom) params.set('date_from', activeDateFrom) if (dateTo) params.set('date_to', dateTo) if (continuationKey) params.set('continuation_key', continuationKey) if (activeStrategy) params.set('strategy', activeStrategy) @@ -581,9 +731,16 @@ export async function getAllTransactionsWithRaw( if (!response.ok) { const body = await response.text() - // If the API rejects an unknown strategy on the very first request, - // fall back to the implicit default and retry the same page. - if (response.status === 400 && activeStrategy && page === 0 && !continuationKey) { + const recovery = planFirstPageRecovery({ + status: response.status, + body, + page, + hasContinuationKey: !!continuationKey, + activeStrategy, + activeDateFrom, + dateTo, + }) + if (recovery.type === 'drop-strategy') { console.warn('[enable-banking] strategy rejected by API, retrying without strategy', { accountUid, strategy: activeStrategy, @@ -592,12 +749,23 @@ export async function getAllTransactionsWithRaw( activeStrategy = undefined continue } + if (recovery.type === 'narrow') { + console.warn('[enable-banking] ASPSP rejected history window, retrying with narrower date_from', { + accountUid, + previousDateFrom: activeDateFrom, + nextDateFrom: recovery.dateFrom, + dateTo, + body, + }) + activeDateFrom = recovery.dateFrom + continue + } console.error('[enable-banking] getAllTransactionsWithRaw failed', { status: response.status, statusText: response.statusText, body, accountUid, - dateFrom, + dateFrom: activeDateFrom, dateTo, strategy: activeStrategy, page, diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 512bd362..68b72965 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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(), diff --git a/lib/pending-operations/__tests__/create-supplier-invoice-from-inbox.test.ts b/lib/pending-operations/__tests__/create-supplier-invoice-from-inbox.test.ts index 5d321ea8..ad4b445f 100644 --- a/lib/pending-operations/__tests__/create-supplier-invoice-from-inbox.test.ts +++ b/lib/pending-operations/__tests__/create-supplier-invoice-from-inbox.test.ts @@ -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 | 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) => { + 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 }) diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index 2b2076c0..28475814 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -1685,6 +1685,7 @@ async function commitCreateSupplierInvoiceFromInbox( total_sek: totalSek, paid_amount: 0, remaining_amount: totalRounded, + document_id: documentId, notes, }) .select() diff --git a/lib/salary/__tests__/agi-xml.test.ts b/lib/salary/__tests__/agi-xml.test.ts index 049c085d..865fc625 100644 --- a/lib/salary/__tests__/agi-xml.test.ts +++ b/lib/salary/__tests__/agi-xml.test.ts @@ -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('') + expect(xml).toContain('202604') + expect(xml).toContain('165561234567') + // No individuppgifter and no absence section + expect(xml).not.toContain('') + 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) + }) +}) diff --git a/lib/salary/agi/generate-declaration.ts b/lib/salary/agi/generate-declaration.ts index 2fc20551..5f407797 100644 --- a/lib/salary/agi/generate-declaration.ts +++ b/lib/salary/agi/generate-declaration.ts @@ -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 }> diff --git a/lib/salary/run-calculation.ts b/lib/salary/run-calculation.ts index 8cdfc220..47f2a018 100644 --- a/lib/salary/run-calculation.ts +++ b/lib/salary/run-calculation.ts @@ -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