Files
accounted/components/skattekonto/SkattekontoMatchDialog.tsx
T
MattssonandClaude Opus 4.7 980f29dae8 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>
2026-05-12 18:04:48 +02:00

220 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useEffect, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
import type { StoredSkattekontoTransaction } from '@/types/skatteverket'
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'
}
/**
* Shared dialog for linking a skattekonto_transactions row to an existing
* journal entry. Used by both /skattekonto and /transactions so we don't
* have two copies of the same dialog drifting apart.
*
* The dialog owns its own data fetch — pass the row + open flag and it
* handles the rest. On successful match it calls onMatched(), letting the
* caller refresh its data.
*/
export function SkattekontoMatchDialog({
row,
open,
onClose,
onMatched,
}: {
row: StoredSkattekontoTransaction | null
open: boolean
onClose: () => void
onMatched: () => void
}) {
const { toast } = useToast()
const [candidates, setCandidates] = useState<MatchCandidate[] | null>(null)
const [loading, setLoading] = useState(false)
const [submittingId, setSubmittingId] = useState<string | null>(null)
useEffect(() => {
if (!open || !row) {
setCandidates(null)
return
}
let cancelled = false
setLoading(true)
;(async () => {
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/skattekonto/transaktioner/${row.id}/match-candidates`,
)
const json = await res.json()
if (cancelled) return
if (!res.ok) {
throw new Error(json.error || 'Kunde inte söka kandidater')
}
setCandidates(json.data.candidates as MatchCandidate[])
} catch (err) {
if (cancelled) return
toast({
title: 'Kunde inte hämta kandidater',
description: err instanceof Error ? err.message : undefined,
variant: 'destructive',
})
onClose()
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [open, row, toast, onClose])
async function confirmMatch(journalEntryId: string) {
if (!row) return
setSubmittingId(journalEntryId)
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/skattekonto/transaktioner/${row.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' })
onMatched()
onClose()
} catch (err) {
toast({
title: 'Kunde inte koppla transaktionen',
description: err instanceof Error ? err.message : undefined,
variant: 'destructive',
})
} finally {
setSubmittingId(null)
}
}
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={() => confirmMatch(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>
)
}