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
+24 -7
View File
@@ -261,11 +261,10 @@ export default function NewInvoicePage() {
)
}
// When customer forces a single rate (reverse charge/export), or the
// seller isn't VAT-registered, update all lines so the picker can't
// leave stale 25% values behind.
// When the customer forces a single rate (reverse charge/export),
// update all lines so the picker can't leave stale 25% values behind.
if (customer) {
const rates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated, vatRegistered)
const rates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated)
if (rates.length === 1) {
const forcedRate = rates[0].rate
watchItems.forEach((_, i) => {
@@ -274,7 +273,7 @@ export default function NewInvoicePage() {
}
}
}
}, [watchCustomerId, customers, setValue, vatRegistered])
}, [watchCustomerId, customers, setValue])
async function fetchCustomers() {
if (!company?.id) return
@@ -331,13 +330,19 @@ export default function NewInvoicePage() {
}, 0)
const vatRules = selectedCustomer
? getVatRules(selectedCustomer.customer_type, selectedCustomer.vat_number_validated, vatRegistered)
? getVatRules(selectedCustomer.customer_type, selectedCustomer.vat_number_validated)
: null
const availableRates = selectedCustomer
? getAvailableVatRates(selectedCustomer.customer_type, selectedCustomer.vat_number_validated, vatRegistered)
? getAvailableVatRates(selectedCustomer.customer_type, selectedCustomer.vat_number_validated)
: []
const isRateLocked = availableRates.length === 1
// Show a warning when a non-registered seller has picked any non-zero VAT
// rate. ML 16 kap. 23 § (faktureringsmoms): stated VAT is owed to
// Skatteverket regardless of registration, but the buyer cannot deduct it
// as input VAT — so we surface the consequence rather than block the input.
const hasNonZeroVat = watchItems.some((item) => (item?.vat_rate ?? 0) > 0)
const showNotRegisteredVatWarning = !vatRegistered && hasNonZeroVat
// Calculate per-item VAT
const vatByRate = new Map<number, { base: number; vat: number }>()
@@ -670,6 +675,18 @@ export default function NewInvoicePage() {
<CardDescription>{t('items_card_description')}</CardDescription>
</CardHeader>
<CardContent>
{showNotRegisteredVatWarning && (
<div className="mb-4 flex items-start gap-3 rounded-lg border border-border bg-secondary/60 px-4 py-3 text-sm">
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5 text-muted-foreground" />
<p className="text-muted-foreground">
Du är inte momsregistrerad. Om du ändå tar ut moms är du
enligt ML 16 kap. 23 § skyldig att betala in den till
Skatteverket, men din kund får inte dra av den som ingående
moms. Om du har börjat bedriva momspliktig verksamhet bör
du först registrera dig för moms.
</p>
</div>
)}
<div className="space-y-4">
{fields.map((field, index) => {
const lineTotal = (watchItems[index]?.quantity || 0) * (watchItems[index]?.unit_price || 0)
-8
View File
@@ -4,7 +4,6 @@ import { headers } from 'next/headers'
import DashboardNav from '@/components/dashboard/DashboardNav'
import { MainContainer } from '@/components/dashboard/MainContainer'
import CompanyTabSync from '@/components/dashboard/CompanyTabSync'
import { RecaptIdentify } from '@/components/RecaptIdentify'
import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
import { getExtensionNavItems } from '@/lib/extensions/sectors'
import { CompanyProvider } from '@/contexts/CompanyContext'
@@ -225,13 +224,6 @@ export default async function DashboardLayout({
<main id="main-content" className="safe-area-main-padding md:!pb-0 md:pl-64" role="main">
<MainContainer companyId={companyId}>{children}</MainContainer>
</main>
{!isSandbox && (
<RecaptIdentify
userId={user.id}
email={user.email}
displayName={settings?.company_name || undefined}
/>
)}
</div>
</CompanyProvider>
)
+15 -3
View File
@@ -76,6 +76,7 @@ export default async function DashboardPage() {
{ count: staleUncategorizedCount },
{ count: uncategorizedCount },
{ count: skatteverketTokenCount },
{ data: noDocRequiredEntries },
] = await Promise.all([
supabase.from('company_settings').select('*').eq('company_id', companyId).single(),
supabase.from('customers').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
@@ -105,6 +106,7 @@ export default async function DashboardPage() {
// carry the active company_id; either filter would work — we use user_id
// because that's what the token-store reads/writes against.
supabase.from('skatteverket_tokens').select('*', { count: 'exact', head: true }).eq('user_id', user.id),
supabase.from('journal_entry_no_doc_required').select('journal_entry_id').eq('company_id', companyId),
])
// If onboarding is not complete, redirect to onboarding
@@ -208,11 +210,21 @@ export default async function DashboardPage() {
),
}))
const uniqueEntriesWithDocs = new Set(
const entriesWithDocsSet = new Set(
(entriesWithDocs || []).map((d) => d.journal_entry_id)
).size
)
const missingUnderlagCount = Math.max(0, (postedEntriesCount || 0) - uniqueEntriesWithDocs)
// Exempted entries that *also* have a doc are already excluded by entriesWithDocsSet,
// so subtracting only the exempt-without-doc set avoids double-counting.
let exemptedWithoutDoc = 0
for (const row of (noDocRequiredEntries || []) as { journal_entry_id: string }[]) {
if (!entriesWithDocsSet.has(row.journal_entry_id)) exemptedWithoutDoc++
}
const missingUnderlagCount = Math.max(
0,
(postedEntriesCount || 0) - entriesWithDocsSet.size - exemptedWithoutDoc
)
let streakCount = 0
if (recentReceiptActivity && recentReceiptActivity.length > 0) {
+70 -26
View File
@@ -15,6 +15,7 @@ import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { AccountNumber } from '@/components/ui/account-number'
import { useCompany } from '@/contexts/CompanyContext'
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
import { ReportDateRange, type DateRangeValue } from '@/components/common/ReportDateRange'
import { ReportsNav } from '@/components/reports/ReportsNav'
import { NEDeclarationView } from '@/components/reports/NEDeclarationView'
import { PeriodiskSammanstallningView } from '@/components/reports/PeriodiskSammanstallningView'
@@ -44,6 +45,18 @@ function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
/**
* Build a report API query string with the period and optional date range.
* Omitting from/to lets the API fall back to full-period behaviour, which
* keeps URLs (and the matching caches) identical for the "Hela året" preset.
*/
function reportQuery(periodId: string, range?: DateRangeValue): string {
const params = new URLSearchParams({ period_id: periodId })
if (range?.fromDate) params.set('from_date', range.fromDate)
if (range?.toDate) params.set('to_date', range.toDate)
return params.toString()
}
// Breadcrumb trail for drill-down navigation
interface DrillDownStep {
tab: string
@@ -60,9 +73,18 @@ const TAB_LABEL_KEYS: Record<string, string> = {
'huvudbok': 'name_huvudbok',
}
const DATE_RANGE_TABS = new Set([
'resultatrapport',
'balansrapport',
'income-statement',
'balance-sheet',
])
export default function ReportsPage() {
const router = useRouter()
const [selectedPeriod, setSelectedPeriod] = useState('')
const [selectedPeriodBounds, setSelectedPeriodBounds] = useState<{ start: string; end: string } | null>(null)
const [dateRange, setDateRange] = useState<DateRangeValue>({})
const [activeTab, setActiveTab] = useState('resultatrapport')
const [isLoadingInit, setIsLoadingInit] = useState(true)
const { company } = useCompany()
@@ -123,14 +145,30 @@ export default function ReportsPage() {
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">{t('title')}</h1>
</div>
<div className="flex flex-col sm:flex-row sm:items-end gap-4">
<div className="flex flex-col sm:flex-row sm:items-end gap-6">
<FiscalYearSelector
value={selectedPeriod || null}
onChange={(id) => setSelectedPeriod(id || '')}
onChange={(id, period) => {
setSelectedPeriod(id || '')
setSelectedPeriodBounds(
period ? { start: period.period_start, end: period.period_end } : null,
)
// Reset the range so the new period's stored preset re-resolves
// against the new bounds (avoids stale dates from the prior year).
setDateRange({})
}}
includeAllOption={false}
hideFuturePeriods
onReady={() => setIsLoadingInit(false)}
/>
{DATE_RANGE_TABS.has(activeTab) && selectedPeriodBounds && (
<ReportDateRange
periodStart={selectedPeriodBounds.start}
periodEnd={selectedPeriodBounds.end}
value={dateRange}
onChange={setDateRange}
/>
)}
</div>
<p className="text-sm text-muted-foreground">
{t('sie_moved_hint')}{' '}
@@ -194,19 +232,19 @@ export default function ReportsPage() {
/>
<div className="flex-1 min-w-0">
{activeTab === 'resultatrapport' && (
<ResultatrapportView periodId={selectedPeriod} onNavigateToAccount={navigateToAccount} />
<ResultatrapportView periodId={selectedPeriod} dateRange={dateRange} onNavigateToAccount={navigateToAccount} />
)}
{activeTab === 'balansrapport' && (
<BalansrapportView periodId={selectedPeriod} onNavigateToAccount={navigateToAccount} />
<BalansrapportView periodId={selectedPeriod} dateRange={dateRange} onNavigateToAccount={navigateToAccount} />
)}
{activeTab === 'trial-balance' && (
<TrialBalanceView periodId={selectedPeriod} onNavigateToAccount={navigateToAccount} />
)}
{activeTab === 'income-statement' && (
<IncomeStatementView periodId={selectedPeriod} onNavigateToAccount={navigateToAccount} />
<IncomeStatementView periodId={selectedPeriod} dateRange={dateRange} onNavigateToAccount={navigateToAccount} />
)}
{activeTab === 'balance-sheet' && (
<BalanceSheetView periodId={selectedPeriod} onNavigateToAccount={navigateToAccount} />
<BalanceSheetView periodId={selectedPeriod} dateRange={dateRange} onNavigateToAccount={navigateToAccount} />
)}
{activeTab === 'vat-declaration' && <VatDeclarationView />}
{activeTab === 'periodisk-sammanstallning' && <PeriodiskSammanstallningView />}
@@ -567,20 +605,21 @@ function TrialBalanceDetailedRow({
</>
)
}
function IncomeStatementView({ periodId, onNavigateToAccount }: { periodId: string; onNavigateToAccount: (account: string) => void }) {
function IncomeStatementView({ periodId, dateRange, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; onNavigateToAccount: (account: string) => void }) {
const t = useTranslations('reports')
const [data, setData] = useState<IncomeStatementReport | null>(null)
const [monthlyData, setMonthlyData] = useState<MonthlyDataPoint[]>([])
const [monthlyLoading, setMonthlyLoading] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const reportQs = reportQuery(periodId, dateRange)
useEffect(() => {
setLoading(true)
setError(null)
setMonthlyLoading(true)
fetch(`/api/reports/income-statement?period_id=${periodId}`)
fetch(`/api/reports/income-statement?${reportQs}`)
.then((res) => res.json())
.then((result) => {
if (result.error) {
@@ -595,6 +634,8 @@ function IncomeStatementView({ periodId, onNavigateToAccount }: { periodId: stri
setLoading(false)
})
// Monthly breakdown is full-period by design (it IS the per-month view),
// so the date range only affects the headline numbers above the chart.
fetch(`/api/reports/monthly-breakdown?period_id=${periodId}`)
.then((res) => res.json())
.then((result) => {
@@ -606,7 +647,7 @@ function IncomeStatementView({ periodId, onNavigateToAccount }: { periodId: stri
.catch(() => {
setMonthlyLoading(false)
})
}, [periodId])
}, [periodId, reportQs])
if (loading) {
return (
@@ -645,7 +686,7 @@ function IncomeStatementView({ periodId, onNavigateToAccount }: { periodId: stri
<Button
variant="outline"
size="sm"
onClick={() => window.open(`/api/reports/income-statement/pdf?period_id=${periodId}`, '_blank')}
onClick={() => window.open(`/api/reports/income-statement/pdf?${reportQs}`, '_blank')}
>
<Download className="h-4 w-4 mr-2" />
{t('download_pdf')}
@@ -653,7 +694,7 @@ function IncomeStatementView({ periodId, onNavigateToAccount }: { periodId: stri
<Button
variant="outline"
size="sm"
onClick={() => window.open(`/api/reports/income-statement/xlsx?period_id=${periodId}`, '_blank')}
onClick={() => window.open(`/api/reports/income-statement/xlsx?${reportQs}`, '_blank')}
>
<FileSpreadsheet className="h-4 w-4 mr-2" />
Ladda ner Excel
@@ -735,16 +776,17 @@ function IncomeStatementView({ periodId, onNavigateToAccount }: { periodId: stri
)
}
function BalanceSheetView({ periodId, onNavigateToAccount }: { periodId: string; onNavigateToAccount: (account: string) => void }) {
function BalanceSheetView({ periodId, dateRange, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; onNavigateToAccount: (account: string) => void }) {
const t = useTranslations('reports')
const [data, setData] = useState<BalanceSheetReport | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const reportQs = reportQuery(periodId, dateRange)
useEffect(() => {
setLoading(true)
setError(null)
fetch(`/api/reports/balance-sheet?period_id=${periodId}`)
fetch(`/api/reports/balance-sheet?${reportQs}`)
.then((res) => res.json())
.then((result) => {
if (result.error) {
@@ -758,7 +800,7 @@ function BalanceSheetView({ periodId, onNavigateToAccount }: { periodId: string;
setError('Kunde inte hämta balansräkning')
setLoading(false)
})
}, [periodId])
}, [periodId, reportQs])
if (loading) {
return (
@@ -799,7 +841,7 @@ function BalanceSheetView({ periodId, onNavigateToAccount }: { periodId: string;
<Button
variant="outline"
size="sm"
onClick={() => window.open(`/api/reports/balance-sheet/pdf?period_id=${periodId}`, '_blank')}
onClick={() => window.open(`/api/reports/balance-sheet/pdf?${reportQs}`, '_blank')}
>
<Download className="h-4 w-4 mr-2" />
{t('download_pdf')}
@@ -807,7 +849,7 @@ function BalanceSheetView({ periodId, onNavigateToAccount }: { periodId: string;
<Button
variant="outline"
size="sm"
onClick={() => window.open(`/api/reports/balance-sheet/xlsx?period_id=${periodId}`, '_blank')}
onClick={() => window.open(`/api/reports/balance-sheet/xlsx?${reportQs}`, '_blank')}
>
<FileSpreadsheet className="h-4 w-4 mr-2" />
Ladda ner Excel
@@ -868,16 +910,17 @@ function BalanceSheetView({ periodId, onNavigateToAccount }: { periodId: string;
)
}
function ResultatrapportView({ periodId, onNavigateToAccount }: { periodId: string; onNavigateToAccount: (account: string) => void }) {
function ResultatrapportView({ periodId, dateRange, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; onNavigateToAccount: (account: string) => void }) {
const t = useTranslations('reports')
const [data, setData] = useState<ResultatrapportReport | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const reportQs = reportQuery(periodId, dateRange)
useEffect(() => {
setLoading(true)
setError(null)
fetch(`/api/reports/resultatrapport?period_id=${periodId}`)
fetch(`/api/reports/resultatrapport?${reportQs}`)
.then((res) => res.json())
.then((result) => {
if (result.error) {
@@ -891,7 +934,7 @@ function ResultatrapportView({ periodId, onNavigateToAccount }: { periodId: stri
setError('Kunde inte hämta resultatrapport')
setLoading(false)
})
}, [periodId])
}, [periodId, reportQs])
if (loading) {
return (
@@ -933,7 +976,7 @@ function ResultatrapportView({ periodId, onNavigateToAccount }: { periodId: stri
<Button
variant="outline"
size="sm"
onClick={() => window.open(`/api/reports/resultatrapport/pdf?period_id=${periodId}`, '_blank')}
onClick={() => window.open(`/api/reports/resultatrapport/pdf?${reportQs}`, '_blank')}
>
<Download className="h-4 w-4 mr-2" />
{t('download_pdf')}
@@ -941,7 +984,7 @@ function ResultatrapportView({ periodId, onNavigateToAccount }: { periodId: stri
<Button
variant="outline"
size="sm"
onClick={() => window.open(`/api/reports/resultatrapport/xlsx?period_id=${periodId}`, '_blank')}
onClick={() => window.open(`/api/reports/resultatrapport/xlsx?${reportQs}`, '_blank')}
>
<FileSpreadsheet className="h-4 w-4 mr-2" />
Ladda ner Excel
@@ -1018,16 +1061,17 @@ function ResultatrapportView({ periodId, onNavigateToAccount }: { periodId: stri
)
}
function BalansrapportView({ periodId, onNavigateToAccount }: { periodId: string; onNavigateToAccount: (account: string) => void }) {
function BalansrapportView({ periodId, dateRange, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; onNavigateToAccount: (account: string) => void }) {
const t = useTranslations('reports')
const [data, setData] = useState<BalansrapportReport | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const reportQs = reportQuery(periodId, dateRange)
useEffect(() => {
setLoading(true)
setError(null)
fetch(`/api/reports/balansrapport?period_id=${periodId}`)
fetch(`/api/reports/balansrapport?${reportQs}`)
.then((res) => res.json())
.then((result) => {
if (result.error) {
@@ -1041,7 +1085,7 @@ function BalansrapportView({ periodId, onNavigateToAccount }: { periodId: string
setError('Kunde inte hämta balansrapport')
setLoading(false)
})
}, [periodId])
}, [periodId, reportQs])
if (loading) {
return (
@@ -1080,7 +1124,7 @@ function BalansrapportView({ periodId, onNavigateToAccount }: { periodId: string
<Button
variant="outline"
size="sm"
onClick={() => window.open(`/api/reports/balansrapport/pdf?period_id=${periodId}`, '_blank')}
onClick={() => window.open(`/api/reports/balansrapport/pdf?${reportQs}`, '_blank')}
>
<Download className="h-4 w-4 mr-2" />
{t('download_pdf')}
@@ -1088,7 +1132,7 @@ function BalansrapportView({ periodId, onNavigateToAccount }: { periodId: string
<Button
variant="outline"
size="sm"
onClick={() => window.open(`/api/reports/balansrapport/xlsx?period_id=${periodId}`, '_blank')}
onClick={() => window.open(`/api/reports/balansrapport/xlsx?${reportQs}`, '_blank')}
>
<FileSpreadsheet className="h-4 w-4 mr-2" />
Ladda ner Excel
@@ -13,7 +13,6 @@ import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings
import { AccountDangerZone } from '@/components/settings/AccountDangerZone'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { useSettings } from '@/components/settings/useSettings'
import { clearRecaptIdentity } from '@/lib/recapt'
import { useToast } from '@/components/ui/use-toast'
import { SUPPORTED_LOCALES, type Locale } from '@/i18n/config'
@@ -33,7 +32,6 @@ export default function AccountSettingsPage() {
useEffect(() => { setMounted(true) }, [])
async function handleLogout() {
clearRecaptIdentity()
await supabase.auth.signOut()
router.push('/login')
}
+101 -28
View File
@@ -10,6 +10,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { Checkbox } from '@/components/ui/checkbox'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
@@ -90,6 +91,97 @@ function vatRateFromAi(rate: number | null | undefined): number {
return 0
}
function rateToPctString(rate: number): string {
const pct = Math.round(rate * 10000) / 100
return Number.isFinite(pct) ? String(pct) : ''
}
const VAT_RATE_PRESETS = [0.25, 0.12, 0.06, 0]
function VatRateCell({ value, onChange }: { value: number; onChange: (v: number) => void }) {
const t = useTranslations('supplier_invoice_editor')
const inputRef = useRef<HTMLInputElement>(null)
// Local draft so the user can type "12," or "12." mid-keystroke without the
// controlled input snapping back to a parsed integer.
const [draft, setDraft] = useState(() => rateToPctString(value))
// Re-sync from form value only when the field isn't focused — keeps AI
// prefill / supplier defaults / dropdown picks flowing in without clobbering
// active typing.
useEffect(() => {
if (document.activeElement !== inputRef.current) {
setDraft(rateToPctString(value))
}
}, [value])
return (
<div className="flex items-center gap-1">
<div className="relative flex-1">
<Input
ref={inputRef}
type="text"
inputMode="decimal"
value={draft}
onFocus={(e) => e.currentTarget.select()}
onBlur={() => setDraft(rateToPctString(value))}
onChange={(e) => {
const raw = e.target.value
// Strict whitelist: digits with at most one decimal separator.
// Blocks "2-22", "100-2", "1.2.3", letters, signs — the keystroke
// is dropped before reaching the draft.
if (raw !== '' && !/^\d*[.,]?\d*$/.test(raw)) return
const normalized = raw.replace(',', '.')
if (normalized === '' || normalized === '.') {
setDraft(raw)
onChange(0)
return
}
const parsed = parseFloat(normalized)
if (!Number.isFinite(parsed)) {
setDraft(raw)
return
}
const clamped = Math.min(100, Math.max(0, parsed))
// Snap the draft back when the parsed value falls outside [0, 100]
// so the input can never display a rate the form won't apply.
setDraft(clamped === parsed ? raw : String(clamped))
onChange(clamped / 100)
}}
className="text-right tabular-nums pr-6"
aria-label={t('col_vat_rate')}
/>
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground pointer-events-none">
%
</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
aria-label={t('vat_rate_presets_aria')}
>
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[6rem]">
{VAT_RATE_PRESETS.map((preset) => (
<DropdownMenuItem
key={preset}
onSelect={() => onChange(preset)}
className="justify-end tabular-nums"
>
{Math.round(preset * 100)} %
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
const EMPTY_NEW_SUPPLIER: NewSupplierForm = {
name: '',
supplier_type: 'swedish_business',
@@ -407,7 +499,6 @@ export default function NewSupplierInvoicePage() {
}
}
// Calculate totals
const itemTotals = (watchedItems || []).map((item) => {
const lineTotal = Math.round((item.amount || 0) * 100) / 100
const vatAmount = Math.round(lineTotal * (item.vat_rate || 0) * 100) / 100
@@ -1098,7 +1189,7 @@ export default function NewSupplierInvoicePage() {
<th className="pb-2 w-28">{t('col_account')}</th>
<th className="pb-2">{t('col_description')}</th>
<th className="pb-2 w-32">{t('col_amount_excl')}</th>
<th className="pb-2 w-24">{t('col_vat_rate')}</th>
<th className="pb-2 w-36">{t('col_vat_rate')}</th>
<th className="pb-2 w-24 text-right">{t('col_vat')}</th>
<th className="pb-2 w-8"></th>
</tr>
@@ -1156,22 +1247,12 @@ export default function NewSupplierInvoicePage() {
name={`items.${index}.vat_rate`}
control={control}
render={({ field: f }) => (
<Select value={String(f.value)} onValueChange={(v) => f.onChange(parseFloat(v))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0.25">25%</SelectItem>
<SelectItem value="0.12">12%</SelectItem>
<SelectItem value="0.06">6%</SelectItem>
<SelectItem value="0">0%</SelectItem>
</SelectContent>
</Select>
<VatRateCell value={f.value} onChange={f.onChange} />
)}
/>
</td>
<td className="py-2 pr-2 text-right font-mono pt-4">
{formatAmount(itemTotals[index]?.vatAmount || 0)}
<td className="py-2 pr-2 text-right tabular-nums text-muted-foreground">
{formatAmount(itemTotals[index]?.vatAmount ?? 0)}
</td>
<td className="py-2 pt-3">
{fields.length > 1 && (
@@ -1249,24 +1330,16 @@ export default function NewSupplierInvoicePage() {
name={`items.${index}.vat_rate`}
control={control}
render={({ field: f }) => (
<Select value={String(f.value)} onValueChange={(v) => f.onChange(parseFloat(v))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0.25">25%</SelectItem>
<SelectItem value="0.12">12%</SelectItem>
<SelectItem value="0.06">6%</SelectItem>
<SelectItem value="0">0%</SelectItem>
</SelectContent>
</Select>
<VatRateCell value={f.value} onChange={f.onChange} />
)}
/>
</div>
</div>
<div className="flex justify-between items-center pt-1 border-t">
<div className="pt-1 border-t flex items-center justify-between">
<span className="text-xs text-muted-foreground">{t('col_vat')}</span>
<span className="font-mono text-sm">{formatCurrency(itemTotals[index]?.vatAmount || 0, watchedCurrency)}</span>
<span className="tabular-nums text-muted-foreground">
{formatAmount(itemTotals[index]?.vatAmount ?? 0)}
</span>
</div>
</div>
))}
-6
View File
@@ -124,12 +124,6 @@ export default function PrivacyPolicyPage() {
<td className="py-2 pr-4">USA</td>
<td className="py-2">SCCs (standardavtalsklausuler)</td>
</tr>
<tr className="border-b">
<td className="py-2 pr-4 font-medium">Recapt</td>
<td className="py-2 pr-4">Produktanalys och användarfeedback</td>
<td className="py-2 pr-4">EU</td>
<td className="py-2">EU-baserad</td>
</tr>
</tbody>
</table>
</div>
@@ -0,0 +1,157 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createMockRouteParams,
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const mockRequireWrite = vi.fn().mockResolvedValue({ ok: true })
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => mockRequireWrite(...args),
}))
import { POST, DELETE } from '../route'
const mockUser = { id: 'user-1', email: 'test@test.se' }
describe('POST /api/bookkeeping/journal-entries/[id]/no-document-required', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
mockRequireWrite.mockResolvedValue({ ok: true })
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const request = createMockRequest(
'/api/bookkeeping/journal-entries/entry-1/no-document-required',
{ method: 'POST', body: { reason: 'Bankavgift' } }
)
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns 403 when the user only has read access', async () => {
mockRequireWrite.mockResolvedValue({
ok: false,
response: new Response(JSON.stringify({ error: 'forbidden' }), {
status: 403,
headers: { 'content-type': 'application/json' },
}),
})
const request = createMockRequest(
'/api/bookkeeping/journal-entries/entry-1/no-document-required',
{ method: 'POST', body: {} }
)
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
expect(response.status).toBe(403)
})
it('returns 404 when the entry does not belong to the active company', async () => {
enqueue({ data: null, error: null }) // journal_entries lookup misses
const request = createMockRequest(
'/api/bookkeeping/journal-entries/entry-1/no-document-required',
{ method: 'POST', body: { reason: 'Bankavgift' } }
)
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(404)
expect(body).toEqual({ error: 'Verifikationen hittades inte.' })
})
it('rejects a reason longer than 200 chars (Zod validation)', async () => {
const longReason = 'a'.repeat(201)
const request = createMockRequest(
'/api/bookkeeping/journal-entries/entry-1/no-document-required',
{ method: 'POST', body: { reason: longReason } }
)
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
expect(response.status).toBe(400)
})
it('upserts the exemption and returns exempted: true', async () => {
enqueue({ data: { id: 'entry-1' }, error: null }) // entry lookup
enqueue({ data: null, error: null }) // upsert
const request = createMockRequest(
'/api/bookkeeping/journal-entries/entry-1/no-document-required',
{ method: 'POST', body: { reason: 'Bankavgift' } }
)
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(body).toEqual({ data: { exempted: true } })
})
it('accepts an empty body (no reason)', async () => {
enqueue({ data: { id: 'entry-1' }, error: null })
enqueue({ data: null, error: null })
const request = createMockRequest(
'/api/bookkeeping/journal-entries/entry-1/no-document-required',
{ method: 'POST', body: {} }
)
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(body).toEqual({ data: { exempted: true } })
})
})
describe('DELETE /api/bookkeeping/journal-entries/[id]/no-document-required', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
mockRequireWrite.mockResolvedValue({ ok: true })
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const request = createMockRequest(
'/api/bookkeeping/journal-entries/entry-1/no-document-required',
{ method: 'DELETE' }
)
const response = await DELETE(request, createMockRouteParams({ id: 'entry-1' }))
expect(response.status).toBe(401)
})
it('returns exempted: false on successful delete', async () => {
enqueue({ data: null, error: null }) // delete
const request = createMockRequest(
'/api/bookkeeping/journal-entries/entry-1/no-document-required',
{ method: 'DELETE' }
)
const response = await DELETE(request, createMockRouteParams({ id: 'entry-1' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(body).toEqual({ data: { exempted: false } })
})
})
@@ -0,0 +1,95 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { z } from 'zod'
import { validateBody } from '@/lib/api/validate'
const SetNoDocSchema = z.object({
reason: z.string().trim().max(200).nullable().optional(),
})
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const result = await validateBody(request, SetNoDocSchema)
if (!result.success) return result.response
const { data: entry, error: entryError } = await supabase
.from('journal_entries')
.select('id')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle()
if (entryError || !entry) {
return NextResponse.json({ error: 'Verifikationen hittades inte.' }, { status: 404 })
}
const { error } = await supabase
.from('journal_entry_no_doc_required')
.upsert(
{
journal_entry_id: id,
company_id: companyId,
user_id: user.id,
reason: result.data.reason ?? null,
},
{ onConflict: 'journal_entry_id' }
)
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
return NextResponse.json({ data: { exempted: true } })
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
// Authorization is company-scoped, not user-scoped: any non-viewer member
// of the active company may revoke any exemption in that company. The flag
// is a shared bookkeeping artefact (same model as booking_template_library,
// mapping_rules, etc.) — exemptions are reviewed as a team. The audit_log
// trigger captures the DELETE with actor_id so accountability is preserved.
const { error } = await supabase
.from('journal_entry_no_doc_required')
.delete()
.eq('journal_entry_id', id)
.eq('company_id', companyId)
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
return NextResponse.json({ data: { exempted: false } })
}
@@ -0,0 +1,32 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
/**
* Returns the set of journal_entry IDs in the active company that the user has
* flagged as "no underlag required". The client uses this set to:
* - exclude exempted entries from the "Saknade underlag" filter
* - show a muted "no doc needed" indicator instead of the warning triangle
*/
export async function GET() {
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 rows = await fetchAllRows<{ journal_entry_id: string; reason: string | null }>(
({ from, to }) =>
supabase
.from('journal_entry_no_doc_required')
.select('journal_entry_id, reason')
.eq('company_id', companyId)
.range(from, to)
)
return NextResponse.json({ data: rows })
}
-7
View File
@@ -188,9 +188,6 @@ describe('POST /api/invoices (create invoice)', () => {
// Fetch customer
enqueue({ data: customer, error: null })
// Fetch company_settings.vat_registered — feeds the helpers so the
// allowed-rates set is correct for non-VAT-registered sellers.
enqueue({ data: { vat_registered: true }, error: null })
// Insert invoice (number is null on insert; allocated immediately after items)
enqueue({ data: createdInvoice, error: null })
// Insert items
@@ -241,8 +238,6 @@ describe('POST /api/invoices (create invoice)', () => {
])
enqueue({ data: customer, error: null })
// Fetch company_settings.vat_registered
enqueue({ data: { vat_registered: true }, error: null })
enqueue({ data: createdInvoice, error: null })
// Items insertion fails
enqueue({ data: null, error: { message: 'Items insert failed' } })
@@ -285,8 +280,6 @@ describe('POST /api/invoices (create invoice)', () => {
])
enqueue({ data: customer, error: null })
// Fetch company_settings.vat_registered
enqueue({ data: { vat_registered: true }, error: null })
enqueue({ data: createdInvoice, error: null })
// Items insertion succeeds
enqueue({ data: null, error: null })
+6 -9
View File
@@ -98,14 +98,11 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Företagsinställningar saknas' }, { status: 404 })
}
// Match the creation API: a non-VAT-registered seller may not charge VAT
// (ML 1 kap. 1§). Coerce instead of rejecting so the preview always renders
// — the form may still be carrying a stale 25% selection while the user
// hasn't yet noticed the rate picker locked itself. Default `true` mirrors
// the API and getVatRules' own default — a NULL column must not silently
// strip VAT from a preview the seller is about to send.
const vatRegistered = (company as CompanySettings).vat_registered ?? true
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated, vatRegistered)
// VAT rules are customer-type-driven; the seller's registration status no
// longer constrains the preview. A non-momsregistrerad seller who chose a
// non-zero rate sees the rate they picked rendered — the form surfaces the
// ML 16 kap. 23 § warning at submit time.
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
const docType: InvoiceDocumentType = document_type || 'invoice'
const isDeliveryNote = docType === 'delivery_note'
@@ -113,7 +110,7 @@ export async function POST(request: Request) {
// Build items with line totals and per-item VAT
const invoiceItems: InvoiceItem[] = items.map((item: { description: string; quantity: number; unit: string; unit_price: number; vat_rate?: number }, index: number) => {
const lineTotal = Math.round(item.quantity * item.unit_price * 100) / 100
const rate = vatRegistered ? (item.vat_rate ?? vatRules.rate) : 0
const rate = item.vat_rate ?? vatRules.rate
return {
id: `preview-${index}`,
invoice_id: 'preview',
+2 -35
View File
@@ -1,42 +1,9 @@
import { NextResponse } from 'next/server'
import { processOverdueReminders } from '@/lib/invoices/reminder-processor'
import { getEmailService } from '@/lib/email/service'
import { withCronContext } from '@/lib/api/with-cron-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
/**
* GET/POST /api/invoices/reminders/cron — daily 08:00 UTC.
* Sends overdue invoice reminders. POST exists so the dashboard can
* trigger a run manually.
*/
export const GET = withCronContext('cron.invoice_reminders', async (_request, ctx) => {
if (!getEmailService().isConfigured()) {
ctx.log.error('email service not configured; skipping reminder run')
return errorResponseFromCode('INVOICE_SEND_EMAIL_NOT_CONFIGURED', ctx.log, {
requestId: ctx.requestId,
})
}
const result = await processOverdueReminders()
ctx.log.info('reminder cron summary', {
processed: result.processed,
sent: result.sent,
failed: result.failed,
})
return NextResponse.json({
success: true,
processed: result.processed,
sent: result.sent,
failed: result.failed,
results: result.results.map((r) => ({
invoiceNumber: r.invoiceNumber,
reminderLevel: r.reminderLevel,
success: r.success,
error: r.error,
})),
})
ctx.log.info('invoice reminders feature is disabled; skipping run')
return NextResponse.json({ disabled: true }, { status: 503 })
})
export const POST = GET
+2 -16
View File
@@ -119,22 +119,8 @@ export const POST = withRouteContext(
})
}
// A non-VAT-registered seller may not charge output VAT (ML 1 kap. 1§).
// We pull vat_registered from company_settings and feed it into the rule
// helpers so the allowed-rates set collapses to {0} and any non-zero rate
// submitted from the client fails INVOICE_CREATE_VAT_RULE_VIOLATION below.
// Default to true when the settings row is absent or the column is NULL
// — matches the prior implicit behavior (getVatRules' own default) so a
// missing settings row never silently blocks legitimate VAT invoices.
const { data: companySettings } = await supabase
.from('company_settings')
.select('vat_registered')
.eq('company_id', companyId!)
.single()
const vatRegistered = companySettings?.vat_registered ?? true
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated, vatRegistered)
const availableRates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated, vatRegistered)
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
const availableRates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated)
const allowedRates = new Set(availableRates.map((r) => r.rate))
const subtotal = invoiceInput.items.reduce((sum, item) => sum + item.quantity * item.unit_price, 0)
+12 -3
View File
@@ -4,6 +4,7 @@ import { renderToBuffer } from '@react-pdf/renderer'
import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
import { FinancialStatementPDF } 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'
export async function GET(request: Request) {
@@ -49,9 +50,17 @@ export async function GET(request: Request) {
)
}
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 generateBalanceSheet(supabase, companyId, periodId)
report.period = { start: period.period_start, end: period.period_end }
const report = await generateBalanceSheet(supabase, companyId, periodId, range)
report.period = { start: effectiveStart, end: effectiveEnd }
const totalAssets = report.total_assets
const totalEquityLiab = report.total_equity_liabilities
@@ -98,7 +107,7 @@ export async function GET(request: Request) {
// "-utkast" suffix keeps the draft status visible even after the file
// leaves the browser — complements the in-document ÅRL 2:7 disclaimer.
const filename = `balansrakning-${report.period.start}-utkast.pdf`
const filename = `balansrakning-${report.period.start}--${report.period.end}-utkast.pdf`
return new Response(new Uint8Array(pdfBuffer), {
headers: {
+13 -3
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { parseReportDateRange } from '@/lib/reports/date-range'
export const GET = withRouteContext(
'report.balance_sheet',
@@ -24,13 +25,22 @@ export const GET = withRouteContext(
.eq('company_id', companyId)
.single()
let range: { fromDate?: string; toDate?: string } = {}
if (period) {
const parsed = parseReportDateRange(searchParams, period)
if (!parsed.ok) {
return NextResponse.json({ error: parsed.error }, { status: 400 })
}
range = parsed.range
}
try {
const result = await generateBalanceSheet(supabase, companyId!, periodId)
const result = await generateBalanceSheet(supabase, companyId!, periodId, range)
if (period) {
result.period = {
start: period.period_start,
end: period.period_end,
start: range.fromDate ?? period.period_start,
end: range.toDate ?? period.period_end,
}
}
+10 -2
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
import { requireCompanyId } from '@/lib/company/context'
import { parseReportDateRange } from '@/lib/reports/date-range'
import {
reportToWorkbook,
textColumn,
@@ -52,8 +53,15 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Räkenskapsperioden kunde inte läsas.' }, { status: 400 })
}
const parsedRange = parseReportDateRange(searchParams, period)
if (!parsedRange.ok) {
return NextResponse.json({ error: parsedRange.error }, { status: 400 })
}
const range = parsedRange.range
const effectiveEnd = range.toDate ?? period.period_end
try {
const report = await generateBalanceSheet(supabase, companyId, periodId)
const report = await generateBalanceSheet(supabase, companyId, periodId, range)
// Flatten nested sections into a single tabular view, mirroring how the
// PDF lays them out: each section's rows followed by a subtotal line, with
@@ -137,7 +145,7 @@ export async function GET(request: Request) {
},
])
const filename = xlsxFilename('balansrakning', companyRow?.company_name ?? '', period.period_end)
const filename = xlsxFilename('balansrakning', companyRow?.company_name ?? '', effectiveEnd)
return new NextResponse(new Uint8Array(buffer), {
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+8 -2
View File
@@ -4,6 +4,7 @@ import { renderToBuffer } from '@react-pdf/renderer'
import { generateBalansrapport } from '@/lib/reports/balansrapport'
import { BalansrapportPDF } from '@/lib/reports/operational-report-pdf-template'
import { requireCompanyId } from '@/lib/company/context'
import { parseReportDateRange } from '@/lib/reports/date-range'
import type { CompanySettings } from '@/types'
export async function GET(request: Request) {
@@ -47,8 +48,13 @@ export async function GET(request: Request) {
)
}
const parsedRange = parseReportDateRange(searchParams, period)
if (!parsedRange.ok) {
return NextResponse.json({ error: parsedRange.error }, { status: 400 })
}
try {
const report = await generateBalansrapport(supabase, companyId, periodId)
const report = await generateBalansrapport(supabase, companyId, periodId, parsedRange.range)
const pdfBuffer = await renderToBuffer(
BalansrapportPDF({
@@ -62,7 +68,7 @@ export async function GET(request: Request) {
// report PDF route in this repo (resultatrapport, balance-sheet,
// income-statement). A balansrapport is a snapshot at period end, but
// consistent filenames let users sort and script-rename predictably.
const filename = `balansrapport-${report.period.start}.pdf`
const filename = `balansrapport-${report.period.start}--${report.period.end}.pdf`
return new Response(new Uint8Array(pdfBuffer), {
headers: {
+18 -1
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateBalansrapport } from '@/lib/reports/balansrapport'
import { requireCompanyId } from '@/lib/company/context'
import { parseReportDateRange } from '@/lib/reports/date-range'
export async function GET(request: Request) {
const supabase = await createClient()
@@ -20,8 +21,24 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
}
const { data: period } = await supabase
.from('fiscal_periods')
.select('period_start, period_end')
.eq('id', periodId)
.eq('company_id', companyId)
.single()
let range: { fromDate?: string; toDate?: string } = {}
if (period) {
const parsed = parseReportDateRange(searchParams, period)
if (!parsed.ok) {
return NextResponse.json({ error: parsed.error }, { status: 400 })
}
range = parsed.range
}
try {
const result = await generateBalansrapport(supabase, companyId, periodId)
const result = await generateBalansrapport(supabase, companyId, periodId, range)
return NextResponse.json({ data: result })
} catch (err) {
return NextResponse.json(
+24 -6
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateBalansrapport } from '@/lib/reports/balansrapport'
import { requireCompanyId } from '@/lib/company/context'
import { parseReportDateRange } from '@/lib/reports/date-range'
import {
reportToWorkbook,
textColumn,
@@ -35,14 +36,31 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
}
const { data: companyRow } = await supabase
.from('company_settings')
.select('company_name')
.eq('company_id', companyId)
.single()
const [{ data: companyRow }, { data: period }] = await Promise.all([
supabase
.from('company_settings')
.select('company_name')
.eq('company_id', companyId)
.single(),
supabase
.from('fiscal_periods')
.select('period_start, period_end')
.eq('id', periodId)
.eq('company_id', companyId)
.single(),
])
let range: { fromDate?: string; toDate?: string } = {}
if (period) {
const parsed = parseReportDateRange(searchParams, period)
if (!parsed.ok) {
return NextResponse.json({ error: parsed.error }, { status: 400 })
}
range = parsed.range
}
try {
const report = await generateBalansrapport(supabase, companyId, periodId)
const report = await generateBalansrapport(supabase, companyId, periodId, range)
const rows: FlatRow[] = []
for (const g of report.groups) {
+12 -3
View File
@@ -4,6 +4,7 @@ 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
@@ -71,9 +72,17 @@ export async function GET(request: Request) {
)
}
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)
report.period = { start: period.period_start, end: period.period_end }
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
@@ -198,7 +207,7 @@ export async function GET(request: Request) {
// "-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}-utkast.pdf`
const filename = `resultatrakning-${report.period.start}--${report.period.end}-utkast.pdf`
return new Response(new Uint8Array(pdfBuffer), {
headers: {
+13 -3
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { parseReportDateRange } from '@/lib/reports/date-range'
export const GET = withRouteContext(
'report.income_statement',
@@ -24,13 +25,22 @@ export const GET = withRouteContext(
.eq('company_id', companyId)
.single()
let range: { fromDate?: string; toDate?: string } = {}
if (period) {
const parsed = parseReportDateRange(searchParams, period)
if (!parsed.ok) {
return NextResponse.json({ error: parsed.error }, { status: 400 })
}
range = parsed.range
}
try {
const result = await generateIncomeStatement(supabase, companyId!, periodId)
const result = await generateIncomeStatement(supabase, companyId!, periodId, range)
if (period) {
result.period = {
start: period.period_start,
end: period.period_end,
start: range.fromDate ?? period.period_start,
end: range.toDate ?? period.period_end,
}
}
+10 -2
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { requireCompanyId } from '@/lib/company/context'
import { parseReportDateRange } from '@/lib/reports/date-range'
import {
reportToWorkbook,
textColumn,
@@ -84,8 +85,15 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Räkenskapsperioden kunde inte läsas.' }, { status: 400 })
}
const parsedRange = parseReportDateRange(searchParams, period)
if (!parsedRange.ok) {
return NextResponse.json({ error: parsedRange.error }, { status: 400 })
}
const range = parsedRange.range
const effectiveEnd = range.toDate ?? period.period_end
try {
const report = await generateIncomeStatement(supabase, companyId, periodId)
const report = await generateIncomeStatement(supabase, companyId, periodId, range)
const revenueRows = flatten(
report.revenue_sections,
@@ -139,7 +147,7 @@ export async function GET(request: Request) {
const filename = xlsxFilename(
'resultatrakning',
companyRow?.company_name ?? '',
period.period_end,
effectiveEnd,
)
return new NextResponse(new Uint8Array(buffer), {
headers: {
+8 -2
View File
@@ -4,6 +4,7 @@ import { renderToBuffer } from '@react-pdf/renderer'
import { generateResultatrapport } from '@/lib/reports/resultatrapport'
import { ResultatrapportPDF } from '@/lib/reports/operational-report-pdf-template'
import { requireCompanyId } from '@/lib/company/context'
import { parseReportDateRange } from '@/lib/reports/date-range'
import type { CompanySettings } from '@/types'
export async function GET(request: Request) {
@@ -49,8 +50,13 @@ export async function GET(request: Request) {
)
}
const parsedRange = parseReportDateRange(searchParams, period)
if (!parsedRange.ok) {
return NextResponse.json({ error: parsedRange.error }, { status: 400 })
}
try {
const report = await generateResultatrapport(supabase, companyId, periodId)
const report = await generateResultatrapport(supabase, companyId, periodId, parsedRange.range)
const pdfBuffer = await renderToBuffer(
ResultatrapportPDF({
@@ -60,7 +66,7 @@ export async function GET(request: Request) {
})
)
const filename = `resultatrapport-${report.period.start}.pdf`
const filename = `resultatrapport-${report.period.start}--${report.period.end}.pdf`
return new Response(new Uint8Array(pdfBuffer), {
headers: {
+18 -1
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateResultatrapport } from '@/lib/reports/resultatrapport'
import { requireCompanyId } from '@/lib/company/context'
import { parseReportDateRange } from '@/lib/reports/date-range'
export async function GET(request: Request) {
const supabase = await createClient()
@@ -20,8 +21,24 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
}
const { data: period } = await supabase
.from('fiscal_periods')
.select('period_start, period_end')
.eq('id', periodId)
.eq('company_id', companyId)
.single()
let range: { fromDate?: string; toDate?: string } = {}
if (period) {
const parsed = parseReportDateRange(searchParams, period)
if (!parsed.ok) {
return NextResponse.json({ error: parsed.error }, { status: 400 })
}
range = parsed.range
}
try {
const result = await generateResultatrapport(supabase, companyId, periodId)
const result = await generateResultatrapport(supabase, companyId, periodId, range)
return NextResponse.json({ data: result })
} catch (err) {
return NextResponse.json(
+24 -6
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateResultatrapport } from '@/lib/reports/resultatrapport'
import { requireCompanyId } from '@/lib/company/context'
import { parseReportDateRange } from '@/lib/reports/date-range'
import {
reportToWorkbook,
textColumn,
@@ -34,14 +35,31 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
}
const { data: companyRow } = await supabase
.from('company_settings')
.select('company_name')
.eq('company_id', companyId)
.single()
const [{ data: companyRow }, { data: period }] = await Promise.all([
supabase
.from('company_settings')
.select('company_name')
.eq('company_id', companyId)
.single(),
supabase
.from('fiscal_periods')
.select('period_start, period_end')
.eq('id', periodId)
.eq('company_id', companyId)
.single(),
])
let range: { fromDate?: string; toDate?: string } = {}
if (period) {
const parsed = parseReportDateRange(searchParams, period)
if (!parsed.ok) {
return NextResponse.json({ error: parsed.error }, { status: 400 })
}
range = parsed.range
}
try {
const report = await generateResultatrapport(supabase, companyId, periodId)
const report = await generateResultatrapport(supabase, companyId, periodId, range)
const rows: FlatRow[] = []
for (const g of report.groups) {
@@ -18,7 +18,17 @@ ensureInitialized()
* Per BFL 7 kap: Delivery confirmation retained as part of audit trail.
*/
export async function POST(
request: Request,
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
await params
return NextResponse.json({ error: 'Funktionen är inaktiverad' }, { status: 503 })
}
// Implementation preserved but unreachable — feature disabled at the export above.
// To re-enable, replace the POST export above with this function body.
async function _sendPayslipsImpl(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
@@ -560,6 +560,94 @@ describe('POST /api/supplier-invoices', () => {
expect(call[5]).toBe('enskild_firma')
})
it('persists manual vat_amount override on items and forwards it to the engine', async () => {
// Bilförmån-fallet: leverantören tar 25% moms men endast 50% är
// avdragsgill. Användaren skriver 1 250 kr i momsrutan i stället för
// den beräknade 2 500 kr.
const supplier = makeSupplier({ id: VALID_UUID })
const createdInvoice = makeSupplierInvoice({ id: 'si-1' })
enqueue({ data: supplier, error: null })
enqueue({ data: 7 })
enqueue({ data: createdInvoice, error: null })
enqueue({ data: null, error: null })
enqueue({ data: { accounting_method: 'accrual' }, error: null })
mockCreateSupplierInvoiceRegistrationEntry.mockResolvedValue({ id: 'je-1' })
enqueue({ data: null, error: null })
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
supplier_invoice_number: 'LEAS-001',
invoice_date: '2024-06-01',
due_date: '2024-07-01',
items: [
{
description: 'Leasing personbil',
amount: 10000,
account_number: '5615',
vat_rate: 0.25,
vat_amount: 1250,
},
],
},
})
const response = await POST(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(mockCreateSupplierInvoiceRegistrationEntry).toHaveBeenCalled()
const items = mockCreateSupplierInvoiceRegistrationEntry.mock.calls[0][4] as Array<{
vat_amount: number
vat_rate: number
line_total: number
}>
expect(items).toHaveLength(1)
expect(items[0].vat_amount).toBe(1250)
expect(items[0].vat_rate).toBe(0.25)
expect(items[0].line_total).toBe(10000)
})
it('falls back to line_total × rate when vat_amount is omitted', async () => {
const supplier = makeSupplier({ id: VALID_UUID })
const createdInvoice = makeSupplierInvoice({ id: 'si-1' })
enqueue({ data: supplier, error: null })
enqueue({ data: 8 })
enqueue({ data: createdInvoice, error: null })
enqueue({ data: null, error: null })
enqueue({ data: { accounting_method: 'accrual' }, error: null })
mockCreateSupplierInvoiceRegistrationEntry.mockResolvedValue({ id: 'je-1' })
enqueue({ data: null, error: null })
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
supplier_invoice_number: 'LF-001',
invoice_date: '2024-06-01',
due_date: '2024-07-01',
items: [
{
description: 'Material',
amount: 10000,
account_number: '4010',
vat_rate: 0.25,
},
],
},
})
const response = await POST(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
const items = mockCreateSupplierInvoiceRegistrationEntry.mock.calls[0][4] as Array<{
vat_amount: number
}>
expect(items[0].vat_amount).toBe(2500)
})
it('rejects paid_with_private_funds combined with reverse_charge', async () => {
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
+6 -1
View File
@@ -115,7 +115,12 @@ export const POST = withRouteContext(
const lineTotal = item.amount != null
? Math.round(item.amount * 100) / 100
: Math.round((item.quantity ?? 1) * (item.unit_price ?? 0) * 100) / 100
const vatAmount = Math.round(lineTotal * vatRate * 100) / 100
// Honor a manual VAT override (partial-deduction cases, foreign-currency
// rounding, supplier-side POS rounding). Falls back to line_total × rate
// when the caller didn't supply one.
const vatAmount = item.vat_amount != null
? Math.round(item.vat_amount * 100) / 100
: Math.round(lineTotal * vatRate * 100) / 100
return {
sort_order: index,
description: item.description,
-9
View File
@@ -6,7 +6,6 @@ import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server";
import { Toaster } from "@/components/ui/toaster";
import { ThemeProvider } from "@/components/theme-provider";
import { RecaptHideWidget } from "@/components/RecaptHideWidget";
import { ensureInitialized } from "@/lib/init";
import { getBranding } from "@/lib/branding/service";
import "./globals.css";
@@ -68,13 +67,6 @@ export default async function RootLayout({
<html lang={locale} suppressHydrationWarning className={`${geistSans.variable} ${geistMono.variable} ${hedvigSerif.variable}`}>
<head>
<link rel="apple-touch-icon" href={branding.appleTouchIconPath} />
<script
src="https://cdn.recapt.app/browser/glimt.js"
async
data-public-key="pk_8de220ce34c81413de154d10ff681a9eb3a5a9c12d28bd6c7bc2613c9f5acfbb"
data-persist
data-enable-user-comments
/>
</head>
<body
className="antialiased"
@@ -88,7 +80,6 @@ export default async function RootLayout({
>
{children}
<Toaster />
<RecaptHideWidget />
</ThemeProvider>
</NextIntlClientProvider>
<Script src="/sw-register.js" strategy="afterInteractive" />