Bug/momsdek overflow (#465)

* feat(settings): add option for company name position in invoice PDF

* feat(migrations): add backfill for VAT account labels to correct bad seed data

* feat(migrations): add backfill for VAT account labels to correct bad seed data

* fix(ui): improve accessibility for company name position toggle in PDF settings

* fix(bookkeeping): align BAS 2026 reference data with official PDF

Reconciled lib/bookkeeping/bas-data/ against the BAS 2026 v1.1 official
chart (1286 accounts). All real discrepancies fixed:

- 2089 Fond för utvecklingsutgifter: k2_excluded → true
- 8417 Räntekostnader för dold räntekompensation: k2_excluded → true
- 1250, 1260 renamed to "(Fritt konto för Inventarier, verktyg och
  installationer)" — BAS 2026 freed these slots
- Periodiseringsfond 2120-2139: added year suffixes (2120 = "...2020"
  etc.) and added 8 missing accounts (2121-2127, 2129) for years
  2019, 2021-2027. Dropped phantom 2022/2024 prior-parser garbage.
- 4075-4078: EUland → EU-land
- 8411: förlagsoch → förlags- och

Verified: 1282 of 1286 PDF accounts match exactly after edits (remaining
4 are PDF-parser artifacts, not real data). Build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(migrations): backfill BAS 2026 account labels in chart_of_accounts

Companion to the TS reference fix. Updates existing companies' rows where
they still carry seed-data typos or generic names that don't match BAS 2026:

- 4075-4078: EUland → EU-land (hyphenation)
- 8411: förlagsoch → förlags- och (hyphenation)
- 2120, 2130-2137, 2139: rename "Periodiseringsfond" (generic, no year) to
  the BAS 2026 canonical name with year suffix

Defensive: every WHERE clause matches an EXACT current value. Rows that
have been manually renamed by users — including those with a wrong year
that may reference legacy fonds from an earlier BAS numbering cycle — are
left untouched. No row is deleted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(migrations): fix wrong-year labels on Periodiseringsfond accounts

Follow-up to 20260513140000. The first backfill only renamed accounts whose
name was the generic "Periodiseringsfond" (no year). Many customers were
seeded from an older BAS numbering cycle where 2126 = "2016", 2127 = "2017",
etc. — BAS 2026 reuses those account numbers for years 2026/2027.

This migration aligns the year tag with the BAS 2026 meaning of each
account number across 2120-2127, 2129, 2130-2137, 2139. Only rows whose
name still starts with "Periodiseringsfond" are touched — customers who
renamed the account to something custom keep their name.

Verified on staging: all 18 accounts now carry BAS 2026 canonical names.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(data): update account names and descriptions for clarity and consistency

* fix(migrations): backfill account names for BAS 2026 freed accounts and refine Periodiseringsfond name matching

* feat: enhance VAT handling with reverse charge logic and supplier type support

* feat: implement VAT declaration validation rules and enhance moms box mapping

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-05-13 15:17:32 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 9e0b89ee7e
commit da39eb2d43
13 changed files with 724 additions and 36 deletions
+63 -19
View File
@@ -36,6 +36,22 @@ interface SkatteverketStatus {
expiresAt?: string
}
/**
* Codes from /lib/api-client.ts's SkatteverketAuthError that mean "the user
* needs to reconnect with BankID before this action can succeed". When the API
* returns one of these codes we flip the local status.expired flag so the
* "Session utgången" badge + "Förnya session" button surface, even if the
* upstream /status endpoint hasn't reflected the change yet.
*/
const AUTH_RECONNECT_CODES = new Set([
'NOT_CONNECTED',
'SESSION_EXPIRED',
'REFRESH_EXHAUSTED',
'TOKEN_REVOKED',
'TOKEN_CORRUPTED',
'MISSING_SCOPE',
])
// Shape per Skatteverket Momsdeklaration v1.0.24 RAML
// (kontrollResultat.resultat[].{kod, status, beskrivning})
interface KontrollResult {
@@ -90,6 +106,21 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk
const localErrors = localChecks.filter((c) => c.status === 'ERROR')
const localBlocked = localErrors.length > 0
/**
* Apply an API JSON error result. When the error indicates the SKV session
* has expired/been revoked/lost scope, immediately reflect that in the
* local status so the "Förnya session" CTA appears next to the message —
* the user shouldn't have to wait for /status to catch up.
*/
const applyApiError = useCallback((result: { error?: string; code?: string } | null) => {
if (!result?.error) return false
setError(result.error)
if (result.code && AUTH_RECONNECT_CODES.has(result.code)) {
setStatus((prev) => prev ? { ...prev, expired: true } : prev)
}
return true
}, [])
const fetchStatus = useCallback(async () => {
try {
const res = await fetch('/api/extensions/ext/skatteverket/status')
@@ -169,8 +200,8 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk
body: JSON.stringify({ periodType, year, period }),
})
const result = await res.json()
if (result.error) {
setError(result.error)
if (applyApiError(result)) {
// surfaced + status updated; nothing more to do
} else {
const controls: KontrollResult[] = result.data?.kontrollResultat?.resultat || []
setKontroller(controls)
@@ -215,8 +246,8 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk
body: JSON.stringify({ periodType, year, period }),
})
const result = await res.json()
if (result.error) {
setError(result.error)
if (applyApiError(result)) {
// surfaced + status updated; nothing more to do
} else {
const controls: KontrollResult[] = result.data?.kontrollResultat?.resultat || []
setKontroller(controls)
@@ -245,8 +276,8 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk
{ method: 'PUT' }
)
const result = await res.json()
if (result.error) {
setError(result.error)
if (applyApiError(result)) {
// surfaced + status updated; nothing more to do
} else if (result.data?.signeringsLank) {
setSigneringslank(result.data.signeringsLank)
setSuccess('Utkastet är låst. Öppna signeringslänken för att signera med BankID.')
@@ -269,8 +300,8 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk
{ method: 'DELETE' }
)
const result = await res.json()
if (result.error) {
setError(result.error)
if (applyApiError(result)) {
// surfaced + status updated; nothing more to do
} else {
setSigneringslank(null)
setSuccess('Utkastet har låsts upp')
@@ -292,8 +323,8 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk
)}&redovisningsperiod=${getRedovisningsperiod()}`
)
const result = await res.json()
if (result.error) {
setError(result.error)
if (applyApiError(result)) {
// surfaced + status updated; nothing more to do
} else if (result.data) {
setSubmitted(result.data)
setSuccess('Deklarationen har lämnats in')
@@ -323,7 +354,9 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk
setSuccess('Utkastet har raderats från Eget utrymme')
} else {
const result = await res.json().catch(() => ({}))
setError(result.error || `Kunde inte radera utkast (${res.status})`)
if (!applyApiError(result)) {
setError(`Kunde inte radera utkast (${res.status})`)
}
}
} catch {
setError('Kunde inte radera utkast')
@@ -342,8 +375,8 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk
)}&redovisningsperiod=${getRedovisningsperiod()}`
)
const result = await res.json()
if (result.error) {
setError(result.error)
if (applyApiError(result)) {
// surfaced + status updated; nothing more to do
} else if (!result.data) {
setSuccess('Inget sparat utkast hittades för perioden')
} else {
@@ -369,8 +402,8 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk
)}&redovisningsperiod=${getRedovisningsperiod()}`
)
const result = await res.json()
if (result.error) {
setError(result.error)
if (applyApiError(result)) {
// surfaced + status updated; nothing more to do
} else if (!result.data) {
setSuccess('Inget beslut hittades för perioden')
} else {
@@ -455,10 +488,21 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk
Ansluten
</Badge>
{status.expired && (
<Badge variant="destructive" className="gap-1">
<AlertCircle className="h-3 w-3" />
Session utgången
</Badge>
<>
<Badge variant="destructive" className="gap-1">
<AlertCircle className="h-3 w-3" />
Session utgången
</Badge>
<Button
size="sm"
variant="outline"
onClick={handleConnect}
className="gap-1.5 h-7"
>
<Link2 className="h-3.5 w-3.5" />
Förnya session
</Button>
</>
)}
</div>
</div>
@@ -24,6 +24,8 @@ interface JournalEntryPreviewProps {
templateDebitAccount?: string
templateCreditAccount?: string
templateVatRate?: number
templateVatTreatment?: VatTreatment | null
templateSupplierType?: 'eu_business' | 'non_eu_business' | 'swedish_business'
/** For multi-line counterparty template bookings */
linePattern?: LinePatternEntry[]
settlementAccount?: string
@@ -39,6 +41,8 @@ export default function JournalEntryPreview({
templateDebitAccount,
templateCreditAccount,
templateVatRate,
templateVatTreatment,
templateSupplierType,
linePattern,
settlementAccount = '1930',
}: JournalEntryPreviewProps) {
@@ -91,6 +95,7 @@ export default function JournalEntryPreview({
const vatAmt = extractVatAmount(absAmount, vatRate)
const netAmt = extractNetAmount(absAmount, vatRate)
const isIncome = amount > 0
const isReverseCharge = templateVatTreatment === 'reverse_charge' && !isIncome
if (isIncome) {
// Income: debit bank gross, credit revenue net, credit output VAT
@@ -101,6 +106,32 @@ export default function JournalEntryPreview({
const outputVatAccount = vatRate === 0.06 ? '2631' : vatRate === 0.12 ? '2621' : '2611'
result.push({ side: 'kredit', account: outputVatAccount, amount: vatAmt })
}
} else if (isReverseCharge) {
// Expense with reverse charge: full reverse-charge verifikation
// (must match engine output in buildMappingResultFromTemplate).
const rcRate = 0.25
const rcVatAmt = Math.round(absAmount * rcRate * 100) / 100
const supplierType = templateSupplierType ?? 'eu_business'
const isDomestic = supplierType === 'swedish_business'
// Expense gross + bank
result.push({ side: 'debet', account: templateDebitAccount, amount: absAmount })
result.push({ side: 'kredit', account: templateCreditAccount, amount: absAmount })
// Fiktiv moms pair: 2645 (or 2647 domestic) / 2614
result.push({ side: 'debet', account: isDomestic ? '2647' : '2645', amount: rcVatAmt })
result.push({ side: 'kredit', account: '2614', amount: rcVatAmt })
// Basbelopp pair: 44xx|45xx / 4598 — populates rutor 20–24.
// Skip if the debit account is already a basis account.
if (!/^4[45]\d{2}$/.test(templateDebitAccount)) {
const basisAccount =
supplierType === 'eu_business' ? '4535'
: supplierType === 'non_eu_business' ? '4531'
: '4425'
result.push({ side: 'debet', account: basisAccount, amount: absAmount })
result.push({ side: 'kredit', account: '4598', amount: absAmount })
}
} else {
// Expense: debit expense net + input VAT, credit bank gross
result.push({ side: 'debet', account: templateDebitAccount, amount: netAmt })
@@ -150,7 +181,7 @@ export default function JournalEntryPreview({
}
return result
}, [amount, category, vatTreatment, accountOverride, entityType, templateDebitAccount, templateCreditAccount, templateVatRate, linePattern, settlementAccount])
}, [amount, category, vatTreatment, accountOverride, entityType, templateDebitAccount, templateCreditAccount, templateVatRate, templateVatTreatment, templateSupplierType, linePattern, settlementAccount])
if (lines.length === 0) return null
@@ -172,7 +172,7 @@ export default function QuickReviewDialog({
}
onOpenChange(o)
}}>
<DialogContent className="max-w-md max-h-[85vh] overflow-y-auto">
<DialogContent className="max-w-md sm:max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Granska bokföring</DialogTitle>
<DialogDescription>
@@ -264,7 +264,13 @@ export default function QuickReviewDialog({
{...(isCounterpartyTemplate
? { linePattern: counterpartyLinePattern ?? undefined }
: templateId && template
? { templateDebitAccount: template.debit_account, templateCreditAccount: template.credit_account, templateVatRate: template.vat_rate }
? {
templateDebitAccount: template.debit_account,
templateCreditAccount: template.credit_account,
templateVatRate: template.vat_rate,
templateVatTreatment: template.vat_treatment,
templateSupplierType: template.reverse_charge_supplier_type,
}
: { category, vatTreatment: isLiabilityAccount ? 'none' : vatTreatment, accountOverride, entityType }
)}
/>