* 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>
79 lines
2.9 KiB
TypeScript
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 },
|
|
)
|