* feat: add option to exclude year-end closing entries in SIE export and related reports * delete docs * fix: allow Chrome's PDF viewer in verifikat document preview The /api/documents/:id/inline route shipped with `object-src 'none'` in its CSP, which blocked Chrome's built-in PDF viewer (it renders inline PDFs via an internal <embed>). Users on Chrome saw "Det här innehållet har blockerats" when expanding a PDF attachment in the bookkeeping view; Firefox (PDF.js) and Edge (own viewer) were unaffected, and JPGs worked because <img> isn't subject to object-src. Drops the CSP for this route to the minimum needed for embeddability: `frame-ancestors 'self'`. X-Content-Type-Options: nosniff plus the fixed Content-Type from the handler already block MIME confusion; X-Frame-Options: SAMEORIGIN + frame-ancestors still block clickjacking. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(auth): add webmail deep link to email confirmation screens Mirrors Stripe's signup UX: after asking the user to verify their email, detect their webmail provider from the domain and show a button that opens the inbox in a new tab. Gmail gets a from:<sender> search pre-populated; Outlook/Yahoo/iCloud/Proton open the inbox directly. Unknown / custom domains fall back to the existing copy. Sender address is configurable via NEXT_PUBLIC_BRANDING_AUTH_EMAIL_FROM (default noreply@gnubok.se) so white-label installs can match their Supabase Auth SMTP config. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auth): unblock first-time password set for BankID users with MFA Supabase rejects updateUser({password}) and mfa.unenroll with "AAL2 session is required" whenever a TOTP factor is enrolled. BankID magic-link logins produce AAL1, and middleware skips MFA enforcement for bankid_linked users, so they had no path to AAL2 — leaving them unable to set a backup password or disable MFA without going through the email-recovery escape hatch. - /api/account/password: branch on app_metadata.has_password. First-time set writes via service.auth.admin.updateUserById (no existing credential to protect, AAL2 guard does not apply). Change-password keeps the user-session updateUser so AAL2 still fires for credential rotation. - /mfa/verify: accept a safeReturnTo query param and route there after successful verify, so step-up flows can land back where they came from. - SecuritySettings: detect the AAL2 error from both change-password and mfa.unenroll and redirect through /mfa/verify?returnTo=/settings/account instead of toasting a dead-end error. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add tests and rounding utility for öre precision in bokslut calculations - Implemented `roundOre` function for rounding SEK amounts to two decimal places, ensuring consistent monetary calculations. - Introduced `ORE_TOLERANCE` constant for comparing rounded amounts, facilitating invariant checks in financial entries. - Created comprehensive tests for `roundOre`, covering typical cases, edge cases, and idempotency. - Added year-end invariants tests to verify database-level guarantees for closing entries, ensuring they balance to the öre and reject discrepancies. - Developed end-to-end tests for the dispositions chain, validating the correctness of calculations across various scenarios. * fix: update PDF rendering to remove Swish QR code generation and set default to disable Swish visibility * fix: enhance security by rejecting data URIs in safeReturnTo function tests * fix: improve rounding logic in roundOre function and add customer_type migration * fix: add customer_type column to customers and enforce CHECK constraint --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
688 lines
26 KiB
TypeScript
688 lines
26 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
||
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||
import type {
|
||
Asset,
|
||
AssetCategory,
|
||
DepreciationMethod,
|
||
K3Component,
|
||
CreateJournalEntryLineInput,
|
||
JournalEntry,
|
||
VatTreatment,
|
||
} from '@/types'
|
||
|
||
/**
|
||
* Default BAS account triples per category. The user can override at create
|
||
* time; these only kick in when the form doesn't specify accounts. Matches
|
||
* the seeded BAS 2020 chart (lib/bookkeeping/bas-data/).
|
||
*/
|
||
export const DEFAULT_ACCOUNTS_BY_CATEGORY: Record<
|
||
AssetCategory,
|
||
{ asset: string; accumulated: string; expense: string }
|
||
> = {
|
||
immaterial: { asset: '1010', accumulated: '1019', expense: '7810' },
|
||
building: { asset: '1110', accumulated: '1119', expense: '7821' },
|
||
land_improvement: { asset: '1150', accumulated: '1159', expense: '7824' },
|
||
machinery: { asset: '1210', accumulated: '1219', expense: '7831' },
|
||
equipment: { asset: '1220', accumulated: '1229', expense: '7832' },
|
||
vehicle: { asset: '1240', accumulated: '1249', expense: '7834' },
|
||
computer: { asset: '1250', accumulated: '1259', expense: '7833' },
|
||
other_tangible: { asset: '1280', accumulated: '1289', expense: '7839' },
|
||
}
|
||
|
||
export interface CreateAssetInput {
|
||
name: string
|
||
category: AssetCategory
|
||
acquisition_date: string
|
||
acquisition_cost: number
|
||
salvage_value?: number
|
||
useful_life_months: number
|
||
depreciation_method?: DepreciationMethod
|
||
/** Required when depreciation_method = 'restvardesavskrivning_25'. */
|
||
restvarde_target?: number | null
|
||
bas_asset_account?: string
|
||
bas_accumulated_account?: string
|
||
bas_expense_account?: string
|
||
/** K3 component depreciation (BFNAR 2012:1 ch.17.4). When non-null, the
|
||
* engine sums per-component linear depreciation instead of applying
|
||
* `depreciation_method` to the asset as a whole. The API layer rejects
|
||
* writes for K2 companies with K3_REQUIRED_FOR_COMPONENTS. */
|
||
k3_components?: K3Component[] | null
|
||
notes?: string
|
||
}
|
||
|
||
/**
|
||
* Create a new asset. Defaults BAS accounts from the category mapping when
|
||
* the caller doesn't override them. Does NOT post a journal entry — the
|
||
* acquisition is assumed to already be in the books (bank payment or
|
||
* supplier invoice). Posting an acquisition entry alongside an existing
|
||
* payment would double-count.
|
||
*/
|
||
export async function createAsset(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
userId: string,
|
||
input: CreateAssetInput,
|
||
): Promise<Asset> {
|
||
const defaults = DEFAULT_ACCOUNTS_BY_CATEGORY[input.category]
|
||
const method: DepreciationMethod = input.depreciation_method ?? 'linear'
|
||
// The DB CHECK constraint enforces the biconditional between method and
|
||
// restvarde_target. Pass null explicitly when not restvärde so a stale
|
||
// value never leaks through.
|
||
const restvarde =
|
||
method === 'restvardesavskrivning_25' ? input.restvarde_target ?? null : null
|
||
const row = {
|
||
user_id: userId,
|
||
company_id: companyId,
|
||
name: input.name,
|
||
category: input.category,
|
||
acquisition_date: input.acquisition_date,
|
||
acquisition_cost: input.acquisition_cost,
|
||
salvage_value: input.salvage_value ?? 0,
|
||
useful_life_months: input.useful_life_months,
|
||
depreciation_method: method,
|
||
restvarde_target: restvarde,
|
||
bas_asset_account: input.bas_asset_account ?? defaults.asset,
|
||
bas_accumulated_account: input.bas_accumulated_account ?? defaults.accumulated,
|
||
bas_expense_account: input.bas_expense_account ?? defaults.expense,
|
||
// K3 components are persisted as JSONB. The route handler enforces the
|
||
// accounting_framework='k3' gate; here we only pass the value through
|
||
// (null when omitted, so K2 assets stay clean).
|
||
k3_components: input.k3_components ?? null,
|
||
notes: input.notes ?? null,
|
||
}
|
||
|
||
const { data, error } = await supabase
|
||
.from('assets')
|
||
.insert(row)
|
||
.select('*')
|
||
.single()
|
||
|
||
if (error || !data) {
|
||
throw new Error(`Failed to create asset: ${error?.message ?? 'unknown'}`)
|
||
}
|
||
return data as Asset
|
||
}
|
||
|
||
export async function listAssets(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
options: { activeOnly?: boolean } = {},
|
||
): Promise<Asset[]> {
|
||
let query = supabase
|
||
.from('assets')
|
||
.select('*')
|
||
.eq('company_id', companyId)
|
||
.order('acquisition_date', { ascending: true })
|
||
|
||
if (options.activeOnly) {
|
||
query = query.is('disposed_at', null)
|
||
}
|
||
|
||
const { data, error } = await query
|
||
if (error) throw new Error(`Failed to list assets: ${error.message}`)
|
||
return (data ?? []) as Asset[]
|
||
}
|
||
|
||
export async function getAsset(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
assetId: string,
|
||
): Promise<Asset | null> {
|
||
const { data, error } = await supabase
|
||
.from('assets')
|
||
.select('*')
|
||
.eq('id', assetId)
|
||
.eq('company_id', companyId)
|
||
.maybeSingle()
|
||
if (error) throw new Error(`Failed to load asset: ${error.message}`)
|
||
return (data as Asset | null) ?? null
|
||
}
|
||
|
||
export interface UpdateAssetInput {
|
||
name?: string
|
||
notes?: string | null
|
||
/** Salvage value, useful life, method, accounts — editable as long as the
|
||
* asset isn't disposed yet (DB trigger enforces this beyond the API). */
|
||
salvage_value?: number
|
||
useful_life_months?: number
|
||
depreciation_method?: DepreciationMethod
|
||
/** Editable as long as method=restvärdeavskrivning. Set to null when
|
||
* switching back to a non-restvärde method (the DB CHECK enforces). */
|
||
restvarde_target?: number | null
|
||
bas_asset_account?: string
|
||
bas_accumulated_account?: string
|
||
bas_expense_account?: string
|
||
/** K3 component breakdown. Pass null to clear an existing breakdown
|
||
* (engine then falls back to depreciation_method). The route handler
|
||
* enforces accounting_framework='k3' + sum validation before delegating. */
|
||
k3_components?: K3Component[] | null
|
||
}
|
||
|
||
export async function updateAsset(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
assetId: string,
|
||
inputParam: UpdateAssetInput,
|
||
): Promise<Asset> {
|
||
// Copy so we can adjust restvarde_target without mutating the caller's object.
|
||
let input: UpdateAssetInput = { ...inputParam }
|
||
// Defense-in-depth: when callers remap BAS accounts, refuse anything
|
||
// outside the legitimate range for the existing asset's category — keeps
|
||
// INK2R mappings + the depreciation engine's category-driven defaults in
|
||
// sync with what users actually pick.
|
||
if (
|
||
input.bas_asset_account ||
|
||
input.bas_accumulated_account ||
|
||
input.bas_expense_account
|
||
) {
|
||
const existing = await getAsset(supabase, companyId, assetId)
|
||
if (!existing) throw new Error('Asset not found')
|
||
const ranges = BAS_RANGES_BY_CATEGORY[existing.category]
|
||
if (input.bas_asset_account && !inBasRange(input.bas_asset_account, ranges.asset)) {
|
||
throw new Error(
|
||
`bas_asset_account ${input.bas_asset_account} is outside ${ranges.asset[0]}–${ranges.asset[1]} for ${existing.category}`,
|
||
)
|
||
}
|
||
if (
|
||
input.bas_accumulated_account &&
|
||
!inBasRange(input.bas_accumulated_account, ranges.accumulated)
|
||
) {
|
||
throw new Error(
|
||
`bas_accumulated_account ${input.bas_accumulated_account} is outside ${ranges.accumulated[0]}–${ranges.accumulated[1]} for ${existing.category}`,
|
||
)
|
||
}
|
||
if (
|
||
input.bas_expense_account &&
|
||
!inBasRange(input.bas_expense_account, ranges.expense)
|
||
) {
|
||
throw new Error(
|
||
`bas_expense_account ${input.bas_expense_account} is outside ${ranges.expense[0]}–${ranges.expense[1]} for ${existing.category}`,
|
||
)
|
||
}
|
||
// Anskaffning and ackumulerade-avskrivningar must be different accounts —
|
||
// see CreateAssetSchema validateBasOverrides for the rationale.
|
||
const finalAsset = input.bas_asset_account ?? existing.bas_asset_account
|
||
const finalAccumulated = input.bas_accumulated_account ?? existing.bas_accumulated_account
|
||
if (finalAsset === finalAccumulated) {
|
||
throw new Error(
|
||
'bas_asset_account and bas_accumulated_account must be different accounts',
|
||
)
|
||
}
|
||
}
|
||
|
||
// Method / restvärde-target biconditional: required iff restvärdeavskrivning.
|
||
// Resolve final method+target across the merged row (existing + patch) so we
|
||
// can null the target when switching away and require it when switching in.
|
||
if (input.depreciation_method !== undefined || input.restvarde_target !== undefined) {
|
||
const existing = await getAsset(supabase, companyId, assetId)
|
||
if (!existing) throw new Error('Asset not found')
|
||
const finalMethod = input.depreciation_method ?? existing.depreciation_method
|
||
const finalTarget =
|
||
input.restvarde_target !== undefined ? input.restvarde_target : existing.restvarde_target
|
||
if (finalMethod === 'restvardesavskrivning_25' && (finalTarget === null || finalTarget === undefined)) {
|
||
throw new Error(
|
||
'restvarde_target krävs när avskrivningsmetoden är restvärdeavskrivning (25 %).',
|
||
)
|
||
}
|
||
if (finalMethod !== 'restvardesavskrivning_25' && finalTarget !== null && finalTarget !== undefined) {
|
||
// Auto-null the target when switching away from restvärde so the DB
|
||
// CHECK doesn't reject the update.
|
||
input = { ...input, restvarde_target: null }
|
||
}
|
||
if (
|
||
finalMethod === 'restvardesavskrivning_25' &&
|
||
finalTarget !== null &&
|
||
finalTarget !== undefined &&
|
||
Number(finalTarget) >= Number(existing.acquisition_cost)
|
||
) {
|
||
throw new Error(
|
||
'restvarde_target måste vara lägre än anskaffningsvärdet — annars finns inget kvar att skriva av.',
|
||
)
|
||
}
|
||
}
|
||
|
||
const { data, error } = await supabase
|
||
.from('assets')
|
||
.update(input)
|
||
.eq('id', assetId)
|
||
.eq('company_id', companyId)
|
||
.select('*')
|
||
.single()
|
||
if (error || !data) {
|
||
throw new Error(`Failed to update asset: ${error?.message ?? 'unknown'}`)
|
||
}
|
||
return data as Asset
|
||
}
|
||
|
||
const BAS_RANGES_BY_CATEGORY: Record<
|
||
AssetCategory,
|
||
{ asset: [string, string]; accumulated: [string, string]; expense: [string, string] }
|
||
> = {
|
||
immaterial: { asset: ['1010', '1099'], accumulated: ['1010', '1099'], expense: ['7810', '7819'] },
|
||
building: { asset: ['1100', '1199'], accumulated: ['1100', '1199'], expense: ['7820', '7829'] },
|
||
land_improvement:{ asset: ['1150', '1159'], accumulated: ['1150', '1159'], expense: ['7820', '7829'] },
|
||
machinery: { asset: ['1210', '1219'], accumulated: ['1210', '1219'], expense: ['7830', '7839'] },
|
||
equipment: { asset: ['1220', '1229'], accumulated: ['1220', '1229'], expense: ['7830', '7839'] },
|
||
vehicle: { asset: ['1240', '1249'], accumulated: ['1240', '1249'], expense: ['7830', '7839'] },
|
||
computer: { asset: ['1250', '1259'], accumulated: ['1250', '1259'], expense: ['7830', '7839'] },
|
||
other_tangible: { asset: ['1280', '1299'], accumulated: ['1280', '1299'], expense: ['7830', '7839'] },
|
||
}
|
||
|
||
function inBasRange(account: string, range: [string, string]): boolean {
|
||
return account >= range[0] && account <= range[1]
|
||
}
|
||
|
||
export interface DisposeAssetInput {
|
||
/** ISO date of disposal — typically the day of sale or scrapping. */
|
||
disposed_at: string
|
||
/** Cash / receivable received for the asset, INCLUDING VAT when applicable.
|
||
* Zero for scrapping. */
|
||
disposed_proceeds: number
|
||
/** Optional override for the bank/receivable account credited with the
|
||
* proceeds. Defaults to 1930 (företagskonto). */
|
||
proceeds_account?: string
|
||
/** Fiscal period the disposal entry lands in. Caller resolves this from
|
||
* disposed_at — we don't auto-derive to keep the period-lock check at
|
||
* the route layer. */
|
||
fiscal_period_id: string
|
||
/**
|
||
* Output VAT on the proceeds (ML 3 kap 3 § / 7 kap 3 §). When > 0, a
|
||
* credit on the matching 26xx account is appended to the journal entry,
|
||
* and `disposed_proceeds` is treated as the GROSS amount (incl. VAT).
|
||
* The net (proceeds − vat) is what gets compared to NBV to compute
|
||
* gain/loss. Defaults to 0 (sale was momsfri / outside scope).
|
||
*/
|
||
proceeds_vat?: number
|
||
/**
|
||
* Treatment for `proceeds_vat`. Required when `proceeds_vat > 0` because
|
||
* the engine needs to know which 26xx account to credit:
|
||
* - standard_25 → 2611
|
||
* - reduced_12 → 2621
|
||
* - reduced_6 → 2631
|
||
* - reverse_charge / export / exempt → no VAT line (treated as
|
||
* informational; proceeds_vat must be 0 in those cases)
|
||
*/
|
||
vat_treatment?: VatTreatment
|
||
/**
|
||
* Jämkning amount per ML 8a kap 7 § — when the disposal happens inside
|
||
* the korrigeringstid, the originally-deducted input VAT must be
|
||
* partially paid back. The caller computes this via
|
||
* computeJamkningAmount() (lib/bokslut/assets/jamkning.ts) and passes
|
||
* the result here. A positive value means "pay back to the state" and
|
||
* is booked as a CREDIT to 2641 (reverses the original input-VAT
|
||
* deduction) with an offsetting debit on the asset's gain/loss account.
|
||
* Zero / undefined = no jämkning line.
|
||
*/
|
||
jamkning_amount?: number
|
||
/** Audit metadata: remaining months in korrigeringstid at disposal date. */
|
||
jamkning_remaining_months?: number
|
||
/** Audit metadata: total korrigeringstid (60 or 120 months). */
|
||
jamkning_total_months?: number
|
||
/** Audit metadata: original input VAT deducted at acquisition. */
|
||
jamkning_original_input_vat?: number
|
||
}
|
||
|
||
export interface DisposalResult {
|
||
asset: Asset
|
||
/** Disposal entry. Null when no entry was needed (zero-value, fully-
|
||
* depreciated asset scrapped for nothing). */
|
||
disposal_entry: JournalEntry | null
|
||
gain_or_loss: number
|
||
}
|
||
|
||
/**
|
||
* Dispose of an asset. Posts a journal entry that:
|
||
* - Debit accumulated depreciation (to zero out the asset's accumulated
|
||
* account)
|
||
* - Credit acquisition cost (to zero out the asset's anskaffning account)
|
||
* - Debit proceeds account (bank / receivable) for sale price (gross,
|
||
* incl VAT)
|
||
* - Credit 26xx (output VAT) when the sale is momspliktig (standard_25 →
|
||
* 2611, reduced_12 → 2621, reduced_6 → 2631)
|
||
* - Credit 2641 + Debit loss account for the jämkning amount when the
|
||
* disposal happens inside the korrigeringstid (ML 8a kap 4-7 §§)
|
||
* - Debit 78xx (loss on sale) OR Credit 30xx (gain on sale) for the
|
||
* net gain / loss vs NBV — accounts branch on category (3013/7813
|
||
* for immaterial, 3971/7971 for building / markanläggning, 3973/7973
|
||
* for everything else).
|
||
*
|
||
* Gain/loss is computed on the NET proceeds (excl VAT), since VAT is a
|
||
* pass-through to Skatteverket and does not affect resultaträkningen.
|
||
*
|
||
* After posting, marks the asset row with disposed_at, disposed_proceeds,
|
||
* disposed_proceeds_vat, disposed_vat_treatment, and jämkning audit fields.
|
||
* The DB trigger then prevents further edits to financial fields.
|
||
*/
|
||
export async function disposeAsset(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
userId: string,
|
||
assetId: string,
|
||
input: DisposeAssetInput,
|
||
): Promise<DisposalResult> {
|
||
const asset = await getAsset(supabase, companyId, assetId)
|
||
if (!asset) throw new Error('Asset not found')
|
||
if (asset.disposed_at) {
|
||
throw new Error('Asset is already disposed')
|
||
}
|
||
|
||
// Derive accumulated depreciation server-side from posted
|
||
// depreciation_schedules so a malicious or buggy caller cannot inflate the
|
||
// book-value calculation. Limitation: manual avskrivningsverifikationer
|
||
// posted outside the engine aren't captured here. Phase 5+ can replace
|
||
// this with a trial-balance scan on bas_accumulated_account.
|
||
const accumulated = await sumPostedDepreciation(supabase, companyId, assetId)
|
||
|
||
const acquisitionCost = Number(asset.acquisition_cost)
|
||
const proceedsGross = round2(Number(input.disposed_proceeds))
|
||
const proceedsVat = round2(Number(input.proceeds_vat ?? 0))
|
||
const proceedsNet = round2(proceedsGross - proceedsVat)
|
||
const vatTreatment = input.vat_treatment
|
||
// Internal validation guard: when caller passes a VAT amount, treatment
|
||
// must accompany it so we can resolve the BAS 26xx account. The API
|
||
// layer also enforces this via Zod refinement; mirroring here keeps
|
||
// the engine self-defending against direct callers (MCP, scripts).
|
||
if (proceedsVat > 0.005 && !vatTreatment) {
|
||
throw new Error(
|
||
'vat_treatment krävs när proceeds_vat > 0 — engine kan inte avgöra rätt 26xx-konto.',
|
||
)
|
||
}
|
||
// Treatments that produce no VAT line must carry 0 VAT.
|
||
if (
|
||
proceedsVat > 0.005 &&
|
||
vatTreatment &&
|
||
(vatTreatment === 'reverse_charge' ||
|
||
vatTreatment === 'export' ||
|
||
vatTreatment === 'exempt')
|
||
) {
|
||
throw new Error(
|
||
`proceeds_vat måste vara 0 för momsbehandling "${vatTreatment}".`,
|
||
)
|
||
}
|
||
|
||
// Gain/loss is computed on the NET proceeds — VAT is pass-through and
|
||
// never hits the income statement.
|
||
const netBookValue = round2(acquisitionCost - accumulated)
|
||
const gainOrLoss = round2(proceedsNet - netBookValue)
|
||
const proceedsAccount = input.proceeds_account ?? '1930'
|
||
|
||
// ── Jämkning (ML 8a kap 7 §) ──────────────────────────────────────
|
||
// When the disposal happens inside the korrigeringstid, part of the
|
||
// originally-deducted input VAT must be paid back. Caller passes the
|
||
// precomputed amount (positive = debt to the state).
|
||
//
|
||
// Booking direction: We credit 2641 to reverse the original input-VAT
|
||
// deduction (2641 normal balance is debit; a credit reduces the
|
||
// deduction). The offset is debited to BAS 6991 — jämkning is a VAT
|
||
// correction per ML 8a kap, NOT a disposal loss, so it must NOT hit
|
||
// the 78xx förlust-vid-avyttring accounts. See the jämkning lines
|
||
// below for details.
|
||
const jamkning = round2(Number(input.jamkning_amount ?? 0))
|
||
|
||
const lines: CreateJournalEntryLineInput[] = []
|
||
|
||
if (accumulated > 0.005) {
|
||
lines.push({
|
||
account_number: asset.bas_accumulated_account,
|
||
debit_amount: round2(accumulated),
|
||
credit_amount: 0,
|
||
line_description: `Avyttring: nollställ ack. avskrivning ${asset.name}`,
|
||
})
|
||
}
|
||
lines.push({
|
||
account_number: asset.bas_asset_account,
|
||
debit_amount: 0,
|
||
credit_amount: round2(acquisitionCost),
|
||
line_description: `Avyttring: nollställ anskaffning ${asset.name}`,
|
||
})
|
||
if (proceedsGross > 0.005) {
|
||
lines.push({
|
||
account_number: proceedsAccount,
|
||
debit_amount: proceedsGross,
|
||
credit_amount: 0,
|
||
line_description: `Avyttring: erhållet belopp ${asset.name}`,
|
||
})
|
||
}
|
||
|
||
// Output VAT line — credit the matching 26xx account.
|
||
if (proceedsVat > 0.005 && vatTreatment) {
|
||
const vatAccount = outputVatAccountFor(vatTreatment)
|
||
if (vatAccount) {
|
||
lines.push({
|
||
account_number: vatAccount,
|
||
debit_amount: 0,
|
||
credit_amount: proceedsVat,
|
||
line_description: `Utgående moms ${vatRateLabel(vatTreatment)} avyttring ${asset.name}`,
|
||
})
|
||
}
|
||
}
|
||
|
||
// Disposal gain/loss accounts vary by asset class — BAS 2026 splits them
|
||
// because INK2R routes each pair to a different field. Mixing them
|
||
// misclassifies in the tax declaration.
|
||
// - immaterial → 3013 (vinst) / 7813 (förlust)
|
||
// - building / markanlägg → 3971 / 7971
|
||
// - other tangible → 3973 / 7973
|
||
const isBuilding = asset.category === 'building' || asset.category === 'land_improvement'
|
||
const gainAccount =
|
||
asset.category === 'immaterial' ? '3013' : isBuilding ? '3971' : '3973'
|
||
const lossAccount =
|
||
asset.category === 'immaterial' ? '7813' : isBuilding ? '7971' : '7973'
|
||
|
||
if (gainOrLoss > 0.005) {
|
||
lines.push({
|
||
account_number: gainAccount,
|
||
debit_amount: 0,
|
||
credit_amount: gainOrLoss,
|
||
line_description: `Vinst vid avyttring av ${asset.name}`,
|
||
})
|
||
} else if (gainOrLoss < -0.005) {
|
||
lines.push({
|
||
account_number: lossAccount,
|
||
debit_amount: Math.abs(gainOrLoss),
|
||
credit_amount: 0,
|
||
line_description: `Förlust vid avyttring av ${asset.name}`,
|
||
})
|
||
}
|
||
|
||
// Jämkning lines — credit 2641 + debit 6991. Jämkning is a VAT
|
||
// correction per ML 8a kap, NOT a disposal loss. Routing it through
|
||
// the 78xx förlust-vid-avyttring accounts would distort both the
|
||
// gain/loss line on the income statement and the INK2R mapping, and
|
||
// mix tax-correction costs with disposal losses in the audit trail.
|
||
// BAS 6991 "Övriga externa kostnader, avdragsgilla" is the seeded
|
||
// catch-all för-en-extern-kostnad account that fits a repayment of
|
||
// previously-deducted input VAT.
|
||
if (jamkning > 0.005) {
|
||
lines.push({
|
||
account_number: '6991',
|
||
debit_amount: jamkning,
|
||
credit_amount: 0,
|
||
line_description: `Jämkning av tidigare avdragen ingående moms enligt ML 8a kap (${asset.name})`,
|
||
})
|
||
lines.push({
|
||
account_number: '2641',
|
||
debit_amount: 0,
|
||
credit_amount: jamkning,
|
||
line_description: `Återförd ingående moms jämkning ${asset.name}`,
|
||
})
|
||
}
|
||
|
||
// K3 component breakdown — when the asset was depreciated per-component,
|
||
// we surface the component list in the journal entry notes so auditors
|
||
// can trace which underlying components contributed to the disposal.
|
||
// Gain/loss math is unchanged: total book value is still
|
||
// acquisition_cost − accumulated_depreciation regardless of structure,
|
||
// because component depreciations sum into the same accumulated account.
|
||
const hasComponents =
|
||
Array.isArray(asset.k3_components) && asset.k3_components.length > 0
|
||
const componentNotes = hasComponents
|
||
? `K3-komponenter: ${(asset.k3_components ?? [])
|
||
.map((c) => `${c.name} (${round2(Number(c.cost))} kr / ${c.useful_life_months} mån)`)
|
||
.join('; ')}`
|
||
: null
|
||
|
||
let disposalEntry: JournalEntry | null = null
|
||
if (lines.length > 0) {
|
||
disposalEntry = await createJournalEntry(supabase, companyId, userId, {
|
||
fiscal_period_id: input.fiscal_period_id,
|
||
entry_date: input.disposed_at,
|
||
description: `Avyttring av tillgång: ${asset.name}`,
|
||
source_type: 'manual',
|
||
lines,
|
||
...(componentNotes ? { notes: componentNotes } : {}),
|
||
})
|
||
}
|
||
|
||
const { data: updated, error: updateError } = await supabase
|
||
.from('assets')
|
||
.update({
|
||
disposed_at: input.disposed_at,
|
||
disposed_proceeds: proceedsGross,
|
||
disposed_proceeds_vat: proceedsVat,
|
||
disposed_vat_treatment: vatTreatment ?? null,
|
||
jamkning_amount: jamkning,
|
||
jamkning_remaining_months: input.jamkning_remaining_months ?? null,
|
||
jamkning_total_months: input.jamkning_total_months ?? null,
|
||
jamkning_original_input_vat: input.jamkning_original_input_vat ?? null,
|
||
})
|
||
.eq('id', assetId)
|
||
.eq('company_id', companyId)
|
||
.select('*')
|
||
.single()
|
||
|
||
if (updateError || !updated) {
|
||
throw new Error(`Failed to mark asset disposed: ${updateError?.message ?? 'unknown'}`)
|
||
}
|
||
|
||
return {
|
||
asset: updated as Asset,
|
||
disposal_entry: disposalEntry,
|
||
gain_or_loss: gainOrLoss,
|
||
}
|
||
}
|
||
|
||
/** Resolve the BAS 26xx output-VAT account for a given VAT treatment.
|
||
* Returns null for treatments that produce no VAT line. */
|
||
function outputVatAccountFor(treatment: VatTreatment): string | null {
|
||
switch (treatment) {
|
||
case 'standard_25':
|
||
return '2611'
|
||
case 'reduced_12':
|
||
return '2621'
|
||
case 'reduced_6':
|
||
return '2631'
|
||
case 'reverse_charge':
|
||
case 'export':
|
||
case 'exempt':
|
||
return null
|
||
}
|
||
}
|
||
|
||
function vatRateLabel(treatment: VatTreatment): string {
|
||
switch (treatment) {
|
||
case 'standard_25':
|
||
return '25%'
|
||
case 'reduced_12':
|
||
return '12%'
|
||
case 'reduced_6':
|
||
return '6%'
|
||
default:
|
||
return ''
|
||
}
|
||
}
|
||
|
||
function round2(n: number): number {
|
||
return Math.round(n * 100) / 100
|
||
}
|
||
|
||
/**
|
||
* Sum every posted depreciation_schedules row for an asset to get accumulated
|
||
* depreciation as of "now". Used by disposeAsset so the caller cannot
|
||
* influence the book-value calculation.
|
||
*/
|
||
async function sumPostedDepreciation(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
assetId: string,
|
||
): Promise<number> {
|
||
const { data, error } = await supabase
|
||
.from('depreciation_schedules')
|
||
.select('planned_depreciation')
|
||
.eq('company_id', companyId)
|
||
.eq('asset_id', assetId)
|
||
.not('journal_entry_id', 'is', null)
|
||
|
||
if (error) {
|
||
throw new Error(`Failed to sum depreciation for asset ${assetId}: ${error.message}`)
|
||
}
|
||
|
||
type Row = { planned_depreciation: number | string }
|
||
return ((data ?? []) as Row[]).reduce(
|
||
(sum, row) => sum + (Number(row.planned_depreciation) || 0),
|
||
0,
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Sum prior depreciation booked against an asset's 78xx avskrivningskonto
|
||
* up to and including `asOfDate`. Reads from journal_entry_lines so manually-
|
||
* posted avskrivningsverifikationer (i.e. not driven by depreciation_schedules)
|
||
* are also counted — the declining-balance engine needs the most accurate
|
||
* net book value to compute the next period's charge.
|
||
*
|
||
* Only counts posted entries against the asset's `bas_expense_account`
|
||
* (the 78xx avskrivningskonto), and only debits (since avskrivning = debit
|
||
* 78xx / credit 12x9). Returns 0 if the asset has never been depreciated.
|
||
*
|
||
* Why we look at the expense account rather than the accumulated account:
|
||
* the expense account is asset-specific by convention (7831 for machinery,
|
||
* 7832 for equipment, etc.) so we can scope per-asset accurately, whereas
|
||
* the accumulated account (12x9) may aggregate across assets in the same
|
||
* category. Limitation: when multiple assets share the same bas_expense_account
|
||
* we cannot disambiguate at the journal line level. The depreciation_schedules
|
||
* sum (see `sumPostedDepreciation`) is the safer fallback in that case.
|
||
* Callers that need exact per-asset accuracy should prefer the schedules sum.
|
||
*/
|
||
export async function getAccumulatedDepreciationAsOf(
|
||
supabase: SupabaseClient,
|
||
assetId: string,
|
||
asOfDate: string,
|
||
): Promise<number> {
|
||
// 1. Resolve the asset and its expense account first so we can target the
|
||
// correct 78xx code.
|
||
const { data: asset, error: assetError } = await supabase
|
||
.from('assets')
|
||
.select('bas_expense_account, company_id')
|
||
.eq('id', assetId)
|
||
.maybeSingle()
|
||
|
||
if (assetError) {
|
||
throw new Error(`Failed to load asset for accumulated depreciation: ${assetError.message}`)
|
||
}
|
||
if (!asset) return 0
|
||
|
||
type Row = { debit_amount: number | string | null; credit_amount: number | string | null }
|
||
const { data, error } = await supabase
|
||
.from('journal_entry_lines')
|
||
.select(
|
||
'debit_amount, credit_amount, journal_entries!inner(company_id, status, entry_date)',
|
||
)
|
||
.eq('account_number', asset.bas_expense_account)
|
||
.eq('journal_entries.company_id', asset.company_id)
|
||
.eq('journal_entries.status', 'posted')
|
||
.lte('journal_entries.entry_date', asOfDate)
|
||
|
||
if (error) {
|
||
throw new Error(
|
||
`Failed to sum accumulated depreciation for asset ${assetId}: ${error.message}`,
|
||
)
|
||
}
|
||
|
||
return ((data ?? []) as Row[]).reduce((sum, row) => {
|
||
// Expense account — normal balance is debit. Net = debit − credit so
|
||
// any storno (reversal) is netted out.
|
||
return sum + ((Number(row.debit_amount) || 0) - (Number(row.credit_amount) || 0))
|
||
}, 0)
|
||
}
|