diff --git a/DECISIONS.md b/DECISIONS.md index f8771eb9..fb3fd4ff 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -817,3 +817,6 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-06] Added guardSandbox to /api/salary/runs/[id]/payslips/send: it was the only send path without one, and seeding a booked salary run put "Skicka lönebesked" one click from an anonymous visitor with live Resend behind it. [2026-08-06] Login credentials error says "Fel e-postadress eller lösenord", not "Fel lösenord": GoTrue returns one invalid_credentials code for unknown-email and wrong-password alike (anti-enumeration), so a "wrong password" claim would be both unknowable and an account-existence leak. Clarity comes from inline placement + reset link instead. [2026-08-06] Empty SIE file (0 parsed vouchers AND no raw #VER declaration) finalizes as completed no-op, not failed: Fortnox exports an empty file for a not-yet-booked fiscal year and failing it aborted the whole migration wizard (CashLeads case). The failed-downgrade now fires only when the file contained vouchers that could not be imported; the raw-content #VER cross-check must stay, since a separator/encoding mismatch can swallow every #VER block with only a warning-severity parse issue and would otherwise masquerade as a legitimate empty year. The balance-only continuation-guard scenario rides along as no-op since re-running the same file can never produce a different outcome. +[2026-08-06] Bucket A defaults pass commits the /pending Godkänn pill directly for low/medium risk and keeps the ConfirmationDialog only for high risk: the Granskning row already states source, title, risk and offers Detaljer, so the dialog's second Godkänn restated the row (the audit's expert lens called double-Godkänn the thing professionals do not tolerate). The chat-side "Godkänn alla N" was DEFERRED, not built: ApprovalCard owns its whole state machine internally (commit fetch, account-activation retry, typed high-risk confirm) and a bulk commit from AgentChat would leave committed cards rendering as pending; that is assistant-redesign seam 8.8 (approval batching) and needs the state lifted, not a button. +[2026-08-06] SIE-export period default left unchanged despite the choice-audit finding: FiscalYearSelector with includeAllOption=false already auto-selects the newest started period once loaded, so the "opens with nothing selected" claim is only fetch latency. FyPicker gained preferLatestEnded for helårsmoms instead, which also skips the shared per-company localStorage scope on that surface: a filing page defaulting to the current (unfilable) year because Balansräkningen was last viewed there is the one wrong default. +[2026-08-06] Review-workflow triage on the Bucket A branch (13 confirmed findings): fixed 10, incl. the branch-killing one (setActiveCompany's cookie write throws in Server Component render, so the /select-company auto-forward silently never fired: the cookie set is now best-effort because the gnubok-company-id cookie is write-only compat nothing reads). Batch "Ingen moms" now goes over the wire as 'exempt' instead of collapsing to undefined, which had an explicit no-VAT choice booking the derived 25%; the same pre-existing collapse in QuickReviewDialog/CategoryExpandedDialog is left for a follow-up. Skipped by choice: generalizing AiFilledIndicator for history provenance (the note's copy already names the source) and converting BulkBookInboxDialog's hardcoded-Swedish option lists to i18n (whole-file migration, not this branch's divergence). Monthly momsdeklaration default is deadline-aware (M-2 until the 12th/17th, M-1 after; over-40M always M-1) mirroring deadline-config, not just calendar-ended. diff --git a/app/(dashboard)/pending/page.tsx b/app/(dashboard)/pending/page.tsx index a0fefdae..0e2c48ae 100644 --- a/app/(dashboard)/pending/page.tsx +++ b/app/(dashboard)/pending/page.tsx @@ -854,19 +854,32 @@ export default function PendingOperationsPage() { setSelectedIds(new Set()) }, [activeTab, sourceFilter, conversationFilter]) - async function handleCommit() { - if (!selectedOp) return + // Shared by the direct-commit pill (low/medium risk) and the high-risk + // confirmation dialog. The Granskning row already states source, title and + // risk and offers Detaljer, so for low/medium the pill IS the deliberate + // approval; only high risk keeps the dialog, whose warning sentence carries + // information the row does not. + async function commitOp(op: PendingOperation) { setIsCommitting(true) try { - const res = await fetch(`/api/pending-operations/${selectedOp.id}/commit`, { method: 'POST' }) + const res = await fetch(`/api/pending-operations/${op.id}/commit`, { method: 'POST' }) const json = await res.json().catch(() => ({})) // getErrorMessage handles both `{ error: string }` and the structured // `{ error: { code, message } }` envelope (the latter would otherwise // toast "[object Object]") and never surfaces raw English. if (!res.ok) throw new Error(getErrorMessage(json, { statusCode: res.status })) - toast({ title: 'Godkänd', description: selectedOp.title }) + toast({ title: 'Godkänd', description: op.title }) setShowCommitDialog(false) setSelectedOp(null) + // Drop the committed op from the bulk selection: the row leaves the + // pending list on refresh, but a stale id would keep inflating the + // bulk bar and ride along into bulk-commit. + setSelectedIds((prev) => { + if (!prev.has(op.id)) return prev + const next = new Set(prev) + next.delete(op.id) + return next + }) fetchOperations() } catch (err) { toast({ @@ -878,6 +891,11 @@ export default function PendingOperationsPage() { setIsCommitting(false) } + async function handleCommit() { + if (!selectedOp) return + await commitOp(selectedOp) + } + async function handleBulkCommit(ids: string[]) { if (ids.length === 0) return setIsBulkCommitting(true) @@ -1421,8 +1439,17 @@ export default function PendingOperationsPage() { onClick={(e) => { e.stopPropagation() if (periodLocked) return - setSelectedOp(op) - setShowCommitDialog(true) + if (op.risk_level === 'high') { + // High risk keeps the confirmation dialog: its + // warning sentence carries real information. + setSelectedOp(op) + setShowCommitDialog(true) + } else { + // Low/medium: the pill on the review row is the + // approval; a second Godkann in a dialog restated + // what the row already shows. + void commitOp(op) + } }} > @@ -1547,8 +1574,15 @@ export default function PendingOperationsPage() { disabled={detailPeriodLocked || isCommitting} title={detailPeriodLocked ? 'Perioden är låst' : undefined} onClick={() => { - setSelectedOp(detailOp) - setShowCommitDialog(true) + // Same risk gate as the review-row pill: the detail + // panel already shows the full preview, so low/medium + // commit directly; only high risk keeps the dialog. + if (detailOp.risk_level === 'high') { + setSelectedOp(detailOp) + setShowCommitDialog(true) + } else { + void commitOp(detailOp) + } }} > {t('approve')} diff --git a/app/(onboarding)/select-company/page.tsx b/app/(onboarding)/select-company/page.tsx index fcdc2258..62891d07 100644 --- a/app/(onboarding)/select-company/page.tsx +++ b/app/(onboarding)/select-company/page.tsx @@ -1,6 +1,7 @@ import { cookies } from 'next/headers' import { redirect } from 'next/navigation' import { createClient, createServiceClient } from '@/lib/supabase/server' +import { setActiveCompany } from '@/lib/company/context' import { acceptPendingInviteByToken, hasPendingInviteForEmail, @@ -15,7 +16,11 @@ export const dynamic = 'force-dynamic' const ENRICHMENT_TTL_DAYS = 7 -export default async function SelectCompanyPage() { +export default async function SelectCompanyPage({ + searchParams, +}: { + searchParams: Promise<{ choose?: string }> +}) { const supabase = await createClient() const { data: { user } } = await supabase.auth.getUser() @@ -182,6 +187,30 @@ export default async function SelectCompanyPage() { ? Date.now() - new Date(enrichmentTimestamp).getTime() > ENRICHMENT_TTL_DAYS * 24 * 60 * 60 * 1000 : false + // A member of exactly one company with nothing else to decide (no new TIC + // engagements, no pending invite, enrichment not stale enough to hide one) + // gets sent straight in instead of clicking the only row on every login. + // `?choose=1` (the in-app switcher links) always renders the picker, and + // multi-company/byra users are untouched. + const { choose } = await searchParams + if ( + !choose && + memberCompanies.length === 1 && + ticCompanies.length === 0 && + !hasPendingInvite && + !enrichmentStale + ) { + // redirect() throws NEXT_REDIRECT, so it must stay outside the try. + let switched = false + try { + await setActiveCompany(supabase, user.id, memberCompanies[0].id) + switched = true + } catch { + // Fall through to the picker: rendering it is always safe. + } + if (switched) redirect('/') + } + return ( ({ requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), })) -import { DELETE } from '../route' +const findCounterpartyTemplateMock = vi.fn() +vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({ + findCounterpartyTemplate: (...args: unknown[]) => findCounterpartyTemplateMock(...args), +})) + +import { GET, DELETE } from '../route' + +describe('GET /api/settings/counterparty-templates', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) + }) + + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const request = createMockRequest('/api/settings/counterparty-templates') + const response = await GET(request, { params: Promise.resolve({}) }) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(401) + }) + + it('lists active templates without a counterparty param', async () => { + enqueue({ data: [{ id: 't1', counterparty_name: 'anthropic' }], error: null }) + + const request = createMockRequest('/api/settings/counterparty-templates') + const response = await GET(request, { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ data: Array<{ id: string }> }>(response) + + expect(status).toBe(200) + expect(body.data).toHaveLength(1) + expect(findCounterpartyTemplateMock).not.toHaveBeenCalled() + }) + + it('runs the tiered matcher against a name probe in counterparty mode', async () => { + findCounterpartyTemplateMock.mockResolvedValue({ + template: { id: 't1', counterparty_name: 'circle k', debit_account: '5611', credit_account: '1930' }, + matchMethod: 'exact_normalized', + confidence: 0.9, + }) + + const request = createMockRequest('/api/settings/counterparty-templates?counterparty=Circle%20K') + const response = await GET(request, { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ + data: { template: { debit_account: string }; match_method: string; confidence: number } + }>(response) + + expect(status).toBe(200) + expect(body.data.template.debit_account).toBe('5611') + expect(body.data.match_method).toBe('exact_normalized') + expect(body.data.confidence).toBe(0.9) + const probe = findCounterpartyTemplateMock.mock.calls[0][2] as { description: string } + expect(probe.description).toBe('Circle K') + }) + + it('returns null data when the matcher finds nothing', async () => { + findCounterpartyTemplateMock.mockResolvedValue(null) + + const request = createMockRequest('/api/settings/counterparty-templates?counterparty=Unknown') + const response = await GET(request, { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ data: null }>(response) + + expect(status).toBe(200) + expect(body.data).toBeNull() + }) + + it('rejects an oversized counterparty name', async () => { + const request = createMockRequest( + `/api/settings/counterparty-templates?counterparty=${'a'.repeat(201)}` + ) + const response = await GET(request, { params: Promise.resolve({}) }) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(400) + expect(findCounterpartyTemplateMock).not.toHaveBeenCalled() + }) +}) describe('DELETE /api/settings/counterparty-templates', () => { beforeEach(() => { diff --git a/app/api/settings/counterparty-templates/route.ts b/app/api/settings/counterparty-templates/route.ts index d61a94e5..0194b4d4 100644 --- a/app/api/settings/counterparty-templates/route.ts +++ b/app/api/settings/counterparty-templates/route.ts @@ -1,10 +1,32 @@ import { NextResponse } from 'next/server' import { withRouteContext } from '@/lib/api/with-route-context' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' +import { findCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates' +import type { Transaction } from '@/types' export const GET = withRouteContext( 'counterparty_template.list', - async (_request, { supabase, companyId }) => { + async (request, { supabase, companyId }) => { + // ?counterparty= switches to single-match mode: run the same + // tiered matcher (alias / normalized / token-subset / fuzzy) the booking + // flows use, against a name instead of a transaction. The matcher only + // reads `merchant_name || description` and `id` off the transaction, so a + // probe object is sufficient; building a name-based variant of the matcher + // here would just drift from the real one. + const counterparty = new URL(request.url).searchParams.get('counterparty')?.trim() + if (counterparty) { + if (counterparty.length > 200) { + return NextResponse.json({ error: 'counterparty too long' }, { status: 400 }) + } + const probe = { id: 'probe', merchant_name: null, description: counterparty } as unknown as Transaction + const match = await findCounterpartyTemplate(supabase, companyId, probe) + return NextResponse.json({ + data: match + ? { template: match.template, match_method: match.matchMethod, confidence: match.confidence } + : null, + }) + } + const { data, error } = await supabase .from('categorization_templates') .select('*') diff --git a/components/bookkeeping/JournalEntryForm.tsx b/components/bookkeeping/JournalEntryForm.tsx index 0c7f06c5..bb289c39 100644 --- a/components/bookkeeping/JournalEntryForm.tsx +++ b/components/bookkeeping/JournalEntryForm.tsx @@ -1347,32 +1347,21 @@ export default function JournalEntryForm({
- {embedded ? ( -
- - -
- ) : ( - selectedPeriodObj && ( - - {t('fiscal_year')}:{' '} - {selectedPeriodObj.name} - {nextVoucherNumber != null && ( - - {voucherSeries}{nextVoucherNumber} - - )} - - ) + {/* The period is a total function of the entry date (Swedish fiscal + periods never overlap), so it renders as derived text in both + variants. The embedded Select this replaces allowed hand-picking + a period that disagreed with the date, which only the DB period + trigger would catch. */} + {selectedPeriodObj && ( + + {t('fiscal_year')}:{' '} + {selectedPeriodObj.name} + {nextVoucherNumber != null && ( + + {voucherSeries}{nextVoucherNumber} + + )} + )}
diff --git a/components/common/FyPicker.tsx b/components/common/FyPicker.tsx index df7b1324..f863ae82 100644 --- a/components/common/FyPicker.tsx +++ b/components/common/FyPicker.tsx @@ -22,6 +22,14 @@ interface FyPickerProps { includeAllOption?: boolean /** Only show periods that have started (Reports-style filter). */ hideFuturePeriods?: boolean + /** + * Auto-select the most recently ENDED period on load instead of restoring + * the shared per-company scope or falling back to the newest started one. + * For filing surfaces (helårsmoms): only an ended räkenskapsår can be + * declared, so the newest started period is the one default that is always + * wrong there. Manual picks still work and are still persisted. + */ + preferLatestEnded?: boolean /** Fires once after the initial period load completes. */ onReady?: () => void /** Server-loaded periods for the first render, scoped to initialCompanyId. */ @@ -49,6 +57,7 @@ export function FyPicker({ onChange, includeAllOption = true, hideFuturePeriods = false, + preferLatestEnded = false, onReady, initialPeriods, initialCompanyId, @@ -92,14 +101,22 @@ export function FyPicker({ // Restore last selection (same key as FiscalYearSelector so pages keep // their scope when the picker swaps in). if (value === null && typeof window !== 'undefined') { - const stored = window.localStorage.getItem(STORAGE_KEY_PREFIX + company.id) - if (stored === ALL_YEARS_VALUE) { - if (includeAllOption) onChange(null, null) - else if (fetched.length > 0) onChange(fetched[0].id, fetched[0]) - } else if (stored && fetched.some((p) => p.id === stored)) { - onChange(stored, fetched.find((p) => p.id === stored) ?? null) - } else if (!includeAllOption && fetched.length > 0) { - onChange(fetched[0].id, fetched[0]) + if (preferLatestEnded) { + // Filing surfaces: ignore the shared scope memory and open on the + // most recently ended period (fetched is sorted newest-first). + const today = new Date().toISOString().split('T')[0] + const pick = fetched.find((p) => p.period_end < today) ?? fetched[0] + if (pick) onChange(pick.id, pick) + } else { + const stored = window.localStorage.getItem(STORAGE_KEY_PREFIX + company.id) + if (stored === ALL_YEARS_VALUE) { + if (includeAllOption) onChange(null, null) + else if (fetched.length > 0) onChange(fetched[0].id, fetched[0]) + } else if (stored && fetched.some((p) => p.id === stored)) { + onChange(stored, fetched.find((p) => p.id === stored) ?? null) + } else if (!includeAllOption && fetched.length > 0) { + onChange(fetched[0].id, fetched[0]) + } } } @@ -111,7 +128,7 @@ export function FyPicker({ // onReady is a lifecycle callback: fire once per load, not on parent // re-renders that re-create it. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [company?.id, hideFuturePeriods, includeAllOption, initialCompanyId, initialPeriods]) + }, [company?.id, hideFuturePeriods, includeAllOption, preferLatestEnded, initialCompanyId, initialPeriods]) const handleChange = (id: string) => { const nextId = id === ALL_YEARS_VALUE ? null : id diff --git a/components/dashboard/CompanySwitcher.tsx b/components/dashboard/CompanySwitcher.tsx index 6052bde4..d82ea461 100644 --- a/components/dashboard/CompanySwitcher.tsx +++ b/components/dashboard/CompanySwitcher.tsx @@ -102,7 +102,7 @@ export default function CompanySwitcher() { if (isSandbox) return null return ( @@ -182,7 +182,7 @@ export default function CompanySwitcher() { {!isSandbox && (
0 && 'border-t border-border/40 mt-1 pt-1', 'px-1')}> setOpen(false)} className="flex items-center gap-2 px-2.5 py-2 text-[13px] text-muted-foreground hover:text-foreground hover:bg-muted/40 rounded-md transition-colors md:whitespace-nowrap" > diff --git a/components/dashboard/UserMenu.tsx b/components/dashboard/UserMenu.tsx index 747e91fe..ce428444 100644 --- a/components/dashboard/UserMenu.tsx +++ b/components/dashboard/UserMenu.tsx @@ -306,7 +306,7 @@ export default function UserMenu({
{!sandbox && (
- + {tSwitcher('add_company')} diff --git a/components/extensions/general/BookDirectlyDialog.tsx b/components/extensions/general/BookDirectlyDialog.tsx index d1df8345..71f9f7d3 100644 --- a/components/extensions/general/BookDirectlyDialog.tsx +++ b/components/extensions/general/BookDirectlyDialog.tsx @@ -12,7 +12,6 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Badge } from '@/components/ui/badge' import { useToast } from '@/components/ui/use-toast' import { Loader2, Plus, Trash2, AlertTriangle, Search, Check, BookmarkPlus } from 'lucide-react' @@ -33,6 +32,8 @@ import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils' import { resolveAccount } from '@/lib/cash-accounts/resolve-account' import { renderChannelContextNotes } from '@/lib/documents/channel-context-notes' +import { formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates' +import { AttnLine } from '@/components/ui/attn-line' import type { BASAccount, BookingTemplateLibrary, CashAccount, FiscalPeriod, InboxChannelContext, InvoiceExtractionResult } from '@/types' interface InboxItem { @@ -243,6 +244,46 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl = // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, item.id]) + // Cost-account prefill from the company's own booking history for this + // supplier (counterparty templates). Fills only the first line's still-empty + // account: never a generic seed (the old silent-'5010' incident is the + // reason there is no fallback), never over anything the user typed, and only + // for expense-shaped templates (cost on debit, settlement on credit) so an + // income template can't plant a revenue account on a purchase. + const [accountSuggestion, setAccountSuggestion] = useState<{ account: string; counterparty: string } | null>(null) + useEffect(() => { + if (!open) return + setAccountSuggestion(null) + const supplier = item.extracted_data?.supplier?.name?.trim() + if (!supplier) return + let cancelled = false + ;(async () => { + try { + const res = await fetch( + `/api/settings/counterparty-templates?counterparty=${encodeURIComponent(supplier)}` + ) + if (!res.ok) return + const json = await res.json() + if (cancelled) return + const match = json?.data + const debit: string | undefined = match?.template?.debit_account + const credit: string | undefined = match?.template?.credit_account + if (!match || (match.confidence ?? 0) < 0.5) return + // P&L cost on debit (4xxx-8xxx), settlement on credit: keeps private + // and balance-sheet templates (2013, 1630, 12xx) out of a cost field. + if (!debit || !/^[4-8]/.test(debit) || !credit || !credit.startsWith('19')) return + setLines((current) => { + if (!current[0] || current[0].account_number) return current + return current.map((l, i) => (i === 0 ? { ...l, account_number: debit } : l)) + }) + setAccountSuggestion({ account: debit, counterparty: match.template.counterparty_name }) + } catch { + // Prefill is best-effort; the field simply stays blank. + } + })() + return () => { cancelled = true } + }, [open, item.id, item.extracted_data?.supplier?.name]) + // Fetch the underlag's SEK rate for a foreign-currency document so candidate // transactions can be ranked against the SEK-equivalent total (and not the // raw foreign number). SEK / unsupported currencies skip the fetch. @@ -376,18 +417,18 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl = return () => { cancelled = true } }, [open]) - // Auto-select fiscal period matching the entry date + // Derive the fiscal period from the entry date. Periods never overlap, so + // this is a total function of the date; when the date falls outside every + // period the id clears and submit is blocked with an explanation. The old + // else-branch silently borrowed periods[0], which could book into the wrong + // period with only the DB period trigger left to catch it. useEffect(() => { if (periods.length === 0) return const match = periods.find( (p) => entryDate >= p.period_start && entryDate <= p.period_end ) - if (match) { - setPeriodId(match.id) - } else if (!periodId && periods.length > 0) { - setPeriodId(periods[0].id) - } - }, [entryDate, periods, periodId]) + setPeriodId(match ? match.id : '') + }, [entryDate, periods]) // Fetch unmatched transactions whenever the dialog opens: the picker // is always visible now (selection is optional). @@ -503,15 +544,22 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl = [], ) + const derivedPeriod = useMemo( + () => periods.find((p) => p.id === periodId) ?? null, + [periods, periodId], + ) + const derivedPeriodBlocked = !!(derivedPeriod?.locked_at || derivedPeriod?.is_closed) + const disabledReason = useMemo(() => { if (isSubmitting) return null if (!entryDate) return 'Välj datum' - if (!periodId) return 'Välj räkenskapsperiod' + if (!periodId) return 'Datumet matchar ingen öppen räkenskapsperiod' + if (derivedPeriodBlocked) return 'Räkenskapsperioden är låst eller stängd' if (description.trim().length === 0) return 'Fyll i beskrivning' if (lines.some((l) => l.account_number.trim().length === 0)) return 'Alla rader behöver ett konto' if (!totals.balanced) return 'Debet och kredit måste vara lika' return null - }, [isSubmitting, entryDate, periodId, description, lines, totals.balanced]) + }, [isSubmitting, entryDate, periodId, derivedPeriodBlocked, description, lines, totals.balanced]) const canSubmit = !isSubmitting && disabledReason === null @@ -619,31 +667,25 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl = />
- - + + {/* Derived from the entry date (periods never overlap): text, + not a picker, so it can never disagree with the date. */} + {periods.length === 0 ? ( +

Hämtar perioder …

+ ) : derivedPeriod ? ( +

+ {derivedPeriod.period_start}: {derivedPeriod.period_end} + {(derivedPeriod.locked_at || derivedPeriod.is_closed) && ( + + {' '}({derivedPeriod.locked_at ? 'låst' : 'stängd'}) + + )} +

+ ) : ( + + Datumet ligger utanför öppna räkenskapsperioder. Ändra datumet eller skapa perioden under Bokföring. + + )}
@@ -792,6 +834,13 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl = lägg till en rad för omvänd skattskyldighet manuellt.

)} + {accountSuggestion && lines[0]?.account_number === accountSuggestion.account && ( +

+ + Konto {accountSuggestion.account} föreslaget från tidigare bokföringar av{' '} + {formatCounterpartyName(accountSuggestion.counterparty)} +

+ )}
diff --git a/components/extensions/general/BulkBookInboxDialog.tsx b/components/extensions/general/BulkBookInboxDialog.tsx index b6c7cb22..4d691e47 100644 --- a/components/extensions/general/BulkBookInboxDialog.tsx +++ b/components/extensions/general/BulkBookInboxDialog.tsx @@ -71,7 +71,11 @@ const CATEGORY_OPTIONS: { value: string; label: string }[] = [ // here by `reduced_12` / `reduced_6`: there is deliberately no `standard_12` / // `standard_6` (no such treatment exists; the backend would reject it). Keep // this list in sync with the union, not with rate labels. -const VAT_OPTIONS: { value: VatTreatment; label: string }[] = [ +const VAT_OPTIONS: { value: VatTreatment | 'auto'; label: string }[] = [ + // 'auto' sends no explicit treatment: the bulk-book route derives the + // default from the picked category (exempt for bank/card fees, 12% + // representation, else 25%). Reverse charge is never derived; see below. + { value: 'auto', label: 'Enligt kategori' }, { value: 'standard_25', label: 'Moms 25%' }, { value: 'reduced_12', label: 'Moms 12%' }, { value: 'reduced_6', label: 'Moms 6%' }, @@ -88,7 +92,7 @@ export default function BulkBookInboxDialog({ open, onOpenChange, items, onSucce const { toast } = useToast() const t = useTranslations('inbox_bulk_book') const [category, setCategory] = useState('') - const [vatTreatment, setVatTreatment] = useState('standard_25') + const [vatTreatment, setVatTreatment] = useState('auto') const [isSubmitting, setIsSubmitting] = useState(false) const bookable = useMemo(() => items.filter(isBookable), [items]) @@ -101,15 +105,16 @@ export default function BulkBookInboxDialog({ open, onOpenChange, items, onSucce [items], ) - // Reset to the safe default (25% svensk moms) each time the dialog opens. + // Reset to the category-derived default each time the dialog opens. // Currency is deliberately NOT used to preselect omvänd skattskyldighet: a // foreign currency does not imply a foreign seller: a Swedish supplier can // invoice in EUR and still debit 25% moms. Reverse charge is a property of - // the seller (utländsk, utan svenskt momsnr), never of the currency, so - // defaulting to it from currency alone would silently mis-book domestic VAT. - // The advisory rendered under the Moms picker spells this out to the user. + // the seller (utländsk, utan svenskt momsnr), never of the currency, and the + // server-side derivation never produces it either, so it is only ever an + // explicit user choice. The advisory rendered under the Moms picker spells + // this out to the user. useEffect(() => { - if (open) setVatTreatment('standard_25') + if (open) setVatTreatment('auto') }, [open]) // Underlag subtotals, split per currency. This used to be a single scalar @@ -140,7 +145,7 @@ export default function BulkBookInboxDialog({ open, onOpenChange, items, onSucce body: JSON.stringify({ item_ids: bookable.map((it) => it.id), category, - vat_treatment: vatTreatment, + ...(vatTreatment !== 'auto' ? { vat_treatment: vatTreatment } : {}), }), }) const json = await res.json().catch(() => ({})) @@ -217,7 +222,7 @@ export default function BulkBookInboxDialog({ open, onOpenChange, items, onSucce } /> - setVatTreatment(v as VatTreatment | 'auto')}> diff --git a/components/reports/views/index.tsx b/components/reports/views/index.tsx index 1710454a..93addaad 100644 --- a/components/reports/views/index.tsx +++ b/components/reports/views/index.tsx @@ -16,6 +16,7 @@ import { AlertCircle, Check, ChevronDown, ChevronRight, ExternalLink, FileCode, import { Skeleton } from '@/components/ui/skeleton' import { EmptyState } from '@/components/ui/empty-state' import { FyPicker } from '@/components/common/FyPicker' +import { mostRecentEndedVatPeriod } from '@/lib/vat/period-defaults' import { ContextPicker } from '@/components/common/ContextPicker' import { cn, formatDate } from '@/lib/utils' import { getErrorMessage } from '@/lib/errors/get-error-message' @@ -1489,6 +1490,77 @@ const MONTH_NAMES = [ ] const QUARTER_SPANS = ['jan-mar', 'apr-jun', 'jul-sep', 'okt-dec'] +// Inline momsperiod setup for the "registered but no period picked" state. +// Writes through the same PUT /api/settings validation as the tax settings +// form (SFL 26 kap coherence rules included), so this is a shortcut, not a +// second write path. Until a period exists the deadline engine generates NO +// VAT deadlines at all, silently, which is why this state answers inline +// instead of bouncing to settings. +function MomsPeriodInlineSetup({ + onSaved, +}: { + onSaved: (value: 'monthly' | 'quarterly' | 'yearly') => Promise | void +}) { + const [saving, setSaving] = useState(null) + const [error, setError] = useState(null) + + const choose = async (value: 'monthly' | 'quarterly' | 'yearly') => { + if (saving) return + setSaving(value) + setError(null) + try { + const res = await fetch('/api/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ moms_period: value }), + }) + if (!res.ok) { + const json = await res.json().catch(() => ({})) + setError(getErrorMessage(json, { statusCode: res.status })) + return + } + await onSaved(value) + } catch (err) { + setError(getErrorMessage(err)) + } finally { + setSaving(null) + } + } + + const options: { value: 'monthly' | 'quarterly' | 'yearly'; label: string }[] = [ + { value: 'quarterly', label: 'Varje kvartal' }, + { value: 'monthly', label: 'Varje månad' }, + { value: 'yearly', label: 'Helår' }, + ] + + return ( +
+
+ {options.map((opt) => ( + + ))} +
+ {error && ( +

+ {error} +

+ )} +
+ ) +} + export function VatDeclarationView({ pageTitle }: { pageTitle?: string } = {}) { const currentYear = new Date().getFullYear() const currentMonth = new Date().getMonth() + 1 @@ -1536,16 +1608,25 @@ export function VatDeclarationView({ pageTitle }: { pageTitle?: string } = {}) { // switch re-applies the new company's setting. `useCompanySettings` only // refetches when the active company changes, so this never clobbers a // manual selection mid-session. - const { settings, isLoading: settingsLoading } = useCompanySettings() + const { settings, isLoading: settingsLoading, refetch: refetchSettings } = useCompanySettings() const [appliedCompany, setAppliedCompany] = useState(null) const companyKey = settingsLoading ? null : (settings?.company_id ?? 'none') if (companyKey !== null && appliedCompany !== companyKey) { setAppliedCompany(companyKey) const configured = settings?.moms_period ?? 'quarterly' setPeriodType(configured) - setPeriod( - configured === 'monthly' ? currentMonth : configured === 'quarterly' ? currentQuarter : 1, - ) + if (configured === 'monthly' || configured === 'quarterly') { + // Default to the period whose declaration is actually open: the current + // one can never be filed, so seeding it forced a step-back click on + // every filing visit (and a year-boundary trap in January). + const ended = mostRecentEndedVatPeriod(configured, new Date(), { + over40m: settings?.vat_taxable_base_over_40m === true, + }) + setYear(ended.year) + setPeriod(ended.period) + } else { + setPeriod(1) + } } // Settings row present and the company answered "not VAT-registered" — @@ -1555,12 +1636,21 @@ export function VatDeclarationView({ pageTitle }: { pageTitle?: string } = {}) { // requires it, but companies created outside that flow can miss it). const momsPeriodMissing = settings?.vat_registered === true && !settings.moms_period - // Switching periodicity resets the period to "now" in the new unit. Done in - // the change handler (not an effect) so the auto-fetch below never sees an - // inconsistent periodType/period pair. + // Switching periodicity resets the period to the most recently ended one in + // the new unit (same default as first load: the current period can never be + // filed). Done in the change handler (not an effect) so the auto-fetch below + // never sees an inconsistent periodType/period pair. const handlePeriodTypeChange = (value: VatPeriodType) => { setPeriodType(value) - setPeriod(value === 'monthly' ? currentMonth : value === 'quarterly' ? currentQuarter : 1) + if (value === 'monthly' || value === 'quarterly') { + const ended = mostRecentEndedVatPeriod(value, new Date(), { + over40m: settings?.vat_taxable_base_over_40m === true, + }) + setYear(ended.year) + setPeriod(ended.period) + } else { + setPeriod(1) + } } // Annual VAT (helårsmoms) is reported per räkenskapsår, not per calendar year. @@ -1764,18 +1854,55 @@ export function VatDeclarationView({ pageTitle }: { pageTitle?: string } = {}) { // Registered but no redovisningsperiod picked: block instead of guessing. // A declaration rendered (and submittable via panelen) for the wrong - // period type is a compliance hazard, not a convenience. + // period type is a compliance hazard, not a convenience. But the answer is + // collected HERE, inline: until it exists the deadline engine generates no + // VAT deadlines at all (silently), so bouncing the user to settings left a + // compliance hole open longer than it needed to be. When vat_number is ALSO + // missing, the inline save would 400 on the vat_number coherence rule in + // PUT /api/settings (momsregistrerad requires a registreringsnummer), so + // that (rarer) state keeps the settings bounce, which has both fields. if (momsPeriodMissing) { + if (!settings?.vat_number) { + return ( +
+ {bareHeader} + +
+ ) + } return (
{bareHeader} + title="Välj redovisningsperiod för moms" + description="Företaget är momsregistrerat men ingen redovisningsperiod är vald, så deklarationen och momsdeadlines kan inte visas. Perioden står i registreringsbeslutet från Skatteverket." + > +
+ { + await refetchSettings() + // The first-settle seeding above only runs once per company, + // so re-apply the fresh periodicity (and its most-recent- + // ended default period) by hand. + handlePeriodTypeChange(value) + }} + /> +

+ Du kan alltid ändra den i{' '} + + skatteinställningarna + + . +

+
+
) } @@ -1851,6 +1978,7 @@ export function VatDeclarationView({ pageTitle }: { pageTitle?: string } = {}) { }} includeAllOption={false} hideFuturePeriods + preferLatestEnded /> ) : ( (null) + // Rows planted by the counterparty-history prefill, so a supplier SWITCH can + // un-plant them: without this, supplier A's history account survives into + // supplier B's invoice and silently blocks B's own default_expense_account + // (the fill branches only touch empty rows). + const plantedRef = useRef<{ account: string; rows: number[] } | null>(null) + // Automatic fill is requested, not applied inline: handleAccountChange + // needs the loaded BAS chart to apply the konto's default moms, and the + // requests originate in closures (the supplier effect and its async + // template fetch) that may hold a stale empty `accounts`. The applying + // effect below re-runs on both the request tick and the chart load with + // fresh closures, so whichever arrives last triggers the fill. Filling + // early would leave a VAT-free konto on the 25% row default, the exact + // mis-booking the fill exists to prevent. + const pendingAccountFillRef = useRef<{ account: string; plant: boolean; counterparty?: string } | null>(null) + const [accountFillTick, setAccountFillTick] = useState(0) + + function requestAccountFill(account: string, plant: boolean, counterparty?: string) { + pendingAccountFillRef.current = { account, plant, counterparty } + setAccountFillTick((t) => t + 1) + } + + useEffect(() => { + if (accounts.length === 0 || !pendingAccountFillRef.current) return + const { account, plant, counterparty } = pendingAccountFillRef.current + pendingAccountFillRef.current = null + const items = getValues('items') + const appliedRows: number[] = [] + items.forEach((row, i) => { + if (!row.account_number) { + // Same path as a manual pick: konto default moms rides along. + handleAccountChange(i, account) + appliedRows.push(i) + } + }) + if (appliedRows.length > 0 && plant && counterparty) { + plantedRef.current = { account, rows: appliedRows } + setTemplateAccountNote({ account, counterparty }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [accounts, accountFillTick]) + useEffect(() => { if (!watchedSupplierId) return const supplier = suppliers.find((s) => s.id === watchedSupplierId) if (!supplier) return + setTemplateAccountNote(null) + pendingAccountFillRef.current = null + if (plantedRef.current) { + const { account, rows } = plantedRef.current + const planted = getValues('items') + rows.forEach((i) => { + if (planted[i]?.account_number === account) { + // Clear only the account; the rate is left for the next fill or + // manual pick to settle (handleAccountChange reapplies konto + // defaults), so an AI-extracted rate is never clobbered here. + setValue(`items.${i}.account_number`, '') + } + }) + plantedRef.current = null + } const invoiceDate = watch('invoice_date') const currentDue = watch('due_date') @@ -625,12 +683,9 @@ export default function NewSupplierInvoiceForm({ if (supplier.default_expense_account && fields.length > 0) { // Fill every row the user hasn't assigned yet: an empty account is the // only signal needed (rows start empty by design, no seeded default). - const items = getValues('items') - items.forEach((row, i) => { - if (!row.account_number) { - setValue(`items.${i}.account_number`, supplier.default_expense_account!) - } - }) + // Routed through the fill request so it waits for the BAS chart and the + // konto's default moms comes along exactly like a manual pick. + requestAccountFill(supplier.default_expense_account, false) } if (supplier.default_currency && watch('currency') === 'SEK') { setValue('currency', supplier.default_currency) @@ -638,6 +693,36 @@ export default function NewSupplierInvoiceForm({ if (supplier.supplier_type === 'eu_business') { setValue('reverse_charge', true) } + + // No supplier default: fall back to the company's own booking history for + // this counterparty (the same tiered matcher the booking flows use). Fills + // empty rows only, never a generic seed, and only from expense-shaped + // templates (P&L cost on debit, settlement on credit; the 4-8 gate keeps + // private/balance-sheet templates like 2013 or 1630 out). Best-effort: on + // any miss the rows simply stay blank, exactly as before. + if (!supplier.default_expense_account && fields.length > 0 && supplier.name?.trim()) { + let cancelled = false + ;(async () => { + try { + const res = await fetch( + `/api/settings/counterparty-templates?counterparty=${encodeURIComponent(supplier.name.trim())}` + ) + if (!res.ok) return + const json = await res.json() + if (cancelled) return + const match = json?.data + const debit: string | undefined = match?.template?.debit_account + const credit: string | undefined = match?.template?.credit_account + if (!match || (match.confidence ?? 0) < 0.5) return + if (!debit || !/^[4-8]/.test(debit) || !credit || !credit.startsWith('19')) return + requestAccountFill(debit, true, match.template.counterparty_name) + } catch { + // Prefill is best-effort; the rows stay blank. + } + })() + return () => { cancelled = true } + } + return undefined // eslint-disable-next-line react-hooks/exhaustive-deps }, [watchedSupplierId, suppliers]) @@ -1733,6 +1818,16 @@ export default function NewSupplierInvoiceForm({ + {templateAccountNote && + (watchedItems ?? []).some((r) => r.account_number === templateAccountNote.account) && ( +

+ + {t('account_from_history', { + account: templateAccountNote.account, + counterparty: formatCounterpartyName(templateAccountNote.counterparty), + })} +

+ )} {/* Valuta & moms: kept inline with the line items because they drive how each row is interpreted. Hidden defaults (SEK + normal moms) collapse to nothing so most users don't see this. */} diff --git a/components/transactions/BatchCategorySelector.tsx b/components/transactions/BatchCategorySelector.tsx index a1339abb..5edf0321 100644 --- a/components/transactions/BatchCategorySelector.tsx +++ b/components/transactions/BatchCategorySelector.tsx @@ -30,11 +30,20 @@ export default function BatchCategorySelector({ }: BatchCategorySelectorProps) { const t = useTranslations('tx_batch_selector') const tCat = useTranslations('tx_categories') - const [vatTreatment, setVatTreatment] = useState('standard_25') + // 'auto' sends no explicit treatment: the server derives the correct default + // per category (exempt for bank/card fees, 12% representation, else 25%). + // A hardcoded 'standard_25' initial value used to override that derivation + // and claim 25% moms on VAT-exempt bank fees. + const [vatTreatment, setVatTreatment] = useState('auto') const isProcessing = progress !== null const handleSelectCategory = (category: TransactionCategory) => { - const resolvedVat = vatTreatment === 'none' ? undefined : vatTreatment + // 'auto' omits the treatment so the server derives it per category. An + // explicit 'Ingen moms' goes over the wire as 'exempt' (books no VAT + // line): the old collapse to undefined made an explicit no-VAT choice + // book the DERIVED default, i.e. 25% moms on most expense categories. + const resolvedVat = + vatTreatment === 'auto' ? undefined : vatTreatment === 'none' ? 'exempt' : vatTreatment onSelectCategory(category, resolvedVat) } @@ -76,6 +85,7 @@ export default function BatchCategorySelector({
diff --git a/components/transactions/VatTreatmentSelect.tsx b/components/transactions/VatTreatmentSelect.tsx index 7fb65876..cd3471b9 100644 --- a/components/transactions/VatTreatmentSelect.tsx +++ b/components/transactions/VatTreatmentSelect.tsx @@ -8,29 +8,40 @@ import { cn } from '@/lib/utils' import { VAT_TREATMENT_OPTIONS } from './transaction-types' import type { VatTreatment } from '@/types' -interface VatTreatmentSelectProps { - value: VatTreatment | 'none' - onValueChange: (value: VatTreatment | 'none') => void +type VatSelectValue = VatTreatment | 'none' | 'auto' + +// The "auto" option is opt-in (allowAuto): it means "no explicit treatment, +// the server derives it from the picked category" and is only meaningful in +// flows that submit category + treatment together (batch booking). +const AUTO_OPTION = { value: 'auto', labelKey: 'vat_auto', descriptionKey: 'vat_auto_desc' } as const + +interface VatTreatmentSelectProps { + value: T + onValueChange: (value: T) => void disabled?: boolean + allowAuto?: boolean } -export default function VatTreatmentSelect({ +export default function VatTreatmentSelect({ value, onValueChange, disabled, -}: VatTreatmentSelectProps) { + allowAuto, +}: VatTreatmentSelectProps) { const t = useTranslations('tx_categories') + const options: ReadonlyArray<{ value: VatSelectValue; labelKey: string; descriptionKey?: string }> = + allowAuto ? [AUTO_OPTION, ...VAT_TREATMENT_OPTIONS] : VAT_TREATMENT_OPTIONS return (