feat(supplier-invoices): dokument-forst editor rebuild (prototype shell + 4 flow optimizations) (#1653)
* refactor(supplier-invoices): extract payload builder and form hooks, pin wire contract with parity tests Zero visual/behavioral change. Pulls the pure payload builder (buildSupplierInvoicePayload + inferVatTreatment + vatRateFromAi) out of NewSupplierInvoiceForm into lib/supplier-invoices/form-payload.ts and pins it with a mode/feature-matrix parity test suite (document_id vs inbox, privately paid due-date default, reverse charge rate forcing, accrual attach/drop, dimensions bags, apply_slp validity, FX parsing, empty-string stripping, ore_rounding passthrough). Also extracts, verbatim: the VatRateCell/RcRateSelect cells, the reference data loading hook (suppliers/accounts/settings/periods), the inbox AI prefill hook (exposing applyInboxItem for reuse), and the submit orchestration hook (endpoint chooser, three submit paths, duplicate-number conflict recovery, inbox field sync-back). Deliberately NOT moved: the effect-ordering couplings (pendingAccountFillRef/accountFillTick supplier-defaults dance, the icke-momsregistrerad gross-up re-run keyed on hasPrefilled, the RC accrual-clearing effect, per-currency FX touched flags) stay in the component untouched; their ordering semantics are load-bearing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(supplier-invoices): dokument-forst editor rebuild with prototype shell and four flow optimizations Rebuilds NewSupplierInvoiceForm to the approved Leverantorsflodet prototype: single 640px column, flat sections (Underlag first, then Leverantor, Fakturauppgifter, Kontering, Forval, Summering), honest state marks (RequiredMark, sage checks for binary facts, muted row counts), a single ochre next-step line (aria-live polite) whose link focuses the missing field, and a sticky bottom action bar with the live total that binds to the dialog scroll container in bare mode and the page panel scroll standalone. Dokument-forst (1): the standalone upload now tries the invoice-inbox pipeline over HTTP first (POST upload, poll items/:id past 'processing'), then runs the same applyInboxItem prefill path as an inbox arrival (settle tint on filled fields, reset(getValues()) dirty baseline, submit through the convert endpoint so the document links and the item is stamped). Extension off or extraction failed degrades to the plain /api/documents attachment; manual entry is never blocked. Total cross-check (2): optional "Totalt enligt fakturan" field in Summering, client-only compare against the displayed payable (sage match line, terracotta diff line), prefilled from extraction totals. Duplicate advisory (3): new index-only GET /api/supplier-invoices/exists (withRouteContext + validateQuery, mirrors the partial unique index's credited/reversed exclusion, full route tests), debounce-called on fakturanummer change; terracotta field-adjacent line with a link to the existing invoice. The structured 409 conflict dialog stays the backstop. Terms-based due date (4): muted caption "Fran leverantorens villkor (N dagar)" when auto-set, re-derives on invoice-date and supplier change, stops the moment the user or the AI supplies a date; terms 0 leaves the field empty with "Star pa fakturan". OCR hint (5): "Anvands i betalningsfilen." under the payment reference when the chosen supplier has bankgiro or plusgiro. Table model: rows start empty; the ghost tfoot entry row (never part of form state) commits an account via the existing AccountCombobox (opens on focus, Enter commits) and moves focus to the new row's amount cell; the supplier default/history fill plants the first row when the table is empty. Row controls are hover-revealed via HOVER_REVEAL_CLASS at a 24px hit area with per-row aria-labels carrying the description. The primary button is never disabled pre-click for writable users (in-flight only); every submit-time hard block stays in onSubmit; viewers keep the lock treatment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(supplier-invoices): re-run gross-up per apply, guard deferred prefill, honest un-plant - Gross-up/zero-rate pass for icke momsregistrerade re-runs per applied extraction (applyCount bumps in applyInboxItem) instead of keying on the one-shot hasPrefilled flag: a remove + re-upload could previously push AI 25 % rates to the convert endpoint with the moms columns hidden. - Deferred extraction on the standalone upload path no longer overwrites what the user typed mid-poll: the result auto-applies only while the form is pristine (live isDirty ref), otherwise it is buffered behind a quiet "Tolkning klar" click-to-apply line. Inbox arrivals are unchanged. - Supplier-switch un-plant keeps rows the user edited in ANY field, not just amount (plant-time snapshot compare in lib/supplier-invoices/planted-rows.ts, since dirtyFields is unreliable for appended array rows), clearing only the stale account; untouched plant-created rows are still removed and rows that existed before the fill are never removed. - default_expense_account plants now register in plantedRef too, so a supplier switch un-plants them under the same rules as history plants. - applyInboxItem reads suppliers through a ref: the 90 s poll no longer resolves matched suppliers against a stale empty list. - The duplicate advisory bumps its seq in the clear branch, so an in-flight exists response cannot resurrect a warning under a cleared field. - Drop 7 orphaned supplier_invoice_editor keys from both message files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(supplier-invoices): retry the entry-row focus hand-off on the next frame A single requestAnimationFrame after appending the row can fire before the new amount input's ref is mounted, silently dropping the focus hand-off (observed in headless verification). One retry frame makes the signature interaction reliable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(supplier-invoices): deterministic entry-row focus hand-off via effect The rAF retry still lost to the dialog focus scope re-parking focus when the entry input remounts mid-commit. An effect keyed on the pending row index runs after the new row's input has mounted and wins deterministically. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(supplier-invoices): comma-tolerant amount cell and surviving focus routing The focus trace exposed two real issues behind a probe mystery: the amount cell was type=number (ArrowDown decrements money by 0.01, Enter fires the form's implicit submit mid-edit, and Swedish comma decimals are rejected outright), and the supplier menu's close-autofocus yanked focus back to the trigger, undoing the routed hand-off to the invoice-number field. AmountCell mirrors VatRateCell's draft pattern: text input with decimal inputMode, digits-and-one-separator whitelist, Enter commits via blur. The supplier DropdownMenuContent prevents default close autofocus. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(supplier-invoices): show comma decimals in the amount cell display Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(supplier-invoices): stop dialog grid item overflowing small viewports min-w-0 on the form root (DialogContent is display:grid, so the kontering table's min-w otherwise forces the column past narrow screens) and wrap the sticky-bar action cluster. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
dc311726de
commit
93e99012d7
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createQueuedMockSupabase,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset, findCall } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
import { GET } from '../route'
|
||||
|
||||
const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000'
|
||||
|
||||
describe('GET /api/supplier-invoices/exists', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices/exists', {
|
||||
searchParams: { supplier_id: VALID_UUID, number: 'F-2026-881' },
|
||||
})
|
||||
const response = await GET(request, { params: Promise.resolve({}) })
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('returns 400 when supplier_id is not a uuid', async () => {
|
||||
const request = createMockRequest('/api/supplier-invoices/exists', {
|
||||
searchParams: { supplier_id: 'not-a-uuid', number: 'F-2026-881' },
|
||||
})
|
||||
const response = await GET(request, { params: Promise.resolve({}) })
|
||||
const { status, body } = await parseJsonResponse<{ type: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.type).toBe('validation_error')
|
||||
})
|
||||
|
||||
it('returns 400 when number is missing', async () => {
|
||||
const request = createMockRequest('/api/supplier-invoices/exists', {
|
||||
searchParams: { supplier_id: VALID_UUID },
|
||||
})
|
||||
const response = await GET(request, { params: Promise.resolve({}) })
|
||||
const { status, body } = await parseJsonResponse<{ type: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.type).toBe('validation_error')
|
||||
})
|
||||
|
||||
it('returns exists: false when no matching invoice', async () => {
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices/exists', {
|
||||
searchParams: { supplier_id: VALID_UUID, number: 'F-2026-881' },
|
||||
})
|
||||
const response = await GET(request, { params: Promise.resolve({}) })
|
||||
const { status, body } = await parseJsonResponse<{ data: { exists: boolean } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual({ exists: false })
|
||||
})
|
||||
|
||||
it('returns exists: true with the existing invoice details', async () => {
|
||||
const existing = { id: 'si-1', supplier_invoice_number: 'F-2026-881', status: 'approved' }
|
||||
enqueue({ data: existing, error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices/exists', {
|
||||
searchParams: { supplier_id: VALID_UUID, number: 'F-2026-881' },
|
||||
})
|
||||
const response = await GET(request, { params: Promise.resolve({}) })
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { exists: boolean; existing: typeof existing }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual({ exists: true, existing })
|
||||
})
|
||||
|
||||
it('mirrors the partial unique index predicate: excludes credited/reversed', async () => {
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices/exists', {
|
||||
searchParams: { supplier_id: VALID_UUID, number: 'F-2026-881' },
|
||||
})
|
||||
await GET(request, { params: Promise.resolve({}) })
|
||||
|
||||
expect(findCall('supplier_invoices', 'not')).toEqual([
|
||||
'status',
|
||||
'in',
|
||||
'(credited,reversed)',
|
||||
])
|
||||
expect(findCall('supplier_invoices', 'eq')).toEqual(['company_id', 'company-1'])
|
||||
})
|
||||
|
||||
it('returns 500 on database error', async () => {
|
||||
enqueue({ data: null, error: { message: 'DB error' } })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices/exists', {
|
||||
searchParams: { supplier_id: VALID_UUID, number: 'F-2026-881' },
|
||||
})
|
||||
const response = await GET(request, { params: Promise.resolve({}) })
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateQuery } from '@/lib/api/validate'
|
||||
import { SupplierInvoiceExistsQuerySchema } from '@/lib/api/schemas'
|
||||
import { errorResponse } from '@/lib/errors/get-structured-error'
|
||||
|
||||
/**
|
||||
* GET /api/supplier-invoices/exists?supplier_id=<uuid>&number=<string>
|
||||
*
|
||||
* Index-only pre-submit duplicate lookup for the supplier-invoice editor's
|
||||
* advisory warning. Mirrors the partial unique index
|
||||
* idx_supplier_invoices_company_supplier_number:
|
||||
* (company_id, supplier_id, supplier_invoice_number)
|
||||
* WHERE supplier_invoice_number IS NOT NULL AND status NOT IN
|
||||
* ('credited','reversed'). The create route's structured 409 remains the
|
||||
* authoritative backstop; this endpoint only powers the early warning.
|
||||
*/
|
||||
export const GET = withRouteContext(
|
||||
'supplier_invoice.exists',
|
||||
async (request, ctx) => {
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
|
||||
const validation = validateQuery(request, SupplierInvoiceExistsQuerySchema, {
|
||||
log,
|
||||
operation: 'supplier_invoice.exists',
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
const { supplier_id, number } = validation.data
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('id, supplier_invoice_number, status')
|
||||
.eq('company_id', companyId)
|
||||
.eq('supplier_id', supplier_id)
|
||||
.eq('supplier_invoice_number', number)
|
||||
.not('status', 'in', '(credited,reversed)')
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) {
|
||||
log.error('supplier_invoice exists lookup failed', error)
|
||||
return errorResponse(error, log, { requestId })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: data ? { exists: true, existing: data } : { exists: false },
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -825,3 +825,16 @@ input[type="number"] {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
/* Brief sage settle tint on tolkning-filled fields (supplier-invoice
|
||||
dokument-först flow): the field flashes softly as the extraction lands and
|
||||
fades back to its normal surface. Runs once per class application; the
|
||||
global prefers-reduced-motion block collapses the animation, so reduced
|
||||
motion sees the filled value with no flash. */
|
||||
@keyframes prefill-settle {
|
||||
0% { background-color: hsl(var(--success) / 0.16); }
|
||||
100% { background-color: transparent; }
|
||||
}
|
||||
.prefill-settle {
|
||||
animation: prefill-settle 0.9s ease-out both;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function NewSupplierInvoiceDialog({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="sm:max-w-4xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto"
|
||||
className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto"
|
||||
// A half-typed invoice must survive an accidental backdrop click or a
|
||||
// stray Escape (nested comboboxes and date pickers portal outside the
|
||||
// dialog). Closing is explicit: the header X or Avbryt. Same
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,178 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { LEGAL_VAT_RATES } from '@/lib/vat/supplier-invoice-line-checks'
|
||||
import { rateToPctString } from '@/lib/supplier-invoices/form-payload'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
|
||||
export function VatRateCell({ value, onChange }: { value: number; onChange: (v: number) => void }) {
|
||||
const t = useTranslations('supplier_invoice_editor')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
// Local draft so the user can type "12," or "12." mid-keystroke without the
|
||||
// controlled input snapping back to a parsed integer.
|
||||
const [draft, setDraft] = useState(() => rateToPctString(value))
|
||||
|
||||
// Re-sync from form value only when the field isn't focused: keeps AI
|
||||
// prefill / supplier defaults / dropdown picks flowing in without clobbering
|
||||
// active typing.
|
||||
useEffect(() => {
|
||||
if (document.activeElement !== inputRef.current) {
|
||||
setDraft(rateToPctString(value))
|
||||
}
|
||||
}, [value])
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={draft}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
onBlur={() => setDraft(rateToPctString(value))}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value
|
||||
// Strict whitelist: digits with at most one decimal separator.
|
||||
// Blocks "2-22", "100-2", "1.2.3", letters, signs: the keystroke
|
||||
// is dropped before reaching the draft.
|
||||
if (raw !== '' && !/^\d*[.,]?\d*$/.test(raw)) return
|
||||
const normalized = raw.replace(',', '.')
|
||||
if (normalized === '' || normalized === '.') {
|
||||
setDraft(raw)
|
||||
onChange(0)
|
||||
return
|
||||
}
|
||||
const parsed = parseFloat(normalized)
|
||||
if (!Number.isFinite(parsed)) {
|
||||
setDraft(raw)
|
||||
return
|
||||
}
|
||||
const clamped = Math.min(100, Math.max(0, parsed))
|
||||
// Snap the draft back when the parsed value falls outside [0, 100]
|
||||
// so the input can never display a rate the form won't apply.
|
||||
setDraft(clamped === parsed ? raw : String(clamped))
|
||||
onChange(clamped / 100)
|
||||
}}
|
||||
className="h-8 px-2 pr-6 text-right text-[13px] tabular-nums"
|
||||
aria-label={t('col_vat_rate')}
|
||||
/>
|
||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground pointer-events-none">
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
aria-label={t('vat_rate_presets_aria')}
|
||||
>
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[6rem]">
|
||||
{LEGAL_VAT_RATES.map((preset) => (
|
||||
<DropdownMenuItem
|
||||
key={preset}
|
||||
onSelect={() => onChange(preset)}
|
||||
className="justify-end tabular-nums"
|
||||
>
|
||||
{Math.round(preset * 100)} %
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Self-assessment rate picker shown in place of the Momssats cell when an
|
||||
// invoice is reverse charge. The supplier charges no VAT (the line vat_rate is
|
||||
// 0); this is the Swedish statutory rate the buyer self-assesses at: 25%
|
||||
// huvudregeln for EU services, 12%/6% for reduced-rated services (ML 6 kap 34 §).
|
||||
/**
|
||||
* Money amount cell: text input with decimal inputMode so Swedish comma
|
||||
* decimals work (type=number rejects them and adds spinner arrows that
|
||||
* decrement money on ArrowDown). Enter commits via blur instead of
|
||||
* triggering the form's implicit submit.
|
||||
*/
|
||||
export function AmountCell({
|
||||
value,
|
||||
onChange,
|
||||
inputRef,
|
||||
className,
|
||||
}: {
|
||||
value: number
|
||||
onChange: (v: number) => void
|
||||
inputRef?: (el: HTMLInputElement | null) => void
|
||||
className?: string
|
||||
}) {
|
||||
const innerRef = useRef<HTMLInputElement | null>(null)
|
||||
// Display Swedish comma decimals; parsing accepts both comma and dot.
|
||||
const toDisplay = (v: number) => (v ? String(v).replace('.', ',') : '')
|
||||
const [draft, setDraft] = useState(() => toDisplay(value))
|
||||
|
||||
useEffect(() => {
|
||||
if (document.activeElement !== innerRef.current) {
|
||||
setDraft(toDisplay(value))
|
||||
}
|
||||
}, [value])
|
||||
|
||||
return (
|
||||
<Input
|
||||
ref={(el) => {
|
||||
innerRef.current = el
|
||||
inputRef?.(el)
|
||||
}}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
placeholder="0,00"
|
||||
className={className}
|
||||
value={draft}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
onBlur={() => setDraft(toDisplay(value))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value
|
||||
if (raw !== '' && !/^-?\d*[.,]?\d*$/.test(raw)) return
|
||||
setDraft(raw)
|
||||
const normalized = raw.replace(',', '.')
|
||||
if (normalized === '' || normalized === '.' || normalized === '-') {
|
||||
onChange(0)
|
||||
return
|
||||
}
|
||||
const parsed = parseFloat(normalized)
|
||||
onChange(Number.isFinite(parsed) ? parsed : 0)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function RcRateSelect({ value, onChange }: { value: number; onChange: (v: number) => void }) {
|
||||
const t = useTranslations('supplier_invoice_editor')
|
||||
return (
|
||||
<Select value={String(value ?? 0.25)} onValueChange={(v) => onChange(parseFloat(v))}>
|
||||
<SelectTrigger className="h-8 tabular-nums" aria-label={t('col_rc_vat_rate')}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0.25" className="tabular-nums">25 %</SelectItem>
|
||||
<SelectItem value="0.12" className="tabular-nums">12 %</SelectItem>
|
||||
<SelectItem value="0.06" className="tabular-nums">6 %</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import type { UseFormGetValues, UseFormReset, UseFormSetValue } from 'react-hook-form'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { suggestBalanceAccount } from '@/lib/bookkeeping/accruals/account-suggestions'
|
||||
import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute'
|
||||
import {
|
||||
vatRateFromAi,
|
||||
type SupplierInvoiceFormData,
|
||||
type SupplierInvoiceLineItem,
|
||||
} from '@/lib/supplier-invoices/form-payload'
|
||||
import type { Supplier, InvoiceExtractionResult } from '@/types'
|
||||
|
||||
export interface InboxItemData {
|
||||
id: string
|
||||
extracted_data: InvoiceExtractionResult | null
|
||||
matched_supplier_id: string | null
|
||||
document_id: string | null
|
||||
}
|
||||
|
||||
interface UseInboxPrefillParams {
|
||||
inboxItemId: string | null
|
||||
suppliersLoaded: boolean
|
||||
suppliers: Supplier[]
|
||||
setValue: UseFormSetValue<SupplierInvoiceFormData>
|
||||
replace: (items: SupplierInvoiceLineItem[]) => void
|
||||
reset: UseFormReset<SupplierInvoiceFormData>
|
||||
getValues: UseFormGetValues<SupplierInvoiceFormData>
|
||||
toast: ReturnType<typeof useToast>['toast']
|
||||
t: ReturnType<typeof useTranslations>
|
||||
/** Called for each scalar form field the extraction filled (settle tint hosts). */
|
||||
onFieldPrefilled?: (field: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The AI-extraction prefill for the supplier-invoice editor. Owns the
|
||||
* extraction state (extractedData/originalExtracted, matched supplier flag,
|
||||
* loading + one-shot guards) and the effect that loads an inbox item when the
|
||||
* form is opened with ?inbox_item_id. `applyInboxItem` is the reusable core:
|
||||
* the dokument-först upload flow feeds the same function, so both arrival
|
||||
* paths share one prefill semantics (incl. the reset(getValues()) baseline
|
||||
* that keeps the unsaved-changes prompt quiet).
|
||||
*/
|
||||
export function useInboxPrefill({
|
||||
inboxItemId,
|
||||
suppliersLoaded,
|
||||
suppliers,
|
||||
setValue,
|
||||
replace,
|
||||
reset,
|
||||
getValues,
|
||||
toast,
|
||||
t,
|
||||
onFieldPrefilled,
|
||||
}: UseInboxPrefillParams) {
|
||||
const [extractedData, setExtractedData] = useState<InvoiceExtractionResult | null>(null)
|
||||
const [originalExtracted, setOriginalExtracted] = useState<InvoiceExtractionResult | null>(null)
|
||||
const [hasMatchedSupplier, setHasMatchedSupplier] = useState(false)
|
||||
const [isLoadingInbox, setIsLoadingInbox] = useState(!!inboxItemId)
|
||||
const [hasPrefilled, setHasPrefilled] = useState(false)
|
||||
// Bumped once per applied extraction. Effects that must re-run after every
|
||||
// apply (not just the first: a remove + re-upload applies again) depend on
|
||||
// this instead of the one-shot `hasPrefilled` flag.
|
||||
const [applyCount, setApplyCount] = useState(0)
|
||||
|
||||
// The dokument-forst upload path calls applyInboxItem from long-lived
|
||||
// closures (the 90 s extraction poll captures the starting render), so the
|
||||
// supplier list is read through a ref: otherwise an extraction that lands
|
||||
// after the suppliers finished loading resolves against a stale [] and the
|
||||
// matched supplier is silently dropped.
|
||||
const suppliersRef = useRef(suppliers)
|
||||
useEffect(() => {
|
||||
suppliersRef.current = suppliers
|
||||
}, [suppliers])
|
||||
|
||||
const applyInboxItem = useCallback(
|
||||
(item: InboxItemData): boolean => {
|
||||
const extracted = item.extracted_data
|
||||
if (!extracted) return false
|
||||
|
||||
setExtractedData(extracted)
|
||||
setOriginalExtracted(extracted)
|
||||
|
||||
// Supplier
|
||||
if (
|
||||
item.matched_supplier_id &&
|
||||
suppliersRef.current.find((s) => s.id === item.matched_supplier_id)
|
||||
) {
|
||||
setValue('supplier_id', item.matched_supplier_id)
|
||||
setHasMatchedSupplier(true)
|
||||
onFieldPrefilled?.('supplier_id')
|
||||
}
|
||||
|
||||
// Scalar invoice fields
|
||||
if (extracted.invoice?.invoiceNumber) {
|
||||
setValue('supplier_invoice_number', extracted.invoice.invoiceNumber)
|
||||
onFieldPrefilled?.('supplier_invoice_number')
|
||||
}
|
||||
if (extracted.invoice?.invoiceDate) {
|
||||
setValue('invoice_date', extracted.invoice.invoiceDate)
|
||||
onFieldPrefilled?.('invoice_date')
|
||||
}
|
||||
if (extracted.invoice?.dueDate) {
|
||||
setValue('due_date', extracted.invoice.dueDate)
|
||||
onFieldPrefilled?.('due_date')
|
||||
}
|
||||
if (extracted.invoice?.paymentReference) {
|
||||
setValue('payment_reference', extracted.invoice.paymentReference)
|
||||
onFieldPrefilled?.('payment_reference')
|
||||
}
|
||||
if (extracted.invoice?.currency) {
|
||||
setValue('currency', extracted.invoice.currency)
|
||||
onFieldPrefilled?.('currency')
|
||||
}
|
||||
|
||||
// Line items: keep the single empty default if AI returned nothing,
|
||||
// otherwise replace it with the extracted lines. When the document
|
||||
// states a service window of 2+ calendar months (insurance period,
|
||||
// license term), pre-fill periodisering on every positive line: the
|
||||
// user sees the panel and can remove it before booking.
|
||||
if (extracted.lineItems && extracted.lineItems.length > 0) {
|
||||
// AI-extracted values are untrusted input: only accept strict
|
||||
// ISO-8601 dates before they reach form state (and later the API).
|
||||
const isIsoDate = (v: unknown): v is string =>
|
||||
typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v)
|
||||
const spsRaw = extracted.invoice?.servicePeriodStart
|
||||
const speRaw = extracted.invoice?.servicePeriodEnd
|
||||
const sps = isIsoDate(spsRaw) ? spsRaw : null
|
||||
const spe = isIsoDate(speRaw) ? speRaw : null
|
||||
let prefillAccrual = false
|
||||
if (sps && spe && spe >= sps) {
|
||||
try {
|
||||
prefillAccrual = countCalendarMonths(sps, spe) >= 2
|
||||
} catch {
|
||||
prefillAccrual = false
|
||||
}
|
||||
}
|
||||
replace(
|
||||
extracted.lineItems.map((li) => {
|
||||
const amount = typeof li.lineTotal === 'number' ? li.lineTotal : 0
|
||||
const withAccrual = prefillAccrual && amount > 0
|
||||
return {
|
||||
description: li.description || '',
|
||||
amount,
|
||||
// Extraction never suggests accounts (forcibly nulled at parse
|
||||
// time) and a silent default misbooks: leave empty so the user
|
||||
// (or the supplier default) makes the call.
|
||||
account_number: '',
|
||||
// Deliberately unconditional: for icke momsregistrerade the
|
||||
// zeroing effect grosses the net amount up by this rate
|
||||
// before forcing it to 0, so the rate must arrive intact.
|
||||
vat_rate: vatRateFromAi(li.vatRate),
|
||||
accrual_period_start: withAccrual ? (sps as string) : undefined,
|
||||
accrual_period_end: withAccrual ? (spe as string) : undefined,
|
||||
// No account yet → generic 1790; toggleAccrual re-suggests the
|
||||
// same way once the user picks one.
|
||||
accrual_balance_account: withAccrual
|
||||
? suggestBalanceAccount('expense', '')
|
||||
: undefined,
|
||||
}
|
||||
}),
|
||||
)
|
||||
onFieldPrefilled?.('items')
|
||||
}
|
||||
|
||||
// Treat the AI prefill as the new baseline: otherwise the unsaved-
|
||||
// changes prompt fires the moment the user navigates away, even if
|
||||
// they didn't touch anything.
|
||||
reset(getValues())
|
||||
setHasPrefilled(true)
|
||||
setApplyCount((c) => c + 1)
|
||||
return true
|
||||
},
|
||||
[setValue, replace, reset, getValues, onFieldPrefilled],
|
||||
)
|
||||
|
||||
// One-shot: load inbox item and prefill form. Runs after suppliers are
|
||||
// loaded so we can resolve matched_supplier_id to a real picker value.
|
||||
// Gate on `suppliersLoaded`, not `suppliers.length > 0`: otherwise the
|
||||
// effect never fires for users who haven't booked a supplier yet and
|
||||
// the "Laddar uppgifter från inkorgen…" spinner sticks forever.
|
||||
useEffect(() => {
|
||||
if (!inboxItemId || hasPrefilled || !suppliersLoaded) return
|
||||
let cancelled = false
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/extensions/ext/invoice-inbox/items/${inboxItemId}`)
|
||||
const json = await res.json()
|
||||
if (cancelled) return
|
||||
if (!res.ok) {
|
||||
toast({
|
||||
title: t('inbox_load_failed_title'),
|
||||
description: json?.error || t('inbox_load_failed_description'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
setIsLoadingInbox(false)
|
||||
return
|
||||
}
|
||||
|
||||
const item = json.data as InboxItemData
|
||||
if (!item.extracted_data) {
|
||||
setIsLoadingInbox(false)
|
||||
setHasPrefilled(true)
|
||||
return
|
||||
}
|
||||
|
||||
applyInboxItem(item)
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
toast({
|
||||
title: t('inbox_load_failed_title'),
|
||||
description: err instanceof Error ? getErrorMessage(err) : t('unknown_error'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
if (!cancelled) setIsLoadingInbox(false)
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [inboxItemId, suppliersLoaded, suppliers])
|
||||
|
||||
return {
|
||||
extractedData,
|
||||
originalExtracted,
|
||||
hasMatchedSupplier,
|
||||
setHasMatchedSupplier,
|
||||
isLoadingInbox,
|
||||
hasPrefilled,
|
||||
applyCount,
|
||||
applyInboxItem,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { Supplier, BASAccount, EntityType, FiscalPeriod } from '@/types'
|
||||
|
||||
/**
|
||||
* Reference data for the supplier-invoice editor: suppliers, the BAS chart,
|
||||
* company settings (entity type, accounting method, öresavrundning default,
|
||||
* dimensions, VAT registration) and fiscal periods. Extracted verbatim from
|
||||
* NewSupplierInvoiceForm; the fetch-once-on-mount semantics are unchanged.
|
||||
*/
|
||||
export function useSupplierInvoiceData() {
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>([])
|
||||
const [suppliersLoaded, setSuppliersLoaded] = useState(false)
|
||||
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
||||
const [entityType, setEntityType] = useState<EntityType>('enskild_firma')
|
||||
const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual')
|
||||
// Öresavrundning is display-only; defaults to the company-wide setting and is
|
||||
// overridable per invoice via the toggle in the totals section.
|
||||
const [oreRounding, setOreRounding] = useState<boolean>(true)
|
||||
// Dimension tagging (kostnadsställe/projekt). Affordances render only when
|
||||
// company_settings.dimensions_enabled: the same UI-visibility gate as
|
||||
// JournalEntryForm.
|
||||
const [dimensionsEnabled, setDimensionsEnabled] = useState(false)
|
||||
// Icke momsregistrerad verksamhet has no right to deduct input VAT: the
|
||||
// moms controls disappear and every line books at 0 % (the gross amount IS
|
||||
// the cost). Defaults true so registered companies keep the 25 % prefill
|
||||
// while /api/settings is still in flight.
|
||||
const [vatRegistered, setVatRegistered] = useState(true)
|
||||
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
|
||||
const [periodsLoaded, setPeriodsLoaded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchSuppliers() {
|
||||
try {
|
||||
const res = await fetch('/api/suppliers')
|
||||
const { data } = await res.json()
|
||||
setSuppliers(data || [])
|
||||
} finally {
|
||||
setSuppliersLoaded(true)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAccounts() {
|
||||
const res = await fetch('/api/bookkeeping/accounts')
|
||||
const { data } = await res.json()
|
||||
setAccounts(data || [])
|
||||
}
|
||||
|
||||
async function fetchEntityType() {
|
||||
try {
|
||||
const res = await fetch('/api/settings')
|
||||
const { data } = await res.json()
|
||||
if (data?.entity_type) setEntityType(data.entity_type)
|
||||
// Cash method books at payment, not registration: drives whether the
|
||||
// out-of-period warning is relevant (see willBookAtRegistration).
|
||||
if (data?.accounting_method === 'cash' || data?.accounting_method === 'accrual') {
|
||||
setAccountingMethod(data.accounting_method)
|
||||
}
|
||||
if (typeof data?.ore_rounding === 'boolean') setOreRounding(data.ore_rounding)
|
||||
setDimensionsEnabled(data?.dimensions_enabled === true)
|
||||
// Only an explicit false gates: a missing column or failed fetch keeps
|
||||
// the registered-company behavior.
|
||||
if (data?.vat_registered === false) setVatRegistered(false)
|
||||
} catch {
|
||||
// Default to enskild_firma / accrual, dimension affordances hidden
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPeriods() {
|
||||
try {
|
||||
const res = await fetch('/api/bookkeeping/fiscal-periods')
|
||||
const { data } = await res.json()
|
||||
setPeriods(data || [])
|
||||
} catch {
|
||||
// Non-critical: the server still hard-blocks an out-of-period booking.
|
||||
} finally {
|
||||
setPeriodsLoaded(true)
|
||||
}
|
||||
}
|
||||
|
||||
fetchSuppliers()
|
||||
fetchAccounts()
|
||||
fetchEntityType()
|
||||
fetchPeriods()
|
||||
}, [])
|
||||
|
||||
return {
|
||||
suppliers,
|
||||
setSuppliers,
|
||||
suppliersLoaded,
|
||||
accounts,
|
||||
entityType,
|
||||
accountingMethod,
|
||||
oreRounding,
|
||||
setOreRounding,
|
||||
dimensionsEnabled,
|
||||
vatRegistered,
|
||||
periods,
|
||||
periodsLoaded,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, type RefObject } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute'
|
||||
import { findIllegalVatRateRow } from '@/lib/vat/supplier-invoice-line-checks'
|
||||
import { rateToPctString, type SupplierInvoiceFormData } from '@/lib/supplier-invoices/form-payload'
|
||||
import type { InvoiceExtractionResult } from '@/types'
|
||||
|
||||
// The existing invoice surfaced on a duplicate-number conflict, used to drive
|
||||
// the resolution dialog (open it / uncredit-and-retry).
|
||||
export interface ExistingSupplierInvoice {
|
||||
id: string
|
||||
supplier_invoice_number: string
|
||||
status: string
|
||||
credit_note_id: string | null
|
||||
}
|
||||
|
||||
// Canonical create/convert response. On failure `error` is the structured
|
||||
// envelope's inner object ({ code, message, details }); a few legacy convert
|
||||
// paths still return a flat string, so accept both.
|
||||
export interface CreateResult {
|
||||
data?: { id: string; arrival_number: number }
|
||||
warnings?: Array<{ code: string; message: string }>
|
||||
error?:
|
||||
| string
|
||||
| {
|
||||
code?: string
|
||||
message?: string
|
||||
message_en?: string
|
||||
details?: { existing?: ExistingSupplierInvoice | null }
|
||||
}
|
||||
}
|
||||
|
||||
interface UseSupplierInvoiceSubmitParams {
|
||||
t: ReturnType<typeof useTranslations>
|
||||
ta: ReturnType<typeof useTranslations>
|
||||
toast: ReturnType<typeof useToast>['toast']
|
||||
isEF: boolean
|
||||
/** Inbox item to convert (route chooser + best-effort field sync-back). */
|
||||
inboxItemId: string | null
|
||||
originalExtracted: InvoiceExtractionResult | null
|
||||
buildPayload: (data: SupplierInvoiceFormData) => unknown
|
||||
reset: (data: SupplierInvoiceFormData) => void
|
||||
finishCreate: (invoiceId?: string) => void
|
||||
documentUploadInProgress: boolean
|
||||
documentUploadFailed: boolean
|
||||
showNoPeriodWarning: boolean
|
||||
canUseAccrual: boolean
|
||||
invoiceNumberInputRef: RefObject<HTMLInputElement | null>
|
||||
/**
|
||||
* Focus router for the always-enabled primary: called instead of a toast
|
||||
* when a required field is missing (supplier, invoice number, row account),
|
||||
* in the same priority order as the next-step line. Optional so the hook
|
||||
* can ship before the shell rebuild wires it.
|
||||
*/
|
||||
onMissingField?: (
|
||||
field: 'supplier' | 'invoice_number' | 'rows' | 'row_account',
|
||||
rowIndex?: number,
|
||||
) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit orchestration for the supplier-invoice editor: endpoint chooser
|
||||
* (plain create vs inbox convert), the three submit paths (EF/private direct,
|
||||
* AB review, register-and-match), duplicate-number conflict recovery and the
|
||||
* best-effort inbox field sync-back. Extracted verbatim from
|
||||
* NewSupplierInvoiceForm; both endpoints validate CreateSupplierInvoiceSchema
|
||||
* and return the canonical error envelope.
|
||||
*/
|
||||
export function useSupplierInvoiceSubmit({
|
||||
t,
|
||||
ta,
|
||||
toast,
|
||||
isEF,
|
||||
inboxItemId,
|
||||
originalExtracted,
|
||||
buildPayload,
|
||||
reset,
|
||||
finishCreate,
|
||||
documentUploadInProgress,
|
||||
documentUploadFailed,
|
||||
showNoPeriodWarning,
|
||||
canUseAccrual,
|
||||
invoiceNumberInputRef,
|
||||
onMissingField,
|
||||
}: UseSupplierInvoiceSubmitParams) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [showReview, setShowReview] = useState(false)
|
||||
const [pendingData, setPendingData] = useState<SupplierInvoiceFormData | null>(null)
|
||||
|
||||
// Match-on-create state
|
||||
const [showBankPicker, setShowBankPicker] = useState(false)
|
||||
const [pendingTransactionId, setPendingTransactionId] = useState<string | null>(null)
|
||||
// The button's onClick and the form's onSubmit run in the same React event
|
||||
// batch, so a `useState`-backed submitMode would still hold the previous
|
||||
// render's value when onSubmit reads it. A ref bridges the two synchronous
|
||||
// handlers; the matching state mirror only drives the review-dialog UI.
|
||||
const submitModeRef = useRef<'register' | 'register_and_match'>('register')
|
||||
|
||||
// Conflict state for duplicate-supplier-invoice-number
|
||||
const [conflict, setConflict] = useState<{
|
||||
message: string
|
||||
existing: ExistingSupplierInvoice | null
|
||||
} | null>(null)
|
||||
const [isResolvingConflict, setIsResolvingConflict] = useState(false)
|
||||
|
||||
// Persist user edits back into the inbox item's extracted_data so the
|
||||
// inbox stays in sync with what was actually booked. Best-effort: a
|
||||
// failed PATCH never blocks the registration.
|
||||
async function patchInboxFieldsIfChanged(data: SupplierInvoiceFormData) {
|
||||
if (!inboxItemId || !originalExtracted) return
|
||||
const supplierField: Record<string, unknown> = {}
|
||||
const invoiceField: Record<string, unknown> = {}
|
||||
|
||||
if (originalExtracted.invoice?.invoiceNumber !== data.supplier_invoice_number) {
|
||||
invoiceField.invoiceNumber = data.supplier_invoice_number || null
|
||||
}
|
||||
if (originalExtracted.invoice?.invoiceDate !== data.invoice_date) {
|
||||
invoiceField.invoiceDate = data.invoice_date || null
|
||||
}
|
||||
if (originalExtracted.invoice?.dueDate !== data.due_date) {
|
||||
invoiceField.dueDate = data.due_date || null
|
||||
}
|
||||
if ((originalExtracted.invoice?.paymentReference || null) !== (data.payment_reference || null)) {
|
||||
invoiceField.paymentReference = data.payment_reference || null
|
||||
}
|
||||
if (originalExtracted.invoice?.currency !== data.currency) {
|
||||
invoiceField.currency = data.currency
|
||||
}
|
||||
|
||||
if (Object.keys(supplierField).length === 0 && Object.keys(invoiceField).length === 0) return
|
||||
|
||||
try {
|
||||
await fetch(`/api/extensions/ext/invoice-inbox/items/${inboxItemId}/fields`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...(Object.keys(supplierField).length ? { supplier: supplierField } : {}),
|
||||
...(Object.keys(invoiceField).length ? { invoice: invoiceField } : {}),
|
||||
}),
|
||||
})
|
||||
} catch {
|
||||
// Best-effort sync; don't block registration on this.
|
||||
}
|
||||
}
|
||||
|
||||
// Single submit endpoint chooser: convert when we came from inbox, plain
|
||||
// POST otherwise. Both endpoints validate the same CreateSupplierInvoiceSchema
|
||||
// and return the same canonical error envelope ({ error: { code, message,
|
||||
// details } }): including the recoverable duplicate-number 409.
|
||||
async function postCreate(data: SupplierInvoiceFormData): Promise<{
|
||||
ok: boolean
|
||||
status: number
|
||||
result: CreateResult
|
||||
}> {
|
||||
const url = inboxItemId
|
||||
? `/api/extensions/ext/invoice-inbox/items/${inboxItemId}/convert`
|
||||
: '/api/supplier-invoices'
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(buildPayload(data)),
|
||||
})
|
||||
const result = await res.json()
|
||||
if (
|
||||
res.ok &&
|
||||
(result as CreateResult).warnings?.some((warning) => warning.code === 'DOCUMENT_LINK_FAILED')
|
||||
) {
|
||||
toast({
|
||||
title: t('document_link_warning_title'),
|
||||
description: t('document_link_warning_description'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
return { ok: res.ok, status: res.status, result }
|
||||
}
|
||||
|
||||
function onSubmit(data: SupplierInvoiceFormData) {
|
||||
if (documentUploadInProgress) {
|
||||
toast({
|
||||
title: t('document_upload_in_progress_title'),
|
||||
description: t('document_upload_in_progress_description'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
if (documentUploadFailed) {
|
||||
toast({
|
||||
title: t('document_upload_failed_title'),
|
||||
description: t('document_upload_failed_description'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
// Hard block: under faktureringsmetoden (and for privately-paid kvitton) a
|
||||
// verifikation is posted at registration, and BFL 5 kap kräver att
|
||||
// verifikationsnumret ligger i en obruten serie inom ett räkenskapsår. No
|
||||
// räkenskapsår for the invoice date → no compliant voucher can exist, so we
|
||||
// refuse rather than register an unbooked invoice. Kontantmetoden books at
|
||||
// payment, so it is intentionally not blocked here (see showNoPeriodWarning).
|
||||
if (showNoPeriodWarning) {
|
||||
toast({
|
||||
title: t('warning_title'),
|
||||
description: t('no_period_warning', { date: data.invoice_date }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!data.supplier_id) {
|
||||
if (onMissingField) onMissingField('supplier')
|
||||
else toast({ title: t('supplier_missing_title'), description: t('supplier_missing_description'), variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
if (!data.supplier_invoice_number) {
|
||||
if (onMissingField) onMissingField('invoice_number')
|
||||
else toast({ title: t('invoice_number_missing_title'), description: t('invoice_number_missing_description'), variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
// The dokument-först table starts with zero rows (the ghost entry row is
|
||||
// never part of form state): an empty items array must route to the entry
|
||||
// input instead of slipping past the per-row account check below.
|
||||
if (data.items.length === 0) {
|
||||
if (onMissingField) onMissingField('rows')
|
||||
else {
|
||||
toast({
|
||||
title: t('account_missing_title'),
|
||||
description: t('account_missing_description', { row: 1 }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
const rowWithoutAccount = data.items.findIndex((item) => !item.account_number)
|
||||
if (rowWithoutAccount !== -1) {
|
||||
if (onMissingField) onMissingField('row_account', rowWithoutAccount)
|
||||
else {
|
||||
toast({
|
||||
title: t('account_missing_title'),
|
||||
description: t('account_missing_description', { row: rowWithoutAccount + 1 }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
// Only 25/12/6/0 % are legal Swedish VAT rates (ML 2023:200). The free-text
|
||||
// VatRateCell clamps to [0, 100] but accepts anything in between, so block
|
||||
// illegal rates here. Reverse-charge invoices skip this: their line vat_rate
|
||||
// is forced to 0 and RcRateSelect already restricts the self-assessed rate.
|
||||
if (!data.reverse_charge) {
|
||||
const rowWithIllegalRate = findIllegalVatRateRow(data.items)
|
||||
if (rowWithIllegalRate !== -1) {
|
||||
toast({
|
||||
title: t('illegal_vat_rate_title'),
|
||||
description: t('illegal_vat_rate_description', {
|
||||
row: rowWithIllegalRate + 1,
|
||||
rate: rateToPctString(data.items[rowWithIllegalRate].vat_rate),
|
||||
}),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
// A row with an open periodisering panel must carry a complete period of
|
||||
// at least two calendar months before the invoice can be booked.
|
||||
const invalidAccrual = canUseAccrual && data.items.some((item) => {
|
||||
if (item.accrual_balance_account == null) return false
|
||||
if (!item.accrual_period_start || !item.accrual_period_end) return true
|
||||
if (item.accrual_period_end < item.accrual_period_start) return true
|
||||
return countCalendarMonths(item.accrual_period_start, item.accrual_period_end) < 2
|
||||
})
|
||||
if (invalidAccrual) {
|
||||
toast({
|
||||
title: ta('incomplete_toast_title'),
|
||||
description: ta('incomplete_toast_description'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (submitModeRef.current === 'register_and_match') {
|
||||
// Open the bank-transaction picker; actual create happens on pick.
|
||||
// For AB the review dialog is shown after a transaction is picked.
|
||||
setPendingData(data)
|
||||
setShowBankPicker(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Privately-paid skips the AB review dialog: the toggle itself is the
|
||||
// explicit user intent, and the resulting verifikat is just expense + VAT
|
||||
// against the owner account (2893/2018). Same path for EF.
|
||||
if (isEF || data.paid_with_private_funds) {
|
||||
setPendingData(data)
|
||||
handleDirectSubmit(data)
|
||||
} else {
|
||||
setPendingData(data)
|
||||
setShowReview(true)
|
||||
}
|
||||
}
|
||||
|
||||
// EF: create + auto-approve, no review dialog. Privately-paid invoices land
|
||||
// here too and skip auto-approve since they're already in status='paid'.
|
||||
async function handleDirectSubmit(data: SupplierInvoiceFormData) {
|
||||
setIsSubmitting(true)
|
||||
await patchInboxFieldsIfChanged(data)
|
||||
const { ok, status, result } = await postCreate(data)
|
||||
|
||||
if (!ok) {
|
||||
// EF/direct path also hits the duplicate-number 409 (e.g. converting an
|
||||
// inbox receipt whose number was already registered): offer recovery
|
||||
// instead of a dead-end toast.
|
||||
if (!tryHandleDuplicateConflict(status, result)) {
|
||||
handleCreateError(status, result)
|
||||
}
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
if (!result.data) {
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Clear dirty state so useUnsavedChanges doesn't fire the
|
||||
// beforeunload prompt while we navigate away on a successful submit.
|
||||
reset(data)
|
||||
|
||||
if (data.paid_with_private_funds) {
|
||||
toast({
|
||||
title: t('expense_registered_title'),
|
||||
description: t('arrival_number_label', { number: result.data.arrival_number }),
|
||||
})
|
||||
finishCreate()
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-approve for EF
|
||||
const approveRes = await fetch(`/api/supplier-invoices/${result.data.id}/approve`, { method: 'POST' })
|
||||
if (!approveRes.ok) {
|
||||
toast({
|
||||
title: t('warning_title'),
|
||||
description: t('auto_approve_failed_description'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
finishCreate(result.data.id)
|
||||
} else {
|
||||
toast({ title: t('invoice_registered_title'), description: t('arrival_number_label', { number: result.data.arrival_number }) })
|
||||
finishCreate()
|
||||
}
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
// AB: create after review dialog. If a bank transaction was picked first
|
||||
// (register-and-match flow), also match the new invoice to it.
|
||||
async function handleConfirm() {
|
||||
if (!pendingData) return
|
||||
setIsSubmitting(true)
|
||||
await patchInboxFieldsIfChanged(pendingData)
|
||||
const { ok, status, result } = await postCreate(pendingData)
|
||||
|
||||
if (ok && result.data) {
|
||||
const invoiceId = result.data.id
|
||||
const arrivalNumber = result.data.arrival_number
|
||||
setShowReview(false)
|
||||
// Clear dirty state: see comment in handleDirectSubmit.
|
||||
reset(pendingData)
|
||||
|
||||
if (pendingTransactionId) {
|
||||
const matchRes = await fetch(`/api/transactions/${pendingTransactionId}/match-supplier-invoice`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ supplier_invoice_id: invoiceId }),
|
||||
})
|
||||
const matchResult = await matchRes.json()
|
||||
setPendingTransactionId(null)
|
||||
submitModeRef.current = 'register'
|
||||
|
||||
if (matchRes.ok) {
|
||||
toast({
|
||||
title: t('invoice_registered_and_matched_title'),
|
||||
description: t('invoice_registered_and_matched_description', { number: arrivalNumber }),
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
title: t('invoice_registered_match_failed_title'),
|
||||
description: getErrorMessage(matchResult, { context: 'supplier_invoice', statusCode: matchRes.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
toast({ title: t('invoice_registered_title'), description: t('arrival_number_label', { number: arrivalNumber }) })
|
||||
}
|
||||
|
||||
finishCreate(invoiceId)
|
||||
} else {
|
||||
// Treat duplicate-number as a recoverable conflict; everything else as a hard error.
|
||||
if (!tryHandleDuplicateConflict(status, result)) {
|
||||
handleCreateError(status, result)
|
||||
}
|
||||
}
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
// Detect the recoverable duplicate-supplier-invoice-number conflict and open
|
||||
// the resolution dialog. Both the inbox `convert` route and the plain create
|
||||
// route return the same structured 409 envelope, so this works for every
|
||||
// submit path. Returns true when handled (caller should skip the error toast).
|
||||
function tryHandleDuplicateConflict(status: number, result: CreateResult): boolean {
|
||||
const err = result.error
|
||||
if (
|
||||
status !== 409 ||
|
||||
typeof err !== 'object' ||
|
||||
err === null ||
|
||||
err.code !== 'SI_CREATE_DUPLICATE_INVOICE_NUMBER'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// Close the review dialog if it was the path that triggered the conflict;
|
||||
// a no-op for the EF/direct paths where it was never opened.
|
||||
setShowReview(false)
|
||||
setConflict({
|
||||
message: err.message || t('duplicate_default_message'),
|
||||
existing: err.details?.existing ?? null,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
// Shared error toast for non-conflict failures.
|
||||
function handleCreateError(status: number, result: CreateResult) {
|
||||
toast({
|
||||
title: t('register_invoice_failed_title'),
|
||||
description: getErrorMessage(result, { context: 'supplier_invoice', statusCode: status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
async function handleUncreditAndRetry() {
|
||||
if (!conflict?.existing) return
|
||||
const existingId = conflict.existing.id
|
||||
const existingNumber = conflict.existing.supplier_invoice_number
|
||||
setIsResolvingConflict(true)
|
||||
|
||||
const uncreditRes = await fetch(
|
||||
`/api/supplier-invoices/${existingId}/uncredit`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
const uncreditResult = await uncreditRes.json()
|
||||
if (!uncreditRes.ok) {
|
||||
toast({
|
||||
title: t('uncredit_failed_title'),
|
||||
description: getErrorMessage(uncreditResult, { context: 'supplier_invoice', statusCode: uncreditRes.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
setIsResolvingConflict(false)
|
||||
return
|
||||
}
|
||||
|
||||
setConflict(null)
|
||||
|
||||
if (!pendingData) {
|
||||
setIsResolvingConflict(false)
|
||||
return
|
||||
}
|
||||
|
||||
const { ok, status, result } = await postCreate(pendingData)
|
||||
setIsResolvingConflict(false)
|
||||
|
||||
if (ok && result.data) {
|
||||
toast({
|
||||
title: t('uncredit_and_register_success_title'),
|
||||
description: t('arrival_number_label', { number: result.data.arrival_number }),
|
||||
})
|
||||
reset(pendingData)
|
||||
finishCreate(result.data.id)
|
||||
return
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t('uncredit_but_register_failed_title'),
|
||||
description: t('uncredit_but_register_failed_description', {
|
||||
number: existingNumber,
|
||||
reason: getErrorMessage(result, { context: 'supplier_invoice', statusCode: status }),
|
||||
}),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
function handlePickNewNumber() {
|
||||
setConflict(null)
|
||||
setTimeout(() => invoiceNumberInputRef.current?.focus(), 0)
|
||||
}
|
||||
|
||||
// Match-on-create: register the invoice, then match the picked transaction.
|
||||
// EF goes straight through (auto-approve included). AB stores the picked
|
||||
// transaction and routes through the same review dialog as the plain
|
||||
// register flow: handleConfirm picks up the match step on confirmation.
|
||||
async function handlePickTransaction(transactionId: string) {
|
||||
if (!pendingData) return
|
||||
setShowBankPicker(false)
|
||||
|
||||
if (!isEF) {
|
||||
setPendingTransactionId(transactionId)
|
||||
setShowReview(true)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
await patchInboxFieldsIfChanged(pendingData)
|
||||
const { ok, status, result } = await postCreate(pendingData)
|
||||
|
||||
if (!ok || !result.data) {
|
||||
if (!tryHandleDuplicateConflict(status, result)) {
|
||||
handleCreateError(status, result)
|
||||
}
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
const invoiceId = result.data.id
|
||||
const arrivalNumber = result.data.arrival_number
|
||||
|
||||
// Auto-approve before matching, so the invoice is in the 'approved' state
|
||||
// that match-supplier-invoice expects (it accepts registered too, but
|
||||
// EF's expectation is fully-booked).
|
||||
await fetch(`/api/supplier-invoices/${invoiceId}/approve`, { method: 'POST' })
|
||||
|
||||
const matchRes = await fetch(`/api/transactions/${transactionId}/match-supplier-invoice`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ supplier_invoice_id: invoiceId }),
|
||||
})
|
||||
const matchResult = await matchRes.json()
|
||||
setIsSubmitting(false)
|
||||
submitModeRef.current = 'register'
|
||||
|
||||
if (matchRes.ok) {
|
||||
toast({
|
||||
title: t('invoice_registered_and_matched_title'),
|
||||
description: t('invoice_registered_and_matched_description', { number: arrivalNumber }),
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
title: t('invoice_registered_match_failed_title'),
|
||||
description: getErrorMessage(matchResult, { context: 'supplier_invoice', statusCode: matchRes.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
reset(pendingData)
|
||||
finishCreate(invoiceId)
|
||||
}
|
||||
|
||||
return {
|
||||
isSubmitting,
|
||||
showReview,
|
||||
setShowReview,
|
||||
pendingData,
|
||||
showBankPicker,
|
||||
setShowBankPicker,
|
||||
setPendingTransactionId,
|
||||
submitModeRef,
|
||||
conflict,
|
||||
setConflict,
|
||||
isResolvingConflict,
|
||||
onSubmit,
|
||||
handleConfirm,
|
||||
handleUncreditAndRetry,
|
||||
handlePickNewNumber,
|
||||
handlePickTransaction,
|
||||
}
|
||||
}
|
||||
@@ -1164,6 +1164,16 @@ export const CreateSupplierInvoiceSchema = z.object({
|
||||
items: z.array(CreateSupplierInvoiceItemSchema).min(1, 'At least one item is required'),
|
||||
})
|
||||
|
||||
// Pre-submit duplicate lookup for the supplier-invoice editor. Mirrors the
|
||||
// partial unique index idx_supplier_invoices_company_supplier_number on
|
||||
// (company_id, supplier_id, supplier_invoice_number), which excludes
|
||||
// credited/reversed invoices, so the advisory never warns on the re-issue
|
||||
// pattern the index was widened to allow.
|
||||
export const SupplierInvoiceExistsQuerySchema = z.object({
|
||||
supplier_id: uuid,
|
||||
number: z.string().min(1, 'number is required'),
|
||||
})
|
||||
|
||||
export const MarkSupplierInvoicePaidSchema = z.object({
|
||||
amount: z.number().positive().optional(),
|
||||
payment_date: isoDate.optional(),
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
buildSupplierInvoicePayload,
|
||||
inferVatTreatment,
|
||||
vatRateFromAi,
|
||||
rateToPctString,
|
||||
type SupplierInvoiceFormData,
|
||||
type SupplierInvoiceLineItem,
|
||||
type BuildSupplierInvoicePayloadOptions,
|
||||
} from '../form-payload'
|
||||
|
||||
function makeItem(overrides: Partial<SupplierInvoiceLineItem> = {}): SupplierInvoiceLineItem {
|
||||
return {
|
||||
description: 'Lokalhyra augusti',
|
||||
amount: 1000,
|
||||
account_number: '5010',
|
||||
vat_rate: 0.25,
|
||||
reverse_charge_rate: 0.25,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeFormData(overrides: Partial<SupplierInvoiceFormData> = {}): SupplierInvoiceFormData {
|
||||
return {
|
||||
supplier_id: 'supplier-1',
|
||||
supplier_invoice_number: 'F-2026-881',
|
||||
invoice_date: '2026-08-01',
|
||||
due_date: '2026-08-31',
|
||||
delivery_date: '',
|
||||
currency: 'SEK',
|
||||
exchange_rate: '',
|
||||
reverse_charge: false,
|
||||
payment_reference: '',
|
||||
notes: '',
|
||||
paid_with_private_funds: false,
|
||||
items: [makeItem()],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeOpts(
|
||||
overrides: Partial<BuildSupplierInvoicePayloadOptions> = {},
|
||||
): BuildSupplierInvoicePayloadOptions {
|
||||
return {
|
||||
inboxItemId: null,
|
||||
uploadedDocumentId: undefined,
|
||||
oreRounding: true,
|
||||
defaultDims: {},
|
||||
canUseAccrual: true,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('inferVatTreatment', () => {
|
||||
it('maps a uniform rate to the matching treatment', () => {
|
||||
expect(inferVatTreatment([makeItem({ vat_rate: 0.25 })], false)).toBe('standard_25')
|
||||
expect(inferVatTreatment([makeItem({ vat_rate: 0.12 })], false)).toBe('reduced_12')
|
||||
expect(inferVatTreatment([makeItem({ vat_rate: 0.06 })], false)).toBe('reduced_6')
|
||||
expect(inferVatTreatment([makeItem({ vat_rate: 0 })], false)).toBe('exempt')
|
||||
})
|
||||
|
||||
it('falls back to standard_25 on mixed rates', () => {
|
||||
expect(
|
||||
inferVatTreatment([makeItem({ vat_rate: 0.25 }), makeItem({ vat_rate: 0.12 })], false),
|
||||
).toBe('standard_25')
|
||||
})
|
||||
|
||||
it('reverse charge wins over everything', () => {
|
||||
expect(inferVatTreatment([makeItem({ vat_rate: 0.12 })], true)).toBe('reverse_charge')
|
||||
})
|
||||
})
|
||||
|
||||
describe('vatRateFromAi', () => {
|
||||
it('maps integer percent to decimals, null to the 25 % default', () => {
|
||||
expect(vatRateFromAi(25)).toBe(0.25)
|
||||
expect(vatRateFromAi(12)).toBe(0.12)
|
||||
expect(vatRateFromAi(6)).toBe(0.06)
|
||||
expect(vatRateFromAi(0)).toBe(0)
|
||||
expect(vatRateFromAi(null)).toBe(0.25)
|
||||
expect(vatRateFromAi(undefined)).toBe(0.25)
|
||||
// Anything unrecognized collapses to 0, never an illegal rate.
|
||||
expect(vatRateFromAi(19)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rateToPctString', () => {
|
||||
it('renders decimals as percent strings', () => {
|
||||
expect(rateToPctString(0.25)).toBe('25')
|
||||
expect(rateToPctString(0.12)).toBe('12')
|
||||
expect(rateToPctString(0.06)).toBe('6')
|
||||
expect(rateToPctString(0)).toBe('0')
|
||||
expect(rateToPctString(0.1234)).toBe('12.34')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSupplierInvoicePayload', () => {
|
||||
it('builds the plain create payload (SEK, no extras)', () => {
|
||||
const payload = buildSupplierInvoicePayload(makeFormData(), makeOpts())
|
||||
expect(payload).toEqual({
|
||||
supplier_id: 'supplier-1',
|
||||
supplier_invoice_number: 'F-2026-881',
|
||||
invoice_date: '2026-08-01',
|
||||
due_date: '2026-08-31',
|
||||
delivery_date: undefined,
|
||||
currency: 'SEK',
|
||||
exchange_rate: undefined,
|
||||
vat_treatment: 'standard_25',
|
||||
reverse_charge: false,
|
||||
payment_reference: undefined,
|
||||
notes: undefined,
|
||||
paid_with_private_funds: false,
|
||||
ore_rounding: true,
|
||||
items: [
|
||||
{
|
||||
description: 'Lokalhyra augusti',
|
||||
amount: 1000,
|
||||
account_number: '5010',
|
||||
vat_rate: 0.25,
|
||||
reverse_charge_rate: undefined,
|
||||
},
|
||||
],
|
||||
})
|
||||
// Conditional keys must be absent, not undefined-valued.
|
||||
expect(payload).not.toHaveProperty('document_id')
|
||||
expect(payload).not.toHaveProperty('default_dimensions')
|
||||
expect(payload.items[0]).not.toHaveProperty('accrual_period_start')
|
||||
expect(payload.items[0]).not.toHaveProperty('dimensions')
|
||||
expect(payload.items[0]).not.toHaveProperty('apply_slp')
|
||||
})
|
||||
|
||||
it('attaches document_id only on non-inbox submits', () => {
|
||||
const withDoc = buildSupplierInvoicePayload(
|
||||
makeFormData(),
|
||||
makeOpts({ uploadedDocumentId: 'doc-1' }),
|
||||
)
|
||||
expect(withDoc).toHaveProperty('document_id', 'doc-1')
|
||||
|
||||
const inbox = buildSupplierInvoicePayload(
|
||||
makeFormData(),
|
||||
makeOpts({ inboxItemId: 'item-1', uploadedDocumentId: 'doc-1' }),
|
||||
)
|
||||
expect(inbox).not.toHaveProperty('document_id')
|
||||
})
|
||||
|
||||
it('privately paid: empty due_date defaults to invoice_date', () => {
|
||||
const payload = buildSupplierInvoicePayload(
|
||||
makeFormData({ paid_with_private_funds: true, due_date: '' }),
|
||||
makeOpts({ canUseAccrual: false }),
|
||||
)
|
||||
expect(payload.due_date).toBe('2026-08-01')
|
||||
expect(payload.paid_with_private_funds).toBe(true)
|
||||
})
|
||||
|
||||
it('privately paid: an explicit due_date is kept', () => {
|
||||
const payload = buildSupplierInvoicePayload(
|
||||
makeFormData({ paid_with_private_funds: true, due_date: '2026-09-15' }),
|
||||
makeOpts({ canUseAccrual: false }),
|
||||
)
|
||||
expect(payload.due_date).toBe('2026-09-15')
|
||||
})
|
||||
|
||||
it('reverse charge: vat_rate forced to 0, reverse_charge_rate travels with 0.25 default', () => {
|
||||
const payload = buildSupplierInvoicePayload(
|
||||
makeFormData({
|
||||
reverse_charge: true,
|
||||
items: [
|
||||
makeItem({ vat_rate: 0.25, reverse_charge_rate: 0.12 }),
|
||||
makeItem({ vat_rate: 0.25, reverse_charge_rate: undefined }),
|
||||
],
|
||||
}),
|
||||
makeOpts({ canUseAccrual: false }),
|
||||
)
|
||||
expect(payload.vat_treatment).toBe('reverse_charge')
|
||||
expect(payload.items[0].vat_rate).toBe(0)
|
||||
expect(payload.items[0].reverse_charge_rate).toBe(0.12)
|
||||
expect(payload.items[1].vat_rate).toBe(0)
|
||||
expect(payload.items[1].reverse_charge_rate).toBe(0.25)
|
||||
})
|
||||
|
||||
it('non-RC: reverse_charge_rate is stripped to undefined', () => {
|
||||
const payload = buildSupplierInvoicePayload(makeFormData(), makeOpts())
|
||||
expect(payload.items[0].reverse_charge_rate).toBeUndefined()
|
||||
})
|
||||
|
||||
it('accrual fields travel only when canUseAccrual and the period is complete', () => {
|
||||
const item = makeItem({
|
||||
accrual_period_start: '2026-08-01',
|
||||
accrual_period_end: '2026-12-31',
|
||||
accrual_balance_account: '1790',
|
||||
})
|
||||
|
||||
const withAccrual = buildSupplierInvoicePayload(
|
||||
makeFormData({ items: [item] }),
|
||||
makeOpts({ canUseAccrual: true }),
|
||||
)
|
||||
expect(withAccrual.items[0]).toMatchObject({
|
||||
accrual_period_start: '2026-08-01',
|
||||
accrual_period_end: '2026-12-31',
|
||||
accrual_balance_account: '1790',
|
||||
})
|
||||
|
||||
// Kontantmetod / eget utlägg / RC: canUseAccrual false drops the fields.
|
||||
const withoutAccrual = buildSupplierInvoicePayload(
|
||||
makeFormData({ items: [item] }),
|
||||
makeOpts({ canUseAccrual: false }),
|
||||
)
|
||||
expect(withoutAccrual.items[0]).not.toHaveProperty('accrual_period_start')
|
||||
|
||||
// Incomplete period (end missing) also drops the fields.
|
||||
const incomplete = buildSupplierInvoicePayload(
|
||||
makeFormData({ items: [makeItem({ accrual_period_start: '2026-08-01' })] }),
|
||||
makeOpts({ canUseAccrual: true }),
|
||||
)
|
||||
expect(incomplete.items[0]).not.toHaveProperty('accrual_period_start')
|
||||
})
|
||||
|
||||
it('empty accrual balance account is sent as undefined, not empty string', () => {
|
||||
const payload = buildSupplierInvoicePayload(
|
||||
makeFormData({
|
||||
items: [
|
||||
makeItem({
|
||||
accrual_period_start: '2026-08-01',
|
||||
accrual_period_end: '2026-12-31',
|
||||
accrual_balance_account: '',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
makeOpts({ canUseAccrual: true }),
|
||||
)
|
||||
expect(payload.items[0]).toHaveProperty('accrual_period_start')
|
||||
expect(payload.items[0].accrual_balance_account).toBeUndefined()
|
||||
})
|
||||
|
||||
it('default_dimensions only when non-empty; item bags only when carrying values', () => {
|
||||
const bare = buildSupplierInvoicePayload(
|
||||
makeFormData({ items: [makeItem({ dimensions: {} })] }),
|
||||
makeOpts(),
|
||||
)
|
||||
expect(bare).not.toHaveProperty('default_dimensions')
|
||||
// Open-but-empty per-row panel means inherit: the bag is dropped.
|
||||
expect(bare.items[0]).not.toHaveProperty('dimensions')
|
||||
|
||||
const dims = buildSupplierInvoicePayload(
|
||||
makeFormData({ items: [makeItem({ dimensions: { '1': 'KS01' } })] }),
|
||||
makeOpts({ defaultDims: { '6': 'P001' } }),
|
||||
)
|
||||
expect(dims).toHaveProperty('default_dimensions', { '6': 'P001' })
|
||||
expect(dims.items[0]).toHaveProperty('dimensions', { '1': 'KS01' })
|
||||
})
|
||||
|
||||
it('apply_slp travels only on 741x rows without an accrual period', () => {
|
||||
const valid = buildSupplierInvoicePayload(
|
||||
makeFormData({ items: [makeItem({ account_number: '7412', apply_slp: true })] }),
|
||||
makeOpts(),
|
||||
)
|
||||
expect(valid.items[0]).toHaveProperty('apply_slp', true)
|
||||
|
||||
// Stale flag on a non-pension account is stripped.
|
||||
const wrongAccount = buildSupplierInvoicePayload(
|
||||
makeFormData({ items: [makeItem({ account_number: '5010', apply_slp: true })] }),
|
||||
makeOpts(),
|
||||
)
|
||||
expect(wrongAccount.items[0]).not.toHaveProperty('apply_slp')
|
||||
|
||||
// SLP + periodisering on the same row: accrual wins, flag stripped.
|
||||
const withAccrual = buildSupplierInvoicePayload(
|
||||
makeFormData({
|
||||
items: [
|
||||
makeItem({
|
||||
account_number: '7412',
|
||||
apply_slp: true,
|
||||
accrual_period_start: '2026-08-01',
|
||||
accrual_period_end: '2026-12-31',
|
||||
accrual_balance_account: '1790',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
makeOpts({ canUseAccrual: true }),
|
||||
)
|
||||
expect(withAccrual.items[0]).not.toHaveProperty('apply_slp')
|
||||
|
||||
// But when the flow cannot accrue, the period is dropped and SLP survives.
|
||||
const accrualBlocked = buildSupplierInvoicePayload(
|
||||
makeFormData({
|
||||
items: [
|
||||
makeItem({
|
||||
account_number: '7412',
|
||||
apply_slp: true,
|
||||
accrual_period_start: '2026-08-01',
|
||||
accrual_period_end: '2026-12-31',
|
||||
accrual_balance_account: '1790',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
makeOpts({ canUseAccrual: false }),
|
||||
)
|
||||
expect(accrualBlocked.items[0]).not.toHaveProperty('accrual_period_start')
|
||||
expect(accrualBlocked.items[0]).toHaveProperty('apply_slp', true)
|
||||
})
|
||||
|
||||
it('FX: exchange_rate string parses to a number, empty stays undefined', () => {
|
||||
const fx = buildSupplierInvoicePayload(
|
||||
makeFormData({ currency: 'EUR', exchange_rate: '11.2345' }),
|
||||
makeOpts(),
|
||||
)
|
||||
expect(fx.currency).toBe('EUR')
|
||||
expect(fx.exchange_rate).toBe(11.2345)
|
||||
|
||||
const noFx = buildSupplierInvoicePayload(makeFormData(), makeOpts())
|
||||
expect(noFx.exchange_rate).toBeUndefined()
|
||||
})
|
||||
|
||||
it('empty-string optionals collapse to undefined', () => {
|
||||
const payload = buildSupplierInvoicePayload(
|
||||
makeFormData({ delivery_date: '', payment_reference: '', notes: '' }),
|
||||
makeOpts(),
|
||||
)
|
||||
expect(payload.delivery_date).toBeUndefined()
|
||||
expect(payload.payment_reference).toBeUndefined()
|
||||
expect(payload.notes).toBeUndefined()
|
||||
|
||||
const filled = buildSupplierInvoicePayload(
|
||||
makeFormData({
|
||||
delivery_date: '2026-08-05',
|
||||
payment_reference: '78912345678',
|
||||
notes: 'intern anteckning',
|
||||
}),
|
||||
makeOpts(),
|
||||
)
|
||||
expect(filled.delivery_date).toBe('2026-08-05')
|
||||
expect(filled.payment_reference).toBe('78912345678')
|
||||
expect(filled.notes).toBe('intern anteckning')
|
||||
})
|
||||
|
||||
it('ore_rounding passes through both ways', () => {
|
||||
expect(buildSupplierInvoicePayload(makeFormData(), makeOpts({ oreRounding: true })).ore_rounding).toBe(true)
|
||||
expect(buildSupplierInvoicePayload(makeFormData(), makeOpts({ oreRounding: false })).ore_rounding).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { plantedRowTouched, snapshotPlantedRow } from '@/lib/supplier-invoices/planted-rows'
|
||||
import type { SupplierInvoiceLineItem } from '@/lib/supplier-invoices/form-payload'
|
||||
|
||||
function plantedRow(overrides: Partial<SupplierInvoiceLineItem> = {}): SupplierInvoiceLineItem {
|
||||
return {
|
||||
description: 'Programvaror',
|
||||
amount: 0,
|
||||
account_number: '5420',
|
||||
vat_rate: 0.25,
|
||||
reverse_charge_rate: 0.25,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('plantedRowTouched', () => {
|
||||
it('is false for a row identical to its snapshot', () => {
|
||||
const snapshot = snapshotPlantedRow(plantedRow())
|
||||
expect(plantedRowTouched(plantedRow(), snapshot)).toBe(false)
|
||||
})
|
||||
|
||||
it('is true when the user typed an amount', () => {
|
||||
const snapshot = snapshotPlantedRow(plantedRow())
|
||||
expect(plantedRowTouched(plantedRow({ amount: 1250 }), snapshot)).toBe(true)
|
||||
})
|
||||
|
||||
// The review finding: description/moms/dimensions edits on a 0-amount
|
||||
// planted row were treated as untouched and the row was deleted on a
|
||||
// supplier switch.
|
||||
it('is true when the user edited the description with amount still 0', () => {
|
||||
const snapshot = snapshotPlantedRow(plantedRow())
|
||||
expect(plantedRowTouched(plantedRow({ description: 'Licens Q3' }), snapshot)).toBe(true)
|
||||
})
|
||||
|
||||
it('is true when the user changed the moms rate with amount still 0', () => {
|
||||
const snapshot = snapshotPlantedRow(plantedRow())
|
||||
expect(plantedRowTouched(plantedRow({ vat_rate: 0.12 }), snapshot)).toBe(true)
|
||||
})
|
||||
|
||||
it('is true when the user set dimensions with amount still 0', () => {
|
||||
const snapshot = snapshotPlantedRow(plantedRow())
|
||||
expect(plantedRowTouched(plantedRow({ dimensions: { '1': 'STO' } }), snapshot)).toBe(true)
|
||||
})
|
||||
|
||||
it('is true when the user opened periodisering with amount still 0', () => {
|
||||
const snapshot = snapshotPlantedRow(plantedRow())
|
||||
expect(
|
||||
plantedRowTouched(
|
||||
plantedRow({
|
||||
accrual_period_start: '2026-08-01',
|
||||
accrual_period_end: '2026-10-31',
|
||||
accrual_balance_account: '1790',
|
||||
}),
|
||||
snapshot,
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('is true when the user opted into SLP with amount still 0', () => {
|
||||
const snapshot = snapshotPlantedRow(plantedRow({ account_number: '7410' }))
|
||||
expect(
|
||||
plantedRowTouched(plantedRow({ account_number: '7410', apply_slp: true }), snapshot),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('treats an open-but-empty dimensions bag as untouched', () => {
|
||||
const snapshot = snapshotPlantedRow(plantedRow())
|
||||
expect(plantedRowTouched(plantedRow({ dimensions: {} }), snapshot)).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores account_number: the caller keys on it separately', () => {
|
||||
const snapshot = snapshotPlantedRow(plantedRow())
|
||||
expect(plantedRowTouched(plantedRow({ account_number: '4010' }), snapshot)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('snapshotPlantedRow', () => {
|
||||
it('does not alias the live row or its dimensions bag', () => {
|
||||
const row = plantedRow({ dimensions: { '1': 'STO' } })
|
||||
const snapshot = snapshotPlantedRow(row)
|
||||
row.description = 'edited'
|
||||
row.dimensions!['1'] = 'GBG'
|
||||
expect(snapshot.description).toBe('Programvaror')
|
||||
expect(snapshot.dimensions).toEqual({ '1': 'STO' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Pure payload builders for the supplier-invoice editor
|
||||
* (components/supplier-invoices/NewSupplierInvoiceForm.tsx).
|
||||
*
|
||||
* Extracted verbatim from the form component so the wire contract against
|
||||
* POST /api/supplier-invoices and the invoice-inbox convert endpoint (both
|
||||
* validating CreateSupplierInvoiceSchema) is pinned by unit tests. Behavior
|
||||
* changes here are wire-format changes: keep byte-compatible with the
|
||||
* schema in lib/api/schemas.ts.
|
||||
*/
|
||||
|
||||
import { isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines'
|
||||
import type { VatTreatment } from '@/types'
|
||||
|
||||
export interface SupplierInvoiceLineItem {
|
||||
description: string
|
||||
amount: number
|
||||
account_number: string
|
||||
vat_rate: number
|
||||
// Self-assessed VAT rate for omvänd skattskyldighet (0.25/0.12/0.06). Only
|
||||
// meaningful when reverse_charge is on; the line's vat_rate is then 0.
|
||||
reverse_charge_rate?: number
|
||||
// Periodisering (förutbetald kostnad): both dates + 17xx interim account.
|
||||
// Present only while the row's periodisering panel is active.
|
||||
accrual_period_start?: string
|
||||
accrual_period_end?: string
|
||||
accrual_balance_account?: string
|
||||
// Per-item dimensions bag ({sie_dim_no: code}, dimensions PR7). Defined
|
||||
// (possibly empty) while the row's dimensions panel is open; the server
|
||||
// merges it over the invoice's default_dimensions at booking time.
|
||||
dimensions?: Record<string, string>
|
||||
// Särskild löneskatt på pensionskostnader: booking injects a self-balancing
|
||||
// 7533 D / 2514 K pair at 24.26 % of the line amount. Only offered on 741x
|
||||
// pension-premium accounts; never changes the invoice total.
|
||||
apply_slp?: boolean
|
||||
}
|
||||
|
||||
export interface SupplierInvoiceFormData {
|
||||
supplier_id: string
|
||||
supplier_invoice_number: string
|
||||
invoice_date: string
|
||||
due_date: string
|
||||
delivery_date: string
|
||||
currency: string
|
||||
exchange_rate: string
|
||||
reverse_charge: boolean
|
||||
payment_reference: string
|
||||
notes: string
|
||||
paid_with_private_funds: boolean
|
||||
items: SupplierInvoiceLineItem[]
|
||||
}
|
||||
|
||||
export function inferVatTreatment(
|
||||
items: SupplierInvoiceLineItem[],
|
||||
reverseCharge: boolean,
|
||||
): VatTreatment {
|
||||
if (reverseCharge) return 'reverse_charge'
|
||||
|
||||
const rates = new Set(items.map((i) => i.vat_rate))
|
||||
if (rates.size === 1) {
|
||||
const rate = rates.values().next().value!
|
||||
if (rate === 0.25) return 'standard_25'
|
||||
if (rate === 0.12) return 'reduced_12'
|
||||
if (rate === 0.06) return 'reduced_6'
|
||||
if (rate === 0) return 'exempt'
|
||||
}
|
||||
|
||||
return 'standard_25'
|
||||
}
|
||||
|
||||
// AI returns VAT as integer percent (25, 12, 6, 0). The form stores decimals.
|
||||
export function vatRateFromAi(rate: number | null | undefined): number {
|
||||
if (rate == null) return 0.25
|
||||
if (rate === 25) return 0.25
|
||||
if (rate === 12) return 0.12
|
||||
if (rate === 6) return 0.06
|
||||
return 0
|
||||
}
|
||||
|
||||
export function rateToPctString(rate: number): string {
|
||||
const pct = Math.round(rate * 10000) / 100
|
||||
return Number.isFinite(pct) ? String(pct) : ''
|
||||
}
|
||||
|
||||
export interface BuildSupplierInvoicePayloadOptions {
|
||||
/** Inbox item being converted; when set the convert endpoint links the document itself. */
|
||||
inboxItemId: string | null
|
||||
/** Uploaded document to attach on the plain create path (non-inbox only). */
|
||||
uploadedDocumentId: string | undefined
|
||||
/** Display-only öresavrundning override sent on every payload. */
|
||||
oreRounding: boolean
|
||||
/** Invoice-level default dimensions bag; only sent when non-empty. */
|
||||
defaultDims: Record<string, string>
|
||||
/** Whether the flow supports periodisering (accrual method, not privately paid, not RC). */
|
||||
canUseAccrual: boolean
|
||||
}
|
||||
|
||||
export function buildSupplierInvoicePayload(
|
||||
data: SupplierInvoiceFormData,
|
||||
opts: BuildSupplierInvoicePayloadOptions,
|
||||
) {
|
||||
const { inboxItemId, uploadedDocumentId, oreRounding, defaultDims, canUseAccrual } = opts
|
||||
const vatTreatment = inferVatTreatment(data.items, data.reverse_charge)
|
||||
// When paid privately, due_date is irrelevant: but the API still requires
|
||||
// a YYYY-MM-DD value. Default to invoice_date so the field passes validation.
|
||||
const dueDate = data.paid_with_private_funds && !data.due_date
|
||||
? data.invoice_date
|
||||
: data.due_date
|
||||
return {
|
||||
supplier_id: data.supplier_id,
|
||||
...(!inboxItemId && uploadedDocumentId ? { document_id: uploadedDocumentId } : {}),
|
||||
supplier_invoice_number: data.supplier_invoice_number,
|
||||
invoice_date: data.invoice_date,
|
||||
due_date: dueDate,
|
||||
delivery_date: data.delivery_date || undefined,
|
||||
currency: data.currency,
|
||||
exchange_rate: data.exchange_rate ? parseFloat(data.exchange_rate) : undefined,
|
||||
vat_treatment: vatTreatment,
|
||||
reverse_charge: data.reverse_charge,
|
||||
payment_reference: data.payment_reference || undefined,
|
||||
notes: data.notes || undefined,
|
||||
paid_with_private_funds: data.paid_with_private_funds,
|
||||
ore_rounding: oreRounding,
|
||||
// Invoice-level default dimensions (kostnadsställe/projekt): only sent
|
||||
// when the user actually picked something.
|
||||
...(Object.keys(defaultDims).length > 0 ? { default_dimensions: defaultDims } : {}),
|
||||
items: data.items.map((item) => ({
|
||||
description: item.description,
|
||||
amount: item.amount,
|
||||
account_number: item.account_number,
|
||||
// Reverse charge: the supplier charges no VAT, so the line rate is 0 and
|
||||
// the self-assessed rate travels on reverse_charge_rate (25% default).
|
||||
vat_rate: data.reverse_charge ? 0 : item.vat_rate,
|
||||
reverse_charge_rate: data.reverse_charge ? (item.reverse_charge_rate ?? 0.25) : undefined,
|
||||
// Periodisering: only sent when the row has a complete period AND the
|
||||
// flow supports it (kontantmetod/eget utlägg would be rejected by the
|
||||
// API: an AI prefill must never block those submits).
|
||||
...(canUseAccrual && item.accrual_period_start && item.accrual_period_end
|
||||
? {
|
||||
accrual_period_start: item.accrual_period_start,
|
||||
accrual_period_end: item.accrual_period_end,
|
||||
accrual_balance_account: item.accrual_balance_account || undefined,
|
||||
}
|
||||
: {}),
|
||||
// Per-item dimensions override: only when the bag carries values
|
||||
// (an open-but-empty panel means "inherit the invoice default").
|
||||
...(item.dimensions && Object.keys(item.dimensions).length > 0
|
||||
? { dimensions: item.dimensions }
|
||||
: {}),
|
||||
// Särskild löneskatt (SLP): only sent when the opt-in is valid for
|
||||
// the row (741x account, no periodisering): a stale flag must never
|
||||
// 400 the submit.
|
||||
...(item.apply_slp &&
|
||||
isSlpPensionAccount(item.account_number) &&
|
||||
!(canUseAccrual && item.accrual_period_start && item.accrual_period_end)
|
||||
? { apply_slp: true }
|
||||
: {}),
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Touched-detection for kontering rows planted by a supplier default or the
|
||||
* counterparty-history prefill (components/supplier-invoices/
|
||||
* NewSupplierInvoiceForm.tsx). A supplier SWITCH un-plants those rows; a row
|
||||
* the user edited since the plant must survive with only the stale account
|
||||
* cleared, never be removed. react-hook-form's dirtyFields is unreliable here
|
||||
* (an appended row is born all-dirty against the array defaults), so the form
|
||||
* snapshots each planted row at plant time and compares against the snapshot.
|
||||
*/
|
||||
|
||||
import type { SupplierInvoiceLineItem } from './form-payload'
|
||||
|
||||
function normalizeDims(dims: Record<string, string> | undefined): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(dims ?? {})) {
|
||||
if (value) out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the row diverged from its plant-time snapshot in any user-editable
|
||||
* field: description, belopp, moms, omvand-moms rate, periodisering,
|
||||
* dimensioner or the SLP opt-in. `account_number` is deliberately excluded:
|
||||
* the caller already keys on it (a changed account means the row is no longer
|
||||
* the planted row at all).
|
||||
*/
|
||||
export function plantedRowTouched(
|
||||
row: SupplierInvoiceLineItem,
|
||||
snapshot: SupplierInvoiceLineItem,
|
||||
): boolean {
|
||||
if ((row.amount || 0) !== (snapshot.amount || 0)) return true
|
||||
if ((row.description || '') !== (snapshot.description || '')) return true
|
||||
if ((row.vat_rate ?? null) !== (snapshot.vat_rate ?? null)) return true
|
||||
if ((row.reverse_charge_rate ?? null) !== (snapshot.reverse_charge_rate ?? null)) return true
|
||||
if ((row.accrual_period_start || '') !== (snapshot.accrual_period_start || '')) return true
|
||||
if ((row.accrual_period_end || '') !== (snapshot.accrual_period_end || '')) return true
|
||||
if ((row.accrual_balance_account || '') !== (snapshot.accrual_balance_account || '')) return true
|
||||
if ((row.apply_slp ?? false) !== (snapshot.apply_slp ?? false)) return true
|
||||
const rowDims = normalizeDims(row.dimensions)
|
||||
const snapDims = normalizeDims(snapshot.dimensions)
|
||||
const keys = new Set([...Object.keys(rowDims), ...Object.keys(snapDims)])
|
||||
for (const key of keys) {
|
||||
if ((rowDims[key] || '') !== (snapDims[key] || '')) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Deep-copies a row so the snapshot cannot alias live form state. */
|
||||
export function snapshotPlantedRow(row: SupplierInvoiceLineItem): SupplierInvoiceLineItem {
|
||||
return {
|
||||
...row,
|
||||
...(row.dimensions ? { dimensions: { ...row.dimensions } } : {}),
|
||||
}
|
||||
}
|
||||
+55
-7
@@ -4146,6 +4146,61 @@
|
||||
"link_failed_title": "Could not link the journal entry"
|
||||
},
|
||||
"supplier_invoice_editor": {
|
||||
"section_underlag": "Source document",
|
||||
"section_supplier": "Supplier",
|
||||
"section_details": "Invoice details",
|
||||
"section_forval": "Defaults",
|
||||
"section_summary": "Summary",
|
||||
"underlag_recommended": "recommended",
|
||||
"underlag_caption_default": "The reader picks up supplier, dates and amounts and suggests the booking. You review before anything is posted.",
|
||||
"underlag_caption_done": "Read: check against the invoice before you register.",
|
||||
"underlag_caption_attached": "Attached as the voucher's source document.",
|
||||
"underlag_caption_inbox": "The inbox document travels with the invoice and is archived on the voucher.",
|
||||
"underlag_drop_prompt": "Drop the invoice here and we fill it in for you",
|
||||
"underlag_uploading": "Uploading…",
|
||||
"underlag_processing": "Reading…",
|
||||
"underlag_remove": "Remove",
|
||||
"underlag_remove_aria": "Remove the document",
|
||||
"underlag_unsupported_type": "Unsupported file type. Allowed formats: PDF, JPEG, PNG and WebP.",
|
||||
"underlag_too_large": "The file is too large. The maximum size is 10 MB.",
|
||||
"underlag_upload_failed": "The document could not be uploaded. Remove the file and try again.",
|
||||
"extraction_ready_line": "Reading done.",
|
||||
"extraction_ready_apply": "Fill in from the document",
|
||||
"mark_selected": "selected",
|
||||
"mark_filled": "filled in",
|
||||
"mark_attached": "attached",
|
||||
"rows_count": "{count, plural, =1 {1 row} other {# rows}}",
|
||||
"org_number_prefix": "Org no {number}",
|
||||
"forval_books_at_registration": "Posted at registration",
|
||||
"forval_books_at_payment": "Posted at payment",
|
||||
"forval_toggle_open": "Change defaults",
|
||||
"forval_toggle_close": "Close",
|
||||
"chip_paid_privately": "Paid privately",
|
||||
"chip_ore_rounding_off": "No öre rounding",
|
||||
"chip_delivery_date": "Delivery date {date}",
|
||||
"chip_notes": "Notes",
|
||||
"chip_currency_rate": "{currency} (rate {rate})",
|
||||
"next_step_prefix": "Next step: ",
|
||||
"next_step_supplier": "choose a supplier",
|
||||
"next_step_invoice_number": "enter the supplier's invoice number",
|
||||
"next_step_add_row": "add a booking row",
|
||||
"next_step_row_account": "choose an account for row {row}",
|
||||
"ready_line_review": "Ready to review: posted as a voucher when you approve.",
|
||||
"ready_line_register": "Ready to register: posted as a voucher right away.",
|
||||
"ready_line_register_cash": "Ready to register: posted at payment.",
|
||||
"crosscheck_label": "Total according to the invoice",
|
||||
"crosscheck_optional": "optional",
|
||||
"crosscheck_match": "Matches the rows.",
|
||||
"crosscheck_diff": "Differs by {diff} from the rows. Check amounts and VAT.",
|
||||
"duplicate_warning_line": "{number} is already registered for this supplier.",
|
||||
"duplicate_warning_link": "View the invoice",
|
||||
"due_from_terms_caption": "From the supplier's terms ({days} days)",
|
||||
"due_on_invoice_caption": "Stated on the invoice",
|
||||
"ocr_payment_file_caption": "Used in the payment file.",
|
||||
"remove_row_named_aria": "Remove row {index}: {description}",
|
||||
"row_no_description": "no description",
|
||||
"ghost_description": "description",
|
||||
"total_payable_label": "Total to pay",
|
||||
"ore_rounding_label": "Öre rounding",
|
||||
"ore_rounding_help": "Round the invoice total to whole kronor",
|
||||
"page_title": "Register supplier invoice",
|
||||
@@ -4162,14 +4217,11 @@
|
||||
"ai_no_org_number": "No organisation number found",
|
||||
"ai_supplier_not_in_system": ": supplier is not yet registered.",
|
||||
"create_and_select": "Create & select",
|
||||
"section_invoice": "Invoice",
|
||||
"section_accounting": "Accounting",
|
||||
"account_from_history": "Account {account} suggested from previous bookings of {counterparty}",
|
||||
"section_other": "Other",
|
||||
"paid_privately_label": "I paid this privately",
|
||||
"paid_privately_help_ef": "Booked as a liability from the company to you (2018 Egen insättning). Refund manually later from the company account.",
|
||||
"paid_privately_help_ab": "Booked as a liability from the company to you (2893 Skuld till ägare). Refund manually later from the company account.",
|
||||
"supplier_label": "Supplier",
|
||||
"supplier_placeholder": "Select supplier",
|
||||
"add_new_supplier": "+ Add new supplier...",
|
||||
"supplier_invoice_number_label": "Supplier's invoice number",
|
||||
@@ -4178,14 +4230,12 @@
|
||||
"due_date_label": "Due date",
|
||||
"payment_reference_label": "OCR / Payment reference",
|
||||
"payment_reference_placeholder": "OCR-nummer",
|
||||
"document_label": "Invoice document",
|
||||
"document_upload_in_progress_title": "The document is still uploading",
|
||||
"document_upload_in_progress_description": "Wait for the upload to finish before registering the invoice.",
|
||||
"document_upload_failed_title": "The document could not be uploaded",
|
||||
"document_upload_failed_description": "Remove the file and try uploading it again.",
|
||||
"document_link_warning_title": "The invoice was registered, but the document is missing from the verifikation",
|
||||
"document_link_warning_description": "Open the verifikation and attach the document there.",
|
||||
"add_row": "Add row",
|
||||
"currency_label": "Currency",
|
||||
"exchange_rate_label": "Exchange rate",
|
||||
"exchange_rate_to_sek": "(to SEK)",
|
||||
@@ -4204,8 +4254,6 @@
|
||||
"col_credit": "Credit",
|
||||
"vat_rate_presets_aria": "Pick VAT rate from list",
|
||||
"description_placeholder": "Description",
|
||||
"row_label": "Row {index}",
|
||||
"remove_row_aria": "Remove row {index}",
|
||||
"row_dimensions_aria": "Cost centre/project for row {index}",
|
||||
"row_dimensions_title": "Cost centre/project",
|
||||
"row_dimensions_inherit_hint": "Empty fields inherit the invoice default ({dims}).",
|
||||
|
||||
+55
-7
@@ -4146,6 +4146,61 @@
|
||||
"link_failed_title": "Kunde inte länka verifikationen"
|
||||
},
|
||||
"supplier_invoice_editor": {
|
||||
"section_underlag": "Underlag",
|
||||
"section_supplier": "Leverantör",
|
||||
"section_details": "Fakturauppgifter",
|
||||
"section_forval": "Förval",
|
||||
"section_summary": "Summering",
|
||||
"underlag_recommended": "rekommenderas",
|
||||
"underlag_caption_default": "Tolkningen läser leverantör, datum och belopp och föreslår kontering. Du kontrollerar innan något bokförs.",
|
||||
"underlag_caption_done": "Tolkat: kontrollera mot fakturan innan du registrerar.",
|
||||
"underlag_caption_attached": "Bifogas som underlag till verifikationen.",
|
||||
"underlag_caption_inbox": "Underlaget från inkorgen följer med och arkiveras på verifikationen.",
|
||||
"underlag_drop_prompt": "Släpp fakturan här så fyller vi i den åt dig",
|
||||
"underlag_uploading": "Laddar upp…",
|
||||
"underlag_processing": "Tolkar…",
|
||||
"underlag_remove": "Ta bort",
|
||||
"underlag_remove_aria": "Ta bort underlaget",
|
||||
"underlag_unsupported_type": "Filtypen stöds inte. Tillåtna format: PDF, JPEG, PNG och WebP.",
|
||||
"underlag_too_large": "Filen är för stor. Maxstorlek är 10 MB.",
|
||||
"underlag_upload_failed": "Underlaget kunde inte laddas upp. Ta bort filen och försök igen.",
|
||||
"extraction_ready_line": "Tolkning klar.",
|
||||
"extraction_ready_apply": "Fyll i från underlaget",
|
||||
"mark_selected": "vald",
|
||||
"mark_filled": "ifyllt",
|
||||
"mark_attached": "bifogat",
|
||||
"rows_count": "{count, plural, =1 {1 rad} other {# rader}}",
|
||||
"org_number_prefix": "Org.nr {number}",
|
||||
"forval_books_at_registration": "Bokförs vid registrering",
|
||||
"forval_books_at_payment": "Bokförs vid betalning",
|
||||
"forval_toggle_open": "Ändra förval",
|
||||
"forval_toggle_close": "Stäng",
|
||||
"chip_paid_privately": "Betald privat",
|
||||
"chip_ore_rounding_off": "Utan öresavrundning",
|
||||
"chip_delivery_date": "Leveransdatum {date}",
|
||||
"chip_notes": "Anteckningar",
|
||||
"chip_currency_rate": "{currency} (kurs {rate})",
|
||||
"next_step_prefix": "Nästa steg: ",
|
||||
"next_step_supplier": "välj leverantör",
|
||||
"next_step_invoice_number": "ange leverantörens fakturanummer",
|
||||
"next_step_add_row": "lägg till en konteringsrad",
|
||||
"next_step_row_account": "välj konto för rad {row}",
|
||||
"ready_line_review": "Klar att granska: bokförs som verifikat när du godkänner.",
|
||||
"ready_line_register": "Klar att registrera: bokförs som verifikat direkt.",
|
||||
"ready_line_register_cash": "Klar att registrera: bokförs vid betalning.",
|
||||
"crosscheck_label": "Totalt enligt fakturan",
|
||||
"crosscheck_optional": "valfritt",
|
||||
"crosscheck_match": "Stämmer mot raderna.",
|
||||
"crosscheck_diff": "Diff {diff} mot raderna. Kontrollera belopp och moms.",
|
||||
"duplicate_warning_line": "{number} är redan registrerad för den här leverantören.",
|
||||
"duplicate_warning_link": "Visa fakturan",
|
||||
"due_from_terms_caption": "Från leverantörens villkor ({days} dagar)",
|
||||
"due_on_invoice_caption": "Står på fakturan",
|
||||
"ocr_payment_file_caption": "Används i betalningsfilen.",
|
||||
"remove_row_named_aria": "Ta bort rad {index}: {description}",
|
||||
"row_no_description": "utan beskrivning",
|
||||
"ghost_description": "beskrivning",
|
||||
"total_payable_label": "Totalt att betala",
|
||||
"ore_rounding_label": "Öresavrundning",
|
||||
"ore_rounding_help": "Avrunda fakturatotal till hel krona",
|
||||
"page_title": "Registrera leverantörsfaktura",
|
||||
@@ -4162,14 +4217,11 @@
|
||||
"ai_no_org_number": "Ingen organisationsnummer hittades",
|
||||
"ai_supplier_not_in_system": ": leverantören finns inte upplagd ännu.",
|
||||
"create_and_select": "Skapa & välj",
|
||||
"section_invoice": "Faktura",
|
||||
"section_accounting": "Kontering",
|
||||
"account_from_history": "Konto {account} föreslaget från tidigare bokföringar av {counterparty}",
|
||||
"section_other": "Övrigt",
|
||||
"paid_privately_label": "Jag har betalat detta privat",
|
||||
"paid_privately_help_ef": "Bokförs som skuld från bolaget till dig (2018 Egen insättning). Återbetalas senare manuellt från företagskontot.",
|
||||
"paid_privately_help_ab": "Bokförs som skuld från bolaget till dig (2893 Skuld till ägare). Återbetalas senare manuellt från företagskontot.",
|
||||
"supplier_label": "Leverantör",
|
||||
"supplier_placeholder": "Välj leverantör",
|
||||
"add_new_supplier": "+ Lägg till ny leverantör...",
|
||||
"supplier_invoice_number_label": "Leverantörens fakturanummer",
|
||||
@@ -4178,14 +4230,12 @@
|
||||
"due_date_label": "Förfallodatum",
|
||||
"payment_reference_label": "OCR / Betalningsreferens",
|
||||
"payment_reference_placeholder": "OCR-nummer",
|
||||
"document_label": "Fakturaunderlag",
|
||||
"document_upload_in_progress_title": "Underlaget laddas fortfarande upp",
|
||||
"document_upload_in_progress_description": "Vänta tills uppladdningen är klar innan du registrerar fakturan.",
|
||||
"document_upload_failed_title": "Underlaget kunde inte laddas upp",
|
||||
"document_upload_failed_description": "Ta bort filen och försök ladda upp den igen.",
|
||||
"document_link_warning_title": "Fakturan registrerades, men underlaget saknas på verifikationen",
|
||||
"document_link_warning_description": "Öppna verifikationen och bifoga underlaget där.",
|
||||
"add_row": "Lägg till rad",
|
||||
"currency_label": "Valuta",
|
||||
"exchange_rate_label": "Växelkurs",
|
||||
"exchange_rate_to_sek": "(till SEK)",
|
||||
@@ -4204,8 +4254,6 @@
|
||||
"col_credit": "Kredit",
|
||||
"vat_rate_presets_aria": "Välj momssats från lista",
|
||||
"description_placeholder": "Beskrivning",
|
||||
"row_label": "Rad {index}",
|
||||
"remove_row_aria": "Ta bort rad {index}",
|
||||
"row_dimensions_aria": "Kostnadsställe/Projekt för rad {index}",
|
||||
"row_dimensions_title": "Kostnadsställe/Projekt",
|
||||
"row_dimensions_inherit_hint": "Tomma fält ärver fakturans standard ({dims}).",
|
||||
|
||||
Reference in New Issue
Block a user