feat(ux): Bucket A defaults pass: remove choices the system already knows the answer to (#1443)

* feat(booking): batch VAT seeds from category default, period derives from entry date

BatchCategorySelector and BulkBookInboxDialog hardcoded standard_25 as the
initial VAT treatment, overriding the server's per-category derivation and
claiming 25% moms on VAT-exempt bank fees. Both now default to an explicit
'Enligt kategori' option that omits vat_treatment so the server derives it
(exempt bank/card fees, 12% representation). Reverse charge is never derived.

The embedded JournalEntryForm period Select is replaced by the same derived
read-only text the standalone variant already uses: the period is a total
function of the entry date, and the Select allowed picking a period that
disagreed with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(booking): prefill cost account from counterparty history; period text in Bokfor direkt

BookDirectlyDialog and the supplier-invoice form left the cost account
deliberately blank even when the company's own confirmed history for the
counterparty (categorization_templates) or supplier.default_expense_account
knew the answer. Both now prefill from a counterparty-template hit (new
?counterparty= single-match mode on the settings route, same tiered matcher
as the booking flows), only into still-empty fields, only from expense-shaped
templates, with a provenance line. No generic fallback: a miss leaves the
field blank exactly as before.

Bokfor direkt's period Select is replaced by text derived from the entry
date; the silent periods[0] fallback becomes a blocking explanation, since
borrowing an arbitrary period could book into the wrong one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ux): single-company login skips the picker; filing surfaces default to filable periods

/select-company auto-forwards when the user is a member of exactly one
company with nothing else to decide (no new TIC engagements, no pending
invite, enrichment fresh); the in-app 'Lagg till foretag' links pass
?choose=1 to keep the picker deliberately reachable. Byra/multi-company
users are untouched.

The VAT declaration now opens on the most recently ENDED month/quarter
(lib/vat/period-defaults, tested) instead of the current one, which can
never be filed and forced a step-back click on every filing visit; the
periodicity switch resets the same way. Helarsmoms FyPicker gains
preferLatestEnded and opens on the latest ended rakenskapsar instead of
the newest started one.

The 'momsperiod saknas' dead end now collects the answer inline through
the same PUT /api/settings validation instead of bouncing to settings:
until the period exists the deadline engine generates zero VAT deadlines,
silently, so every extra hop kept a compliance hole open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(granskning): approve pill commits directly for low and medium risk

The Godkann pill on /pending only opened a ConfirmationDialog demanding a
second Godkann, regardless of tier. The review row already states source,
title and risk and offers Detaljer, so for low/medium the pill now commits
directly; high risk keeps the dialog, whose warning sentence carries
information the row does not. Chat-side bulk approve is deferred: it needs
ApprovalCard's state lifted (assistant-redesign seam 8.8), see DECISIONS.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reports): map inline momsperiod save errors through getErrorMessage

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): repair the dead login auto-forward and nine review findings

The big one: setActiveCompany ends with a cookie write that throws during
Server Component render (sealed cookie store), so the /select-company
auto-forward silently never fired; the write is now best-effort since the
cookie is write-only compat and the DB write is already verified.

Also: supplier-switch un-plants history-prefilled accounts so the new
supplier's own default applies; prefill routes through handleAccountChange
so konto default moms rides along; batch 'Ingen moms' books exempt instead
of the derived 25%; monthly VAT default tracks the actual 12th/17th filing
deadline (over-40M stays M-1); inline momsperiod setup uses EmptyState,
gates on vat_number (the PUT would 400 without it), keeps keyboard focus
and announces errors; cost-account shape guard tightened to P&L accounts;
attn tone on the new warning lines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: retrigger workflows; the Actions outage swallowed the rebase push event

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: retrigger after outage (events dropped, not delayed)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: retrigger after GitHub Actions recovery

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): address CodeRabbit and compliance-bot findings

Direct commit now prunes the op from the bulk selection (a stale id kept
inflating the bulk bar and rode into bulk-commit) and the detail-panel
Godkann gets the same risk gate as the row pill. The automatic account
fill in the supplier-invoice form is requested, not applied inline: the
applying effect waits for both the BAS chart and the request with fresh
closures, so a fill can no longer land before the chart and leave a
VAT-free konto on the 25% row default. Test dates use local-time
constructors (ISO strings parse as UTC midnight and shift a day in
negative-offset timezones). Stale ML 11 kap citation dropped from a
comment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: retrigger; push event dropped again

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:
Jakob Wennberg
2026-08-07 10:16:23 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 63d520719a
commit c0a106e591
20 changed files with 760 additions and 130 deletions
+3
View File
@@ -817,3 +817,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
+42 -8
View File
@@ -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)
}
}}
>
<Check className="h-3.5 w-3.5" />
@@ -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')}
+30 -1
View File
@@ -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 (
<BankIdCompanyPicker
firstName={firstName}
@@ -19,7 +19,89 @@ vi.mock('@/lib/auth/require-write', () => ({
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(() => {
@@ -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=<raw name> 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('*')
+15 -26
View File
@@ -1347,32 +1347,21 @@ export default function JournalEntryForm({
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs">
{embedded ? (
<div className="flex items-center gap-2">
<Label className="text-xs text-muted-foreground">{t('fiscal_year')}</Label>
<Select value={selectedPeriod} onValueChange={setSelectedPeriod}>
<SelectTrigger className="h-7 w-auto text-xs">
<SelectValue placeholder={t('fiscal_year_placeholder')} />
</SelectTrigger>
<SelectContent>
{periods.map((p) => (
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
selectedPeriodObj && (
<span className="text-muted-foreground">
{t('fiscal_year')}:{' '}
<span className="text-foreground">{selectedPeriodObj.name}</span>
{nextVoucherNumber != null && (
<span className="ml-2 font-mono text-foreground">
{voucherSeries}{nextVoucherNumber}
</span>
)}
</span>
)
{/* 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 && (
<span className="text-muted-foreground">
{t('fiscal_year')}:{' '}
<span className="text-foreground">{selectedPeriodObj.name}</span>
{nextVoucherNumber != null && (
<span className="ml-2 font-mono text-foreground">
{voucherSeries}{nextVoucherNumber}
</span>
)}
</span>
)}
<div className="flex items-center gap-2">
<Label className="text-xs text-muted-foreground">{t('currency')}</Label>
+26 -9
View File
@@ -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
+2 -2
View File
@@ -102,7 +102,7 @@ export default function CompanySwitcher() {
if (isSandbox) return null
return (
<Link
href="/select-company"
href="/select-company?choose=1"
className="flex items-center gap-2 w-full text-left rounded-lg border border-dashed border-border/60 hover:border-foreground/30 hover:bg-muted/40 -mx-1 px-2 py-1.5 transition-colors duration-150"
>
<Plus className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
@@ -182,7 +182,7 @@ export default function CompanySwitcher() {
{!isSandbox && (
<div className={cn(companies.length > 0 && 'border-t border-border/40 mt-1 pt-1', 'px-1')}>
<Link
href="/select-company"
href="/select-company?choose=1"
onClick={() => 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"
>
+1 -1
View File
@@ -306,7 +306,7 @@ export default function UserMenu({
</div>
{!sandbox && (
<div className="border-t border-border/60 px-1 pt-1">
<Link href="/select-company" onClick={close} className={menuRow}>
<Link href="/select-company?choose=1" onClick={close} className={menuRow}>
<Plus className="h-4 w-4 flex-shrink-0" />
{tSwitcher('add_company')}
</Link>
@@ -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 =
/>
</div>
<div className="space-y-1.5 md:col-span-2">
<Label htmlFor="bd-period">Räkenskapsperiod</Label>
<Select
value={periodId}
onValueChange={setPeriodId}
disabled={isSubmitting || periods.length === 0}
>
<SelectTrigger id="bd-period">
<SelectValue placeholder="Välj period" />
</SelectTrigger>
<SelectContent>
{periods.map((p) => {
const lockState = p.locked_at
? 'låst'
: p.is_closed
? 'stängd'
: null
return (
<SelectItem key={p.id} value={p.id}>
{p.period_start}: {p.period_end}
{lockState && ` (${lockState})`}
</SelectItem>
)
})}
</SelectContent>
</Select>
<Label>Räkenskapsperiod</Label>
{/* Derived from the entry date (periods never overlap): text,
not a picker, so it can never disagree with the date. */}
{periods.length === 0 ? (
<p className="text-sm text-muted-foreground pt-2">Hämtar perioder …</p>
) : derivedPeriod ? (
<p className="text-sm pt-2 tabular-nums">
{derivedPeriod.period_start}: {derivedPeriod.period_end}
{(derivedPeriod.locked_at || derivedPeriod.is_closed) && (
<span className="text-attn">
{' '}({derivedPeriod.locked_at ? 'låst' : 'stängd'})
</span>
)}
</p>
) : (
<AttnLine className="pt-2">
Datumet ligger utanför öppna räkenskapsperioder. Ändra datumet eller skapa perioden under Bokföring.
</AttnLine>
)}
</div>
</div>
@@ -792,6 +834,13 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl =
lägg till en rad för omvänd skattskyldighet manuellt.
</p>
)}
{accountSuggestion && lines[0]?.account_number === accountSuggestion.account && (
<p className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<span aria-hidden className="inline-block h-1.5 w-1.5 rounded-full bg-success" />
Konto {accountSuggestion.account} föreslaget från tidigare bokföringar av{' '}
{formatCounterpartyName(accountSuggestion.counterparty)}
</p>
)}
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/40">
@@ -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<string>('')
const [vatTreatment, setVatTreatment] = useState<VatTreatment>('standard_25')
const [vatTreatment, setVatTreatment] = useState<VatTreatment | 'auto'>('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
}
/>
</div>
<Select value={vatTreatment} onValueChange={(v) => setVatTreatment(v as VatTreatment)}>
<Select value={vatTreatment} onValueChange={(v) => setVatTreatment(v as VatTreatment | 'auto')}>
<SelectTrigger id="bulk-vat">
<SelectValue />
</SelectTrigger>
+142 -14
View File
@@ -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> | void
}) {
const [saving, setSaving] = useState<string | null>(null)
const [error, setError] = useState<string | null>(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 (
<div className="space-y-3">
<div className="flex flex-wrap justify-center gap-2">
{options.map((opt) => (
<Button
key={opt.value}
variant="outline"
size="sm"
// The clicked button stays enabled (aria-busy) so keyboard focus
// survives the save; the `if (saving) return` guard in choose()
// prevents a double submit.
disabled={saving !== null && saving !== opt.value}
aria-busy={saving === opt.value}
onClick={() => choose(opt.value)}
>
{saving === opt.value ? 'Sparar …' : opt.label}
</Button>
))}
</div>
{error && (
<p role="status" className="text-sm text-destructive">
{error}
</p>
)}
</div>
)
}
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<string | null>(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 (
<div className="space-y-8">
{bareHeader}
<EmptyState
icon={Percent}
title="Redovisningsperiod för moms saknas"
description="Företaget är momsregistrerat men momsregistreringsnummer och redovisningsperiod (månad, kvartal eller helår) saknas. Ange dem i skatteinställningarna så visas deklarationen för rätt period."
actionLabel="Öppna skatteinställningar"
actionHref="/settings/tax"
/>
</div>
)
}
return (
<div className="space-y-8">
{bareHeader}
<EmptyState
icon={Percent}
title="Redovisningsperiod för moms saknas"
description="Företaget är momsregistrerat men ingen redovisningsperiod (månad, kvartal eller helår) är vald. Ange den i skatteinställningarna så visas deklarationen för rätt period."
actionLabel="Öppna skatteinställningar"
actionHref="/settings/tax"
/>
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."
>
<div className="space-y-3">
<MomsPeriodInlineSetup
onSaved={async (value) => {
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)
}}
/>
<p className="text-xs text-muted-foreground">
Du kan alltid ändra den i{' '}
<Link href="/settings/tax" className="underline underline-offset-2 hover:text-foreground">
skatteinställningarna
</Link>
.
</p>
</div>
</EmptyState>
</div>
)
}
@@ -1851,6 +1978,7 @@ export function VatDeclarationView({ pageTitle }: { pageTitle?: string } = {}) {
}}
includeAllOption={false}
hideFuturePeriods
preferLatestEnded
/>
) : (
<ContextPicker
@@ -20,6 +20,7 @@ import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { SupplierInvoiceReviewContent } from '@/components/suppliers/SupplierInvoiceReviewContent'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions'
import { formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { cn, formatCurrency } from '@/lib/utils'
import { getDisplayTotal } from '@/lib/invoices/rounding'
@@ -610,10 +611,67 @@ export default function NewSupplierInvoiceForm({
// Auto-fill due date and defaults when supplier is selected: but never
// overwrite a value the AI already filled in for us.
const [templateAccountNote, setTemplateAccountNote] = useState<{ account: string; counterparty: string } | null>(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({
</Button>
</CardHeader>
<CardContent>
{templateAccountNote &&
(watchedItems ?? []).some((r) => r.account_number === templateAccountNote.account) && (
<p className="mb-4 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<span aria-hidden className="inline-block h-1.5 w-1.5 rounded-full bg-success" />
{t('account_from_history', {
account: templateAccountNote.account,
counterparty: formatCounterpartyName(templateAccountNote.counterparty),
})}
</p>
)}
{/* 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. */}
@@ -30,11 +30,20 @@ export default function BatchCategorySelector({
}: BatchCategorySelectorProps) {
const t = useTranslations('tx_batch_selector')
const tCat = useTranslations('tx_categories')
const [vatTreatment, setVatTreatment] = useState<VatTreatment | 'none'>('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<VatTreatment | 'none' | 'auto'>('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({
<VatTreatmentSelect
value={vatTreatment}
onValueChange={setVatTreatment}
allowAuto
/>
</div>
<div>
+18 -7
View File
@@ -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<T extends VatSelectValue> {
value: T
onValueChange: (value: T) => void
disabled?: boolean
allowAuto?: boolean
}
export default function VatTreatmentSelect({
export default function VatTreatmentSelect<T extends VatSelectValue>({
value,
onValueChange,
disabled,
}: VatTreatmentSelectProps) {
allowAuto,
}: VatTreatmentSelectProps<T>) {
const t = useTranslations('tx_categories')
const options: ReadonlyArray<{ value: VatSelectValue; labelKey: string; descriptionKey?: string }> =
allowAuto ? [AUTO_OPTION, ...VAT_TREATMENT_OPTIONS] : VAT_TREATMENT_OPTIONS
return (
<Select
value={value}
onValueChange={(v) => { if (v) onValueChange(v as VatTreatment | 'none') }}
onValueChange={(v) => { if (v) onValueChange(v as T) }}
disabled={disabled}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{VAT_TREATMENT_OPTIONS.map((opt) => (
{options.map((opt) => (
<SelectPrimitive.Item
key={opt.value}
value={opt.value}
+20 -8
View File
@@ -305,14 +305,26 @@ export async function setActiveCompany(
// Refresh the cookie as a compat hint: only after the DB write is
// confirmed, so the cookie can never diverge from user_preferences.
const cookieStore = await cookies()
cookieStore.set(COMPANY_COOKIE, companyId, {
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 365, // 1 year
})
// Best-effort: Next only allows cookie mutation in Server Actions and
// Route Handlers, and setActiveCompany is also called from Server
// Component render (the /select-company single-company auto-forward),
// where cookies() is sealed and set() throws. The cookie is a write-only
// legacy hint that nothing reads anymore (see CLAUDE.md tenancy notes),
// and the authoritative DB write above is already verified, so a skipped
// refresh cannot desync anything. Swallowing here mirrors the setAll
// pattern in lib/supabase/server.ts.
try {
const cookieStore = await cookies()
cookieStore.set(COMPANY_COOKIE, companyId, {
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 365, // 1 year
})
} catch {
// Sealed cookie store (render phase): the DB write is what matters.
}
}
/**
+89
View File
@@ -0,0 +1,89 @@
import { describe, it, expect } from 'vitest'
import { mostRecentEndedVatPeriod } from '../period-defaults'
// Dates use the local-time Date(y, m, d) constructor: the ISO-string form
// parses as UTC midnight, which shifts a calendar day in negative-offset
// timezones and would break the deadline-boundary assertions there.
describe('mostRecentEndedVatPeriod', () => {
describe('monthly (standard filers: month M due the 12th/17th of M+2)', () => {
it('returns M-2 while the deadline for it is still open', () => {
// 2026-08-06: June's declaration is due 17 Aug (August deadline day).
expect(mostRecentEndedVatPeriod('monthly', new Date(2026, 7, 6))).toEqual({
year: 2026,
period: 6,
})
})
it('advances to M-1 once the deadline has passed', () => {
// 2026-08-20: June is filed; July (due 12 Sep) is the open one.
expect(mostRecentEndedVatPeriod('monthly', new Date(2026, 7, 20))).toEqual({
year: 2026,
period: 7,
})
})
it('uses the 12th as deadline day outside January and August', () => {
expect(mostRecentEndedVatPeriod('monthly', new Date(2026, 2, 12))).toEqual({
year: 2026,
period: 1,
})
expect(mostRecentEndedVatPeriod('monthly', new Date(2026, 2, 13))).toEqual({
year: 2026,
period: 2,
})
})
it('rolls back across the year boundary in January', () => {
// 2026-01-10: November 2025 is due 17 Jan (January deadline day).
expect(mostRecentEndedVatPeriod('monthly', new Date(2026, 0, 10))).toEqual({
year: 2025,
period: 11,
})
// After the January deadline: December 2025 is the open one.
expect(mostRecentEndedVatPeriod('monthly', new Date(2026, 0, 20))).toEqual({
year: 2025,
period: 12,
})
})
it('over-40M filers (month M due the 26th of M+1) always get M-1', () => {
expect(
mostRecentEndedVatPeriod('monthly', new Date(2026, 7, 6), { over40m: true }),
).toEqual({ year: 2026, period: 7 })
expect(
mostRecentEndedVatPeriod('monthly', new Date(2026, 0, 10), { over40m: true }),
).toEqual({ year: 2025, period: 12 })
})
})
describe('quarterly', () => {
it('returns the previous quarter mid-year', () => {
expect(mostRecentEndedVatPeriod('quarterly', new Date(2026, 7, 6))).toEqual({
year: 2026,
period: 2,
})
})
it('rolls back to Q4 of the previous year during Q1', () => {
expect(mostRecentEndedVatPeriod('quarterly', new Date(2026, 1, 28))).toEqual({
year: 2025,
period: 4,
})
})
it('returns Q1 at the start of Q2', () => {
expect(mostRecentEndedVatPeriod('quarterly', new Date(2026, 3, 1))).toEqual({
year: 2026,
period: 1,
})
})
it('returns Q3 in December', () => {
expect(mostRecentEndedVatPeriod('quarterly', new Date(2026, 11, 31))).toEqual({
year: 2026,
period: 3,
})
})
})
})
+49
View File
@@ -0,0 +1,49 @@
/**
* Default period selection for the VAT declaration view.
*
* A momsdeklaration can only ever be filed for a period that has ENDED, so
* the view defaults to the most recently ended month/quarter instead of the
* current one (which used to force a step-back click on every filing visit,
* and invited reading rutor for a period that cannot be filed yet).
*
* Monthly filers below the 40 MSEK threshold declare month M on the 12th of
* M+2 (17th when that deadline lands in January or August), mirroring
* lib/tax/deadline-config.ts. Until that deadline has passed, the declaration
* the user actually has open is M-2, not M-1, so the default tracks the
* deadline. Over-40M companies declare M on the 26th of M+1, which makes the
* most recently ended month the due one year-round.
*/
export interface VatPeriodDefault {
year: number
/** 1-12 for monthly, 1-4 for quarterly. */
period: number
}
export function mostRecentEndedVatPeriod(
periodType: 'monthly' | 'quarterly',
today: Date = new Date(),
opts: { over40m?: boolean } = {},
): VatPeriodDefault {
const year = today.getFullYear()
const month = today.getMonth() + 1
if (periodType === 'monthly') {
let m = month - 1
let y = year
if (!opts.over40m) {
// Deadline day of the CURRENT month for the M-2 declaration: 17 in
// January and August, else 12 (deadline-config huvudregel).
const deadlineDay = month === 1 || month === 8 ? 17 : 12
if (today.getDate() <= deadlineDay) m -= 1
}
while (m < 1) {
m += 12
y -= 1
}
return { year: y, period: m }
}
const quarter = Math.ceil(month / 3)
return quarter === 1 ? { year: year - 1, period: 4 } : { year, period: quarter - 1 }
}
+3
View File
@@ -3074,6 +3074,8 @@
"income_services": "Services",
"income_products": "Products",
"income_other": "Other",
"vat_auto": "Based on category",
"vat_auto_desc": "VAT is suggested from the selected category",
"vat_standard_25": "VAT 25%",
"vat_reduced_12": "VAT 12%",
"vat_reduced_12_desc": "Food, hotels, camping",
@@ -3834,6 +3836,7 @@
"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.",
+3
View File
@@ -3074,6 +3074,8 @@
"income_services": "Tjänster",
"income_products": "Produkter",
"income_other": "Övrigt",
"vat_auto": "Enligt kategori",
"vat_auto_desc": "Momsen föreslås utifrån vald kategori",
"vat_standard_25": "Moms 25%",
"vat_reduced_12": "Moms 12%",
"vat_reduced_12_desc": "Livsmedel, hotell, camping",
@@ -3834,6 +3836,7 @@
"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.",