Bug/vat selection warning (#583)
* refactor: update VAT handling logic for non-registered sellers and improve related comments * chore: gate automated email flows behind 503 responses Disables user-facing access to invoice payment reminders and salary payslip email sending. Underlying lib code (reminder-processor, PDF templates, notification_settings) is preserved for easy re-enable. - Invoice reminders cron route returns 503; settings UI section removed. - Payslip send route returns 503; original implementation kept as _sendPayslipsImpl for future re-enable. - Push notifications were already extension-disabled, no change needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: remove Recapt feedback widget Strips the third-party Recapt SDK and its floating feedback bubble from the app. The in-app contact form keeps working via the existing email channel (/api/support/contact). Drops the Recapt entries from the CSP and the subprocessor list in the privacy policy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: reject meaningless rättelser in correctEntry Guard against zero-economic-effect corrections in the storno engine: - Reject when proposed lines net to zero on every account (e.g. 1930 debit 100 / 1930 credit 100), which would erase the original posting without representing any affärshändelse (BFL 5 kap. 5 §). - Reject when proposed lines are an exact multiset match of the original entry — a rättelse must actually change something. New MeaninglessCorrectionError wired through bookkeepingErrorResponse (HTTP 400) and the Swedish error translator. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: add date-range picker to resultat- and balansrapport Adds optional from/to date filtering to the four operational financial reports (resultatrapport, balansrapport, income-statement, balance-sheet) so users can view a month, quarter, or custom range inside a fiscal year without leaving the report. Defaults to YTD; "Hela året" preserves the prior full-period behaviour (URL-identical, cache-stable). - trial-balance engine accepts optional fromDate/toDate, rolling prior in-period activity into IB and clamping period activity to the window - 12 API routes accept and validate from_date/to_date query params - ReportDateRange chip picker persists preset per company, only renders on the four relevant tabs - FiscalYearSelector now emits the period object so the range picker has bounds without an extra fetch - PDF/XLSX filenames reflect the chosen range - Resultatrapport drops the prior-year column when narrowed (full-year vs partial-year would mislead) - 11 new tests (engine + parser); all existing report tests pass Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: add support for marking journal entries as "no document required" - Introduced a new sidecar table `journal_entry_no_doc_required` to track entries that do not require separate documentation (e.g., bank fees, interest). - Implemented API routes for creating and deleting exemptions, including validation and authorization checks. - Added a toggle component in the UI to allow users to mark entries as exempt, with an optional reason. - Updated relevant tests to cover the new functionality, including RLS checks and cascading deletes. - Enhanced existing schemas and types to accommodate the new `vat_amount` field for supplier invoice items. * fix: address PR review findings on no-doc-required + VAT changes - pg-real cascade test wraps DELETE in gnubok.allow_delete='true' txn so the immutability trigger bypass fires (mirrors delete_last_voucher RPC). - Clamp supplier-invoice item vat_amount to <= line_total * vat_rate via Zod refinement (with 1-öre rounding tolerance) so the manual override can't inflate the 2641 debit beyond the statutory ceiling. - groupVatByRate falls back to line_total * rate when stored vat_amount is 0 with a positive rate, so legacy/import paths leaving the column at its NOT NULL DEFAULT 0 don't silently understate ruta 48. - ReportDateRange todayIso() and preset endpoints use local date components instead of toISOString() (UTC) — fixes the midnight-to-02:00 off-by-one that truncated a day from YTD / this-month / this-quarter for Swedish users. - NoDocRequiredToggle restores the previous reason on failed POST/DELETE so the rolled-back toggle state stays consistent with the rendered reason. - Document the company-scoped (not user-scoped) DELETE authorization policy on the no-document-required route. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
627109b5bd
commit
a9b43ebeb7
@@ -87,6 +87,13 @@ const {
|
||||
} = await import('../supplier-invoice-entries')
|
||||
|
||||
function makeItem(overrides: Partial<SupplierInvoiceItem> = {}): SupplierInvoiceItem {
|
||||
// Mirror the API: vat_amount derives from line_total × vat_rate unless the
|
||||
// test overrides it explicitly (manual-override cases). This keeps multi-
|
||||
// item and mixed-rate fixtures self-consistent with the engine, which now
|
||||
// reads stored vat_amount directly rather than recomputing from line_total.
|
||||
const lineTotal = overrides.line_total ?? 8000
|
||||
const vatRate = overrides.vat_rate ?? 0.25
|
||||
const vatAmount = overrides.vat_amount ?? Math.round(lineTotal * vatRate * 100) / 100
|
||||
return {
|
||||
id: 'si-item-1',
|
||||
supplier_invoice_id: 'si-1',
|
||||
@@ -94,12 +101,12 @@ function makeItem(overrides: Partial<SupplierInvoiceItem> = {}): SupplierInvoice
|
||||
description: 'Consulting services',
|
||||
quantity: 1,
|
||||
unit: 'st',
|
||||
unit_price: 8000,
|
||||
line_total: 8000,
|
||||
unit_price: lineTotal,
|
||||
line_total: lineTotal,
|
||||
account_number: '6200',
|
||||
vat_code: null,
|
||||
vat_rate: 0.25,
|
||||
vat_amount: 2000,
|
||||
vat_rate: vatRate,
|
||||
vat_amount: vatAmount,
|
||||
created_at: '2024-06-01T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
@@ -170,6 +177,128 @@ describe('createSupplierInvoiceRegistrationEntry', () => {
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('books manual VAT override (bilförmån 50%) instead of recomputing from rate', async () => {
|
||||
// Personbilsleasing: leverantören fakturerar 25% moms (2 500 kr), men
|
||||
// endast 50% (1 250 kr) är avdragsgill enligt ML 8 kap 16§. Användaren
|
||||
// anger 1 250 kr manuellt. Det resterande beloppet förblir på
|
||||
// kostnadskontot (10 000 + 1 250 ej avdragsgill moms = 11 250 brutto-
|
||||
// kostnad om användaren även justerar line_total; här testar vi enbart
|
||||
// att momsöverskridningen genererar rätt 2641-belopp).
|
||||
const invoice = makeSupplierInvoice({
|
||||
subtotal: 10000,
|
||||
vat_amount: 1250,
|
||||
total: 11250,
|
||||
})
|
||||
const items = [
|
||||
makeItem({
|
||||
line_total: 10000,
|
||||
account_number: '5615', // Leasing av personbilar
|
||||
vat_rate: 0.25,
|
||||
vat_amount: 1250, // manual override (50% av 2 500)
|
||||
}),
|
||||
]
|
||||
|
||||
await createSupplierInvoiceRegistrationEntry(
|
||||
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
|
||||
const debit5615 = findByAccount(input.lines, '5615')
|
||||
expect(debit5615[0].debit_amount).toBe(10000)
|
||||
|
||||
const debit2641 = findByAccount(input.lines, '2641')
|
||||
expect(debit2641).toHaveLength(1)
|
||||
// Avgörande: 1 250 (manual) — INTE 2 500 (10 000 × 0.25).
|
||||
expect(debit2641[0].debit_amount).toBe(1250)
|
||||
|
||||
const credit2440 = findByAccount(input.lines, '2440')
|
||||
expect(credit2440[0].credit_amount).toBe(11250)
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('recomputes 2641 from line_total × rate when stored vat_amount is 0 (legacy/import path)', async () => {
|
||||
// Schema default is vat_amount=0; SIE imports and demo seeders sometimes
|
||||
// leave it that way. Silently posting 0 to 2641 would understate ruta 48
|
||||
// in the momsdeklaration. The engine recovers by recomputing from the
|
||||
// base when the stored amount is missing.
|
||||
const invoice = makeSupplierInvoice({
|
||||
subtotal: 10000,
|
||||
vat_amount: 2500,
|
||||
total: 12500,
|
||||
})
|
||||
const items = [
|
||||
makeItem({ line_total: 10000, account_number: '5410', vat_rate: 0.25, vat_amount: 0 }),
|
||||
]
|
||||
|
||||
await createSupplierInvoiceRegistrationEntry(
|
||||
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
const debit2641 = findByAccount(input.lines, '2641')
|
||||
expect(debit2641).toHaveLength(1)
|
||||
expect(debit2641[0].debit_amount).toBe(2500)
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('aggregates manual VAT overrides per rate group on mixed-rate invoice', async () => {
|
||||
// Restaurangkvitto med två olika momsöverskridningar pga representation-
|
||||
// tak och egen avrundning. 25%-raden får manuell 100 kr, 12%-raden får
|
||||
// manuell 50 kr.
|
||||
const invoice = makeSupplierInvoice({
|
||||
subtotal: 1000,
|
||||
vat_amount: 150,
|
||||
total: 1150,
|
||||
})
|
||||
const items = [
|
||||
makeItem({ id: 'item-1', line_total: 400, account_number: '6071', vat_rate: 0.25, vat_amount: 100 }),
|
||||
makeItem({ id: 'item-2', line_total: 600, account_number: '6071', vat_rate: 0.12, vat_amount: 50 }),
|
||||
]
|
||||
|
||||
await createSupplierInvoiceRegistrationEntry(
|
||||
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
const vat2641 = findByAccount(input.lines, '2641')
|
||||
expect(vat2641).toHaveLength(2)
|
||||
expect(vat2641.find((l) => l.line_description?.includes('25%'))?.debit_amount).toBe(100)
|
||||
expect(vat2641.find((l) => l.line_description?.includes('12%'))?.debit_amount).toBe(50)
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('reverse charge ignores manual vat_amount and uses statutory base × rate', async () => {
|
||||
// RC: fiktiv moms beräknas alltid på basbeloppet med lagstadgad sats —
|
||||
// ett manuellt vat_amount på posten är meningslöst (köparen redovisar
|
||||
// själv) och får inte påverka 2645/2614.
|
||||
const invoice = makeSupplierInvoice({
|
||||
subtotal: 10000,
|
||||
vat_amount: 0,
|
||||
total: 10000,
|
||||
reverse_charge: true,
|
||||
})
|
||||
const items = [
|
||||
makeItem({
|
||||
line_total: 10000,
|
||||
account_number: '6540',
|
||||
vat_rate: 0.25,
|
||||
vat_amount: 999, // ska ignoreras
|
||||
}),
|
||||
]
|
||||
|
||||
await createSupplierInvoiceRegistrationEntry(
|
||||
null as never, 'company-1', 'user-1', invoice, items, 'eu_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
expect(findByAccount(input.lines, '2645')[0].debit_amount).toBe(2500)
|
||||
expect(findByAccount(input.lines, '2614')[0].credit_amount).toBe(2500)
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('creates domestic entry with zero VAT (no 2641 line)', async () => {
|
||||
const invoice = makeSupplierInvoice({
|
||||
subtotal: 5000,
|
||||
|
||||
@@ -15,6 +15,7 @@ export const ENTRY_ALREADY_REVERSED = 'ENTRY_ALREADY_REVERSED' as const
|
||||
export const CURRENCY_REVALUATION_ALREADY_EXISTS = 'CURRENCY_REVALUATION_ALREADY_EXISTS' as const
|
||||
export const INVALID_MAPPING_RESULT = 'INVALID_MAPPING_RESULT' as const
|
||||
export const BOOKKEEPING_DATABASE_ERROR = 'BOOKKEEPING_DATABASE_ERROR' as const
|
||||
export const MEANINGLESS_CORRECTION = 'MEANINGLESS_CORRECTION' as const
|
||||
|
||||
// ============================================================================
|
||||
// AccountsNotInChartError — kept for back-compat (many existing call sites)
|
||||
@@ -138,6 +139,20 @@ export class CurrencyRevaluationAlreadyExistsError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export type MeaninglessCorrectionReason = 'net_zero_per_account' | 'identical_to_original'
|
||||
|
||||
export class MeaninglessCorrectionError extends Error {
|
||||
readonly code = MEANINGLESS_CORRECTION
|
||||
constructor(public readonly reason: MeaninglessCorrectionReason) {
|
||||
super(
|
||||
reason === 'net_zero_per_account'
|
||||
? 'Correction lines net to zero on every account — no economic event represented (BFL 5 kap. 5 §).'
|
||||
: 'Correction lines are identical to the original entry — nothing to correct.'
|
||||
)
|
||||
this.name = 'MeaninglessCorrectionError'
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidMappingResultError extends Error {
|
||||
readonly code = INVALID_MAPPING_RESULT
|
||||
constructor(
|
||||
@@ -206,7 +221,8 @@ export function isBookkeepingError(err: unknown): boolean {
|
||||
err instanceof EntryAlreadyReversedError ||
|
||||
err instanceof CurrencyRevaluationAlreadyExistsError ||
|
||||
err instanceof InvalidMappingResultError ||
|
||||
err instanceof BookkeepingDatabaseError
|
||||
err instanceof BookkeepingDatabaseError ||
|
||||
err instanceof MeaninglessCorrectionError
|
||||
)
|
||||
}
|
||||
|
||||
@@ -356,6 +372,19 @@ export function bookkeepingErrorResponse(err: unknown): NextResponse | null {
|
||||
)
|
||||
}
|
||||
|
||||
if (err instanceof MeaninglessCorrectionError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
details: { reason: err.reason },
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (err instanceof BookkeepingDatabaseError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
@@ -125,12 +125,16 @@ export async function createSupplierInvoiceRegistrationEntry(
|
||||
// went to NON-basis accounts. Mixed invoices (4535 + 6540 at 25%) used to
|
||||
// skip basis lines entirely under a per-invoice flag, leaving ruta 30
|
||||
// larger than ruta 21 by the 6540 portion — the exact FK004 pattern.
|
||||
const vatByRate = groupVatByRate(items, invoice.currency, invoice.exchange_rate)
|
||||
//
|
||||
// Drive iteration off the basis (line_total per rate), not stored
|
||||
// vat_amount — fiktiv moms is always statutory base × rate. This keeps
|
||||
// RC immune to per-line manual VAT overrides (which only make sense for
|
||||
// domestic deductible-VAT adjustments).
|
||||
const baseByRate = groupBaseByRate(items, invoice.currency, invoice.exchange_rate)
|
||||
const nonBasisBaseByRate = groupNonBasisBaseByRate(items, invoice.currency, invoice.exchange_rate)
|
||||
const rcSupplierType = supplierType as 'eu_business' | 'non_eu_business' | 'swedish_business'
|
||||
for (const [rate, amount] of vatByRate) {
|
||||
if (rate > 0 && amount > 0) {
|
||||
const baseAmount = amount / rate
|
||||
for (const [rate, baseAmount] of baseByRate) {
|
||||
if (rate > 0 && baseAmount > 0) {
|
||||
const rcLines = generateReverseChargeLines(baseAmount, rate, isDomesticRC)
|
||||
lines.push(...rcLines)
|
||||
const nonBasisBase = nonBasisBaseByRate.get(rate) || 0
|
||||
@@ -335,12 +339,13 @@ export async function createSupplierInvoiceCashEntry(
|
||||
// but ruta 20-24 stay at 0, which Skatteverket rejects with felkod
|
||||
// FK004 ("silent netting prohibited"; ML 13 kap kräver båda sidor).
|
||||
// Per-rate bucketing: see registration entry above for the FK004 rationale.
|
||||
const vatByRate = groupVatByRate(items, invoice.currency, invoice.exchange_rate)
|
||||
// Drive iteration off the basis (line_total per rate) — fiktiv moms is
|
||||
// always statutory base × rate; manual vat_amount overrides don't apply.
|
||||
const baseByRate = groupBaseByRate(items, invoice.currency, invoice.exchange_rate)
|
||||
const nonBasisBaseByRate = groupNonBasisBaseByRate(items, invoice.currency, invoice.exchange_rate)
|
||||
const rcSupplierType = supplierType as 'eu_business' | 'non_eu_business' | 'swedish_business'
|
||||
for (const [rate, amount] of vatByRate) {
|
||||
if (rate > 0 && amount > 0) {
|
||||
const baseAmount = amount / rate
|
||||
for (const [rate, baseAmount] of baseByRate) {
|
||||
if (rate > 0 && baseAmount > 0) {
|
||||
const rcLines = generateReverseChargeLines(baseAmount, rate, isDomesticRC)
|
||||
lines.push(...rcLines)
|
||||
const nonBasisBase = nonBasisBaseByRate.get(rate) || 0
|
||||
@@ -523,16 +528,18 @@ export async function createSupplierCreditNoteEntry(
|
||||
if (isReverseCharge) {
|
||||
// Reverse the fiktiv moms per rate group (swap debit/credit from registration)
|
||||
// Input VAT account: 2647 for domestic RC, 2645 for EU/non-EU
|
||||
// Drive iteration off the basis — fiktiv moms is always statutory base × rate.
|
||||
const inputAccount = isDomesticRC ? '2647' : '2645'
|
||||
const vatByRate = groupVatByRate(items, creditNote.currency, creditNote.exchange_rate, true)
|
||||
const baseByRate = groupBaseByRate(items, creditNote.currency, creditNote.exchange_rate, true)
|
||||
const nonBasisBaseByRate = groupNonBasisBaseByRate(items, creditNote.currency, creditNote.exchange_rate, true)
|
||||
const rcSupplierType = supplierType as 'eu_business' | 'non_eu_business' | 'swedish_business'
|
||||
// Only reverse basbeloppsraderna for the portion the registration would
|
||||
// have emitted them — namely the non-basis-account base per rate. Items
|
||||
// booked directly to 44xx/45xx had no parallel basis lines in registration
|
||||
// and so are reversed only via the expense credit line above.
|
||||
for (const [rate, amount] of vatByRate) {
|
||||
if (rate > 0 && amount > 0) {
|
||||
for (const [rate, baseAmount] of baseByRate) {
|
||||
if (rate > 0 && baseAmount > 0) {
|
||||
const fiktivVat = Math.round(baseAmount * rate * 100) / 100
|
||||
// Determine the output account for this rate
|
||||
let outputAccount: string
|
||||
switch (rate) {
|
||||
@@ -543,12 +550,12 @@ export async function createSupplierCreditNoteEntry(
|
||||
creditLines.push({
|
||||
account_number: inputAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: amount,
|
||||
credit_amount: fiktivVat,
|
||||
line_description: `Omvänd fiktiv ingående moms ${Math.round(rate * 100)}% ${desc}`,
|
||||
})
|
||||
lines.push({
|
||||
account_number: outputAccount,
|
||||
debit_amount: amount,
|
||||
debit_amount: fiktivVat,
|
||||
credit_amount: 0,
|
||||
line_description: `Omvänd fiktiv utgående moms ${Math.round(rate * 100)}% ${desc}`,
|
||||
})
|
||||
@@ -613,8 +620,21 @@ export async function createSupplierCreditNoteEntry(
|
||||
}
|
||||
|
||||
/**
|
||||
* Group items by VAT rate and sum the VAT amount per rate.
|
||||
* Returns a Map<rate, totalVatAmount> for generating per-rate journal lines.
|
||||
* Group items by VAT rate and sum the stored VAT amount per rate.
|
||||
* Returns a Map<rate, totalVatAmount> in SEK for per-rate 2641 journal lines.
|
||||
*
|
||||
* Reads `item.vat_amount` directly — set by the API from the line's manual
|
||||
* override when present, else computed line_total × rate. This is the path
|
||||
* for partial-deductible cases (bilförmån 50%, representation 300 kr-tak),
|
||||
* foreign-currency rounding, and supplier POS rounding.
|
||||
*
|
||||
* Fallback to line_total × rate when vat_amount is null/0 but rate > 0 —
|
||||
* legacy import paths (SIE, CSV, demo seed) sometimes leave vat_amount at
|
||||
* the column DEFAULT of 0. Silently dropping input VAT to 2641 would
|
||||
* understate ruta 48 in the momsdeklaration.
|
||||
*
|
||||
* Reverse-charge fiktiv moms doesn't use this — see groupBaseByRate, which
|
||||
* derives the basis directly so fiktiv VAT is always base × statutory rate.
|
||||
*/
|
||||
function groupVatByRate(
|
||||
items: SupplierInvoiceItem[],
|
||||
@@ -625,14 +645,39 @@ function groupVatByRate(
|
||||
const vatByRate = new Map<number, number>()
|
||||
for (const item of items) {
|
||||
const rate = item.vat_rate ?? 0.25
|
||||
let itemSek = resolveSekAmount(item.line_total, null, currency, exchangeRate)
|
||||
if (useAbsoluteValues) itemSek = Math.abs(itemSek)
|
||||
const itemVat = Math.round(itemSek * rate * 100) / 100
|
||||
vatByRate.set(rate, (vatByRate.get(rate) || 0) + itemVat)
|
||||
const storedVat = item.vat_amount ?? 0
|
||||
const computedVat = rate > 0
|
||||
? Math.round((item.line_total ?? 0) * rate * 100) / 100
|
||||
: 0
|
||||
const sourceVat = storedVat > 0 ? storedVat : computedVat
|
||||
let vatSek = resolveSekAmount(sourceVat, null, currency, exchangeRate)
|
||||
if (useAbsoluteValues) vatSek = Math.abs(vatSek)
|
||||
vatByRate.set(rate, (vatByRate.get(rate) || 0) + vatSek)
|
||||
}
|
||||
return vatByRate
|
||||
}
|
||||
|
||||
/**
|
||||
* Group items by VAT rate and sum the base (line_total) per rate.
|
||||
* Used by reverse-charge paths to compute fiktiv moms from the basis,
|
||||
* decoupled from any manual VAT override on the items themselves.
|
||||
*/
|
||||
function groupBaseByRate(
|
||||
items: SupplierInvoiceItem[],
|
||||
currency: string,
|
||||
exchangeRate: number | null,
|
||||
useAbsoluteValues = false
|
||||
): Map<number, number> {
|
||||
const baseByRate = new Map<number, number>()
|
||||
for (const item of items) {
|
||||
const rate = item.vat_rate ?? 0.25
|
||||
let baseSek = resolveSekAmount(item.line_total, null, currency, exchangeRate)
|
||||
if (useAbsoluteValues) baseSek = Math.abs(baseSek)
|
||||
baseByRate.set(rate, (baseByRate.get(rate) || 0) + baseSek)
|
||||
}
|
||||
return baseByRate
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum, per VAT rate, the base (line_total in SEK) of items booked to
|
||||
* non-basis expense accounts. Items already booked to a 44xx/45xx basis
|
||||
|
||||
Reference in New Issue
Block a user