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:
co-authored by
Claude Opus 4.7
parent
eb77ad50b5
commit
980f29dae8
@@ -1393,6 +1393,7 @@ function VatDeclarationView() {
|
||||
year={year}
|
||||
period={period}
|
||||
hasData={data !== null}
|
||||
rutor={data?.rutor ?? null}
|
||||
/>
|
||||
|
||||
{!data && !loading && !error && (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user