From d02fd8219184f9a4f9d302ba5839700f05448d4a Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:03:35 +0200 Subject: [PATCH] feat(vat): add per-account declaration treatments (#1588) Closes #1457 --- DECISIONS.md | 2 + app/(dashboard)/import/page.tsx | 61 +++- .../bookkeeping/accounts/[number]/route.ts | 16 +- .../accounts/__tests__/accounts.test.ts | 49 ++++ app/api/bookkeeping/accounts/route.ts | 1 + .../[ruta]/sources/__tests__/route.test.ts | 33 ++- .../ruta/[ruta]/sources/route.ts | 16 +- .../bookkeeping/AccountVatTreatmentSelect.tsx | 52 ++++ components/bookkeeping/AddAccountDialog.tsx | 27 +- components/bookkeeping/EditAccountDialog.tsx | 27 +- components/import/AccountMappingStep.tsx | 110 ++++++- components/import/ImportResultStep.tsx | 19 ++ lib/api/__tests__/schemas.test.ts | 25 ++ lib/api/schemas.ts | 26 ++ lib/import/__tests__/account-sync.test.ts | 19 ++ .../__tests__/account-vat-treatment.test.ts | 94 ++++++ lib/import/account-sync.ts | 46 ++- lib/import/account-vat-treatment.ts | 67 +++++ lib/import/types.ts | 5 + lib/reports/__tests__/rc-basis-gaps.test.ts | 37 +++ lib/reports/__tests__/vat-declaration.test.ts | 41 ++- lib/reports/rc-basis-gaps.ts | 25 +- lib/reports/vat-declaration.ts | 32 +- lib/reports/vat-filing-gate.ts | 22 +- lib/reports/vat-revenue-accounts.ts | 275 ++++++------------ .../__tests__/account-vat-treatment.test.ts | 84 ++++++ lib/vat/account-vat-treatment.ts | 121 ++++++++ messages/en.json | 25 +- messages/sv.json | 25 +- ...4930_add_account_default_vat_treatment.sql | 19 ++ .../account-default-vat-treatment.pg.test.ts | 47 +++ types/index.ts | 1 + 32 files changed, 1196 insertions(+), 253 deletions(-) create mode 100644 components/bookkeeping/AccountVatTreatmentSelect.tsx create mode 100644 lib/import/__tests__/account-vat-treatment.test.ts create mode 100644 lib/import/account-vat-treatment.ts create mode 100644 lib/vat/__tests__/account-vat-treatment.test.ts create mode 100644 lib/vat/account-vat-treatment.ts create mode 100644 supabase/migrations/20260813124930_add_account_default_vat_treatment.sql create mode 100644 supabase/migrations/__tests__/account-default-vat-treatment.pg.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 37b0fb81..b44f8790 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -932,3 +932,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-13] WhatsApp decline observability (#1552) reuses whatsapp_messages with content-free rows for unknown-sender declines instead of a new table or aggregate RPC: no migration (no orphan risk), the wamid unique index gives redelivery dedupe for free (a redelivered bad-code or greeted message no longer earns a second reply), and the existing 30-day unknown-sender retention pass already deletes the rows. Write amplification from an over-quota flood is bounded by a 20-rows-per-hash-per-day trace cap, not by dropping the trail entirely. The settings panel gets a closed event enum derived server-side (lib/last-event.ts), never raw error_message text, so internal errors cannot leak to the client. [2026-08-13] Issue #546 ships a provider-agnostic Peppol BIS Billing 3 XML export with strict Swedish preflight, not a fake send path: certified access-point delivery, SMP lookup, receipts, inbound handling, credentials, and commercial terms depend on Emil selecting and contracting a multitenant provider, and the existing email delivery state cannot truthfully model those guarantees. [2026-08-13] Kontantmetoden year-end VAT supersedes the 2026-08-06 VAT-reporting premise: BAS 2618/2628/2638 and 2648 feed the final declaration, reverse-charge purchases include both VAT sides and their basis, and only the mechanical day-one reversal is excluded from later VAT periods. Skatteverket requires unpaid invoice VAT in the final period and warns against reporting it twice after year end. +[2026-08-13] Per-account VAT treatment is class-aware and explicit values override the static BAS mapping; SIE #SRU and #KTYP never supply it because they encode tax-return fields and account class, not momsdeklaration treatment. +[2026-08-13] Per-account VAT treatment is class-aware; explicit values extend custom accounts while canonical accounts keep their static BAS momsdeklaration mapping. SIE #SRU and #KTYP never supply it because they encode tax-return fields and account class, not momsdeklaration treatment. VMB carries no default account rate because its VAT base is the margin, not gross sales. diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index 3db7220e..c1b4a2da 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -62,6 +62,11 @@ import type { ImportResult, ParseIssue, } from '@/lib/import/types' +import { + applyVatTreatmentReview, + enrichAccountMappingsWithVat, +} from '@/lib/import/account-vat-treatment' +import type { AccountVatTreatment } from '@/lib/vat/account-vat-treatment' import type { TheaterModel } from '@/lib/import/theater-model' /** Above this size the client-side theater parse is skipped (main-thread @@ -513,7 +518,11 @@ function SIEImportWizard() { // Skip the mapping step when all accounts are already mapped const hasUnmapped = mappings.some((m) => !m.targetAccount) - const sieSteps: ImportWizardStep[] = hasUnmapped + const needsVatReview = mappings.some((m) => + m.requiresVatTreatmentReview && !m.vatTreatmentReviewed + ) + const showMappingStep = hasUnmapped || needsVatReview + const sieSteps: ImportWizardStep[] = showMappingStep ? ['upload', 'preview', 'mapping', 'review', 'result'] : ['upload', 'preview', 'review', 'result'] @@ -589,7 +598,6 @@ function SIEImportWizard() { issues: data.parsed.issues, stats: data.parsed.stats, }) - setMappings(data.mappings) setPreview(data.preview) setIssues(data.parsed.issues) setSieAccounts(data.parsed.accounts) @@ -597,7 +605,11 @@ function SIEImportWizard() { const accountsRes = await fetch('/api/bookkeeping/accounts') if (accountsRes.ok) { const accountsData = await accountsRes.json() - setBasAccounts(accountsData.data || []) + const accounts = accountsData.data || [] + setBasAccounts(accounts) + setMappings(enrichAccountMappingsWithVat(data.mappings, accounts)) + } else { + setMappings(enrichAccountMappingsWithVat(data.mappings, [])) } setStep('preview') @@ -711,6 +723,27 @@ function SIEImportWizard() { }) }, [mappings]) + const handleVatTreatmentChange = useCallback(( + sourceAccount: string, + treatment: AccountVatTreatment | null, + rate: number | null, + ) => { + setMappings((prev) => applyVatTreatmentReview(prev, sourceAccount, treatment, rate)) + }, []) + + const confirmVatReview = useCallback(() => { + if (mappings.some((mapping) => + mapping.requiresVatTreatmentReview && !mapping.vatTreatmentReviewed + )) { + setError('Granska momshanteringen för alla markerade konton innan du fortsätter.') + return + } + setStep('review') + setError(null) + setValidationErrors([]) + setValidationWarnings([]) + }, [mappings]) + const missingAccounts = mappings .filter((m) => !m.targetAccount) .map((m) => ({ number: m.sourceAccount, name: m.sourceName })) @@ -738,11 +771,11 @@ function SIEImportWizard() { // Optimistically update mappings: mark created accounts as self-mapped const createdSet = new Set(missingAccounts.map(a => a.number)) - setMappings(prev => prev.map(m => + setMappings(prev => enrichAccountMappingsWithVat(prev.map(m => !m.targetAccount && createdSet.has(m.sourceAccount) ? { ...m, targetAccount: m.sourceAccount, targetName: m.sourceName, confidence: 1.0 } : m - )) + ), basAccounts)) setPreview(prev => { if (!prev) return prev const newMapped = prev.mappingStatus.mapped + createdSet.size @@ -760,14 +793,16 @@ function SIEImportWizard() { const accountsRes = await fetch('/api/bookkeeping/accounts') if (accountsRes.ok) { const accountsData = await accountsRes.json() - setBasAccounts(accountsData.data || []) + const accounts = accountsData.data || [] + setBasAccounts(accounts) + setMappings(prev => enrichAccountMappingsWithVat(prev, accounts)) } } catch (err) { toast({ title: 'Kunde inte skapa konton', description: err instanceof Error ? getErrorMessage(err) : 'Försök igen.', variant: 'destructive' }) } finally { setIsCreatingAccounts(false) } - }, [missingAccounts, toast]) + }, [basAccounts, missingAccounts, toast]) const handleExecuteImport = useCallback(async (options: ImportExecuteOptions) => { if (!file) { setError('No file selected'); return } @@ -887,11 +922,12 @@ function SIEImportWizard() { {step === 'preview' && preview && ( goToStep(hasUnmapped ? 'mapping' : 'review')} onBack={goBack} /> + onContinue={() => goToStep(showMappingStep ? 'mapping' : 'review')} onBack={goBack} /> )} {step === 'mapping' && ( goToStep('review')} onBack={goBack} /> + onMappingChange={handleMappingChange} onVatTreatmentChange={handleVatTreatmentChange} + onContinue={confirmVatReview} onBack={goBack} /> )} {step === 'review' && preview && ( + preview={preview} theaterModel={theaterModel} + unresolvedVatAccountCount={mappings.filter((mapping) => + mapping.sourceAccount === mapping.targetAccount && + ['3', '4', '5', '6'].includes(mapping.sourceAccount.charAt(0)) && + !mapping.vatTreatmentReviewed + ).length} /> )} ) diff --git a/app/api/bookkeeping/accounts/[number]/route.ts b/app/api/bookkeeping/accounts/[number]/route.ts index 6e8228fb..93144db8 100644 --- a/app/api/bookkeeping/accounts/[number]/route.ts +++ b/app/api/bookkeeping/accounts/[number]/route.ts @@ -4,6 +4,7 @@ import { validateBody } from '@/lib/api/validate' import { sparsePatchBody } from '@/lib/api/sparse-patch' import { UpdateAccountSchema } from '@/lib/api/schemas' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' +import { isVatTreatmentValidForAccountClass } from '@/lib/vat/account-vat-treatment' // DELETE hard-deletes an unused, non-system account; accounts referenced by // this company's journal entries must be deactivated instead (PUT is_active). @@ -95,7 +96,7 @@ export const PUT = withRouteContext( // .default() today, so sparsePatchBody is a no-op here: it is the // structural guarantee that adding one later cannot make a PUT that // renames an account also rewrite its VAT code or SRU mapping. An - // explicit null (clearing sru_code, default_vat_code, default_vat_rate) + // explicit null (clearing sru_code, VAT defaults, or descriptions) // still survives. const validation = await validateBody(request, sparsePatchBody(UpdateAccountSchema), { log, @@ -104,6 +105,19 @@ export const PUT = withRouteContext( if (!validation.success) return validation.response const body = validation.data + if ( + body.default_vat_treatment && + !isVatTreatmentValidForAccountClass( + body.default_vat_treatment, + Number(number.charAt(0)), + ) + ) { + return NextResponse.json( + { error: 'Momshanteringen är inte giltig för kontoklassen' }, + { status: 400 }, + ) + } + if (Object.keys(body).length === 0) { return NextResponse.json({ error: 'Inget att uppdatera' }, { status: 400 }) } diff --git a/app/api/bookkeeping/accounts/__tests__/accounts.test.ts b/app/api/bookkeeping/accounts/__tests__/accounts.test.ts index 9f3190ec..54530ce9 100644 --- a/app/api/bookkeeping/accounts/__tests__/accounts.test.ts +++ b/app/api/bookkeeping/accounts/__tests__/accounts.test.ts @@ -267,6 +267,28 @@ describe('POST /api/bookkeeping/accounts', () => { } expect(insertArg?.default_vat_rate).toBe(0) }) + + it('forwards default_vat_treatment into the insert', async () => { + const { supabase, calls } = createCapturingSupabase([{ + data: { account_number: '4056', default_vat_treatment: 'reverse_charge_eu_goods' }, + }]) + auth(supabase) + const req = createMockRequest('/api/bookkeeping/accounts', { + method: 'POST', + body: { + account_number: '4056', + account_name: 'Inköp varor EU', + account_type: 'expense', + normal_balance: 'debit', + default_vat_treatment: 'reverse_charge_eu_goods', + }, + }) + expect((await createPOST(req, routeParams)).status).toBe(200) + const insertArg = calls.find((c) => c.method === 'insert')?.args[0] as { + default_vat_treatment?: string | null + } + expect(insertArg.default_vat_treatment).toBe('reverse_charge_eu_goods') + }) }) describe('DELETE /api/bookkeeping/accounts/[number]', () => { @@ -376,6 +398,33 @@ describe('PUT /api/bookkeeping/accounts/[number]', () => { expect(updateArg?.default_vat_rate).toBe(0) }) + it('forwards default_vat_treatment into the update', async () => { + const { supabase, calls } = createCapturingSupabase([{ + data: { account_number: '3041', default_vat_treatment: 'standard_25' }, + }]) + auth(supabase) + const req = createMockRequest('/api/bookkeeping/accounts/3041', { + method: 'PUT', + body: { default_vat_treatment: 'standard_25' }, + }) + expect((await PUT(req, { params: Promise.resolve({ number: '3041' }) })).status).toBe(200) + const updateArg = calls.find((c) => c.method === 'update')?.args[0] as { + default_vat_treatment?: string | null + } + expect(updateArg.default_vat_treatment).toBe('standard_25') + }) + + it('rejects a VAT treatment that does not apply to the account class', async () => { + const { supabase, calls } = createCapturingSupabase([]) + auth(supabase) + const req = createMockRequest('/api/bookkeeping/accounts/5010', { + method: 'PUT', + body: { default_vat_treatment: 'standard_25' }, + }) + expect((await PUT(req, numberParams)).status).toBe(400) + expect(calls.some((call) => call.method === 'update')).toBe(false) + }) + // The body is spread straight into .update(), so the write set must be // exactly what the caller named. UpdateAccountSchema carries no .default() // today; these two lock the property in so adding one cannot turn a rename diff --git a/app/api/bookkeeping/accounts/route.ts b/app/api/bookkeeping/accounts/route.ts index 979a6cdd..62f95ba0 100644 --- a/app/api/bookkeeping/accounts/route.ts +++ b/app/api/bookkeeping/accounts/route.ts @@ -104,6 +104,7 @@ export const POST = withRouteContext( description: body.description || null, default_vat_code: body.default_vat_code || null, default_vat_rate: body.default_vat_rate ?? null, + default_vat_treatment: body.default_vat_treatment ?? null, sru_code: body.sru_code || null, sort_order: parseInt(body.account_number), }) diff --git a/app/api/reports/vat-declaration/ruta/[ruta]/sources/__tests__/route.test.ts b/app/api/reports/vat-declaration/ruta/[ruta]/sources/__tests__/route.test.ts index e753227e..ba929d0c 100644 --- a/app/api/reports/vat-declaration/ruta/[ruta]/sources/__tests__/route.test.ts +++ b/app/api/reports/vat-declaration/ruta/[ruta]/sources/__tests__/route.test.ts @@ -31,10 +31,19 @@ function buildSupabase( chartAccounts: Array<{ account_number: string account_name?: string + account_class?: number default_vat_rate: number | null + default_vat_treatment?: string | null }> = [] ): SupabaseShape { - const chartResult = { data: chartAccounts, error: null } + const chartResult = { + data: chartAccounts.map((account) => ({ + account_class: 3, + default_vat_treatment: null, + ...account, + })), + error: null, + } return { rpc: vi.fn().mockResolvedValue(linesResult), from: vi.fn().mockImplementation((table: string) => { @@ -44,6 +53,8 @@ function buildSupabase( return { select: vi.fn().mockReturnThis(), eq: vi.fn().mockReturnThis(), + gte: vi.fn().mockReturnThis(), + lte: vi.fn().mockReturnThis(), in: vi.fn().mockReturnThis(), not: vi.fn().mockReturnThis(), order: vi.fn().mockReturnThis(), @@ -228,7 +239,7 @@ describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources', () => { }) }) -describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources: ruta 05 accounts', () => { +describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources: account overrides', () => { /** The p_accounts array the route handed to get_vat_ruta_source_lines. */ function rpcAccounts(supabase: SupabaseShape): string[] { return (supabase.rpc.mock.calls[0][1] as { p_accounts: string[] }).p_accounts @@ -280,7 +291,23 @@ describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources: ruta 05 accounts const accounts = rpcAccounts(supabase) expect(accounts).toContain('2611') expect(accounts).not.toContain('3013') - expect(supabase.from).not.toHaveBeenCalledWith('chart_of_accounts') + // Every ruta resolves explicit account overrides before the static BAS + // fallback, but a ruta 05-only custom account still stays out of ruta 10. + expect(supabase.from).toHaveBeenCalledWith('chart_of_accounts') + }) + + it('drills into a custom EU purchase account in ruta 20', async () => { + const supabase = buildSupabase({ data: [], error: null }, { data: null, error: null }, [{ + account_number: '4056', + account_name: 'Inköp varor 25% EU', + account_class: 4, + default_vat_rate: 0.25, + default_vat_treatment: 'reverse_charge_eu_goods', + }]) + authOk(supabase) + + expect((await get('20')).status).toBe(200) + expect(rpcAccounts(supabase)).toContain('4056') }) }) diff --git a/app/api/reports/vat-declaration/ruta/[ruta]/sources/route.ts b/app/api/reports/vat-declaration/ruta/[ruta]/sources/route.ts index ce99ae5e..40196bbd 100644 --- a/app/api/reports/vat-declaration/ruta/[ruta]/sources/route.ts +++ b/app/api/reports/vat-declaration/ruta/[ruta]/sources/route.ts @@ -4,7 +4,7 @@ import { ACCOUNT_RUTA, resolvePeriodDates, } from '@/lib/reports/vat-declaration' -import { fetchDynamicRuta05Accounts } from '@/lib/reports/vat-revenue-accounts' +import { fetchDynamicVatAccounts } from '@/lib/reports/vat-revenue-accounts' import type { ReportSourceLine } from '@/lib/reports/source-lines' import type { VatDeclarationRutor, VatPeriodType } from '@/types' @@ -40,17 +40,17 @@ export const GET = withRouteContext<{ params: Promise<{ ruta: string }> }>( rutaParam.startsWith('ruta') ? rutaParam : `ruta${rutaParam}` ) as keyof VatDeclarationRutor - // Invert ACCOUNT_RUTA: which BAS accounts feed this ruta? + const dynamicVatAccounts = await fetchDynamicVatAccounts(supabase, companyId) + + // Invert the effective mapping. Fixed BAS mappings stay authoritative; + // explicit treatments add custom accounts only. const accountsForRuta = Object.entries(ACCOUNT_RUTA) .filter(([, m]) => m.box === rutaKey) .map(([acc]) => acc) - // Ruta 05 also collects the company's own momspliktiga intäktskonton, which - // ACCOUNT_RUTA cannot know about (#1261). Without them the drill-down would - // list a smaller sum than the figure it drills into. - if (rutaKey === 'ruta05') { - const { accounts } = await fetchDynamicRuta05Accounts(supabase, companyId) - accountsForRuta.push(...accounts) + for (const [account, mapping] of dynamicVatAccounts.mappingByAccount) { + if (ACCOUNT_RUTA[account]) continue + if (mapping.box === rutaKey) accountsForRuta.push(account) } if (accountsForRuta.length === 0) { diff --git a/components/bookkeeping/AccountVatTreatmentSelect.tsx b/components/bookkeeping/AccountVatTreatmentSelect.tsx new file mode 100644 index 00000000..16cb5313 --- /dev/null +++ b/components/bookkeeping/AccountVatTreatmentSelect.tsx @@ -0,0 +1,52 @@ +'use client' + +import { useTranslations } from 'next-intl' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { + vatTreatmentsForAccountClass, + type AccountVatTreatment, +} from '@/lib/vat/account-vat-treatment' + +interface AccountVatTreatmentSelectProps { + value: AccountVatTreatment | 'none' + onValueChange: (value: AccountVatTreatment | 'none') => void + accountClass: number | null +} + +export function AccountVatTreatmentSelect({ + value, + onValueChange, + accountClass, +}: AccountVatTreatmentSelectProps) { + const t = useTranslations('chart_of_accounts') + const isRelevant = accountClass === 3 || + (accountClass !== null && accountClass >= 4 && accountClass <= 6) + const treatments = accountClass === null ? [] : vatTreatmentsForAccountClass(accountClass) + + return ( +
+ + +

+ {isRelevant ? t('vat_treatment_help') : t('vat_treatment_not_applicable')} +

+
+ ) +} diff --git a/components/bookkeeping/AddAccountDialog.tsx b/components/bookkeeping/AddAccountDialog.tsx index 842ccca0..eac2b7b7 100644 --- a/components/bookkeeping/AddAccountDialog.tsx +++ b/components/bookkeeping/AddAccountDialog.tsx @@ -19,6 +19,11 @@ import { isStandardBASAccount } from '@/lib/bookkeeping/bas-reference' import { classifyAccount } from '@/lib/bookkeeping/account-classifier' import type { BASAccount } from '@/types' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' +import { AccountVatTreatmentSelect } from './AccountVatTreatmentSelect' +import { + defaultRateForVatTreatment, + type AccountVatTreatment, +} from '@/lib/vat/account-vat-treatment' /** * The create path hands back the full row the API inserted. The reactivate @@ -49,6 +54,7 @@ export function AddAccountDialog({ // "Standard moms": the moms-sats a booking line defaults to when this konto is // picked. 'none' = no default. SelectItem values are stringified decimals. const [defaultVatRate, setDefaultVatRate] = useState('none') + const [defaultVatTreatment, setDefaultVatTreatment] = useState('none') const [sruCode, setSruCode] = useState('') const [normalBalance, setNormalBalance] = useState<'debit' | 'credit'>('debit') const [isSaving, setIsSaving] = useState(false) @@ -102,6 +108,7 @@ export function AddAccountDialog({ normal_balance: normalBalance, description: description || null, default_vat_rate: defaultVatRate === 'none' ? null : parseFloat(defaultVatRate), + default_vat_treatment: defaultVatTreatment === 'none' ? null : defaultVatTreatment, sru_code: sruCode || null, }), }) @@ -127,6 +134,7 @@ export function AddAccountDialog({ setAccountName('') setDescription('') setDefaultVatRate('none') + setDefaultVatTreatment('none') setSruCode('') onCreated(createdAccount) onOpenChange(false) @@ -162,6 +170,7 @@ export function AddAccountDialog({ setAccountName('') setDescription('') setDefaultVatRate('none') + setDefaultVatTreatment('none') setSruCode('') onCreated({ account_number: accountNumber }) onOpenChange(false) @@ -260,6 +269,18 @@ export function AddAccountDialog({ /> + { + setDefaultVatTreatment(treatment) + if (treatment !== 'none') { + const rate = defaultRateForVatTreatment(treatment, Number(accountNumber.charAt(0))) + setDefaultVatRate(rate === null ? 'none' : String(rate)) + } + }} + /> +
@@ -275,12 +296,6 @@ export function AddAccountDialog({ 6 % - {accountNumber.length === 4 && accountNumber.startsWith('3') && ( -

- På intäktskonton avgör satsen också om kontot räknas som - momspliktig försäljning i ruta 05 i momsdeklarationen. -

- )}
diff --git a/components/bookkeeping/EditAccountDialog.tsx b/components/bookkeeping/EditAccountDialog.tsx index 7397a178..0c8200a5 100644 --- a/components/bookkeeping/EditAccountDialog.tsx +++ b/components/bookkeeping/EditAccountDialog.tsx @@ -32,6 +32,11 @@ import { type DimensionRuleType, } from '@/components/dimensions/types' import type { BASAccount } from '@/types' +import { AccountVatTreatmentSelect } from './AccountVatTreatmentSelect' +import { + defaultRateForVatTreatment, + type AccountVatTreatment, +} from '@/lib/vat/account-vat-treatment' interface EditAccountDialogProps { open: boolean @@ -65,6 +70,9 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit const [defaultVatRate, setDefaultVatRate] = useState( account.default_vat_rate != null ? String(account.default_vat_rate) : 'none', ) + const [defaultVatTreatment, setDefaultVatTreatment] = useState( + account.default_vat_treatment ?? 'none', + ) const [sruCode, setSruCode] = useState(account.sru_code || '') const [isActive, setIsActive] = useState(account.is_active) const [isSaving, setIsSaving] = useState(false) @@ -256,6 +264,7 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit account_name: accountName, description: description || null, default_vat_rate: defaultVatRate === 'none' ? null : parseFloat(defaultVatRate), + default_vat_treatment: defaultVatTreatment === 'none' ? null : defaultVatTreatment, sru_code: sruCode || null, is_active: isActive, }), @@ -313,6 +322,18 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit />
+ { + setDefaultVatTreatment(treatment) + if (treatment !== 'none') { + const rate = defaultRateForVatTreatment(treatment, account.account_class) + setDefaultVatRate(rate === null ? 'none' : String(rate)) + } + }} + /> +
@@ -328,12 +349,6 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit 6 % - {account.account_class === 3 && ( -

- På intäktskonton avgör satsen också om kontot räknas som - momspliktig försäljning i ruta 05 i momsdeklarationen. -

- )}
diff --git a/components/import/AccountMappingStep.tsx b/components/import/AccountMappingStep.tsx index 32909424..68704ff3 100644 --- a/components/import/AccountMappingStep.tsx +++ b/components/import/AccountMappingStep.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useMemo } from 'react' +import { useTranslations } from 'next-intl' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -31,16 +32,26 @@ import { import type { AccountMapping } from '@/lib/import/types' import type { BASAccount } from '@/types' import { getAccountClassName } from '@/lib/bookkeeping/account-descriptions' +import { + defaultRateForVatTreatment, + vatTreatmentsForAccountClass, + type AccountVatTreatment, +} from '@/lib/vat/account-vat-treatment' interface AccountMappingStepProps { mappings: AccountMapping[] basAccounts: BASAccount[] onMappingChange: (sourceAccount: string, targetAccount: string, targetName: string) => void + onVatTreatmentChange?: ( + sourceAccount: string, + treatment: AccountVatTreatment | null, + rate: number | null, + ) => void onContinue: () => void onBack: () => void } -type FilterType = 'all' | 'unmapped' | 'low_confidence' | 'manual' +type FilterType = 'all' | 'unmapped' | 'vat_review' | 'low_confidence' | 'manual' const PAGE_SIZE = 50 @@ -48,14 +59,17 @@ export default function AccountMappingStep({ mappings, basAccounts, onMappingChange, + onVatTreatmentChange, onContinue, onBack, }: AccountMappingStepProps) { + const t = useTranslations('chart_of_accounts') const [searchTerm, setSearchTerm] = useState('') // Default to showing unmapped accounts first (most actionable) const [filter, setFilter] = useState(() => { const hasUnmapped = mappings.some((m) => !m.targetAccount) - return hasUnmapped ? 'unmapped' : 'all' + const hasVatReview = mappings.some((m) => m.requiresVatTreatmentReview && !m.vatTreatmentReviewed) + return hasUnmapped ? 'unmapped' : hasVatReview ? 'vat_review' : 'all' }) const [currentPage, setCurrentPage] = useState(1) @@ -71,6 +85,9 @@ export default function AccountMappingStep({ case 'low_confidence': result = result.filter((m) => m.targetAccount && m.confidence < 0.7) break + case 'vat_review': + result = result.filter((m) => m.requiresVatTreatmentReview && !m.vatTreatmentReviewed) + break case 'manual': result = result.filter((m) => m.isOverride) break @@ -114,10 +131,11 @@ export default function AccountMappingStep({ const unmapped = mappings.filter((m) => !m.targetAccount).length const lowConfidence = mappings.filter((m) => m.targetAccount && m.confidence < 0.7).length const manual = mappings.filter((m) => m.isOverride).length - return { unmapped, lowConfidence, manual } + const vatReview = mappings.filter((m) => m.requiresVatTreatmentReview && !m.vatTreatmentReviewed).length + return { unmapped, lowConfidence, manual, vatReview } }, [mappings]) - const canContinue = stats.unmapped === 0 + const canContinue = stats.unmapped === 0 && stats.vatReview === 0 // Group BAS accounts by class for the dropdown const accountsByClass = useMemo(() => { @@ -145,6 +163,14 @@ export default function AccountMappingStep({ {/* Stats */}
+ 0 ? 'secondary' : 'outline'} + className="cursor-pointer" + onClick={() => handleFilterChange('vat_review')} + > + + {t('vat_review_filter', { count: stats.vatReview })} + 0 ? 'destructive' : 'secondary'} className="cursor-pointer" @@ -197,6 +223,7 @@ export default function AccountMappingStep({ Visa alla Ej mappade + {t('vat_review_filter', { count: stats.vatReview })} Osäkra Manuellt satta @@ -211,6 +238,7 @@ export default function AccountMappingStep({ Källkonto Källnamn + {t('vat_treatment_column')} Målkonto Konfidens @@ -226,6 +254,78 @@ export default function AccountMappingStep({ + + {onVatTreatmentChange && + mapping.sourceAccount === mapping.targetAccount && + ['3', '4', '5', '6'].includes(mapping.sourceAccount.charAt(0)) ? ( +
+ + + {mapping.requiresVatTreatmentReview && !mapping.vatTreatmentReviewed && ( + + )} +
+ ) : ( + - + )} +