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
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user