Fix/attributes config (#926)
* fix(git): pin LF on generated extension registry and vitest snapshots setup:extensions and vitest write these files with LF; with core.autocrlf=true git expects CRLF and flags them as phantom modifications on every dev/build run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): enforce MFA on mcp-oauth consent and gate viewer storno route mcp-oauth/authorize renders an HTML consent page and issues 303 redirects that withRouteContext cannot express, so it kept raw getUser() and thereby skipped the AAL2 gate: a password-only (AAL1) session could approve consent that mints a long-lived, MFA-bypassing API key. Add a route-local requireAal2() step-up on GET and POST; AAL1 sessions redirect to /mfa/verify, BankID users are exempt. Separately, POST /api/reports/vat-declaration/rc-basis-gaps/fix calls correctEntry() (storno of a posted entry) but lacked requireWrite, so viewer-role members could trigger it. Add { requireWrite: true }. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): route transactions endpoints through withRouteContext Migrate the transactions routes off hand-rolled supabase.auth.getUser() onto the MFA-enforcing withRouteContext wrapper; add requireWrite on mutating handlers (book, uncategorize, attach-document, ignore, batch-match, create-from-document). Behavior and response shapes preserved; tests updated to the wrapper mock pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): route SIE import and bank reconciliation through withRouteContext Migrate import/sie and reconciliation/bank routes onto the MFA-enforcing wrapper; requireWrite on mutations (import execute, create-accounts, mappings write verbs, link/unlink/run/mark-opening-balance). Reads (status, unmatched-entries) stay ungated. Response shapes preserved; tests added/updated to the wrapper mock pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): route salary endpoints through withRouteContext Migrate salary employees and runs routes (plus ku, payroll-config, tax-tables) onto the MFA-enforcing wrapper; requireWrite on mutations. Personnummer masking/encryption untouched; file downloads (AGI XML, payslip PDF, payment files) keep their headers. Two payment-file GETs retain requireWrite because they stamp *_file_generated_at and previously gated viewers. Tests added/updated to the wrapper mock pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): route report endpoints through withRouteContext Migrate the read-only report routes (trial balance, balansrapport, resultatrapport, income statement, ledgers, KPI, VAT declaration, salary journal, monthly breakdown, journal register, continuity check, full archive, etc.) onto the MFA-enforcing wrapper. All read-only, no requireWrite. JSON/XLSX/PDF/ZIP response bodies and headers preserved byte-for-byte; tests updated to the wrapper mock pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): route invoices, skatteverket, agent and extension endpoints through withRouteContext Migrate invoices, supplier-invoices, skatteverket tax-payments, and dynamic extension routes onto the MFA-enforcing wrapper with requireWrite on mutations. The two NDJSON streaming agent routes (invoke, onboarding/stream) use requireAuth() directly (the wrapper can't wrap a streaming response) so MFA is still enforced. skatteverket payment-file GET keeps requireWrite (stamps a generated-at field). Response shapes and file headers preserved; tests added/updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): route documents, events, team and account endpoints through withRouteContext Migrate documents, events, kpi/preferences, vat/validate, support/contact onto the MFA-enforcing wrapper with requireWrite on mutations. account/password, team/accept and team/members use requireAuth() directly (user-level or pre-membership flows with no active company context) so MFA is still enforced. events keeps its dual API-key-or-session auth. Document retention guard untouched; tests added/updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): route settings and pending-operations endpoints through withRouteContext Migrate settings (api-keys, oauth-clients, booking-templates, counterparty-templates, logo, company settings) and pending-operations (commit, bulk-commit, reject, edit-before-approve) onto the MFA-enforcing wrapper with requireWrite on mutations. Credential-guarding routes keep their per-user ownership filters. Response shapes preserved; tests added/updated to the wrapper mock pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(guards): ratchet raw-route-auth baseline 119->1 after A1 migration Lock in the withRouteContext migration so the count cannot regress. The single remaining entry, mcp-oauth/authorize, is a documented exception (HTML consent + redirects, MFA enforced via route-local step-up). Record the campaign and requireWrite decisions in DECISIONS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(vat): add eSKD momsdeklaration file export for "Deklarera via fil" Generate the Skatteverket eSKDUpload v6.0 XML file so users can file VAT by upload instead of typing every ruta into the form. Extract buildFiledAmounts() as the shared whole-krona source of truth (öre truncated per SFL 22 kap 1 §) so the XML file and the manual-filing PDF can never disagree. Adds the /eskd API route, an XML option in the report export menu, and the upload button on the manual-filing card. Strings in sv + en. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(vat): add 'vat_settlement' source type and update related components * fix(booking): adjust search input layout and enable autofocus * fix(vat): support 12-digit org numbers and adjust emission order for eSKD file * fix(migration): add 'vat_settlement' to journal_entries.source_type CHECK --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
63e05c4eec
commit
abe9ac9d8c
@@ -198,6 +198,7 @@ describe('Enum schemas', () => {
|
||||
'opening_balance', 'year_end', 'storno', 'correction',
|
||||
'import', 'system', 'supplier_invoice_registered',
|
||||
'supplier_invoice_paid', 'supplier_invoice_cash_payment', 'supplier_credit_note',
|
||||
'vat_settlement',
|
||||
]
|
||||
for (const s of sources) {
|
||||
expect(JournalEntrySourceTypeSchema.safeParse(s).success).toBe(true)
|
||||
|
||||
@@ -207,6 +207,7 @@ export const JournalEntrySourceTypeSchema = z.enum([
|
||||
'reminder_fee',
|
||||
'accrual',
|
||||
'result_appropriation',
|
||||
'vat_settlement',
|
||||
])
|
||||
|
||||
/** Query params for GET /api/bookkeeping/voucher-sequences/next. */
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { sourceTypeForTemplateCategory } from '@/lib/bookkeeping/template-source-type'
|
||||
import type { BookingTemplateCategory } from '@/types'
|
||||
|
||||
describe('sourceTypeForTemplateCategory', () => {
|
||||
it('routes the vat category to vat_settlement', () => {
|
||||
expect(sourceTypeForTemplateCategory('vat')).toBe('vat_settlement')
|
||||
})
|
||||
|
||||
it('returns undefined for categories without a dedicated source type', () => {
|
||||
const others: BookingTemplateCategory[] = [
|
||||
'eu_trade',
|
||||
'tax_account',
|
||||
'private_transfer',
|
||||
'salary',
|
||||
'representation',
|
||||
'year_end',
|
||||
'financial',
|
||||
'other',
|
||||
]
|
||||
for (const category of others) {
|
||||
expect(sourceTypeForTemplateCategory(category)).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('returns undefined for null/undefined', () => {
|
||||
expect(sourceTypeForTemplateCategory(null)).toBeUndefined()
|
||||
expect(sourceTypeForTemplateCategory(undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { BookingTemplateCategory, JournalEntrySourceType } from '@/types'
|
||||
|
||||
/**
|
||||
* Map a booking-template category to the journal `source_type` an entry booked
|
||||
* from that template should carry, when the category has a dedicated source
|
||||
* type.
|
||||
*
|
||||
* VAT-category templates (the system "Momsredovisning (nettning)" template and
|
||||
* any user template filed under `vat`) book as `vat_settlement` so the entry
|
||||
* lands in the company's configured VAT voucher series
|
||||
* (`default_voucher_series_per_source_type.vat_settlement`) instead of the
|
||||
* shared `manual` series, keeping momsredovisning verifikat separate from
|
||||
* "övriga".
|
||||
*
|
||||
* Returns `undefined` for categories with no dedicated source type: the entry
|
||||
* keeps whatever source type the form was already using.
|
||||
*/
|
||||
export function sourceTypeForTemplateCategory(
|
||||
category: BookingTemplateCategory | null | undefined,
|
||||
): JournalEntrySourceType | undefined {
|
||||
return category === 'vat' ? 'vat_settlement' : undefined
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildESkdFile } from '@/lib/reports/vat-eskd-file'
|
||||
import { buildManualFilingRows } from '@/lib/reports/vat-manual-filing'
|
||||
import type { VatDeclarationRutor } from '@/types'
|
||||
|
||||
/** All rutor zeroed; override the ones a case cares about. */
|
||||
function makeRutor(overrides: Partial<VatDeclarationRutor> = {}): VatDeclarationRutor {
|
||||
return {
|
||||
ruta05: 0, ruta06: 0, ruta07: 0, ruta08: 0,
|
||||
ruta10: 0, ruta11: 0, ruta12: 0,
|
||||
ruta20: 0, ruta21: 0, ruta22: 0, ruta23: 0, ruta24: 0,
|
||||
ruta30: 0, ruta31: 0, ruta32: 0,
|
||||
ruta35: 0, ruta36: 0, ruta37: 0, ruta38: 0, ruta39: 0, ruta40: 0, ruta41: 0, ruta42: 0,
|
||||
ruta48: 0,
|
||||
ruta49: 0,
|
||||
ruta50: 0, ruta60: 0, ruta61: 0, ruta62: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const ORG = '556000-0175'
|
||||
const PERIOD_END = '2025-03-31' // Q1 2025 or March 2025 → <Period>202503</Period>
|
||||
|
||||
describe('buildESkdFile', () => {
|
||||
it('emits the eSKD header, OrgNr and Period per the SKV file spec', () => {
|
||||
const xml = buildESkdFile(makeRutor({ ruta49: 0 }), { orgNumber: ORG, periodEnd: PERIOD_END })
|
||||
const lines = xml.split('\r\n')
|
||||
expect(lines[0]).toBe('<?xml version="1.0" encoding="ISO-8859-1"?>')
|
||||
expect(lines[1]).toBe('<eSKDUpload Version="6.0">')
|
||||
expect(lines[2]).toBe('<OrgNr>556000-0175</OrgNr>')
|
||||
expect(lines[3]).toBe('<Moms>')
|
||||
expect(lines[4]).toBe('<Period>202503</Period>')
|
||||
expect(xml.endsWith('</Moms>\r\n</eSKDUpload>\r\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('maps rutor to their eSKD tags (Exempel 1: moms att betala)', () => {
|
||||
const xml = buildESkdFile(
|
||||
makeRutor({ ruta05: 100000, ruta10: 25000, ruta48: 1000 }),
|
||||
{ orgNumber: ORG, periodEnd: '2024-01-31' },
|
||||
)
|
||||
expect(xml).toContain('<ForsMomsEjAnnan>100000</ForsMomsEjAnnan>')
|
||||
expect(xml).toContain('<MomsUtgHog>25000</MomsUtgHog>')
|
||||
expect(xml).toContain('<MomsIngAvdr>1000</MomsIngAvdr>')
|
||||
expect(xml).toContain('<MomsBetala>24000</MomsBetala>')
|
||||
expect(xml).toContain('<Period>202401</Period>')
|
||||
})
|
||||
|
||||
it('omits zero rutor but always emits MomsBetala (Exempel 3: inget att deklarera)', () => {
|
||||
const xml = buildESkdFile(makeRutor(), { orgNumber: ORG, periodEnd: PERIOD_END })
|
||||
expect(xml).not.toContain('<ForsMomsEjAnnan>')
|
||||
expect(xml).not.toContain('<MomsIngAvdr>')
|
||||
expect(xml).toContain('<MomsBetala>0</MomsBetala>')
|
||||
})
|
||||
|
||||
it('writes a refund with a leading minus and no plus sign (Exempel 2)', () => {
|
||||
const xml = buildESkdFile(
|
||||
makeRutor({ ruta05: 100000, ruta10: 25000, ruta48: 55000 }),
|
||||
{ orgNumber: ORG, periodEnd: '2024-01-31' },
|
||||
)
|
||||
expect(xml).toContain('<MomsBetala>-30000</MomsBetala>')
|
||||
expect(xml).not.toContain('+')
|
||||
})
|
||||
|
||||
it('truncates öre to whole kronor (öretal faller bort), never rounds up', () => {
|
||||
const xml = buildESkdFile(
|
||||
makeRutor({ ruta05: 100000.99, ruta10: 25000.75, ruta48: 999.99 }),
|
||||
{ orgNumber: ORG, periodEnd: PERIOD_END },
|
||||
)
|
||||
expect(xml).toContain('<ForsMomsEjAnnan>100000</ForsMomsEjAnnan>')
|
||||
expect(xml).toContain('<MomsUtgHog>25000</MomsUtgHog>')
|
||||
expect(xml).toContain('<MomsIngAvdr>999</MomsIngAvdr>')
|
||||
// Net = 25000 (truncated) - 999 (truncated) = 24001.
|
||||
expect(xml).toContain('<MomsBetala>24001</MomsBetala>')
|
||||
})
|
||||
|
||||
it('emits reverse-charge and import rutor with the correct tags', () => {
|
||||
const xml = buildESkdFile(
|
||||
makeRutor({ ruta21: 5000, ruta30: 1250, ruta50: 10000, ruta60: 2000, ruta48: 3250 }),
|
||||
{ orgNumber: ORG, periodEnd: PERIOD_END },
|
||||
)
|
||||
expect(xml).toContain('<InkopTjanstAnnatEg>5000</InkopTjanstAnnatEg>')
|
||||
expect(xml).toContain('<MomsInkopUtgHog>1250</MomsInkopUtgHog>')
|
||||
expect(xml).toContain('<MomsUlagImport>10000</MomsUlagImport>')
|
||||
expect(xml).toContain('<MomsImportUtgHog>2000</MomsImportUtgHog>')
|
||||
// Net = (1250 + 2000) - 3250 = 0.
|
||||
expect(xml).toContain('<MomsBetala>0</MomsBetala>')
|
||||
})
|
||||
|
||||
it('derives <Period> from the period end month for all period lengths', () => {
|
||||
// Yearly räkenskapsår ending in a non-December month.
|
||||
const xml = buildESkdFile(makeRutor(), { orgNumber: ORG, periodEnd: '2025-06-30' })
|
||||
expect(xml).toContain('<Period>202506</Period>')
|
||||
})
|
||||
|
||||
it('accepts an unformatted org number and normalises it', () => {
|
||||
const xml = buildESkdFile(makeRutor(), { orgNumber: '5560000175', periodEnd: PERIOD_END })
|
||||
expect(xml).toContain('<OrgNr>556000-0175</OrgNr>')
|
||||
})
|
||||
|
||||
it('strips the century prefix from a 12-digit org number', () => {
|
||||
const xml = buildESkdFile(makeRutor(), { orgNumber: '165560000175', periodEnd: PERIOD_END })
|
||||
expect(xml).toContain('<OrgNr>556000-0175</OrgNr>')
|
||||
})
|
||||
|
||||
it('emits the import block (rutor 50/60/61/62) BEFORE MomsIngAvdr per the SKV radnummer order', () => {
|
||||
const xml = buildESkdFile(
|
||||
makeRutor({ ruta48: 3250, ruta50: 10000, ruta60: 2000, ruta61: 120, ruta62: 60 }),
|
||||
{ orgNumber: ORG, periodEnd: PERIOD_END },
|
||||
)
|
||||
const order = ['MomsUlagImport', 'MomsImportUtgHog', 'MomsImportUtgMedel', 'MomsImportUtgLag', 'MomsIngAvdr']
|
||||
.map((tag) => xml.indexOf(`<${tag}>`))
|
||||
expect(order.every((idx) => idx >= 0)).toBe(true)
|
||||
expect([...order].sort((a, b) => a - b)).toEqual(order)
|
||||
})
|
||||
|
||||
it('throws on an org number without exactly 10 digits', () => {
|
||||
expect(() => buildESkdFile(makeRutor(), { orgNumber: '12345', periodEnd: PERIOD_END })).toThrow()
|
||||
})
|
||||
|
||||
it('throws on a malformed period end date', () => {
|
||||
expect(() => buildESkdFile(makeRutor(), { orgNumber: ORG, periodEnd: '2025-3' })).toThrow()
|
||||
})
|
||||
|
||||
it('ties out to the PDF net for the same rutor', () => {
|
||||
const rutor = makeRutor({ ruta05: 100000, ruta10: 25000, ruta11: 1200, ruta48: 3333 })
|
||||
const xml = buildESkdFile(rutor, { orgNumber: ORG, periodEnd: PERIOD_END })
|
||||
const pdfRows = buildManualFilingRows(rutor)
|
||||
const pdfNet = pdfRows.find((r) => r.ruta === '49')!
|
||||
// PDF carries the absolute value + label; XML carries the signed net.
|
||||
const signedPdfNet = pdfNet.label === 'Moms att återfå' ? -pdfNet.amount : pdfNet.amount
|
||||
expect(xml).toContain(`<MomsBetala>${signedPdfNet}</MomsBetala>`)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { VAT_RUTA_LABELS, type VatDeclarationRutor } from '@/types'
|
||||
import { buildFiledAmounts } from '@/lib/reports/vat-manual-filing'
|
||||
|
||||
/**
|
||||
* Maps each momsdeklaration ruta to its eSKDUpload XML tag, per Skatteverket's
|
||||
* "Skapa en fil" specification (dev_docs/skatteverket/moms). The tag order in
|
||||
* the emitted file follows the ruta order defined below, which matches the SKV
|
||||
* form/file layout. ruta49 (MomsBetala) is the mandatory summering and is always
|
||||
* emitted last.
|
||||
*/
|
||||
const RUTA_TO_ESKD_TAG: Record<keyof VatDeclarationRutor, string> = {
|
||||
ruta05: 'ForsMomsEjAnnan',
|
||||
ruta06: 'UttagMoms',
|
||||
ruta07: 'UlagMargbesk',
|
||||
ruta08: 'HyrinkomstFriv',
|
||||
ruta10: 'MomsUtgHog',
|
||||
ruta11: 'MomsUtgMedel',
|
||||
ruta12: 'MomsUtgLag',
|
||||
ruta20: 'InkopVaruAnnatEg',
|
||||
ruta21: 'InkopTjanstAnnatEg',
|
||||
ruta22: 'InkopTjanstUtomEg',
|
||||
ruta23: 'InkopVaruSverige',
|
||||
ruta24: 'InkopTjanstSverige',
|
||||
ruta30: 'MomsInkopUtgHog',
|
||||
ruta31: 'MomsInkopUtgMedel',
|
||||
ruta32: 'MomsInkopUtgLag',
|
||||
ruta35: 'ForsVaruAnnatEg',
|
||||
ruta36: 'ForsVaruUtomEg',
|
||||
ruta37: 'InkopVaruMellan3p',
|
||||
ruta38: 'ForsVaruMellan3p',
|
||||
ruta39: 'ForsTjSkskAnnatEg',
|
||||
ruta40: 'ForsTjOvrUtomEg',
|
||||
ruta41: 'ForsKopareSkskSverige',
|
||||
ruta42: 'ForsOvrigt',
|
||||
// Import block: beskattningsunderlag (50) then output VAT (60/61/62).
|
||||
ruta50: 'MomsUlagImport',
|
||||
ruta60: 'MomsImportUtgHog',
|
||||
ruta61: 'MomsImportUtgMedel',
|
||||
ruta62: 'MomsImportUtgLag',
|
||||
ruta48: 'MomsIngAvdr',
|
||||
ruta49: 'MomsBetala',
|
||||
}
|
||||
|
||||
// Emission order: the RUTA_TO_ESKD_TAG key order, which encodes the SKV file
|
||||
// spec's radnummer sequence. Notably the import block (ruta 50/60/61/62,
|
||||
// rad 29-32) comes BEFORE MomsIngAvdr (ruta 48, rad 33); a numeric ruta sort
|
||||
// would invert that and produce a file SKV rejects (avvisande fel). ruta49
|
||||
// (MomsBetala) is the mandatory summering and is emitted last by the builder.
|
||||
const EMIT_ORDER: (keyof VatDeclarationRutor)[] = (
|
||||
Object.keys(RUTA_TO_ESKD_TAG) as (keyof VatDeclarationRutor)[]
|
||||
).filter((key) => key !== 'ruta49')
|
||||
|
||||
export interface ESkdFileInput {
|
||||
/** Company org/person number, any format; digits are extracted and re-formatted. */
|
||||
orgNumber: string
|
||||
/**
|
||||
* The declaration period's END date (YYYY-MM-DD). The eSKD <Period> is the
|
||||
* year plus the last month of the period (YYYYMM): for monthly that is the
|
||||
* month itself, for quarterly the last month of the quarter, for a full
|
||||
* beskattningsår the last month of the fiscal year. Deriving it from the end
|
||||
* date handles all three uniformly.
|
||||
*/
|
||||
periodEnd: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a Swedish org/person number as `xxxxxx-xxxx` (10 digits with hyphen),
|
||||
* as required by the eSKD header. Accepts 12-digit century-prefixed values
|
||||
* (16xxxxxxxxxx org numbers, 19/20-prefixed personnummer) by stripping the
|
||||
* prefix, mirroring formatRedovisare/formatOrgNumber12; settings rows predating
|
||||
* org-number normalization legitimately hold 12 digits. Throws otherwise, since
|
||||
* an out-of-format OrgNr is an "avvisande fel" that Skatteverket rejects outright.
|
||||
*/
|
||||
function formatESkdOrgNumber(orgNumber: string): string {
|
||||
let digits = orgNumber.replace(/\D/g, '')
|
||||
if (digits.length === 12) digits = digits.slice(2)
|
||||
if (digits.length !== 10) {
|
||||
throw new Error(
|
||||
`Ogiltigt organisationsnummer för momsdeklaration (kräver 10 siffror): ${orgNumber}`,
|
||||
)
|
||||
}
|
||||
return `${digits.slice(0, 6)}-${digits.slice(6)}`
|
||||
}
|
||||
|
||||
/** Derives the eSKD <Period> value (YYYYMM) from the period end date. */
|
||||
function toESkdPeriod(periodEnd: string): string {
|
||||
const match = /^(\d{4})-(\d{2})-\d{2}$/.exec(periodEnd)
|
||||
if (!match) {
|
||||
throw new Error(`Ogiltigt periodslutdatum för momsdeklaration: ${periodEnd}`)
|
||||
}
|
||||
return `${match[1]}${match[2]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the Skatteverket eSKDUpload (v6.0) momsdeklaration file from the
|
||||
* calculated rutor. This is the file a user uploads under "Deklarera via fil";
|
||||
* unlike the PDF (a read/record copy) this is a real submission artifact.
|
||||
*
|
||||
* Format rules (dev_docs/skatteverket/moms/momsdeklaration_via_etjänst.txt):
|
||||
* - Whole kronor only, no decimals; öre are truncated (shared with the PDF via
|
||||
* buildFiledAmounts so the two documents always tie out).
|
||||
* - Only rutor with a value are emitted, except <MomsBetala> (ruta49) which is
|
||||
* the mandatory summering and is always present.
|
||||
* - A refund is written with a leading minus directly before the amount; no
|
||||
* leading plus is ever emitted.
|
||||
* - Declared encoding is ISO-8859-1. The content is effectively ASCII (tags,
|
||||
* digits, hyphen), so no non-ASCII bytes appear; the caller still encodes as
|
||||
* latin1 to honour the declared charset.
|
||||
* - CRLF line endings.
|
||||
*
|
||||
* @returns the XML string. Encode with Buffer.from(xml, 'latin1') before serving.
|
||||
*/
|
||||
export function buildESkdFile(rutor: VatDeclarationRutor, input: ESkdFileInput): string {
|
||||
const orgNr = formatESkdOrgNumber(input.orgNumber)
|
||||
const period = toESkdPeriod(input.periodEnd)
|
||||
const { amounts } = buildFiledAmounts(rutor)
|
||||
|
||||
const lines: string[] = [
|
||||
'<?xml version="1.0" encoding="ISO-8859-1"?>',
|
||||
'<eSKDUpload Version="6.0">',
|
||||
`<OrgNr>${orgNr}</OrgNr>`,
|
||||
'<Moms>',
|
||||
`<Period>${period}</Period>`,
|
||||
]
|
||||
|
||||
for (const key of EMIT_ORDER) {
|
||||
const amount = amounts[key]
|
||||
if (amount === 0) continue // omit empty rutor; MomsBetala is emitted below
|
||||
lines.push(`<${RUTA_TO_ESKD_TAG[key]}>${amount}</${RUTA_TO_ESKD_TAG[key]}>`)
|
||||
}
|
||||
|
||||
// ruta49 (MomsBetala) is always emitted, even when 0 (Exempel 3: inget att
|
||||
// deklarera). A refund keeps its leading minus from the signed net.
|
||||
lines.push(`<MomsBetala>${amounts.ruta49}</MomsBetala>`)
|
||||
|
||||
lines.push('</Moms>')
|
||||
lines.push('</eSKDUpload>')
|
||||
|
||||
return lines.join('\r\n') + '\r\n'
|
||||
}
|
||||
|
||||
// Re-exported for tests and any caller that needs the tag mapping directly.
|
||||
export { RUTA_TO_ESKD_TAG, VAT_RUTA_LABELS }
|
||||
@@ -21,6 +21,42 @@ const OUTPUT_VAT_RUTOR: (keyof VatDeclarationRutor)[] = [
|
||||
'ruta60', 'ruta61', 'ruta62',
|
||||
]
|
||||
|
||||
/**
|
||||
* The rutor exactly as they are filed at Skatteverket: every ruta truncated to
|
||||
* a whole krona (öretal faller bort per SFL 22 kap 1 §: dropped, not rounded to
|
||||
* nearest) and the ruta 49 net recomputed from those truncated output/input
|
||||
* rutor rather than from the pre-computed öre value, so the arithmetic ties out
|
||||
* exactly to what is filed. This is NOT bookkeeping math (nothing here is
|
||||
* posted), so the öre-precision money rule (Math.round(x*100)/100) does not
|
||||
* apply; it mirrors the SRU income-tax path, which drops öre under the same
|
||||
* statute.
|
||||
*
|
||||
* This is the single source of truth for the whole-krona amounts shared by the
|
||||
* manual-filing PDF (buildManualFilingRows) and the eSKD XML file
|
||||
* (lib/reports/vat-eskd-file.ts), so the two can never disagree.
|
||||
*/
|
||||
export function buildFiledAmounts(rutor: VatDeclarationRutor): {
|
||||
/** Every ruta truncated to whole kronor. ruta49 carries its sign (negative = återfå). */
|
||||
amounts: Record<keyof VatDeclarationRutor, number>
|
||||
/** The recomputed ruta 49 net, signed: positive = att betala, negative = att återfå. */
|
||||
net: number
|
||||
} {
|
||||
// Truncate toward zero: öretal faller bort (SFL 22 kap 1 §), never round up.
|
||||
const kr = (key: keyof VatDeclarationRutor): number => Math.trunc(rutor[key] ?? 0)
|
||||
|
||||
const outputVat = OUTPUT_VAT_RUTOR.reduce((sum, key) => sum + kr(key), 0)
|
||||
const net = outputVat - kr('ruta48')
|
||||
|
||||
const amounts = {} as Record<keyof VatDeclarationRutor, number>
|
||||
for (const key of Object.keys(VAT_RUTA_LABELS) as (keyof VatDeclarationRutor)[]) {
|
||||
amounts[key] = kr(key)
|
||||
}
|
||||
// ruta49 is the recomputed net, not the source öre value.
|
||||
amounts.ruta49 = net
|
||||
|
||||
return { amounts, net }
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the momsdeklaration rows for manual filing at skatteverket.se, in
|
||||
* hela kronor.
|
||||
@@ -43,11 +79,7 @@ const OUTPUT_VAT_RUTOR: (keyof VatDeclarationRutor)[] = [
|
||||
* which stay Swedish in both locales.
|
||||
*/
|
||||
export function buildManualFilingRows(rutor: VatDeclarationRutor): ManualFilingRow[] {
|
||||
// Truncate toward zero: öretal faller bort (SFL 22 kap 1 §), never round up.
|
||||
const kr = (key: keyof VatDeclarationRutor): number => Math.trunc(rutor[key] ?? 0)
|
||||
|
||||
const outputVat = OUTPUT_VAT_RUTOR.reduce((sum, key) => sum + kr(key), 0)
|
||||
const net = outputVat - kr('ruta48')
|
||||
const { amounts, net } = buildFiledAmounts(rutor)
|
||||
|
||||
// Every ruta except 49, in ascending form order; 49 is appended last below.
|
||||
const keys = (Object.keys(VAT_RUTA_LABELS) as (keyof VatDeclarationRutor)[])
|
||||
@@ -56,7 +88,7 @@ export function buildManualFilingRows(rutor: VatDeclarationRutor): ManualFilingR
|
||||
|
||||
const rows: ManualFilingRow[] = []
|
||||
for (const key of keys) {
|
||||
const amount = kr(key)
|
||||
const amount = amounts[key]
|
||||
// Always surface ruta 48 (deductible input VAT); otherwise only populated
|
||||
// rutor so the reference stays short.
|
||||
if (key !== 'ruta48' && amount === 0) continue
|
||||
|
||||
Reference in New Issue
Block a user