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:
co-authored by
Claude Fable 5
parent
21cfcbe180
commit
f9ea9c0082
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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"> </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}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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>) => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"')
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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';
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user