* fix(skattekonto): deep-link Skapa verifikat manuellt to a prefilled, auto-linked verifikat "Skapa verifikat manuellt" in the SkattekontoBookDialog routed to plain /bookkeeping: the user landed on the list with no form, no prefill and no link to the row (reported by a user for a Slutlig skatt event, which has no booking rule by design). The CTA now deep-links to /bookkeeping?skv_tx=... carrying the row's id, date, text and amount. The bookkeeping page opens the Nytt verifikat dialog prefilled (1630 on the correct side per the booking sign convention, balanced counter line with the motkonto left to pick, date and description set) and, once the verifikat is saved (posted or draft), links it back to the skattekonto row via the existing match endpoint. A failed link degrades to a destructive toast pointing at the manual "Matcha mot verifikat" path. The URL params are prefill convenience only: the match route re-validates ownership, ALREADY_BOOKED and ENTRY_ALREADY_LINKED server-side. The parse/build/line-shaping contract lives in core lib (lib/skatteverket/manual-verifikat-prefill.ts, unit-tested) because the bookkeeping page cannot import from the extension. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skattekonto): keep deep-link payload out of the URL + share the 1630 constant Resolves the PR #1621 review findings in one pass: - Compliance swarm (GDPR Art.5(1)(f), ISO A.8.12): the deep link no longer carries date, text and amount as query params, where they would persist in browser history, access logs and Referer headers. The row payload is staged in sessionStorage, consumed single-use and validated against the opaque skv_tx id, which is all the URL exposes. A missing or mismatched payload degrades to the plain /bookkeeping list; the auto-link itself is still validated server-side by the match route. - Swedish accounting review note: SKATTEKONTO_ACCOUNT ('1630') is now imported by the extension's booking and match libs from the core prefill lib instead of being duplicated, so prefill and server-side booking cannot drift. - CodeRabbit docstring warning: the new lib exports carry docstrings. Storage is injectable (PrefillStorage) so the node-env tests cover the round-trip, single-use semantics, id mismatch, malformed payloads and a throwing privacy-mode storage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(skattekonto): record the sessionStorage staging window as accepted residual risk The compliance swarm's remaining LOW finding (ISO A.8.12) offers documentation as its remediation path: an XSS attacker already reads the full ledger via the session's authenticated APIs, so the sub-second sessionStorage staging window adds no capability worth a server-issued token roundtrip. Recorded in the lib header and DECISIONS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
260 lines
8.9 KiB
TypeScript
260 lines
8.9 KiB
TypeScript
'use client'
|
|
|
|
import { useState } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { useTranslations } from 'next-intl'
|
|
import { Button } from '@/components/ui/button'
|
|
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
|
|
import { useToast } from '@/components/ui/use-toast'
|
|
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
|
import { stageSkvManualPrefill } from '@/lib/skatteverket/manual-verifikat-prefill'
|
|
import { cn, formatCurrency, formatDate } from '@/lib/utils'
|
|
import { Loader2 } from 'lucide-react'
|
|
import type {
|
|
SkattekontoBatchResult,
|
|
SkattekontoBatchRowResult,
|
|
SkattekontoTransactionWithSuggestion,
|
|
} from '@/types/skatteverket'
|
|
|
|
/**
|
|
* Inline booking for one skattekonto row: confirm-and-post without leaving
|
|
* the list (convention 10: confirm up front). The primary "Bokför" runs the
|
|
* server-side draft+commit (bokfor-batch with one id) so the row lands as a
|
|
* posted verifikat in one step; "Öppna som utkast" preserves the old
|
|
* draft-then-review path, but with a client-side navigation instead of a
|
|
* full reload.
|
|
*
|
|
* Suggestion states drive the layout:
|
|
* - object: rule matched → direct booking is the primary action
|
|
* - null: computed, no rule matched → the draft endpoint re-derives the same
|
|
* null rule and rejects with NO_COUNTER_ACCOUNT, so no draft CTA here;
|
|
* route to the match flow (onMatch) or manual creation in /bookkeeping
|
|
* - undefined: not computed (kommande rows) → plain draft confirm
|
|
*/
|
|
export default function SkattekontoBookDialog({
|
|
row,
|
|
open,
|
|
onOpenChange,
|
|
onBooked,
|
|
onMatch,
|
|
}: {
|
|
row: SkattekontoTransactionWithSuggestion | null
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
onBooked: (rowId: string, result: SkattekontoBatchRowResult) => void
|
|
// Opens the existing "Matcha mot verifikat" flow for this row; the dialog
|
|
// closes itself first. Optional: callers without a match flow fall back to
|
|
// manual verifikat creation only.
|
|
onMatch?: () => void
|
|
}) {
|
|
const t = useTranslations('skv_book_dialog')
|
|
const { toast } = useToast()
|
|
const router = useRouter()
|
|
const [isBooking, setIsBooking] = useState(false)
|
|
const [isOpeningDraft, setIsOpeningDraft] = useState(false)
|
|
|
|
if (!row) return null
|
|
|
|
const amount = Number(row.belopp_skatteverket)
|
|
const suggestion = row.booking_suggestion
|
|
const canBookDirectly = suggestion != null
|
|
// null (not undefined) means the rules were evaluated and none matched:
|
|
// the draft endpoint would be a guaranteed 422 (NO_COUNTER_ACCOUNT).
|
|
const noRuleMatched = suggestion === null
|
|
|
|
async function handleBook() {
|
|
if (!row) return
|
|
setIsBooking(true)
|
|
try {
|
|
const res = await fetch(
|
|
'/api/extensions/ext/skatteverket/skattekonto/transaktioner/bokfor-batch',
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ids: [row.id] }),
|
|
},
|
|
)
|
|
const json = await res.json()
|
|
if (!res.ok) {
|
|
toast({
|
|
title: t('book_failed'),
|
|
description: getErrorMessage(json, { statusCode: res.status }),
|
|
variant: 'destructive',
|
|
})
|
|
return
|
|
}
|
|
const result = (json.data as SkattekontoBatchResult).results[0]
|
|
if (!result) {
|
|
toast({ title: t('book_failed'), variant: 'destructive' })
|
|
return
|
|
}
|
|
if (result.ok) {
|
|
onOpenChange(false)
|
|
onBooked(row.id, result)
|
|
return
|
|
}
|
|
if (result.error_code === 'COMMIT_FAILED' && result.journal_entry_id) {
|
|
// The draft exists and is linked: hand the user over to it instead
|
|
// of leaving a half-done state behind a destructive toast.
|
|
toast({
|
|
title: t('commit_failed_title'),
|
|
description: result.error_message,
|
|
variant: 'destructive',
|
|
})
|
|
onOpenChange(false)
|
|
router.push(`/bookkeeping/${result.journal_entry_id}`)
|
|
return
|
|
}
|
|
toast({
|
|
title: t('book_failed'),
|
|
description: result.error_message,
|
|
variant: 'destructive',
|
|
})
|
|
} catch (err) {
|
|
toast({
|
|
title: t('book_failed'),
|
|
description: err instanceof Error ? getErrorMessage(err) : undefined,
|
|
variant: 'destructive',
|
|
})
|
|
} finally {
|
|
setIsBooking(false)
|
|
}
|
|
}
|
|
|
|
async function handleOpenDraft() {
|
|
if (!row) return
|
|
setIsOpeningDraft(true)
|
|
try {
|
|
const res = await fetch(
|
|
`/api/extensions/ext/skatteverket/skattekonto/transaktioner/${row.id}/bokfor`,
|
|
{ method: 'POST' },
|
|
)
|
|
const json = await res.json()
|
|
if (!res.ok) {
|
|
// Map the parsed body plus the status, never `new Error(json.error)`:
|
|
// the mapper would discard the route's own Swedish reason.
|
|
toast({
|
|
title: t('book_failed'),
|
|
description: getErrorMessage(json, { statusCode: res.status }),
|
|
variant: 'destructive',
|
|
})
|
|
return
|
|
}
|
|
toast({ title: t('draft_created_title'), description: t('draft_created_description') })
|
|
onOpenChange(false)
|
|
router.push(`/bookkeeping/${json.data.entry.id}`)
|
|
} catch (err) {
|
|
toast({
|
|
title: t('book_failed'),
|
|
description: err instanceof Error ? getErrorMessage(err) : undefined,
|
|
variant: 'destructive',
|
|
})
|
|
} finally {
|
|
setIsOpeningDraft(false)
|
|
}
|
|
}
|
|
|
|
function handleMatchInstead() {
|
|
onOpenChange(false)
|
|
onMatch?.()
|
|
}
|
|
|
|
function handleManualCreate() {
|
|
if (!row) return
|
|
// Deep-link into /bookkeeping's Nytt verifikat dialog: the row payload is
|
|
// staged in sessionStorage (only the opaque id rides in the URL) so the
|
|
// form opens prefilled (1630 + counter line) and the created verifikat is
|
|
// auto-linked back to this skattekonto row via the match endpoint. Plain
|
|
// '/bookkeeping' here was a dead end: the user landed on the list with no
|
|
// form, no prefill and no link.
|
|
onOpenChange(false)
|
|
router.push(stageSkvManualPrefill(row))
|
|
}
|
|
|
|
return (
|
|
<ConfirmationDialog
|
|
open={open}
|
|
onOpenChange={onOpenChange}
|
|
title={t('title')}
|
|
isSubmitting={isBooking || isOpeningDraft}
|
|
confirmLabel={
|
|
canBookDirectly
|
|
? t('confirm_book')
|
|
: noRuleMatched
|
|
? onMatch
|
|
? t('match_cta')
|
|
: t('manual_create_cta')
|
|
: t('open_draft')
|
|
}
|
|
// A draft is still editable: the immutable-verifikat warning only
|
|
// applies when the confirm commits directly.
|
|
warningText={canBookDirectly ? t('commit_warning') : ''}
|
|
onConfirm={
|
|
canBookDirectly
|
|
? handleBook
|
|
: noRuleMatched
|
|
? onMatch
|
|
? handleMatchInstead
|
|
: handleManualCreate
|
|
: handleOpenDraft
|
|
}
|
|
extraActions={
|
|
canBookDirectly ? (
|
|
<Button
|
|
variant="ghost"
|
|
onClick={handleOpenDraft}
|
|
disabled={isBooking || isOpeningDraft}
|
|
className="min-h-11 w-full sm:w-auto text-muted-foreground"
|
|
>
|
|
{isOpeningDraft && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
{t('open_draft')}
|
|
</Button>
|
|
) : noRuleMatched && onMatch ? (
|
|
<Button
|
|
variant="ghost"
|
|
onClick={handleManualCreate}
|
|
disabled={isBooking || isOpeningDraft}
|
|
className="min-h-11 w-full sm:w-auto text-muted-foreground"
|
|
>
|
|
{t('manual_create_cta')}
|
|
</Button>
|
|
) : undefined
|
|
}
|
|
>
|
|
<dl className="space-y-3 py-2 text-sm">
|
|
<div className="flex items-baseline justify-between gap-4">
|
|
<dt className="shrink-0 text-muted-foreground">{t('event_label')}</dt>
|
|
<dd className="text-right">{row.transaktionstext}</dd>
|
|
</div>
|
|
<div className="flex items-baseline justify-between gap-4">
|
|
<dt className="text-muted-foreground">{t('date_label')}</dt>
|
|
<dd className="tabular-nums">{formatDate(row.transaktionsdatum)}</dd>
|
|
</div>
|
|
<div className="flex items-baseline justify-between gap-4">
|
|
<dt className="text-muted-foreground">{t('amount_label')}</dt>
|
|
<dd className={cn('tabular-nums', amount > 0 && 'text-success')}>
|
|
{amount > 0 ? '+' : ''}
|
|
{formatCurrency(amount)}
|
|
</dd>
|
|
</div>
|
|
{suggestion ? (
|
|
<div className="flex items-baseline justify-between gap-4">
|
|
<dt className="text-muted-foreground">{t('posting_label')}</dt>
|
|
<dd className="text-right tabular-nums">
|
|
{t('posting_value', {
|
|
account: suggestion.account_name
|
|
? `${suggestion.account} ${suggestion.account_name}`
|
|
: suggestion.account,
|
|
})}
|
|
</dd>
|
|
</div>
|
|
) : suggestion === null ? (
|
|
<p className="pt-1 text-xs leading-5 text-muted-foreground">
|
|
{t('no_rule_matched')}
|
|
</p>
|
|
) : null}
|
|
</dl>
|
|
</ConfirmationDialog>
|
|
)
|
|
}
|