Files
accounted/app/api/reports/income-statement/pdf/route.ts
T
MattssonandClaude Opus 4.7 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

225 lines
8.3 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 { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { renderToBuffer } from '@react-pdf/renderer'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { FinancialStatementPDF, type FinancialStatementGroup, type FinancialStatementSection, type FinancialStatementSummaryRow } from '@/lib/reports/financial-statement-pdf-template'
import { requireCompanyId } from '@/lib/company/context'
import { parseReportDateRange } from '@/lib/reports/date-range'
import type { CompanySettings } from '@/types'
// K2/K3 uppställningsform (ÅRL bilaga 2, kostnadsslagsindelad) splits class 8
// into three named blocks with subtotals:
// 80–84 → Finansiella poster (followed by "Resultat efter finansiella poster")
// 88 → Bokslutsdispositioner
// 89 → Skatt på årets resultat
// The generator lumps these together under financial_sections, so we split
// here by the first row's account prefix.
const FINANSIELLA_POSTER_PREFIXES = ['80', '81', '82', '83', '84']
const BOKSLUTSDISPOSITIONER_PREFIXES = ['88']
const SKATT_PREFIXES = ['89']
const KNOWN_CLASS_8_PREFIXES = [
...FINANSIELLA_POSTER_PREFIXES,
...BOKSLUTSDISPOSITIONER_PREFIXES,
...SKATT_PREFIXES,
]
function sectionPrefix(section: FinancialStatementSection, prefixes: string[]): boolean {
if (section.rows.length === 0) return false
const acc = section.rows[0].account_number
return prefixes.some((p) => acc.startsWith(p))
}
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
if (!periodId) {
return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
}
const [{ data: period }, { data: companyRow }] = await Promise.all([
supabase
.from('fiscal_periods')
.select('period_start, period_end')
.eq('id', periodId)
.eq('company_id', companyId)
.single(),
supabase
.from('company_settings')
.select('*')
.eq('company_id', companyId)
.single(),
])
if (!companyRow) {
return NextResponse.json({ error: 'Företagsinställningar saknas' }, { status: 404 })
}
// An identifiable period is part of räkenskapsinformation (BFL 7 kap). Refuse
// to render a PDF that can't be archived with the period it refers to.
if (!period) {
return NextResponse.json(
{ error: 'Räkenskapsperioden kunde inte läsas. Välj en befintlig period innan du genererar PDF.' },
{ status: 400 }
)
}
const parsedRange = parseReportDateRange(searchParams, period)
if (!parsedRange.ok) {
return NextResponse.json({ error: parsedRange.error }, { status: 400 })
}
const range = parsedRange.range
const effectiveStart = range.fromDate ?? period.period_start
const effectiveEnd = range.toDate ?? period.period_end
try {
const report = await generateIncomeStatement(supabase, companyId, periodId, range)
report.period = { start: effectiveStart, end: effectiveEnd }
const operatingResult = Math.round((report.total_revenue - report.total_expenses) * 100) / 100
// Split class 8 into its three K2/K3 blocks plus a catch-all for any
// prefix the generator emits but we haven't explicitly mapped. If a future
// generator change adds sections for 85/86/87 or similar, this keeps them
// visible and arithmetically accounted for rather than silently dropped.
const finansiellaPosterSections = report.financial_sections.filter((s) =>
sectionPrefix(s, FINANSIELLA_POSTER_PREFIXES),
)
const bokslutsdispositionerSections = report.financial_sections.filter((s) =>
sectionPrefix(s, BOKSLUTSDISPOSITIONER_PREFIXES),
)
const skattSections = report.financial_sections.filter((s) =>
sectionPrefix(s, SKATT_PREFIXES),
)
const ovrigaFinansiellaPosterSections = report.financial_sections.filter(
(s) => !sectionPrefix(s, KNOWN_CLASS_8_PREFIXES),
)
const totalFinansiellaPoster = Math.round(
finansiellaPosterSections.reduce((sum, s) => sum + s.subtotal, 0) * 100,
) / 100
const totalBokslutsdispositioner = Math.round(
bokslutsdispositionerSections.reduce((sum, s) => sum + s.subtotal, 0) * 100,
) / 100
const totalSkatt = Math.round(
skattSections.reduce((sum, s) => sum + s.subtotal, 0) * 100,
) / 100
const totalOvrigaFinansiellaPoster = Math.round(
ovrigaFinansiellaPosterSections.reduce((sum, s) => sum + s.subtotal, 0) * 100,
) / 100
// Catch-all is treated as part of "finansiella poster" for the subtotal —
// 85-87 accounts in BAS are financial-adjacent (not tax, not bokslut).
const resultatEfterFinansiellaPoster = Math.round(
(operatingResult + totalFinansiellaPoster + totalOvrigaFinansiellaPoster) * 100,
) / 100
const groups: FinancialStatementGroup[] = [
{
heading: 'Rörelseintäkter',
sections: report.revenue_sections,
totalLabel: 'Summa rörelseintäkter',
total: report.total_revenue,
},
{
heading: 'Rörelsekostnader',
sections: report.expense_sections,
totalLabel: 'Summa rörelsekostnader',
total: report.total_expenses,
negate: true,
},
]
if (finansiellaPosterSections.length > 0) {
groups.push({
heading: 'Finansiella poster',
sections: finansiellaPosterSections,
totalLabel: 'Summa finansiella poster',
total: totalFinansiellaPoster,
})
}
if (ovrigaFinansiellaPosterSections.length > 0) {
groups.push({
heading: 'Övriga finansiella poster',
sections: ovrigaFinansiellaPosterSections,
totalLabel: 'Summa övriga finansiella poster',
total: totalOvrigaFinansiellaPoster,
})
}
if (bokslutsdispositionerSections.length > 0) {
groups.push({
heading: 'Bokslutsdispositioner',
sections: bokslutsdispositionerSections,
totalLabel: 'Summa bokslutsdispositioner',
total: totalBokslutsdispositioner,
})
}
if (skattSections.length > 0) {
groups.push({
heading: 'Skatter',
sections: skattSections,
totalLabel: 'Summa skatter',
total: totalSkatt,
})
}
// K2/K3 uppställningsform (ÅRL bilaga 2) summary structure:
// Rörelseresultat
// Resultat efter finansiella poster (only if finansiella poster present)
// Bokslutsdispositioner (only if present)
// Skatt på årets resultat (always, so the reader can verify the tax calc)
// Årets resultat
const summary: FinancialStatementSummaryRow[] = [
{ label: 'Rörelseresultat', amount: operatingResult },
]
if (
finansiellaPosterSections.length > 0 ||
ovrigaFinansiellaPosterSections.length > 0
) {
summary.push({
label: 'Resultat efter finansiella poster',
amount: resultatEfterFinansiellaPoster,
})
}
if (bokslutsdispositionerSections.length > 0) {
summary.push({ label: 'Bokslutsdispositioner', amount: totalBokslutsdispositioner })
}
summary.push({ label: 'Skatt på årets resultat', amount: totalSkatt })
summary.push({ label: 'Årets resultat', amount: report.net_result, emphasis: true })
const pdfBuffer = await renderToBuffer(
FinancialStatementPDF({
title: 'Resultaträkning',
groups,
summary,
period: report.period,
company: companyRow as CompanySettings,
generatedAt: new Date().toISOString(),
})
)
// "-utkast" suffix keeps the draft status visible even after the file
// leaves the browser — complements the in-document ÅRL 2:7 disclaimer.
const filename = `resultatrakning-${report.period.start}--${report.period.end}-utkast.pdf`
return new Response(new Uint8Array(pdfBuffer), {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`,
},
})
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Kunde inte generera resultaträkning' },
{ status: 500 }
)
}
}