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