Add/pdf and templates (#705)

* fix(invoices): apply configured voucher series to payments + preview next voucher

The booking engine resolves the series from
default_voucher_series_per_source_type, but the global "Standardserie"
dropdown wrote a separate field the engine ignored, and cash-method invoice
payments (invoice_cash_payment) weren't exposed in settings — so configured
series were silently dropped to "A".

- Expose cash/private payment source types in the per-source-type form
- Write the global default through to the map on save, keeping overrides
- Resolve voucher-sequences/next by source_type (+date) to match the engine
- Show the upcoming voucher (V2) in the payment dialog title
- Share resolveInvoicePaymentSourceType so preview and booking can't drift

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

* fix(salary): keep AGI panel in sync with Skatteverket signing state

The AGI panel mixed run-scoped generation state (agi_generated_at,
agi_declarations) with period-scoped submission state (extension_data
agi_submission_{period}), so the two could drift and present
contradictory UI. Reconcile them:

- Auto-detect a Mina Sidor BankID signature: while awaiting_signing,
  poll /agi/kvittenser on mount and on tab refocus so the panel flips
  to "signed" (hiding the signing actions) without a manual
  "Hamta kvittens" click.
- Warn instead of offering to sign when the locked granskningsunderlag
  predates the run's latest AGI generation (draftIsStale) — avoids
  filing superseded figures.
- Self-heal a stale "AGI-XML saknas" error once the run's AGI is
  (re)generated out-of-band (MCP/API/other tab).
- Refetch the salary run on tab focus so agi_generated_at reflects
  out-of-band generation without a hard reload.
- /agi/lasUpp now clears the cached agi_submission_{period} record, so
  unlocking drops the panel back to the pre-submission state instead of
  stranding it on a released "redo att signeras" draft.

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

* feat: Implement VAT registration handling and invoice item line types

- Added VAT registration check in commitCreateInvoice to set VAT rate to 0% for non-VAT registered companies.
- Updated invoice creation logic to reflect 'exempt' VAT treatment and adjusted related fields accordingly.
- Introduced support for free-text and blank spacer rows in invoice items by adding a new line_type field.
- Enhanced invoice and credit note handling to accommodate new line types.
- Added new localized messages for text rows in English and Swedish.
- Created tests for salary run approval logic, ensuring bank details are validated correctly.
- Implemented effective net payout calculation for salary runs, considering tax overrides.
- Added SQL migrations to support new invoice item line types and accounting method awareness for linking invoices to vouchers.

* feat(articles): artikelregister with revenue account + VAT rate per article

Article register (non-inventory) with per-article VAT rate and optional
BAS class-3 revenue-account override. Includes API routes, UI pages,
MCP tools, pending-operation staging, and the activate-or-create
account flow (ACCOUNTS_NOT_IN_CHART -> ActivateAccountsDialog,
unknown numbers -> AddAccountDialog) reusing the journal entry UX.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bookkeeping): no-doc-required batch + bulk-missing endpoints

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(payments): supplier payment lines + cash-method invoice matching

Shared payment-line proposal for supplier invoices, improved
match-invoice/match-supplier-invoice flows (kontantmetoden-aware),
and voucher-link support without requiring a 151x clearing entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bookkeeping): new journal entry dialog, SIE import tweaks, misc

New journal entry dialog component, journal list/page updates,
invoice editor updates, SIE import adjustments, transaction ingest
and api-key tweaks, pr-agent workflow update.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(invoices): implement tax reduction features and localization updates

* feat(tests): add VAT registration gate to pending operations commit tests

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-06-10 13:52:24 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 21cfcbe180
commit f9ea9c0082
54 changed files with 2725 additions and 532 deletions
+64 -9
View File
@@ -1,6 +1,6 @@
'use client'
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
@@ -13,7 +13,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { ChevronDown, Loader2, Lock } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { CreateArticleInput } from '@/types'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog'
import type { BASAccount, CreateArticleInput } from '@/types'
// Unit list mirrors the invoice line editor (app/(dashboard)/invoices/new/page.tsx).
const UNITS = ['st', 'tim', 'dag', 'månad', 'km', 'kg'] as const
@@ -35,6 +37,28 @@ export default function ArticleForm({
}: ArticleFormProps) {
const { canWrite } = useCanWrite()
const t = useTranslations('form_article')
// Active class-3 (revenue) accounts for the combobox. The combobox accepts
// unknown 4-digit numbers optimistically — the API answers with
// ACCOUNTS_NOT_IN_CHART for activatable BAS accounts, and the host page's
// ActivateAccountsDialog flow takes over (same UX as the journal entry form).
const [revenueAccounts, setRevenueAccounts] = useState<BASAccount[]>([])
// Inline account creation: what the user typed in the combobox when they hit
// "Skapa konto" — non-null opens AddAccountDialog prefilled with it.
const [createAccountPrefill, setCreateAccountPrefill] = useState<string | null>(null)
async function fetchRevenueAccounts() {
try {
const res = await fetch('/api/bookkeeping/accounts?class=3')
const body = await res.json()
setRevenueAccounts((body?.data as BASAccount[]) || [])
} catch {
// Non-fatal: the combobox degrades to free 4-digit entry.
}
}
useEffect(() => {
fetchRevenueAccounts()
}, [])
// Open the advanced section by default when it already holds data, so an
// edit never hides a value the user previously set.
const [advancedOpen, setAdvancedOpen] = useState(
@@ -73,6 +97,7 @@ export default function ArticleForm({
handleSubmit,
watch,
control,
setValue,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
@@ -232,13 +257,18 @@ export default function ArticleForm({
<div className="space-y-4 pt-4">
{/* Revenue account */}
<div className="space-y-2">
<Label htmlFor="revenue_account">{t('revenue_account_label')}</Label>
<Input
id="revenue_account"
inputMode="numeric"
placeholder={t('revenue_account_placeholder')}
className="tabular-nums"
{...register('revenue_account')}
<Label>{t('revenue_account_label')}</Label>
<Controller
name="revenue_account"
control={control}
render={({ field }) => (
<AccountCombobox
value={field.value || ''}
accounts={revenueAccounts}
onChange={field.onChange}
onCreateAccount={(prefill) => setCreateAccountPrefill(prefill)}
/>
)}
/>
<p className="text-xs text-muted-foreground">{t('revenue_account_hint')}</p>
</div>
@@ -332,6 +362,31 @@ export default function ArticleForm({
)}
</Button>
</div>
{/* Inline custom-account creation (renders in a portal, outside the form).
After create: refresh the chart and select the new number as the
article's revenue account — mirrors the journal entry form. */}
<AddAccountDialog
open={createAccountPrefill != null}
onOpenChange={(next) => {
if (!next) setCreateAccountPrefill(null)
}}
initialAccountNumber={
createAccountPrefill && /^\d{1,4}$/.test(createAccountPrefill)
? createAccountPrefill
: undefined
}
initialAccountName={
createAccountPrefill && !/^\d{1,4}$/.test(createAccountPrefill)
? createAccountPrefill
: undefined
}
onCreated={async (account) => {
await fetchRevenueAccounts()
setValue('revenue_account', account.account_number, { shouldDirty: true })
setCreateAccountPrefill(null)
}}
/>
</form>
)
}
@@ -21,6 +21,9 @@ export interface ActivateAccountsDialogProps {
// for a number that isn't in the BAS catalogue. The host should close this
// dialog and open AddAccountDialog prefilled with the number.
onCreateUnknown?: (accountNumber: string) => void
// Confirm button label. Defaults to the bookkeeping wording; non-booking
// hosts (e.g. the article register) pass their own.
confirmLabel?: string
}
interface BasLookupRow {
@@ -35,6 +38,7 @@ export function ActivateAccountsDialog({
onConfirm,
onCancel,
onCreateUnknown,
confirmLabel,
}: ActivateAccountsDialogProps) {
const [rows, setRows] = useState<BasLookupRow[]>([])
const [loading, setLoading] = useState(false)
@@ -149,7 +153,7 @@ export function ActivateAccountsDialog({
) : (
<>
<Plus className="mr-2 h-4 w-4" />
Aktivera och bokför
{confirmLabel ?? 'Aktivera och bokför'}
</>
)}
</Button>
+41 -26
View File
@@ -13,6 +13,8 @@ interface ReviewItem {
unit: string
unit_price: number
vat_rate?: number
/** 'text' rows are free-text/blank lines — description only, no amounts. */
line_type?: 'product' | 'text'
}
interface InvoiceReviewContentProps {
@@ -62,9 +64,10 @@ export function InvoiceReviewContent({
non_eu_business: t('customer_type_non_eu_business'),
}
// Calculate per-rate VAT breakdown
// Calculate per-rate VAT breakdown (free-text rows carry no amounts).
const vatByRate = new Map<number, number>()
for (const item of items) {
if (item.line_type === 'text') continue
const rate = item.vat_rate ?? 0
const lineTotal = item.quantity * item.unit_price
const lineVat = Math.round(lineTotal * rate / 100 * 100) / 100
@@ -118,36 +121,48 @@ export function InvoiceReviewContent({
</tr>
</thead>
<tbody>
{items.map((item, index) => (
<tr key={index} className="border-b last:border-0">
<td className="py-2">{item.description}</td>
<td className="py-2 text-right">{item.quantity}</td>
<td className="py-2 text-center">{item.unit}</td>
<td className="py-2 text-right">{formatCurrency(item.unit_price, currency)}</td>
{showVatColumn && (
<td className="py-2 text-right">{item.vat_rate ?? 0}%</td>
)}
<td className="py-2 text-right">
{formatCurrency(item.quantity * item.unit_price, currency)}
</td>
</tr>
))}
{items.map((item, index) =>
item.line_type === 'text' ? (
<tr key={index} className="border-b last:border-0">
<td className="py-2 text-muted-foreground" colSpan={showVatColumn ? 6 : 5}>
{item.description || ' '}
</td>
</tr>
) : (
<tr key={index} className="border-b last:border-0">
<td className="py-2">{item.description}</td>
<td className="py-2 text-right">{item.quantity}</td>
<td className="py-2 text-center">{item.unit}</td>
<td className="py-2 text-right">{formatCurrency(item.unit_price, currency)}</td>
{showVatColumn && (
<td className="py-2 text-right">{item.vat_rate ?? 0}%</td>
)}
<td className="py-2 text-right">
{formatCurrency(item.quantity * item.unit_price, currency)}
</td>
</tr>
)
)}
</tbody>
</table>
</div>
<div className="sm:hidden space-y-2">
{items.map((item, index) => (
<div key={index} className="border rounded-lg p-3 text-sm space-y-1.5">
<p className="font-medium">{item.description}</p>
<div className="flex items-center justify-between text-muted-foreground">
<span>{item.quantity} {item.unit} × {formatCurrency(item.unit_price, currency)}</span>
{showVatColumn && <span className="text-xs">{t('mobile_vat_suffix', { rate: item.vat_rate ?? 0 })}</span>}
{items.map((item, index) =>
item.line_type === 'text' ? (
<p key={index} className="text-sm text-muted-foreground px-1">{item.description || ' '}</p>
) : (
<div key={index} className="border rounded-lg p-3 text-sm space-y-1.5">
<p className="font-medium">{item.description}</p>
<div className="flex items-center justify-between text-muted-foreground">
<span>{item.quantity} {item.unit} × {formatCurrency(item.unit_price, currency)}</span>
{showVatColumn && <span className="text-xs">{t('mobile_vat_suffix', { rate: item.vat_rate ?? 0 })}</span>}
</div>
<p className="text-right font-medium">
{formatCurrency(item.quantity * item.unit_price, currency)}
</p>
</div>
<p className="text-right font-medium">
{formatCurrency(item.quantity * item.unit_price, currency)}
</p>
</div>
))}
)
)}
</div>
{/* Totals */}
+16 -2
View File
@@ -43,6 +43,13 @@ interface LinkVoucherPickerProps {
onCancel: () => void
/** Defaults to 'customer_invoice' for back-compat with existing call sites. */
mode?: VoucherPickerMode
/**
* Company accounting method. On 'cash' (kontantmetoden) the matcher searches
* bank/cash debits (19xx) instead of AR credits (1510), so the intro + empty
* copy switch to describe that. Defaults to 'accrual'. Only affects the
* customer-invoice mode's wording — the data path is decided server-side.
*/
accountingMethod?: 'accrual' | 'cash'
}
function candidateAmount(c: VoucherCandidate): number {
@@ -72,10 +79,17 @@ export default function LinkVoucherPicker({
onLinked,
onCancel,
mode = 'customer_invoice',
accountingMethod = 'accrual',
}: LinkVoucherPickerProps) {
const { toast } = useToast()
const t = useTranslations('invoice_link_voucher')
// Kontantmetoden links against a bank/cash debit (19xx), not an AR credit —
// describe that. Only the customer-invoice copy varies by method.
const isCash = mode === 'customer_invoice' && accountingMethod === 'cash'
const introKey = isCash ? 'intro_cash' : 'intro'
const emptyDescriptionKey = isCash ? 'empty_description_cash' : 'empty_description'
const apiBase =
mode === 'supplier_invoice'
? `/api/supplier-invoices/${invoiceId}`
@@ -173,7 +187,7 @@ export default function LinkVoucherPicker({
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t('intro')}</p>
<p className="text-sm text-muted-foreground">{t(introKey)}</p>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
@@ -194,7 +208,7 @@ export default function LinkVoucherPicker({
) : filtered.length === 0 ? (
<div className="rounded-lg border border-dashed bg-muted/30 p-6 text-center">
<p className="text-sm font-medium">{t('empty_title')}</p>
<p className="mt-1 text-xs text-muted-foreground">{t('empty_description')}</p>
<p className="mt-1 text-xs text-muted-foreground">{t(emptyDescriptionKey)}</p>
</div>
) : (
<ul className="space-y-2 max-h-[320px] overflow-y-auto">
+53 -4
View File
@@ -19,7 +19,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { useToast } from '@/components/ui/use-toast'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import LinkVoucherPicker from '@/components/invoices/LinkVoucherPicker'
import { proposePaymentLines } from '@/lib/bookkeeping/propose-payment-lines'
import { proposePaymentLines, resolveInvoicePaymentSourceType } from '@/lib/bookkeeping/propose-payment-lines'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatCurrency, formatDate } from '@/lib/utils'
import { createClient } from '@/lib/supabase/client'
@@ -44,6 +44,9 @@ interface DuplicateCandidate {
interface InvoiceWithRelations extends Invoice {
customer: Customer
items: InvoiceItem[]
// Present once an issuance verifikat has been booked (faktureringsmetoden);
// absent on kontantmetoden invoices that recognise revenue at payment.
journal_entry_id?: string | null
}
interface PaymentBookingDialogProps {
@@ -80,6 +83,14 @@ export default function PaymentBookingDialog({
const [isInitialized, setIsInitialized] = useState(false)
const [duplicateCandidates, setDuplicateCandidates] = useState<DuplicateCandidate[] | null>(null)
const [tab, setTab] = useState<'new' | 'existing'>('new')
// Drives the "Befintlig verifikation" picker copy: cash links against a 19xx
// debit, accrual against a 1510 credit.
const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual')
// source_type the booking will use — drives the voucher-series preview so the
// number shown matches what mark-paid will actually create.
const [sourceType, setSourceType] =
useState<'invoice_cash_payment' | 'invoice_paid' | null>(null)
const [nextVoucher, setNextVoucher] = useState<{ series: string; next: number | null } | null>(null)
// Load accounts and settings when dialog opens
useEffect(() => {
@@ -87,6 +98,8 @@ export default function PaymentBookingDialog({
setIsInitialized(false)
setDuplicateCandidates(null)
setTab('new')
setSourceType(null)
setNextVoucher(null)
return
}
@@ -117,6 +130,15 @@ export default function PaymentBookingDialog({
const accountingMethod = (settings?.accounting_method || 'accrual') as 'accrual' | 'cash'
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
setAccountingMethod(accountingMethod)
setSourceType(
resolveInvoicePaymentSourceType({
invoiceAlreadyBooked: !!invoice.journal_entry_id,
accountingMethod,
}),
)
const proposed = proposePaymentLines({
invoice: {
invoice_number: invoice.invoice_number,
@@ -153,6 +175,25 @@ export default function PaymentBookingDialog({
return () => { cancelled = true }
}, [open, invoice.id, company?.id])
// Voucher-series preview: resolve the upcoming serie + nummer the same way the
// booking engine will, so a misconfigured series is visible before confirming.
// Re-runs when the payment date changes (vouchers are numbered per period).
useEffect(() => {
if (!open || !sourceType) return
let cancelled = false
const qs = new URLSearchParams({ source_type: sourceType, date: paymentDate })
fetch(`/api/bookkeeping/voucher-sequences/next?${qs}`)
.then((res) => (res.ok ? res.json() : null))
.then((json) => {
if (cancelled || !json?.data) return
setNextVoucher({ series: json.data.series, next: json.data.next })
})
.catch(() => {
if (!cancelled) setNextVoucher(null)
})
return () => { cancelled = true }
}, [open, sourceType, paymentDate])
// Balance computation
const { totalDebit, totalCredit, isBalanced } = useMemo(() => {
let totalDebit = 0
@@ -258,7 +299,14 @@ export default function PaymentBookingDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[680px]">
<DialogHeader>
<DialogTitle>{t('title')}{invoice.invoice_number ? t('title_suffix', { number: invoice.invoice_number }) : ''}</DialogTitle>
<DialogTitle>
{t('title')}{invoice.invoice_number ? t('title_suffix', { number: invoice.invoice_number }) : ''}
{nextVoucher && (
<span className="ml-1 text-muted-foreground tabular-nums">
({nextVoucher.series}{nextVoucher.next})
</span>
)}
</DialogTitle>
<DialogDescription>
{formatCurrency(invoice.total, invoice.currency)}
{invoice.currency !== 'SEK' && invoice.total_sek && (
@@ -328,6 +376,7 @@ export default function PaymentBookingDialog({
<LinkVoucherPicker
invoiceId={invoice.id}
invoiceCurrency={invoice.currency}
accountingMethod={accountingMethod}
onLinked={() => {
onOpenChange(false)
onSuccess()
@@ -440,7 +489,7 @@ export default function PaymentBookingDialog({
placeholder="0,00"
value={line.debit_amount}
onChange={(e) => updateLine(index, 'debit_amount', e.target.value)}
className="font-mono text-right h-8"
className="font-mono text-right"
/>
<Input
type="number"
@@ -449,7 +498,7 @@ export default function PaymentBookingDialog({
placeholder="0,00"
value={line.credit_amount}
onChange={(e) => updateLine(index, 'credit_amount', e.target.value)}
className="font-mono text-right h-8"
className="font-mono text-right"
/>
<Button
type="button"
+130 -30
View File
@@ -73,6 +73,8 @@ interface SubmissionState {
inlamningId?: number
tillstand?: string
meddelande?: string
/** ISO timestamp the submission record was last written by the extension. */
updatedAt?: string
}
/** Subset of SkatteverketAGIKontrollresultat we use in the panel. */
@@ -196,6 +198,19 @@ export function AGIPanel(props: AGIPanelProps) {
return () => window.removeEventListener('message', handleMessage)
}, [fetchStatus])
// Drop a stale "AGI-XML saknas" error once the run's AGI is (re)generated.
// That error is set when "Skicka in underlag" runs before the XML exists; if
// the file is then generated out-of-band (MCP, the download button, another
// tab) the parent refreshes `agiGeneratedAt` and this clears the now-wrong
// message without forcing a full reload — mirroring the session-expired
// self-heal in fetchStatus above.
useEffect(() => {
if (!agiGeneratedAt) return
setError(prev =>
prev && /agi-xml saknas|inte genererats/i.test(prev) ? null : prev,
)
}, [agiGeneratedAt])
// Background kvittens-polling timers (see scheduleKvittensPolls below).
// Held in a ref so the unmount-cleanup effect can cancel them if the
// user leaves the page mid-signing.
@@ -208,47 +223,95 @@ export function AGIPanel(props: AGIPanelProps) {
}, [])
/**
* Background-poll /agi/kvittenser at 30s, 2 min, and 5 min after the user
* receives a signing link. The kvittenser handler in the extension stamps
* salary_runs.agi_submitted_at when it observes a uuidKvittens, so this
* gives us a high-probability confirmation without depending on the user
* returning to the panel and clicking "Hämta kvittens" — which is critical
* for the audit trail (BFL 5 kap / BFNAR 2013:2): a NULL agi_submitted_at
* after a real filing would misrepresent the behandlingshistorik.
* Silently ask Skatteverket whether this period's granskningsunderlag has
* been signed. The kvittenser handler stamps salary_runs.agi_submitted_at
* and flips the local submission state to 'signed' the instant it sees a
* uuidKvittens — so a positive result transitions the panel out of
* awaiting_signing on its own (the action buttons then disappear via the
* isSigned gate). Returns true iff a signed kvittens was observed. No-ops
* (returns false) until we have the arbetsgivare id.
*
* Each poll silently refreshes local submission state on success and
* stops scheduling further polls once a kvittens is observed.
* Shared by the post-link background timers (scheduleKvittensPolls) and the
* auto-detect effect that runs on mount / tab refocus.
*/
const checkKvittens = useCallback(async (): Promise<boolean> => {
if (!arbetsgivare) return false
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/agi/kvittenser?arbetsgivare=${encodeURIComponent(arbetsgivare)}&period=${period}`,
)
if (!res.ok) return false
const json = await res.json()
const signed = !!json.data?.kvittenser?.[0]?.uuidKvittens
await fetchSubmission()
if (signed) {
// Replace any lingering "Granskningsunderlag klart…" / stale error
// with an unambiguous confirmation. Mirrors handleCheckSubmitted.
setError(null)
setSuccess('AGI har signerats och lämnats in.')
onChange?.()
}
return signed
} catch {
return false
}
}, [arbetsgivare, period, fetchSubmission, onChange])
/**
* Background-poll /agi/kvittenser at 30s, 2 min, and 5 min after the user
* receives a signing link — a timer-based fallback to the focus-driven
* auto-detect below. The kvittenser handler stamps salary_runs.agi_submitted_at
* when it observes a uuidKvittens, critical for the audit trail (BFL 5 kap /
* BFNAR 2013:2): a NULL agi_submitted_at after a real filing would
* misrepresent the behandlingshistorik. Stops scheduling once observed.
*/
const scheduleKvittensPolls = useCallback(() => {
for (const t of kvittensTimers.current) clearTimeout(t)
kvittensTimers.current = []
const poll = async () => {
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/agi/kvittenser?arbetsgivare=${encodeURIComponent(arbetsgivare)}&period=${period}`,
)
if (!res.ok) return
const json = await res.json()
const signed = !!json.data?.kvittenser?.[0]?.uuidKvittens
await fetchSubmission()
if (signed) {
// Cancel any remaining timers — the kvittens has been recorded
// server-side and further polls are wasted requests.
for (const t of kvittensTimers.current) clearTimeout(t)
kvittensTimers.current = []
onChange?.()
}
} catch {
// Silent: this is a background helper. The "Hämta kvittens" button
// remains the explicit recovery path.
// checkKvittens is silent on failure — the "Hämta kvittens" button
// remains the explicit recovery path.
const signed = await checkKvittens()
if (signed) {
// Cancel any remaining timers — the kvittens has been recorded
// server-side and further polls are wasted requests.
for (const t of kvittensTimers.current) clearTimeout(t)
kvittensTimers.current = []
}
}
kvittensTimers.current.push(setTimeout(poll, 30_000))
kvittensTimers.current.push(setTimeout(poll, 120_000))
kvittensTimers.current.push(setTimeout(poll, 300_000))
}, [arbetsgivare, period, fetchSubmission, onChange])
}, [checkKvittens])
// Auto-detect a Mina Sidor BankID signature so the panel reflects "signed"
// without the user having to click "Hämta kvittens". While we sit in
// awaiting_signing the user has typically opened the signing link (which
// opens a new tab), signed on Skatteverket's site, and come back. We re-check
// the kvittens (a) once on entering awaiting_signing — covering a reload
// after signing — and (b) whenever the tab regains focus — covering the
// sign-in-the-other-tab-then-return flow. A found kvittens flips the local
// state to 'signed', hiding the signing actions. The ref makes the on-enter
// check fire once per episode even if checkKvittens's identity churns (its
// onChange dep is an unmemoized parent callback).
const signCheckedRef = useRef(false)
useEffect(() => {
if (submission?.status !== 'awaiting_signing') {
signCheckedRef.current = false
return
}
if (!signCheckedRef.current) {
signCheckedRef.current = true
checkKvittens()
}
function onVisible() {
if (document.visibilityState === 'visible') checkKvittens()
}
document.addEventListener('visibilitychange', onVisible)
return () => document.removeEventListener('visibilitychange', onVisible)
}, [submission?.status, checkKvittens])
const handleDisconnect = useCallback(async () => {
setActionLoading('disconnect')
@@ -568,6 +631,18 @@ export function AGIPanel(props: AGIPanelProps) {
const underlagSubmitted = subState === 'underlag_submitted'
const underlagRejected = subState === 'underlag_rejected'
const isSigned = subState === 'signed' || !!agiSubmittedAt
// The submission state is keyed by PERIOD; AGI generation is keyed by RUN.
// If the run's AGI was (re)generated AFTER this signing draft was created,
// the locked underlag at Skatteverket reflects superseded figures and must
// not be signed — surface a warning and steer the user to unlock + resubmit
// rather than presenting it as ready to sign (avoids filing stale amounts).
const draftUpdatedAt = submission?.updatedAt ? new Date(submission.updatedAt) : null
const draftIsStale =
awaitingSigning &&
!!agiGeneratedAt &&
!!draftUpdatedAt &&
!Number.isNaN(draftUpdatedAt.getTime()) &&
new Date(agiGeneratedAt).getTime() > draftUpdatedAt.getTime()
// Tokens issued before the agd scope was added to DEFAULT_SCOPES will
// 403 with invalid_scope at submission time — surface that proactively
// so the user reconnects before hitting the deadline rather than at it.
@@ -663,7 +738,9 @@ export function AGIPanel(props: AGIPanelProps) {
}
pendingText={
awaitingSigning
? 'Granskningsunderlag klart — väntar på BankID-signatur i Mina Sidor.'
? draftIsStale
? 'Ett signeringsutkast finns hos Skatteverket men är inaktuellt — lås upp och skicka in underlaget på nytt.'
: 'Granskningsunderlag klart — väntar på BankID-signatur i Mina Sidor.'
: underlagSubmitted
? 'Underlag inläst hos Skatteverket. Skapa granskningsunderlag för att gå vidare till signering.'
: 'Inte skickad till Skatteverket ännu. Deadline: 12:e i månaden efter utbetalning (17:e i januari/augusti för arbetsgivare vars sammanlagda lönesumma understiger 40 MSEK per år).'
@@ -676,7 +753,7 @@ export function AGIPanel(props: AGIPanelProps) {
below to surface a felrapport URL, which deserves a distinct
treatment so the user understands they must fix errors before
BankID signing is even possible. */}
{submission?.signeringslank && awaitingSigning && (
{submission?.signeringslank && awaitingSigning && !draftIsStale && (
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 dark:border-amber-900/40 dark:bg-amber-900/20">
<p className="text-sm font-medium">Utkastet är låst och redo att signeras</p>
<p className="mt-0.5 text-xs text-muted-foreground">
@@ -693,6 +770,29 @@ export function AGIPanel(props: AGIPanelProps) {
</div>
)}
{/* Stale-draft guard — the signing draft at Skatteverket predates the
current run's AGI generation, so it carries superseded figures.
We deliberately do NOT surface "Öppna signeringslänk" here: signing
it would file the old amounts. The "Lås upp" button below releases
the SKV lock; the user then re-submits the freshly generated XML. */}
{awaitingSigning && draftIsStale && (
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 dark:border-amber-900/40 dark:bg-amber-900/20">
<p className="text-sm font-medium">Signeringsutkastet är inaktuellt</p>
<p className="mt-0.5 text-xs text-muted-foreground">
AGI:n genererades om{' '}
{agiGeneratedAt ? new Date(agiGeneratedAt).toLocaleString('sv-SE') : ''}{' '}
efter att det här signeringsutkastet skapades
{submission?.updatedAt
? ` (${new Date(submission.updatedAt).toLocaleString('sv-SE')})`
: ''}
. Utkastet hos Skatteverket innehåller äldre siffror. Klicka{' '}
<span className="font-medium">Lås upp</span> och därefter{' '}
<span className="font-medium">Skicka in underlag</span> för att
signera rätt belopp.
</p>
</div>
)}
{/* INCORRECT_DATA branch — skapaGranskningsunderlag returned 409 with
a felrapport link. The user must open the link in Mina Sidor to
see what's wrong, fix it, and then re-submit. Without this UI the
@@ -26,8 +26,11 @@ const VISIBLE_SOURCE_TYPES: Array<{ key: JournalEntrySourceType; labelKey: strin
{ key: 'manual', labelKey: 'manual' },
{ key: 'invoice_created', labelKey: 'invoice_created' },
{ key: 'invoice_paid', labelKey: 'invoice_paid' },
{ key: 'invoice_cash_payment', labelKey: 'invoice_cash_payment' },
{ key: 'supplier_invoice_registered', labelKey: 'supplier_invoice_registered' },
{ key: 'supplier_invoice_paid', labelKey: 'supplier_invoice_paid' },
{ key: 'supplier_invoice_cash_payment', labelKey: 'supplier_invoice_cash_payment' },
{ key: 'supplier_invoice_privately_paid', labelKey: 'supplier_invoice_privately_paid' },
{ key: 'salary_payment', labelKey: 'salary_payment' },
{ key: 'bank_transaction', labelKey: 'bank_transaction' },
{ key: 'reminder_fee', labelKey: 'reminder_fee' },
@@ -41,9 +44,12 @@ const VISIBLE_SOURCE_TYPES: Array<{ key: JournalEntrySourceType; labelKey: strin
const SV_LABELS: Record<string, string> = {
manual: 'Manuella verifikat',
invoice_created: 'Kundfakturor (skapande)',
invoice_paid: 'Kundfakturor (betalning)',
invoice_paid: 'Kundfakturor (betalning, fakturametod)',
invoice_cash_payment: 'Kundfakturor (betalning, kontantmetod)',
supplier_invoice_registered: 'Leverantörsfakturor (registrering)',
supplier_invoice_paid: 'Leverantörsfakturor (betalning)',
supplier_invoice_paid: 'Leverantörsfakturor (betalning, fakturametod)',
supplier_invoice_cash_payment: 'Leverantörsfakturor (betalning, kontantmetod)',
supplier_invoice_privately_paid: 'Leverantörsfakturor (privat utlägg)',
salary_payment: 'Lön',
bank_transaction: 'Banktransaktioner',
reminder_fee: 'Påminnelseavgifter',
@@ -9,6 +9,7 @@ import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSk
import { PeriodLockingSettings } from '@/components/settings/PeriodLockingSettings'
import { VoucherSeriesManager } from '@/components/settings/VoucherSeriesManager'
import { VoucherSeriesPerSourceTypeForm } from '@/components/settings/VoucherSeriesPerSourceTypeForm'
import { applyDefaultSeriesToMap } from '@/lib/bookkeeping/voucher-series-resolver'
import { PeriodiseringAutoDetectToggle } from '@/components/settings/PeriodiseringAutoDetectToggle'
import { AccountingFrameworkForm } from '@/components/settings/AccountingFrameworkForm'
import { useSettings } from '@/components/settings/useSettings'
@@ -46,6 +47,25 @@ export function BookkeepingSettingsContent() {
accounting_method: accountingMethod,
default_voucher_series: defaultVoucherSeries,
}
// Write-through: the booking engine resolves the series from the
// per-source-type map, NOT from default_voucher_series. So when the user
// changes the global default, propagate it across the map — but only for
// types that were still following the previous default, leaving explicit
// per-type overrides (set via VoucherSeriesPerSourceTypeForm) untouched.
// Without this the "Standardserie" dropdown is a no-op for bookkeeping.
// Only runs when the series actually changed, so saving the form for an
// unrelated reason (e.g. the lock date) never rewrites the map.
const prevDefault = settings?.default_voucher_series || 'A'
const currentMap = settings?.default_voucher_series_per_source_type
if (currentMap && defaultVoucherSeries !== prevDefault) {
updates.default_voucher_series_per_source_type = applyDefaultSeriesToMap(
currentMap,
prevDefault,
defaultVoucherSeries,
)
}
return {
updates,
onSuccess: (data: Record<string, unknown>) => {
+68
View File
@@ -0,0 +1,68 @@
'use client'
import { Reorder, useDragControls, useReducedMotion } from 'framer-motion'
import { GripVertical } from 'lucide-react'
import type { ReactNode } from 'react'
interface SortableRowProps<T> {
/** Identity used by Reorder to track this item across reorders. */
value: T
/** The row's existing markup — rendered untouched beside the drag handle. */
children: ReactNode
/** Localized aria-label for the drag handle. */
handleLabel: string
/** Disable dragging (e.g. a single-row list). */
disabled?: boolean
className?: string
}
/**
* A drag-to-reorder row built on framer-motion's Reorder, with the grip handle
* on the LEFT edge. The handle owns the drag (dragListener=false +
* dragControls) so text inputs inside the row stay selectable. The handle is
* vertically centered against the row so it reads correctly for both compact
* text rows and tall product rows. Motion collapses to instant when the user
* prefers reduced motion.
*
* Wrap the list in `<Reorder.Group as="div" axis="y" values={...} onReorder={...}>`
* and render one SortableRow per item; the row's own markup goes in `children`,
* so callers don't have to restructure existing JSX.
*/
export function SortableRow<T>({
value,
children,
handleLabel,
disabled = false,
className,
}: SortableRowProps<T>) {
const controls = useDragControls()
const reduceMotion = useReducedMotion()
return (
<Reorder.Item
value={value}
as="div"
dragListener={false}
dragControls={controls}
transition={reduceMotion ? { duration: 0 } : undefined}
className={className}
>
<div className="flex items-stretch gap-2">
<button
type="button"
aria-label={handleLabel}
disabled={disabled}
onPointerDown={(e) => {
if (disabled) return
e.preventDefault()
controls.start(e)
}}
className="flex shrink-0 touch-none cursor-grab items-center px-1 text-muted-foreground transition-colors duration-150 hover:text-foreground active:cursor-grabbing disabled:cursor-default disabled:opacity-30"
>
<GripVertical className="h-4 w-4" />
</button>
<div className="min-w-0 flex-1">{children}</div>
</div>
</Reorder.Item>
)
}