Bug/momsdeklaration skv (#449)

* fix(salary): show birthdate in masked personnummer, hide the 4-digit suffix

Flip the personnummer display format from XXXXXXXX-NNNN to YYYYMMDD-XXXX so
the sensitive 4-digit suffix is hidden while the (public) birthdate stays
visible. Affects the employees list/detail, salary run, payslip PDF, payslip
email, and the MCP server tools (list_employees, get_salary_run). Each call
site now decrypts the stored personnummer before masking.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(transactions): allow deleting unbooked transactions from "Alla transaktioner"

The history list only let users delete via the inbox card; once a category or
mall was picked but the verifikation hadn't been created, the row showed
"Ej bokförd" with no way to remove it. The API already permits delete while
journal_entry_id is null, so the gap was purely a missing UI affordance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(vat): populate ruta 20-24 for reverse charge + dishonest "Validera OK"

Three connected issues caused Skatteverket to reject momsdeklarationer
with FK004 even after our local "Validera"-knapp returned OK.

1. supplier-invoice-entries booked fiktiv moms (2614/2624/2634 + 2645/2647)
   on reverse-charge invoices but never the underlying basbelopp on 44xx/45xx.
   Ruta 30-32 filled up at SKV while ruta 20-24 stayed at 0 — SKV's FK004
   ("silent netting prohibited", ML 13 kap kräver båda sidor).

   Fix: generateReverseChargeBasisLines in vat-entries.ts emits parallel
   45xx/44xx debit + 4598 motkonto credit per rate group. Engine calls it
   from registration, cash, and credit-note paths. Skipped when the user
   booked the expense directly on a basis account to avoid double-counting.
   4598 added to BAS reference (no migration needed; account_number is
   plain text on journal_entry_lines).

2. rutorToMomsuppgift rounded each ruta independently but computed
   summaMoms from the unrounded ruta49. SKV recomputes the sum from
   integer rutor on their side, so fractional öres caused ±1 SEK drift
   and SKV rejected with FK009.

   Fix: derive summaMoms from the already-rounded VAT-amount rutor.

3. "Validera"-knappen only confirmed SKV's internal arithmetic — a
   declaration with ruta 30-32 populated and ruta 20-24 empty validated
   fine until /utkast hit FK004. Users got a false green light.

   Fix: vat-declaration-checks.ts runs locally before the SKV call,
   blocks Validera/Spara when ERROR-level findings exist, and surfaces
   them in a separate "Lokala kontroller"-section. Success message
   reworded so SKV's OK is no longer presented as filing-ready.

Tests: 4535/4536/4531/4425 lines + 4598 motkonto on EU/non-EU/byggtjänster
RC, credit-note reversal, fractional-öres summaMoms, all four pre-flight
codes (RC_BASIS_MISSING, RC_OUTPUT_MISSING, RC_INPUT_VAT_MISMATCH,
SUMMA_MOMS_DRIFT).

Backfill for already-posted entries follows in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: add skattekonto matching functionality

- Enhance TransactionInboxCard to display a warning for potential 1930↔1630 transfers.
- Implement match suggestions for skattekonto transactions in the backend.
- Create SkattekontoMatchDialog component for linking skattekonto rows to existing journal entries.
- Develop SkattekontoInboxCard component to handle skattekonto transactions in the inbox.
- Introduce skattekonto-match utility functions for candidate matching and linking.
- Update types to include match suggestions and enriched transaction responses.

* refactor: reorganize skattekonto types and implement bank counterpart matching logic

* docs: update CLAUDE.md to streamline integrations and clarify architecture details

* refactor: enhance reverse charge logic to handle non-basis accounts and prevent double-counting

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-05-12 18:04:48 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent eb77ad50b5
commit 980f29dae8
33 changed files with 3337 additions and 493 deletions
+1
View File
@@ -1393,6 +1393,7 @@ function VatDeclarationView() {
year={year}
period={period}
hasData={data !== null}
rutor={data?.rutor ?? null}
/>
{!data && !loading && !error && (
+248 -13
View File
@@ -7,6 +7,14 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
import {
@@ -14,10 +22,12 @@ import {
ExternalLink,
FileCheck,
Landmark,
Link2,
RefreshCw,
} from 'lucide-react'
import type {
SkatteverketSaldoResponse,
SkattekontoTransactionWithSuggestion,
StoredSkattekontoTransaction,
} from '@/extensions/general/skatteverket/types'
@@ -29,11 +39,22 @@ interface SaldoEnvelope {
interface TransaktionerEnvelope {
data: {
booked: StoredSkattekontoTransaction[]
booked: SkattekontoTransactionWithSuggestion[]
upcoming: StoredSkattekontoTransaction[]
}
}
interface MatchCandidate {
journal_entry_id: string
voucher_number: number | null
voucher_series: string | null
entry_date: string
description: string
status: 'draft' | 'posted' | 'reversed'
matched_amount: number
matched_side: 'debit' | 'credit'
}
export default function SkattekontoPage() {
const { toast } = useToast()
const [saldo, setSaldo] = useState<SaldoEnvelope | null>(null)
@@ -42,6 +63,12 @@ export default function SkattekontoPage() {
const [syncing, setSyncing] = useState(false)
const [bookingId, setBookingId] = useState<string | null>(null)
const [notConnected, setNotConnected] = useState(false)
const [matchOpenFor, setMatchOpenFor] = useState<StoredSkattekontoTransaction | null>(
null,
)
const [matchCandidates, setMatchCandidates] = useState<MatchCandidate[] | null>(null)
const [matchLoading, setMatchLoading] = useState(false)
const [matchSubmitting, setMatchSubmitting] = useState<string | null>(null)
const reload = useCallback(async () => {
setLoading(true)
@@ -130,6 +157,62 @@ export default function SkattekontoPage() {
}
}
async function openMatch(row: StoredSkattekontoTransaction) {
setMatchOpenFor(row)
setMatchCandidates(null)
setMatchLoading(true)
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/skattekonto/transaktioner/${row.id}/match-candidates`,
)
const json = await res.json()
if (!res.ok) {
throw new Error(json.error || 'Kunde inte söka kandidater')
}
setMatchCandidates(json.data.candidates as MatchCandidate[])
} catch (err) {
toast({
title: 'Kunde inte hämta kandidater',
description: err instanceof Error ? err.message : undefined,
variant: 'destructive',
})
setMatchOpenFor(null)
} finally {
setMatchLoading(false)
}
}
async function confirmMatch(journalEntryId: string) {
if (!matchOpenFor) return
setMatchSubmitting(journalEntryId)
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/skattekonto/transaktioner/${matchOpenFor.id}/match`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ journal_entry_id: journalEntryId }),
},
)
const json = await res.json()
if (!res.ok) {
throw new Error(json.error || 'Matchning misslyckades')
}
toast({ title: 'Transaktion kopplad till verifikat' })
setMatchOpenFor(null)
setMatchCandidates(null)
await reload()
} catch (err) {
toast({
title: 'Kunde inte koppla transaktionen',
description: err instanceof Error ? err.message : undefined,
variant: 'destructive',
})
} finally {
setMatchSubmitting(null)
}
}
function copyOcr(ocr: string) {
navigator.clipboard
.writeText(ocr)
@@ -182,7 +265,7 @@ export default function SkattekontoPage() {
<Tabs defaultValue="booked">
<TabsList>
<TabsTrigger value="booked">
Bokförda {tx?.booked ? `(${tx.booked.length})` : ''}
Genomförda {tx?.booked ? `(${tx.booked.length})` : ''}
</TabsTrigger>
<TabsTrigger value="upcoming">
Kommande {tx?.upcoming ? `(${tx.upcoming.length})` : ''}
@@ -192,14 +275,16 @@ export default function SkattekontoPage() {
<TransactionTable
rows={tx?.booked ?? []}
onBokfor={bokfor}
onMatch={openMatch}
bookingId={bookingId}
emptyText="Inga bokförda transaktioner än."
emptyText="Inga genomförda transaktioner än."
/>
</TabsContent>
<TabsContent value="upcoming" className="mt-4">
<TransactionTable
rows={tx?.upcoming ?? []}
onBokfor={bokfor}
onMatch={openMatch}
bookingId={bookingId}
emptyText="Inga kommande transaktioner."
showForfallodatum
@@ -208,6 +293,18 @@ export default function SkattekontoPage() {
</Tabs>
</CardContent>
</Card>
<MatchDialog
row={matchOpenFor}
candidates={matchCandidates}
loading={matchLoading}
submittingId={matchSubmitting}
onClose={() => {
setMatchOpenFor(null)
setMatchCandidates(null)
}}
onConfirm={confirmMatch}
/>
</div>
)
}
@@ -351,12 +448,14 @@ function BalanceHero({
function TransactionTable({
rows,
onBokfor,
onMatch,
bookingId,
emptyText,
showForfallodatum = false,
}: {
rows: StoredSkattekontoTransaction[]
rows: SkattekontoTransactionWithSuggestion[]
onBokfor: (id: string) => void
onMatch: (row: StoredSkattekontoTransaction) => void
bookingId: string | null
emptyText: string
showForfallodatum?: boolean
@@ -387,7 +486,18 @@ function TransactionTable({
{showForfallodatum && (
<TableCell className="tabular-nums">{row.forfallodatum ?? '–'}</TableCell>
)}
<TableCell>{row.transaktionstext}</TableCell>
<TableCell>
{row.transaktionstext}
{!isBooked && row.match_suggestion && (
<p className="mt-1 text-xs text-warning">
Möjlig dublett av{' '}
{row.match_suggestion.voucher_series && row.match_suggestion.voucher_number
? `${row.match_suggestion.voucher_series}${row.match_suggestion.voucher_number}`
: 'utkast'}{' '}
({row.match_suggestion.entry_date})
</p>
)}
</TableCell>
<TableCell
className={`text-right tabular-nums ${negative ? 'text-destructive' : ''}`}
>
@@ -399,6 +509,10 @@ function TransactionTable({
<FileCheck className="h-3 w-3" />
Bokförd
</Badge>
) : row.match_suggestion ? (
<Badge variant="outline" className="border-warning text-warning">
Möjlig dublett
</Badge>
) : (
<Badge variant="outline">Ej bokförd</Badge>
)}
@@ -411,14 +525,25 @@ function TransactionTable({
</Link>
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={() => onBokfor(row.id)}
disabled={bookingId === row.id}
>
{bookingId === row.id ? 'Bokför…' : 'Bokför'}
</Button>
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => onMatch(row)}
title="Koppla till befintligt verifikat"
>
<Link2 className="mr-1 h-3.5 w-3.5" />
Matcha
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onBokfor(row.id)}
disabled={bookingId === row.id}
>
{bookingId === row.id ? 'Bokför…' : 'Bokför'}
</Button>
</div>
)}
</TableCell>
</TableRow>
@@ -428,3 +553,113 @@ function TransactionTable({
</Table>
)
}
function MatchDialog({
row,
candidates,
loading,
submittingId,
onClose,
onConfirm,
}: {
row: StoredSkattekontoTransaction | null
candidates: MatchCandidate[] | null
loading: boolean
submittingId: string | null
onClose: () => void
onConfirm: (journalEntryId: string) => void
}) {
const open = !!row
return (
<Dialog open={open} onOpenChange={o => !o && onClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Matcha mot befintligt verifikat</DialogTitle>
<DialogDescription>
{row && (
<>
{row.transaktionsdatum} • {row.transaktionstext} •{' '}
<span className="tabular-nums">
{formatCurrency(Number(row.belopp_skatteverket))}
</span>
</>
)}
</DialogDescription>
</DialogHeader>
{loading && (
<p className="py-6 text-center text-sm text-muted-foreground">
Söker kandidater…
</p>
)}
{!loading && candidates && candidates.length === 0 && (
<div className="space-y-2 py-4 text-sm">
<p>Hittade inga verifikat med en matchande rad på konto 1630.</p>
<p className="text-muted-foreground">
Kandidaten måste ha samma belopp och sida på 1630 inom ±14 dagar
från transaktionsdatumet, och får inte redan vara kopplad till en
annan skattekonto-transaktion. Använd <strong>Bokför</strong> för
att skapa ett nytt verifikat istället.
</p>
</div>
)}
{!loading && candidates && candidates.length > 0 && (
<div className="max-h-[420px] overflow-y-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Datum</TableHead>
<TableHead>Verifikat</TableHead>
<TableHead>Beskrivning</TableHead>
<TableHead>Status</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{candidates.map(c => (
<TableRow key={c.journal_entry_id}>
<TableCell className="tabular-nums">{c.entry_date}</TableCell>
<TableCell className="tabular-nums">
{c.voucher_series && c.voucher_number
? `${c.voucher_series}${c.voucher_number}`
: '–'}
</TableCell>
<TableCell className="max-w-[260px] truncate">
{c.description}
</TableCell>
<TableCell>
{c.status === 'posted' ? (
<Badge variant="secondary">Bokförd</Badge>
) : c.status === 'draft' ? (
<Badge variant="outline">Utkast</Badge>
) : (
<Badge variant="destructive">Makulerad</Badge>
)}
</TableCell>
<TableCell className="text-right">
<Button
size="sm"
onClick={() => onConfirm(c.journal_entry_id)}
disabled={submittingId === c.journal_entry_id}
>
{submittingId === c.journal_entry_id ? 'Kopplar…' : 'Koppla'}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<DialogFooter>
<Button variant="ghost" onClick={onClose}>
Avbryt
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+214 -24
View File
@@ -11,7 +11,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
import { useToast } from '@/components/ui/use-toast'
import { ToastAction } from '@/components/ui/toast'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import { X } from 'lucide-react'
import { Landmark, X } from 'lucide-react'
import TransactionForm from '@/components/transactions/TransactionForm'
import SwipeCategorizationView from '@/components/transactions/SwipeCategorizationView'
import BatchCategorySelector from '@/components/transactions/BatchCategorySelector'
@@ -19,6 +19,8 @@ import TransactionStatusBar from '@/components/transactions/TransactionStatusBar
import TransactionInboxCard from '@/components/transactions/TransactionInboxCard'
import TransactionHistoryList from '@/components/transactions/TransactionHistoryList'
import InboxZeroState from '@/components/transactions/InboxZeroState'
import SkattekontoInboxCard from '@/components/transactions/SkattekontoInboxCard'
import { SkattekontoMatchDialog } from '@/components/skattekonto/SkattekontoMatchDialog'
import InvoiceMatchDialog from '@/components/transactions/InvoiceMatchDialog'
import InvoicePicker from '@/components/transactions/InvoicePicker'
import TransactionBookingDialog from '@/components/transactions/TransactionBookingDialog'
@@ -31,9 +33,14 @@ import { getTemplateById, type BookingTemplate } from '@/lib/bookkeeping/booking
import { isCounterpartyTemplateId, extractCounterpartyId } from '@/lib/bookkeeping/counterparty-templates'
import { isLibraryTemplateId } from '@/lib/bookkeeping/template-library'
import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types'
import type {
SkattekontoTransactionWithSuggestion,
StoredSkattekontoTransaction,
} from '@/types/skatteverket'
import { findBankSkvCounterparts } from '@/lib/skatteverket/bank-counterpart'
import { useCompany } from '@/contexts/CompanyContext'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatCurrency, formatDate } from '@/lib/utils'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, SupplierInvoice, Supplier, VatTreatment, EntityType, LinePatternEntry } from '@/types'
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
@@ -121,6 +128,19 @@ export default function TransactionsPage() {
// Set of transaction IDs that are animating out (just categorized)
const [exitingIds, setExitingIds] = useState<Set<string>>(new Set())
// Skattekonto rows (unmatched, status='booked'). Loaded if the
// Skatteverket extension is enabled and connected. 503/401 → silently
// hidden (extension disabled or user not connected).
const [skvRows, setSkvRows] = useState<SkattekontoTransactionWithSuggestion[]>([])
const [skvProcessingId, setSkvProcessingId] = useState<string | null>(null)
const [skvMatchTarget, setSkvMatchTarget] = useState<StoredSkattekontoTransaction | null>(
null,
)
// Source filter for the merged inbox. Defaults to 'all' so users see
// both sources unless they want to narrow down.
const [sourceFilter, setSourceFilter] = useState<'all' | 'bank' | 'skatteverket'>('all')
const { toast } = useToast()
const { dialogProps: deleteDialogProps, confirm: confirmDelete } = useDestructiveConfirm()
const supabase = createClient()
@@ -139,6 +159,44 @@ export default function TransactionsPage() {
if (aHasMatch !== bHasMatch) return bHasMatch - aHasMatch
return b.date.localeCompare(a.date)
})
// Merged inbox: bank tx + SKV rows interleaved by date. Source filter
// narrows to one side. SKV rows always go after bank rows on the same
// date — bank tx tend to have invoice-match suggestions and we'd rather
// surface those first.
type InboxItem =
| { source: 'bank'; date: string; data: TransactionWithInvoice }
| { source: 'skatteverket'; date: string; data: SkattekontoTransactionWithSuggestion }
const skvUnmatched = skvRows.filter(r => !r.journal_entry_id)
const bankToSkvHints = findBankSkvCounterparts({
bankRows: uncategorizedTransactions.map(t => ({ id: t.id, date: t.date, amount: t.amount })),
skvRows: skvUnmatched,
})
const inboxItems: InboxItem[] = (() => {
const items: InboxItem[] = []
if (sourceFilter !== 'skatteverket') {
for (const t of uncategorizedTransactions) {
items.push({ source: 'bank', date: t.date, data: t })
}
}
if (sourceFilter !== 'bank') {
// Inbox only shows SKV rows that need action (no verifikat yet).
for (const r of skvRows) {
if (r.journal_entry_id) continue
if (exitingIds.has(r.id)) continue
items.push({ source: 'skatteverket', date: r.transaktionsdatum, data: r })
}
}
return items.sort((a, b) => {
if (a.date !== b.date) return b.date.localeCompare(a.date)
// Same date → bank first so invoice-match cards lead.
if (a.source !== b.source) return a.source === 'bank' ? -1 : 1
return 0
})
})()
const transactionsWithMatches = transactions.filter(
(t) =>
(t.potential_invoice && !t.invoice_id) ||
@@ -202,6 +260,30 @@ export default function TransactionsPage() {
setTotalUncategorizedCount(uncatCount ?? 0)
setHasMore(rows.length >= PAGE_SIZE)
setIsLoading(false)
// Fire-and-forget: load SKV rows in parallel with the rest of the
// page. We don't block on this — if the extension is disabled or the
// user isn't connected the response is 503/401 and we just leave the
// SKV section empty.
void loadSkvRows()
}
async function loadSkvRows() {
try {
const res = await fetch('/api/extensions/ext/skatteverket/skattekonto/transaktioner')
if (!res.ok) {
setSkvRows([])
return
}
const json = await res.json()
const booked = (json.data?.booked ?? []) as SkattekontoTransactionWithSuggestion[]
// Keep all booked SKV rows in state — inbox view filters to obokförda
// (journal_entry_id null), history view shows all of them (matched
// and unmatched) interleaved with bank tx by date.
setSkvRows(booked)
} catch {
setSkvRows([])
}
}
async function loadMoreTransactions() {
@@ -690,6 +772,50 @@ export default function TransactionsPage() {
}
}
async function handleSkvBokfor(row: StoredSkattekontoTransaction) {
setSkvProcessingId(row.id)
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/skattekonto/transaktioner/${row.id}/bokfor`,
{ method: 'POST' },
)
const json = await res.json()
if (!res.ok) {
throw new Error(json.error || 'Bokföring misslyckades')
}
toast({
title: 'Utkast skapat',
description: 'Granska och bokför verifikatet i Bokföring.',
})
window.location.href = `/bookkeeping/${json.data.entry.id}`
} catch (err) {
toast({
title: 'Kunde inte bokföra',
description: err instanceof Error ? err.message : undefined,
variant: 'destructive',
})
} finally {
setSkvProcessingId(null)
}
}
function handleSkvMatched() {
// After a successful match, drop the row from the inbox — it's now
// linked to a verifikat. Trigger an exit animation first.
if (skvMatchTarget) {
const id = skvMatchTarget.id
setExitingIds(prev => new Set(prev).add(id))
setTimeout(() => {
setSkvRows(prev => prev.filter(r => r.id !== id))
setExitingIds(prev => {
const next = new Set(prev)
next.delete(id)
return next
})
}, 350)
}
}
function handleTransactionBooked(transactionId: string, journalEntryId: string) {
setExitingIds((prev) => new Set(prev).add(transactionId))
setTimeout(() => {
@@ -939,42 +1065,99 @@ export default function TransactionsPage() {
))}
</div>
) : mode === 'inbox' ? (
uncategorizedTransactions.length === 0 ? (
inboxItems.length === 0 ? (
<InboxZeroState
hasTransactions={transactions.length > 0}
hasTransactions={transactions.length > 0 || skvRows.length > 0}
onCreateTransaction={() => setIsDialogOpen(true)}
/>
) : (
<div className="space-y-3">
{/* Source filter — only render when both sources have content
to filter between, otherwise it'd be a no-op chip row. */}
{skvUnmatched.length > 0 && uncategorizedTransactions.length > 0 && (
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">Källa:</span>
<button
onClick={() => setSourceFilter('all')}
className={cn(
'rounded-full border px-3 py-1 transition-colors',
sourceFilter === 'all'
? 'border-foreground bg-foreground text-background'
: 'border-border text-muted-foreground hover:text-foreground',
)}
>
Alla ({uncategorizedTransactions.length + skvUnmatched.length})
</button>
<button
onClick={() => setSourceFilter('bank')}
className={cn(
'rounded-full border px-3 py-1 transition-colors',
sourceFilter === 'bank'
? 'border-foreground bg-foreground text-background'
: 'border-border text-muted-foreground hover:text-foreground',
)}
>
Bank ({uncategorizedTransactions.length})
</button>
<button
onClick={() => setSourceFilter('skatteverket')}
className={cn(
'flex items-center gap-1 rounded-full border px-3 py-1 transition-colors',
sourceFilter === 'skatteverket'
? 'border-foreground bg-foreground text-background'
: 'border-border text-muted-foreground hover:text-foreground',
)}
>
<Landmark className="h-3 w-3" />
Skatteverket ({skvUnmatched.length})
</button>
</div>
)}
<AnimatePresence mode="popLayout">
{uncategorizedTransactions.map((transaction) => (
<TransactionInboxCard
key={transaction.id}
transaction={transaction}
suggestions={categorySuggestions[transaction.id]}
templateSuggestions={templateSuggestions[transaction.id]}
processingId={processingId}
isBatchMode={isBatchMode}
isSelected={selectedIds.has(transaction.id)}
entityType={entityType}
onCategorize={handleCategorize}
onMarkPrivate={handleMarkPrivate}
onOpenMatchDialog={openMatchDialog}
onOpenCategoryDialog={openCategoryDialog}
onDelete={handleDeleteTransaction}
onOpenQuickReview={handleOpenQuickReview}
onOpenTemplateReview={handleOpenTemplateReview}
onToggleSelect={toggleBatchSelect}
/>
))}
{inboxItems.map(item =>
item.source === 'bank' ? (
<TransactionInboxCard
key={`bank-${item.data.id}`}
transaction={item.data}
suggestions={categorySuggestions[item.data.id]}
templateSuggestions={templateSuggestions[item.data.id]}
skvCounterpartDate={bankToSkvHints.get(item.data.id)}
processingId={processingId}
isBatchMode={isBatchMode}
isSelected={selectedIds.has(item.data.id)}
entityType={entityType}
onCategorize={handleCategorize}
onMarkPrivate={handleMarkPrivate}
onOpenMatchDialog={openMatchDialog}
onOpenCategoryDialog={openCategoryDialog}
onDelete={handleDeleteTransaction}
onOpenQuickReview={handleOpenQuickReview}
onOpenTemplateReview={handleOpenTemplateReview}
onToggleSelect={toggleBatchSelect}
/>
) : (
<SkattekontoInboxCard
key={`skv-${item.data.id}`}
row={item.data}
matchSuggestion={item.data.match_suggestion}
processing={skvProcessingId === item.data.id}
onBokfor={handleSkvBokfor}
onMatch={r => setSkvMatchTarget(r)}
/>
),
)}
</AnimatePresence>
</div>
)
) : (
<TransactionHistoryList
transactions={transactions}
skvRows={skvRows}
onOpenMatchDialog={openMatchDialog}
onOpenCategoryDialog={openCategoryDialog}
onDelete={handleDeleteTransaction}
onSkvBokfor={handleSkvBokfor}
onSkvMatch={r => setSkvMatchTarget(r)}
hasMore={hasMore}
isLoadingMore={isLoadingMore}
onLoadMore={loadMoreTransactions}
@@ -1150,6 +1333,13 @@ export default function TransactionsPage() {
</Dialog>
<DestructiveConfirmDialog {...deleteDialogProps} />
<SkattekontoMatchDialog
row={skvMatchTarget}
open={!!skvMatchTarget}
onClose={() => setSkvMatchTarget(null)}
onMatched={handleSkvMatched}
/>
</div>
)
}
+3 -3
View File
@@ -5,7 +5,7 @@ import { validateBody } from '@/lib/api/validate'
import { UpdateEmployeeSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { encryptPersonnummer, extractLast4, validatePersonnummer } from '@/lib/salary/personnummer'
import { decryptPersonnummer, encryptPersonnummer, extractLast4, maskPersonnummer, validatePersonnummer } from '@/lib/salary/personnummer'
ensureInitialized()
@@ -34,7 +34,7 @@ export async function GET(
return NextResponse.json({
data: {
...employee,
personnummer: `XXXXXXXX-${employee.personnummer_last4}`,
personnummer: maskPersonnummer(decryptPersonnummer(employee.personnummer)),
},
})
}
@@ -117,7 +117,7 @@ export async function PATCH(
return NextResponse.json({
data: {
...updated,
personnummer: `XXXXXXXX-${updated.personnummer_last4}`,
personnummer: maskPersonnummer(decryptPersonnummer(updated.personnummer)),
},
})
}
+4 -4
View File
@@ -5,7 +5,7 @@ import { validateBody } from '@/lib/api/validate'
import { CreateEmployeeSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { encryptPersonnummer, extractLast4, validatePersonnummer } from '@/lib/salary/personnummer'
import { decryptPersonnummer, encryptPersonnummer, extractLast4, maskPersonnummer, validatePersonnummer } from '@/lib/salary/personnummer'
ensureInitialized()
@@ -34,10 +34,10 @@ export async function GET(request: Request) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
// Mask personnummer — only show last 4
// Mask personnummer — show birthdate, hide the 4-digit suffix
const masked = (data || []).map(emp => ({
...emp,
personnummer: `XXXXXXXX-${emp.personnummer_last4}`,
personnummer: maskPersonnummer(decryptPersonnummer(emp.personnummer)),
}))
return NextResponse.json({ data: masked })
@@ -115,7 +115,7 @@ export async function POST(request: Request) {
return NextResponse.json({
data: {
...employee,
personnummer: `XXXXXXXX-${last4}`,
personnummer: maskPersonnummer(body.personnummer),
},
}, { status: 201 })
}
@@ -5,7 +5,7 @@ import { requireCompanyId } from '@/lib/company/context'
import { renderToBuffer } from '@react-pdf/renderer'
import { PayslipPDF } from '@/lib/salary/pdf/payslip-template'
import type { PayslipData, PayslipLineItem } from '@/lib/salary/pdf/payslip-template'
import { maskPersonnummer } from '@/lib/salary/personnummer'
import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer'
ensureInitialized()
@@ -41,7 +41,7 @@ export async function GET(
// Load salary run employee
const { data: sre } = await supabase
.from('salary_run_employees')
.select('*, employee:employees(first_name, last_name, personnummer_last4, employment_type, tax_table_number, tax_column, clearing_number, bank_account_number), line_items:salary_line_items(*)')
.select('*, employee:employees(first_name, last_name, personnummer, personnummer_last4, employment_type, tax_table_number, tax_column, clearing_number, bank_account_number), line_items:salary_line_items(*)')
.eq('salary_run_id', id)
.eq('employee_id', employeeId)
.single()
@@ -62,7 +62,7 @@ export async function GET(
}
const emp = sre.employee as {
first_name: string; last_name: string; personnummer_last4: string;
first_name: string; last_name: string; personnummer: string; personnummer_last4: string;
employment_type: string; tax_table_number: number | null; tax_column: number;
clearing_number: string | null; bank_account_number: string | null;
}
@@ -104,7 +104,7 @@ export async function GET(
companyName: company.name,
companyOrgNumber: company.org_number || '',
employeeName: `${emp.first_name} ${emp.last_name}`,
personnummerMasked: maskPersonnummer(emp.personnummer_last4),
personnummerMasked: maskPersonnummer(decryptPersonnummer(emp.personnummer)),
employmentType: EMPLOYMENT_LABELS[emp.employment_type] || emp.employment_type,
periodYear: run.period_year,
periodMonth: run.period_month,
@@ -7,7 +7,7 @@ import { getEmailService } from '@/lib/email/service'
import { renderToBuffer } from '@react-pdf/renderer'
import { PayslipPDF } from '@/lib/salary/pdf/payslip-template'
import type { PayslipData, PayslipLineItem } from '@/lib/salary/pdf/payslip-template'
import { maskPersonnummer } from '@/lib/salary/personnummer'
import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer'
ensureInitialized()
@@ -58,7 +58,7 @@ export async function POST(
// Load employees with line items
const { data: runEmployees } = await supabase
.from('salary_run_employees')
.select('*, employee:employees(first_name, last_name, personnummer_last4, employment_type, email, tax_table_number, tax_column, clearing_number, bank_account_number), line_items:salary_line_items(*)')
.select('*, employee:employees(first_name, last_name, personnummer, personnummer_last4, employment_type, email, tax_table_number, tax_column, clearing_number, bank_account_number), line_items:salary_line_items(*)')
.eq('salary_run_id', id)
if (!runEmployees || runEmployees.length === 0) {
@@ -75,7 +75,7 @@ export async function POST(
for (const sre of runEmployees) {
const emp = sre.employee as {
first_name: string; last_name: string; personnummer_last4: string;
first_name: string; last_name: string; personnummer: string; personnummer_last4: string;
employment_type: string; email: string | null; tax_table_number: number | null;
tax_column: number; clearing_number: string | null; bank_account_number: string | null;
} | null
@@ -116,7 +116,7 @@ export async function POST(
companyName: company.name,
companyOrgNumber: company.org_number || '',
employeeName: `${emp.first_name} ${emp.last_name}`,
personnummerMasked: maskPersonnummer(emp.personnummer_last4),
personnummerMasked: maskPersonnummer(decryptPersonnummer(emp.personnummer)),
employmentType: emp.employment_type,
periodYear: run.period_year,
periodMonth: run.period_month,
+3 -2
View File
@@ -4,6 +4,7 @@ import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { formatRedovisare } from '@/lib/skatteverket/format'
import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer'
ensureInitialized()
@@ -32,7 +33,7 @@ export async function GET(
// Load employees with line items
const { data: employees } = await supabase
.from('salary_run_employees')
.select('*, employee:employees(id, first_name, last_name, personnummer_last4, employment_type), line_items:salary_line_items(*)')
.select('*, employee:employees(id, first_name, last_name, personnummer, personnummer_last4, employment_type), line_items:salary_line_items(*)')
.eq('salary_run_id', id)
.order('created_at')
@@ -61,7 +62,7 @@ export async function GET(
...emp,
employee: emp.employee ? {
...emp.employee,
personnummer: `XXXXXXXX-${emp.employee.personnummer_last4}`,
personnummer: maskPersonnummer(decryptPersonnummer(emp.employee.personnummer)),
} : null,
})),
},