Files
accounted/lib/reports/resultatrapport.ts
T
Mattsson a9b43ebeb7 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>
2026-05-28 01:56:09 +02:00

179 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { SupabaseClient } from '@supabase/supabase-js'
import { generateTrialBalance } from './trial-balance'
import type {
ResultatrapportReport,
ResultatrapportRow,
ResultatrapportGroup,
TrialBalanceRow,
} from '@/types'
const CLASS_LABELS: Record<number, string> = {
3: '3 Rörelsens inkomster/intäkter',
4: '4 Material- och varukostnader',
5: '5 Övriga externa kostnader',
6: '6 Övriga externa kostnader',
7: '7 Personalkostnader',
8: '8 Finansiella poster och bokslutsdispositioner',
}
/**
* Resultatrapport — operational P&L report.
*
* Lists every account in classes 38 with current-period and prior-period
* values side by side. Unlike Resultaträkning (formal, ÅRL Bilaga 2), this
* keeps account numbers and is meant for ongoing reconciliation, not for
* årsbokslut/årsredovisning.
*
* Account 8999 is excluded — it's the year-end closing account that moves
* årets resultat into equity (2099). Including its balance would double-count
* the result. Same exclusion as generateIncomeStatement.
*/
export async function generateResultatrapport(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
options?: { fromDate?: string; toDate?: string }
): Promise<ResultatrapportReport> {
const { data: period } = await supabase
.from('fiscal_periods')
.select('period_start, period_end, previous_period_id')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
if (!period) {
throw new Error('Fiscal period not found')
}
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
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')
.eq('id', period.previous_period_id)
.eq('company_id', companyId)
.single()
if (prior) {
const priorTb = await generateTrialBalance(supabase, companyId, period.previous_period_id)
priorRows = filterPnl(priorTb.rows)
priorPeriodInfo = { start: prior.period_start, end: prior.period_end }
}
}
const priorByAccount = new Map<string, TrialBalanceRow>()
for (const r of priorRows) priorByAccount.set(r.account_number, r)
const groups = buildGroups(currentRows, priorByAccount)
const netResultCurrent = sumNet(currentRows)
const netResultPrior = sumNet(priorRows)
return {
groups,
net_result_current: round2(netResultCurrent),
net_result_prior: round2(netResultPrior),
period: { start: effectiveFromDate, end: effectiveToDate },
prior_period: priorPeriodInfo,
}
}
function filterPnl(rows: TrialBalanceRow[]): TrialBalanceRow[] {
return rows.filter(
(r) =>
r.account_class >= 3 &&
r.account_class <= 8 &&
r.account_number !== '8999'
)
}
/**
* Sign convention: revenue (class 3) has credit normal balance, expenses
* (class 47) have debit. We render every line as `credit - debit` so that
* revenue is positive, expenses are negative, and a positive net result
* means profit. This matches how Fortnox and Visma present a Resultatrapport.
*/
function signedAmount(row: TrialBalanceRow): number {
return row.closing_credit - row.closing_debit
}
function sumNet(rows: TrialBalanceRow[]): number {
return rows.reduce((sum, r) => sum + signedAmount(r), 0)
}
function buildGroups(
currentRows: TrialBalanceRow[],
priorByAccount: Map<string, TrialBalanceRow>
): ResultatrapportGroup[] {
const accountIndex = new Map<string, { name: string; class: number }>()
for (const r of currentRows) {
accountIndex.set(r.account_number, { name: r.account_name, class: r.account_class })
}
for (const r of priorByAccount.values()) {
if (!accountIndex.has(r.account_number)) {
accountIndex.set(r.account_number, { name: r.account_name, class: r.account_class })
}
}
const currentByAccount = new Map<string, TrialBalanceRow>()
for (const r of currentRows) currentByAccount.set(r.account_number, r)
const groups: ResultatrapportGroup[] = []
for (const klass of [3, 4, 5, 6, 7, 8] as const) {
const accountsInClass = [...accountIndex.entries()]
.filter(([, info]) => info.class === klass)
.map(([account_number, info]) => ({ account_number, name: info.name }))
.sort((a, b) => a.account_number.localeCompare(b.account_number))
const rows: ResultatrapportRow[] = []
let subtotalCurrent = 0
let subtotalPrior = 0
for (const { account_number, name } of accountsInClass) {
const cur = currentByAccount.get(account_number)
const pr = priorByAccount.get(account_number)
const currentAmount = cur ? signedAmount(cur) : 0
const priorAmount = pr ? signedAmount(pr) : 0
if (Math.abs(currentAmount) < 0.005 && Math.abs(priorAmount) < 0.005) continue
rows.push({
account_number,
account_name: name,
current_period: round2(currentAmount),
prior_period: round2(priorAmount),
})
subtotalCurrent += currentAmount
subtotalPrior += priorAmount
}
if (rows.length === 0) continue
groups.push({
class: klass,
class_label: CLASS_LABELS[klass],
rows,
subtotal_current: round2(subtotalCurrent),
subtotal_prior: round2(subtotalPrior),
})
}
return groups
}
function round2(n: number): number {
return Math.round(n * 100) / 100
}