Files
accounted/app/api/assets/[id]/dispose/route.ts
T
MattssonandClaude Fable 5 cb3ef45f14 feat(assets): atomic asset disposal workflow (avyttring, utrangering, verksamhetsoverlatelse) (#1391)
* feat(assets): atomic asset disposal workflow (avyttring, utrangering, verksamhetsoverlatelse)

Disposal books depreciation to the disposal date, clears cost and
accumulated depreciation, books gain (3973) or loss (7973), applies
output VAT on third-party sales, honors the ML 5 kap. 38 §
verksamhetsoverlatelse exemption, and recalculates ML 15 kap. jamkning
server-side from tax years and original input VAT. The voucher, the
disposal-date depreciation schedule and the immutable register state
commit in one dedicated commit_asset_disposal RPC transaction that
delegates voucher numbering to commit_journal_entry.

Fixes #325

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

* fix(assets): harden disposal per review and pg-real findings

- commit_asset_disposal now uses the NULL-safe caller_is_company_member()
  guard (tenant-guard ratchet) and passes the allowed 'user_accept'
  commit_method instead of the unlisted 'asset_disposal' value
- disposal metadata invariants validated in the RPC (non-negative
  proceeds/VAT, VAT requires a treatment, VAT <= gross, scrap carries no
  proceeds) since the RPC is independently callable
- new FK and CHECK constraints added NOT VALID + VALIDATE CONSTRAINT so
  the migration never blocks writes on the hot journal_entries table
- disposeAsset paginates fiscal periods and depreciation schedules with
  fetchAllRows; jamkning_remaining_years keeps a valid 0 (?? not ||)
- engine imports shared AssetDisposalType/AssetJamkningDirection/
  VatTreatment unions; post-commit reload retries once and logs before
  surfacing, so a transient read cannot masquerade as a failed disposal
- dispose page parses Swedish-formatted amounts (125 000,50) and blocks
  submission on unparseable proceeds
- assets pg tests write disposal attributes in the disposal transition
  itself and gain a regression test that the register is frozen after

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 09:59:52 +02:00

79 lines
2.9 KiB
TypeScript

import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse } from '@/lib/errors/get-structured-error'
import { validateBody } from '@/lib/api/validate'
import { disposeAsset } from '@/lib/bokslut/assets/asset-service'
const VAT_TREATMENTS = [
'standard_25',
'reverse_charge',
'export',
'exempt',
] as const
const DisposeAssetSchema = z
.object({
disposal_type: z.enum(['sale', 'scrap', 'business_transfer']),
disposed_at: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
disposed_proceeds: z.number().nonnegative(),
proceeds_account: z.string().regex(/^\d{4}$/).optional(),
fiscal_period_id: z.string().uuid(),
vat_treatment: z.enum(VAT_TREATMENTS).optional(),
jamkning_original_input_vat: z.number().nonnegative().optional(),
jamkning_original_deduction_percent: z.number().min(0).max(100).optional(),
business_transfer_confirmed: z.boolean().optional(),
adjustment_document_confirmed: z.boolean().optional(),
})
.superRefine((value, ctx) => {
if (value.disposal_type === 'scrap' && value.disposed_proceeds !== 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['disposed_proceeds'],
message: 'disposed_proceeds måste vara 0 vid utrangering.',
})
}
if (value.disposal_type === 'sale' && value.disposed_proceeds > 0 && !value.vat_treatment) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['vat_treatment'],
message: 'vat_treatment krävs vid försäljning.',
})
}
if (value.disposal_type !== 'sale' && value.vat_treatment !== undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['vat_treatment'],
message: 'vat_treatment får bara anges vid försäljning.',
})
}
const hasVat = value.jamkning_original_input_vat !== undefined
const hasPercent = value.jamkning_original_deduction_percent !== undefined
if (hasVat !== hasPercent) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: hasVat
? ['jamkning_original_deduction_percent']
: ['jamkning_original_input_vat'],
message: 'Ursprungsmoms och ursprunglig avdragsprocent måste anges tillsammans.',
})
}
})
export const POST = withRouteContext(
'assets.dispose',
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { user, supabase, companyId, log, requestId } = ctx
const validation = await validateBody(request, DisposeAssetSchema)
if (!validation.success) return validation.response
try {
const result = await disposeAsset(supabase, companyId, user.id, id, validation.data)
return NextResponse.json({ data: result })
} catch (err) {
return errorResponse(err, log, { requestId })
}
},
{ requireWrite: true },
)