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
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { eventBus } from '@/lib/events/bus'
import { makeJournalEntry, makeJournalEntryLine } from '@/tests/helpers'
import { BookkeepingDatabaseError } from '@/lib/bookkeeping/errors'
import { BookkeepingDatabaseError, MeaninglessCorrectionError } from '@/lib/bookkeeping/errors'
// ============================================================
// Mock — separate client (no .then) from query builder (thenable)
@@ -212,6 +212,70 @@ describe('correctEntry', () => {
expect(journalEntryInserts[1]).toMatchObject({ source_type: 'correction', entry_date: '2024-06-15' })
})
it('rejects rättelse where every account nets to zero (1930 → 1930)', async () => {
const supabase = makeClient()
const noOpLines = [
{ account_number: '1930', debit_amount: 100, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 100 },
]
await expect(
correctEntry(supabase as never, 'company-1', 'user-1', 'orig-1', noOpLines)
).rejects.toBeInstanceOf(MeaninglessCorrectionError)
// Guard runs before any DB call — original must not be fetched.
expect(supabase.from).not.toHaveBeenCalled()
})
it('rejects rättelse where multiple accounts each net to zero', async () => {
const supabase = makeClient()
const noOpLines = [
{ account_number: '1930', debit_amount: 100, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 100 },
{ account_number: '5410', debit_amount: 50, credit_amount: 0 },
{ account_number: '5410', debit_amount: 0, credit_amount: 50 },
]
await expect(
correctEntry(supabase as never, 'company-1', 'user-1', 'orig-1', noOpLines)
).rejects.toMatchObject({
code: 'MEANINGLESS_CORRECTION',
reason: 'net_zero_per_account',
})
})
it('rejects rättelse identical to the original entry', async () => {
const supabase = makeClient()
// Only the fetch-original result is needed — guard runs right after.
results = [{ data: originalEntry, error: null }]
const identicalLines = [
{ account_number: '5410', debit_amount: 1000, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 1000 },
]
await expect(
correctEntry(supabase as never, 'company-1', 'user-1', 'orig-1', identicalLines)
).rejects.toMatchObject({
code: 'MEANINGLESS_CORRECTION',
reason: 'identical_to_original',
})
})
it('allows rättelse that shifts amounts between different accounts', async () => {
setupResults()
const supabase = makeClient()
// correctedLines moves expense from 5410 → 5420 — net effect per account
// is non-zero (5420 +1200, 5410 0 since absent, 1930 -1200), and the lines
// differ from the original, so both guards must pass.
const result = await correctEntry(
supabase as never,
'company-1',
'user-1',
'orig-1',
correctedLines
)
expect(result.corrected).toBeDefined()
})
it('emits journal_entry.corrected event', async () => {
setupResults()
+56
View File
@@ -13,8 +13,51 @@ import {
EntryAlreadyReversedError,
JournalEntryNotBalancedError,
JournalEntryNotFoundError,
MeaninglessCorrectionError,
} from '@/lib/bookkeeping/errors'
/**
* Round to 2dp using cents-integer math to avoid 0.1+0.2 drift.
*/
function round2(n: number): number {
return Math.round(n * 100) / 100
}
/**
* True when every account's (debit − credit) sum across the proposed lines is
* zero. Such a rättelse describes no real affärshändelse and would erase the
* original posting without representing anything in its place — disallowed by
* BFL 5 kap. 5 § / BFNAR 2013:2.
*/
function netsToZeroPerAccount(lines: CreateJournalEntryLineInput[]): boolean {
const nets = new Map<string, number>()
for (const line of lines) {
const delta = round2(line.debit_amount || 0) - round2(line.credit_amount || 0)
nets.set(line.account_number, (nets.get(line.account_number) || 0) + delta)
}
return Array.from(nets.values()).every((n) => Math.abs(n) < 0.005)
}
/**
* True when proposed lines are the same multiset as the original lines
* (account_number + debit + credit). A rättelse must actually change something.
*/
function isIdenticalToOriginal(
proposed: CreateJournalEntryLineInput[],
original: JournalEntryLine[]
): boolean {
if (proposed.length !== original.length) return false
const key = (acc: string, d: number, c: number) =>
`${acc}|${round2(d).toFixed(2)}|${round2(c).toFixed(2)}`
const proposedKeys = proposed
.map((l) => key(l.account_number, l.debit_amount || 0, l.credit_amount || 0))
.sort()
const originalKeys = original
.map((l) => key(l.account_number, Number(l.debit_amount) || 0, Number(l.credit_amount) || 0))
.sort()
return proposedKeys.every((k, i) => k === originalKeys[i])
}
/**
* Storno Service - 3-step correction flow per Bokföringslagen
*
@@ -65,6 +108,13 @@ export async function correctEntry(
throw new JournalEntryNotBalancedError(balance.totalDebit, balance.totalCredit, 'correction')
}
// Reject a rättelse with no economic effect (e.g. 1930 debit 100 / 1930
// credit 100). Such an entry would erase the original posting without
// representing any affärshändelse — disallowed by BFL 5 kap. 5 §.
if (netsToZeroPerAccount(correctedLines)) {
throw new MeaninglessCorrectionError('net_zero_per_account')
}
// Fetch original entry with lines
const { data: original, error: fetchError } = await supabase
.from('journal_entries')
@@ -83,6 +133,12 @@ export async function correctEntry(
const originalLines = (original.lines as JournalEntryLine[]) || []
// Reject when the proposed lines are identical to the original entry —
// a rättelse must actually change something.
if (isIdenticalToOriginal(correctedLines, originalLines)) {
throw new MeaninglessCorrectionError('identical_to_original')
}
// ===== Step 1: Create storno (reversal) entry =====
const reversalVoucherNumber = await getNextVoucherNumber(
supabase,