diff --git a/app/(dashboard)/bookkeeping/year-end/page.tsx b/app/(dashboard)/bookkeeping/year-end/page.tsx index bb94eb62..f9c2d61a 100644 --- a/app/(dashboard)/bookkeeping/year-end/page.tsx +++ b/app/(dashboard)/bookkeeping/year-end/page.tsx @@ -65,6 +65,7 @@ export default function YearEndPage() { const [executing, setExecuting] = useState(false) const [executeError, setExecuteError] = useState(null) const [result, setResult] = useState(null) + const [navigationBlocked, setNavigationBlocked] = useState(false) // ---- Load eligible periods ---- useEffect(() => { @@ -231,6 +232,7 @@ export default function YearEndPage() { annotation: `${p.period_start} till ${p.period_end}`, }))} value={selectedPeriodId} + disabled={navigationBlocked} onChange={(value) => { setSelectedPeriodId(value) setStep('preflight') @@ -296,13 +298,13 @@ export default function YearEndPage() { type="button" role="tab" aria-selected={s === step} - disabled={i >= currentStepIndex} + disabled={navigationBlocked || i >= currentStepIndex} onClick={() => { - if (i < currentStepIndex) setStep(s) + if (!navigationBlocked && i < currentStepIndex) setStep(s) }} className={cn( 'group flex shrink-0 items-center gap-2 text-left', - i >= currentStepIndex && 'cursor-default', + (navigationBlocked || i >= currentStepIndex) && 'cursor-default', )} > setStep('accruals')} onContinue={goToPreview} + onNavigationBlockedChange={setNavigationBlocked} /> )} diff --git a/app/api/assets/[id]/route.ts b/app/api/assets/[id]/route.ts index 76086d10..db8b82e0 100644 --- a/app/api/assets/[id]/route.ts +++ b/app/api/assets/[id]/route.ts @@ -6,7 +6,7 @@ import { validateBody } from '@/lib/api/validate' import { K3ComponentSchema } from '@/lib/api/schemas' import { getAsset, updateAsset } from '@/lib/bokslut/assets/asset-service' import { validateComponents } from '@/lib/bokslut/assets/k3-components' -import type { AssetCategory, DepreciationMethod } from '@/types' +import type { AssetCategory, WritableDepreciationMethod } from '@/types' const ASSET_CATEGORIES: readonly AssetCategory[] = [ 'immaterial', @@ -19,11 +19,8 @@ const ASSET_CATEGORIES: readonly AssetCategory[] = [ 'other_tangible', ] as const -const DEPRECIATION_METHODS: readonly DepreciationMethod[] = [ +const DEPRECIATION_METHODS: readonly WritableDepreciationMethod[] = [ 'linear', - 'declining_balance_30', - 'declining_balance_20', - 'restvardesavskrivning_25', ] as const const UpdateAssetSchema = z @@ -42,9 +39,12 @@ const UpdateAssetSchema = z salvage_value: z.number().nonnegative().optional(), useful_life_months: z.number().int().positive().optional(), depreciation_method: z - .enum(DEPRECIATION_METHODS as unknown as [DepreciationMethod, ...DepreciationMethod[]]) + .enum(DEPRECIATION_METHODS as unknown as [ + WritableDepreciationMethod, + ...WritableDepreciationMethod[], + ]) .optional(), - restvarde_target: z.number().nonnegative().nullable().optional(), + restvarde_target: z.null().optional(), bas_asset_account: z.string().regex(/^\d{4}$/).optional(), bas_accumulated_account: z.string().regex(/^\d{4}$/).optional(), bas_expense_account: z.string().regex(/^\d{4}$/).optional(), @@ -55,34 +55,6 @@ const UpdateAssetSchema = z // in the PATCH handler below, which can read the existing row. k3_components: z.array(K3ComponentSchema).nullable().optional(), }) - .superRefine((value, ctx) => { - // Enforce the method/target biconditional when EITHER field is supplied. - // We can't see the existing row from a zod refinement, so the - // application-level updateAsset() carries the cross-row check; here we - // only catch the obviously inconsistent combinations within a single - // PATCH body. - const hasMethod = value.depreciation_method !== undefined - const hasTarget = value.restvarde_target !== undefined - if (!hasMethod && !hasTarget) return - - const isRestvarde = value.depreciation_method === 'restvardesavskrivning_25' - const targetIsSet = value.restvarde_target !== null && value.restvarde_target !== undefined - - if (hasMethod && isRestvarde && hasTarget && !targetIsSet) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['restvarde_target'], - message: 'restvarde_target krävs när avskrivningsmetoden är restvärdeavskrivning (25 %).', - }) - } - if (hasMethod && !isRestvarde && hasTarget && targetIsSet) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['restvarde_target'], - message: 'restvarde_target får bara anges för restvärdeavskrivning (25 %).', - }) - } - }) export const GET = withRouteContext( 'assets.get', diff --git a/app/api/assets/__tests__/id.test.ts b/app/api/assets/__tests__/id.test.ts index 71550bb9..73716506 100644 --- a/app/api/assets/__tests__/id.test.ts +++ b/app/api/assets/__tests__/id.test.ts @@ -29,15 +29,19 @@ vi.mock('@/lib/auth/require-write', () => ({ })) vi.mock('@/lib/bokslut/assets/asset-service', () => ({ + createAsset: vi.fn(), + listAssets: vi.fn(), getAsset: vi.fn(), updateAsset: vi.fn(), })) -import { getAsset, updateAsset } from '@/lib/bokslut/assets/asset-service' +import { createAsset, getAsset, updateAsset } from '@/lib/bokslut/assets/asset-service' import { GET, PATCH } from '../[id]/route' +import { POST } from '../route' const mockGetAsset = vi.mocked(getAsset) const mockUpdateAsset = vi.mocked(updateAsset) +const mockCreateAsset = vi.mocked(createAsset) const routeParams = { params: Promise.resolve({ id: 'asset-1' }) } beforeEach(() => { @@ -71,7 +75,60 @@ describe('GET /api/assets/[id]', () => { }) }) +describe('POST /api/assets', () => { + it('rejects legacy per-asset tax depreciation methods with 400', async () => { + const response = await POST(createMockRequest('/api/assets', { + method: 'POST', + body: { + name: 'Maskin', + category: 'machinery', + acquisition_date: '2025-01-01', + acquisition_cost: 100_000, + useful_life_months: 60, + depreciation_method: 'declining_balance_30', + }, + })) + + expect(response.status).toBe(400) + }) + + it('creates a valid asset with ordinary linear depreciation', async () => { + mockCreateAsset.mockResolvedValue({ id: 'asset-new', depreciation_method: 'linear' } as never) + const response = await POST(createMockRequest('/api/assets', { + method: 'POST', + body: { + name: 'Maskin', + category: 'machinery', + acquisition_date: '2025-01-01', + acquisition_cost: 100_000, + useful_life_months: 60, + depreciation_method: 'linear', + }, + })) + + expect(response.status).toBe(200) + expect(mockCreateAsset).toHaveBeenCalledWith( + supabase, + 'company-1', + 'user-1', + expect.objectContaining({ depreciation_method: 'linear' }), + ) + }) +}) + describe('PATCH /api/assets/[id]', () => { + it('rejects legacy per-asset tax depreciation methods with 400', async () => { + const req = createMockRequest('/api/assets/asset-1', { + method: 'PATCH', + body: { depreciation_method: 'declining_balance_30' }, + }) + + const { status } = await parseJsonResponse(await PATCH(req, routeParams)) + + expect(status).toBe(400) + expect(mockUpdateAsset).not.toHaveBeenCalled() + }) + it('rejects an invalid body (non-positive acquisition_cost) with 400', async () => { const req = createMockRequest('/api/assets/asset-1', { method: 'PATCH', diff --git a/app/api/assets/route.ts b/app/api/assets/route.ts index 3a89537e..4714c106 100644 --- a/app/api/assets/route.ts +++ b/app/api/assets/route.ts @@ -6,7 +6,7 @@ import { validateBody } from '@/lib/api/validate' import { K3ComponentSchema } from '@/lib/api/schemas' import { createAsset, listAssets } from '@/lib/bokslut/assets/asset-service' import { validateComponents } from '@/lib/bokslut/assets/k3-components' -import type { AssetCategory, DepreciationMethod } from '@/types' +import type { AssetCategory, WritableDepreciationMethod } from '@/types' const ASSET_CATEGORIES: readonly AssetCategory[] = [ 'immaterial', @@ -19,14 +19,8 @@ const ASSET_CATEGORIES: readonly AssetCategory[] = [ 'other_tangible', ] as const -// All four depreciation methods are now implemented by the engine. The DB -// CHECK constraint mirrors this list (see -// 20260526120100_restvardeavskrivning.sql). -const DEPRECIATION_METHODS: readonly DepreciationMethod[] = [ +const DEPRECIATION_METHODS: readonly WritableDepreciationMethod[] = [ 'linear', - 'declining_balance_30', - 'declining_balance_20', - 'restvardesavskrivning_25', ] as const const CreateAssetSchema = z @@ -40,13 +34,12 @@ const CreateAssetSchema = z salvage_value: z.number().nonnegative().optional(), useful_life_months: z.number().int().positive(), depreciation_method: z - .enum(DEPRECIATION_METHODS as unknown as [DepreciationMethod, ...DepreciationMethod[]]) + .enum(DEPRECIATION_METHODS as unknown as [ + WritableDepreciationMethod, + ...WritableDepreciationMethod[], + ]) .optional(), - // Restvärde-target floor for restvärdeavskrivning. Required iff - // depreciation_method = 'restvardesavskrivning_25'. The DB CHECK enforces - // the same biconditional; we mirror it in the API for an early, Swedish - // error message rather than a Postgres check_violation surfacing. - restvarde_target: z.number().nonnegative().nullable().optional(), + restvarde_target: z.null().optional(), bas_asset_account: z.string().regex(/^\d{4}$/).optional(), bas_accumulated_account: z.string().regex(/^\d{4}$/).optional(), bas_expense_account: z.string().regex(/^\d{4}$/).optional(), @@ -63,7 +56,6 @@ const CreateAssetSchema = z // outside the legitimate range for the asset category so the chart stays // BAS-aligned and INK2R mappings continue to work. validateBasOverrides(value, ctx) - validateRestvardeTarget(value, ctx) validateK3Components(value, ctx) }) @@ -88,45 +80,6 @@ function validateK3Components( } } -function validateRestvardeTarget( - value: { - depreciation_method?: DepreciationMethod - restvarde_target?: number | null - acquisition_cost?: number - }, - ctx: z.RefinementCtx, -): void { - const isRestvarde = value.depreciation_method === 'restvardesavskrivning_25' - const hasTarget = value.restvarde_target !== undefined && value.restvarde_target !== null - if (isRestvarde && !hasTarget) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['restvarde_target'], - message: 'restvarde_target krävs när avskrivningsmetoden är restvärdeavskrivning (25 %).', - }) - } - if (!isRestvarde && hasTarget) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['restvarde_target'], - message: 'restvarde_target får bara anges för restvärdeavskrivning (25 %).', - }) - } - if ( - isRestvarde && - hasTarget && - value.acquisition_cost !== undefined && - (value.restvarde_target as number) >= value.acquisition_cost - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['restvarde_target'], - message: - 'restvarde_target måste vara lägre än anskaffningsvärdet: annars finns inget kvar att skriva av.', - }) - } -} - function validateBasOverrides( value: { category: AssetCategory diff --git a/app/api/bookkeeping/fiscal-periods/[id]/depreciation/__tests__/route.test.ts b/app/api/bookkeeping/fiscal-periods/[id]/depreciation/__tests__/route.test.ts new file mode 100644 index 00000000..6742e778 --- /dev/null +++ b/app/api/bookkeeping/fiscal-periods/[id]/depreciation/__tests__/route.test.ts @@ -0,0 +1,259 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { NextResponse } from 'next/server' +import { createMockRequest, parseJsonResponse } from '@/tests/helpers' + +vi.mock('@/lib/bokslut/assets/depreciation-engine', () => ({ + proposeAnnualPostings: vi.fn(), + commitAnnualPostings: vi.fn(), +})) + +vi.mock('@/lib/bokslut/assets/tax-depreciation-service', async (importOriginal) => { + const actual = await importOriginal< + typeof import('@/lib/bokslut/assets/tax-depreciation-service') + >() + return { + ...actual, + loadTaxDepreciationView: vi.fn(), + previewTaxDepreciationElection: vi.fn(), + saveTaxDepreciationElection: vi.fn(), + } +}) + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +import { + commitAnnualPostings, + proposeAnnualPostings, +} from '@/lib/bokslut/assets/depreciation-engine' +import { + loadTaxDepreciationView, + previewTaxDepreciationElection, + saveTaxDepreciationElection, + TaxDepreciationPeriodLockedError, +} from '@/lib/bokslut/assets/tax-depreciation-service' +import { GET, POST, PUT } from '../route' + +const params = { params: Promise.resolve({ id: 'period-1' }) } +const periodBuilder = { + select: vi.fn(), + eq: vi.fn(), + single: vi.fn(), +} +periodBuilder.select.mockReturnValue(periodBuilder) +periodBuilder.eq.mockReturnValue(periodBuilder) +const supabase = { from: vi.fn().mockReturnValue(periodBuilder) } +const ordinary = { + fiscalPeriod: { + id: 'period-1', + name: '2025', + period_start: '2025-01-01', + period_end: '2025-12-31', + }, + items: [], + totalAmount: 0, +} +const tax = { + status: 'ready' as const, + method: 'rakenskapsenlig' as const, + selectedRule: 'huvudregel_30' as const, + methodLocked: false, + openingTaxValue: 100_000, + openingSource: 'saved' as const, + periodMonths: 12, + eligibleAssetCount: 2, + excludedAssetCount: 0, + excludedCategories: [], + cohortHistoryComplete: true, + incompleteCohortCount: 0, + result: null, + snapshot: null, + isStale: false, +} + +function get() { + return GET(createMockRequest('/api/bookkeeping/fiscal-periods/period-1/depreciation'), params) +} + +function put(body: unknown) { + return PUT( + createMockRequest('/api/bookkeeping/fiscal-periods/period-1/depreciation', { + method: 'PUT', + body, + }), + params, + ) +} + +function post(body: unknown) { + return POST( + createMockRequest('/api/bookkeeping/fiscal-periods/period-1/depreciation', { + method: 'POST', + body, + }), + params, + ) +} + +beforeEach(() => { + vi.clearAllMocks() + requireAuthMock.mockResolvedValue({ + user: { id: 'user-1' }, + supabase, + error: null, + }) + requireWriteMock.mockResolvedValue({ ok: true }) + vi.mocked(proposeAnnualPostings).mockResolvedValue(ordinary) + vi.mocked(loadTaxDepreciationView).mockResolvedValue(tax) + vi.mocked(previewTaxDepreciationElection).mockResolvedValue(tax) + vi.mocked(saveTaxDepreciationElection).mockResolvedValue(tax) + vi.mocked(commitAnnualPostings).mockResolvedValue({ posted: [], skipped: [] }) + periodBuilder.single.mockResolvedValue({ + data: { is_closed: false, locked_at: null, closing_entry_id: null }, + error: null, + }) +}) + +describe('GET /api/bookkeeping/fiscal-periods/[id]/depreciation', () => { + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: {}, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + expect((await get()).status).toBe(401) + }) + + it('returns ordinary and tax depreciation for the active company', async () => { + const { status, body } = await parseJsonResponse<{ data: ProposalWithTax }>( + await get(), + ) + expect(status).toBe(200) + expect(body.data).toEqual({ ...ordinary, tax }) + expect(loadTaxDepreciationView).toHaveBeenCalledWith(supabase, 'company-1', 'period-1') + }) + + it('returns a read-only tax preview for validated query inputs', async () => { + const request = createMockRequest( + '/api/bookkeeping/fiscal-periods/period-1/depreciation?tax_method=rakenskapsenlig&tax_rule=huvudregel_30&opening_tax_value=100000', + ) + expect((await GET(request, params)).status).toBe(200) + expect(previewTaxDepreciationElection).toHaveBeenCalledWith( + supabase, + 'company-1', + 'period-1', + { + method: 'rakenskapsenlig', + selectedRule: 'huvudregel_30', + openingTaxValue: 100_000, + }, + ) + }) + + it('returns 404 when the fiscal period is missing', async () => { + vi.mocked(loadTaxDepreciationView).mockRejectedValue(new Error('Fiscal period not found')) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await get()) + expect(status).toBe(404) + expect(body.error.code).toBe('PERIOD_NOT_FOUND') + }) +}) + +describe('PUT /api/bookkeeping/fiscal-periods/[id]/depreciation', () => { + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: {}, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + expect((await put({ + method: 'restvarde', + opening_tax_value: 100_000, + elected_deduction: 25_000, + })).status).toBe(401) + }) + + it('returns 400 for an incoherent method and annual rule', async () => { + expect((await put({ + method: 'restvarde', + selected_rule: 'huvudregel_30', + elected_deduction: 25_000, + })).status).toBe(400) + expect(saveTaxDepreciationElection).not.toHaveBeenCalled() + }) + + it('returns 404 when the fiscal period is missing', async () => { + vi.mocked(saveTaxDepreciationElection).mockRejectedValue(new Error('Fiscal period not found')) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await put({ + method: 'restvarde', + opening_tax_value: 100_000, + elected_deduction: 25_000, + }), + ) + expect(status).toBe(404) + expect(body.error.code).toBe('PERIOD_NOT_FOUND') + }) + + it('returns PERIOD_LOCKED when the snapshot cannot be saved', async () => { + vi.mocked(saveTaxDepreciationElection).mockRejectedValue( + new TaxDepreciationPeriodLockedError('Fiscal period is locked'), + ) + const { body } = await parseJsonResponse<{ error: { code: string } }>( + await put({ + method: 'restvarde', + opening_tax_value: 100_000, + elected_deduction: 25_000, + }), + ) + expect(body.error.code).toBe('PERIOD_LOCKED') + }) + + it('saves a validated annual election for the active company', async () => { + const { status } = await parseJsonResponse( + await put({ + method: 'rakenskapsenlig', + selected_rule: 'kompletteringsregel_20', + opening_tax_value: 100_000, + elected_deduction: 20_000, + book_conformity_confirmed: true, + }), + ) + expect(status).toBe(200) + expect(saveTaxDepreciationElection).toHaveBeenCalledWith( + supabase, + 'company-1', + 'user-1', + 'period-1', + { + method: 'rakenskapsenlig', + selectedRule: 'kompletteringsregel_20', + openingTaxValue: 100_000, + electedDeduction: 20_000, + bookConformityConfirmed: true, + }, + ) + }) +}) + +type ProposalWithTax = typeof ordinary & { tax: typeof tax } + +describe('POST /api/bookkeeping/fiscal-periods/[id]/depreciation', () => { + it('continues to post ordinary depreciation only', async () => { + expect((await post({})).status).toBe(200) + expect(commitAnnualPostings).toHaveBeenCalledWith(supabase, 'company-1', 'user-1', 'period-1', { + assetIds: undefined, + }) + }) +}) diff --git a/app/api/bookkeeping/fiscal-periods/[id]/depreciation/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/depreciation/route.ts index 3869fd99..74a03c1e 100644 --- a/app/api/bookkeeping/fiscal-periods/[id]/depreciation/route.ts +++ b/app/api/bookkeeping/fiscal-periods/[id]/depreciation/route.ts @@ -2,11 +2,18 @@ import { NextResponse } from 'next/server' import { z } from 'zod' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' -import { validateBody } from '@/lib/api/validate' +import { validateBody, validateQuery } from '@/lib/api/validate' import { proposeAnnualPostings, commitAnnualPostings, } from '@/lib/bokslut/assets/depreciation-engine' +import { + loadTaxDepreciationView, + previewTaxDepreciationElection, + saveTaxDepreciationElection, + TaxDepreciationPeriodLockedError, + TaxDepreciationValidationError, +} from '@/lib/bokslut/assets/tax-depreciation-service' const CommitSchema = z.object({ /** Optional whitelist: when supplied, only assets in this list are posted. @@ -14,15 +21,99 @@ const CommitSchema = z.object({ asset_ids: z.array(z.string().uuid()).optional(), }) +const TaxElectionSchema = z + .object({ + method: z.enum(['rakenskapsenlig', 'restvarde']), + selected_rule: z.enum(['huvudregel_30', 'kompletteringsregel_20']).optional(), + opening_tax_value: z.number().nonnegative().optional(), + elected_deduction: z.number().nonnegative(), + book_conformity_confirmed: z.boolean().optional(), + }) + .superRefine((value, ctx) => { + if (value.method === 'rakenskapsenlig' && !value.selected_rule) { + ctx.addIssue({ + code: 'custom', + path: ['selected_rule'], + message: 'Välj 30-procentsregeln eller 20-procentsregeln.', + }) + } + if (value.method === 'restvarde' && value.selected_rule) { + ctx.addIssue({ + code: 'custom', + path: ['selected_rule'], + message: 'Restvärdeavskrivning har ingen kompletteringsregel.', + }) + } + if (value.method === 'rakenskapsenlig' && value.book_conformity_confirmed !== true) { + ctx.addIssue({ + code: 'custom', + path: ['book_conformity_confirmed'], + message: 'Bekräfta att avdraget motsvarar bokslutets totala avskrivning.', + }) + } + }) + +const TaxPreviewQuerySchema = z + .object({ + tax_method: z.enum(['rakenskapsenlig', 'restvarde']).optional(), + tax_rule: z.enum(['huvudregel_30', 'kompletteringsregel_20']).optional(), + opening_tax_value: z.coerce.number().nonnegative().optional(), + }) + .superRefine((value, ctx) => { + if (!value.tax_method && (value.tax_rule || value.opening_tax_value !== undefined)) { + ctx.addIssue({ + code: 'custom', + path: ['tax_method'], + message: 'tax_method is required for a tax depreciation preview.', + }) + } + if (value.tax_method === 'rakenskapsenlig' && !value.tax_rule) { + ctx.addIssue({ + code: 'custom', + path: ['tax_rule'], + message: 'tax_rule is required for räkenskapsenlig depreciation.', + }) + } + if (value.tax_method === 'restvarde' && value.tax_rule) { + ctx.addIssue({ + code: 'custom', + path: ['tax_rule'], + message: 'tax_rule is not valid for restvärdeavskrivning.', + }) + } + }) + export const GET = withRouteContext( 'period.depreciation_preview', - async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { + async (request, ctx, { params }: { params: Promise<{ id: string }> }) => { const { id } = await params const { supabase, companyId, log, requestId } = ctx + const query = validateQuery(request, TaxPreviewQuerySchema, { + log, + operation: 'period.depreciation_preview', + }) + if (!query.success) return query.response try { - const proposal = await proposeAnnualPostings(supabase, companyId, id) - return NextResponse.json({ data: proposal }) + const [ordinary, tax] = await Promise.all([ + proposeAnnualPostings(supabase, companyId, id), + query.data.tax_method + ? previewTaxDepreciationElection(supabase, companyId, id, { + method: query.data.tax_method, + selectedRule: query.data.tax_rule, + openingTaxValue: query.data.opening_tax_value, + }) + : loadTaxDepreciationView(supabase, companyId, id), + ]) + return NextResponse.json({ data: { ...ordinary, tax } }) } catch (err) { + if (err instanceof TaxDepreciationValidationError) { + return errorResponseFromCode('VALIDATION_ERROR', log, { + requestId, + status: 400, + messageSv: 'Valet för skattemässig avskrivning är ogiltigt.', + messageEn: 'The tax depreciation election is invalid.', + }) + } const message = err instanceof Error ? err.message : '' if (/not found/i.test(message)) { return errorResponseFromCode('PERIOD_NOT_FOUND', log, { requestId }) @@ -32,6 +123,51 @@ export const GET = withRouteContext( }, ) +export const PUT = withRouteContext( + 'period.tax_depreciation_save', + async (request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { user, supabase, companyId, log, requestId } = ctx + const validation = await validateBody(request, TaxElectionSchema) + if (!validation.success) return validation.response + + try { + const tax = await saveTaxDepreciationElection( + supabase, + companyId, + user.id, + id, + { + method: validation.data.method, + selectedRule: validation.data.selected_rule, + openingTaxValue: validation.data.opening_tax_value, + electedDeduction: validation.data.elected_deduction, + bookConformityConfirmed: validation.data.book_conformity_confirmed, + }, + ) + return NextResponse.json({ data: tax }) + } catch (err) { + if (err instanceof TaxDepreciationValidationError) { + return errorResponseFromCode('VALIDATION_ERROR', log, { + requestId, + status: 400, + messageSv: 'Valet för skattemässig avskrivning är ogiltigt.', + messageEn: 'The tax depreciation election is invalid.', + }) + } + if (err instanceof TaxDepreciationPeriodLockedError) { + return errorResponseFromCode('PERIOD_LOCKED', log, { requestId }) + } + const message = err instanceof Error ? err.message : '' + if (/not found/i.test(message)) { + return errorResponseFromCode('PERIOD_NOT_FOUND', log, { requestId }) + } + return errorResponse(err, log, { requestId }) + } + }, + { requireWrite: true }, +) + export const POST = withRouteContext( 'period.depreciation_commit', async (request, ctx, { params }: { params: Promise<{ id: string }> }) => { diff --git a/components/bookkeeping/assets/CreateAssetDialog.tsx b/components/bookkeeping/assets/CreateAssetDialog.tsx index 2a81ca84..84d3ec4a 100644 --- a/components/bookkeeping/assets/CreateAssetDialog.tsx +++ b/components/bookkeeping/assets/CreateAssetDialog.tsx @@ -22,7 +22,7 @@ import { Loader2, Plus, X } from 'lucide-react' import { useToast } from '@/components/ui/use-toast' import { useCompanyOptional } from '@/contexts/CompanyContext' import { formatCurrency } from '@/lib/utils' -import type { AssetCategory, DepreciationMethod, K3Component } from '@/types' +import type { AssetCategory, K3Component } from '@/types' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' interface CreateAssetDialogProps { @@ -67,29 +67,6 @@ const CATEGORY_OPTIONS: { value: AssetCategory; label: string; defaultYears: num { value: 'other_tangible', label: 'Övrig materiell tillgång', defaultYears: 5 }, ] -const DEPRECIATION_METHOD_OPTIONS: { value: DepreciationMethod; label: string; hint: string }[] = [ - { - value: 'linear', - label: 'Linjär', - hint: 'Planenlig raklinje över nyttjandeperioden (ÅRL 4 kap 4§).', - }, - { - value: 'declining_balance_30', - label: 'Räkenskapsenlig 30 %', - hint: 'Huvudregeln (IL 18 kap 13§): 30 % degressivt på avskrivningsunderlaget.', - }, - { - value: 'declining_balance_20', - label: 'Räkenskapsenlig 20 %', - hint: 'Kompletteringsregeln (IL 18 kap 17§): 20 % degressivt. Vanlig för byggnader.', - }, - { - value: 'restvardesavskrivning_25', - label: 'Restvärdeavskrivning 25 %', - hint: 'IL 18 kap 13§ st.3: 25 % degressivt ner till angivet restvärde.', - }, -] - export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAssetDialogProps) { const { toast } = useToast() // useCompanyOptional so the dialog still works in tests / storyboards @@ -104,8 +81,6 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset ) const [acquisitionCost, setAcquisitionCost] = useState('') const [usefulLifeYears, setUsefulLifeYears] = useState('5') - const [depreciationMethod, setDepreciationMethod] = useState('linear') - const [restvardeTarget, setRestvardeTarget] = useState('') // K3 component depreciation. `useComponents` toggles the advanced section; // null when disabled, an array (possibly empty during editing) when enabled. const [useComponents, setUseComponents] = useState(false) @@ -119,10 +94,6 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset if (option) setUsefulLifeYears(option.defaultYears.toString()) } - const isRestvarde = depreciationMethod === 'restvardesavskrivning_25' - const methodHint = - DEPRECIATION_METHOD_OPTIONS.find((o) => o.value === depreciationMethod)?.hint ?? '' - const totalComponentCost = useMemo(() => { return componentRows.reduce((sum, row) => { const v = parseFloat(row.cost) @@ -161,19 +132,6 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset setError('Fyll i namn, anskaffningsvärde och avskrivningstid.') return } - let restvardeTargetNumber: number | null = null - if (isRestvarde) { - const parsed = parseFloat(restvardeTarget) - if (!Number.isFinite(parsed) || parsed < 0) { - setError('Ange ett restvärde (0 kr eller högre).') - return - } - if (parsed >= cost) { - setError('Restvärdet måste vara lägre än anskaffningsvärdet.') - return - } - restvardeTargetNumber = parsed - } // K3 components: only when both the framework permits (gate at API) // and the user opted into the section. Empty array is invalid (the // validator rejects it) so the dialog also flips back to "off" when @@ -239,10 +197,6 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset acquisition_date: acquisitionDate, acquisition_cost: cost, useful_life_months: years * 12, - depreciation_method: depreciationMethod, - ...(restvardeTargetNumber !== null - ? { restvarde_target: restvardeTargetNumber } - : {}), ...(componentsPayload !== null ? { k3_components: componentsPayload } : {}), }), }) @@ -255,8 +209,6 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset // Reset form for next entry setName('') setAcquisitionCost('') - setDepreciationMethod('linear') - setRestvardeTarget('') setUseComponents(false) setComponentRows([]) onCreated() @@ -340,44 +292,6 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset För skattemässig avskrivning kan annan livslängd gälla (IL 18-20 kap).

-
- - -

{methodHint}

-
- {isRestvarde && ( -
- - setRestvardeTarget(e.target.value)} - placeholder="t.ex. 5000" - className="tabular-nums" - /> -

- Avskrivningen stannar när bokfört värde når restvärdet. Restvärdet - måste vara lägre än anskaffningsvärdet. -

-
- )} {isK3 && (
diff --git a/components/bookkeeping/assets/EditAssetDialog.tsx b/components/bookkeeping/assets/EditAssetDialog.tsx index 6d29a35b..3921c69a 100644 --- a/components/bookkeeping/assets/EditAssetDialog.tsx +++ b/components/bookkeeping/assets/EditAssetDialog.tsx @@ -18,11 +18,11 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' -import { AlertTriangle, Loader2, Lock } from 'lucide-react' +import { Loader2, Lock } from 'lucide-react' import { useToast } from '@/components/ui/use-toast' import { useCanWrite } from '@/lib/hooks/use-can-write' import { getErrorMessage } from '@/lib/errors/get-error-message' -import type { Asset, AssetCategory, DepreciationMethod } from '@/types' +import type { Asset, AssetCategory } from '@/types' /** The list route annotates each asset with whether any depreciation has been * posted against it. When true, the acquisition-basis fields are locked. */ @@ -35,8 +35,7 @@ interface EditAssetDialogProps { onSaved: () => void } -// Same category labels and depreciation hints as CreateAssetDialog (kept in -// sync deliberately: this register surface is Swedish-only, like creation). +// Same category labels as CreateAssetDialog. const CATEGORY_OPTIONS: { value: AssetCategory; label: string }[] = [ { value: 'computer', label: 'Dator / IT-utrustning' }, { value: 'equipment', label: 'Inventarier' }, @@ -48,29 +47,6 @@ const CATEGORY_OPTIONS: { value: AssetCategory; label: string }[] = [ { value: 'other_tangible', label: 'Övrig materiell tillgång' }, ] -const DEPRECIATION_METHOD_OPTIONS: { value: DepreciationMethod; label: string; hint: string }[] = [ - { - value: 'linear', - label: 'Linjär', - hint: 'Planenlig raklinje över nyttjandeperioden (ÅRL 4 kap 4§).', - }, - { - value: 'declining_balance_30', - label: 'Räkenskapsenlig 30 %', - hint: 'Huvudregeln (IL 18 kap 13§): 30 % degressivt på avskrivningsunderlaget.', - }, - { - value: 'declining_balance_20', - label: 'Räkenskapsenlig 20 %', - hint: 'Kompletteringsregeln (IL 18 kap 17§): 20 % degressivt. Vanlig för byggnader.', - }, - { - value: 'restvardesavskrivning_25', - label: 'Restvärdeavskrivning 25 %', - hint: 'IL 18 kap 13§ st.3: 25 % degressivt ner till angivet restvärde.', - }, -] - export function EditAssetDialog({ asset, open, onOpenChange, onSaved }: EditAssetDialogProps) { const { toast } = useToast() const { canWrite } = useCanWrite() @@ -86,19 +62,9 @@ export function EditAssetDialog({ asset, open, onOpenChange, onSaved }: EditAsse const [usefulLifeYears, setUsefulLifeYears] = useState( String(Math.round(asset.useful_life_months / 12)), ) - const [depreciationMethod, setDepreciationMethod] = useState( - asset.depreciation_method, - ) - const [restvardeTarget, setRestvardeTarget] = useState( - asset.restvarde_target != null ? String(asset.restvarde_target) : '', - ) const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) - const isRestvarde = depreciationMethod === 'restvardesavskrivning_25' - const methodHint = - DEPRECIATION_METHOD_OPTIONS.find((o) => o.value === depreciationMethod)?.hint ?? '' - const handleSubmit = async () => { setError(null) const trimmedName = name.trim() @@ -133,31 +99,6 @@ export function EditAssetDialog({ asset, open, onOpenChange, onSaved }: EditAsse const months = years * 12 if (months !== asset.useful_life_months) patch.useful_life_months = months - if (depreciationMethod !== asset.depreciation_method) { - patch.depreciation_method = depreciationMethod - } - - if (isRestvarde) { - const target = parseFloat(restvardeTarget) - const cost = !basisLocked ? parseFloat(acquisitionCost) : Number(asset.acquisition_cost) - if (!Number.isFinite(target) || target < 0) { - setError('Ange ett restvärde (0 kr eller högre).') - return - } - if (Number.isFinite(cost) && target >= cost) { - setError('Restvärdet måste vara lägre än anskaffningsvärdet.') - return - } - // Send the target when switching into restvärde or when it changed, so - // the method/target biconditional always holds. - if ( - depreciationMethod !== asset.depreciation_method || - target !== Number(asset.restvarde_target) - ) { - patch.restvarde_target = target - } - } - if (Object.keys(patch).length === 0) { toast({ title: 'Inga ändringar', description: 'Inget att spara.' }) onOpenChange(false) @@ -255,7 +196,7 @@ export function EditAssetDialog({ asset, open, onOpenChange, onSaved }: EditAsse Anskaffningsdatum, anskaffningsvärde och kategori är låsta eftersom avskrivningar redan har bokförts. Återför avskrivningen (storno) eller använd avyttring för att - ändra grunduppgifterna. Namn, avskrivningstid och metod kan fortfarande justeras. + ändra grunduppgifterna. Namn och avskrivningstid kan fortfarande justeras.
)} @@ -274,58 +215,6 @@ export function EditAssetDialog({ asset, open, onOpenChange, onSaved }: EditAsse />
-
- - -

{methodHint}

-
- - {basisLocked && depreciationMethod !== asset.depreciation_method && ( -
- - - Byte av avskrivningsmetod efter att avskrivning påbörjats. Enligt K2 - (BFNAR 2016:10 p. 10.26) ska vald metod tillämpas konsekvent: ändra - bara vid särskilda skäl och lämna i så fall upplysning i bokslutet. - Ändringen gäller framåt; redan bokförda avskrivningar påverkas inte. - -
- )} - - {isRestvarde && ( -
- - setRestvardeTarget(e.target.value)} - placeholder="t.ex. 5000" - className="tabular-nums" - /> -

- Avskrivningen stannar när bokfört värde når restvärdet. Restvärdet måste vara lägre - än anskaffningsvärdet. -

-
- )} - {error && (
{error} diff --git a/components/bookkeeping/year-end/DepreciationPanel.tsx b/components/bookkeeping/year-end/DepreciationPanel.tsx index d90ce8e9..cd97278c 100644 --- a/components/bookkeeping/year-end/DepreciationPanel.tsx +++ b/components/bookkeeping/year-end/DepreciationPanel.tsx @@ -1,11 +1,21 @@ 'use client' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Checkbox } from '@/components/ui/checkbox' import { Badge } from '@/components/ui/badge' import { Skeleton } from '@/components/ui/skeleton' import { Loader2 } from 'lucide-react' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' import Link from 'next/link' import { Table, @@ -35,19 +45,73 @@ interface Proposal { totalAmount: number } +type TaxMethod = 'rakenskapsenlig' | 'restvarde' +type TaxRule = 'huvudregel_30' | 'kompletteringsregel_20' + +interface TaxAlternative { + rule: TaxRule | 'restvarde_25' + rate: number | null + deduction: number + closingTaxValue: number +} + +interface TaxResult { + openingTaxValue: number + additions: number + disposals: number + basis: number + maximumDeduction: number + deduction: number + closingTaxValue: number + excessDisposals: number + alternatives: TaxAlternative[] +} + +interface TaxView { + status: + | 'needs_previous_period' + | 'needs_period_history' + | 'needs_method' + | 'needs_opening_value' + | 'needs_rule' + | 'ready' + method: TaxMethod | null + selectedRule: TaxRule | null + methodLocked: boolean + openingTaxValue: number | null + openingSource: 'saved' | 'previous_period' | 'previous_period_required' | 'manual_required' + periodMonths: number + eligibleAssetCount: number + excludedAssetCount: number + excludedCategories: string[] + cohortHistoryComplete: boolean + incompleteCohortCount: number + result: TaxResult | null + snapshot: { deduction: number } | null + isStale: boolean +} + interface DepreciationPanelProps { periodId: string /** Called after a successful post: parent refetches dispositions because * posted avskrivningar change the result which affects bolagsskatt etc. */ onPosted: () => void + onTaxDirtyChange?: (dirty: boolean) => void } -export function DepreciationPanel({ periodId, onPosted }: DepreciationPanelProps) { +export function DepreciationPanel({ periodId, onPosted, onTaxDirtyChange }: DepreciationPanelProps) { const { toast } = useToast() const [proposal, setProposal] = useState(null) + const [tax, setTax] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [posting, setPosting] = useState(false) + const [taxDirty, setTaxDirty] = useState(false) + + const handleTaxDirtyChange = useCallback((dirty: boolean) => { + setTaxDirty(dirty) + onTaxDirtyChange?.(dirty) + }, [onTaxDirtyChange]) const load = useCallback(async () => { setLoading(true) @@ -60,6 +124,7 @@ export function DepreciationPanel({ periodId, onPosted }: DepreciationPanelProps return } setProposal(body.data as Proposal) + setTax(body.data.tax as TaxView) } catch (err) { setError(err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel') } finally { @@ -72,6 +137,10 @@ export function DepreciationPanel({ periodId, onPosted }: DepreciationPanelProps }, [load]) const handlePost = useCallback(async () => { + if (taxDirty) { + setError('Spara eller återställ ändringarna i skattemässig avskrivning först.') + return + } setPosting(true) try { const res = await fetch(`/api/bookkeeping/fiscal-periods/${periodId}/depreciation`, { @@ -97,7 +166,7 @@ export function DepreciationPanel({ periodId, onPosted }: DepreciationPanelProps } finally { setPosting(false) } - }, [periodId, onPosted, load, toast]) + }, [periodId, onPosted, load, toast, taxDirty]) if (loading) { return ( @@ -120,6 +189,15 @@ export function DepreciationPanel({ periodId, onPosted }: DepreciationPanelProps if (!proposal) return null + const taxPanel = tax ? ( + + ) : null + const allPosted = proposal.items.length > 0 && proposal.items.every((i) => Boolean(i.existingJournalEntryId)) const anyPending = @@ -127,22 +205,26 @@ export function DepreciationPanel({ periodId, onPosted }: DepreciationPanelProps if (proposal.items.length === 0) { return ( - - - Planenliga avskrivningar - - - Inga aktiva anläggningstillgångar att skriva av.{' '} - - Lägg till tillgångar - {' '} - så räknar bokslutet ut avskrivningarna automatiskt. - - +
+ + + Planenliga avskrivningar + + + Inga aktiva anläggningstillgångar att skriva av.{' '} + + Lägg till tillgångar + {' '} + så räknar bokslutet ut avskrivningarna automatiskt. + + + {taxPanel} +
) } return ( +
@@ -195,7 +277,7 @@ export function DepreciationPanel({ periodId, onPosted }: DepreciationPanelProps {anyPending && (
-
+ ) +} + +function TaxDepreciationCard({ + periodId, + view, + onSaved, + onDirtyChange, +}: { + periodId: string + view: TaxView + onSaved: () => Promise + onDirtyChange?: (dirty: boolean) => void +}) { + const { toast } = useToast() + const [method, setMethod] = useState(view.method ?? 'rakenskapsenlig') + const [rule, setRule] = useState(view.selectedRule ?? 'huvudregel_30') + const [openingValue, setOpeningValue] = useState( + view.openingTaxValue === null ? '' : String(view.openingTaxValue), + ) + const [electedDeduction, setElectedDeduction] = useState( + view.snapshot ? String(view.snapshot.deduction) : '', + ) + const [bookConformityConfirmed, setBookConformityConfirmed] = useState(false) + const [preview, setPreview] = useState(null) + const [dirty, setDirty] = useState(false) + const [previewing, setPreviewing] = useState(false) + const [saving, setSaving] = useState(false) + const [saveError, setSaveError] = useState(null) + const requestVersion = useRef(0) + const saveSequence = useRef(0) + + useEffect(() => { + requestVersion.current += 1 + setMethod(view.method ?? 'rakenskapsenlig') + setRule(view.selectedRule ?? 'huvudregel_30') + setOpeningValue(view.openingTaxValue === null ? '' : String(view.openingTaxValue)) + setElectedDeduction(view.snapshot ? String(view.snapshot.deduction) : '') + setBookConformityConfirmed(false) + setPreview(null) + setDirty(false) + }, [view]) + + useEffect(() => { + onDirtyChange?.(dirty) + }, [dirty, onDirtyChange]) + + useEffect(() => () => onDirtyChange?.(false), [onDirtyChange]) + + const resetCalculatedDraft = () => { + setPreview(null) + setElectedDeduction('') + setBookConformityConfirmed(false) + setSaveError(null) + setDirty(true) + } + + const restoreSavedValues = () => { + requestVersion.current += 1 + setMethod(view.method ?? 'rakenskapsenlig') + setRule(view.selectedRule ?? 'huvudregel_30') + setOpeningValue(view.openingTaxValue === null ? '' : String(view.openingTaxValue)) + setElectedDeduction(view.snapshot ? String(view.snapshot.deduction) : '') + setBookConformityConfirmed(false) + setPreview(null) + setPreviewing(false) + setSaving(false) + setSaveError(null) + setDirty(false) + } + + const parseOpening = (): number | null => { + if (openingValue.trim() === '') { + setSaveError('Ange skattemässigt värde vid årets ingång.') + return null + } + const opening = Number(openingValue.replace(',', '.')) + if (!Number.isFinite(opening) || opening < 0) { + setSaveError('Ange ett ingående skattemässigt värde på 0 kr eller mer.') + return null + } + return opening + } + + const loadPreview = async () => { + setSaveError(null) + const opening = parseOpening() + if (opening === null) return + setDirty(true) + setPreviewing(true) + const version = ++requestVersion.current + try { + const query = new URLSearchParams({ + tax_method: method, + opening_tax_value: String(opening), + }) + if (method === 'rakenskapsenlig') query.set('tax_rule', rule) + const response = await fetch( + `/api/bookkeeping/fiscal-periods/${periodId}/depreciation?${query.toString()}`, + ) + const body = await response.json() + if (version !== requestVersion.current) return + if (!response.ok) { + setSaveError(getUserErrorMessage(body?.error ?? body) ?? 'Kunde inte beräkna förslaget') + return + } + const nextPreview = body.data.tax as TaxView + setPreview(nextPreview) + setElectedDeduction( + nextPreview.result ? String(nextPreview.result.maximumDeduction) : '', + ) + setBookConformityConfirmed(false) + setDirty(true) + } catch (err) { + if (version !== requestVersion.current) return + setSaveError(err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel') + } finally { + if (version === requestVersion.current) setPreviewing(false) + } + } + + const save = async () => { + setSaveError(null) + const opening = parseOpening() + if (opening === null) return + const calculation = preview ?? (dirty && view.result ? view : null) + if (!calculation?.result) { + setSaveError('Beräkna förslaget innan valet sparas.') + return + } + if (electedDeduction.trim() === '') { + setSaveError('Ange årets faktiska skattemässiga avdrag.') + return + } + const deduction = Number(electedDeduction.replace(',', '.')) + if (!Number.isFinite(deduction) || deduction < 0) { + setSaveError('Årets faktiska avdrag måste vara 0 kr eller mer.') + return + } + if (deduction > calculation.result.maximumDeduction) { + setSaveError('Avdraget får inte överstiga högsta avdrag enligt den valda regeln.') + return + } + if (method === 'rakenskapsenlig' && !bookConformityConfirmed) { + setSaveError('Bekräfta att avdraget motsvarar bokslutets totala avskrivning.') + return + } + setSaving(true) + const version = ++requestVersion.current + const saveId = ++saveSequence.current + try { + const response = await fetch(`/api/bookkeeping/fiscal-periods/${periodId}/depreciation`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + method, + ...(method === 'rakenskapsenlig' ? { selected_rule: rule } : {}), + opening_tax_value: opening, + elected_deduction: deduction, + ...(method === 'rakenskapsenlig' + ? { book_conformity_confirmed: bookConformityConfirmed } + : {}), + }), + }) + const body = await response.json() + if (version !== requestVersion.current) return + if (!response.ok) { + setSaveError(getUserErrorMessage(body?.error) ?? 'Kunde inte spara beräkningen') + return + } + toast({ title: 'Skattemässig avskrivning sparad' }) + setDirty(false) + await onSaved() + } catch (err) { + if (version !== requestVersion.current) return + setSaveError(err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel') + } finally { + // A successful save triggers onSaved -> parent load -> new view prop, + // which bumps requestVersion before this finally runs. Gate on the + // save sequence instead so the card never stays stuck busy after its + // own save completes. + if (saveId === saveSequence.current) setSaving(false) + } + } + + const calculation = dirty ? preview : view + const result = calculation?.result ?? null + const controlsBusy = previewing || saving + const formBlocked = view.openingSource === 'previous_period_required' + + return ( + + +
+
+ Skattemässig avskrivning +

+ Beräkna högsta avdrag för den gemensamma inventariepoolen enligt IL 18 kap. +

+

+ Du sparar det faktiska avdraget efter avstämning. Eventuell överavskrivning + bokförs separat. +

+
+ {dirty && Ej sparad} + {!dirty && view.snapshot && !view.isStale && ( + Sparad + )} + {!dirty && view.isStale && Behöver sparas om} +
+
+ + {view.openingSource === 'previous_period_required' && ( +

+ Spara skattemässig avskrivning för närmast föregående räkenskapsår först. Ett osparat + mellanår får inte hoppas över. +

+ )} + {view.status === 'needs_period_history' && ( +

+ Kompletteringsregeln kan inte beräknas utan fullständig räkenskapsårshistorik för + alla kvarvarande inventarier. Välj huvudregeln eller komplettera periodhistoriken. +

+ )} +
+
+ + + {view.methodLocked && ( +

+ Metoden följer föregående sparade år. Ett byte kräver en separat övergångsbedömning. +

+ )} +
+ {method === 'rakenskapsenlig' && ( +
+ + +
+ )} +
+ + { + setOpeningValue(event.target.value) + resetCalculatedDraft() + }} + disabled={ + view.openingSource === 'previous_period' + || formBlocked + || controlsBusy + } + className="tabular-nums" + /> + {view.openingSource === 'previous_period' && ( +

Hämtat från föregående sparade år.

+ )} +
+
+ + {dirty && !preview && ( +

+ Inställningarna är ändrade. Beräkna förslaget för att se rätt gränsbelopp innan du + sparar. +

+ )} + + {result && ( + <> +
+ + + + + + {!dirty && view.snapshot ? ( + + ) : ( + + )} +
+ {method === 'rakenskapsenlig' && ( +
+

+ Jämförelse +

+ {result.alternatives.map((alternative) => ( +
+ {alternative.rule === 'huvudregel_30' ? '30-procentsregeln' : '20-procentsregeln'} + + {formatCurrency(alternative.closingTaxValue)} kvar + +
+ ))} +
+ )} + {dirty && ( +
+ + setElectedDeduction(event.target.value)} + disabled={controlsBusy} + className="max-w-xs tabular-nums" + /> +

+ Beloppet får vara lägre än gränsbeloppet men aldrig högre. +

+
+ )} + {dirty && method === 'rakenskapsenlig' && ( +
+ setBookConformityConfirmed(checked === true)} + disabled={controlsBusy} + /> + +
+ )} + + )} + + {view.excludedAssetCount > 0 && ( +

+ {view.excludedAssetCount} tillgång{view.excludedAssetCount === 1 ? '' : 'ar'} i andra + kategorier ingår inte i inventariepoolen och måste bedömas separat. +

+ )} + {result && result.excessDisposals > 0 && ( +

+ Avyttringsersättningen överstiger underlaget med {formatCurrency(result.excessDisposals)}. + Överskjutande belopp behöver hanteras i deklarationen. +

+ )} + {saveError &&

{saveError}

} +
+

+ {view.eligibleAssetCount} tillgång{view.eligibleAssetCount === 1 ? '' : 'ar'} i poolen, + {view.periodMonths} månader i räkenskapsåret. +

+
+ {dirty && ( + + )} + + +
+
+
+
+ ) +} + +function TaxValue({ label, value, strong = false }: { label: string; value: number; strong?: boolean }) { + return ( +
+

{label}

+

{formatCurrency(value)}

+
) } diff --git a/components/bookkeeping/year-end/DispositionsStep.tsx b/components/bookkeeping/year-end/DispositionsStep.tsx index a854ca85..ca88bb15 100644 --- a/components/bookkeeping/year-end/DispositionsStep.tsx +++ b/components/bookkeeping/year-end/DispositionsStep.tsx @@ -25,6 +25,7 @@ interface DispositionsStepProps { periodId: string onBack: () => void onContinue: () => void + onNavigationBlockedChange?: (blocked: boolean) => void } interface UiState { @@ -47,7 +48,12 @@ interface TaxAdjustmentDraft { * EF companies get an empty `proposals` array from the server, so this step * renders a short pass-through note and lets the user continue. */ -export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsStepProps) { +export function DispositionsStep({ + periodId, + onBack, + onContinue, + onNavigationBlockedChange, +}: DispositionsStepProps) { const { toast } = useToast() const [proposal, setProposal] = useState(null) const [loading, setLoading] = useState(true) @@ -58,6 +64,13 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS const [savingAdjustments, setSavingAdjustments] = useState(false) const [adjustmentError, setAdjustmentError] = useState(null) const [taxAdjustmentDraft, setTaxAdjustmentDraft] = useState(emptyTaxDraft) + const [taxDepreciationDirty, setTaxDepreciationDirty] = useState(false) + + useEffect(() => { + onNavigationBlockedChange?.(taxDepreciationDirty) + }, [onNavigationBlockedChange, taxDepreciationDirty]) + + useEffect(() => () => onNavigationBlockedChange?.(false), [onNavigationBlockedChange]) // ---- Fetch proposals ---- const loadProposals = useCallback(async () => { @@ -136,6 +149,10 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS // ---- POST accepted dispositions ---- const handleCommit = useCallback(async () => { if (!proposal) return + if (taxDepreciationDirty) { + setPostError('Spara eller återställ ändringarna i skattemässig avskrivning innan du fortsätter.') + return + } if (proposal.completedDispositions?.some((item) => item.status === 'needs_correction')) { setPostError('Rätta den bokförda bolagsskatten och ladda om sidan innan du fortsätter.') return @@ -172,7 +189,7 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS } finally { setPosting(false) } - }, [proposal, ui, periodId, onContinue, toast]) + }, [proposal, ui, periodId, onContinue, toast, taxDepreciationDirty]) // ---- Render branches ---- if (loading) { @@ -209,17 +226,41 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS const fiscalYear = parseInt(proposal.fiscalPeriod.period_end.slice(0, 4), 10) return (
- void loadProposals()} /> + void loadProposals()} + onTaxDirtyChange={setTaxDepreciationDirty} + /> + {taxDepreciationDirty && ( +

+ Spara eller återställ ändringarna i skattemässig avskrivning innan du lämnar steget. +

+ )}
- -
@@ -229,7 +270,11 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS return (
- void loadProposals()} /> + void loadProposals()} + onTaxDirtyChange={setTaxDepreciationDirty} + /> Bokslutsdispositioner @@ -332,11 +377,30 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS )} + {taxDepreciationDirty && ( +

+ Spara eller återställ ändringarna i skattemässig avskrivning innan du lämnar steget. +

+ )} +
- -