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
+86 -5
View File
@@ -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 (
<div className="space-y-8">
{/* Header */}
@@ -346,6 +394,19 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
))}
</div>
{isNollkorning && (
<Card>
<CardContent className="p-4">
<p className="text-sm font-medium">Nollkörning</p>
<p className="text-sm text-muted-foreground mt-1">
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.
</p>
</CardContent>
</Card>
)}
{/* Employees */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
@@ -397,7 +458,7 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
<TableHeader>
<TableRow>
<TableHead>Anställd</TableHead>
<TableHead className="hidden md:table-cell text-right">Brutto</TableHead>
<TableHead className="hidden md:table-cell text-right">{run.status === 'draft' ? 'Månadslön' : 'Brutto'}</TableHead>
<TableHead className="hidden lg:table-cell text-right">Skatt</TableHead>
<TableHead className="text-right">Netto</TableHead>
<TableHead className="hidden lg:table-cell text-right">Avgifter</TableHead>
@@ -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 (
<TableRow
key={sre.id}
@@ -428,10 +491,28 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
{name}
</Link>
<span className="md:hidden block text-xs text-muted-foreground font-normal mt-0.5 tabular-nums">
Brutto {formatCurrency(sre.gross_salary)}
{run.status === 'draft'
? `Månadslön ${formatCurrency(sre.monthly_salary)}`
: `Brutto ${formatCurrency(sre.gross_salary)}`}
</span>
</TableCell>
<TableCell className="hidden md:table-cell text-right tabular-nums">{formatCurrency(sre.gross_salary)}</TableCell>
<TableCell className="hidden md:table-cell text-right tabular-nums">
{editableSalary ? (
<Input
type="number"
step="0.01"
min="0"
defaultValue={sre.monthly_salary}
onClick={(e) => 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)
)}
</TableCell>
<TableCell className="hidden lg:table-cell text-right tabular-nums">{formatCurrency(taxValue)}</TableCell>
<TableCell className="text-right tabular-nums font-medium">{formatCurrency(sre.net_salary + (sre.tax_withheld - taxValue))}</TableCell>
<TableCell className="hidden lg:table-cell text-right tabular-nums">{formatCurrency(avgifterValue)}</TableCell>
@@ -597,7 +678,7 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
{actionLoading === 'delete' ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Trash2 className="mr-2 h-4 w-4" />}
Radera utkast
</Button>
<Button variant="outline" onClick={handleCalculate} disabled={!!actionLoading || employees.length === 0}>
<Button variant="outline" onClick={handleCalculate} disabled={!!actionLoading}>
{actionLoading === 'calculate' ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Calculator className="mr-2 h-4 w-4" />}
Beräkna
</Button>
@@ -605,7 +686,7 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
{actionLoading === 'preview' ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Eye className="mr-2 h-4 w-4" />}
Förhandsgranska
</Button>
<Button onClick={() => handleAction('review')} disabled={!!actionLoading || run.total_gross === 0}>
<Button onClick={handleToReview} disabled={!!actionLoading || !isCalculated}>
Till granskning
</Button>
</>
@@ -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()
})
})
+42 -3
View File
@@ -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,
@@ -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)
})
})
@@ -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).' },
@@ -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<typeof import('@/lib/core/documents/document-service')>(
'@/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({
@@ -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',
+5 -5
View File
@@ -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<string | null>(null)
useEffect(() => {
const momsPeriod = settings?.moms_period
+24
View File
@@ -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 (
<div className="flex flex-col items-center justify-center gap-4 py-12 text-center">
<AlertCircle className="h-8 w-8 text-muted-foreground" aria-hidden />
<p className="text-sm text-muted-foreground">{t('load_error')}</p>
<Button variant="outline" size="sm" onClick={onRetry}>
{t('retry')}
</Button>
</div>
)
}
+18 -17
View File
@@ -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 (
<div className="space-y-8 animate-in fade-in duration-300">
{[1, 2].map(i => (
<div key={i} className="space-y-4">
<Skeleton className="h-3.5 w-24" />
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Skeleton className="h-3.5 w-20" />
<Skeleton className="h-10" />
<div className="space-y-8 animate-in fade-in duration-300" aria-busy="true">
{[0, 1].map((block) => (
<div
key={block}
className={block === 0 ? 'space-y-4' : 'space-y-4 border-t border-border pt-8'}
>
<Skeleton className="h-3.5 w-32" />
{[0, 1, 2].map((row) => (
<div key={row} className="space-y-2">
<Skeleton className="h-3.5 w-24" />
<Skeleton className="h-10 w-full max-w-md" />
</div>
<div className="space-y-2">
<Skeleton className="h-3.5 w-28" />
<Skeleton className="h-10" />
</div>
</div>
<div className="space-y-2">
<Skeleton className="h-3.5 w-16" />
<Skeleton className="h-10" />
</div>
))}
</div>
))}
</div>
+29 -22
View File
@@ -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 (
<div className="flex min-h-0 flex-1">
<aside className="hidden w-56 shrink-0 overflow-y-auto border-r border-border p-3 md:block">
<SettingsRail variant="modal" display="rail" activeId={activeSection} />
</aside>
<div className="min-h-0 flex-1 overflow-y-auto p-6">
<div className="mb-6 md:hidden">
<SettingsRail variant="modal" display="select" activeId={activeSection} />
// 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.
<SettingsProvider>
<div className="flex min-h-0 flex-1">
<aside className="hidden w-56 shrink-0 overflow-y-auto border-r border-border p-3 md:block">
<SettingsRail variant="modal" display="rail" activeId={activeSection} />
</aside>
<div className="min-h-0 flex-1 overflow-y-auto p-6">
<div className="mb-6 md:hidden">
<SettingsRail variant="modal" display="select" activeId={activeSection} />
</div>
<Suspense fallback={<SettingsLoadingSkeleton />}>
{Section ? <Section /> : null}
</Suspense>
</div>
<Suspense fallback={<SettingsLoadingSkeleton />}>
{Section ? <Section /> : null}
</Suspense>
</div>
</div>
</SettingsProvider>
)
}
return (
<div className="grid gap-8 md:grid-cols-[220px_1fr]">
<aside className="md:sticky md:top-8 md:self-start">
<div className="mb-4 md:hidden">
<SettingsRail variant="page" display="select" />
</div>
<div className="hidden md:block">
<SettingsRail variant="page" display="rail" />
</div>
</aside>
<div className="min-w-0">{children}</div>
</div>
<SettingsProvider>
<div className="grid gap-8 md:grid-cols-[220px_1fr]">
<aside className="md:sticky md:top-8 md:self-start">
<div className="mb-4 md:hidden">
<SettingsRail variant="page" display="select" />
</div>
<div className="hidden md:block">
<SettingsRail variant="page" display="rail" />
</div>
</aside>
<div className="min-w-0">{children}</div>
</div>
</SettingsProvider>
)
}
@@ -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 <SettingsLoadingSkeleton />
if (isLoading) return <SettingsLoadingSkeleton />
if (!settings) return <SettingsLoadError onRetry={refetch} />
function handleSave(formData: FormData) {
const autoLockValue = formData.get('auto_lock_period_days') as string
@@ -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 <SettingsLoadingSkeleton />
if (isLoading) return <SettingsLoadingSkeleton />
if (!settings) return <SettingsLoadError onRetry={refetch} />
function handleSave(formData: FormData) {
const updates: Record<string, unknown> = {
@@ -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 <SettingsLoadingSkeleton />
if (isLoading) return <SettingsLoadingSkeleton />
if (!settings) return <SettingsLoadError onRetry={refetch} />
function handleSave(formData: FormData) {
const bankErrors = validateBankFields(formData)
@@ -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 <SettingsLoadingSkeleton />
if (isLoading) return <SettingsLoadingSkeleton />
if (!settings) return <SettingsLoadError onRetry={refetch} />
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 (
<div className="space-y-8">
+84 -18
View File
@@ -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<CompanySettings>) => void
refetch: () => Promise<void>
}
/**
* 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<CompanySettings | null>(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<CompanySettings>) => {
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<SettingsState | null>(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
}
@@ -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()
})
})
})
@@ -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,
+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