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
+48 -18
View File
@@ -1,15 +1,21 @@
'use client'
import { useState, useEffect, use } from 'react'
import { useState, useEffect, useCallback, useRef, use } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { useLocale, useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import ArticleForm from '@/components/articles/ArticleForm'
import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog'
import {
useSubmitWithAccountActivation,
throwOnStructuredError,
} from '@/lib/hooks/use-submit-with-account-activation'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import {
ArrowLeft,
@@ -44,6 +50,7 @@ export default function ArticleDetailPage({
const { toast } = useToast()
const { canWrite } = useCanWrite()
const t = useTranslations('article_detail')
const errorLocale = useLocale() as ErrorLocale
const [article, setArticle] = useState<Article | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isEditOpen, setIsEditOpen] = useState(false)
@@ -75,31 +82,46 @@ export default function ArticleDetailPage({
}
}
// Update runs through useSubmitWithAccountActivation so an
// ACCOUNTS_NOT_IN_CHART response (revenue account not yet activated) opens
// the standard activate-and-retry dialog — same UX as the journal entry form.
const pendingUpdateRef = useRef<CreateArticleInput | null>(null)
const submitUpdate = useCallback(async () => {
const response = await fetch(`/api/articles/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(pendingUpdateRef.current),
})
return throwOnStructuredError(response)
}, [id])
const {
runSubmit: runUpdate,
dialog: activationDialog,
confirm: confirmActivation,
cancel: cancelActivation,
} = useSubmitWithAccountActivation(submitUpdate)
async function handleUpdate(data: CreateArticleInput) {
setIsUpdating(true)
pendingUpdateRef.current = data
try {
const response = await fetch(`/api/articles/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
throw new Error('Update failed')
}
await runUpdate()
toast({
title: t('updated_title'),
description: data.name,
})
setIsEditOpen(false)
fetchArticle()
} catch {
toast({
title: t('update_failed_title'),
description: t('retry'),
variant: 'destructive',
})
} catch (err) {
// The user closing the activation dialog is not an error worth toasting.
if (!(err instanceof Error && err.message === 'cancelled')) {
const body = (err as { body?: unknown }).body
toast({
title: t('update_failed_title'),
description: getErrorMessage(body ?? err, { context: 'article', locale: errorLocale }),
variant: 'destructive',
})
}
} finally {
setIsUpdating(false)
}
@@ -299,6 +321,14 @@ export default function ArticleDetailPage({
<DestructiveConfirmDialog {...confirmDialogProps} />
<ActivateAccountsDialog
open={activationDialog.open}
accountNumbers={activationDialog.accountNumbers}
onConfirm={confirmActivation}
onCancel={cancelActivation}
confirmLabel={t('activate_and_save')}
/>
{/* Edit dialog */}
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
+45 -16
View File
@@ -1,6 +1,6 @@
'use client'
import { useState, useEffect, useMemo, useCallback, Suspense } from 'react'
import { useState, useEffect, useMemo, useCallback, useRef, Suspense } from 'react'
import { useLocale, useTranslations } from 'next-intl'
import { useSearchParams, useRouter, usePathname } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
@@ -22,6 +22,11 @@ import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { Plus, Search, Package, Lock, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'
import ArticleForm from '@/components/articles/ArticleForm'
import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog'
import {
useSubmitWithAccountActivation,
throwOnStructuredError,
} from '@/lib/hooks/use-submit-with-account-activation'
import { EmptyState } from '@/components/ui/empty-state'
import { PageHeader } from '@/components/ui/page-header'
import { formatCurrency } from '@/lib/utils'
@@ -116,33 +121,49 @@ function ArticlesPageInner() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
async function handleCreateArticle(data: CreateArticleInput) {
setIsCreating(true)
// Create runs through useSubmitWithAccountActivation so an ACCOUNTS_NOT_IN_CHART
// response (revenue account not yet activated) opens the standard
// activate-and-retry dialog instead of failing — same UX as the journal entry form.
const pendingCreateRef = useRef<CreateArticleInput | null>(null)
const submitCreate = useCallback(async () => {
const response = await fetch('/api/articles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
body: JSON.stringify(pendingCreateRef.current),
})
return (await throwOnStructuredError(response)) as { data: Article }
}, [])
const {
runSubmit: runCreate,
dialog: activationDialog,
confirm: confirmActivation,
cancel: cancelActivation,
} = useSubmitWithAccountActivation(submitCreate)
const result = await response.json()
if (!response.ok) {
toast({
title: t('create_failed_title'),
description: getErrorMessage(result, { context: 'article', locale: errorLocale }),
variant: 'destructive',
})
} else {
async function handleCreateArticle(data: CreateArticleInput) {
setIsCreating(true)
pendingCreateRef.current = data
try {
const result = await runCreate()
toast({
title: t('created_title'),
description: t('created_description', { name: data.name }),
})
setArticles([...articles, result.data])
setIsDialogOpen(false)
} catch (err) {
// The user closing the activation dialog is not an error worth toasting.
if (!(err instanceof Error && err.message === 'cancelled')) {
const body = (err as { body?: unknown }).body
toast({
title: t('create_failed_title'),
description: getErrorMessage(body ?? err, { context: 'article', locale: errorLocale }),
variant: 'destructive',
})
}
} finally {
setIsCreating(false)
}
setIsCreating(false)
}
const filteredArticles = useMemo(() => {
@@ -408,6 +429,14 @@ function ArticlesPageInner() {
</div>
</>
)}
<ActivateAccountsDialog
open={activationDialog.open}
accountNumbers={activationDialog.accountNumbers}
onConfirm={confirmActivation}
onCancel={cancelActivation}
confirmLabel={t('activate_and_save')}
/>
</div>
)
}
+33 -22
View File
@@ -680,36 +680,47 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
<div className="col-span-2 text-right">{t('th_amount')}</div>
</div>
{/* Items — desktop */}
{/* Items — desktop. Free-text rows span the full width with no
numeric columns; a blank one renders as a spacer. */}
<div className="hidden sm:block space-y-4">
{invoice.items.map((item) => (
<div key={item.id} className="grid grid-cols-12 gap-4 text-sm">
<div className="col-span-5">{item.description}</div>
<div className="col-span-2 text-right">{item.quantity}</div>
<div className="col-span-1 text-center">{item.unit}</div>
<div className="col-span-2 text-right">
{formatCurrency(item.unit_price, invoice.currency)}
{invoice.items.map((item) =>
item.line_type === 'text' ? (
<div key={item.id} className="grid grid-cols-12 gap-4 text-sm">
<div className="col-span-12 text-muted-foreground">{item.description || ' '}</div>
</div>
<div className="col-span-2 text-right font-medium">
{formatCurrency(item.line_total, invoice.currency)}
) : (
<div key={item.id} className="grid grid-cols-12 gap-4 text-sm">
<div className="col-span-5">{item.description}</div>
<div className="col-span-2 text-right">{item.quantity}</div>
<div className="col-span-1 text-center">{item.unit}</div>
<div className="col-span-2 text-right">
{formatCurrency(item.unit_price, invoice.currency)}
</div>
<div className="col-span-2 text-right font-medium">
{formatCurrency(item.line_total, invoice.currency)}
</div>
</div>
</div>
))}
)
)}
</div>
{/* Items — mobile cards */}
<div className="sm:hidden space-y-2">
{invoice.items.map((item) => (
<div key={item.id} 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, invoice.currency)}</span>
{invoice.items.map((item) =>
item.line_type === 'text' ? (
<p key={item.id} className="text-sm text-muted-foreground px-1">{item.description || ' '}</p>
) : (
<div key={item.id} 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, invoice.currency)}</span>
</div>
<p className="text-right font-medium">
{formatCurrency(item.line_total, invoice.currency)}
</p>
</div>
<p className="text-right font-medium">
{formatCurrency(item.line_total, invoice.currency)}
</p>
</div>
))}
)
)}
</div>
<Separator />
+355 -217
View File
@@ -5,6 +5,8 @@ import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { useForm, useFieldArray, Controller } from 'react-hook-form'
import { Reorder } from 'framer-motion'
import { SortableRow } from '@/components/ui/sortable-row'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { addDays, format } from 'date-fns'
@@ -21,7 +23,18 @@ import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { Loader2, Plus, Trash2, ArrowLeft, Send, Eye, Landmark, Lock, AlertTriangle } from 'lucide-react'
import { Loader2, Plus, Trash2, ArrowLeft, Send, Eye, Landmark, Lock, AlertTriangle, MoreVertical } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { InvoiceReviewContent } from '@/components/invoices/InvoiceReviewContent'
@@ -68,10 +81,14 @@ export default function NewInvoicePage() {
const schema = useMemo(() => {
const itemSchema = z.object({
description: z.string().min(1, t('validation_description_required')),
quantity: z.number().min(0.01, t('validation_quantity_min')),
unit: z.string().min(1, t('validation_unit_required')),
unit_price: z.number().min(0, t('validation_price_positive')),
// 'text' rows carry only a (possibly empty) description — a free-text or
// blank spacer line. Product rows keep the original requirements,
// enforced in the refine below so the base shape stays uniform.
line_type: z.enum(['product', 'text']).optional(),
description: z.string(),
quantity: z.number(),
unit: z.string(),
unit_price: z.number(),
vat_rate: z.number().min(0).max(25),
// Article linkage (artikelregister). Optional — free-text lines omit them.
article_id: z.string().nullable().optional(),
@@ -82,6 +99,20 @@ export default function NewInvoicePage() {
work_type: z.string().nullable().optional(),
housing_designation: z.string().nullable().optional(),
apartment_number: z.string().nullable().optional(),
}).superRefine((item, ctx) => {
if (item.line_type === 'text') return
if (item.description.trim().length === 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['description'], message: t('validation_description_required') })
}
if (!(item.quantity >= 0.01)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['quantity'], message: t('validation_quantity_min') })
}
if (item.unit.trim().length === 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['unit'], message: t('validation_unit_required') })
}
if (!(item.unit_price >= 0)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['unit_price'], message: t('validation_price_positive') })
}
})
return z.object({
customer_id: z.string().min(1, t('validation_customer_required')),
@@ -184,11 +215,23 @@ export default function NewInvoicePage() {
setValue('due_date', format(addDays(new Date(), 30), 'yyyy-MM-dd'))
}, [])
const { fields, append, remove } = useFieldArray({
const { fields, append, remove, move } = useFieldArray({
control,
name: 'items',
})
// Drag-to-reorder (grip handle left of each row). framer-motion hands back
// the fully reordered array; we translate the single displacement into a
// react-hook-form move() so the registered inputs follow. The persisted
// sort_order is the array index at create time, so reordering here is all
// that's needed — no extra payload.
const handleItemsReorder = (newOrder: typeof fields) => {
const movedAt = newOrder.findIndex((f, i) => f.id !== fields[i]?.id)
if (movedAt === -1) return
const from = fields.findIndex((f) => f.id === newOrder[movedAt].id)
if (from !== -1 && from !== movedAt) move(from, movedAt)
}
const watchItems = watch('items')
const watchCurrency = watch('currency')
const watchCustomerId = watch('customer_id')
@@ -445,18 +488,20 @@ export default function NewInvoicePage() {
? getAvailableVatRates(selectedCustomer.customer_type, selectedCustomer.vat_number_validated)
: []
const isRateLocked = availableRates.length === 1
// Show a warning when a non-registered seller has picked any non-zero VAT
// rate. ML 16 kap. 23 § (faktureringsmoms): stated VAT is owed to
// Skatteverket regardless of registration, but the buyer cannot deduct it
// as input VAT — so we surface the consequence rather than block the input.
const hasNonZeroVat = watchItems.some((item) => (item?.vat_rate ?? 0) > 0)
const showNotRegisteredVatWarning = !vatRegistered && hasNonZeroVat
// A non-momsregistrerad company never charges VAT: hide the Moms column and
// book every line momsfritt. `vatRegistered` is the single switch the whole
// form keys off — no rate picker, no warning, no VAT in the totals/preview.
// The API enforces the same (forces 0% server-side), so a stale hidden field
// value can't smuggle VAT onto the invoice. With VAT shown the description
// keeps its 3/12 width; when hidden it widens to fill the freed columns.
const descColSpan = vatRegistered ? 'md:col-span-3' : 'md:col-span-5'
// Calculate per-item VAT
// Calculate per-item VAT. When not VAT-registered every rate is forced to 0
// so vatAmount stays 0 and total === subtotal.
const vatByRate = new Map<number, { base: number; vat: number }>()
let vatAmount = 0
for (const item of watchItems) {
const rate = item.vat_rate ?? (vatRules?.rate || 25)
const rate = vatRegistered ? (item.vat_rate ?? (vatRules?.rate || 25)) : 0
const lineTotal = (item.quantity || 0) * (item.unit_price || 0)
const lineVat = Math.round(lineTotal * rate / 100 * 100) / 100
vatAmount += lineVat
@@ -934,25 +979,124 @@ export default function NewInvoicePage() {
<CardDescription>{t('items_card_description')}</CardDescription>
</CardHeader>
<CardContent>
{showNotRegisteredVatWarning && (
<div className="mb-4 flex items-start gap-3 rounded-lg border border-border bg-secondary/60 px-4 py-3 text-sm">
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5 text-muted-foreground" />
<p className="text-muted-foreground">
Du är inte momsregistrerad. Om du ändå tar ut moms är du
enligt ML 16 kap. 23 § skyldig att betala in den till
Skatteverket, men din kund får inte dra av den som ingående
moms. Om du har börjat bedriva momspliktig verksamhet bör
du först registrera dig för moms.
</p>
</div>
)}
<div className="space-y-4">
<Reorder.Group
as="div"
axis="y"
values={fields}
onReorder={handleItemsReorder}
className="space-y-4"
>
{fields.map((field, index) => {
const isTextRow = watchItems[index]?.line_type === 'text'
const lineTotal = (watchItems[index]?.quantity || 0) * (watchItems[index]?.unit_price || 0)
const lineVat = Math.round(lineTotal * (watchItems[index]?.vat_rate ?? 25) / 100 * 100) / 100
const lineVat = vatRegistered && !isTextRow
? Math.round(lineTotal * (watchItems[index]?.vat_rate ?? 25) / 100 * 100) / 100
: 0
// Free-text / blank row: just a description field (may be left
// empty for a spacer) and a delete button.
if (isTextRow) {
return (
<SortableRow
key={field.id}
value={field}
handleLabel={t('drag_handle_aria')}
disabled={fields.length === 1}
>
<div className="rounded-lg border bg-card p-4 md:rounded-none md:border-0 md:bg-transparent md:p-0">
<div className="flex items-end gap-2">
<div className="flex-1 space-y-1">
<Label className="text-xs text-muted-foreground">{t('text_row_label')}</Label>
<Input
placeholder={t('text_row_placeholder')}
{...register(`items.${index}.description`)}
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 min-h-[44px] min-w-[44px] text-muted-foreground hover:text-destructive"
onClick={() => remove(index)}
disabled={fields.length === 1}
aria-label={t('remove_row_aria')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</SortableRow>
)
}
// Per-row action button. On real invoices it's a ⋮ menu that
// holds both the ROT/RUT skattereduktion choice and delete;
// proformas/delivery notes have no deduction model, so they
// keep a plain trash button (a one-item menu would be noise).
const renderRowActions = (triggerClassName: string) =>
isInvoiceDoc ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={triggerClassName}
aria-label={t('row_actions_aria')}
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{t('deduction_menu_label')}</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={watchItems[index]?.deduction_type ?? 'none'}
onValueChange={(v) => {
const next = v === 'none' ? null : (v as 'rot' | 'rut')
setValue(`items.${index}.deduction_type`, next, { shouldDirty: true })
if (next === null) {
setValue(`items.${index}.work_type`, null)
setValue(`items.${index}.labor_hours`, null)
setValue(`items.${index}.housing_designation`, null)
setValue(`items.${index}.apartment_number`, null)
}
}}
>
<DropdownMenuRadioItem value="none">{t('deduction_none')}</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="rot">{t('deduction_rot')}</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="rut">{t('deduction_rut')}</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={fields.length === 1}
onSelect={() => remove(index)}
>
<Trash2 className="h-4 w-4" />
{t('remove_row')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button
type="button"
variant="ghost"
size="icon"
className={triggerClassName}
onClick={() => remove(index)}
disabled={fields.length === 1}
aria-label={t('remove_row_aria')}
>
<Trash2 className="h-4 w-4" />
</Button>
)
return (
<div
<SortableRow
key={field.id}
value={field}
handleLabel={t('drag_handle_aria')}
disabled={fields.length === 1}
>
<div
className="rounded-lg border bg-card p-4 space-y-3 relative md:rounded-none md:border-0 md:bg-transparent md:p-0 md:space-y-0 md:grid md:grid-cols-12 md:gap-4 md:items-start"
>
{/* Article picker (artikelregister). Optional — leave on
@@ -1006,7 +1150,7 @@ export default function NewInvoicePage() {
{/* Description + mobile delete button */}
<div className="flex items-start gap-2 md:contents">
<div className="flex-1 space-y-1 md:col-span-3 md:space-y-2">
<div className={`flex-1 space-y-1 ${descColSpan} md:space-y-2`}>
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">{t('description_label')}</Label>
<Input
placeholder={t('description_placeholder')}
@@ -1018,16 +1162,7 @@ export default function NewInvoicePage() {
</p>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 min-h-[44px] min-w-[44px] -mr-2 -mt-1 md:hidden"
onClick={() => remove(index)}
disabled={fields.length === 1}
>
<Trash2 className="h-4 w-4" />
</Button>
{renderRowActions('shrink-0 min-h-[44px] min-w-[44px] -mr-2 -mt-1 md:hidden')}
</div>
{/* Antal, Enhet, à-pris */}
@@ -1075,154 +1210,118 @@ export default function NewInvoicePage() {
</div>
</div>
{/* Moms */}
<div className="space-y-1 md:col-span-2 md:space-y-2">
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">{t('vat_label')}</Label>
<Controller
name={`items.${index}.vat_rate`}
control={control}
render={({ field }) => (
<Select
value={String(field.value ?? 25)}
onValueChange={(v) => field.onChange(Number(v))}
disabled={isRateLocked}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableRates.map((opt) => (
<SelectItem key={opt.rate} value={String(opt.rate)}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
{/* Desktop delete button */}
<div className="hidden md:flex md:col-span-1 md:items-end">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => remove(index)}
disabled={fields.length === 1}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* ROT/RUT-avdrag per-row controls. Only shown on real
invoices — proformas and delivery notes have no
deduction model. Collapsed to a tiny segmented
toggle by default; selecting ROT or RUT reveals the
work-type picker. */}
{isInvoiceDoc && (
<div className="md:col-span-12 mt-2 md:mt-3">
{/* Moms — hidden entirely when the company is not
momsregistrerad (no VAT may be charged). */}
{vatRegistered && (
<div className="space-y-1 md:col-span-2 md:space-y-2">
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">{t('vat_label')}</Label>
<Controller
name={`items.${index}.deduction_type`}
name={`items.${index}.vat_rate`}
control={control}
render={({ field }) => {
const value = field.value ?? 'none'
return (
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-muted-foreground">Skattereduktion:</span>
render={({ field }) => (
<Select
value={String(field.value ?? 25)}
onValueChange={(v) => field.onChange(Number(v))}
disabled={isRateLocked}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableRates.map((opt) => (
<SelectItem key={opt.rate} value={String(opt.rate)}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
)}
{/* Desktop row actions (⋮ menu or trash). An invisible
label spacer mirrors the field columns (same Label +
space-y-2), so the button sits on the input row — not
high against the labels, nor low at the row bottom. */}
<div className="hidden md:col-span-1 md:block md:space-y-2">
<Label className="invisible text-xs md:text-sm" aria-hidden="true">&nbsp;</Label>
<div className="flex justify-end">
{renderRowActions('')}
</div>
</div>
{/* ROT/RUT-avdrag strip — only when a deduction is active
on this row (chosen via the ⋮ menu). A leading tag shows
which reduction applies; the work-type + hours are
required for the Skatteverket claim. Rows with no
deduction render nothing here and stay clean. */}
{isInvoiceDoc && watchItems[index]?.deduction_type && (
<div className="md:col-span-12 mt-2 md:mt-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="tabular-nums">
{watchItems[index]?.deduction_type === 'rot' ? 'ROT(30)' : 'RUT(50)'}
</Badge>
<Controller
name={`items.${index}.work_type`}
control={control}
render={({ field: workField }) => {
const opts =
watchItems[index]?.deduction_type === 'rot'
? ROT_WORK_TYPES
: RUT_WORK_TYPES
return (
<Select
value={value}
onValueChange={(v) => {
const next = v === 'none' ? null : (v as 'rot' | 'rut')
field.onChange(next)
if (next === null) {
setValue(`items.${index}.work_type`, null)
setValue(`items.${index}.labor_hours`, null)
setValue(`items.${index}.housing_designation`, null)
setValue(`items.${index}.apartment_number`, null)
}
}}
value={workField.value ?? ''}
onValueChange={(v) => workField.onChange(v || null)}
>
<SelectTrigger className="h-8 w-32">
<SelectValue />
<SelectTrigger className="h-8 w-56">
<SelectValue placeholder={t('deduction_work_type_placeholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Ingen</SelectItem>
<SelectItem value="rot">ROT (30%)</SelectItem>
<SelectItem value="rut">RUT (50%)</SelectItem>
{opts.map((w) => (
<SelectItem key={w.code} value={w.code}>
{w.label}
</SelectItem>
))}
</SelectContent>
</Select>
{watchItems[index]?.deduction_type && (
<>
<Controller
name={`items.${index}.work_type`}
control={control}
render={({ field: workField }) => {
const opts =
watchItems[index]?.deduction_type === 'rot'
? ROT_WORK_TYPES
: RUT_WORK_TYPES
return (
<Select
value={workField.value ?? ''}
onValueChange={(v) => workField.onChange(v || null)}
>
<SelectTrigger className="h-8 w-56">
<SelectValue placeholder="Välj arbetstyp" />
</SelectTrigger>
<SelectContent>
{opts.map((w) => (
<SelectItem key={w.code} value={w.code}>
{w.label}
</SelectItem>
))}
</SelectContent>
</Select>
)
}}
/>
<Input
type="number"
step="0.5"
inputMode="decimal"
placeholder="Arbetstimmar"
className="h-8 w-32 text-right tabular-nums"
{...register(`items.${index}.labor_hours`, {
valueAsNumber: true,
setValueAs: (v) =>
v === '' || Number.isNaN(v) ? null : Number(v),
})}
/>
{(() => {
const amt = computeDeduction({
unit_price: watchItems[index]?.unit_price || 0,
quantity: watchItems[index]?.quantity || 0,
deduction_type: watchItems[index]?.deduction_type,
})
return amt > 0 ? (
<span className="text-xs tabular-nums text-muted-foreground">
−{formatCurrency(amt, watchCurrency)}
</span>
) : null
})()}
</>
)}
</div>
)
}}
/>
)
}}
/>
<Input
type="number"
step="0.5"
inputMode="decimal"
placeholder={t('deduction_hours_placeholder')}
className="h-8 w-32 text-right tabular-nums"
{...register(`items.${index}.labor_hours`, {
valueAsNumber: true,
setValueAs: (v) =>
v === '' || Number.isNaN(v) ? null : Number(v),
})}
/>
{(() => {
const amt = computeDeduction({
unit_price: watchItems[index]?.unit_price || 0,
quantity: watchItems[index]?.quantity || 0,
deduction_type: watchItems[index]?.deduction_type,
})
return amt > 0 ? (
<span className="text-xs tabular-nums text-muted-foreground">
−{formatCurrency(amt, watchCurrency)}
</span>
) : null
})()}
</div>
{/* Labor-only disclosure (Skatteverket fakturamodellen).
30%/50% applies to the full line total — the seller
must ensure the line is 100% labor; material has
to be invoiced separately. */}
{watchItems[index]?.deduction_type && (
<div className="mt-2 flex items-start gap-2 text-xs text-warning-foreground">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 text-warning shrink-0" />
<p>
Skatteverket kräver att endast arbetskostnad ingår i ROT/RUT-grundlaget. Material ska faktureras separat. Sätt endast skattereduktion på rader som är 100% arbete.
</p>
</div>
)}
<div className="mt-2 flex items-start gap-2 text-xs text-warning-foreground">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 text-warning shrink-0" />
<p>{t('deduction_labor_only_warning')}</p>
</div>
</div>
)}
@@ -1232,33 +1331,71 @@ export default function NewInvoicePage() {
<span className="font-medium tabular-nums">{formatCurrency(lineTotal + lineVat, watchCurrency)}</span>
</div>
</div>
</SortableRow>
)
})}
</Reorder.Group>
<Button
type="button"
variant="outline"
className="w-full md:w-auto"
onClick={() =>
append({
description: '',
quantity: 1,
unit: 'st',
unit_price: 0,
vat_rate: availableRates[0]?.rate ?? 25,
article_id: null,
revenue_account: null,
deduction_type: null,
labor_hours: null,
work_type: null,
housing_designation: null,
apartment_number: null,
})
}
>
<Plus className="mr-2 h-4 w-4" />
{t('add_row')}
</Button>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
type="button"
variant="outline"
className="w-full md:w-auto"
onClick={() =>
append({
line_type: 'product',
description: '',
quantity: 1,
unit: 'st',
unit_price: 0,
vat_rate: vatRegistered ? (availableRates[0]?.rate ?? 25) : 0,
article_id: null,
revenue_account: null,
deduction_type: null,
labor_hours: null,
work_type: null,
housing_designation: null,
apartment_number: null,
})
}
>
<Plus className="mr-2 h-4 w-4" />
{t('add_row')}
</Button>
{/* Free-text / blank row — explanatory text under an item, or
an empty spacer. Carries no amounts and never books. Not
offered for a received självfaktura: that is a faithful
revenue-only transcription, and the self-billed endpoint
(SelfBillingInvoiceItemSchema) has no line_type and rejects
zero-amount rows. */}
{!isSelfBilled && (
<Button
type="button"
variant="ghost"
className="w-full md:w-auto text-muted-foreground"
onClick={() =>
append({
line_type: 'text',
description: '',
quantity: 0,
unit: '',
unit_price: 0,
vat_rate: 0,
article_id: null,
revenue_account: null,
deduction_type: null,
labor_hours: null,
work_type: null,
housing_designation: null,
apartment_number: null,
})
}
>
<Plus className="mr-2 h-4 w-4" />
{t('add_text_row')}
</Button>
)}
</div>
</div>
</CardContent>
</Card>
@@ -1269,47 +1406,46 @@ export default function NewInvoicePage() {
{isInvoiceDoc && hasAnyDeduction && (
<Card>
<CardHeader>
<CardTitle>Underlag för skattereduktion</CardTitle>
<CardDescription>
ROT/RUT-avdrag begärs hos Skatteverket via fakturamodellen. Kunden behöver godkänna utbetalningen, så uppgifterna måste matcha köparen exakt.
</CardDescription>
<CardTitle>{t('deduction_card_title')}</CardTitle>
<CardDescription>{t('deduction_card_description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="deduction_personnummer">
Personnummer<RequiredMark />
{t('deduction_personnummer_label')}<RequiredMark />
</Label>
<Input
id="deduction_personnummer"
placeholder="ÅÅÅÅMMDD-NNNN"
placeholder={t('deduction_personnummer_placeholder')}
autoComplete="off"
{...register('deduction_personnummer')}
/>
<p className="text-xs text-muted-foreground">
Krypteras innan lagring. Endast de fyra sista siffrorna visas på fakturan.
{t('deduction_personnummer_hint')}
</p>
</div>
{hasAnyRotLine && (
<div className="space-y-2">
<Label htmlFor="deduction_housing_designation">
Fastighetsbeteckning<RequiredMark />
{t('deduction_housing_label')}<RequiredMark />
</Label>
<Input
id="deduction_housing_designation"
placeholder="t.ex. Stockholm Vasastan 1:23"
placeholder={t('deduction_housing_placeholder')}
{...register('deduction_housing_designation')}
/>
<p className="text-xs text-muted-foreground">
Krävs för ROT-avdrag (RUT behöver inte detta fält).
{t('deduction_housing_hint')}
</p>
</div>
)}
{(deductionByKind.rot > ROT_MAX || deductionByKind.rut > RUT_MAX) && (
<div className="rounded-lg border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
Fakturans avdrag överstiger årstaket
{t('deduction_cap_over')}
{deductionByKind.rot > ROT_MAX && ` (ROT ${ROT_MAX.toLocaleString('sv-SE')} kr)`}
{deductionByKind.rut > RUT_MAX && ` (RUT ${RUT_MAX.toLocaleString('sv-SE')} kr)`}
. Kunden behöver kontrollera sitt återstående utrymme själv.
{'. '}
{t('deduction_cap_check')}
</div>
)}
</CardContent>
@@ -1458,7 +1594,9 @@ export default function NewInvoicePage() {
<span className="text-muted-foreground">{t('subtotal_label')}</span>
<span>{formatCurrency(subtotal, watchCurrency)}</span>
</div>
{Array.from(vatByRate.entries())
{/* VAT rows — only when momsregistrerad. A non-registered company
shows no moms line at all (subtotal === total). */}
{vatRegistered && Array.from(vatByRate.entries())
.sort(([a], [b]) => b - a)
.map(([rate, group]) => (
<div key={rate}>
@@ -1476,7 +1614,7 @@ export default function NewInvoicePage() {
)}
</div>
))}
{vatByRate.size === 0 && (
{vatRegistered && vatByRate.size === 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">{t('vat_label_short')}</span>
<span>{formatCurrency(0, watchCurrency)}</span>
@@ -1484,18 +1622,18 @@ export default function NewInvoicePage() {
)}
{hasAnyDeduction && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Skattereduktion ROT/RUT</span>
<span className="text-muted-foreground">{t('deduction_summary_label')}</span>
<span className="tabular-nums">−{formatCurrency(deductionTotal, watchCurrency)}</span>
</div>
)}
<Separator />
<div className="flex justify-between font-bold text-lg">
<span>{hasAnyDeduction ? 'Att betala' : t('total_label')}</span>
<span>{hasAnyDeduction ? t('to_pay_label') : t('total_label')}</span>
<span>{formatCurrency(hasAnyDeduction ? toPay : total, watchCurrency)}</span>
</div>
{hasAnyDeduction && (
<div className="flex justify-between text-xs text-muted-foreground">
<span>Totalt inkl. moms</span>
<span>{t('total_incl_vat_label')}</span>
<span className="tabular-nums">{formatCurrency(total, watchCurrency)}</span>
</div>
)}
@@ -1612,7 +1750,7 @@ export default function NewInvoicePage() {
currency={(pendingData?.currency || 'SEK') as Currency}
items={(pendingData?.items || []).map((item) => ({
...item,
vat_rate: item.vat_rate ?? (vatRules?.rate || 25),
vat_rate: vatRegistered ? (item.vat_rate ?? (vatRules?.rate || 25)) : 0,
}))}
subtotal={subtotal}
vatAmount={vatAmount}
+14
View File
@@ -109,6 +109,20 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
load()
}, [id])
// Refetch when the tab regains focus. AGI can be generated out-of-band — via
// the MCP server, the public API, or another browser tab — and this page
// would otherwise keep showing a stale "AGI-fil har inte genererats ännu"
// (and a stale "AGI-XML saknas" error in the panel below) until a full
// reload. Reconciling agi_generated_at on visibilitychange picks up that
// generation without the user hard-refreshing.
useEffect(() => {
function onVisible() {
if (document.visibilityState === 'visible') loadRun()
}
document.addEventListener('visibilitychange', onVisible)
return () => document.removeEventListener('visibilitychange', onVisible)
}, [id])
async function handleAction(action: string, method: string = 'POST') {
setActionLoading(action)
const res = await fetch(`/api/salary/runs/${id}/${action}`, { method })
+9 -3
View File
@@ -4,7 +4,8 @@ import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { UpdateArticleSchema } from '@/lib/api/schemas'
import { withRouteContext } from '@/lib/api/with-route-context'
import { isValidRevenueAccount } from '@/lib/articles/validate-revenue-account'
import { checkRevenueAccount } from '@/lib/articles/validate-revenue-account'
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { Article } from '@/types'
@@ -53,9 +54,14 @@ export const PATCH = withRouteContext(
if (!result.success) return result.response
const body = result.data
// Same activate-and-retry contract as POST /api/articles: a class-3 account
// that just isn't activated yet returns ACCOUNTS_NOT_IN_CHART.
if (body.revenue_account) {
const ok = await isValidRevenueAccount(supabase, companyId!, body.revenue_account)
if (!ok) {
const status = await checkRevenueAccount(supabase, companyId!, body.revenue_account)
if (status === 'activatable') {
return accountsNotInChartResponse(new AccountsNotInChartError([body.revenue_account]))
}
if (status === 'invalid') {
return errorResponseFromCode('ARTICLE_REVENUE_ACCOUNT_INVALID', opLog, { requestId })
}
}
+36
View File
@@ -61,6 +61,42 @@ describe('GET/PATCH/DELETE /api/articles/[id]', () => {
expect(body.data.price_excl_vat).toBe(1500)
})
it('PATCH answers ACCOUNTS_NOT_IN_CHART for a BAS class-3 account missing from the chart', async () => {
// chart_of_accounts lookup: no row, but 3999 is a known BAS class-3
// account → activatable via the activate-and-retry dialog flow.
enqueue({ data: null })
const request = createMockRequest('/api/articles/a1', {
method: 'PATCH',
body: { revenue_account: '3999' },
})
const response = await PATCH(request, createMockRouteParams({ id: 'a1' }))
const { status, body } = await parseJsonResponse<{
error: { code: string; account_numbers: string[] }
}>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('ACCOUNTS_NOT_IN_CHART')
expect(body.error.account_numbers).toEqual(['3999'])
})
it('PATCH rejects a 3xxx revenue_account unknown to both chart and BAS catalogue', async () => {
// No chart row and 3041 is not in the BAS reference → invalid, no dialog.
enqueue({ data: null })
const request = createMockRequest('/api/articles/a1', {
method: 'PATCH',
body: { revenue_account: '3041' },
})
const response = await PATCH(request, createMockRouteParams({ id: 'a1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('ARTICLE_REVENUE_ACCOUNT_INVALID')
})
it('DELETE soft-deactivates and returns success', async () => {
enqueue({ data: { id: 'a1', active: false } })
+58 -3
View File
@@ -60,8 +60,27 @@ describe('GET/POST /api/articles', () => {
expect(status).toBe(400)
})
it('POST rejects a revenue_account that is not an active class-3 account', async () => {
// chart_of_accounts lookup returns no row → override is invalid.
it('POST rejects a 3xxx revenue_account unknown to both chart and BAS catalogue', async () => {
// Non-3xxx numbers are already stopped by the Zod schema; the route-level
// 'invalid' branch covers 3xxx numbers that exist nowhere — no chart row
// and not in the BAS reference (3041 is not a BAS 2026 account).
enqueue({ data: null })
const request = createMockRequest('/api/articles', {
method: 'POST',
body: { name: 'Frakt', price_excl_vat: 100, revenue_account: '3041' },
})
const response = await POST(request, { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('ARTICLE_REVENUE_ACCOUNT_INVALID')
})
it('POST answers ACCOUNTS_NOT_IN_CHART for a BAS class-3 account missing from the chart', async () => {
// No chart row, but 3999 is a known BAS class-3 account → activatable, so
// the client can run the activate-and-retry dialog flow.
enqueue({ data: null })
const request = createMockRequest('/api/articles', {
@@ -69,11 +88,47 @@ describe('GET/POST /api/articles', () => {
body: { name: 'Frakt', price_excl_vat: 100, revenue_account: '3999' },
})
const response = await POST(request, { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{
error: { code: string; account_numbers: string[] }
}>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('ACCOUNTS_NOT_IN_CHART')
expect(body.error.account_numbers).toEqual(['3999'])
})
it('POST answers ACCOUNTS_NOT_IN_CHART for an inactive class-3 chart account', async () => {
enqueue({ data: { account_class: 3, is_active: false } })
const request = createMockRequest('/api/articles', {
method: 'POST',
body: { name: 'Frakt', price_excl_vat: 100, revenue_account: '3001' },
})
const response = await POST(request, { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('ARTICLE_REVENUE_ACCOUNT_INVALID')
expect(body.error.code).toBe('ACCOUNTS_NOT_IN_CHART')
})
it('POST accepts a revenue_account that is active class 3 in the chart', async () => {
// 1st DB hit: chart_of_accounts lookup → active class-3 row.
enqueue({ data: { account_class: 3, is_active: true } })
// 2nd DB hit: insert ... returning the row.
enqueue({ data: { id: 'a1', name: 'Frakt', article_number: '3', revenue_account: '3001' } })
const request = createMockRequest('/api/articles', {
method: 'POST',
body: { name: 'Frakt', price_excl_vat: 100, revenue_account: '3001' },
})
const response = await POST(request, { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{ data: { revenue_account: string } }>(response)
expect(status).toBe(200)
expect(body.data.revenue_account).toBe('3001')
})
it('POST creates an article and auto-assigns a number', async () => {
+9 -3
View File
@@ -5,7 +5,8 @@ import { validateBody } from '@/lib/api/validate'
import { CreateArticleSchema } from '@/lib/api/schemas'
import { withRouteContext } from '@/lib/api/with-route-context'
import { ensureArticleNumber } from '@/lib/articles/ensure-article-number'
import { isValidRevenueAccount } from '@/lib/articles/validate-revenue-account'
import { checkRevenueAccount } from '@/lib/articles/validate-revenue-account'
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { Article } from '@/types'
@@ -50,9 +51,14 @@ export const POST = withRouteContext(
const body = result.data
// Guard the optional revenue-account override against the chart of accounts.
// A class-3 account that merely isn't activated yet gets the standard
// ACCOUNTS_NOT_IN_CHART envelope so the client can offer activate-and-retry.
if (body.revenue_account) {
const ok = await isValidRevenueAccount(supabase, companyId!, body.revenue_account)
if (!ok) {
const status = await checkRevenueAccount(supabase, companyId!, body.revenue_account)
if (status === 'activatable') {
return accountsNotInChartResponse(new AccountsNotInChartError([body.revenue_account]))
}
if (status === 'invalid') {
return errorResponseFromCode('ARTICLE_REVENUE_ACCOUNT_INVALID', log, { requestId })
}
}
@@ -31,8 +31,8 @@ function mockChain(result: ChainResult) {
return chain
}
function mkReq() {
return new Request('http://localhost/api/bookkeeping/voucher-sequences/next')
function mkReq(query = '') {
return new Request(`http://localhost/api/bookkeeping/voucher-sequences/next${query}`)
}
function mkParams() {
@@ -142,4 +142,92 @@ describe('GET /api/bookkeeping/voucher-sequences/next', () => {
expect(response.status).toBe(200)
expect(body.data).toEqual({ next: 13, series: 'V', fiscal_period_id: 'period-2' })
})
it('resolves the series per source_type, matching the booking engine', async () => {
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
mockFrom.mockImplementation((table: string) => {
if (table === 'fiscal_periods') {
return mockChain({ data: { id: 'period-1' }, error: null })
}
if (table === 'company_settings') {
return mockChain({
data: {
default_voucher_series: 'A',
default_voucher_series_per_source_type: { invoice_cash_payment: 'V' },
},
error: null,
})
}
if (table === 'voucher_sequences') {
return mockChain({ data: { last_number: 4 }, error: null })
}
throw new Error(`Unexpected table: ${table}`)
})
const response = await GET(mkReq('?source_type=invoice_cash_payment'), mkParams())
const body = await response.json()
expect(response.status).toBe(200)
// Uses the per-source-type map (V), NOT the global default (A).
expect(body.data).toEqual({ next: 5, series: 'V', fiscal_period_id: 'period-1' })
})
it('falls back to A when the source_type has no per-source-type mapping', async () => {
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
mockFrom.mockImplementation((table: string) => {
if (table === 'fiscal_periods') {
return mockChain({ data: { id: 'period-1' }, error: null })
}
if (table === 'company_settings') {
return mockChain({
data: {
default_voucher_series: 'V',
default_voucher_series_per_source_type: { invoice_paid: 'B' },
},
error: null,
})
}
if (table === 'voucher_sequences') {
return mockChain({ data: null, error: null })
}
throw new Error(`Unexpected table: ${table}`)
})
const response = await GET(mkReq('?source_type=invoice_cash_payment'), mkParams())
const body = await response.json()
expect(response.status).toBe(200)
// No mapping for invoice_cash_payment → engine-matching fallback of 'A'
// (the global default is intentionally NOT used here — no consolidation).
expect(body.data).toEqual({ next: 1, series: 'A', fiscal_period_id: 'period-1' })
})
it('rejects an unknown source_type with 400 before touching the database', async () => {
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
const response = await GET(mkReq('?source_type=not_a_source_type'), mkParams())
expect(response.status).toBe(400)
expect(mockFrom).not.toHaveBeenCalled()
})
it('rejects a malformed date with 400 before touching the database', async () => {
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
const response = await GET(mkReq('?date=2026-13-99x'), mkParams())
expect(response.status).toBe(400)
expect(mockFrom).not.toHaveBeenCalled()
})
it('rejects a malformed series with 400 before touching the database', async () => {
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
const response = await GET(mkReq('?series=AB'), mkParams())
expect(response.status).toBe(400)
expect(mockFrom).not.toHaveBeenCalled()
})
})
@@ -1,17 +1,26 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse } from '@/lib/errors/get-structured-error'
import { validateQuery } from '@/lib/api/validate'
import { VoucherSequenceNextQuerySchema } from '@/lib/api/schemas'
import { resolveDefaultSeriesForSource } from '@/lib/bookkeeping/voucher-series-resolver'
export const GET = withRouteContext(
'voucher_sequence.next',
async (request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
const url = new URL(request.url)
const overridePeriodId = url.searchParams.get('period_id')
const overrideSeries = url.searchParams.get('series')
const query = validateQuery(request, VoucherSequenceNextQuerySchema, {
log,
operation: 'voucher_sequence.next',
})
if (!query.success) return query.response
const { period_id: overridePeriodId, series: overrideSeries, source_type: sourceType } = query.data
const today = new Date().toISOString().split('T')[0]
// Vouchers are numbered per fiscal period, so the preview must reflect the
// period of the entry's date (e.g. a back-dated payment), not today's.
const date = query.data.date || today
const [{ data: period, error: periodError }, { data: settings, error: settingsError }] =
await Promise.all([
@@ -26,14 +35,14 @@ export const GET = withRouteContext(
.from('fiscal_periods')
.select('id')
.eq('company_id', companyId)
.lte('period_start', today)
.gte('period_end', today)
.lte('period_start', date)
.gte('period_end', date)
.maybeSingle(),
overrideSeries
? Promise.resolve({ data: null, error: null })
: supabase
.from('company_settings')
.select('default_voucher_series')
.select('default_voucher_series, default_voucher_series_per_source_type')
.eq('company_id', companyId)
.maybeSingle(),
])
@@ -47,7 +56,15 @@ export const GET = withRouteContext(
return errorResponse(settingsError, log, { requestId })
}
const series = overrideSeries || settings?.default_voucher_series || 'A'
// When a source_type is supplied, resolve the series exactly as the booking
// engine does (per-source-type map → 'A'), so the preview can never disagree
// with the verifikat that actually gets created. Without a source_type, keep
// the legacy generic default for callers that just want "the next number".
const series = overrideSeries
? overrideSeries
: sourceType
? resolveDefaultSeriesForSource(settings, sourceType)
: settings?.default_voucher_series || 'A'
if (!period) {
return NextResponse.json({ data: { next: null, series, fiscal_period_id: null } })
+2 -1
View File
@@ -98,9 +98,10 @@ export async function POST(
return NextResponse.json({ error: invoiceError.message }, { status: 500 })
}
const items = (proforma.items || []).map((item: { sort_order: number; description: string; quantity: number; unit: string; unit_price: number; line_total: number }) => ({
const items = (proforma.items || []).map((item: { sort_order: number; line_type?: 'product' | 'text'; description: string; quantity: number; unit: string; unit_price: number; line_total: number }) => ({
invoice_id: invoice.id,
sort_order: item.sort_order,
line_type: item.line_type ?? 'product',
description: item.description,
quantity: item.quantity,
unit: item.unit,
+5 -1
View File
@@ -4,6 +4,7 @@ import {
createInvoiceCashEntry,
} from '@/lib/bookkeeping/invoice-entries'
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { resolveInvoicePaymentSourceType } from '@/lib/bookkeeping/propose-payment-lines'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { MarkInvoicePaidSchema } from '@/lib/api/schemas'
import { ensureInitialized } from '@/lib/init'
@@ -164,7 +165,10 @@ export const POST = withRouteContext(
details: { paymentDate },
})
}
const sourceType = useCashEntry ? 'invoice_cash_payment' : 'invoice_paid'
const sourceType = resolveInvoicePaymentSourceType({
invoiceAlreadyBooked,
accountingMethod,
})
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: paymentDate,
+8
View File
@@ -188,6 +188,8 @@ describe('POST /api/invoices (create invoice)', () => {
// Fetch customer
enqueue({ data: customer, error: null })
// company_settings.vat_registered gate (registered → VAT flows as before)
enqueue({ data: { vat_registered: true }, error: null })
// Insert invoice (number is null on insert; allocated immediately after items)
enqueue({ data: createdInvoice, error: null })
// Insert items
@@ -239,6 +241,8 @@ describe('POST /api/invoices (create invoice)', () => {
// Fetch customer
enqueue({ data: customer, error: null })
// company_settings.vat_registered gate (registered → VAT flows as before)
enqueue({ data: { vat_registered: true }, error: null })
// Insert invoice (stays unnumbered — the allocation step is skipped)
enqueue({ data: createdInvoice, error: null })
// Insert items
@@ -288,6 +292,8 @@ describe('POST /api/invoices (create invoice)', () => {
])
enqueue({ data: customer, error: null })
// company_settings.vat_registered gate (registered → VAT flows as before)
enqueue({ data: { vat_registered: true }, error: null })
enqueue({ data: createdInvoice, error: null })
// Items insertion fails
enqueue({ data: null, error: { message: 'Items insert failed' } })
@@ -330,6 +336,8 @@ describe('POST /api/invoices (create invoice)', () => {
])
enqueue({ data: customer, error: null })
// company_settings.vat_registered gate (registered → VAT flows as before)
enqueue({ data: { vat_registered: true }, error: null })
enqueue({ data: createdInvoice, error: null })
// Items insertion succeeds
enqueue({ data: null, error: null })
+59 -6
View File
@@ -123,11 +123,32 @@ export const POST = withRouteContext(
const availableRates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated)
const allowedRates = new Set(availableRates.map((r) => r.rate))
const subtotal = invoiceInput.items.reduce((sum, item) => sum + item.quantity * item.unit_price, 0)
// VAT registration gate (defense in depth — the invoice form already hides
// the Moms column when vat_registered is false). A non-momsregistrerad
// company books no output VAT: zero every line rate so the sale lands as
// momsfri (treatment 'exempt' → revenue 3004/3100, no 2611). 0% is a valid
// rate for every customer type, so the allowedRates guard below still
// passes. Mirrors lib/pending-operations/commit.ts commitCreateInvoice.
const { data: vatSettings } = await supabase
.from('company_settings')
.select('vat_registered')
.eq('company_id', companyId!)
.maybeSingle()
const notVatRegistered = vatSettings?.vat_registered === false
if (notVatRegistered && documentType !== 'delivery_note') {
for (const item of invoiceInput.items) item.vat_rate = 0
}
// Free-text rows carry no amounts and are excluded from totals + VAT.
const subtotal = invoiceInput.items.reduce(
(sum, item) => (item.line_type === 'text' ? sum : sum + item.quantity * item.unit_price),
0,
)
let vatAmount = 0
if (documentType !== 'delivery_note') {
for (const item of invoiceInput.items) {
if (item.line_type === 'text') continue
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
if (!allowedRates.has(itemRate)) {
return errorResponseFromCode('INVOICE_CREATE_VAT_RULE_VIOLATION', log, {
@@ -225,7 +246,11 @@ export const POST = withRouteContext(
}
}
const uniqueRates = new Set(invoiceInput.items.map((item) => item.vat_rate ?? vatRules.rate))
const uniqueRates = new Set(
invoiceInput.items
.filter((item) => item.line_type !== 'text')
.map((item) => item.vat_rate ?? vatRules.rate),
)
const isMixedRate = uniqueRates.size > 1
let exchangeRate: number | null = null
@@ -280,10 +305,10 @@ export const POST = withRouteContext(
// Proformas, delivery notes and quotes have no payment obligation,
// so they keep the 0 default.
remaining_amount: documentType === 'invoice' ? total - deductionTotal : 0,
vat_treatment: vatRules.treatment,
vat_treatment: notVatRegistered ? 'exempt' : vatRules.treatment,
vat_rate: documentType === 'delivery_note' ? 0 : (isMixedRate ? null : (uniqueRates.values().next().value ?? vatRules.rate)),
moms_ruta: vatRules.momsRuta,
reverse_charge_text: vatRules.reverseChargeText || null,
moms_ruta: notVatRegistered ? null : vatRules.momsRuta,
reverse_charge_text: notVatRegistered ? null : (vatRules.reverseChargeText || null),
your_reference: invoiceInput.your_reference,
our_reference: invoiceInput.our_reference,
notes: invoiceInput.notes,
@@ -304,6 +329,32 @@ export const POST = withRouteContext(
}
const items = invoiceInput.items.map((item, index) => {
// Free-text / blank rows carry no amounts and never book — store the
// description only and zero everything else.
if (item.line_type === 'text') {
return {
invoice_id: invoice.id,
sort_order: index,
line_type: 'text',
description: item.description ?? '',
quantity: 0,
unit: '',
unit_price: 0,
line_total: 0,
vat_rate: 0,
vat_amount: 0,
// Keys must match the product branch exactly — PostgREST rejects a
// bulk insert whose objects have differing key sets.
article_id: null,
revenue_account: null,
deduction_type: null,
deduction_amount: 0,
labor_hours: null,
work_type: null,
housing_designation: null,
apartment_number: null,
}
}
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
const lineTotal = item.quantity * item.unit_price
const itemVat = documentType === 'delivery_note' ? 0 : Math.round(lineTotal * itemRate / 100 * 100) / 100
@@ -322,6 +373,7 @@ export const POST = withRouteContext(
return {
invoice_id: invoice.id,
sort_order: index,
line_type: 'product',
description: item.description,
quantity: item.quantity,
unit: item.unit,
@@ -509,9 +561,10 @@ async function createCreditNote(
})
}
const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate?: number; vat_amount?: number; revenue_account?: string | null; article_id?: string | null }) => ({
const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; line_type?: 'product' | 'text'; description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate?: number; vat_amount?: number; revenue_account?: string | null; article_id?: string | null }) => ({
invoice_id: creditNote.id,
sort_order: item.sort_order,
line_type: item.line_type ?? 'product',
description: item.description,
quantity: -Math.abs(item.quantity),
unit: item.unit,
@@ -255,6 +255,7 @@ describe('POST /api/pending-operations/:id/commit', () => {
{ data: pendingOp }, // fetch pending op
{ data: { id: 'op-1' } }, // CAS claim
{ data: customer }, // fetch customer
{ data: { vat_registered: true } }, // company_settings VAT registration gate
{ data: { id: 'inv-1', invoice_number: null } }, // insert invoice (no number — assigned at send)
{ data: null, error: null }, // insert items
{ data: { id: 'inv-1', invoice_number: null, customer: customer, items: [] } }, // fetch complete invoice
@@ -0,0 +1,140 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createQueuedMockSupabase,
createMockRequest,
parseJsonResponse,
createMockRouteParams,
} from '@/tests/helpers'
// The route is wrapped in withRouteContext (auth via requireAuth, company via
// getActiveCompanyId, write gate via requireWritePermission) — mock those.
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() }))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/events', () => ({ eventBus: { emit: vi.fn().mockResolvedValue(undefined) } }))
import { POST } from '../route'
import { requireAuth } from '@/lib/auth/require-auth'
const mockUser = { id: 'user-1', email: 'test@test.se' }
function authed(supabase: unknown) {
vi.mocked(requireAuth).mockResolvedValue({
user: mockUser as never,
supabase: supabase as never,
error: null,
})
}
/** A salary_run_employees row joined with its employee, as the route selects it. */
function runEmp(opts: {
first_name: string
last_name: string
net_salary: number
tax_withheld?: number
tax_withheld_override?: number | null
clearing_number?: string | null
bank_account_number?: string | null
}) {
return {
net_salary: opts.net_salary,
tax_withheld: opts.tax_withheld ?? 0,
tax_withheld_override: opts.tax_withheld_override ?? null,
calculation_breakdown: { steps: [] },
employee: {
first_name: opts.first_name,
last_name: opts.last_name,
clearing_number: opts.clearing_number ?? null,
bank_account_number: opts.bank_account_number ?? null,
email: 'employee@example.com',
},
}
}
describe('POST /api/salary/runs/[id]/approve — bank-detail guard', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('approves a nollkörning where a zero-net employee has no bank details', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
authed(supabase)
enqueueMany([
{ data: { id: 'run-1', status: 'review', company_id: 'company-1' } }, // run lookup
{
data: [
runEmp({ first_name: 'Test', last_name: 'Testsson', net_salary: 0 }),
runEmp({ first_name: 'Anna', last_name: 'Exempelsson', net_salary: 0 }),
],
}, // run employees
{ data: { id: 'run-1', status: 'approved' } }, // update
])
const request = createMockRequest('/api/salary/runs/run-1/approve', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
const { status, body } = await parseJsonResponse<{ data: { status: string } }>(response)
expect(status).toBe(200)
expect(body.data.status).toBe('approved')
})
it('still blocks when an employee who is actually paid has no bank details', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
authed(supabase)
enqueueMany([
{ data: { id: 'run-1', status: 'review', company_id: 'company-1' } },
{
data: [
// Paid 24 000 but no clearing/account → must block.
runEmp({ first_name: 'Test', last_name: 'Testsson', net_salary: 24000, tax_withheld: 8000 }),
],
},
])
const request = createMockRequest('/api/salary/runs/run-1/approve', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
const { status, body } = await parseJsonResponse<{ error: string; details: string[] }>(response)
expect(status).toBe(400)
expect(body.details).toHaveLength(1)
expect(body.details[0]).toContain('Test Testsson')
expect(body.details[0]).toContain('Bankuppgifter saknas')
})
it('approves a mixed run: pays the one with bank details, ignores the zero-net one without', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
authed(supabase)
enqueueMany([
{ data: { id: 'run-1', status: 'review', company_id: 'company-1' } },
{
data: [
runEmp({
first_name: 'Anna',
last_name: 'Exempelsson',
net_salary: 24000,
tax_withheld: 8000,
clearing_number: '8327',
bank_account_number: '1234567',
}),
// Zero payout, no bank details — should not block.
runEmp({ first_name: 'Test', last_name: 'Testsson', net_salary: 0 }),
],
},
{ data: { id: 'run-1', status: 'approved' } },
])
const request = createMockRequest('/api/salary/runs/run-1/approve', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
})
})
+6 -2
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { eventBus } from '@/lib/events'
import { effectiveNetPayout } from '@/lib/salary/payment/effective-net'
ensureInitialized()
@@ -44,8 +45,11 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
if (!emp) continue
const name = `${emp.first_name} ${emp.last_name}`
// Bank details required for payment
if (!emp.clearing_number || !emp.bank_account_number) {
// Bank details are only required when there's an actual payout. A zero
// net (nollkörning, or fully net-deducted) produces no payment-file line,
// so no destination account is needed — mirrors the pain.001 / BG-LB
// generators, which only include employees with effectiveNet > 0.
if (effectiveNetPayout(sre) > 0 && (!emp.clearing_number || !emp.bank_account_number)) {
validationErrors.push(`${name}: Bankuppgifter saknas (clearingnummer och/eller kontonummer)`)
}
@@ -4,6 +4,7 @@ import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { generateBgLb } from '@/lib/salary/payment/bg-lb-generator'
import { effectiveNetPayout } from '@/lib/salary/payment/effective-net'
import { validateBankgiroNumber } from '@/lib/bankgiro/luhn'
import type { BgLbCompanyData, BgLbEmployee } from '@/lib/salary/payment/bg-lb-generator'
@@ -87,7 +88,11 @@ export async function GET(
return NextResponse.json({ error: 'Inga anställda i lönekörningen' }, { status: 400 })
}
// Only employees with a positive payout end up in the file (see filter
// below), so missing bank details must only block when they're actually
// being paid — a zero-net employee needs no destination account.
const missingBank = runEmployees.filter((sre) => {
if (effectiveNetPayout(sre) <= 0) return false
const emp = sre.employee as { clearing_number: string | null; bank_account_number: string | null } | null
return !emp?.clearing_number || !emp?.bank_account_number
})
@@ -105,13 +110,9 @@ export async function GET(
}
const employees: BgLbEmployee[] = runEmployees
.map((sre) => {
// Honor tax override on the bank payment file too — the net the
// employee actually receives depends on the effective tax.
const effectiveNet =
sre.net_salary + (sre.tax_withheld - (sre.tax_withheld_override ?? sre.tax_withheld))
return { sre, effectiveNet }
})
// Honor tax override on the bank payment file too — the net the employee
// actually receives depends on the effective tax.
.map((sre) => ({ sre, effectiveNet: effectiveNetPayout(sre) }))
.filter(({ effectiveNet }) => effectiveNet > 0)
.map(({ sre, effectiveNet }) => {
const emp = sre.employee as {
@@ -4,6 +4,7 @@ import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { generatePain001 } from '@/lib/salary/payment/pain001-generator'
import { effectiveNetPayout } from '@/lib/salary/payment/effective-net'
import { getBranding } from '@/lib/branding/service'
import type { Pain001CompanyData, Pain001Employee } from '@/lib/salary/payment/pain001-generator'
@@ -78,8 +79,11 @@ export async function GET(
return NextResponse.json({ error: 'Inga anställda i lönekörningen' }, { status: 400 })
}
// Validate all employees have bank accounts
// Validate bank accounts — but only for employees who will actually appear
// in the file (positive payout). A zero-net employee is filtered out below,
// so missing bank details for them must not block the file.
const missingBank = runEmployees.filter(sre => {
if (effectiveNetPayout(sre) <= 0) return false
const emp = sre.employee as { clearing_number: string | null; bank_account_number: string | null } | null
return !emp?.clearing_number || !emp?.bank_account_number
})
@@ -98,11 +102,7 @@ export async function GET(
}
const employees: Pain001Employee[] = runEmployees
.map(sre => {
const effectiveNet =
sre.net_salary + (sre.tax_withheld - (sre.tax_withheld_override ?? sre.tax_withheld))
return { sre, effectiveNet }
})
.map(sre => ({ sre, effectiveNet: effectiveNetPayout(sre) }))
.filter(({ effectiveNet }) => effectiveNet > 0)
.map(({ sre, effectiveNet }) => {
const emp = sre.employee as { first_name: string; last_name: string; clearing_number: string; bank_account_number: string }
+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>
)
}
+2 -2
View File
@@ -5256,7 +5256,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_find_voucher_candidates_for_invoice',
title: 'Find Voucher Candidates (Invoice)',
description: 'List posted verifikat that credit kundfordran (1510) and could be the payment for this invoice. Use before gnubok_link_invoice_to_voucher when the user wants to mark a faktura paid against an existing verifikation (no new bokföring).',
description: "List posted verifikat that could be this invoice's payment (faktureringsmetoden: credit 1510; kontantmetoden: debit a bank/cash account 19xx). Call before gnubok_link_invoice_to_voucher to mark a faktura paid against an existing verifikation (no new bokföring).",
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -5322,7 +5322,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_link_invoice_to_voucher',
title: 'Link Invoice to Voucher',
description: 'Markera en faktura som betald genom att länka till en befintlig verifikation som redan krediterar kundfordran (1510). Ingen ny verifikation skapas. Hitta kandidater med gnubok_find_voucher_candidates_for_invoice först.',
description: 'Markera en faktura som betald genom att länka till en befintlig bokförd verifikation (faktureringsmetoden: krediterar 1510; kontantmetoden: debiterar likvidkonto 19xx). Ingen ny verifikation skapas. Hitta kandidater med gnubok_find_voucher_candidates_for_invoice först.',
inputSchema: {
type: 'object',
additionalProperties: false,
+7
View File
@@ -1591,6 +1591,13 @@ export const skatteverketExtension: Extension = {
{ status: result.status },
)
}
// Unlocking abandons the granskningsunderlag, so the locally-cached
// `awaiting_signing` record no longer reflects SKV — the signing link
// it carries points at a released draft. Clear it (mirroring the
// DELETE /agi/underlag and /agi/sparad handlers) so the panel drops
// back to the pre-submission state instead of stranding the user on a
// stale "redo att signeras" box.
await ctx.settings.clear(`agi_submission_${period}`)
return NextResponse.json({ data: result.data })
} catch (err) {
return handleSkvError(err)
+32
View File
@@ -397,6 +397,38 @@ describe('CreateInvoiceItemSchema', () => {
const result = CreateInvoiceItemSchema.safeParse(validInvoiceItem({ quantity: 'ten' }))
expect(result.success).toBe(false)
})
it('rejects a product row with an empty description', () => {
const result = CreateInvoiceItemSchema.safeParse(validInvoiceItem({ description: ' ' }))
expect(result.success).toBe(false)
})
it('rejects a product row with non-positive quantity', () => {
const result = CreateInvoiceItemSchema.safeParse(validInvoiceItem({ quantity: 0 }))
expect(result.success).toBe(false)
})
it('accepts a free-text row with an empty description and zero amounts', () => {
const result = CreateInvoiceItemSchema.safeParse({
line_type: 'text',
description: '',
quantity: 0,
unit: '',
unit_price: 0,
})
expect(result.success).toBe(true)
})
it('accepts a free-text row carrying explanatory text', () => {
const result = CreateInvoiceItemSchema.safeParse({
line_type: 'text',
description: 'Arbetet utfört enligt offert 2026-04',
quantity: 0,
unit: '',
unit_price: 0,
})
expect(result.success).toBe(true)
})
})
describe('CreateCreditNoteSchema', () => {
+48 -20
View File
@@ -147,6 +147,14 @@ export const JournalEntrySourceTypeSchema = z.enum([
'reminder_fee',
])
/** Query params for GET /api/bookkeeping/voucher-sequences/next. */
export const VoucherSequenceNextQuerySchema = z.object({
period_id: uuid.optional(),
series: z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A–Z').optional(),
source_type: JournalEntrySourceTypeSchema.optional(),
date: isoDate.optional(),
})
export const AccountTypeSchema = z.enum([
'asset', 'equity', 'liability', 'revenue', 'expense',
])
@@ -192,26 +200,46 @@ export const DocumentUploadSourceSchema = z.enum([
// Invoice schemas
// ============================================================
export const CreateInvoiceItemSchema = z.object({
description: z.string().min(1, 'Item description is required'),
quantity: z.number().positive('Quantity must be positive'),
unit: z.string().min(1, 'Unit is required'),
unit_price: z.number(),
vat_rate: z.number().min(0).max(100).optional(),
// Article linkage. `article_id` ties the line to a catalog article (free-text
// lines omit it). `revenue_account` is the optional BAS class-3 override the
// engine books to; the API validates it against chart_of_accounts before use.
article_id: uuid.nullable().optional(),
revenue_account: revenueAccount.nullable().optional(),
// ROT/RUT-avdrag fields. `deduction_amount` is intentionally omitted from
// the client schema — the API computes it from rot-rut-rules.ts so a
// tampered client can't expand the 1513 receivable beyond the line total.
deduction_type: z.enum(['rot', 'rut']).nullable().optional(),
labor_hours: z.number().nonnegative().nullable().optional(),
work_type: z.string().max(64).nullable().optional(),
housing_designation: z.string().max(128).nullable().optional(),
apartment_number: z.string().max(32).nullable().optional(),
})
export const CreateInvoiceItemSchema = z
.object({
// 'text' = free-text or blank spacer row: description only, amounts ignored
// and excluded from totals/bookkeeping. Defaults to 'product'. Callers still
// send quantity/unit/unit_price for text rows (the form sends 0/''/0), so
// the inferred shape stays consistent for downstream code.
line_type: z.enum(['product', 'text']).optional(),
description: z.string().max(2000),
quantity: z.number(),
unit: z.string(),
unit_price: z.number(),
vat_rate: z.number().min(0).max(100).optional(),
// Article linkage. `article_id` ties the line to a catalog article (text
// rows omit it). `revenue_account` is the optional BAS class-3 override the
// engine books to; the API validates it against chart_of_accounts before use.
article_id: uuid.nullable().optional(),
revenue_account: revenueAccount.nullable().optional(),
// ROT/RUT-avdrag fields. `deduction_amount` is intentionally omitted from
// the client schema — the API computes it from rot-rut-rules.ts so a
// tampered client can't expand the 1513 receivable beyond the line total.
deduction_type: z.enum(['rot', 'rut']).nullable().optional(),
labor_hours: z.number().nonnegative().nullable().optional(),
work_type: z.string().max(64).nullable().optional(),
housing_designation: z.string().max(128).nullable().optional(),
apartment_number: z.string().max(32).nullable().optional(),
})
.superRefine((item, ctx) => {
// Free-text rows skip the product-line requirements (description may be
// empty for a spacer; quantity/unit/price are ignored).
if (item.line_type === 'text') return
if (item.description.trim().length === 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['description'], message: 'Item description is required' })
}
if (item.quantity <= 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['quantity'], message: 'Quantity must be positive' })
}
if (item.unit.length === 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['unit'], message: 'Unit is required' })
}
})
const optionalIsoDate = isoDate.or(z.literal('')).transform(v => v || undefined).optional()
+41
View File
@@ -1,4 +1,45 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
/**
* Classify a per-article revenue-account override against the company's chart:
*
* - 'ok' — active class-3 account in the chart; accept as-is.
* - 'activatable' — a class-3 account that is merely missing/inactive: either
* an inactive chart row or a known BAS class-3 number not yet
* in the chart. Routes translate this to ACCOUNTS_NOT_IN_CHART
* so the standard activate-and-retry dialog flow applies
* (same UX as the journal entry form).
* - 'invalid' — anything else: a non-revenue account or a number unknown to
* both the chart and the BAS catalogue. Never bookable.
*
* Throws on an unexpected DB error so the route wrapper maps it to the canonical
* envelope.
*/
export type RevenueAccountStatus = 'ok' | 'activatable' | 'invalid'
export async function checkRevenueAccount(
supabase: SupabaseClient,
companyId: string,
account: string,
): Promise<RevenueAccountStatus> {
const { data, error } = await supabase
.from('chart_of_accounts')
.select('account_class, is_active')
.eq('company_id', companyId)
.eq('account_number', account)
.maybeSingle()
if (error) throw error
if (data) {
if (data.account_class !== 3) return 'invalid'
return data.is_active ? 'ok' : 'activatable'
}
const ref = getBASReference(account)
return ref?.account_class === 3 ? 'activatable' : 'invalid'
}
/**
* True when `account` exists in the company's chart of accounts as an ACTIVE
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'
import {
applyDefaultSeriesToMap,
formatVoucher,
parseVoucher,
resolveDefaultSeriesForSource,
@@ -96,6 +97,47 @@ describe('resolveDefaultSeriesForSource', () => {
})
})
describe('applyDefaultSeriesToMap', () => {
it('moves types following the old default onto the new default', () => {
const result = applyDefaultSeriesToMap(
{ manual: 'A', invoice_paid: 'A', invoice_cash_payment: 'A' },
'A',
'V',
)
expect(result).toEqual({ manual: 'V', invoice_paid: 'V', invoice_cash_payment: 'V' })
})
it('preserves explicit per-type overrides that differ from the old default', () => {
const result = applyDefaultSeriesToMap(
{ manual: 'A', supplier_invoice_paid: 'B', salary_payment: 'C' },
'A',
'V',
)
// Only the type that was following the old default (A) moves; B and C stay.
expect(result).toEqual({ manual: 'V', supplier_invoice_paid: 'B', salary_payment: 'C' })
})
it('does not mutate the input map', () => {
const input = { manual: 'A', invoice_paid: 'A' }
applyDefaultSeriesToMap(input, 'A', 'V')
expect(input).toEqual({ manual: 'A', invoice_paid: 'A' })
})
it('returns an empty map when given null/undefined', () => {
expect(applyDefaultSeriesToMap(null, 'A', 'V')).toEqual({})
expect(applyDefaultSeriesToMap(undefined, 'A', 'V')).toEqual({})
})
it('is a no-op on values when old and new default are equal', () => {
const result = applyDefaultSeriesToMap(
{ manual: 'A', supplier_invoice_paid: 'B' },
'A',
'A',
)
expect(result).toEqual({ manual: 'A', supplier_invoice_paid: 'B' })
})
})
describe('formatVoucher', () => {
it('formats series + number for a posted entry', () => {
expect(formatVoucher({ voucher_series: 'A', voucher_number: 1 })).toBe('A1')
+4
View File
@@ -60,6 +60,10 @@ function generatePerRateLines(
const lines: CreateJournalEntryLineInput[] = []
const isForeign = currency != null && currency !== 'SEK'
// Free-text / blank rows carry no amounts and never book — drop them before
// grouping so they can't produce a zero-amount revenue line.
items = items.filter((item) => item.line_type !== 'text')
// Helper: convert item amount to SEK when dealing with foreign currency
const toSek = (amount: number): number => {
if (!isForeign) return amount
+27 -6
View File
@@ -35,6 +35,25 @@ function toFormAmount(n: number): string {
return rounded === 0 ? '' : rounded.toString()
}
/**
* Resolve the journal_entries.source_type used when booking an invoice payment.
*
* Mirrors the branching in app/api/invoices/[id]/mark-paid/route.ts: revenue is
* only recognised at payment (kontantmetoden / invoice_cash_payment) when the
* invoice has no prior issuance verifikat AND the company is on the cash method.
* Otherwise the payment clears the receivable (invoice_paid).
*
* Shared so the dialog's voucher preview and the route's actual booking always
* resolve the same series — they must not drift.
*/
export function resolveInvoicePaymentSourceType(opts: {
invoiceAlreadyBooked: boolean
accountingMethod: 'accrual' | 'cash'
}): 'invoice_cash_payment' | 'invoice_paid' {
const useCashEntry = !opts.invoiceAlreadyBooked && opts.accountingMethod === 'cash'
return useCashEntry ? 'invoice_cash_payment' : 'invoice_paid'
}
/**
* Propose journal entry lines for an invoice payment.
*
@@ -134,16 +153,18 @@ function proposeCashLines(
return amount
}
// Build credit lines per VAT rate group
// Build credit lines per VAT rate group. Free-text / blank rows carry no
// amounts and never book — drop them first.
const creditLines: FormLine[] = []
const billableItems = (invoice.items ?? []).filter((item) => item.line_type !== 'text')
if (invoice.items && invoice.items.length > 0) {
const hasPerLineVat = invoice.items.some((item) => item.vat_rate !== undefined && item.vat_rate !== null)
if (billableItems.length > 0) {
const hasPerLineVat = billableItems.some((item) => item.vat_rate !== undefined && item.vat_rate !== null)
if (!hasPerLineVat) {
// Legacy: single rate from invoice level
const revenueAccount = getRevenueAccount(invoice.vat_treatment, entityType)
const subtotal = invoice.items.reduce((sum, item) => sum + item.line_total, 0)
const subtotal = billableItems.reduce((sum, item) => sum + item.line_total, 0)
creditLines.push({
account_number: revenueAccount,
debit_amount: '',
@@ -151,7 +172,7 @@ function proposeCashLines(
line_description: (invoice.invoice_number ? `Försäljning faktura ${invoice.invoice_number}` : 'Försäljning faktura'),
})
const totalVat = invoice.items.reduce((sum, item) => sum + (item.vat_amount || 0), 0)
const totalVat = billableItems.reduce((sum, item) => sum + (item.vat_amount || 0), 0)
if (totalVat > 0) {
const vatAccount = getOutputVatAccount(invoice.vat_treatment)
creditLines.push({
@@ -164,7 +185,7 @@ function proposeCashLines(
} else {
// Group items by vat_rate
const rateGroups = new Map<number, { subtotal: number; vatAmount: number }>()
for (const item of invoice.items) {
for (const item of billableItems) {
const rate = item.vat_rate ?? 0
const group = rateGroups.get(rate) || { subtotal: 0, vatAmount: 0 }
group.subtotal += item.line_total
@@ -60,6 +60,29 @@ export function resolveDefaultSeriesForSource(
return 'A'
}
/**
* Propagate a change to the global default voucher series across the
* per-source-type map. Source types that were still following the previous
* default move to the new default; explicit overrides (values that differ from
* the previous default) are preserved untouched.
*
* The booking engine resolves series from the per-source-type map, not from the
* global default, so the bookkeeping settings form calls this when the user
* changes the "Standardserie" dropdown — otherwise that control would be a
* no-op for bookkeeping. Pure; returns the next map (input is not mutated).
*/
export function applyDefaultSeriesToMap(
currentMap: VoucherSeriesMap | null | undefined,
prevDefault: string,
nextDefault: string,
): VoucherSeriesMap {
const out: VoucherSeriesMap = {}
for (const [key, value] of Object.entries(currentMap || {})) {
out[key] = value === prevDefault ? nextDefault : value
}
return out
}
/**
* Format a voucher (series + number) for UI display. Returns "—" when the
* voucher number is null (e.g. a draft entry that has not been committed yet).
@@ -114,6 +114,39 @@ describe('getErrorMessage — English locale uses registry English (C9)', () =>
})
})
describe('getErrorMessage — accumulated validation details', () => {
it('surfaces the specific per-item reasons instead of the generic 400 message', () => {
const msg = getErrorMessage(
{
error: 'Valideringsfel — korrigera innan godkännande',
details: ['Tomas Tysén: Bankuppgifter saknas (clearingnummer och/eller kontonummer)'],
warnings: [],
},
{ context: 'salary', statusCode: 400 },
)
expect(msg).toContain('Tomas Tysén')
expect(msg).toContain('Bankuppgifter saknas')
expect(msg).toContain('Valideringsfel')
// Must NOT collapse to the generic HTTP-400 fallback.
expect(msg).not.toBe('Förfrågan innehåller ogiltiga uppgifter.')
})
it('joins multiple items and caps the list with an overflow hint', () => {
const details = Array.from({ length: 7 }, (_, i) => `Anställd ${i + 1}: Bankuppgifter saknas`)
const msg = getErrorMessage({ error: 'Valideringsfel', details }, { statusCode: 400 })
expect(msg).toContain('Anställd 1')
expect(msg).toContain('Anställd 5')
expect(msg).toContain('•')
expect(msg).toContain('(+2 till)')
expect(msg).not.toContain('Anställd 6')
})
it('ignores a non-string details array and falls through to the status fallback', () => {
const msg = getErrorMessage({ error: 'oklart fel', details: [{ x: 1 }] }, { statusCode: 400 })
expect(msg).toBe('Förfrågan innehåller ogiltiga uppgifter.')
})
})
describe('getErrorMessage — existing patterns still work', () => {
it('regex match for "Entry date ... outside fiscal period" on plain string', () => {
const msg = getErrorMessage('Entry date 2024-06-15 is outside fiscal period "FY 2025"')
+20
View File
@@ -167,6 +167,9 @@ function isSwedishUserMessage(message: string): boolean {
/måste/i,
/redan finns/i,
/gick fel/i,
/valideringsfel/i,
/korrigera/i,
/bankuppgifter/i,
/behörighet/i,
/session/i,
/förfrågan/i,
@@ -383,6 +386,23 @@ export function getErrorMessage(
}
}
// Accumulated per-item validation list from routes that collect several
// problems before responding, e.g. the salary approve route:
// { error: 'Valideringsfel …', details: ['Tomas Tysén: Bankuppgifter saknas …', …] }
// Surface the specific reasons — otherwise this shape falls all the way
// through to the generic HTTP-400 message and the user learns nothing.
if (
Array.isArray(obj.details) &&
obj.details.length > 0 &&
obj.details.every((d) => typeof d === 'string' && d.trim() !== '')
) {
const items = (obj.details as string[]).map((d) => d.trim())
const shown = items.slice(0, 5).join(' • ')
const more = items.length > 5 ? ` (+${items.length - 5} till)` : ''
const lead = typeof obj.error === 'string' && obj.error.trim() ? `${obj.error.trim()}: ` : ''
return `${lead}${shown}${more}`
}
// Try Zod validation errors
const zodMessage = tryParseZodErrors(obj)
if (zodMessage) return zodMessage
@@ -81,6 +81,48 @@ async function seedPostedVoucher(params: {
return id
}
/**
* Seed a posted voucher with one debit line and one credit line (balanced).
* Lets kontantmetoden tests build a cash-receipt verifikat (debit 1930 /
* credit 3001 — no 1510) and a non-matching one (debit 1510 / credit 3001).
*/
async function seedVoucherDebitCredit(params: {
userId: string
companyId: string
fiscalPeriodId: string
amount?: number
debitAccount: string
creditAccount: string
}): Promise<string> {
const id = randomUUID()
const amount = params.amount ?? 1000
await getPool().query(
`INSERT INTO public.journal_entries
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
entry_date, description, source_type, status)
VALUES ($1, $2, $3, $4, $5, 'A', '2026-05-05', 'Inbetalning', 'manual', 'posted')`,
[id, params.userId, params.companyId, params.fiscalPeriodId, Math.floor(Math.random() * 100000)],
)
await getPool().query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, $2, $3, 0),
($1, $4, 0, $3)`,
[id, params.debitAccount, amount, params.creditAccount],
)
return id
}
/** Flip a company onto kontantmetoden so the link RPC keys on the 19xx debit. */
async function setCashMethod(companyId: string): Promise<void> {
await getPool().query(
`INSERT INTO public.company_settings (company_id, accounting_method)
VALUES ($1, 'cash')
ON CONFLICT (company_id) DO UPDATE SET accounting_method = 'cash'`,
[companyId],
)
}
describe('link_invoice_voucher pg-real guards', () => {
it('partial unique index blocks linking the same voucher to the same invoice twice', async () => {
const userId = await insertAuthUser()
@@ -414,3 +456,90 @@ describe('link_invoice_to_voucher RPC (atomic link — audit C2)', () => {
expect(Number(pay[0].count)).toBe(1)
})
})
// ============================================================
// link_invoice_to_voucher RPC — kontantmetoden branch
// On cash method no 1510 is ever booked (revenue is recognised at payment),
// so the RPC must key on the bank/cash DEBIT (19xx) instead of the AR credit.
// Mirrors the accounting-method branch in lib/invoices/voucher-matching.ts.
// ============================================================
describe('link_invoice_to_voucher RPC (kontantmetoden — 19xx debit)', () => {
it('cash method: links against the 1930 debit of a receipt voucher (no 1510)', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId })
await setCashMethod(companyId)
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
const customerId = await seedCustomer({ userId, companyId })
const invoiceId = await seedInvoice({ userId, companyId, customerId, total: 1000 })
// Cash receipt: debit 1930 (bank) / credit 3001 (revenue) — no receivable.
const voucherId = await seedVoucherDebitCredit({
userId,
companyId,
fiscalPeriodId,
amount: 1000,
debitAccount: '1930',
creditAccount: '3001',
})
const result = await callLinkRpc({ invoiceId, voucherId, userId, companyId })
expect(result.ok).toBe(true)
expect(result.invoice_status).toBe('paid')
expect(Number(result.paid_amount)).toBe(1000)
expect(Number(result.remaining_amount)).toBe(0)
const { rows: pay } = await getPool().query(
`SELECT amount FROM public.invoice_payments WHERE invoice_id = $1 AND journal_entry_id = $2`,
[invoiceId, voucherId],
)
expect(pay).toHaveLength(1)
expect(Number(pay[0].amount)).toBe(1000)
})
it('cash method: rejects a voucher with no bank/cash debit (NO_AR_CREDIT)', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId })
await setCashMethod(companyId)
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
const customerId = await seedCustomer({ userId, companyId })
const invoiceId = await seedInvoice({ userId, companyId, customerId, total: 1000 })
// An AR-clearing voucher (debit 1510 / credit 3001): valid on accrual, but
// on cash there is no 19xx debit, so it must not match.
const voucherId = await seedVoucherDebitCredit({
userId,
companyId,
fiscalPeriodId,
amount: 1000,
debitAccount: '1510',
creditAccount: '3001',
})
const result = await callLinkRpc({ invoiceId, voucherId, userId, companyId })
expect(result.ok).toBe(false)
expect(result.code).toBe('LINK_VOUCHER_NO_AR_CREDIT')
const { rows: inv } = await getPool().query(
`SELECT status, paid_amount FROM public.invoices WHERE id = $1`,
[invoiceId],
)
expect(inv[0].status).toBe('sent')
expect(Number(inv[0].paid_amount)).toBe(0)
})
it('accrual default (no settings row): still keys on the 1510 credit', async () => {
// Regression guard: a company with no company_settings row must behave as
// accrual — the 1930-debit / 1510-credit voucher links via its AR credit.
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId })
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
const customerId = await seedCustomer({ userId, companyId })
const invoiceId = await seedInvoice({ userId, companyId, customerId, total: 1000 })
const voucherId = await seedPostedVoucher({ userId, companyId, fiscalPeriodId, amount: 1000 })
const result = await callLinkRpc({ invoiceId, voucherId, userId, companyId })
expect(result.ok).toBe(true)
expect(result.invoice_status).toBe('paid')
})
})
+132 -1
View File
@@ -42,6 +42,7 @@ describe('validateVoucherForInvoiceLink', () => {
it('rejects when the voucher is missing', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup()
enqueue({ data: { accounting_method: 'accrual' } }) // resolveAccountingMethod
enqueue({ data: null, error: null }) // journal_entries.maybeSingle → null
const result = await validateVoucherForInvoiceLink(
supabase as never,
@@ -56,6 +57,7 @@ describe('validateVoucherForInvoiceLink', () => {
it('rejects when the voucher is not posted', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup()
enqueue({ data: { accounting_method: 'accrual' } }) // resolveAccountingMethod
enqueue({
data: {
id: 'je-1',
@@ -79,9 +81,10 @@ describe('validateVoucherForInvoiceLink', () => {
if (!result.ok) expect(result.code).toBe('LINK_VOUCHER_NOT_POSTED')
})
it('rejects when the voucher has no AR credit', async () => {
it('rejects when the voucher has no AR credit (accrual)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup()
enqueue({ data: { accounting_method: 'accrual' } }) // resolveAccountingMethod
enqueue({
data: {
id: 'je-1',
@@ -111,9 +114,85 @@ describe('validateVoucherForInvoiceLink', () => {
if (!result.ok) expect(result.code).toBe('LINK_VOUCHER_NO_AR_CREDIT')
})
it('cash method: accepts the same 1930-debit voucher that accrual rejects', async () => {
// Kontantmetoden books debit 19xx / credit 30xx and never touches 1510, so
// the matcher keys on the bank/cash debit instead of an AR credit.
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup()
enqueue({ data: { accounting_method: 'cash' } }) // resolveAccountingMethod
enqueue({
data: {
id: 'je-1',
voucher_series: 'A',
voucher_number: 5,
entry_date: '2026-05-01',
description: '',
status: 'posted',
source_type: 'manual',
fiscal_period_id: 'fp-1',
company_id: 'company-1',
},
})
enqueue({
data: [
{ account_number: '1930', debit_amount: 1000, credit_amount: 0, currency: 'SEK' },
{ account_number: '3001', debit_amount: 0, credit_amount: 1000, currency: 'SEK' },
],
})
enqueue({ data: [] }) // invoice_payments already-linked lookup
const result = await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
invoice as never,
'je-1',
)
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.arCreditAmount).toBe(1000)
expect(result.paymentAmount).toBe(1000)
expect(result.isFullyPaid).toBe(true)
}
})
it('cash method: rejects when the voucher has no bank/cash debit', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup()
enqueue({ data: { accounting_method: 'cash' } }) // resolveAccountingMethod
enqueue({
data: {
id: 'je-1',
voucher_series: 'A',
voucher_number: 5,
entry_date: '2026-05-01',
description: '',
status: 'posted',
source_type: 'manual',
fiscal_period_id: 'fp-1',
company_id: 'company-1',
},
})
enqueue({
data: [
// An AR-clearing voucher (1510 credit) — valid on accrual, but on cash
// there is no 19xx debit so it must not match.
{ account_number: '1510', debit_amount: 0, credit_amount: 1000, currency: 'SEK' },
{ account_number: '3001', debit_amount: 1000, credit_amount: 0, currency: 'SEK' },
],
})
const result = await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
invoice as never,
'je-1',
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('LINK_VOUCHER_NO_AR_CREDIT')
})
it('rejects when the voucher amount exceeds the remaining', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup()
enqueue({ data: { accounting_method: 'accrual' } }) // resolveAccountingMethod
enqueue({
data: {
id: 'je-1',
@@ -146,6 +225,7 @@ describe('validateVoucherForInvoiceLink', () => {
it('rejects when the line currency does not match the invoice', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup(makeInvoice({ remaining_amount: 1000, total: 1000, currency: 'EUR' }))
enqueue({ data: { accounting_method: 'accrual' } }) // resolveAccountingMethod
enqueue({
data: {
id: 'je-1',
@@ -177,6 +257,7 @@ describe('validateVoucherForInvoiceLink', () => {
it('rejects when the voucher is already linked to this invoice', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup()
enqueue({ data: { accounting_method: 'accrual' } }) // resolveAccountingMethod
enqueue({
data: {
id: 'je-1',
@@ -209,6 +290,7 @@ describe('validateVoucherForInvoiceLink', () => {
it('returns ok=true with full-pay flag when amount equals remaining', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup()
enqueue({ data: { accounting_method: 'accrual' } }) // resolveAccountingMethod
enqueue({
data: {
id: 'je-1',
@@ -247,6 +329,7 @@ describe('validateVoucherForInvoiceLink', () => {
it('returns ok=true with partial-pay flag when amount is less than remaining', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup(makeInvoice({ remaining_amount: 1000, total: 1000, currency: 'SEK' }))
enqueue({ data: { accounting_method: 'accrual' } }) // resolveAccountingMethod
enqueue({
data: {
id: 'je-1',
@@ -308,6 +391,7 @@ describe('findMatchingVouchersForInvoice', () => {
total: 1000,
due_date: '2026-05-01',
})
enqueue({ data: { accounting_method: 'accrual' } }) // resolveAccountingMethod
enqueue({ data: null, error: { message: 'db error' } })
const result = await findMatchingVouchersForInvoice(
supabase as never,
@@ -316,6 +400,53 @@ describe('findMatchingVouchersForInvoice', () => {
)
expect(result).toEqual([])
})
it('cash method: surfaces a verifikat that debits a bank account (19xx), no 1510 needed', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = makeInvoice({
remaining_amount: 1000,
total: 1000,
currency: 'SEK',
due_date: '2026-05-01',
invoice_number: 'F-1',
})
enqueue({ data: { accounting_method: 'cash' } }) // resolveAccountingMethod
// journal_entries query with embedded lines (kontantmetoden: 19xx debit > 0)
enqueue({
data: [
{
id: 'je-1',
voucher_series: 'A',
voucher_number: 7,
entry_date: '2026-05-01',
description: 'Betalning faktura F-1',
status: 'posted',
source_type: 'manual',
fiscal_period_id: 'fp-1',
company_id: 'company-1',
journal_entry_lines: [
{
id: 'l1',
account_number: '1930',
debit_amount: 1000,
credit_amount: 0,
currency: 'SEK',
},
],
},
],
})
enqueue({ data: [] }) // invoice_payments already-linked lookup
enqueue({ data: [] }) // fiscal_periods lock lookup
const result = await findMatchingVouchersForInvoice(
supabase as never,
'company-1',
invoice as never,
)
expect(result).toHaveLength(1)
expect(result[0].journal_entry_id).toBe('je-1')
expect(result[0].ar_credit_amount).toBe(1000)
})
})
// ============================================================
+33 -19
View File
@@ -633,17 +633,21 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
const styles = createStyles(branding)
const isCreditNote = !!invoice.credited_invoice_id
// Free-text / blank rows carry no amounts — exclude them from every VAT
// calculation. They still render as their own row in the line-items table.
const billableItems = items.filter((item) => item.line_type !== 'text')
// Check if items have mixed VAT rates
const hasPerLineVat = items.some((item) => item.vat_rate !== undefined && item.vat_rate !== null)
const hasPerLineVat = billableItems.some((item) => item.vat_rate !== undefined && item.vat_rate !== null)
const uniqueRates = hasPerLineVat
? new Set(items.map((item) => item.vat_rate))
? new Set(billableItems.map((item) => item.vat_rate))
: new Set<number>()
const showVatColumn = hasPerLineVat && uniqueRates.size > 1
// Calculate per-rate VAT breakdown for totals
const vatByRate = new Map<number, { base: number; vat: number }>()
if (hasPerLineVat) {
for (const item of items) {
for (const item of billableItems) {
const rate = item.vat_rate ?? 0
const group = vatByRate.get(rate) || { base: 0, vat: 0 }
group.base += Math.abs(item.line_total)
@@ -824,22 +828,32 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
</View>
{/* Table rows */}
{items.map((item, index) => (
<View key={index} style={styles.tableRow}>
<Text style={styles.colDescription}>{item.description}</Text>
<Text style={styles.colQty}>{item.quantity}</Text>
<Text style={styles.colUnit}>{item.unit}</Text>
{!isDeliveryNote && (
<Text style={styles.colPrice}>{formatCurrency(item.unit_price, invoice.currency, lang)}</Text>
)}
{!isDeliveryNote && showVatColumn && (
<Text style={styles.colVat}>{item.vat_rate ?? 0}%</Text>
)}
{!isDeliveryNote && (
<Text style={styles.colTotal}>{formatCurrency(item.line_total, invoice.currency, lang)}</Text>
)}
</View>
))}
{items.map((item, index) =>
item.line_type === 'text' ? (
// Free-text / blank row: description spans the full width, no
// numeric columns. An empty description renders as a spacer.
<View key={index} style={styles.tableRow}>
<Text style={[styles.colDescription, { width: '100%' }]}>
{item.description || ' '}
</Text>
</View>
) : (
<View key={index} style={styles.tableRow}>
<Text style={styles.colDescription}>{item.description}</Text>
<Text style={styles.colQty}>{item.quantity}</Text>
<Text style={styles.colUnit}>{item.unit}</Text>
{!isDeliveryNote && (
<Text style={styles.colPrice}>{formatCurrency(item.unit_price, invoice.currency, lang)}</Text>
)}
{!isDeliveryNote && showVatColumn && (
<Text style={styles.colVat}>{item.vat_rate ?? 0}%</Text>
)}
{!isDeliveryNote && (
<Text style={styles.colTotal}>{formatCurrency(item.line_total, invoice.currency, lang)}</Text>
)}
</View>
)
)}
</View>
</View>
+5 -5
View File
@@ -12,11 +12,11 @@ export interface VatRateOption {
* Swedish/EU-unvalidated customers can choose between 25%, 12%, 6%, and 0% (exempt).
* Reverse charge and export customers are locked to 0%.
*
* The picker does NOT gate on the seller's VAT registration status. A
* non-momsregistrerad seller is shown the same options as a registered one —
* the form surfaces a warning at submit time (ML 16 kap. 23 § faktureringsmoms:
* stated VAT is owed even by non-registered sellers, but the buyer cannot
* deduct it as input VAT).
* This helper does NOT gate on the seller's VAT registration status — it only
* knows the customer side. The seller-side gate lives one level up: the invoice
* form hides the Moms column entirely when company_settings.vat_registered is
* false, and both the create route and the MCP commit force every line to 0%
* (momsfri) server-side, so a non-momsregistrerad company never books output VAT.
*/
export function getAvailableVatRates(
customerType: CustomerType,
+168 -78
View File
@@ -1,19 +1,23 @@
/**
* Link an existing posted verifikat to a customer invoice as its payment row.
*
* Used when the GL already contains a verifikat that credits AR (default
* 1510) — e.g. a SIE-imported payment voucher, a manually-entered cash
* receipt, or any flow where the bookkeeping landed without invoice linkage.
* No new journal entry is created. Only an invoice_payments row is inserted
* pointing at the existing journal_entry_id, plus the invoice's
* The matching is accounting-method aware (company_settings.accounting_method):
* • Faktureringsmetoden (accrual): match verifikat that CREDIT an AR account
* (default 1510, covers 151x) — e.g. a SIE-imported payment voucher or a
* manually-entered receipt that clears the receivable.
* • Kontantmetoden (cash): no 1510 is ever booked (revenue is recognised at
* payment — debit 19xx / credit 30xx+26xx), so instead match verifikat that
* DEBIT a liquid-funds account (BAS class 19 — kassa/bank, covers
* 1910/1920/1930/1940…). That voucher IS the payment the user already
* booked; linking just marks the invoice paid without a duplicate entry.
*
* No new journal entry is created in either case. Only an invoice_payments row
* is inserted pointing at the existing journal_entry_id, plus the invoice's
* paid_amount/remaining_amount/status are advanced.
*
* Vouchers that book income directly (credit 30xx instead of 1510) are
* rejected here with VOUCHER_NO_AR_CREDIT. The proper fix for those is a
* storno+correction via gnubok_correct_entry — out of scope for this V1.
*
* Both the web API route and the MCP commit handler call into the same
* `linkInvoiceToVoucher()` function so behaviour stays in lockstep.
* `linkInvoiceToVoucher()` function (→ link_invoice_to_voucher RPC) so
* behaviour stays in lockstep.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { eventBus } from '@/lib/events/bus'
@@ -29,9 +33,42 @@ import type { Invoice, Customer } from '@/types'
const log = createLogger('voucher-matching')
/** AR account range. Default 1510 (Kundfordringar) — covers all 151x. */
/** AR account range. Default 1510 (Kundfordringar) — covers all 151x. Used on
* faktureringsmetoden, where the issuance verifikat books the receivable. */
const AR_ACCOUNT_PREFIX = '151'
/** Liquid-funds range (Kassa och bank, BAS class 19 — 1910/1920/1930/1940…).
* Used on kontantmetoden, where the payment verifikat debits a bank/cash
* account instead of crediting 1510. */
const CASH_ACCOUNT_PREFIX = '19'
/**
* Read the company's accounting method. Defaults to 'accrual' when the settings
* row or column is absent — mirrors mark-paid / propose-payment-lines.
*/
async function resolveAccountingMethod(
supabase: SupabaseClient,
companyId: string
): Promise<'accrual' | 'cash'> {
const { data, error } = await supabase
.from('company_settings')
.select('accounting_method')
.eq('company_id', companyId)
.maybeSingle()
if (error) {
// A transient failure here would silently flip a cash company to the
// accrual (151x) search and render an empty candidate list — make the
// fallback visible so an intermittent empty state is diagnosable.
log.warn('accounting_method lookup failed; falling back to accrual', {
companyId,
message: error.message,
})
}
return (data as { accounting_method?: string } | null)?.accounting_method === 'cash'
? 'cash'
: 'accrual'
}
/** ±90 days from the invoice's due_date as the default search window. */
const DEFAULT_DATE_WINDOW_DAYS = 90
@@ -47,10 +84,12 @@ export interface VoucherCandidate {
voucher_number: number | null
entry_date: string
description: string
/** Total credit to the AR account on this voucher (always positive). */
/** Matched amount on this voucher, always positive: the AR credit (151x) on
* faktureringsmetoden, or the liquid-funds debit (19xx) on kontantmetoden.
* Kept under this name for API/UI back-compat across both methods. */
ar_credit_amount: number
currency: string
/** Currency of the AR-credit line; nullable when the line stores SEK only. */
/** Currency of the matched line; nullable when the line stores SEK only. */
ar_line_currency: string | null
/** True when the voucher's fiscal period is closed or locked. */
period_locked: boolean
@@ -94,9 +133,10 @@ interface CandidateContext {
const EXCLUDED_SOURCE_TYPES = ['opening_balance', 'storno']
/**
* Find posted journal entries whose lines credit an AR account and could
* plausibly be the payment for this invoice. Returns up to `limit` ranked
* candidates.
* Find posted journal entries that could plausibly be the payment for this
* invoice and return up to `limit` ranked candidates. On faktureringsmetoden
* those are vouchers crediting an AR account (151x); on kontantmetoden they are
* vouchers debiting a liquid-funds account (19xx) — see the module header.
*
* The query is intentionally generous on filtering — we let the validator
* make the final call at commit time. Ranking mirrors
@@ -116,84 +156,138 @@ export async function findMatchingVouchersForInvoice(
const remainingAmount = computeRemaining(invoice)
if (remainingAmount <= AMOUNT_TOLERANCE) return []
// Cash method: match the bank/cash DEBIT (19xx). Accrual: match the AR
// CREDIT (151x). The account prefix + side both switch on the method.
const isCash = (await resolveAccountingMethod(supabase, companyId)) === 'cash'
const accountPrefix = isCash ? CASH_ACCOUNT_PREFIX : AR_ACCOUNT_PREFIX
const amountColumn = isCash ? 'debit_amount' : 'credit_amount'
const dueDate = new Date(invoice.due_date)
const dateFrom = new Date(dueDate)
dateFrom.setDate(dateFrom.getDate() - windowDays)
const dateTo = new Date(dueDate)
dateTo.setDate(dateTo.getDate() + windowDays)
const { data: lines, error } = await supabase
.from('journal_entry_lines')
// Pre-filter the matched side to a band around the invoice amount before the
// row cap applies. Without this, a cash company with many 19xx-debit lines
// (every bank receipt) overflows the cap and the relevant voucher can be
// dropped before it is ever scored. The band is a superset of every case
// scoreCandidate accepts (exact remaining/total + fuzzy ±1% capped 500 SEK),
// so it never hides a single-line match.
const hiAmount = Math.max(remainingAmount, invoice.total)
const loAmount = Math.min(remainingAmount, invoice.total)
const amountPad = Math.min(hiAmount * 0.01, 500) + 0.02
const amountFloor = Math.max(0, loAmount - amountPad)
const amountCeil = hiAmount + amountPad
// Drive the query from journal_entries, embedding the matched lines, NOT
// from journal_entry_lines joined up to the entry. PostgREST executes the
// FROM table first: driving from lines means scanning `account LIKE '19%'`
// across ALL tenants and running the lines RLS policy (a per-row EXISTS via
// current_active_company_id()) thousands of times — on a cash company every
// bank receipt is a 19xx debit, and the query blows the authenticated
// statement_timeout (8s). Driving from entries hits company+date+status
// indexes first (a handful of rows), so the per-line RLS check only runs for
// those entries' lines. Same result set, milliseconds instead of seconds.
let query = supabase
.from('journal_entries')
.select(
`
id,
journal_entry_id,
account_number,
debit_amount,
credit_amount,
currency,
journal_entries!inner (
voucher_series,
voucher_number,
entry_date,
description,
status,
source_type,
fiscal_period_id,
company_id,
journal_entry_lines!inner (
id,
voucher_series,
voucher_number,
entry_date,
description,
status,
source_type,
fiscal_period_id,
company_id
account_number,
debit_amount,
credit_amount,
currency
)
`
)
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.status', 'posted')
.like('account_number', `${AR_ACCOUNT_PREFIX}%`)
.gt('credit_amount', 0)
.gte('journal_entries.entry_date', dateFrom.toISOString().slice(0, 10))
.lte('journal_entries.entry_date', dateTo.toISOString().slice(0, 10))
.eq('company_id', companyId)
.eq('status', 'posted')
.gte('entry_date', dateFrom.toISOString().slice(0, 10))
.lte('entry_date', dateTo.toISOString().slice(0, 10))
.like('journal_entry_lines.account_number', `${accountPrefix}%`)
query = isCash
? query.gt('journal_entry_lines.debit_amount', 0)
: query.gt('journal_entry_lines.credit_amount', 0)
const { data: entryRows, error } = await query
.gte(`journal_entry_lines.${amountColumn}`, amountFloor)
.lte(`journal_entry_lines.${amountColumn}`, amountCeil)
.limit(limit * 10)
if (error || !lines) return []
if (error) {
// Surface transient failures instead of silently rendering "no candidates"
// — a swallowed error looks like a match that intermittently vanishes.
log.warn('voucher candidate query failed', {
companyId,
invoiceId: invoice.id,
message: error.message,
})
}
if (error || !entryRows) return []
// Group lines by journal_entry_id so we sum the AR credit per voucher.
// Sum the matched side per voucher (the embed already contains only the
// lines that passed the account/side/amount filters).
const byEntry = new Map<
string,
{ entry: VoucherRow; arCreditTotal: number; lineCurrency: string | null }
>()
for (const raw of lines) {
const line = raw as unknown as JournalEntryLine & {
journal_entries: VoucherRow
for (const raw of entryRows) {
const entry = raw as unknown as VoucherRow & {
journal_entry_lines: Pick<
JournalEntryLine,
'id' | 'account_number' | 'debit_amount' | 'credit_amount' | 'currency'
>[]
}
const entry = line.journal_entries
if (!entry) continue
if (EXCLUDED_SOURCE_TYPES.includes(entry.source_type ?? '')) continue
const credit = Number(line.credit_amount ?? 0)
if (credit <= 0) continue
const existing = byEntry.get(entry.id)
if (existing) {
existing.arCreditTotal += credit
} else {
byEntry.set(entry.id, {
entry,
arCreditTotal: credit,
lineCurrency: line.currency,
})
let matchedTotal = 0
let lineCurrency: string | null = null
for (const line of entry.journal_entry_lines ?? []) {
// Matched amount = the bank/cash debit (cash) or AR credit (accrual).
const matched = isCash ? Number(line.debit_amount ?? 0) : Number(line.credit_amount ?? 0)
if (matched <= 0) continue
matchedTotal += matched
if (!lineCurrency) lineCurrency = line.currency
}
if (matchedTotal <= 0) continue
byEntry.set(entry.id, { entry, arCreditTotal: matchedTotal, lineCurrency })
}
if (byEntry.size === 0) return []
// Drop entries already fully linked to *this* invoice.
// Fetch the already-linked payments (for dedup) and the fiscal-period locks
// (informational "låst period" badge) concurrently — both depend only on the
// grouped entries, so there is no reason to pay two sequential round-trips.
// Computing locks for entries that dedup later drops is harmless.
const candidateEntryIds = Array.from(byEntry.keys())
const { data: existingLinks } = await supabase
.from('invoice_payments')
.select('journal_entry_id')
.eq('company_id', companyId)
.eq('invoice_id', invoice.id)
.in('journal_entry_id', candidateEntryIds)
const periodIds = Array.from(
new Set(Array.from(byEntry.values()).map((v) => v.entry.fiscal_period_id))
)
const [{ data: existingLinks }, { data: periods }] = await Promise.all([
supabase
.from('invoice_payments')
.select('journal_entry_id')
.eq('company_id', companyId)
.eq('invoice_id', invoice.id)
.in('journal_entry_id', candidateEntryIds),
supabase
.from('fiscal_periods')
.select('id, status')
.in('id', periodIds),
])
// Drop entries already fully linked to *this* invoice.
const alreadyLinked = new Set(
(existingLinks ?? [])
.map((row) => (row as { journal_entry_id: string | null }).journal_entry_id)
@@ -202,16 +296,8 @@ export async function findMatchingVouchersForInvoice(
for (const id of alreadyLinked) byEntry.delete(id)
if (byEntry.size === 0) return []
// Resolve fiscal period locks in one batched query so we can surface a
// "period locked" flag in the candidate preview. Linking is allowed in
// locked periods (no JE mutation) — this is just informational.
const periodIds = Array.from(
new Set(Array.from(byEntry.values()).map((v) => v.entry.fiscal_period_id))
)
const { data: periods } = await supabase
.from('fiscal_periods')
.select('id, status')
.in('id', periodIds)
// Linking is allowed in locked periods (no JE mutation) — this flag is just
// informational for the candidate preview.
const lockedPeriods = new Set(
(periods ?? [])
.filter(
@@ -360,6 +446,10 @@ export async function validateVoucherForInvoiceLink(
return { ok: false, code: 'LINK_VOUCHER_INVOICE_FULLY_PAID' }
}
// Match the bank/cash debit (cash) or the AR credit (accrual) — see header.
const isCash = (await resolveAccountingMethod(supabase, companyId)) === 'cash'
const accountPrefix = isCash ? CASH_ACCOUNT_PREFIX : AR_ACCOUNT_PREFIX
const { data: voucher, error: voucherError } = await supabase
.from('journal_entries')
.select('id, voucher_series, voucher_number, entry_date, description, status, source_type, fiscal_period_id, company_id')
@@ -391,10 +481,10 @@ export async function validateVoucherForInvoiceLink(
let lineCurrency: string | null = null
for (const raw of lines) {
const line = raw as { account_number: string; debit_amount: number | null; credit_amount: number | null; currency: string | null }
if (!line.account_number?.startsWith(AR_ACCOUNT_PREFIX)) continue
const credit = Number(line.credit_amount ?? 0)
if (credit <= 0) continue
arCreditTotal += credit
if (!line.account_number?.startsWith(accountPrefix)) continue
const matched = isCash ? Number(line.debit_amount ?? 0) : Number(line.credit_amount ?? 0)
if (matched <= 0) continue
arCreditTotal += matched
if (!lineCurrency) lineCurrency = line.currency
}
arCreditTotal = round2(arCreditTotal)
@@ -0,0 +1,184 @@
/**
* Unit tests for the create_invoice executor, run through the public
* `commitPendingOperation` dispatcher (executors are not exported).
*
* Covers the two server-authoritative VAT behaviors flagged in review:
* 1. A non-VAT-registered company gets every line rate coerced to 0 and the
* invoice stored as momsfri ('exempt'), regardless of what was staged.
* 2. Free-text rows (line_type 'text') are excluded from subtotal, VAT, and
* mixed-rate detection — a text row's 0% must not flip vat_rate to null.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { eventBus } from '@/lib/events/bus'
import { makeCustomer } from '@/tests/helpers'
import type { PendingOperation } from '@/types'
import { commitPendingOperation } from '../commit'
function makePendingOp(overrides: Partial<PendingOperation>): PendingOperation {
return {
id: 'op-1',
user_id: 'user-1',
company_id: 'company-1',
operation_type: 'create_invoice',
status: 'pending',
title: 'test',
params: {},
preview_data: {},
result_data: null,
actor_type: 'user',
actor_id: null,
actor_label: null,
risk_level: 'medium',
created_at: '2026-05-03T00:00:00Z',
resolved_at: null,
updated_at: '2026-05-03T00:00:00Z',
...overrides,
} as PendingOperation
}
/**
* Queue-based supabase mock that also records `.insert()` payloads per table,
* so assertions can inspect what was actually written.
*/
function createCapturingSupabase(results: Array<{ data?: unknown; error?: unknown }>) {
const queue = [...results]
const inserts: Record<string, unknown[]> = {}
const from = vi.fn((table: string) => {
const raw = queue.shift() ?? { data: null, error: null }
const result = { data: raw.data ?? null, error: raw.error ?? null }
const chain: object = new Proxy(
{},
{
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => resolve(result)
}
if (prop === 'insert') {
return (payload: unknown) => {
;(inserts[table] ??= []).push(payload)
return chain
}
}
return () => chain
},
},
)
return chain
})
return { supabase: { from }, inserts }
}
const customer = makeCustomer({ id: 'cust-1', customer_type: 'swedish_business' })
/** Queue for the dispatcher + executor call sequence (SEK, no overrides):
* CAS claim → customers → company_settings → invoices insert →
* invoice_items insert → complete-invoice select → dispatcher update. */
function queueFor(settings: { vat_registered: boolean } | null) {
return [
{ data: { id: 'op-1' } },
{ data: customer },
{ data: settings },
{ data: { id: 'inv-1', invoice_number: null } },
{ data: null },
{ data: { id: 'inv-1' } },
{ data: null },
]
}
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
describe('commitPendingOperation: create_invoice', () => {
it('coerces a staged non-zero VAT rate to 0 for a non-VAT-registered company', async () => {
const { supabase, inserts } = createCapturingSupabase(queueFor({ vat_registered: false }))
const op = makePendingOp({
params: {
customer_id: 'cust-1',
items: [{ description: 'Konsulttimmar', quantity: 1, unit: 'tim', unit_price: 1000, vat_rate: 25 }],
invoice_date: '2026-06-01',
due_date: '2026-07-01',
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(inserts['invoices']).toHaveLength(1)
expect(inserts['invoices'][0]).toMatchObject({
subtotal: 1000,
vat_amount: 0,
total: 1000,
vat_rate: 0,
vat_treatment: 'exempt',
moms_ruta: null,
})
const itemRows = inserts['invoice_items'][0] as Array<Record<string, unknown>>
expect(itemRows).toHaveLength(1)
expect(itemRows[0]).toMatchObject({ vat_rate: 0, vat_amount: 0 })
})
it('keeps the staged rate for a VAT-registered company', async () => {
const { supabase, inserts } = createCapturingSupabase(queueFor({ vat_registered: true }))
const op = makePendingOp({
params: {
customer_id: 'cust-1',
items: [{ description: 'Konsulttimmar', quantity: 1, unit: 'tim', unit_price: 1000, vat_rate: 25 }],
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(inserts['invoices'][0]).toMatchObject({
subtotal: 1000,
vat_amount: 250,
total: 1250,
vat_rate: 25,
moms_ruta: '05',
})
})
it('excludes text rows from totals and mixed-rate detection', async () => {
const { supabase, inserts } = createCapturingSupabase(queueFor({ vat_registered: true }))
const op = makePendingOp({
params: {
customer_id: 'cust-1',
items: [
{ description: 'Konsulttimmar', quantity: 2, unit: 'tim', unit_price: 500, vat_rate: 25 },
{ line_type: 'text', description: 'Avser vecka 23', quantity: 0, unit: '', unit_price: 0, vat_rate: 0 },
],
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
// The text row's 0% must not trigger mixed-rate (vat_rate: null).
expect(inserts['invoices'][0]).toMatchObject({
subtotal: 1000,
vat_amount: 250,
total: 1250,
vat_rate: 25,
})
const itemRows = inserts['invoice_items'][0] as Array<Record<string, unknown>>
expect(itemRows).toHaveLength(2)
expect(itemRows[0]).toMatchObject({ line_type: 'product', vat_rate: 25, vat_amount: 250, line_total: 1000 })
expect(itemRows[1]).toMatchObject({
line_type: 'text',
description: 'Avser vecka 23',
quantity: 0,
unit_price: 0,
line_total: 0,
vat_rate: 0,
vat_amount: 0,
})
})
})
+49 -7
View File
@@ -650,8 +650,15 @@ async function commitCreateInvoice(
const items = params.items as Array<{
description: string; quantity: number; unit: string; unit_price: number; vat_rate?: number
article_id?: string | null; revenue_account?: string | null
line_type?: 'product' | 'text'
}>
// Free-text rows carry no amounts and never book. The MCP staging tool does
// not accept line_type today, but the totals math must stay identical to
// app/api/invoices/route.ts, which excludes text rows from subtotal, VAT,
// and the mixed-rate detection.
const billableItems = items.filter((item) => item.line_type !== 'text')
const { data: customer, error: customerError } = await supabase
.from('customers').select('*').eq('id', customerId).eq('company_id', companyId).single()
@@ -663,10 +670,22 @@ async function commitCreateInvoice(
const availableRates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated)
const allowedRates = new Set(availableRates.map((r) => r.rate))
const subtotal = items.reduce((sum, item) => sum + item.quantity * item.unit_price, 0)
// VAT registration gate (mirrors app/api/invoices/route.ts). A
// non-momsregistrerad company books no output VAT: force every line to 0%
// (momsfri → treatment 'exempt'). 0% is allowed for every customer type, so
// the allowedRates guard below still passes.
const { data: vatSettings } = await supabase
.from('company_settings')
.select('vat_registered')
.eq('company_id', companyId)
.maybeSingle()
const notVatRegistered = vatSettings?.vat_registered === false
if (notVatRegistered) for (const item of items) item.vat_rate = 0
const subtotal = billableItems.reduce((sum, item) => sum + item.quantity * item.unit_price, 0)
let vatAmount = 0
for (const item of items) {
for (const item of billableItems) {
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
if (!allowedRates.has(itemRate)) {
return { error: `Momssats ${itemRate}% är inte tillåten för denna kundtyp`, status: 400 }
@@ -678,7 +697,7 @@ async function commitCreateInvoice(
// Validate any per-line revenue-account override (defense in depth — the field
// is frozen onto invoice_items and flows to generatePerRateLines()).
const overrideAccounts = Array.from(
new Set(items.map((i) => i.revenue_account).filter((a): a is string => !!a)),
new Set(billableItems.map((i) => i.revenue_account).filter((a): a is string => !!a)),
)
for (const acct of overrideAccounts) {
if (!(await isValidRevenueAccount(supabase, companyId, acct))) {
@@ -706,7 +725,7 @@ async function commitCreateInvoice(
}
}
const uniqueRates = new Set(items.map((item) => item.vat_rate ?? vatRules.rate))
const uniqueRates = new Set(billableItems.map((item) => item.vat_rate ?? vatRules.rate))
const isMixedRate = uniqueRates.size > 1
const { data: invoice, error: invoiceError } = await supabase
@@ -727,10 +746,10 @@ async function commitCreateInvoice(
vat_amount_sek: vatAmountSek,
total,
total_sek: totalSek,
vat_treatment: vatRules.treatment,
vat_treatment: notVatRegistered ? 'exempt' : vatRules.treatment,
vat_rate: isMixedRate ? null : (uniqueRates.values().next().value ?? vatRules.rate),
moms_ruta: vatRules.momsRuta,
reverse_charge_text: vatRules.reverseChargeText || null,
moms_ruta: notVatRegistered ? null : vatRules.momsRuta,
reverse_charge_text: notVatRegistered ? null : (vatRules.reverseChargeText || null),
our_reference: (params.our_reference as string) || null,
your_reference: (params.your_reference as string) || null,
notes: (params.notes as string) || null,
@@ -741,12 +760,32 @@ async function commitCreateInvoice(
if (invoiceError) return { error: invoiceError.message, status: 500 }
const invoiceItems = items.map((item, index) => {
// Text rows store the description only and zero everything else. Keys must
// match the product branch exactly — PostgREST rejects a bulk insert whose
// objects have differing key sets.
if (item.line_type === 'text') {
return {
invoice_id: invoice.id,
sort_order: index,
line_type: 'text',
description: item.description ?? '',
quantity: 0,
unit: '',
unit_price: 0,
line_total: 0,
vat_rate: 0,
vat_amount: 0,
article_id: null,
revenue_account: null,
}
}
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
const lineTotal = item.quantity * item.unit_price
const itemVat = Math.round(lineTotal * itemRate / 100 * 100) / 100
return {
invoice_id: invoice.id,
sort_order: index,
line_type: 'product',
description: item.description,
quantity: item.quantity,
unit: item.unit,
@@ -2218,6 +2257,7 @@ async function commitCreditInvoice(
const creditItems = (original.items || []).map((item: {
sort_order: number
line_type?: 'product' | 'text'
description: string
quantity: number
unit: string
@@ -2230,6 +2270,7 @@ async function commitCreditInvoice(
}) => ({
invoice_id: creditNote.id,
sort_order: item.sort_order,
line_type: item.line_type ?? 'product',
description: item.description,
quantity: -Math.abs(item.quantity),
unit: item.unit,
@@ -2384,6 +2425,7 @@ async function commitConvertInvoice(
const items = (proforma.items ?? []).map((item: Record<string, unknown>) => ({
invoice_id: invoice.id,
sort_order: item.sort_order,
line_type: item.line_type ?? 'product',
description: item.description,
quantity: item.quantity,
unit: item.unit,
@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest'
import { effectiveNetPayout } from '../effective-net'
describe('effectiveNetPayout', () => {
it('returns net_salary when there is no tax override', () => {
expect(
effectiveNetPayout({ net_salary: 24000, tax_withheld: 8000, tax_withheld_override: null }),
).toBe(24000)
})
it('is zero for a nollkörning (nothing paid out)', () => {
expect(
effectiveNetPayout({ net_salary: 0, tax_withheld: 0, tax_withheld_override: null }),
).toBe(0)
})
it('raises the payout when tax is overridden lower than computed', () => {
// Computed tax 8000 → overridden to 5000 means 3000 more reaches the employee.
expect(
effectiveNetPayout({ net_salary: 24000, tax_withheld: 8000, tax_withheld_override: 5000 }),
).toBe(27000)
})
it('lowers the payout when tax is overridden higher than computed', () => {
expect(
effectiveNetPayout({ net_salary: 24000, tax_withheld: 8000, tax_withheld_override: 10000 }),
).toBe(22000)
})
})
+22
View File
@@ -0,0 +1,22 @@
/**
* The net amount actually paid out to an employee's bank account for a salary
* run, honoring any manual tax-withheld override. This is exactly the figure
* written into the pain.001 / Bankgirot LB payment files.
*
* Bank details (clearing + account number) are only required when this is > 0:
* a zero payout — e.g. a nollkörning, or an employee whose net is fully
* consumed by a nettolöneavdrag — produces no payment-file line, so there is
* no destination account to fill in. Gating the bank-details requirement on
* this keeps the approve guard and the payment-file generators in agreement.
*/
export interface EffectiveNetInput {
net_salary: number
tax_withheld: number
tax_withheld_override?: number | null
}
export function effectiveNetPayout(sre: EffectiveNetInput): number {
// net_salary was computed with the calculated tax; if the user overrode the
// tax, the payout shifts by the difference (lower tax → higher payout).
return sre.net_salary + (sre.tax_withheld - (sre.tax_withheld_override ?? sre.tax_withheld))
}
+34 -3
View File
@@ -2105,6 +2105,13 @@
"vat_label": "VAT",
"row_label": "Row {index}",
"add_row": "Add row",
"add_text_row": "Add text row",
"text_row_label": "Free text",
"text_row_placeholder": "Explanatory text – leave empty for a blank row",
"remove_row_aria": "Remove row",
"remove_row": "Remove row",
"row_actions_aria": "Row actions",
"drag_handle_aria": "Drag to move the row",
"notes_card_title": "Notes",
"notes_card_description": "Optional message on the invoice",
"notes_placeholder": "E.g. payment terms or thanks for the collaboration...",
@@ -2172,7 +2179,27 @@
"validation_customer_required": "Select a customer",
"validation_invoice_date_required": "Invoice date required",
"validation_due_date_required": "Due date required",
"validation_min_one_row": "At least one row required"
"validation_min_one_row": "At least one row required",
"deduction_menu_label": "Tax reduction",
"deduction_none": "None",
"deduction_rot": "ROT (30%)",
"deduction_rut": "RUT (50%)",
"deduction_work_type_placeholder": "Select work type",
"deduction_hours_placeholder": "Labor hours",
"deduction_labor_only_warning": "Skatteverket requires that only labor costs are included in the ROT/RUT base. Materials must be invoiced separately. Only apply a tax reduction to lines that are 100% labor.",
"deduction_card_title": "Tax reduction details",
"deduction_card_description": "ROT/RUT deductions are claimed from Skatteverket via the invoice model (fakturamodellen). The customer must approve the payout, so the details must match the buyer exactly.",
"deduction_personnummer_label": "Personal identity number (personnummer)",
"deduction_personnummer_placeholder": "YYYYMMDD-NNNN",
"deduction_personnummer_hint": "Encrypted before storage. Only the last four digits are shown on the invoice.",
"deduction_housing_label": "Property designation (fastighetsbeteckning)",
"deduction_housing_placeholder": "e.g. Stockholm Vasastan 1:23",
"deduction_housing_hint": "Required for ROT deductions (not needed for RUT).",
"deduction_cap_over": "The invoice's deduction exceeds the annual cap",
"deduction_cap_check": "The customer needs to check their remaining allowance themselves.",
"deduction_summary_label": "Tax reduction ROT/RUT",
"to_pay_label": "Amount to pay",
"total_incl_vat_label": "Total incl. VAT"
},
"invoice_review": {
"assigned_number_prefix": "Will be assigned invoice number",
@@ -2538,6 +2565,7 @@
},
"invoice_link_voucher": {
"intro": "Pick an existing posted journal entry that credits accounts receivable (1510). No new entry is created — you only link the existing one as the payment.",
"intro_cash": "Pick an existing posted journal entry that records the payment into a cash/bank account (e.g. 1930). No new entry is created — you only link the existing one as the payment.",
"search_placeholder": "Search by voucher number or description…",
"confidence_high": "Strong match",
"confidence_medium": "Likely match",
@@ -2545,6 +2573,7 @@
"period_locked": "Locked period",
"empty_title": "No matching journal entries found",
"empty_description": "No posted entry credits 1510 in this invoice's currency and date window. Post a new payment instead, or correct the prior bookkeeping first.",
"empty_description_cash": "No posted entry debits a cash/bank account (19xx) in this invoice's currency and date window. Post a new payment instead, or correct the prior bookkeeping first.",
"confirmation": "This links voucher {voucher} ({amount}) as the payment for the invoice.",
"no_new_je_note": "No new bookkeeping is created — the existing journal entry is the payment posting.",
"cancel": "Cancel",
@@ -3508,7 +3537,8 @@
"col_unit": "Unit",
"col_price": "Price excl. VAT",
"col_vat": "VAT",
"col_status": "Status"
"col_status": "Status",
"activate_and_save": "Activate and save"
},
"article_detail": {
"back": "Back to articles",
@@ -3543,7 +3573,8 @@
"deactivate_confirm_description": "The article is hidden from lists and invoice pickers but its history is kept. You can reactivate it later.",
"deactivate_confirm_label": "Deactivate",
"deactivated_title": "Article deactivated",
"deactivate_failed_title": "Could not deactivate article"
"deactivate_failed_title": "Could not deactivate article",
"activate_and_save": "Activate and save"
},
"form_article": {
"type_label": "Type *",
+34 -3
View File
@@ -2105,6 +2105,13 @@
"vat_label": "Moms",
"row_label": "Rad {index}",
"add_row": "Lägg till rad",
"add_text_row": "Lägg till textrad",
"text_row_label": "Fritext",
"text_row_placeholder": "Förklarande text – lämna tom för en tomrad",
"remove_row_aria": "Ta bort rad",
"remove_row": "Ta bort rad",
"row_actions_aria": "Radåtgärder",
"drag_handle_aria": "Dra för att flytta raden",
"notes_card_title": "Anteckningar",
"notes_card_description": "Valfritt meddelande på fakturan",
"notes_placeholder": "T.ex. betalningsvillkor eller tack för samarbetet...",
@@ -2172,7 +2179,27 @@
"validation_customer_required": "Välj en kund",
"validation_invoice_date_required": "Fakturadatum krävs",
"validation_due_date_required": "Förfallodatum krävs",
"validation_min_one_row": "Minst en rad krävs"
"validation_min_one_row": "Minst en rad krävs",
"deduction_menu_label": "Skattereduktion",
"deduction_none": "Ingen",
"deduction_rot": "ROT (30%)",
"deduction_rut": "RUT (50%)",
"deduction_work_type_placeholder": "Välj arbetstyp",
"deduction_hours_placeholder": "Arbetstimmar",
"deduction_labor_only_warning": "Skatteverket kräver att endast arbetskostnad ingår i ROT/RUT-grundlaget. Material ska faktureras separat. Sätt endast skattereduktion på rader som är 100% arbete.",
"deduction_card_title": "Underlag för skattereduktion",
"deduction_card_description": "ROT/RUT-avdrag begärs hos Skatteverket via fakturamodellen. Kunden behöver godkänna utbetalningen, så uppgifterna måste matcha köparen exakt.",
"deduction_personnummer_label": "Personnummer",
"deduction_personnummer_placeholder": "ÅÅÅÅMMDD-NNNN",
"deduction_personnummer_hint": "Krypteras innan lagring. Endast de fyra sista siffrorna visas på fakturan.",
"deduction_housing_label": "Fastighetsbeteckning",
"deduction_housing_placeholder": "t.ex. Stockholm Vasastan 1:23",
"deduction_housing_hint": "Krävs för ROT-avdrag (RUT behöver inte detta fält).",
"deduction_cap_over": "Fakturans avdrag överstiger årstaket",
"deduction_cap_check": "Kunden behöver kontrollera sitt återstående utrymme själv.",
"deduction_summary_label": "Skattereduktion ROT/RUT",
"to_pay_label": "Att betala",
"total_incl_vat_label": "Totalt inkl. moms"
},
"invoice_review": {
"assigned_number_prefix": "Tilldelas fakturanummer",
@@ -2538,6 +2565,7 @@
},
"invoice_link_voucher": {
"intro": "Välj en befintlig verifikation som krediterar kundfordran (1510). Ingen ny verifikation skapas — du länkar bara den befintliga som betalning.",
"intro_cash": "Välj en befintlig verifikation som bokför betalningen mot ett likvidkonto (kassa/bank, t.ex. 1930). Ingen ny verifikation skapas — du länkar bara den befintliga som betalning.",
"search_placeholder": "Sök på verifikatnummer eller beskrivning…",
"confidence_high": "Hög träff",
"confidence_medium": "Möjlig träff",
@@ -2545,6 +2573,7 @@
"period_locked": "Låst period",
"empty_title": "Inga matchande verifikationer hittades",
"empty_description": "Det finns ingen bokförd verifikation som krediterar 1510 i fakturans valuta och period. Bokför istället en ny betalning, eller rätta tidigare bokföring först.",
"empty_description_cash": "Det finns ingen bokförd verifikation som debiterar ett likvidkonto (19xx) i fakturans valuta och period. Bokför istället en ny betalning, eller rätta tidigare bokföring först.",
"confirmation": "Detta länkar verifikat {voucher} ({amount}) som betalning för fakturan.",
"no_new_je_note": "Ingen ny bokföring skapas — den befintliga verifikationen utgör betalningsposten.",
"cancel": "Avbryt",
@@ -3508,7 +3537,8 @@
"col_unit": "Enhet",
"col_price": "Pris exkl. moms",
"col_vat": "Moms",
"col_status": "Status"
"col_status": "Status",
"activate_and_save": "Aktivera och spara"
},
"article_detail": {
"back": "Tillbaka till artiklar",
@@ -3543,7 +3573,8 @@
"deactivate_confirm_description": "Artikeln döljs i listor och fakturaval men historiken bevaras. Du kan aktivera den igen senare.",
"deactivate_confirm_label": "Inaktivera",
"deactivated_title": "Artikel inaktiverad",
"deactivate_failed_title": "Kunde inte inaktivera artikel"
"deactivate_failed_title": "Kunde inte inaktivera artikel",
"activate_and_save": "Aktivera och spara"
},
"form_article": {
"type_label": "Typ *",
@@ -0,0 +1,241 @@
-- Make link_invoice_to_voucher accounting-method aware (kontantmetoden support).
--
-- The customer-invoice voucher-link RPC (latest definition:
-- 20260615120000_link_voucher_rpcs_tenant_guard.sql) only ever matched
-- verifikat that CREDIT an AR account (151x). On kontantmetoden no 1510 is ever
-- booked — revenue is recognised at payment (debit 19xx / credit 30xx+26xx) —
-- so the candidate set was always empty and "Befintlig verifikation" was
-- unusable. (The previous out-of-scope note lived in lib/invoices/voucher-matching.ts.)
--
-- This version reads company_settings.accounting_method and branches step 3:
-- • cash → sum the bank/cash DEBIT across the voucher's 19xx lines
-- (BAS class 19 — kassa/bank, covers 1910/1920/1930/1940…)
-- • accrual → sum the AR CREDIT across the voucher's 151x lines (unchanged)
-- Everything else (tenant guard, notes cap, attribution, locking, amount/
-- currency guards, the writes) is verbatim from 20260615120000. The internal
-- v_ar_credit_total name and the LINK_VOUCHER_NO_AR_CREDIT code are retained so
-- the TS/MCP callers map unchanged; the value simply carries the cash debit on
-- kontantmetoden. Mirrors the accounting-method branch in
-- lib/invoices/voucher-matching.ts so the staging preview and the commit agree.
CREATE OR REPLACE FUNCTION public.link_invoice_to_voucher(
p_invoice_id uuid,
p_journal_entry_id uuid,
p_user_id uuid,
p_company_id uuid,
p_notes text DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_invoice RECORD;
v_voucher RECORD;
v_ar_credit_total numeric := 0;
v_line_currency text;
v_remaining numeric;
v_payment_amount numeric;
v_new_paid numeric;
v_new_remaining numeric;
v_new_status text;
v_is_fully_paid boolean;
v_now timestamptz := now();
v_payment_id uuid;
v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', '');
v_acting_user uuid := p_user_id;
v_accounting_method text;
BEGIN
-- 0. Tenant guard (mirrors 20260611140000): anon/authenticated may only act
-- on their own companies; service_role / direct access bypasses.
IF v_jwt_role IN ('anon', 'authenticated') THEN
IF p_company_id NOT IN (SELECT public.user_company_ids()) THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_INVOICE_NOT_FOUND');
END IF;
-- Attribution: the JWT sub is authoritative for user-session callers —
-- p_user_id cannot point the payment row at someone else.
v_acting_user := coalesce(
(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub')::uuid,
p_user_id
);
END IF;
IF p_notes IS NOT NULL AND char_length(p_notes) > 2000 THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_VOUCHER_NOTES_TOO_LONG',
'details', jsonb_build_object('max_length', 2000, 'length', char_length(p_notes))
);
END IF;
-- 1. Lock the invoice for the duration of this transaction. FOR UPDATE so a
-- concurrent linker has to wait until we commit (or roll back).
SELECT * INTO v_invoice
FROM public.invoices
WHERE id = p_invoice_id AND company_id = p_company_id
FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_INVOICE_NOT_FOUND');
END IF;
IF v_invoice.status NOT IN ('sent', 'overdue', 'partially_paid') THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_VOUCHER_INVOICE_FULLY_PAID',
'details', jsonb_build_object('status', v_invoice.status)
);
END IF;
v_remaining := COALESCE(v_invoice.remaining_amount,
v_invoice.total - COALESCE(v_invoice.paid_amount, 0));
IF v_remaining <= 0.005 THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_INVOICE_FULLY_PAID');
END IF;
-- 2. Resolve the voucher.
SELECT * INTO v_voucher
FROM public.journal_entries
WHERE id = p_journal_entry_id AND company_id = p_company_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_VOUCHER_NOT_FOUND');
END IF;
IF v_voucher.status <> 'posted' THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_VOUCHER_NOT_POSTED',
'details', jsonb_build_object('status', v_voucher.status)
);
END IF;
IF v_voucher.source_type IN ('opening_balance', 'storno') THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_VOUCHER_NO_AR_CREDIT',
'details', jsonb_build_object('source_type', v_voucher.source_type)
);
END IF;
-- 3. Sum the matched amount across the voucher's lines. Branch on the
-- company's accounting method (defaults to accrual when no settings row).
SELECT cs.accounting_method INTO v_accounting_method
FROM public.company_settings cs
WHERE cs.company_id = p_company_id;
v_accounting_method := COALESCE(v_accounting_method, 'accrual');
IF v_accounting_method = 'cash' THEN
-- Kontantmetoden: the payment verifikat debits a liquid-funds account (19xx).
SELECT COALESCE(SUM(debit_amount), 0), MAX(currency)
INTO v_ar_credit_total, v_line_currency
FROM public.journal_entry_lines
WHERE journal_entry_id = p_journal_entry_id
AND account_number LIKE '19%'
AND debit_amount > 0;
ELSE
-- Faktureringsmetoden: the payment verifikat credits the AR account (151x).
SELECT COALESCE(SUM(credit_amount), 0), MAX(currency)
INTO v_ar_credit_total, v_line_currency
FROM public.journal_entry_lines
WHERE journal_entry_id = p_journal_entry_id
AND account_number LIKE '151%'
AND credit_amount > 0;
END IF;
v_ar_credit_total := ROUND(v_ar_credit_total * 100) / 100;
IF v_ar_credit_total <= 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_NO_AR_CREDIT');
END IF;
IF COALESCE(v_line_currency, v_invoice.currency) IS DISTINCT FROM v_invoice.currency THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_VOUCHER_CURRENCY_MISMATCH',
'details', jsonb_build_object(
'invoice_currency', v_invoice.currency,
'line_currency', v_line_currency
)
);
END IF;
IF v_ar_credit_total > v_remaining + 0.005 THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_VOUCHER_AMOUNT_EXCEEDS_REMAINING',
'details', jsonb_build_object(
'ar_credit', v_ar_credit_total,
'remaining', ROUND(v_remaining * 100) / 100
)
);
END IF;
-- 4. Reject re-link of the same voucher to the same invoice. Authoritative
-- under the FOR UPDATE lock; the partial unique index
-- idx_invoice_payments_je_inv_unique stays as the last line of defence
-- for non-RPC writers.
IF EXISTS (
SELECT 1 FROM public.invoice_payments
WHERE company_id = p_company_id
AND invoice_id = p_invoice_id
AND journal_entry_id = p_journal_entry_id
) THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_ALREADY_LINKED');
END IF;
-- 5. Compute the advance.
v_payment_amount := LEAST(v_ar_credit_total, ROUND(v_remaining * 100) / 100);
v_new_remaining := GREATEST(0,
ROUND((v_remaining - v_payment_amount) * 100) / 100
);
v_new_paid := ROUND((COALESCE(v_invoice.paid_amount, 0) + v_payment_amount) * 100) / 100;
v_is_fully_paid := v_new_remaining <= 0.005;
v_new_status := CASE WHEN v_is_fully_paid THEN 'paid' ELSE 'partially_paid' END;
-- 6. Apply both writes. The RPC body is one transaction; a failure on the
-- INSERT triggers PG's own rollback of the UPDATE — no manual rollback
-- path needed.
UPDATE public.invoices
SET status = v_new_status,
paid_at = CASE WHEN v_is_fully_paid THEN v_now ELSE paid_at END,
paid_amount = v_new_paid,
remaining_amount = v_new_remaining,
updated_at = v_now
WHERE id = p_invoice_id;
INSERT INTO public.invoice_payments (
user_id, company_id, invoice_id, payment_date, amount, currency,
exchange_rate, journal_entry_id, transaction_id, notes
) VALUES (
v_acting_user, p_company_id, p_invoice_id, v_voucher.entry_date,
v_payment_amount, v_invoice.currency, v_invoice.exchange_rate,
p_journal_entry_id, NULL, p_notes
)
RETURNING id INTO v_payment_id;
RETURN jsonb_build_object(
'ok', true,
'payment_id', v_payment_id,
'invoice_status', v_new_status,
'paid_amount', v_new_paid,
'remaining_amount', v_new_remaining,
'payment_amount', v_payment_amount,
'journal_entry_id', p_journal_entry_id,
'currency', v_invoice.currency,
'payment_date', v_voucher.entry_date
);
END;
$$;
-- CREATE OR REPLACE preserves privileges, but re-apply the canonical write-RPC
-- grants explicitly (audit A5): never callable anonymously; authenticated covers
-- user sessions, service_role covers the MCP / API-key paths.
REVOKE ALL ON FUNCTION public.link_invoice_to_voucher(uuid, uuid, uuid, uuid, text) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.link_invoice_to_voucher(uuid, uuid, uuid, uuid, text) TO authenticated, service_role;
COMMENT ON FUNCTION public.link_invoice_to_voucher(uuid, uuid, uuid, uuid, text) IS
'Atomically link an existing posted verifikat as payment for a customer invoice. Locks the invoice row, validates the voucher (faktureringsmetoden: credits 151x; kontantmetoden: debits 19xx), advances paid_amount/remaining_amount/status, and inserts an invoice_payments row in one PG transaction. Returns jsonb { ok, ..., payment_id } on success or { ok: false, code, details } on guard failure.';
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,16 @@
-- Add line_type to invoice_items: support free-text and blank spacer rows.
--
-- A 'text' row carries only a description (which may be empty, for a visual
-- spacer) and has no amounts. It is excluded from invoice totals and from the
-- bookkeeping the engine generates — the entry generators filter it out, so a
-- text row never produces a zero-amount journal line. Existing rows and every
-- non-text line default to 'product', preserving current behaviour.
ALTER TABLE public.invoice_items
ADD COLUMN IF NOT EXISTS line_type text NOT NULL DEFAULT 'product'
CHECK (line_type IN ('product', 'text'));
COMMENT ON COLUMN public.invoice_items.line_type IS
'product = normal billable line; text = free-text/blank row (description only, no amounts, excluded from totals and bookkeeping).';
NOTIFY pgrst, 'reload schema';
+9
View File
@@ -860,6 +860,12 @@ export interface InvoiceItem {
// Order
sort_order: number
// Line kind. 'product' is a normal billable line; 'text' is a free-text or
// blank spacer row that carries only a description — no amounts, excluded from
// totals and bookkeeping. Optional in TS for legacy rows (defaults to
// 'product' in Postgres).
line_type?: 'product' | 'text'
// Description
description: string
@@ -1073,6 +1079,9 @@ export interface CreateInvoiceInput {
}
export interface CreateInvoiceItemInput {
/** 'text' rows carry only a description (may be empty for a spacer) and are
* excluded from totals and bookkeeping. Defaults to 'product'. */
line_type?: 'product' | 'text'
description: string
quantity: number
unit: string