a9b43ebeb7
* 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>
179 lines
6.0 KiB
TypeScript
179 lines
6.0 KiB
TypeScript
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 3–8 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 4–7) 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
|
||
}
|