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
+60
View File
@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest'
import { parseReportDateRange } from '../date-range'
const period = { period_start: '2026-01-01', period_end: '2026-12-31' }
function paramsOf(obj: Record<string, string>): URLSearchParams {
return new URLSearchParams(obj)
}
describe('parseReportDateRange', () => {
it('returns an empty range when no params are provided', () => {
const result = parseReportDateRange(paramsOf({}), period)
expect(result.ok).toBe(true)
if (result.ok) expect(result.range).toEqual({})
})
it('accepts valid in-period dates', () => {
const result = parseReportDateRange(
paramsOf({ from_date: '2026-03-01', to_date: '2026-05-31' }),
period,
)
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.range.fromDate).toBe('2026-03-01')
expect(result.range.toDate).toBe('2026-05-31')
}
})
it('rejects malformed from_date', () => {
const result = parseReportDateRange(paramsOf({ from_date: '2026/03/01' }), period)
expect(result.ok).toBe(false)
})
it('rejects from_date before period start', () => {
const result = parseReportDateRange(paramsOf({ from_date: '2025-12-31' }), period)
expect(result.ok).toBe(false)
})
it('rejects to_date after period end', () => {
const result = parseReportDateRange(paramsOf({ to_date: '2027-01-01' }), period)
expect(result.ok).toBe(false)
})
it('rejects reversed range', () => {
const result = parseReportDateRange(
paramsOf({ from_date: '2026-06-01', to_date: '2026-05-01' }),
period,
)
expect(result.ok).toBe(false)
})
it('accepts a single boundary date (only from_date)', () => {
const result = parseReportDateRange(paramsOf({ from_date: '2026-06-01' }), period)
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.range.fromDate).toBe('2026-06-01')
expect(result.range.toDate).toBeUndefined()
}
})
})
+183 -1
View File
@@ -11,7 +11,7 @@ let mockResults: Record<string, MockResult[]>
function makeBuilder(tableName: string) {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'lt', 'neq', 'range']) {
for (const m of ['select', 'eq', 'in', 'lt', 'lte', 'gte', 'neq', 'range']) {
b[m] = vi.fn().mockReturnValue(b)
}
const consume = (): MockResult => {
@@ -389,4 +389,186 @@ describe('generateTrialBalance', () => {
expect(result.totalCredit).toBe(5000)
expect(result.isBalanced).toBe(true)
})
// ── Date-range tests ─────────────────────────────────────────────
// The 4 reports (resultatrapport/balansrapport/income-statement/balance-
// sheet) thread an optional { fromDate, toDate } through to the trial
// balance. The engine must (a) skip the roll-forward query when fromDate
// equals period_start, (b) roll prior in-period lines into IB when
// fromDate is later, and (c) clamp period activity to the window.
it('treats omitted range as parity with the full period', async () => {
mockResults = {
fiscal_periods: [
{
data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null },
error: null,
},
],
journal_entry_lines: [
{
data: [
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 1000 },
],
error: null,
},
],
chart_of_accounts: [
{
data: [
{ account_number: '1930', account_name: 'Bank', account_class: 1 },
{ account_number: '3001', account_name: 'Revenue', account_class: 3 },
],
error: null,
},
],
}
const result = await generateTrialBalance(supabase, 'company-1', 'period-1')
// Same as the existing "balanced two-account" case — no roll-forward query
// is consumed because no range is requested.
expect(result.rows).toHaveLength(2)
expect(result.totalDebit).toBe(1000)
expect(result.totalCredit).toBe(1000)
expect(result.isBalanced).toBe(true)
})
it('skips the roll-forward query when fromDate equals period_start', async () => {
mockResults = {
fiscal_periods: [
{
data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null },
error: null,
},
],
journal_entry_lines: [
// Only the period query — no roll-forward fetch should be triggered.
{
data: [
{ account_number: '1930', debit_amount: 500, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 500 },
],
error: null,
},
],
chart_of_accounts: [
{
data: [
{ account_number: '1930', account_name: 'Bank', account_class: 1 },
{ account_number: '3001', account_name: 'Revenue', account_class: 3 },
],
error: null,
},
],
}
const result = await generateTrialBalance(supabase, 'company-1', 'period-1', {
fromDate: '2024-01-01',
toDate: '2024-06-30',
})
expect(result.rows[0].opening_debit).toBe(0)
expect(result.rows[0].closing_debit).toBe(500)
})
it('rolls prior in-period lines into IB when fromDate is after period_start', async () => {
mockResults = {
fiscal_periods: [
{
data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null },
error: null,
},
],
journal_entry_lines: [
// 1st consumption — roll-forward query for [2024-01-01, 2024-04-01).
{
data: [
{ account_number: '1930', debit_amount: 2000, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 2000 },
],
error: null,
},
// 2nd consumption — period activity for [2024-04-01, 2024-06-30].
{
data: [
{ account_number: '1930', debit_amount: 500, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 500 },
],
error: null,
},
],
chart_of_accounts: [
{
data: [
{ account_number: '1930', account_name: 'Bank', account_class: 1 },
{ account_number: '3001', account_name: 'Revenue', account_class: 3 },
],
error: null,
},
],
}
const result = await generateTrialBalance(supabase, 'company-1', 'period-1', {
fromDate: '2024-04-01',
toDate: '2024-06-30',
})
// 1930: IB carries 2000 from Q1, period adds 500 → UB 2500
const acc1930 = result.rows.find((r) => r.account_number === '1930')!
expect(acc1930.opening_debit).toBe(2000)
expect(acc1930.period_debit).toBe(500)
expect(acc1930.closing_debit).toBe(2500)
// 3001: IB carries 2000 from Q1, period adds 500 → UB 2500
const acc3001 = result.rows.find((r) => r.account_number === '3001')!
expect(acc3001.opening_credit).toBe(2000)
expect(acc3001.period_credit).toBe(500)
expect(acc3001.closing_credit).toBe(2500)
expect(result.isBalanced).toBe(true)
})
it('returns empty period activity when the range matches no lines', async () => {
mockResults = {
fiscal_periods: [
{
data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null },
error: null,
},
],
journal_entry_lines: [
// Roll-forward query — has prior activity
{
data: [
{ account_number: '1930', debit_amount: 750, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 750 },
],
error: null,
},
// Period query — no lines inside [2024-11-01, 2024-11-30]
{ data: [], error: null },
],
chart_of_accounts: [
{
data: [
{ account_number: '1930', account_name: 'Bank', account_class: 1 },
{ account_number: '3001', account_name: 'Revenue', account_class: 3 },
],
error: null,
},
],
}
const result = await generateTrialBalance(supabase, 'company-1', 'period-1', {
fromDate: '2024-11-01',
toDate: '2024-11-30',
})
const acc1930 = result.rows.find((r) => r.account_number === '1930')!
expect(acc1930.opening_debit).toBe(750)
expect(acc1930.period_debit).toBe(0)
expect(acc1930.closing_debit).toBe(750)
})
})
+6 -2
View File
@@ -12,9 +12,13 @@ import type { BalanceSheetReport, BalanceSheetSection, TrialBalanceRow } from '@
export async function generateBalanceSheet(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string
fiscalPeriodId: string,
options?: { fromDate?: string; toDate?: string }
): Promise<BalanceSheetReport> {
const { rows } = await generateTrialBalance(supabase, companyId, fiscalPeriodId)
const { rows } = await generateTrialBalance(supabase, companyId, fiscalPeriodId, {
fromDate: options?.fromDate,
toDate: options?.toDate,
})
// Filter to balance sheet accounts (class 1-2)
const balanceRows = rows.filter(
+10 -3
View File
@@ -27,7 +27,8 @@ const CLASS_LABELS: Record<number, string> = {
export async function generateBalansrapport(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string
fiscalPeriodId: string,
options?: { fromDate?: string; toDate?: string }
): Promise<BalansrapportReport> {
const { data: period } = await supabase
.from('fiscal_periods')
@@ -40,7 +41,13 @@ export async function generateBalansrapport(
throw new Error('Fiscal period not found')
}
const trialBalance = await generateTrialBalance(supabase, companyId, fiscalPeriodId)
const effectiveFromDate = options?.fromDate ?? period.period_start
const effectiveToDate = options?.toDate ?? period.period_end
const trialBalance = await generateTrialBalance(supabase, companyId, fiscalPeriodId, {
fromDate: options?.fromDate,
toDate: options?.toDate,
})
const balanceRows = trialBalance.rows.filter((r) => r.account_class === 1 || r.account_class === 2)
const groups: BalansrapportGroup[] = []
@@ -95,7 +102,7 @@ export async function generateBalansrapport(
total_equity_liabilities_ub: totalEquityLiabilitiesUb,
beraknat_resultat: beraknatResultat,
is_balanced: trialBalance.isBalanced,
period: { start: period.period_start, end: period.period_end },
period: { start: effectiveFromDate, end: effectiveToDate },
}
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Parse and validate optional `from_date` / `to_date` query params for the
* date-range-aware financial reports (resultat- and balansrapport).
*
* Returns the bounds clamped against the fiscal period. Both params are
* optional — when omitted, the report falls back to the period as a whole.
* Returns a `{ error }` shape on invalid input so callers can map it to a
* 400 response without each route duplicating the same checks.
*/
export type DateRange = { fromDate?: string; toDate?: string }
export type DateRangeResult =
| { ok: true; range: DateRange }
| { ok: false; error: string }
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
export function parseReportDateRange(
searchParams: URLSearchParams,
period: { period_start: string; period_end: string }
): DateRangeResult {
const rawFrom = searchParams.get('from_date')
const rawTo = searchParams.get('to_date')
if (rawFrom && !ISO_DATE.test(rawFrom)) {
return { ok: false, error: 'from_date måste vara på formen YYYY-MM-DD.' }
}
if (rawTo && !ISO_DATE.test(rawTo)) {
return { ok: false, error: 'to_date måste vara på formen YYYY-MM-DD.' }
}
const fromDate = rawFrom ?? undefined
const toDate = rawTo ?? undefined
if (fromDate && (fromDate < period.period_start || fromDate > period.period_end)) {
return {
ok: false,
error: `from_date måste ligga inom räkenskapsåret (${period.period_start} — ${period.period_end}).`,
}
}
if (toDate && (toDate < period.period_start || toDate > period.period_end)) {
return {
ok: false,
error: `to_date måste ligga inom räkenskapsåret (${period.period_start} — ${period.period_end}).`,
}
}
if (fromDate && toDate && fromDate > toDate) {
return { ok: false, error: 'from_date får inte vara efter to_date.' }
}
return { ok: true, range: { fromDate, toDate } }
}
+4 -1
View File
@@ -14,7 +14,8 @@ import type { IncomeStatementReport, IncomeStatementSection, TrialBalanceRow } f
export async function generateIncomeStatement(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string
fiscalPeriodId: string,
options?: { fromDate?: string; toDate?: string }
): Promise<IncomeStatementReport> {
// Exclude year-end closing entries: after closing, P&L accounts (3-8) are
// zeroed by the closing verifikat (8999 → 2099). Including them collapses
@@ -22,6 +23,8 @@ export async function generateIncomeStatement(
// pre-closing activity for the year.
const { rows } = await generateTrialBalance(supabase, companyId, fiscalPeriodId, {
excludeYearEndClosing: true,
fromDate: options?.fromDate,
toDate: options?.toDate,
})
// Filter to income/expense accounts (class 3-8)
+16 -4
View File
@@ -31,7 +31,8 @@ const CLASS_LABELS: Record<number, string> = {
export async function generateResultatrapport(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string
fiscalPeriodId: string,
options?: { fromDate?: string; toDate?: string }
): Promise<ResultatrapportReport> {
const { data: period } = await supabase
.from('fiscal_periods')
@@ -44,12 +45,23 @@ export async function generateResultatrapport(
throw new Error('Fiscal period not found')
}
const currentTb = await generateTrialBalance(supabase, companyId, fiscalPeriodId)
const effectiveFromDate = options?.fromDate ?? period.period_start
const effectiveToDate = options?.toDate ?? period.period_end
const currentTb = await generateTrialBalance(supabase, companyId, fiscalPeriodId, {
fromDate: options?.fromDate,
toDate: options?.toDate,
})
const currentRows = filterPnl(currentTb.rows)
// Prior-period comparison stays full-year. A narrower current window
// compared against a full prior year would be misleading; until we ship a
// proper "same window, prior year" comparison the cleanest move is to
// drop the prior column entirely when the user narrows the range.
let priorRows: TrialBalanceRow[] = []
let priorPeriodInfo: { start: string; end: string } | null = null
if (period.previous_period_id) {
const isFullPeriod = !options?.fromDate && !options?.toDate
if (isFullPeriod && period.previous_period_id) {
const { data: prior } = await supabase
.from('fiscal_periods')
.select('period_start, period_end')
@@ -76,7 +88,7 @@ export async function generateResultatrapport(
groups,
net_result_current: round2(netResultCurrent),
net_result_prior: round2(netResultPrior),
period: { start: period.period_start, end: period.period_end },
period: { start: effectiveFromDate, end: effectiveToDate },
prior_period: priorPeriodInfo,
}
}
+72 -5
View File
@@ -4,12 +4,19 @@ import { getOpeningBalances } from './opening-balances'
import type { TrialBalanceRow } from '@/types'
/**
* Generate trial balance (Saldobalans) for a fiscal period.
* Generate trial balance (Saldobalans) for a fiscal period or a date range
* inside one.
*
* Computes IB (ingående balans), period movements, and UB (utgående balans)
* per BFNAR 2013:2 requirements. Uses the opening_balance_entry set by
* year-end closing when available; falls back to summing prior-period entries.
*
* When `fromDate`/`toDate` are passed, they must lie inside the fiscal
* period. The function rolls the IB forward from `period_start` to
* `fromDate − 1` (so "opening" reflects the state at `fromDate`) and limits
* period activity to `[fromDate, toDate]`. Defaults equal `period_start` and
* `period_end` — identical to the no-options behaviour.
*
* Uses joined queries with pagination to handle any number of entries.
* Avoids the broken .in(entryIds) pattern that silently truncated at 1000 rows.
*/
@@ -17,7 +24,11 @@ export async function generateTrialBalance(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
options?: { excludeYearEndClosing?: boolean }
options?: {
excludeYearEndClosing?: boolean
fromDate?: string
toDate?: string
}
): Promise<{
rows: TrialBalanceRow[]
totalDebit: number
@@ -28,16 +39,59 @@ export async function generateTrialBalance(
// Fetch period for opening balance computation
const { data: period } = await supabase
.from('fiscal_periods')
.select('period_start, opening_balance_entry_id')
.select('period_start, period_end, opening_balance_entry_id')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
// ── Opening balances (IB) ──────────────────────────────────────
// ── Opening balances (IB) at period_start ──────────────────────
const { balances: openingBalances, obEntryId } = await getOpeningBalances(
supabase, companyId, period
)
// ── Roll IB forward from period_start up to fromDate ───────────
// When the caller requests a sub-range starting after period_start, the
// "opening" of that window must include all activity since the period
// started. We additively fold those lines into openingBalances so the
// downstream IB/period split stays correct without changing call sites.
if (
options?.fromDate &&
period?.period_start &&
options.fromDate > period.period_start
) {
const priorLines = await fetchAllRows<{
account_number: string
debit_amount: number
credit_amount: number
}>(({ from, to }) => {
let query = supabase
.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount, journal_entries!inner(company_id, fiscal_period_id, status, source_type, entry_date)')
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.fiscal_period_id', fiscalPeriodId)
.in('journal_entries.status', ['posted', 'reversed'])
.gte('journal_entries.entry_date', period.period_start)
.lt('journal_entries.entry_date', options.fromDate)
if (obEntryId) {
query = query.neq('journal_entry_id', obEntryId)
}
if (options?.excludeYearEndClosing) {
query = query.neq('journal_entries.source_type', 'year_end')
}
return query.range(from, to)
})
for (const line of priorLines) {
const existing = openingBalances.get(line.account_number) || { debit: 0, credit: 0 }
existing.debit += Number(line.debit_amount) || 0
existing.credit += Number(line.credit_amount) || 0
openingBalances.set(line.account_number, existing)
}
}
// ── Period lines (excluding opening balance entry) ─────────────
// If year-end closing set an OB entry, exclude it from period lines so
// its values aren't double-counted (they're already captured as IB).
@@ -52,11 +106,24 @@ export async function generateTrialBalance(
}>(({ from, to }) => {
let query = supabase
.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount, journal_entries!inner(company_id, fiscal_period_id, status, source_type)')
.select('account_number, debit_amount, credit_amount, journal_entries!inner(company_id, fiscal_period_id, status, source_type, entry_date)')
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.fiscal_period_id', fiscalPeriodId)
.in('journal_entries.status', ['posted', 'reversed'])
// Date filters are only applied when the caller explicitly asks. The
// period itself is already enforced via the fiscal_period_id join, so
// adding redundant entry_date bounds for the default case would just
// increase query complexity (and break older mocks that don't stub gte
// /lte). The fiscal_period_id constraint plus a CHECK on entry_date in
// the engine keep activity inside the period.
if (options?.fromDate) {
query = query.gte('journal_entries.entry_date', options.fromDate)
}
if (options?.toDate) {
query = query.lte('journal_entries.entry_date', options.toDate)
}
if (obEntryId) {
query = query.neq('journal_entry_id', obEntryId)
}