Audit v4: VAT calc, SAF-T compliance, security hardening, frontend quality

Backend (17 files):
- VAT: REP 25% deductibility (§42), EU reverse charge double-entry (IEUV/IEUY/IVY),
  IVY rate 0%→25%, VatReport Box C/D populated, Basis1 from real revenue
- SAF-T: correct OECD namespace, closing balance net calc, zero-amount fallback,
  credit note auto-numbering (§52)
- Security: BankingController CSRF state token + company auth check,
  attachment canonical path traversal check, discount 0-100% validation,
  deactivated product/customer update guard
- Quality: redact bank API logs, remove dead code (VatCalcService,
  PaymentMatchingService), CompanyAggregate IEmit interfaces, fix URL encoding

Frontend (15 files):
- Fix double "kr." in AmountText and Dashboard Statistic components
- Fix UserSettings Switch defaultChecked desync with Form state
- Remove dual useCompany/useCompanyStore pattern (Dashboard, Moms, Bank)
- Correct SKAT VAT deadline calculation per period type
- Add half-yearly/yearly VAT period options
- Guard console.error with import.meta.env.DEV
- Use shared formatDate in BankConnectionsTab
- Remove dead NONE vatCode check, purge 7 legacy VAT codes from type union
- Migrate S25→U25, K25→I25 across all pages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nicolaj Hartmann 2026-02-06 01:38:52 +01:00
parent a7d76df3a7
commit 8096a19081
32 changed files with 380 additions and 163 deletions

View file

@ -51,8 +51,8 @@ export async function fetchGraphQL<TData, TVariables extends Record<string, unkn
const data = await graphqlClient.request<TData>(query, variables, headers);
return data;
} catch (error) {
// Log error for debugging
console.error('GraphQL Error:', error);
// Log error for debugging (dev only)
if (import.meta.env.DEV) console.error('GraphQL Error:', error);
// Re-throw with more context
if (error instanceof Error) {

View file

@ -19,6 +19,7 @@ import {
DatePicker,
} from 'antd';
import { showSuccess, showError } from '@/lib/errorHandling';
import { formatDate } from '@/lib/formatters';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import {
@ -64,13 +65,6 @@ function getStatusTag(status: BankConnection['status'], isActive: boolean) {
return <Tag>{status}</Tag>;
}
function formatDate(dateString: string) {
return new Date(dateString).toLocaleDateString('da-DK', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
export default function BankConnectionsTab({ companyId }: BankConnectionsTabProps) {
const [searchParams, setSearchParams] = useSearchParams();

View file

@ -1,6 +1,6 @@
import { Typography, Tooltip } from 'antd';
import { ArrowUpOutlined, ArrowDownOutlined, MinusOutlined } from '@ant-design/icons';
import { formatCurrency } from '@/lib/formatters';
import { formatCurrency, formatNumber } from '@/lib/formatters';
import { accountingColors } from '@/styles/theme';
import { typography, getAmountColor } from '@/styles/designTokens';
@ -77,7 +77,7 @@ export function AmountText({
};
const formatAmount = (): string => {
const formatted = formatCurrency(Math.abs(amount));
const formatted = formatNumber(Math.abs(amount));
// Always show +/- prefix for non-zero amounts (accessibility: not color-only)
// When showSign is explicitly true, same behavior; kept for API compatibility
const alwaysSign = showSign;

View file

@ -138,11 +138,11 @@ export const VAT_RATES = {
* Standard Danish VAT codes
*/
export const VAT_CODES = {
S25: { code: 'S25', name: 'Udgående moms 25%', rate: 0.25, type: 'output' },
K25: { code: 'K25', name: 'Indgående moms 25%', rate: 0.25, type: 'input' },
U25: { code: 'U25', name: 'Udgående moms 25%', rate: 0.25, type: 'output' },
I25: { code: 'I25', name: 'Indgående moms 25%', rate: 0.25, type: 'input' },
E0: { code: 'E0', name: 'EU-varekøb 0%', rate: 0, type: 'eu' },
U0: { code: 'U0', name: 'Eksport 0%', rate: 0, type: 'export' },
NONE: { code: 'NONE', name: 'Ingen moms', rate: 0, type: 'none' },
INGEN: { code: 'INGEN', name: 'Ingen moms', rate: 0, type: 'none' },
} as const;
/**
@ -298,11 +298,11 @@ export function generateSimpleDoubleEntry(input: SimpleBookingInput): GeneratedT
// For reverse charge (EU purchases), also credit output VAT
if (vatConfig.reverseCharge && vatAmount > 0) {
const outputVatAccount = vatCode === 'EU_VARE' ? VAT_ACCOUNTS.euVAT : VAT_ACCOUNTS.outputVAT;
const outputVatAccount = vatCode === 'IEUV' ? VAT_ACCOUNTS.euVAT : VAT_ACCOUNTS.outputVAT;
lines.push({
accountId: `vat-output-${vatCode}`,
accountNumber: outputVatAccount,
accountName: vatCode === 'EU_VARE' ? 'EU-moms (erhvervelse)' : 'Udgående moms',
accountName: vatCode === 'IEUV' ? 'EU-moms (erhvervelse)' : 'Udgående moms',
description: `Moms: ${description}`,
debit: 0,
credit: vatAmount,
@ -440,11 +440,11 @@ export function generateSplitDoubleEntry(input: SplitBookingInput): GeneratedTra
// For reverse charge, also credit output VAT
if (vatConfig.reverseCharge && lineVat > 0) {
const outputVatAccount = splitLine.vatCode === 'EU_VARE' ? VAT_ACCOUNTS.euVAT : VAT_ACCOUNTS.outputVAT;
const outputVatAccount = splitLine.vatCode === 'IEUV' ? VAT_ACCOUNTS.euVAT : VAT_ACCOUNTS.outputVAT;
generatedLines.push({
accountId: `vat-output-${splitLine.vatCode}`,
accountNumber: outputVatAccount,
accountName: splitLine.vatCode === 'EU_VARE' ? 'EU-moms (erhvervelse)' : 'Udgående moms',
accountName: splitLine.vatCode === 'IEUV' ? 'EU-moms (erhvervelse)' : 'Udgående moms',
description: `Moms: ${description}`,
debit: 0,
credit: lineVat,
@ -541,17 +541,17 @@ export function getSuggestedVATCode(accountNumber: string, isExpense: boolean):
// Expenses typically use input VAT (K25)
if (isExpense) {
// Some expense types are typically VAT-exempt
if (accountType === 'financial') return 'NONE';
if (accountType === 'personnel') return 'NONE';
return 'K25';
if (accountType === 'financial') return 'INGEN';
if (accountType === 'personnel') return 'INGEN';
return 'I25';
}
// Revenue typically uses output VAT (S25)
// Revenue typically uses output VAT (U25)
if (accountType === 'revenue') {
return 'S25';
return 'U25';
}
return 'NONE';
return 'INGEN';
}
/**

View file

@ -159,11 +159,6 @@ function processTransactionLine(
const vatCode = line.vatCode;
// Skip lines without VAT relevance
if (vatCode === 'NONE') {
return null;
}
const codeConfig = VAT_CODE_CONFIG[vatCode];
// Calculate net amount (amount without VAT)

View file

@ -33,7 +33,6 @@ import dayjs from 'dayjs';
import { useNavigate } from 'react-router-dom';
import { useCompany } from '@/hooks/useCompany';
import { useReconciliationStore } from '@/stores/reconciliationStore';
import { useCompanyStore } from '@/stores/companyStore';
import { useActiveBankConnections } from '@/api/queries/bankConnectionQueries';
import { usePendingBankTransactions } from '@/api/queries/bankTransactionQueries';
import { useActiveAccounts } from '@/api/queries/accountQueries';
@ -69,12 +68,11 @@ interface MatchSuggestion {
export default function Bankafstemning() {
const { company } = useCompany();
const navigate = useNavigate();
const { activeCompany } = useCompanyStore();
// Fetch data from API
const { data: bankConnections = [], isLoading: connectionsLoading } = useActiveBankConnections(activeCompany?.id);
const { data: pendingTransactions = [], isLoading: transactionsLoading } = usePendingBankTransactions(activeCompany?.id);
const { data: activeAccounts = [] } = useActiveAccounts(activeCompany?.id);
const { data: bankConnections = [], isLoading: connectionsLoading } = useActiveBankConnections(company?.id);
const { data: pendingTransactions = [], isLoading: transactionsLoading } = usePendingBankTransactions(company?.id);
const { data: activeAccounts = [] } = useActiveAccounts(company?.id);
const isLoading = connectionsLoading || transactionsLoading;
@ -82,7 +80,7 @@ export default function Bankafstemning() {
const bankAccounts = bankConnections.flatMap(conn =>
(conn.accounts || []).map(acc => ({
id: acc.accountId,
companyId: activeCompany?.id || '',
companyId: company?.id || '',
name: acc.name || acc.iban,
bankName: conn.aspspName,
accountNumber: acc.iban,
@ -697,9 +695,9 @@ export default function Bankafstemning() {
placeholder="Vælg momskode"
allowClear
options={[
{ value: 'K25', label: 'K25 - Indgående moms 25%' },
{ value: 'S25', label: 'S25 - Udgående moms 25%' },
{ value: 'NONE', label: 'Ingen moms' },
{ value: 'I25', label: 'I25 - Indgående moms 25%' },
{ value: 'U25', label: 'U25 - Udgående moms 25%' },
{ value: 'INGEN', label: 'Ingen moms' },
]}
/>
</Form.Item>

View file

@ -10,7 +10,6 @@ import { Line, Pie, Column } from '@ant-design/charts';
import { Link } from 'react-router-dom';
import dayjs from 'dayjs';
import { useCompany } from '@/hooks/useCompany';
import { useCompanyStore } from '@/stores/companyStore';
import { usePeriodStore } from '@/stores/periodStore';
import { useAccountBalances } from '@/api/queries/accountQueries';
import { useInvoices } from '@/api/queries/invoiceQueries';
@ -45,7 +44,6 @@ interface RecentTransaction {
export default function Dashboard() {
const { company } = useCompany();
const { activeCompany } = useCompanyStore();
const { currentFiscalYear } = usePeriodStore();
// Define date interval - always format as YYYY-MM-DD for GraphQL DateOnly type
@ -57,16 +55,16 @@ export default function Dashboard() {
: dayjs().endOf('year').format('YYYY-MM-DD');
const { data: balances = [], isLoading: balancesLoading } = useAccountBalances(
activeCompany?.id,
company?.id,
currentFiscalYear ? {
startDate: dayjs(currentFiscalYear.startDate),
endDate: dayjs(currentFiscalYear.endDate),
} : undefined
);
const { data: invoices = [], isLoading: invoicesLoading } = useInvoices(activeCompany?.id);
const { data: invoices = [], isLoading: invoicesLoading } = useInvoices(company?.id);
const { data: vatReport, isLoading: vatLoading } = useVatReport(
activeCompany?.id,
company?.id,
periodStart,
periodEnd
);
@ -252,7 +250,6 @@ export default function Dashboard() {
value={metrics.cashPosition}
precision={2}
prefix={<BankOutlined />}
suffix="kr."
formatter={(value) => formatCurrency(value as number)}
/>
<div style={{ marginTop: 8 }}>
@ -270,7 +267,6 @@ export default function Dashboard() {
title="Tilgodehavender"
value={metrics.accountsReceivable}
precision={2}
suffix="kr."
valueStyle={{ color: accountingColors.credit }}
formatter={(value) => formatCurrency(value as number)}
/>
@ -294,7 +290,6 @@ export default function Dashboard() {
title="Kreditorer"
value={metrics.accountsPayable}
precision={2}
suffix="kr."
valueStyle={{ color: accountingColors.debit }}
formatter={(value) => formatCurrency(value as number)}
/>
@ -313,7 +308,6 @@ export default function Dashboard() {
title="Moms til betaling"
value={metrics.vatLiability}
precision={2}
suffix="kr."
formatter={(value) => formatCurrency(value as number)}
/>
<div style={{ marginTop: 8 }}>

View file

@ -48,7 +48,7 @@ export default function Eksport() {
}
} catch (error) {
showError('Der opstod en fejl under eksport');
console.error('SAF-T export error:', error);
if (import.meta.env.DEV) console.error('SAF-T export error:', error);
}
};

View file

@ -223,7 +223,7 @@ export default function Fakturaer() {
const handleAddLine = () => {
setEditingLine(null);
lineForm.resetFields();
lineForm.setFieldsValue({ quantity: 1, vatCode: 'S25' });
lineForm.setFieldsValue({ quantity: 1, vatCode: 'U25' });
setIsLineModalOpen(true);
};
@ -862,7 +862,7 @@ export default function Fakturaer() {
lineForm.setFieldsValue({
description: product.description || product.name,
unitPrice: product.unitPrice,
vatCode: product.vatCode === 'U25' ? 'S25' : product.vatCode,
vatCode: product.vatCode,
unit: product.unit || undefined,
});
}
@ -930,7 +930,7 @@ export default function Fakturaer() {
>
<Select
options={[
{ value: 'S25', label: 'S25 - 25% moms' },
{ value: 'U25', label: 'U25 - 25% moms' },
{ value: 'U0', label: 'U0 - Momsfrit' },
{ value: 'UEU', label: 'UEU - EU-salg' },
{ value: 'UEXP', label: 'UEXP - Eksport' },

View file

@ -424,8 +424,8 @@ export default function Kontooversigt() {
allowClear
placeholder="Vælg..."
options={[
{ value: 'S25', label: 'S25 - Udgående (Salg)' },
{ value: 'K25', label: 'K25 - Indgående (Køb)' },
{ value: 'U25', label: 'U25 - Udgående (Salg)' },
{ value: 'I25', label: 'I25 - Indgående (Køb)' },
{ value: 'E0', label: 'E0 - EU-salg' },
{ value: 'U0', label: 'U0 - Eksport' },
]}

View file

@ -211,7 +211,7 @@ export default function Kreditnotaer() {
const handleAddLine = () => {
setEditingLine(null);
lineForm.resetFields();
lineForm.setFieldsValue({ quantity: 1, vatCode: 'S25' });
lineForm.setFieldsValue({ quantity: 1, vatCode: 'U25' });
setIsLineModalOpen(true);
};
@ -854,7 +854,7 @@ export default function Kreditnotaer() {
>
<Select
options={[
{ value: 'S25', label: 'S25 - 25% moms' },
{ value: 'U25', label: 'U25 - 25% moms' },
{ value: 'U0', label: 'U0 - Momsfrit' },
{ value: 'UEU', label: 'UEU - EU-salg' },
{ value: 'UEXP', label: 'UEXP - Eksport' },

View file

@ -26,7 +26,6 @@ import {
import { Pie } from '@ant-design/charts';
import dayjs from 'dayjs';
import { useCompany } from '@/hooks/useCompany';
import { useCompanyStore } from '@/stores/companyStore';
import { useVatReport } from '@/api/queries/vatQueries';
import { formatCurrency, formatPeriod } from '@/lib/formatters';
import { accountingColors } from '@/styles/theme';
@ -47,18 +46,25 @@ interface VATBox {
export default function Momsindberetning() {
const { company } = useCompany();
const { activeCompany } = useCompanyStore();
const [selectedPeriod, setSelectedPeriod] = useState<dayjs.Dayjs>(
dayjs().subtract(1, 'month').startOf('month')
);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [periodType, setPeriodType] = useState<'monthly' | 'quarterly'>('quarterly');
const [periodType, setPeriodType] = useState<'monthly' | 'quarterly' | 'half-yearly' | 'yearly'>('quarterly');
// Calculate period dates based on selection
const periodStart = useMemo(() => {
if (periodType === 'quarterly') {
return selectedPeriod.startOf('quarter').format('YYYY-MM-DD');
}
if (periodType === 'half-yearly') {
const month = selectedPeriod.month(); // 0-indexed
const halfStart = month < 6 ? 0 : 6;
return selectedPeriod.month(halfStart).startOf('month').format('YYYY-MM-DD');
}
if (periodType === 'yearly') {
return selectedPeriod.startOf('year').format('YYYY-MM-DD');
}
return selectedPeriod.startOf('month').format('YYYY-MM-DD');
}, [selectedPeriod, periodType]);
@ -66,12 +72,20 @@ export default function Momsindberetning() {
if (periodType === 'quarterly') {
return selectedPeriod.endOf('quarter').format('YYYY-MM-DD');
}
if (periodType === 'half-yearly') {
const month = selectedPeriod.month(); // 0-indexed
const halfEnd = month < 6 ? 5 : 11;
return selectedPeriod.month(halfEnd).endOf('month').format('YYYY-MM-DD');
}
if (periodType === 'yearly') {
return selectedPeriod.endOf('year').format('YYYY-MM-DD');
}
return selectedPeriod.endOf('month').format('YYYY-MM-DD');
}, [selectedPeriod, periodType]);
// Fetch VAT report from backend
const { data: vatReport, isLoading, error } = useVatReport(
activeCompany?.id,
company?.id,
periodStart,
periodEnd
);
@ -255,20 +269,44 @@ export default function Momsindberetning() {
<Select
value={periodType}
onChange={setPeriodType}
style={{ width: 120 }}
style={{ width: 140 }}
options={[
{ value: 'monthly', label: 'Månedlig' },
{ value: 'quarterly', label: 'Kvartalsvis' },
{ value: 'half-yearly', label: 'Halvårlig' },
{ value: 'yearly', label: 'Årlig' },
]}
/>
<DatePicker
picker={periodType === 'quarterly' ? 'quarter' : 'month'}
picker={periodType === 'quarterly' ? 'quarter' : periodType === 'yearly' ? 'year' : 'month'}
value={selectedPeriod}
onChange={(date) => date && setSelectedPeriod(date)}
format={periodType === 'quarterly' ? '[Q]Q YYYY' : 'MMMM YYYY'}
format={
periodType === 'quarterly' ? '[Q]Q YYYY'
: periodType === 'yearly' ? 'YYYY'
: 'MMMM YYYY'
}
/>
<Tag color="blue">
Frist: {dayjs(selectedPeriod).add(1, 'month').endOf('month').format('D. MMMM YYYY')}
Frist: {(() => {
const pEnd = dayjs(periodEnd);
switch (periodType) {
case 'monthly':
// 25th of next month
return pEnd.add(1, 'month').date(25).format('D. MMMM YYYY');
case 'quarterly':
// 1st day of 2nd month after quarter end
return pEnd.add(2, 'month').startOf('month').format('D. MMMM YYYY');
case 'half-yearly':
// 1st day of 3rd month after half-year end
return pEnd.add(3, 'month').startOf('month').format('D. MMMM YYYY');
case 'yearly':
// 6 months after fiscal year end
return pEnd.add(6, 'month').format('D. MMMM YYYY');
default:
return pEnd.add(1, 'month').endOf('month').format('D. MMMM YYYY');
}
})()}
</Tag>
{vatReport && (
<Tag color="green">{vatReport.transactionCount} transaktioner</Tag>

View file

@ -178,7 +178,7 @@ export default function Ordrer() {
addLineForm.resetFields();
addLineForm.setFieldsValue({
quantity: 1,
vatCode: 'S25',
vatCode: 'U25',
});
setAddLineMode('product');
setSelectedProductId(null);
@ -193,7 +193,7 @@ export default function Ordrer() {
description: product.name,
unitPrice: product.unitPrice,
unit: product.unit || 'stk',
vatCode: product.vatCode || 'S25',
vatCode: product.vatCode || 'U25',
});
}
};
@ -825,7 +825,7 @@ export default function Ordrer() {
addLineForm.resetFields();
addLineForm.setFieldsValue({
quantity: 1,
vatCode: 'S25',
vatCode: 'U25',
});
}}
optionType="button"
@ -918,7 +918,7 @@ export default function Ordrer() {
<Select
disabled={addLineMode === 'product' && !!selectedProductId}
options={[
{ value: 'S25', label: 'S25 - Salgsmoms 25%' },
{ value: 'U25', label: 'U25 - Salgsmoms 25%' },
{ value: 'S0', label: 'S0 - Momsfrit salg' },
]}
/>

View file

@ -308,7 +308,7 @@ export default function UserSettings() {
Modtag vigtige opdateringer om din konto
</Text>
</div>
<Switch defaultChecked={mockUser.notifications.email} />
<Switch />
</div>
</Form.Item>
@ -324,7 +324,7 @@ export default function UserSettings() {
en opsummering af ugens bogføring hver mandag
</Text>
</div>
<Switch defaultChecked={mockUser.notifications.weeklyReport} />
<Switch />
</div>
</Form.Item>
@ -340,7 +340,7 @@ export default function UserSettings() {
besked når momsfrister eller andre deadlines nærmer sig
</Text>
</div>
<Switch defaultChecked={mockUser.notifications.deadlineReminders} />
<Switch />
</div>
</Form.Item>
@ -360,7 +360,7 @@ export default function UserSettings() {
Modtag notifikationer direkte i browseren
</Text>
</div>
<Switch defaultChecked={mockUser.notifications.browser} />
<Switch />
</div>
</Form.Item>

View file

@ -19,15 +19,7 @@ export type VATCode =
| 'IVV' // Import varer verden (0%)
| 'IVY' // Import ydelser verden (0%)
| 'REP' // Repræsentation (25%, 25% fradrag)
| 'INGEN' // Ingen moms
// Legacy codes kept for backwards compatibility with other modules
| 'S25' // @deprecated Use U25
| 'K25' // @deprecated Use I25
| 'EU_VARE' // @deprecated Use IEUV
| 'EU_YDELSE' // @deprecated Use IEUY
| 'MOMSFRI' // @deprecated Use INGEN
| 'EKSPORT' // @deprecated Use UEXP
| 'NONE'; // @deprecated Use INGEN
| 'INGEN'; // Ingen moms
/**
* VAT code type classification