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:
co-authored by
Claude Opus 4.7
parent
627109b5bd
commit
a9b43ebeb7
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+157
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 "{search.trim()}"</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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 : '')
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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' })
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Vendored
-23
@@ -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 {}
|
||||
Reference in New Issue
Block a user