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" />
-38
View File
@@ -1,38 +0,0 @@
'use client'
import { useEffect } from 'react'
/**
* Hides Recapt's floating feedback bubble while keeping the SDK active so
* `window.recapt('identify', ...)` and programmatic `window.recapt('feedback',
* { message })` calls continue to work. Mounted globally in the root layout.
*/
export function RecaptHideWidget() {
useEffect(() => {
let attempts = 0
const maxAttempts = 50
const hide = (): boolean => {
if (typeof window.recapt !== 'function') return false
try {
window.recapt('feedback', { widget: 'hide' })
} catch {
// best-effort
}
return true
}
if (hide()) return
const interval = setInterval(() => {
attempts++
if (hide() || attempts >= maxAttempts) {
clearInterval(interval)
}
}, 100)
return () => clearInterval(interval)
}, [])
return null
}
-37
View File
@@ -1,37 +0,0 @@
'use client'
import { useEffect } from 'react'
export function RecaptIdentify({
userId,
email,
displayName,
}: {
userId: string
email?: string
displayName?: string
}) {
useEffect(() => {
let attempts = 0
const maxAttempts = 50
const interval = setInterval(() => {
if (typeof window.recapt === 'function') {
window.recapt('identify', {
uid: userId,
email,
nickname: displayName,
})
clearInterval(interval)
return
}
attempts++
if (attempts >= maxAttempts) {
clearInterval(interval)
}
}, 100)
return () => clearInterval(interval)
}, [userId, email, displayName])
return null
}
+24 -2
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
import { Plus } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { getAccountClassName } from '@/lib/bookkeeping/account-descriptions'
import type { BASAccount } from '@/types'
@@ -9,11 +10,18 @@ interface AccountComboboxProps {
value: string
accounts: BASAccount[]
onChange: (accountNumber: string) => void
// When provided, an inline "Skapa nytt konto" affordance appears in the
// dropdown's empty state. The current search string is passed so the caller
// can prefill the create dialog.
onCreateAccount?: (prefill: string) => void
// Extra classes merged into the trigger Input — callers pass `h-8` for dense
// table rows, omit it to use the default Input height.
className?: string
}
const MAX_RESULTS = 50
export default function AccountCombobox({ value, accounts, onChange }: AccountComboboxProps) {
export default function AccountCombobox({ value, accounts, onChange, onCreateAccount, className }: AccountComboboxProps) {
const [search, setSearch] = useState(value)
const [isOpen, setIsOpen] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(0)
@@ -179,7 +187,7 @@ export default function AccountCombobox({ value, accounts, onChange }: AccountCo
onBlur={handleBlur}
onKeyDown={handleKeyDown}
placeholder="Sök konto…"
className="font-mono"
className={`font-mono ${className ?? ''}`.trim()}
autoComplete="off"
/>
@@ -237,6 +245,20 @@ export default function AccountCombobox({ value, accounts, onChange }: AccountCo
Kontot kan behöva aktiveras i din kontoplan.
</p>
)}
{onCreateAccount && (
<button
type="button"
className="mt-2 flex w-full items-center gap-2 rounded-md border border-input bg-card px-2 py-1.5 text-left text-sm hover:bg-muted/50"
onMouseDown={(e) => {
e.preventDefault()
setIsOpen(false)
onCreateAccount(search.trim())
}}
>
<Plus className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">Skapa konto &quot;{search.trim()}&quot;</span>
</button>
)}
</div>
)}
</div>
@@ -17,6 +17,10 @@ export interface ActivateAccountsDialogProps {
accountNumbers: string[]
onConfirm: () => Promise<void> | void
onCancel: () => void
// Optional: invoked when the user wants to create a custom (non-BAS) account
// for a number that isn't in the BAS catalogue. The host should close this
// dialog and open AddAccountDialog prefilled with the number.
onCreateUnknown?: (accountNumber: string) => void
}
interface BasLookupRow {
@@ -30,6 +34,7 @@ export function ActivateAccountsDialog({
accountNumbers,
onConfirm,
onCancel,
onCreateUnknown,
}: ActivateAccountsDialogProps) {
const [rows, setRows] = useState<BasLookupRow[]>([])
const [loading, setLoading] = useState(false)
@@ -104,12 +109,29 @@ export function ActivateAccountsDialog({
)}
{!loading && unknownRows.length > 0 && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<p className="font-medium">Okända konton:</p>
<div className="rounded-md border border-warning/40 bg-warning/5 px-3 py-2 text-xs text-warning-foreground">
<p className="font-medium">Finns inte i BAS-katalogen:</p>
<p className="mt-1 font-mono">{unknownRows.map((r) => r.account_number).join(', ')}</p>
<p className="mt-1 text-destructive/80">
Dessa nummer finns inte i BAS-katalogen och kan inte aktiveras automatiskt. Kontrollera inmatningen.
<p className="mt-1 text-warning-foreground/80">
Skapa dem som egna konton, eller kontrollera inmatningen.
</p>
{onCreateUnknown && (
<div className="mt-2 flex flex-wrap gap-1.5">
{unknownRows.map((r) => (
<Button
key={r.account_number}
type="button"
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={() => onCreateUnknown(r.account_number)}
>
<Plus className="mr-1 h-3 w-3" />
Skapa {r.account_number}
</Button>
))}
</div>
)}
</div>
)}
</div>
+29 -4
View File
@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import {
Dialog,
DialogContent,
@@ -16,11 +16,14 @@ import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Loader2, AlertTriangle } from 'lucide-react'
import { isStandardBASAccount } from '@/lib/bookkeeping/bas-reference'
import type { BASAccount } from '@/types'
interface AddAccountDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onCreated: () => void
onCreated: (account: BASAccount) => void
initialAccountNumber?: string
initialAccountName?: string
}
function deriveAccountType(accountNumber: string): { type: string; balance: string } {
@@ -44,7 +47,13 @@ function deriveAccountType(accountNumber: string): { type: string; balance: stri
}
}
export function AddAccountDialog({ open, onOpenChange, onCreated }: AddAccountDialogProps) {
export function AddAccountDialog({
open,
onOpenChange,
onCreated,
initialAccountNumber,
initialAccountName,
}: AddAccountDialogProps) {
const [accountNumber, setAccountNumber] = useState('')
const [accountName, setAccountName] = useState('')
const [description, setDescription] = useState('')
@@ -54,6 +63,20 @@ export function AddAccountDialog({ open, onOpenChange, onCreated }: AddAccountDi
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState('')
// Apply prefill values whenever the dialog opens. Resetting on close happens
// implicitly after a successful create; here we only need to seed inputs so
// the user doesn't retype what the combobox already captured.
useEffect(() => {
if (!open) return
const num = (initialAccountNumber ?? '').replace(/\D/g, '').slice(0, 4)
setAccountNumber(num)
setAccountName(initialAccountName ?? '')
setError('')
if (num.length === 4) {
setNormalBalance(deriveAccountType(num).balance as 'debit' | 'credit')
}
}, [open, initialAccountNumber, initialAccountName])
const isBASMatch = accountNumber.length === 4 && isStandardBASAccount(accountNumber)
const derived = accountNumber.length === 4 ? deriveAccountType(accountNumber) : null
@@ -91,13 +114,15 @@ export function AddAccountDialog({ open, onOpenChange, onCreated }: AddAccountDi
throw new Error(data.error || 'Kunde inte skapa kontot')
}
const { data: createdAccount } = await response.json() as { data: BASAccount }
// Reset form
setAccountNumber('')
setAccountName('')
setDescription('')
setDefaultVatCode('')
setSruCode('')
onCreated()
onCreated(createdAccount)
onOpenChange(false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Något gick fel')
@@ -18,6 +18,7 @@ import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import BookingTemplatePicker from '@/components/bookkeeping/BookingTemplatePicker'
import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog'
import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog'
import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog'
import { Skeleton } from '@/components/ui/skeleton'
import {
useSubmitWithAccountActivation,
@@ -107,6 +108,10 @@ export default function JournalEntryForm({
// Per-account saldo as of entryDate, keyed by account_number.
// undefined = not fetched, null = fetch in flight.
const [accountBalances, setAccountBalances] = useState<Record<string, number | null>>({})
// Inline account-creation: which line triggered the dialog, and what the
// user typed in the combobox so we can prefill the dialog.
const [creatingAccountForLine, setCreatingAccountForLine] = useState<number | null>(null)
const [createAccountPrefill, setCreateAccountPrefill] = useState<string>('')
const isForeign = entryCurrency !== 'SEK'
@@ -382,6 +387,23 @@ export default function JournalEntryForm({
if (!description) setDescription(templateDescription)
}
const handleOpenCreateAccount = (lineIndex: number, prefill: string) => {
setCreatingAccountForLine(lineIndex)
setCreateAccountPrefill(prefill)
}
// After a new account is created, refresh the chart, auto-select it on the
// line that initiated the create, and close the dialog. All other form
// state is preserved — we never navigate away from the form.
const handleAccountCreated = async (account: BASAccount) => {
await fetchAccounts()
if (creatingAccountForLine != null) {
updateLine(creatingAccountForLine, 'account_number', account.account_number)
}
setCreatingAccountForLine(null)
setCreateAccountPrefill('')
}
const handleReview = () => {
if (!selectedPeriod || !description || !isBalanced || periodMismatch) return
const hasDocuments = uploadedFiles.some((f) => f.status === 'uploaded')
@@ -730,6 +752,7 @@ export default function JournalEntryForm({
value={line.account_number}
accounts={accounts}
onChange={(num) => updateLine(index, 'account_number', num)}
onCreateAccount={(prefill) => handleOpenCreateAccount(index, prefill)}
/>
</div>
<Button
@@ -840,6 +863,8 @@ export default function JournalEntryForm({
value={line.account_number}
accounts={accounts}
onChange={(num) => updateLine(index, 'account_number', num)}
onCreateAccount={(prefill) => handleOpenCreateAccount(index, prefill)}
className="h-8"
/>
</td>
<td className="py-1.5 px-1">
@@ -997,6 +1022,25 @@ export default function JournalEntryForm({
accountNumbers={activationDialog.accountNumbers}
onConfirm={confirmActivation}
onCancel={cancelActivation}
onCreateUnknown={(num) => {
cancelActivation()
const lineIndex = lines.findIndex((l) => l.account_number === num)
setCreatingAccountForLine(lineIndex >= 0 ? lineIndex : null)
setCreateAccountPrefill(num)
}}
/>
<AddAccountDialog
open={creatingAccountForLine != null}
onOpenChange={(next) => {
if (!next) {
setCreatingAccountForLine(null)
setCreateAccountPrefill('')
}
}}
initialAccountNumber={/^\d{1,4}$/.test(createAccountPrefill) ? createAccountPrefill : undefined}
initialAccountName={/^\d{1,4}$/.test(createAccountPrefill) ? undefined : createAccountPrefill}
onCreated={handleAccountCreated}
/>
<ConfirmationDialog
+59 -8
View File
@@ -10,13 +10,14 @@ import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { ChevronDown, ChevronRight, Paperclip, AlertTriangle, Loader2, BookOpen, X, Copy, Lock } from 'lucide-react'
import { ChevronDown, ChevronRight, Paperclip, AlertTriangle, CircleSlash, Loader2, BookOpen, X, Copy, Lock } from 'lucide-react'
import { formatDate } from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { Input } from '@/components/ui/input'
import { AccountNumber } from '@/components/ui/account-number'
import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions'
import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments'
import NoDocRequiredToggle from '@/components/bookkeeping/NoDocRequiredToggle'
import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog'
import JournalEntryStatusBadge from '@/components/bookkeeping/JournalEntryStatusBadge'
import AttachmentPreviewSheet from '@/components/bookkeeping/AttachmentPreviewSheet'
@@ -50,6 +51,7 @@ export default function JournalEntryList({ periodId }: Props) {
const [count, setCount] = useState(0)
const [page, setPage] = useState(0)
const [attachmentCounts, setAttachmentCounts] = useState<Record<string, number>>({})
const [noDocRequired, setNoDocRequired] = useState<Map<string, string | null>>(new Map())
const [showMissingOnly, setShowMissingOnly] = useState(false)
const [correctionEntry, setCorrectionEntry] = useState<JournalEntry | null>(null)
const [previewEntryId, setPreviewEntryId] = useState<string | null>(null)
@@ -115,6 +117,25 @@ export default function JournalEntryList({ periodId }: Props) {
}
}, [])
const fetchNoDocRequired = useCallback(async () => {
try {
const res = await fetch('/api/bookkeeping/no-doc-required')
if (!res.ok) return
const { data } = await res.json()
const map = new Map<string, string | null>()
for (const row of (data || []) as { journal_entry_id: string; reason: string | null }[]) {
map.set(row.journal_entry_id, row.reason)
}
setNoDocRequired(map)
} catch {
// Non-critical — silently ignore
}
}, [])
useEffect(() => {
fetchNoDocRequired()
}, [fetchNoDocRequired])
async function fetchEntries() {
setLoading(true)
const params = new URLSearchParams({
@@ -209,7 +230,8 @@ export default function JournalEntryList({ periodId }: Props) {
(e) =>
NEEDS_ATTACHMENT.has(e.source_type) &&
!attachmentCounts[e.id] &&
e.status === 'posted'
e.status === 'posted' &&
!noDocRequired.has(e.id)
)
: entries
@@ -396,9 +418,15 @@ export default function JournalEntryList({ periodId }: Props) {
</Button>
) : (
NEEDS_ATTACHMENT.has(entry.source_type) && entry.status === 'posted' && (
<span className="mr-1" title={t('missing_attachment_tooltip')}>
<AlertTriangle className="h-3.5 w-3.5 text-warning-foreground" />
</span>
noDocRequired.has(entry.id) ? (
<span className="mr-1" title={t('no_doc_required_indicator_tooltip')}>
<CircleSlash className="h-3.5 w-3.5 text-muted-foreground" />
</span>
) : (
<span className="mr-1" title={t('missing_attachment_tooltip')}>
<AlertTriangle className="h-3.5 w-3.5 text-warning-foreground" />
</span>
)
)
)}
</div>
@@ -466,9 +494,15 @@ export default function JournalEntryList({ periodId }: Props) {
</Button>
) : (
NEEDS_ATTACHMENT.has(entry.source_type) && entry.status === 'posted' && (
<span title={t('missing_attachment_tooltip')}>
<AlertTriangle className="h-3.5 w-3.5 text-warning-foreground" />
</span>
noDocRequired.has(entry.id) ? (
<span title={t('no_doc_required_indicator_tooltip')}>
<CircleSlash className="h-3.5 w-3.5 text-muted-foreground" />
</span>
) : (
<span title={t('missing_attachment_tooltip')}>
<AlertTriangle className="h-3.5 w-3.5 text-warning-foreground" />
</span>
)
)
)}
</span>
@@ -545,6 +579,23 @@ export default function JournalEntryList({ periodId }: Props) {
onCountChange={(c) => handleAttachmentCountChange(entry.id, c)}
/>
{entry.status === 'posted' && NEEDS_ATTACHMENT.has(entry.source_type) && (
<NoDocRequiredToggle
entryId={entry.id}
initialExempt={noDocRequired.has(entry.id)}
initialReason={noDocRequired.get(entry.id) ?? null}
canWrite={canWrite}
onChange={(exempted, reason) => {
setNoDocRequired((prev) => {
const next = new Map(prev)
if (exempted) next.set(entry.id, reason ?? null)
else next.delete(entry.id)
return next
})
}}
/>
)}
<div className="mt-4 pt-3 border-t flex flex-col sm:flex-row gap-2">
{entry.status === 'draft' && (
<Button
@@ -0,0 +1,164 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Loader2, CircleSlash, Lock } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
interface Props {
entryId: string
initialExempt: boolean
initialReason: string | null
canWrite: boolean
onChange: (exempted: boolean, reason: string | null) => void
}
/**
* Lets the bookkeeper mark a posted verifikation as not needing a separate
* underlag (bankavgift, ränta, intern överföring etc.). Toggling persists via
* /api/bookkeeping/journal-entries/[id]/no-document-required.
*
* The flag lives in journal_entry_no_doc_required (sidecar) so the underlying
* verifikation stays immutable per BFL.
*/
export default function NoDocRequiredToggle({
entryId,
initialExempt,
initialReason,
canWrite,
onChange,
}: Props) {
const t = useTranslations('journal_list')
const { toast } = useToast()
const [exempt, setExempt] = useState(initialExempt)
const [reason, setReason] = useState(initialReason ?? '')
const [saving, setSaving] = useState(false)
const [editingReason, setEditingReason] = useState(false)
const persistExempt = async (
nextExempt: boolean,
nextReason: string | null,
previousReason: string,
) => {
setSaving(true)
try {
const res = nextExempt
? await fetch(`/api/bookkeeping/journal-entries/${entryId}/no-document-required`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason: nextReason ?? null }),
})
: await fetch(`/api/bookkeeping/journal-entries/${entryId}/no-document-required`, {
method: 'DELETE',
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
toast({
title: t('no_doc_required_save_failed'),
description: body.error,
variant: 'destructive',
})
// Roll back UI state on failure — both the toggle AND the reason so
// the rendered state matches the DB row we failed to mutate.
setExempt(!nextExempt)
setReason(previousReason)
return
}
onChange(nextExempt, nextExempt ? nextReason : null)
} catch {
toast({ title: t('no_doc_required_save_failed'), variant: 'destructive' })
setExempt(!nextExempt)
setReason(previousReason)
} finally {
setSaving(false)
}
}
const handleToggle = (checked: boolean) => {
const previousReason = reason
setExempt(checked)
if (!checked) {
setReason('')
setEditingReason(false)
}
persistExempt(checked, checked ? (reason.trim() || null) : null, previousReason)
}
const handleSaveReason = () => {
setEditingReason(false)
persistExempt(true, reason.trim() || null, reason)
}
return (
<div className="mt-4 pt-3 border-t">
<div className="flex items-center gap-2">
<Switch
id={`no-doc-${entryId}`}
checked={exempt}
onCheckedChange={handleToggle}
disabled={!canWrite || saving}
/>
<Label
htmlFor={`no-doc-${entryId}`}
className="text-sm cursor-pointer flex items-center gap-1.5"
>
{!canWrite && <Lock className="h-3 w-3" />}
{saving && <Loader2 className="h-3 w-3 animate-spin" />}
{!saving && exempt && <CircleSlash className="h-3.5 w-3.5 text-muted-foreground" />}
{t('no_doc_required_toggle')}
</Label>
</div>
{exempt && (
<div className="mt-2 ml-10 space-y-1">
{editingReason ? (
<div className="flex items-center gap-2">
<Input
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder={t('no_doc_required_reason_placeholder')}
list={`no-doc-suggestions-${entryId}`}
maxLength={200}
className="h-8 text-xs flex-1 max-w-sm"
disabled={saving}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSaveReason()
}
}}
/>
<datalist id={`no-doc-suggestions-${entryId}`}>
<option value={t('no_doc_required_suggestion_bank_fee')} />
<option value={t('no_doc_required_suggestion_interest')} />
<option value={t('no_doc_required_suggestion_internal_transfer')} />
<option value={t('no_doc_required_suggestion_tax_payment')} />
<option value={t('no_doc_required_suggestion_salary')} />
</datalist>
<Button size="sm" variant="outline" className="h-8" onClick={handleSaveReason} disabled={saving}>
{t('no_doc_required_save_reason')}
</Button>
</div>
) : (
<button
type="button"
onClick={() => canWrite && setEditingReason(true)}
disabled={!canWrite}
className="text-xs text-muted-foreground hover:text-foreground transition-colors text-left"
>
{reason
? `${t('no_doc_required_reason_label')}: ${reason}`
: t('no_doc_required_reason_add')}
</button>
)}
</div>
)}
</div>
)
}
+12 -6
View File
@@ -23,7 +23,12 @@ interface Props {
* Current selection. `null` means "all years" — no filter applied.
*/
value: string | null
onChange: (periodId: string | null) => void
/**
* Called with the selected period id (or null for "all years"). The second
* arg is the matching FiscalPeriod object so callers can read period_start
* / period_end without an extra fetch.
*/
onChange: (periodId: string | null, period?: FiscalPeriod | null) => void
/**
* If true, include an "Alla räkenskapsår" option that clears the filter.
* Pages that require a specific period (e.g. Reports) should pass false.
@@ -107,12 +112,12 @@ export function FiscalYearSelector({
if (value === null && typeof window !== 'undefined') {
const stored = window.localStorage.getItem(STORAGE_KEY_PREFIX + company.id)
if (stored === ALL_YEARS_VALUE) {
if (includeAllOption) onChange(null)
else if (fetched.length > 0) onChange(fetched[0].id)
if (includeAllOption) onChange(null, null)
else if (fetched.length > 0) onChange(fetched[0].id, fetched[0])
} else if (stored && fetched.some((p) => p.id === stored)) {
onChange(stored)
onChange(stored, fetched.find((p) => p.id === stored) ?? null)
} else if (!includeAllOption && fetched.length > 0) {
onChange(fetched[0].id)
onChange(fetched[0].id, fetched[0])
}
}
@@ -134,7 +139,8 @@ export function FiscalYearSelector({
nextPeriodId ?? ALL_YEARS_VALUE,
)
}
onChange(nextPeriodId)
const nextPeriod = nextPeriodId ? periods.find((p) => p.id === nextPeriodId) ?? null : null
onChange(nextPeriodId, nextPeriod)
}
const selectValue = value ?? (includeAllOption ? ALL_YEARS_VALUE : '')
+237
View File
@@ -0,0 +1,237 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import { useCompany } from '@/contexts/CompanyContext'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
export type DateRangeValue = {
/** Inclusive lower bound. ISO YYYY-MM-DD. `undefined` = period start. */
fromDate?: string
/** Inclusive upper bound. ISO YYYY-MM-DD. `undefined` = period end. */
toDate?: string
}
type Preset = 'full_year' | 'ytd' | 'this_month' | 'last_month' | 'this_quarter' | 'custom'
interface Props {
/** Selected fiscal period — bounds the range. */
periodStart: string
periodEnd: string
value: DateRangeValue
onChange: (next: DateRangeValue) => void
className?: string
}
const STORAGE_KEY_PREFIX = 'gnubok:report-range-preset:'
const PRESETS: Preset[] = ['full_year', 'ytd', 'this_month', 'last_month', 'this_quarter', 'custom']
function todayIso(): string {
// Local calendar date — using toISOString() returns UTC, which falls a day
// behind for Swedish users between midnight and 01:00/02:00 local time and
// would silently truncate "today" from YTD / this-month / this-quarter.
const d = new Date()
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function clampToPeriod(date: string, periodStart: string, periodEnd: string): string {
if (date < periodStart) return periodStart
if (date > periodEnd) return periodEnd
return date
}
/**
* Resolve a preset to a concrete range inside the fiscal period.
*
* Endpoints are always clamped to the period — e.g. "this month" outside the
* period collapses to a zero-width range at whichever boundary you're nearest.
* `full_year` returns `{}` so the API call omits the params entirely and the
* report falls back to its full-period default (preserves cache parity with
* the pre-feature behaviour).
*/
function resolvePreset(
preset: Preset,
periodStart: string,
periodEnd: string,
reference: string,
): DateRangeValue {
if (preset === 'full_year') return {}
if (preset === 'custom') return {}
if (preset === 'ytd') {
const to = clampToPeriod(reference, periodStart, periodEnd)
return { fromDate: periodStart, toDate: to }
}
const ref = new Date(reference)
// Same UTC pitfall as todayIso(): Date.toISOString() returns UTC, so a
// local Date constructed via `new Date(y, m, d)` round-trips to the wrong
// calendar day in any timezone west of UTC. Use the local components.
const toLocalIso = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
if (preset === 'this_month') {
const y = ref.getFullYear()
const m = ref.getMonth()
const start = `${y}-${String(m + 1).padStart(2, '0')}-01`
const end = toLocalIso(new Date(y, m + 1, 0))
return {
fromDate: clampToPeriod(start, periodStart, periodEnd),
toDate: clampToPeriod(end, periodStart, periodEnd),
}
}
if (preset === 'last_month') {
const y = ref.getFullYear()
const m = ref.getMonth() - 1
const start = toLocalIso(new Date(y, m, 1))
const end = toLocalIso(new Date(y, m + 1, 0))
return {
fromDate: clampToPeriod(start, periodStart, periodEnd),
toDate: clampToPeriod(end, periodStart, periodEnd),
}
}
if (preset === 'this_quarter') {
const y = ref.getFullYear()
const q = Math.floor(ref.getMonth() / 3)
const start = `${y}-${String(q * 3 + 1).padStart(2, '0')}-01`
const end = toLocalIso(new Date(y, q * 3 + 3, 0))
return {
fromDate: clampToPeriod(start, periodStart, periodEnd),
toDate: clampToPeriod(end, periodStart, periodEnd),
}
}
return {}
}
/**
* Date-range picker for the resultat-/balansrapport family.
*
* Default = "Hittills i år" (YTD) which matches Fortnox/Visma. A "Hela året"
* preset clears the range entirely so the API falls back to full-period
* behaviour. Custom range is clamped to the fiscal period — cross-year
* ranges are out of scope.
*/
export function ReportDateRange({
periodStart,
periodEnd,
value,
onChange,
className,
}: Props) {
const t = useTranslations('reports')
const { company } = useCompany()
const [preset, setPreset] = useState<Preset>('ytd')
// Restore last-used preset per company, then resolve it against the
// current fiscal period. The period selector lives upstream — when it
// changes, we re-resolve so the dates always sit inside the visible year.
useEffect(() => {
if (!company?.id || typeof window === 'undefined') return
const stored = window.localStorage.getItem(STORAGE_KEY_PREFIX + company.id) as Preset | null
const initial: Preset = stored && PRESETS.includes(stored) ? stored : 'ytd'
setPreset(initial)
if (initial !== 'custom') {
onChange(resolvePreset(initial, periodStart, periodEnd, todayIso()))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [company?.id, periodStart, periodEnd])
const handlePreset = useCallback(
(next: Preset) => {
setPreset(next)
if (company?.id && typeof window !== 'undefined') {
window.localStorage.setItem(STORAGE_KEY_PREFIX + company.id, next)
}
if (next === 'custom') {
// Seed the custom inputs with whatever is currently active so the
// user can nudge them rather than start from scratch.
if (!value.fromDate && !value.toDate) {
onChange({ fromDate: periodStart, toDate: clampToPeriod(todayIso(), periodStart, periodEnd) })
}
return
}
onChange(resolvePreset(next, periodStart, periodEnd, todayIso()))
},
[company?.id, onChange, periodEnd, periodStart, value.fromDate, value.toDate],
)
const handleFromChange = (raw: string) => {
const next = raw ? clampToPeriod(raw, periodStart, periodEnd) : undefined
onChange({ fromDate: next, toDate: value.toDate })
}
const handleToChange = (raw: string) => {
const next = raw ? clampToPeriod(raw, periodStart, periodEnd) : undefined
onChange({ fromDate: value.fromDate, toDate: next })
}
const presetLabels: Record<Preset, string> = useMemo(
() => ({
full_year: t('date_range_preset_full_year'),
ytd: t('date_range_preset_ytd'),
this_month: t('date_range_preset_this_month'),
last_month: t('date_range_preset_last_month'),
this_quarter: t('date_range_preset_this_quarter'),
custom: t('date_range_preset_custom'),
}),
[t],
)
return (
<div className={cn('flex flex-col gap-2', className)}>
<Label className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{t('date_range_label')}
</Label>
<div className="flex flex-wrap items-center gap-1.5">
{PRESETS.map((p) => {
const active = preset === p
return (
<button
key={p}
type="button"
onClick={() => handlePreset(p)}
className={cn(
'px-3 py-1.5 text-xs rounded-md border transition-colors duration-150',
active
? 'bg-secondary border-border text-foreground'
: 'bg-transparent border-border text-muted-foreground hover:bg-secondary/60 hover:text-foreground',
)}
>
{presetLabels[p]}
</button>
)
})}
</div>
{preset === 'custom' && (
<div className="flex flex-wrap items-end gap-3 mt-1">
<div className="flex flex-col gap-1">
<Label className="text-xs text-muted-foreground">{t('date_range_from')}</Label>
<Input
type="date"
min={periodStart}
max={periodEnd}
value={value.fromDate ?? ''}
onChange={(e) => handleFromChange(e.target.value)}
className="w-[160px] tabular-nums"
/>
</div>
<div className="flex flex-col gap-1">
<Label className="text-xs text-muted-foreground">{t('date_range_to')}</Label>
<Input
type="date"
min={periodStart}
max={periodEnd}
value={value.toDate ?? ''}
onChange={(e) => handleToChange(e.target.value)}
className="w-[160px] tabular-nums"
/>
</div>
</div>
)}
</div>
)
}
-2
View File
@@ -35,7 +35,6 @@ import { resolveIcon } from '@/lib/extensions/icon-resolver'
import { SupportLink } from '@/components/ui/support-link'
import CompanySwitcher from '@/components/dashboard/CompanySwitcher'
import { useCompany } from '@/contexts/CompanyContext'
import { clearRecaptIdentity } from '@/lib/recapt'
import type { EntityType } from '@/types'
void _ENABLED_EXTENSION_IDS
@@ -153,7 +152,6 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
}
const handleLogout = async () => {
clearRecaptIdentity()
await supabase.auth.signOut()
router.push(isSandbox ? '/sandbox' : '/login')
}
-140
View File
@@ -2,7 +2,6 @@
import { useTranslations } from 'next-intl'
import { useState, useCallback } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
@@ -19,15 +18,6 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
const { toast } = useToast()
const [lateFeeText, setLateFeeText] = useState(settings.invoice_late_fee_text || '')
const [creditTermsText, setCreditTermsText] = useState(settings.invoice_credit_terms_text || '')
const [reminderFeeAmount, setReminderFeeAmount] = useState(
String(settings.reminder_fee_amount ?? 60),
)
// Display override as a percentage (the DB stores a decimal). Empty = no override.
const [interestRatePercent, setInterestRatePercent] = useState(
settings.reminder_interest_rate_override != null
? String(settings.reminder_interest_rate_override * 100)
: '',
)
const saveToggle = useCallback(async (field: string, value: boolean) => {
try {
@@ -71,62 +61,6 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
}
}, [onUpdate, toast, t])
const saveReminderFeeAmount = useCallback(async (raw: string) => {
const parsed = parseFloat(raw.replace(',', '.'))
if (Number.isNaN(parsed) || parsed < 0) {
toast({ title: 'Ogiltigt belopp', variant: 'destructive' })
return
}
// Lag 1981:739: maxgräns 60 kr för lagstadgad påminnelseavgift.
const clamped = Math.min(parsed, 60)
try {
const response = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reminder_fee_amount: clamped }),
})
if (!response.ok) throw new Error()
onUpdate({ reminder_fee_amount: clamped })
setReminderFeeAmount(String(clamped))
} catch {
toast({ title: t('toast_save_failed'), variant: 'destructive' })
}
}, [onUpdate, toast, t])
const saveInterestOverride = useCallback(async (raw: string) => {
if (raw.trim() === '') {
try {
const response = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reminder_interest_rate_override: null }),
})
if (!response.ok) throw new Error()
onUpdate({ reminder_interest_rate_override: null })
} catch {
toast({ title: t('toast_save_failed'), variant: 'destructive' })
}
return
}
const percent = parseFloat(raw.replace(',', '.'))
if (Number.isNaN(percent) || percent < 0 || percent >= 100) {
toast({ title: 'Ogiltig räntesats (0–99%)', variant: 'destructive' })
return
}
const decimal = Math.round((percent / 100) * 10_000) / 10_000
try {
const response = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reminder_interest_rate_override: decimal }),
})
if (!response.ok) throw new Error()
onUpdate({ reminder_interest_rate_override: decimal })
} catch {
toast({ title: t('toast_save_failed'), variant: 'destructive' })
}
}, [onUpdate, toast, t])
return (
<section className="space-y-6">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
@@ -270,80 +204,6 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
</div>
</div>
<div className="pt-6 space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Automatisering
</h2>
<div className="flex items-center justify-between">
<div>
<Label>Skicka automatiska påminnelser</Label>
<p className="text-xs text-muted-foreground">
Skicka påminnelser till kunder för försenade fakturor enligt din inställning för påminnelseintervall.
</p>
</div>
<Switch
checked={settings.send_invoice_reminders ?? true}
onCheckedChange={(v) => saveToggle('send_invoice_reminders', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Aktivera påminnelseavgift</Label>
<p className="text-xs text-muted-foreground">
Debitera lagstadgad påminnelseavgift (Lag 1981:739, max 60 kr) på varje påminnelse.
Avgiften bokförs automatiskt (1510 / 3990) och adderas till kundens fordran.
</p>
</div>
<Switch
checked={settings.reminder_fee_enabled ?? true}
onCheckedChange={(v) => saveToggle('reminder_fee_enabled', v)}
/>
</div>
{(settings.reminder_fee_enabled ?? true) && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pl-0">
<div className="space-y-2">
<Label htmlFor="reminder_fee_amount">Påminnelseavgift (kr)</Label>
<Input
id="reminder_fee_amount"
type="number"
min={0}
max={60}
step={1}
value={reminderFeeAmount}
onChange={(e) => setReminderFeeAmount(e.target.value)}
onBlur={() => saveReminderFeeAmount(reminderFeeAmount)}
/>
<p className="text-xs text-muted-foreground">
Standardvärde 60 kr. Maxgräns enligt Lag 1981:739.
</p>
</div>
</div>
)}
<div className="space-y-2">
<Label htmlFor="reminder_interest_rate_override">
Räntesats för dröjsmålsränta (% per år)
</Label>
<Input
id="reminder_interest_rate_override"
type="number"
min={0}
max={99}
step={0.1}
placeholder="Lämna tom för Räntelagen §6 (referensränta + 8 procentenheter)"
value={interestRatePercent}
onChange={(e) => setInterestRatePercent(e.target.value)}
onBlur={() => saveInterestOverride(interestRatePercent)}
/>
<p className="text-xs text-muted-foreground">
Om tom används lagstadgad dröjsmålsränta (Räntelagen §6 = Riksbankens
referensränta + 8 procentenheter). Räntan visas i påminnelsen men bokförs inte.
</p>
</div>
</div>
</section>
)
}
@@ -12,6 +12,9 @@ interface ReviewLineItem {
amount: number
account_number: string
vat_rate: number
// When set, the user typed the deductible VAT explicitly (manual override).
// Used for bilförmån 50%, representation tak, FX-rundningar etc.
vat_amount?: number
}
interface SupplierInvoiceReviewContentProps {
@@ -80,20 +83,28 @@ function buildJournalPreview(
})
}
// Per-line effective VAT — manual override wins over computed amount × rate.
// The engine reads stored vat_amount; the preview must reflect the same.
const itemVat = (item: ReviewLineItem) =>
item.vat_amount != null
? Math.round(item.vat_amount * 100) / 100
: Math.round(item.amount * item.vat_rate * 100) / 100
if (reverseCharge) {
// Reverse charge: fiktiv moms per VAT rate (matches engine groupVatByRate logic)
// Reverse charge: fiktiv moms is always statutory base × rate, regardless
// of any manual override on the items themselves (matches engine).
const isDomesticRC = supplierType === 'swedish_business'
const inputAccount = isDomesticRC ? '2647' : '2645'
const vatByRate = new Map<number, number>()
const baseByRate = new Map<number, number>()
for (const item of items) {
if (item.vat_rate > 0) {
const current = vatByRate.get(item.vat_rate) || 0
vatByRate.set(item.vat_rate, current + toSek(item.amount))
const current = baseByRate.get(item.vat_rate) || 0
baseByRate.set(item.vat_rate, current + toSek(item.amount))
}
}
for (const [rate, netAmount] of vatByRate) {
for (const [rate, netAmount] of baseByRate) {
const fiktivVat = Math.round(netAmount * rate * 100) / 100
const outputAccount = getOutputVatAccount(rate)
lines.push({
@@ -119,12 +130,23 @@ function buildJournalPreview(
})
} else {
if (totalVat > 0) {
lines.push({
account_number: '2641',
description: 'Ingående moms',
debit: toSek(totalVat),
credit: 0,
})
// Sum per-rate using effective (manual-or-computed) VAT, so the preview
// matches what groupVatByRate will write to 2641 server-side.
const vatByRate = new Map<number, number>()
for (const item of items) {
const v = itemVat(item)
if (v > 0) {
vatByRate.set(item.vat_rate, (vatByRate.get(item.vat_rate) || 0) + v)
}
}
for (const [, vat] of vatByRate) {
lines.push({
account_number: '2641',
description: 'Ingående moms',
debit: toSek(vat),
credit: 0,
})
}
}
// Credit: 2440 at total incl. VAT
lines.push({
@@ -226,7 +248,9 @@ export function SupplierInvoiceReviewContent({
</thead>
<tbody>
{items.map((item, index) => {
const vatAmount = Math.round(item.amount * item.vat_rate * 100) / 100
const vatAmount = item.vat_amount != null
? Math.round(item.vat_amount * 100) / 100
: Math.round(item.amount * item.vat_rate * 100) / 100
return (
<tr key={index} className="border-b last:border-0">
<td className="py-2">
@@ -244,7 +268,9 @@ export function SupplierInvoiceReviewContent({
</div>
<div className="sm:hidden space-y-2">
{items.map((item, index) => {
const vatAmount = Math.round(item.amount * item.vat_rate * 100) / 100
const vatAmount = item.vat_amount != null
? Math.round(item.vat_amount * 100) / 100
: Math.round(item.amount * item.vat_rate * 100) / 100
return (
<div key={index} className="border rounded-lg p-3 text-sm space-y-1.5">
<div className="flex items-center justify-between">
@@ -255,10 +255,21 @@ export async function sendMissingUnderlagNotifications(
(attachments || []).map((a) => a.journal_entry_id)
)
// Entries the user has explicitly flagged as "no underlag required" (bank
// fees, interest, internal transfers, salary, tax payments). Treated as
// satisfied so we don't nag the user about them.
const { data: exempted } = await supabase
.from('journal_entry_no_doc_required')
.select('journal_entry_id')
const exemptedEntries = new Set(
(exempted || []).map((e) => e.journal_entry_id)
)
// Group missing counts by user
const userMissingCounts = new Map<string, number>()
for (const entry of entries) {
if (!entriesWithDocs.has(entry.id)) {
if (!entriesWithDocs.has(entry.id) && !exemptedEntries.has(entry.id)) {
userMissingCounts.set(
entry.user_id,
(userMissingCounts.get(entry.user_id) || 0) + 1
+51
View File
@@ -682,6 +682,57 @@ describe('CreateSupplierInvoiceItemSchema', () => {
expect(result.success).toBe(true)
}
})
it('accepts vat_amount up to line_total * vat_rate', () => {
const result = CreateSupplierInvoiceItemSchema.safeParse(
validSupplierInvoiceItem({ amount: 5000, vat_rate: 0.25, vat_amount: 1250 })
)
expect(result.success).toBe(true)
})
it('accepts partial-deduction vat_amount (bilförmån 50%)', () => {
const result = CreateSupplierInvoiceItemSchema.safeParse(
validSupplierInvoiceItem({ amount: 5000, vat_rate: 0.25, vat_amount: 625 })
)
expect(result.success).toBe(true)
})
it('rejects vat_amount above line_total * vat_rate', () => {
const result = CreateSupplierInvoiceItemSchema.safeParse(
validSupplierInvoiceItem({ amount: 5000, vat_rate: 0.25, vat_amount: 2000 })
)
expect(result.success).toBe(false)
})
it('accepts vat_amount with 1-öre rounding tolerance', () => {
const result = CreateSupplierInvoiceItemSchema.safeParse(
validSupplierInvoiceItem({ amount: 100.04, vat_rate: 0.25, vat_amount: 25.02 })
)
expect(result.success).toBe(true)
})
it('works with quantity * unit_price line total', () => {
const overByABit = CreateSupplierInvoiceItemSchema.safeParse(
validSupplierInvoiceItem({
amount: undefined,
quantity: 4,
unit_price: 100,
vat_rate: 0.25,
vat_amount: 200,
})
)
expect(overByABit.success).toBe(false)
const exact = CreateSupplierInvoiceItemSchema.safeParse(
validSupplierInvoiceItem({
amount: undefined,
quantity: 4,
unit_price: 100,
vat_rate: 0.25,
vat_amount: 100,
})
)
expect(exact.success).toBe(true)
})
})
describe('MarkSupplierInvoicePaidSchema', () => {
+23 -1
View File
@@ -349,11 +349,33 @@ export const CreateSupplierInvoiceItemSchema = z.object({
amount: z.number().optional(),
account_number: accountNumber,
vat_rate: z.number().min(0).max(100).optional(),
// Manual VAT override. When provided, the engine books this exact amount to
// 2641/2645 instead of recomputing line_total × vat_rate. Use for partial-
// deductible cases (bilförmån 50%, representation 300 kr-tak), foreign-
// currency rounding, or POS receipts where supplier-side rounding makes the
// VAT off by öre.
vat_amount: z.number().min(0).optional(),
vat_code: z.string().optional(),
quantity: z.number().optional(),
unit: z.string().optional(),
unit_price: z.number().optional(),
})
}).refine(
(item) => {
if (item.vat_amount == null) return true
const lineTotal = item.amount != null
? item.amount
: (item.quantity ?? 1) * (item.unit_price ?? 0)
const vatRate = item.vat_rate ?? 0.25
const maxVat = Math.round(lineTotal * vatRate * 100) / 100
// 1-öre tolerance covers POS rounding; anything beyond is an upstream bug
// or a client trying to inflate 2641 debit beyond the statutory ceiling.
return item.vat_amount <= maxVat + 0.01
},
{
message: 'vat_amount cannot exceed line_total × vat_rate',
path: ['vat_amount'],
},
)
export const CreateSupplierInvoiceSchema = z.object({
supplier_id: uuid,
@@ -87,6 +87,13 @@ const {
} = await import('../supplier-invoice-entries')
function makeItem(overrides: Partial<SupplierInvoiceItem> = {}): SupplierInvoiceItem {
// Mirror the API: vat_amount derives from line_total × vat_rate unless the
// test overrides it explicitly (manual-override cases). This keeps multi-
// item and mixed-rate fixtures self-consistent with the engine, which now
// reads stored vat_amount directly rather than recomputing from line_total.
const lineTotal = overrides.line_total ?? 8000
const vatRate = overrides.vat_rate ?? 0.25
const vatAmount = overrides.vat_amount ?? Math.round(lineTotal * vatRate * 100) / 100
return {
id: 'si-item-1',
supplier_invoice_id: 'si-1',
@@ -94,12 +101,12 @@ function makeItem(overrides: Partial<SupplierInvoiceItem> = {}): SupplierInvoice
description: 'Consulting services',
quantity: 1,
unit: 'st',
unit_price: 8000,
line_total: 8000,
unit_price: lineTotal,
line_total: lineTotal,
account_number: '6200',
vat_code: null,
vat_rate: 0.25,
vat_amount: 2000,
vat_rate: vatRate,
vat_amount: vatAmount,
created_at: '2024-06-01T00:00:00Z',
...overrides,
}
@@ -170,6 +177,128 @@ describe('createSupplierInvoiceRegistrationEntry', () => {
assertBalanced(input)
})
it('books manual VAT override (bilförmån 50%) instead of recomputing from rate', async () => {
// Personbilsleasing: leverantören fakturerar 25% moms (2 500 kr), men
// endast 50% (1 250 kr) är avdragsgill enligt ML 8 kap 16§. Användaren
// anger 1 250 kr manuellt. Det resterande beloppet förblir på
// kostnadskontot (10 000 + 1 250 ej avdragsgill moms = 11 250 brutto-
// kostnad om användaren även justerar line_total; här testar vi enbart
// att momsöverskridningen genererar rätt 2641-belopp).
const invoice = makeSupplierInvoice({
subtotal: 10000,
vat_amount: 1250,
total: 11250,
})
const items = [
makeItem({
line_total: 10000,
account_number: '5615', // Leasing av personbilar
vat_rate: 0.25,
vat_amount: 1250, // manual override (50% av 2 500)
}),
]
await createSupplierInvoiceRegistrationEntry(
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
const debit5615 = findByAccount(input.lines, '5615')
expect(debit5615[0].debit_amount).toBe(10000)
const debit2641 = findByAccount(input.lines, '2641')
expect(debit2641).toHaveLength(1)
// Avgörande: 1 250 (manual) — INTE 2 500 (10 000 × 0.25).
expect(debit2641[0].debit_amount).toBe(1250)
const credit2440 = findByAccount(input.lines, '2440')
expect(credit2440[0].credit_amount).toBe(11250)
assertBalanced(input)
})
it('recomputes 2641 from line_total × rate when stored vat_amount is 0 (legacy/import path)', async () => {
// Schema default is vat_amount=0; SIE imports and demo seeders sometimes
// leave it that way. Silently posting 0 to 2641 would understate ruta 48
// in the momsdeklaration. The engine recovers by recomputing from the
// base when the stored amount is missing.
const invoice = makeSupplierInvoice({
subtotal: 10000,
vat_amount: 2500,
total: 12500,
})
const items = [
makeItem({ line_total: 10000, account_number: '5410', vat_rate: 0.25, vat_amount: 0 }),
]
await createSupplierInvoiceRegistrationEntry(
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
const debit2641 = findByAccount(input.lines, '2641')
expect(debit2641).toHaveLength(1)
expect(debit2641[0].debit_amount).toBe(2500)
assertBalanced(input)
})
it('aggregates manual VAT overrides per rate group on mixed-rate invoice', async () => {
// Restaurangkvitto med två olika momsöverskridningar pga representation-
// tak och egen avrundning. 25%-raden får manuell 100 kr, 12%-raden får
// manuell 50 kr.
const invoice = makeSupplierInvoice({
subtotal: 1000,
vat_amount: 150,
total: 1150,
})
const items = [
makeItem({ id: 'item-1', line_total: 400, account_number: '6071', vat_rate: 0.25, vat_amount: 100 }),
makeItem({ id: 'item-2', line_total: 600, account_number: '6071', vat_rate: 0.12, vat_amount: 50 }),
]
await createSupplierInvoiceRegistrationEntry(
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
const vat2641 = findByAccount(input.lines, '2641')
expect(vat2641).toHaveLength(2)
expect(vat2641.find((l) => l.line_description?.includes('25%'))?.debit_amount).toBe(100)
expect(vat2641.find((l) => l.line_description?.includes('12%'))?.debit_amount).toBe(50)
assertBalanced(input)
})
it('reverse charge ignores manual vat_amount and uses statutory base × rate', async () => {
// RC: fiktiv moms beräknas alltid på basbeloppet med lagstadgad sats —
// ett manuellt vat_amount på posten är meningslöst (köparen redovisar
// själv) och får inte påverka 2645/2614.
const invoice = makeSupplierInvoice({
subtotal: 10000,
vat_amount: 0,
total: 10000,
reverse_charge: true,
})
const items = [
makeItem({
line_total: 10000,
account_number: '6540',
vat_rate: 0.25,
vat_amount: 999, // ska ignoreras
}),
]
await createSupplierInvoiceRegistrationEntry(
null as never, 'company-1', 'user-1', invoice, items, 'eu_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '2645')[0].debit_amount).toBe(2500)
expect(findByAccount(input.lines, '2614')[0].credit_amount).toBe(2500)
assertBalanced(input)
})
it('creates domestic entry with zero VAT (no 2641 line)', async () => {
const invoice = makeSupplierInvoice({
subtotal: 5000,
+30 -1
View File
@@ -15,6 +15,7 @@ export const ENTRY_ALREADY_REVERSED = 'ENTRY_ALREADY_REVERSED' as const
export const CURRENCY_REVALUATION_ALREADY_EXISTS = 'CURRENCY_REVALUATION_ALREADY_EXISTS' as const
export const INVALID_MAPPING_RESULT = 'INVALID_MAPPING_RESULT' as const
export const BOOKKEEPING_DATABASE_ERROR = 'BOOKKEEPING_DATABASE_ERROR' as const
export const MEANINGLESS_CORRECTION = 'MEANINGLESS_CORRECTION' as const
// ============================================================================
// AccountsNotInChartError — kept for back-compat (many existing call sites)
@@ -138,6 +139,20 @@ export class CurrencyRevaluationAlreadyExistsError extends Error {
}
}
export type MeaninglessCorrectionReason = 'net_zero_per_account' | 'identical_to_original'
export class MeaninglessCorrectionError extends Error {
readonly code = MEANINGLESS_CORRECTION
constructor(public readonly reason: MeaninglessCorrectionReason) {
super(
reason === 'net_zero_per_account'
? 'Correction lines net to zero on every account — no economic event represented (BFL 5 kap. 5 §).'
: 'Correction lines are identical to the original entry — nothing to correct.'
)
this.name = 'MeaninglessCorrectionError'
}
}
export class InvalidMappingResultError extends Error {
readonly code = INVALID_MAPPING_RESULT
constructor(
@@ -206,7 +221,8 @@ export function isBookkeepingError(err: unknown): boolean {
err instanceof EntryAlreadyReversedError ||
err instanceof CurrencyRevaluationAlreadyExistsError ||
err instanceof InvalidMappingResultError ||
err instanceof BookkeepingDatabaseError
err instanceof BookkeepingDatabaseError ||
err instanceof MeaninglessCorrectionError
)
}
@@ -356,6 +372,19 @@ export function bookkeepingErrorResponse(err: unknown): NextResponse | null {
)
}
if (err instanceof MeaninglessCorrectionError) {
return NextResponse.json(
{
error: {
code: err.code,
message: err.message,
details: { reason: err.reason },
},
},
{ status: 400 }
)
}
if (err instanceof BookkeepingDatabaseError) {
return NextResponse.json(
{
+64 -19
View File
@@ -125,12 +125,16 @@ export async function createSupplierInvoiceRegistrationEntry(
// went to NON-basis accounts. Mixed invoices (4535 + 6540 at 25%) used to
// skip basis lines entirely under a per-invoice flag, leaving ruta 30
// larger than ruta 21 by the 6540 portion — the exact FK004 pattern.
const vatByRate = groupVatByRate(items, invoice.currency, invoice.exchange_rate)
//
// Drive iteration off the basis (line_total per rate), not stored
// vat_amount — fiktiv moms is always statutory base × rate. This keeps
// RC immune to per-line manual VAT overrides (which only make sense for
// domestic deductible-VAT adjustments).
const baseByRate = groupBaseByRate(items, invoice.currency, invoice.exchange_rate)
const nonBasisBaseByRate = groupNonBasisBaseByRate(items, invoice.currency, invoice.exchange_rate)
const rcSupplierType = supplierType as 'eu_business' | 'non_eu_business' | 'swedish_business'
for (const [rate, amount] of vatByRate) {
if (rate > 0 && amount > 0) {
const baseAmount = amount / rate
for (const [rate, baseAmount] of baseByRate) {
if (rate > 0 && baseAmount > 0) {
const rcLines = generateReverseChargeLines(baseAmount, rate, isDomesticRC)
lines.push(...rcLines)
const nonBasisBase = nonBasisBaseByRate.get(rate) || 0
@@ -335,12 +339,13 @@ export async function createSupplierInvoiceCashEntry(
// but ruta 20-24 stay at 0, which Skatteverket rejects with felkod
// FK004 ("silent netting prohibited"; ML 13 kap kräver båda sidor).
// Per-rate bucketing: see registration entry above for the FK004 rationale.
const vatByRate = groupVatByRate(items, invoice.currency, invoice.exchange_rate)
// Drive iteration off the basis (line_total per rate) — fiktiv moms is
// always statutory base × rate; manual vat_amount overrides don't apply.
const baseByRate = groupBaseByRate(items, invoice.currency, invoice.exchange_rate)
const nonBasisBaseByRate = groupNonBasisBaseByRate(items, invoice.currency, invoice.exchange_rate)
const rcSupplierType = supplierType as 'eu_business' | 'non_eu_business' | 'swedish_business'
for (const [rate, amount] of vatByRate) {
if (rate > 0 && amount > 0) {
const baseAmount = amount / rate
for (const [rate, baseAmount] of baseByRate) {
if (rate > 0 && baseAmount > 0) {
const rcLines = generateReverseChargeLines(baseAmount, rate, isDomesticRC)
lines.push(...rcLines)
const nonBasisBase = nonBasisBaseByRate.get(rate) || 0
@@ -523,16 +528,18 @@ export async function createSupplierCreditNoteEntry(
if (isReverseCharge) {
// Reverse the fiktiv moms per rate group (swap debit/credit from registration)
// Input VAT account: 2647 for domestic RC, 2645 for EU/non-EU
// Drive iteration off the basis — fiktiv moms is always statutory base × rate.
const inputAccount = isDomesticRC ? '2647' : '2645'
const vatByRate = groupVatByRate(items, creditNote.currency, creditNote.exchange_rate, true)
const baseByRate = groupBaseByRate(items, creditNote.currency, creditNote.exchange_rate, true)
const nonBasisBaseByRate = groupNonBasisBaseByRate(items, creditNote.currency, creditNote.exchange_rate, true)
const rcSupplierType = supplierType as 'eu_business' | 'non_eu_business' | 'swedish_business'
// Only reverse basbeloppsraderna for the portion the registration would
// have emitted them — namely the non-basis-account base per rate. Items
// booked directly to 44xx/45xx had no parallel basis lines in registration
// and so are reversed only via the expense credit line above.
for (const [rate, amount] of vatByRate) {
if (rate > 0 && amount > 0) {
for (const [rate, baseAmount] of baseByRate) {
if (rate > 0 && baseAmount > 0) {
const fiktivVat = Math.round(baseAmount * rate * 100) / 100
// Determine the output account for this rate
let outputAccount: string
switch (rate) {
@@ -543,12 +550,12 @@ export async function createSupplierCreditNoteEntry(
creditLines.push({
account_number: inputAccount,
debit_amount: 0,
credit_amount: amount,
credit_amount: fiktivVat,
line_description: `Omvänd fiktiv ingående moms ${Math.round(rate * 100)}% ${desc}`,
})
lines.push({
account_number: outputAccount,
debit_amount: amount,
debit_amount: fiktivVat,
credit_amount: 0,
line_description: `Omvänd fiktiv utgående moms ${Math.round(rate * 100)}% ${desc}`,
})
@@ -613,8 +620,21 @@ export async function createSupplierCreditNoteEntry(
}
/**
* Group items by VAT rate and sum the VAT amount per rate.
* Returns a Map<rate, totalVatAmount> for generating per-rate journal lines.
* Group items by VAT rate and sum the stored VAT amount per rate.
* Returns a Map<rate, totalVatAmount> in SEK for per-rate 2641 journal lines.
*
* Reads `item.vat_amount` directly — set by the API from the line's manual
* override when present, else computed line_total × rate. This is the path
* for partial-deductible cases (bilförmån 50%, representation 300 kr-tak),
* foreign-currency rounding, and supplier POS rounding.
*
* Fallback to line_total × rate when vat_amount is null/0 but rate > 0 —
* legacy import paths (SIE, CSV, demo seed) sometimes leave vat_amount at
* the column DEFAULT of 0. Silently dropping input VAT to 2641 would
* understate ruta 48 in the momsdeklaration.
*
* Reverse-charge fiktiv moms doesn't use this — see groupBaseByRate, which
* derives the basis directly so fiktiv VAT is always base × statutory rate.
*/
function groupVatByRate(
items: SupplierInvoiceItem[],
@@ -625,14 +645,39 @@ function groupVatByRate(
const vatByRate = new Map<number, number>()
for (const item of items) {
const rate = item.vat_rate ?? 0.25
let itemSek = resolveSekAmount(item.line_total, null, currency, exchangeRate)
if (useAbsoluteValues) itemSek = Math.abs(itemSek)
const itemVat = Math.round(itemSek * rate * 100) / 100
vatByRate.set(rate, (vatByRate.get(rate) || 0) + itemVat)
const storedVat = item.vat_amount ?? 0
const computedVat = rate > 0
? Math.round((item.line_total ?? 0) * rate * 100) / 100
: 0
const sourceVat = storedVat > 0 ? storedVat : computedVat
let vatSek = resolveSekAmount(sourceVat, null, currency, exchangeRate)
if (useAbsoluteValues) vatSek = Math.abs(vatSek)
vatByRate.set(rate, (vatByRate.get(rate) || 0) + vatSek)
}
return vatByRate
}
/**
* Group items by VAT rate and sum the base (line_total) per rate.
* Used by reverse-charge paths to compute fiktiv moms from the basis,
* decoupled from any manual VAT override on the items themselves.
*/
function groupBaseByRate(
items: SupplierInvoiceItem[],
currency: string,
exchangeRate: number | null,
useAbsoluteValues = false
): Map<number, number> {
const baseByRate = new Map<number, number>()
for (const item of items) {
const rate = item.vat_rate ?? 0.25
let baseSek = resolveSekAmount(item.line_total, null, currency, exchangeRate)
if (useAbsoluteValues) baseSek = Math.abs(baseSek)
baseByRate.set(rate, (baseByRate.get(rate) || 0) + baseSek)
}
return baseByRate
}
/**
* Sum, per VAT rate, the base (line_total in SEK) of items booked to
* non-basis expense accounts. Items already booked to a 44xx/45xx basis
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { eventBus } from '@/lib/events/bus'
import { makeJournalEntry, makeJournalEntryLine } from '@/tests/helpers'
import { BookkeepingDatabaseError } from '@/lib/bookkeeping/errors'
import { BookkeepingDatabaseError, MeaninglessCorrectionError } from '@/lib/bookkeeping/errors'
// ============================================================
// Mock — separate client (no .then) from query builder (thenable)
@@ -212,6 +212,70 @@ describe('correctEntry', () => {
expect(journalEntryInserts[1]).toMatchObject({ source_type: 'correction', entry_date: '2024-06-15' })
})
it('rejects rättelse where every account nets to zero (1930 → 1930)', async () => {
const supabase = makeClient()
const noOpLines = [
{ account_number: '1930', debit_amount: 100, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 100 },
]
await expect(
correctEntry(supabase as never, 'company-1', 'user-1', 'orig-1', noOpLines)
).rejects.toBeInstanceOf(MeaninglessCorrectionError)
// Guard runs before any DB call — original must not be fetched.
expect(supabase.from).not.toHaveBeenCalled()
})
it('rejects rättelse where multiple accounts each net to zero', async () => {
const supabase = makeClient()
const noOpLines = [
{ account_number: '1930', debit_amount: 100, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 100 },
{ account_number: '5410', debit_amount: 50, credit_amount: 0 },
{ account_number: '5410', debit_amount: 0, credit_amount: 50 },
]
await expect(
correctEntry(supabase as never, 'company-1', 'user-1', 'orig-1', noOpLines)
).rejects.toMatchObject({
code: 'MEANINGLESS_CORRECTION',
reason: 'net_zero_per_account',
})
})
it('rejects rättelse identical to the original entry', async () => {
const supabase = makeClient()
// Only the fetch-original result is needed — guard runs right after.
results = [{ data: originalEntry, error: null }]
const identicalLines = [
{ account_number: '5410', debit_amount: 1000, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 1000 },
]
await expect(
correctEntry(supabase as never, 'company-1', 'user-1', 'orig-1', identicalLines)
).rejects.toMatchObject({
code: 'MEANINGLESS_CORRECTION',
reason: 'identical_to_original',
})
})
it('allows rättelse that shifts amounts between different accounts', async () => {
setupResults()
const supabase = makeClient()
// correctedLines moves expense from 5410 → 5420 — net effect per account
// is non-zero (5420 +1200, 5410 0 since absent, 1930 -1200), and the lines
// differ from the original, so both guards must pass.
const result = await correctEntry(
supabase as never,
'company-1',
'user-1',
'orig-1',
correctedLines
)
expect(result.corrected).toBeDefined()
})
it('emits journal_entry.corrected event', async () => {
setupResults()
+56
View File
@@ -13,8 +13,51 @@ import {
EntryAlreadyReversedError,
JournalEntryNotBalancedError,
JournalEntryNotFoundError,
MeaninglessCorrectionError,
} from '@/lib/bookkeeping/errors'
/**
* Round to 2dp using cents-integer math to avoid 0.1+0.2 drift.
*/
function round2(n: number): number {
return Math.round(n * 100) / 100
}
/**
* True when every account's (debit − credit) sum across the proposed lines is
* zero. Such a rättelse describes no real affärshändelse and would erase the
* original posting without representing anything in its place — disallowed by
* BFL 5 kap. 5 § / BFNAR 2013:2.
*/
function netsToZeroPerAccount(lines: CreateJournalEntryLineInput[]): boolean {
const nets = new Map<string, number>()
for (const line of lines) {
const delta = round2(line.debit_amount || 0) - round2(line.credit_amount || 0)
nets.set(line.account_number, (nets.get(line.account_number) || 0) + delta)
}
return Array.from(nets.values()).every((n) => Math.abs(n) < 0.005)
}
/**
* True when proposed lines are the same multiset as the original lines
* (account_number + debit + credit). A rättelse must actually change something.
*/
function isIdenticalToOriginal(
proposed: CreateJournalEntryLineInput[],
original: JournalEntryLine[]
): boolean {
if (proposed.length !== original.length) return false
const key = (acc: string, d: number, c: number) =>
`${acc}|${round2(d).toFixed(2)}|${round2(c).toFixed(2)}`
const proposedKeys = proposed
.map((l) => key(l.account_number, l.debit_amount || 0, l.credit_amount || 0))
.sort()
const originalKeys = original
.map((l) => key(l.account_number, Number(l.debit_amount) || 0, Number(l.credit_amount) || 0))
.sort()
return proposedKeys.every((k, i) => k === originalKeys[i])
}
/**
* Storno Service - 3-step correction flow per Bokföringslagen
*
@@ -65,6 +108,13 @@ export async function correctEntry(
throw new JournalEntryNotBalancedError(balance.totalDebit, balance.totalCredit, 'correction')
}
// Reject a rättelse with no economic effect (e.g. 1930 debit 100 / 1930
// credit 100). Such an entry would erase the original posting without
// representing any affärshändelse — disallowed by BFL 5 kap. 5 §.
if (netsToZeroPerAccount(correctedLines)) {
throw new MeaninglessCorrectionError('net_zero_per_account')
}
// Fetch original entry with lines
const { data: original, error: fetchError } = await supabase
.from('journal_entries')
@@ -83,6 +133,12 @@ export async function correctEntry(
const originalLines = (original.lines as JournalEntryLine[]) || []
// Reject when the proposed lines are identical to the original entry —
// a rättelse must actually change something.
if (isIdenticalToOriginal(correctedLines, originalLines)) {
throw new MeaninglessCorrectionError('identical_to_original')
}
// ===== Step 1: Create storno (reversal) entry =====
const reversalVoucherNumber = await getNextVoucherNumber(
supabase,
+8
View File
@@ -324,6 +324,14 @@ export function getErrorMessage(
return 'Kontering saknas för transaktionen. Kontrollera bokföringsreglerna.'
}
if (structured.code === 'MEANINGLESS_CORRECTION') {
const details = structured.details as { reason?: string } | undefined
if (details?.reason === 'identical_to_original') {
return 'Rättelsen är identisk med originalverifikationen — inget har ändrats.'
}
return 'Rättelsen saknar ekonomisk innebörd: varje konto netto till noll. En rättelse måste beskriva en faktisk affärshändelse (BFL 5 kap. 5 §).'
}
if (structured.code === 'BOOKKEEPING_DATABASE_ERROR') {
// A DB-layer error may carry a user-relevant cause (e.g. period lock
// trigger). Try the known-pattern map before falling back to the
+12 -37
View File
@@ -67,27 +67,14 @@ describe('getAvailableVatRates', () => {
expect(rates).toHaveLength(4)
})
it('collapses to 0%/exempt for non-registered sellers regardless of customer type', () => {
// ML 1 kap. 1§ — only a skattskyldig may charge output VAT. The picker
// must never offer non-zero rates when vat_registered=false; the API
// route (route.ts) and preview-pdf route enforce the same gate so a
// client bypassing the UI also gets rejected.
for (const customerType of ['individual', 'swedish_business', 'eu_business', 'non_eu_business'] as const) {
const rates = getAvailableVatRates(customerType, false, false)
expect(rates).toHaveLength(1)
expect(rates[0]).toEqual({
rate: 0,
label: '0% (ej momsregistrerad)',
treatment: 'exempt',
})
}
})
it('defaults vatRegistered to true (legitimate VAT path)', () => {
// Explicit default-arg pin so a future signature change doesn't silently
// flip the default and strip VAT from invoices for registered sellers.
const rates = getAvailableVatRates('swedish_business', false)
it('does not gate on seller VAT-registration status', () => {
// ML 16 kap. 23 § (faktureringsmoms): the picker offers the full
// customer-type-based rate set regardless of whether the seller is
// momsregistrerad. The invoice form surfaces a warning at submit time
// when a non-registered seller picks a non-zero rate.
const rates = getAvailableVatRates('swedish_business')
expect(rates).toHaveLength(4)
expect(rates.map((r) => r.rate)).toEqual([25, 12, 6, 0])
})
})
@@ -179,23 +166,11 @@ describe('getVatRules', () => {
})
})
it('short-circuits to exempt / rate 0 / empty momsRuta for non-registered sellers', () => {
// ML 1 kap. 1§ — non-skattskyldig cannot charge output VAT. momsRuta is
// intentionally empty so a downstream momsdeklaration generator never
// mis-files a non-registered seller's revenue into ruta 05.
for (const customerType of ['individual', 'swedish_business', 'eu_business', 'non_eu_business'] as const) {
const rules = getVatRules(customerType, false, false)
expect(rules).toEqual({
treatment: 'exempt',
rate: 0,
momsRuta: '',
})
}
})
it('defaults vatRegistered to true (legitimate VAT path)', () => {
// Explicit default-arg pin (see corresponding getAvailableVatRates test).
const rules = getVatRules('swedish_business', false)
it('does not gate on seller VAT-registration status', () => {
// ML 16 kap. 23 § (faktureringsmoms): a non-registered seller who states
// VAT still owes it. The rule output reflects the customer-type rate so
// the booking is consistent with what the buyer sees on the invoice.
const rules = getVatRules('swedish_business')
expect(rules.rate).toBe(25)
expect(rules.treatment).toBe('standard_25')
expect(rules.momsRuta).toBe('05')
+13 -17
View File
@@ -868,12 +868,12 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
</View>
))
) : (
// Suppress the "Moms 0%" row entirely when the seller is not
// VAT-registered. ML 1 kap. 1§ — a non-skattskyldig may not
// charge output VAT, so a "Moms 0%" line would imply VAT
// accounting that doesn't exist. The notice block below the
// payment section explains the absence of VAT.
company.vat_registered !== false && (
// Suppress the "Moms 0%" row only when the seller is not
// VAT-registered AND the invoice actually carries no VAT.
// A non-registered seller who states VAT (warned at create time
// per ML 16 kap. 23 §) still gets the totals row so the printed
// invoice matches what the customer is being asked to pay.
!(company.vat_registered === false && invoice.vat_amount === 0) && (
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>{L.vatRow(invoice.vat_rate ?? (vatByRate.size === 1 ? (vatByRate.keys().next().value ?? 0) : 0))}</Text>
<Text style={styles.totalValue}>{formatCurrency(invoice.vat_amount, invoice.currency, lang)}</Text>
@@ -1063,17 +1063,13 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
)}
{/* Reverse charge / export / exempt / not-registered notice.
"Not VAT-registered" trumps the others — when the seller is
outside the VAT system entirely (vat_registered=false in
company_settings), reverse-charge and ML 3 kap. exempt notices
don't apply, and a single dedicated notice is clearer for the
customer than reusing the exempt notice (which implies the sale
specifically is exempt while the seller is otherwise within the
VAT system). Server-side enforcement (api/invoices/route.ts +
preview-pdf/route.ts) coerces vat_amount to 0 for non-registered
sellers, so this branch always lines up with what's in the
totals block. */}
{company.vat_registered === false ? (
"Not VAT-registered" trumps the others ONLY when the invoice
actually carries no VAT — a non-registered seller who chose to
state VAT on the invoice (warned at create time per ML 16 kap.
23 §) gets the normal reverse-charge / exempt notices instead,
since the "ej momsregistrerad" line would contradict the VAT
shown in the totals block. */}
{company.vat_registered === false && invoice.vat_amount === 0 ? (
<View style={styles.reverseChargeBox}>
<Text style={styles.reverseChargeText}>{L.notVatRegisteredNotice}</Text>
</View>
+8 -25
View File
@@ -12,22 +12,16 @@ export interface VatRateOption {
* Swedish/EU-unvalidated customers can choose between 25%, 12%, 6%, and 0% (exempt).
* Reverse charge and export customers are locked to 0%.
*
* When the seller is not VAT-registered (`vatRegistered=false`), every customer
* type collapses to a single 0% / exempt option. ML 1 kap. 1§ — only a
* skattskyldig person may charge VAT, so the picker must never offer non-zero
* rates in that mode. ML 16 kap. 23 § (faktureringsmoms) imposes liability for
* VAT erroneously stated on a document, but does NOT grant the right to charge
* it — so we block at source rather than allow + warn.
* The picker does NOT gate on the seller's VAT registration status. A
* non-momsregistrerad seller is shown the same options as a registered one —
* the form surfaces a warning at submit time (ML 16 kap. 23 § faktureringsmoms:
* stated VAT is owed even by non-registered sellers, but the buyer cannot
* deduct it as input VAT).
*/
export function getAvailableVatRates(
customerType: CustomerType,
vatNumberValidated: boolean = false,
vatRegistered: boolean = true,
): VatRateOption[] {
if (!vatRegistered) {
return [{ rate: 0, label: '0% (ej momsregistrerad)', treatment: 'exempt' }]
}
// EU business with validated VAT → reverse charge, locked to 0%
if (customerType === 'eu_business' && vatNumberValidated) {
return [{ rate: 0, label: '0% (omvänd skattskyldighet)', treatment: 'reverse_charge' }]
@@ -81,25 +75,14 @@ export interface VatRule {
* - EU business without validated VAT: 25% VAT, moms ruta 05
* - Non-EU business: 0% export, moms ruta 40
*
* When the seller is not VAT-registered (`vatRegistered=false`), the rules
* short-circuit to `{ treatment: 'exempt', rate: 0, momsRuta: '' }` regardless
* of customer type — ML 1 kap. 1§ bars a non-skattskyldig from charging output
* VAT. `momsRuta` is empty so a downstream momsdeklaration generator never
* mis-files a non-registered seller's "sales" into ruta 05.
* Independent of the seller's VAT registration status. A non-momsregistrerad
* seller who charges VAT still owes it under ML 16 kap. 23 § (faktureringsmoms),
* so the rule output must reflect the rate actually charged on the line.
*/
export function getVatRules(
customerType: CustomerType,
vatNumberValidated: boolean = false,
vatRegistered: boolean = true,
): VatRule {
if (!vatRegistered) {
return {
treatment: 'exempt',
rate: 0,
momsRuta: '',
}
}
switch (customerType) {
case 'individual':
case 'swedish_business':
-13
View File
@@ -1,13 +0,0 @@
export function clearRecaptIdentity(): void {
if (typeof window === 'undefined') return
if (typeof window.recapt !== 'function') return
try {
window.recapt('identify', {
uid: undefined,
email: undefined,
nickname: undefined,
})
} catch {
// best-effort — we're already in a logout flow
}
}
+60
View File
@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest'
import { parseReportDateRange } from '../date-range'
const period = { period_start: '2026-01-01', period_end: '2026-12-31' }
function paramsOf(obj: Record<string, string>): URLSearchParams {
return new URLSearchParams(obj)
}
describe('parseReportDateRange', () => {
it('returns an empty range when no params are provided', () => {
const result = parseReportDateRange(paramsOf({}), period)
expect(result.ok).toBe(true)
if (result.ok) expect(result.range).toEqual({})
})
it('accepts valid in-period dates', () => {
const result = parseReportDateRange(
paramsOf({ from_date: '2026-03-01', to_date: '2026-05-31' }),
period,
)
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.range.fromDate).toBe('2026-03-01')
expect(result.range.toDate).toBe('2026-05-31')
}
})
it('rejects malformed from_date', () => {
const result = parseReportDateRange(paramsOf({ from_date: '2026/03/01' }), period)
expect(result.ok).toBe(false)
})
it('rejects from_date before period start', () => {
const result = parseReportDateRange(paramsOf({ from_date: '2025-12-31' }), period)
expect(result.ok).toBe(false)
})
it('rejects to_date after period end', () => {
const result = parseReportDateRange(paramsOf({ to_date: '2027-01-01' }), period)
expect(result.ok).toBe(false)
})
it('rejects reversed range', () => {
const result = parseReportDateRange(
paramsOf({ from_date: '2026-06-01', to_date: '2026-05-01' }),
period,
)
expect(result.ok).toBe(false)
})
it('accepts a single boundary date (only from_date)', () => {
const result = parseReportDateRange(paramsOf({ from_date: '2026-06-01' }), period)
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.range.fromDate).toBe('2026-06-01')
expect(result.range.toDate).toBeUndefined()
}
})
})
+183 -1
View File
@@ -11,7 +11,7 @@ let mockResults: Record<string, MockResult[]>
function makeBuilder(tableName: string) {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'lt', 'neq', 'range']) {
for (const m of ['select', 'eq', 'in', 'lt', 'lte', 'gte', 'neq', 'range']) {
b[m] = vi.fn().mockReturnValue(b)
}
const consume = (): MockResult => {
@@ -389,4 +389,186 @@ describe('generateTrialBalance', () => {
expect(result.totalCredit).toBe(5000)
expect(result.isBalanced).toBe(true)
})
// ── Date-range tests ─────────────────────────────────────────────
// The 4 reports (resultatrapport/balansrapport/income-statement/balance-
// sheet) thread an optional { fromDate, toDate } through to the trial
// balance. The engine must (a) skip the roll-forward query when fromDate
// equals period_start, (b) roll prior in-period lines into IB when
// fromDate is later, and (c) clamp period activity to the window.
it('treats omitted range as parity with the full period', async () => {
mockResults = {
fiscal_periods: [
{
data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null },
error: null,
},
],
journal_entry_lines: [
{
data: [
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 1000 },
],
error: null,
},
],
chart_of_accounts: [
{
data: [
{ account_number: '1930', account_name: 'Bank', account_class: 1 },
{ account_number: '3001', account_name: 'Revenue', account_class: 3 },
],
error: null,
},
],
}
const result = await generateTrialBalance(supabase, 'company-1', 'period-1')
// Same as the existing "balanced two-account" case — no roll-forward query
// is consumed because no range is requested.
expect(result.rows).toHaveLength(2)
expect(result.totalDebit).toBe(1000)
expect(result.totalCredit).toBe(1000)
expect(result.isBalanced).toBe(true)
})
it('skips the roll-forward query when fromDate equals period_start', async () => {
mockResults = {
fiscal_periods: [
{
data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null },
error: null,
},
],
journal_entry_lines: [
// Only the period query — no roll-forward fetch should be triggered.
{
data: [
{ account_number: '1930', debit_amount: 500, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 500 },
],
error: null,
},
],
chart_of_accounts: [
{
data: [
{ account_number: '1930', account_name: 'Bank', account_class: 1 },
{ account_number: '3001', account_name: 'Revenue', account_class: 3 },
],
error: null,
},
],
}
const result = await generateTrialBalance(supabase, 'company-1', 'period-1', {
fromDate: '2024-01-01',
toDate: '2024-06-30',
})
expect(result.rows[0].opening_debit).toBe(0)
expect(result.rows[0].closing_debit).toBe(500)
})
it('rolls prior in-period lines into IB when fromDate is after period_start', async () => {
mockResults = {
fiscal_periods: [
{
data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null },
error: null,
},
],
journal_entry_lines: [
// 1st consumption — roll-forward query for [2024-01-01, 2024-04-01).
{
data: [
{ account_number: '1930', debit_amount: 2000, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 2000 },
],
error: null,
},
// 2nd consumption — period activity for [2024-04-01, 2024-06-30].
{
data: [
{ account_number: '1930', debit_amount: 500, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 500 },
],
error: null,
},
],
chart_of_accounts: [
{
data: [
{ account_number: '1930', account_name: 'Bank', account_class: 1 },
{ account_number: '3001', account_name: 'Revenue', account_class: 3 },
],
error: null,
},
],
}
const result = await generateTrialBalance(supabase, 'company-1', 'period-1', {
fromDate: '2024-04-01',
toDate: '2024-06-30',
})
// 1930: IB carries 2000 from Q1, period adds 500 → UB 2500
const acc1930 = result.rows.find((r) => r.account_number === '1930')!
expect(acc1930.opening_debit).toBe(2000)
expect(acc1930.period_debit).toBe(500)
expect(acc1930.closing_debit).toBe(2500)
// 3001: IB carries 2000 from Q1, period adds 500 → UB 2500
const acc3001 = result.rows.find((r) => r.account_number === '3001')!
expect(acc3001.opening_credit).toBe(2000)
expect(acc3001.period_credit).toBe(500)
expect(acc3001.closing_credit).toBe(2500)
expect(result.isBalanced).toBe(true)
})
it('returns empty period activity when the range matches no lines', async () => {
mockResults = {
fiscal_periods: [
{
data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null },
error: null,
},
],
journal_entry_lines: [
// Roll-forward query — has prior activity
{
data: [
{ account_number: '1930', debit_amount: 750, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 750 },
],
error: null,
},
// Period query — no lines inside [2024-11-01, 2024-11-30]
{ data: [], error: null },
],
chart_of_accounts: [
{
data: [
{ account_number: '1930', account_name: 'Bank', account_class: 1 },
{ account_number: '3001', account_name: 'Revenue', account_class: 3 },
],
error: null,
},
],
}
const result = await generateTrialBalance(supabase, 'company-1', 'period-1', {
fromDate: '2024-11-01',
toDate: '2024-11-30',
})
const acc1930 = result.rows.find((r) => r.account_number === '1930')!
expect(acc1930.opening_debit).toBe(750)
expect(acc1930.period_debit).toBe(0)
expect(acc1930.closing_debit).toBe(750)
})
})
+6 -2
View File
@@ -12,9 +12,13 @@ import type { BalanceSheetReport, BalanceSheetSection, TrialBalanceRow } from '@
export async function generateBalanceSheet(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string
fiscalPeriodId: string,
options?: { fromDate?: string; toDate?: string }
): Promise<BalanceSheetReport> {
const { rows } = await generateTrialBalance(supabase, companyId, fiscalPeriodId)
const { rows } = await generateTrialBalance(supabase, companyId, fiscalPeriodId, {
fromDate: options?.fromDate,
toDate: options?.toDate,
})
// Filter to balance sheet accounts (class 1-2)
const balanceRows = rows.filter(
+10 -3
View File
@@ -27,7 +27,8 @@ const CLASS_LABELS: Record<number, string> = {
export async function generateBalansrapport(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string
fiscalPeriodId: string,
options?: { fromDate?: string; toDate?: string }
): Promise<BalansrapportReport> {
const { data: period } = await supabase
.from('fiscal_periods')
@@ -40,7 +41,13 @@ export async function generateBalansrapport(
throw new Error('Fiscal period not found')
}
const trialBalance = await generateTrialBalance(supabase, companyId, fiscalPeriodId)
const effectiveFromDate = options?.fromDate ?? period.period_start
const effectiveToDate = options?.toDate ?? period.period_end
const trialBalance = await generateTrialBalance(supabase, companyId, fiscalPeriodId, {
fromDate: options?.fromDate,
toDate: options?.toDate,
})
const balanceRows = trialBalance.rows.filter((r) => r.account_class === 1 || r.account_class === 2)
const groups: BalansrapportGroup[] = []
@@ -95,7 +102,7 @@ export async function generateBalansrapport(
total_equity_liabilities_ub: totalEquityLiabilitiesUb,
beraknat_resultat: beraknatResultat,
is_balanced: trialBalance.isBalanced,
period: { start: period.period_start, end: period.period_end },
period: { start: effectiveFromDate, end: effectiveToDate },
}
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Parse and validate optional `from_date` / `to_date` query params for the
* date-range-aware financial reports (resultat- and balansrapport).
*
* Returns the bounds clamped against the fiscal period. Both params are
* optional — when omitted, the report falls back to the period as a whole.
* Returns a `{ error }` shape on invalid input so callers can map it to a
* 400 response without each route duplicating the same checks.
*/
export type DateRange = { fromDate?: string; toDate?: string }
export type DateRangeResult =
| { ok: true; range: DateRange }
| { ok: false; error: string }
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
export function parseReportDateRange(
searchParams: URLSearchParams,
period: { period_start: string; period_end: string }
): DateRangeResult {
const rawFrom = searchParams.get('from_date')
const rawTo = searchParams.get('to_date')
if (rawFrom && !ISO_DATE.test(rawFrom)) {
return { ok: false, error: 'from_date måste vara på formen YYYY-MM-DD.' }
}
if (rawTo && !ISO_DATE.test(rawTo)) {
return { ok: false, error: 'to_date måste vara på formen YYYY-MM-DD.' }
}
const fromDate = rawFrom ?? undefined
const toDate = rawTo ?? undefined
if (fromDate && (fromDate < period.period_start || fromDate > period.period_end)) {
return {
ok: false,
error: `from_date måste ligga inom räkenskapsåret (${period.period_start} — ${period.period_end}).`,
}
}
if (toDate && (toDate < period.period_start || toDate > period.period_end)) {
return {
ok: false,
error: `to_date måste ligga inom räkenskapsåret (${period.period_start} — ${period.period_end}).`,
}
}
if (fromDate && toDate && fromDate > toDate) {
return { ok: false, error: 'from_date får inte vara efter to_date.' }
}
return { ok: true, range: { fromDate, toDate } }
}
+4 -1
View File
@@ -14,7 +14,8 @@ import type { IncomeStatementReport, IncomeStatementSection, TrialBalanceRow } f
export async function generateIncomeStatement(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string
fiscalPeriodId: string,
options?: { fromDate?: string; toDate?: string }
): Promise<IncomeStatementReport> {
// Exclude year-end closing entries: after closing, P&L accounts (3-8) are
// zeroed by the closing verifikat (8999 → 2099). Including them collapses
@@ -22,6 +23,8 @@ export async function generateIncomeStatement(
// pre-closing activity for the year.
const { rows } = await generateTrialBalance(supabase, companyId, fiscalPeriodId, {
excludeYearEndClosing: true,
fromDate: options?.fromDate,
toDate: options?.toDate,
})
// Filter to income/expense accounts (class 3-8)
+16 -4
View File
@@ -31,7 +31,8 @@ const CLASS_LABELS: Record<number, string> = {
export async function generateResultatrapport(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string
fiscalPeriodId: string,
options?: { fromDate?: string; toDate?: string }
): Promise<ResultatrapportReport> {
const { data: period } = await supabase
.from('fiscal_periods')
@@ -44,12 +45,23 @@ export async function generateResultatrapport(
throw new Error('Fiscal period not found')
}
const currentTb = await generateTrialBalance(supabase, companyId, fiscalPeriodId)
const effectiveFromDate = options?.fromDate ?? period.period_start
const effectiveToDate = options?.toDate ?? period.period_end
const currentTb = await generateTrialBalance(supabase, companyId, fiscalPeriodId, {
fromDate: options?.fromDate,
toDate: options?.toDate,
})
const currentRows = filterPnl(currentTb.rows)
// Prior-period comparison stays full-year. A narrower current window
// compared against a full prior year would be misleading; until we ship a
// proper "same window, prior year" comparison the cleanest move is to
// drop the prior column entirely when the user narrows the range.
let priorRows: TrialBalanceRow[] = []
let priorPeriodInfo: { start: string; end: string } | null = null
if (period.previous_period_id) {
const isFullPeriod = !options?.fromDate && !options?.toDate
if (isFullPeriod && period.previous_period_id) {
const { data: prior } = await supabase
.from('fiscal_periods')
.select('period_start, period_end')
@@ -76,7 +88,7 @@ export async function generateResultatrapport(
groups,
net_result_current: round2(netResultCurrent),
net_result_prior: round2(netResultPrior),
period: { start: period.period_start, end: period.period_end },
period: { start: effectiveFromDate, end: effectiveToDate },
prior_period: priorPeriodInfo,
}
}
+72 -5
View File
@@ -4,12 +4,19 @@ import { getOpeningBalances } from './opening-balances'
import type { TrialBalanceRow } from '@/types'
/**
* Generate trial balance (Saldobalans) for a fiscal period.
* Generate trial balance (Saldobalans) for a fiscal period or a date range
* inside one.
*
* Computes IB (ingående balans), period movements, and UB (utgående balans)
* per BFNAR 2013:2 requirements. Uses the opening_balance_entry set by
* year-end closing when available; falls back to summing prior-period entries.
*
* When `fromDate`/`toDate` are passed, they must lie inside the fiscal
* period. The function rolls the IB forward from `period_start` to
* `fromDate − 1` (so "opening" reflects the state at `fromDate`) and limits
* period activity to `[fromDate, toDate]`. Defaults equal `period_start` and
* `period_end` — identical to the no-options behaviour.
*
* Uses joined queries with pagination to handle any number of entries.
* Avoids the broken .in(entryIds) pattern that silently truncated at 1000 rows.
*/
@@ -17,7 +24,11 @@ export async function generateTrialBalance(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
options?: { excludeYearEndClosing?: boolean }
options?: {
excludeYearEndClosing?: boolean
fromDate?: string
toDate?: string
}
): Promise<{
rows: TrialBalanceRow[]
totalDebit: number
@@ -28,16 +39,59 @@ export async function generateTrialBalance(
// Fetch period for opening balance computation
const { data: period } = await supabase
.from('fiscal_periods')
.select('period_start, opening_balance_entry_id')
.select('period_start, period_end, opening_balance_entry_id')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
// ── Opening balances (IB) ──────────────────────────────────────
// ── Opening balances (IB) at period_start ──────────────────────
const { balances: openingBalances, obEntryId } = await getOpeningBalances(
supabase, companyId, period
)
// ── Roll IB forward from period_start up to fromDate ───────────
// When the caller requests a sub-range starting after period_start, the
// "opening" of that window must include all activity since the period
// started. We additively fold those lines into openingBalances so the
// downstream IB/period split stays correct without changing call sites.
if (
options?.fromDate &&
period?.period_start &&
options.fromDate > period.period_start
) {
const priorLines = await fetchAllRows<{
account_number: string
debit_amount: number
credit_amount: number
}>(({ from, to }) => {
let query = supabase
.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount, journal_entries!inner(company_id, fiscal_period_id, status, source_type, entry_date)')
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.fiscal_period_id', fiscalPeriodId)
.in('journal_entries.status', ['posted', 'reversed'])
.gte('journal_entries.entry_date', period.period_start)
.lt('journal_entries.entry_date', options.fromDate)
if (obEntryId) {
query = query.neq('journal_entry_id', obEntryId)
}
if (options?.excludeYearEndClosing) {
query = query.neq('journal_entries.source_type', 'year_end')
}
return query.range(from, to)
})
for (const line of priorLines) {
const existing = openingBalances.get(line.account_number) || { debit: 0, credit: 0 }
existing.debit += Number(line.debit_amount) || 0
existing.credit += Number(line.credit_amount) || 0
openingBalances.set(line.account_number, existing)
}
}
// ── Period lines (excluding opening balance entry) ─────────────
// If year-end closing set an OB entry, exclude it from period lines so
// its values aren't double-counted (they're already captured as IB).
@@ -52,11 +106,24 @@ export async function generateTrialBalance(
}>(({ from, to }) => {
let query = supabase
.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount, journal_entries!inner(company_id, fiscal_period_id, status, source_type)')
.select('account_number, debit_amount, credit_amount, journal_entries!inner(company_id, fiscal_period_id, status, source_type, entry_date)')
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.fiscal_period_id', fiscalPeriodId)
.in('journal_entries.status', ['posted', 'reversed'])
// Date filters are only applied when the caller explicitly asks. The
// period itself is already enforced via the fiscal_period_id join, so
// adding redundant entry_date bounds for the default case would just
// increase query complexity (and break older mocks that don't stub gte
// /lte). The fiscal_period_id constraint plus a CHECK on entry_date in
// the engine keep activity inside the period.
if (options?.fromDate) {
query = query.gte('journal_entries.entry_date', options.fromDate)
}
if (options?.toDate) {
query = query.lte('journal_entries.entry_date', options.toDate)
}
if (obEntryId) {
query = query.neq('journal_entry_id', obEntryId)
}
+11 -59
View File
@@ -11,30 +11,19 @@ describe('submitFeedback', () => {
vi.unstubAllGlobals()
})
function stubRecapt(impl: (...args: unknown[]) => void) {
vi.stubGlobal('window', { recapt: impl })
}
function stubNoRecapt() {
vi.stubGlobal('window', {})
}
function stubFetchOk() {
const fetchSpy = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })
vi.stubGlobal('fetch', fetchSpy)
return fetchSpy
}
it('sends to both Recapt and email when SDK is present', async () => {
const recapt = vi.fn()
stubRecapt(recapt)
it('posts subject and message to the contact endpoint', async () => {
const fetchSpy = stubFetchOk()
const result = await submitFeedback({ subject: 'Hjälpsida', message: 'Hjälp tack' })
expect(result.ok).toBe(true)
expect(result.channels.sort()).toEqual(['email', 'recapt'])
expect(recapt).toHaveBeenCalledWith('feedback', { message: '[Hjälpsida]\n\nHjälp tack' })
expect(result.channels).toEqual(['email'])
expect(fetchSpy).toHaveBeenCalledWith(
'/api/support/contact',
expect.objectContaining({
@@ -44,57 +33,21 @@ describe('submitFeedback', () => {
)
})
it('omits subject prefix in Recapt payload when subject not provided', async () => {
const recapt = vi.fn()
stubRecapt(recapt)
stubFetchOk()
await submitFeedback({ message: 'plain' })
expect(recapt).toHaveBeenCalledWith('feedback', { message: 'plain' })
})
it('still reports success via email when Recapt throws', async () => {
stubRecapt(() => {
throw new Error('boom')
})
stubFetchOk()
const result = await submitFeedback({ subject: 'X', message: 'msg' })
expect(result.ok).toBe(true)
expect(result.channels).toEqual(['email'])
})
it('uses email only when Recapt SDK is absent', async () => {
stubNoRecapt()
it('omits subject when not provided', async () => {
const fetchSpy = stubFetchOk()
const result = await submitFeedback({ message: 'msg' })
const result = await submitFeedback({ message: 'plain' })
expect(result.ok).toBe(true)
expect(result.channels).toEqual(['email'])
expect(fetchSpy).toHaveBeenCalledOnce()
})
it('reports success when Recapt succeeds even if email fails', async () => {
const recapt = vi.fn()
stubRecapt(recapt)
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, json: async () => ({ error: 'down' }) })
expect(fetchSpy).toHaveBeenCalledWith(
'/api/support/contact',
expect.objectContaining({
body: JSON.stringify({ message: 'plain' }),
})
)
const result = await submitFeedback({ message: 'msg' })
expect(result.ok).toBe(true)
expect(result.channels).toEqual(['recapt'])
})
it('returns failure with email error when both channels fail', async () => {
stubRecapt(() => {
throw new Error('boom')
})
it('returns failure with server error message when the endpoint rejects', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
@@ -110,8 +63,7 @@ describe('submitFeedback', () => {
expect(result.error).toBe('Mailtjänsten är inte konfigurerad')
})
it('returns failure when fetch itself throws and Recapt is absent', async () => {
stubNoRecapt()
it('returns failure when fetch itself throws', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network down')))
const result = await submitFeedback({ message: 'msg' })
+10 -45
View File
@@ -3,7 +3,7 @@ export interface SubmitFeedbackInput {
subject?: string
}
export type SupportChannel = 'recapt' | 'email'
export type SupportChannel = 'email'
export interface SubmitFeedbackResult {
ok: boolean
@@ -11,58 +11,23 @@ export interface SubmitFeedbackResult {
error?: string
}
function composeMessage({ message, subject }: SubmitFeedbackInput): string {
if (!subject) return message
return `[${subject}]\n\n${message}`
}
async function submitViaEmail(
{ message, subject }: SubmitFeedbackInput
): Promise<{ ok: true } | { ok: false; error: string }> {
export async function submitFeedback(input: SubmitFeedbackInput): Promise<SubmitFeedbackResult> {
try {
const res = await fetch('/api/support/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subject, message }),
body: JSON.stringify({ subject: input.subject, message: input.message }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
return { ok: false, error: data.error || 'Kunde inte skicka meddelandet' }
return { ok: false, channels: [], error: data.error || 'Kunde inte skicka meddelandet' }
}
return { ok: true }
return { ok: true, channels: ['email'] }
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : 'Nätverksfel' }
}
}
function submitViaRecapt(
input: SubmitFeedbackInput
): { ok: true } | { ok: false; error: string } | null {
const recapt = typeof window !== 'undefined' ? window.recapt : undefined
if (typeof recapt !== 'function') return null
try {
recapt('feedback', { message: composeMessage(input) })
return { ok: true }
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : 'Recapt-fel' }
}
}
export async function submitFeedback(input: SubmitFeedbackInput): Promise<SubmitFeedbackResult> {
const recaptResult = submitViaRecapt(input)
const emailResult = await submitViaEmail(input)
const channels: SupportChannel[] = []
if (recaptResult?.ok) channels.push('recapt')
if (emailResult.ok) channels.push('email')
if (channels.length > 0) {
return { ok: true, channels }
}
return {
ok: false,
channels: [],
error: emailResult.ok ? undefined : emailResult.error,
return {
ok: false,
channels: [],
error: err instanceof Error ? err.message : 'Nätverksfel',
}
}
}
+24 -2
View File
@@ -2373,6 +2373,7 @@
"col_vat": "VAT",
"col_debit": "Debit",
"col_credit": "Credit",
"vat_rate_presets_aria": "Pick VAT rate from list",
"description_placeholder": "Description",
"row_label": "Row {index}",
"remove_row_aria": "Remove row {index}",
@@ -2672,7 +2673,19 @@
"toast_posted_description": "Journal entry {voucher} has been posted.",
"toast_post_failed": "Could not post",
"toast_post_failed_generic": "Could not post journal entry",
"view_attachments": "View attachments"
"view_attachments": "View attachments",
"no_doc_required_toggle": "No supporting document required",
"no_doc_required_indicator_tooltip": "Marked as not requiring a supporting document",
"no_doc_required_reason_label": "Reason",
"no_doc_required_reason_add": "Add reason (optional)",
"no_doc_required_reason_placeholder": "E.g. bank fee, interest, internal transfer",
"no_doc_required_save_reason": "Save",
"no_doc_required_save_failed": "Could not save",
"no_doc_required_suggestion_bank_fee": "Bank fee",
"no_doc_required_suggestion_interest": "Interest",
"no_doc_required_suggestion_internal_transfer": "Internal transfer",
"no_doc_required_suggestion_tax_payment": "Tax payment",
"no_doc_required_suggestion_salary": "Salary"
},
"attachment_preview_sheet": {
"title": "Attachments",
@@ -3425,7 +3438,16 @@
"name_grundbok": "Journal register",
"name_kundreskontra": "Accounts receivable ledger",
"name_supplier_ledger": "Accounts payable ledger",
"name_bank_reconciliation": "Bank reconciliation"
"name_bank_reconciliation": "Bank reconciliation",
"date_range_label": "Period",
"date_range_preset_full_year": "Full year",
"date_range_preset_ytd": "Year to date",
"date_range_preset_this_month": "This month",
"date_range_preset_last_month": "Last month",
"date_range_preset_this_quarter": "This quarter",
"date_range_preset_custom": "Custom",
"date_range_from": "From",
"date_range_to": "To"
},
"salary": {
"title": "Payroll",
+24 -2
View File
@@ -2373,6 +2373,7 @@
"col_vat": "Moms",
"col_debit": "Debet",
"col_credit": "Kredit",
"vat_rate_presets_aria": "Välj momssats från lista",
"description_placeholder": "Beskrivning",
"row_label": "Rad {index}",
"remove_row_aria": "Ta bort rad {index}",
@@ -2672,7 +2673,19 @@
"toast_posted_description": "Verifikat {voucher} har bokförts.",
"toast_post_failed": "Kunde inte bokföra",
"toast_post_failed_generic": "Kunde inte bokföra verifikat",
"view_attachments": "Visa bilagor"
"view_attachments": "Visa bilagor",
"no_doc_required_toggle": "Inget underlag krävs",
"no_doc_required_indicator_tooltip": "Markerad som att underlag inte krävs",
"no_doc_required_reason_label": "Skäl",
"no_doc_required_reason_add": "Lägg till skäl (frivilligt)",
"no_doc_required_reason_placeholder": "T.ex. bankavgift, ränta, intern överföring",
"no_doc_required_save_reason": "Spara",
"no_doc_required_save_failed": "Kunde inte spara",
"no_doc_required_suggestion_bank_fee": "Bankavgift",
"no_doc_required_suggestion_interest": "Ränta",
"no_doc_required_suggestion_internal_transfer": "Intern överföring",
"no_doc_required_suggestion_tax_payment": "Skatteinbetalning",
"no_doc_required_suggestion_salary": "Lön"
},
"attachment_preview_sheet": {
"title": "Bilagor",
@@ -3425,7 +3438,16 @@
"name_grundbok": "Grundbok",
"name_kundreskontra": "Kundreskontra",
"name_supplier_ledger": "Leverantörsreskontra",
"name_bank_reconciliation": "Bankavstämning"
"name_bank_reconciliation": "Bankavstämning",
"date_range_label": "Period",
"date_range_preset_full_year": "Hela året",
"date_range_preset_ytd": "Hittills i år",
"date_range_preset_this_month": "Denna månad",
"date_range_preset_last_month": "Förra månaden",
"date_range_preset_this_quarter": "Detta kvartal",
"date_range_preset_custom": "Anpassat",
"date_range_from": "Från",
"date_range_to": "Till"
},
"salary": {
"title": "Löner",
+2 -2
View File
@@ -11,9 +11,9 @@ const activepiecesUrl = process.env.ACTIVEPIECES_URL ?? "";
const cspDirectives = [
"default-src 'self'",
`connect-src 'self' ${supabaseUrl} https://*.supabase.co wss://*.supabase.co https://*.enablebanking.com https://*.recapt.app`,
`connect-src 'self' ${supabaseUrl} https://*.supabase.co wss://*.supabase.co https://*.enablebanking.com`,
`style-src 'self' 'unsafe-inline' https://*.enablebanking.com`,
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} https://*.enablebanking.com https://cdn.recapt.app`,
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} https://*.enablebanking.com`,
"img-src 'self' data: blob: https:",
"font-src 'self'",
"worker-src 'self' blob:",
@@ -0,0 +1,57 @@
-- =============================================================================
-- journal_entry_no_doc_required — sidecar metadata for the "Saknade underlag"
-- list.
--
-- Some posted entries legitimately don't have a separate kvitto:
-- * Bankavgifter (auto-debited fees)
-- * Räntor (bank interest charges/credits)
-- * Interna överföringar mellan egna konton
-- * Skatteinbetalningar till Skatteverket
-- * Lönebetalningar (the verifikation IS the underlag)
--
-- We can't add a column on journal_entries because the
-- enforce_journal_entry_immutability trigger (migration 17) blocks UPDATE on
-- posted rows. Sidecar table keeps the verifikation untouched while letting
-- the bookkeeper record "no underlag needed" as auditable metadata.
--
-- Toggleable (no immutability trigger) so users can undo a mis-flag. The
-- created_by / created_at columns provide the audit trail; the audit_log
-- trigger captures DELETEs.
-- =============================================================================
CREATE TABLE public.journal_entry_no_doc_required (
journal_entry_id uuid PRIMARY KEY REFERENCES public.journal_entries(id) ON DELETE CASCADE,
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT,
reason text CHECK (reason IS NULL OR char_length(reason) <= 200),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_jenodoc_company ON public.journal_entry_no_doc_required(company_id);
ALTER TABLE public.journal_entry_no_doc_required ENABLE ROW LEVEL SECURITY;
-- SELECT: any company member can see exemptions in their companies
CREATE POLICY "jenodoc_select" ON public.journal_entry_no_doc_required
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
-- INSERT/UPDATE/DELETE: gated on membership only — viewer guard is enforced
-- application-side via requireWritePermission() in the API route. This matches
-- the booking_template_library and similar tables.
CREATE POLICY "jenodoc_insert" ON public.journal_entry_no_doc_required
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "jenodoc_update" ON public.journal_entry_no_doc_required
FOR UPDATE
USING (company_id IN (SELECT public.user_company_ids()))
WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "jenodoc_delete" ON public.journal_entry_no_doc_required
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE TRIGGER set_updated_at
BEFORE UPDATE ON public.journal_entry_no_doc_required
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,131 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import {
seedCompany,
insertCompanyMember,
insertAuthUser,
insertDraftJournalEntry,
} from '@/tests/pg/fixtures'
import { getPool, withUserContext } from '@/tests/pg/setup'
/**
* Covers migration 20260527170000_journal_entry_no_doc_required:
* - Sidecar table accepts inserts via PostgREST (user context)
* - RLS isolates exemptions across companies
* - FK cascade removes the exemption when the parent journal_entry is deleted
* - reason length is capped at 200 chars
* - PRIMARY KEY on journal_entry_id prevents duplicate exemptions
*/
async function insertExemption(client: {
query: (sql: string, params: unknown[]) => Promise<unknown>
}, params: {
journalEntryId: string
companyId: string
userId: string
reason?: string | null
}) {
await client.query(
`INSERT INTO public.journal_entry_no_doc_required
(journal_entry_id, company_id, user_id, reason)
VALUES ($1, $2, $3, $4)`,
[params.journalEntryId, params.companyId, params.userId, params.reason ?? null],
)
}
describe('journal_entry_no_doc_required.pg', () => {
it('inserts and reads back an exemption row', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryId = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId })
await insertExemption(getPool(), {
journalEntryId: entryId,
companyId,
userId,
reason: 'Bankavgift',
})
const res = await getPool().query<{ reason: string | null }>(
`SELECT reason FROM public.journal_entry_no_doc_required WHERE journal_entry_id = $1`,
[entryId],
)
expect(res.rows).toHaveLength(1)
expect(res.rows[0]!.reason).toBe('Bankavgift')
})
it('PK on journal_entry_id blocks duplicate exemptions', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryId = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId })
await insertExemption(getPool(), { journalEntryId: entryId, companyId, userId })
await expect(
insertExemption(getPool(), { journalEntryId: entryId, companyId, userId }),
).rejects.toThrow(/duplicate key|already exists/)
})
it('caps reason at 200 chars', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryId = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId })
const longReason = 'x'.repeat(201)
await expect(
insertExemption(getPool(), {
journalEntryId: entryId,
companyId,
userId,
reason: longReason,
}),
).rejects.toThrow(/check constraint|violates check/i)
})
it('cascades on delete when the journal_entry is removed', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryId = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId })
await insertExemption(getPool(), { journalEntryId: entryId, companyId, userId })
// enforce_journal_entry_immutability blocks DELETE unconditionally; the
// delete_last_voucher RPC sets gnubok.allow_delete='true' before deleting.
// Mirror that here so the cascade can fire.
const client = await getPool().connect()
try {
await client.query('BEGIN')
await client.query(`SELECT set_config('gnubok.allow_delete', 'true', true)`)
await client.query(`DELETE FROM public.journal_entries WHERE id = $1`, [entryId])
await client.query('COMMIT')
} catch (err) {
await client.query('ROLLBACK').catch(() => {})
throw err
} finally {
client.release()
}
const res = await getPool().query<{ count: string }>(
`SELECT COUNT(*)::text as count FROM public.journal_entry_no_doc_required
WHERE journal_entry_id = $1`,
[entryId],
)
expect(res.rows[0]!.count).toBe('0')
})
it('RLS hides exemptions from users in other companies', async () => {
// Company A — owner u1, with one exempted entry
const { userId: u1, companyId: c1, fiscalPeriodId: fp1 } = await seedCompany()
const entryA = await insertDraftJournalEntry({ userId: u1, companyId: c1, fiscalPeriodId: fp1 })
await insertExemption(getPool(), { journalEntryId: entryA, companyId: c1, userId: u1 })
// Company B — owner u2, no overlap with c1
const u2 = await insertAuthUser()
const { companyId: c2 } = await seedCompany()
await insertCompanyMember({ companyId: c2, userId: u2, role: 'owner' })
// u2 must not see u1's exemption row
const visible = await withUserContext(u2, async (client) => {
const r = await client.query<{ journal_entry_id: string }>(
`SELECT journal_entry_id FROM public.journal_entry_no_doc_required`,
)
return r.rows
})
expect(visible.some((r) => r.journal_entry_id === entryA)).toBe(false)
})
})
+2
View File
@@ -941,6 +941,8 @@ export interface CreateSupplierInvoiceItemInput {
amount: number
account_number: string
vat_rate?: number
// Manual override. See CreateSupplierInvoiceItemSchema for rationale.
vat_amount?: number
vat_code?: string
// Legacy fields (backward compat, ignored when amount is set)
quantity?: number
-23
View File
@@ -1,23 +0,0 @@
type RecaptFeedbackPayload =
| { message: string; rating?: number }
| { widget: 'show' | 'hide' | 'open' | 'close'; position?: string }
type RecaptIdentifyPayload = {
uid: string | undefined
email?: string
nickname?: string
}
interface RecaptFn {
(action: 'feedback', data: RecaptFeedbackPayload): void
(action: 'identify', data: RecaptIdentifyPayload): void
}
declare global {
interface Window {
Recapt?: unknown
recapt?: RecaptFn
}
}
export {}