@@ -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 && (
|
||||
<SIEPreviewStep preview={preview} issues={issues} missingAccounts={missingAccounts}
|
||||
onCreateAccounts={handleCreateAccounts} isCreatingAccounts={isCreatingAccounts}
|
||||
onContinue={() => goToStep(hasUnmapped ? 'mapping' : 'review')} onBack={goBack} />
|
||||
onContinue={() => goToStep(showMappingStep ? 'mapping' : 'review')} onBack={goBack} />
|
||||
)}
|
||||
{step === 'mapping' && (
|
||||
<AccountMappingStep mappings={mappings} basAccounts={basAccounts}
|
||||
onMappingChange={handleMappingChange} onContinue={() => goToStep('review')} onBack={goBack} />
|
||||
onMappingChange={handleMappingChange} onVatTreatmentChange={handleVatTreatmentChange}
|
||||
onContinue={confirmVatReview} onBack={goBack} />
|
||||
)}
|
||||
{step === 'review' && preview && (
|
||||
<ImportReviewStep preview={preview} mappings={mappings}
|
||||
@@ -900,7 +936,12 @@ function SIEImportWizard() {
|
||||
)}
|
||||
{step === 'result' && importResult && (
|
||||
<ImportResultStep result={importResult} onNewImport={handleNewImport} onUndo={handleUndo}
|
||||
preview={preview} theaterModel={theaterModel} />
|
||||
preview={preview} theaterModel={theaterModel}
|
||||
unresolvedVatAccountCount={mappings.filter((mapping) =>
|
||||
mapping.sourceAccount === mapping.targetAccount &&
|
||||
['3', '4', '5', '6'].includes(mapping.sourceAccount.charAt(0)) &&
|
||||
!mapping.vatTreatmentReviewed
|
||||
).length} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user