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:
Mattsson
2026-05-28 01:56:09 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 627109b5bd
commit a9b43ebeb7
73 changed files with 2552 additions and 714 deletions
@@ -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,
+30 -1
View File
@@ -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(
{
+64 -19
View File
@@ -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