diff --git a/app/(dashboard)/articles/[id]/page.tsx b/app/(dashboard)/articles/[id]/page.tsx index 7db4ff50..ab4f3e1c 100644 --- a/app/(dashboard)/articles/[id]/page.tsx +++ b/app/(dashboard)/articles/[id]/page.tsx @@ -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
(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(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({ + + {/* Edit dialog */} diff --git a/app/(dashboard)/articles/page.tsx b/app/(dashboard)/articles/page.tsx index fad2a2f6..34214667 100644 --- a/app/(dashboard)/articles/page.tsx +++ b/app/(dashboard)/articles/page.tsx @@ -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(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() { )} + + ) } diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index 7c6930c2..4808579d 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -680,36 +680,47 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
{t('th_amount')}
- {/* Items — desktop */} + {/* Items — desktop. Free-text rows span the full width with no + numeric columns; a blank one renders as a spacer. */}
- {invoice.items.map((item) => ( -
-
{item.description}
-
{item.quantity}
-
{item.unit}
-
- {formatCurrency(item.unit_price, invoice.currency)} + {invoice.items.map((item) => + item.line_type === 'text' ? ( +
+
{item.description || ' '}
-
- {formatCurrency(item.line_total, invoice.currency)} + ) : ( +
+
{item.description}
+
{item.quantity}
+
{item.unit}
+
+ {formatCurrency(item.unit_price, invoice.currency)} +
+
+ {formatCurrency(item.line_total, invoice.currency)} +
-
- ))} + ) + )}
{/* Items — mobile cards */}
- {invoice.items.map((item) => ( -
-

{item.description}

-
- {item.quantity} {item.unit} × {formatCurrency(item.unit_price, invoice.currency)} + {invoice.items.map((item) => + item.line_type === 'text' ? ( +

{item.description || ' '}

+ ) : ( +
+

{item.description}

+
+ {item.quantity} {item.unit} × {formatCurrency(item.unit_price, invoice.currency)} +
+

+ {formatCurrency(item.line_total, invoice.currency)} +

-

- {formatCurrency(item.line_total, invoice.currency)} -

-
- ))} + ) + )}
diff --git a/app/(dashboard)/invoices/new/page.tsx b/app/(dashboard)/invoices/new/page.tsx index 9d27ed17..d3ea13f8 100644 --- a/app/(dashboard)/invoices/new/page.tsx +++ b/app/(dashboard)/invoices/new/page.tsx @@ -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() 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() { {t('items_card_description')} - {showNotRegisteredVatWarning && ( -
- -

- 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. -

-
- )}
+ {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 ( + +
+
+
+ + +
+ +
+
+
+ ) + } + // 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 ? ( + + + + + + {t('deduction_menu_label')} + { + 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) + } + }} + > + {t('deduction_none')} + {t('deduction_rot')} + {t('deduction_rut')} + + + remove(index)} + > + + {t('remove_row')} + + + + ) : ( + + ) return ( -
+
{/* Article picker (artikelregister). Optional — leave on @@ -1006,7 +1150,7 @@ export default function NewInvoicePage() { {/* Description + mobile delete button */}
-
+
)}
- + {renderRowActions('shrink-0 min-h-[44px] min-w-[44px] -mr-2 -mt-1 md:hidden')}
{/* Antal, Enhet, à-pris */} @@ -1075,154 +1210,118 @@ export default function NewInvoicePage() {
- {/* Moms */} -
- - ( - - )} - /> -
- - {/* Desktop delete button */} -
- -
- - {/* 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 && ( -
+ {/* Moms — hidden entirely when the company is not + momsregistrerad (no VAT may be charged). */} + {vatRegistered && ( +
+ { - const value = field.value ?? 'none' - return ( -
- Skattereduktion: + render={({ field }) => ( + + )} + /> +
+ )} + + {/* 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. */} +
+ +
+ {renderRowActions('')} +
+
+ + {/* 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 && ( +
+
+ + {watchItems[index]?.deduction_type === 'rot' ? 'ROT(30)' : 'RUT(50)'} + + { + const opts = + watchItems[index]?.deduction_type === 'rot' + ? ROT_WORK_TYPES + : RUT_WORK_TYPES + return ( - {watchItems[index]?.deduction_type && ( - <> - { - const opts = - watchItems[index]?.deduction_type === 'rot' - ? ROT_WORK_TYPES - : RUT_WORK_TYPES - return ( - - ) - }} - /> - - 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 ? ( - - −{formatCurrency(amt, watchCurrency)} - - ) : null - })()} - - )} -
- ) - }} - /> + ) + }} + /> + + 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 ? ( + + −{formatCurrency(amt, watchCurrency)} + + ) : null + })()} +
{/* 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 && ( -
- -

- 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. -

-
- )} +
+ +

{t('deduction_labor_only_warning')}

+
)} @@ -1232,33 +1331,71 @@ export default function NewInvoicePage() { {formatCurrency(lineTotal + lineVat, watchCurrency)}
+ ) })} +
- +
+ + {/* 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 && ( + + )} +
@@ -1269,47 +1406,46 @@ export default function NewInvoicePage() { {isInvoiceDoc && hasAnyDeduction && ( - Underlag för skattereduktion - - ROT/RUT-avdrag begärs hos Skatteverket via fakturamodellen. Kunden behöver godkänna utbetalningen, så uppgifterna måste matcha köparen exakt. - + {t('deduction_card_title')} + {t('deduction_card_description')}

- Krypteras innan lagring. Endast de fyra sista siffrorna visas på fakturan. + {t('deduction_personnummer_hint')}

{hasAnyRotLine && (

- Krävs för ROT-avdrag (RUT behöver inte detta fält). + {t('deduction_housing_hint')}

)} {(deductionByKind.rot > ROT_MAX || deductionByKind.rut > RUT_MAX) && (
- 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')}
)}
@@ -1458,7 +1594,9 @@ export default function NewInvoicePage() { {t('subtotal_label')} {formatCurrency(subtotal, watchCurrency)}
- {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]) => (
@@ -1476,7 +1614,7 @@ export default function NewInvoicePage() { )}
))} - {vatByRate.size === 0 && ( + {vatRegistered && vatByRate.size === 0 && (
{t('vat_label_short')} {formatCurrency(0, watchCurrency)} @@ -1484,18 +1622,18 @@ export default function NewInvoicePage() { )} {hasAnyDeduction && (
- Skattereduktion ROT/RUT + {t('deduction_summary_label')} −{formatCurrency(deductionTotal, watchCurrency)}
)}
- {hasAnyDeduction ? 'Att betala' : t('total_label')} + {hasAnyDeduction ? t('to_pay_label') : t('total_label')} {formatCurrency(hasAnyDeduction ? toPay : total, watchCurrency)}
{hasAnyDeduction && (
- Totalt inkl. moms + {t('total_incl_vat_label')} {formatCurrency(total, watchCurrency)}
)} @@ -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} diff --git a/app/(dashboard)/salary/runs/[id]/page.tsx b/app/(dashboard)/salary/runs/[id]/page.tsx index 04692ff8..88bc5069 100644 --- a/app/(dashboard)/salary/runs/[id]/page.tsx +++ b/app/(dashboard)/salary/runs/[id]/page.tsx @@ -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 }) diff --git a/app/api/articles/[id]/route.ts b/app/api/articles/[id]/route.ts index 494fdebf..a7836581 100644 --- a/app/api/articles/[id]/route.ts +++ b/app/api/articles/[id]/route.ts @@ -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 }) } } diff --git a/app/api/articles/__tests__/id.test.ts b/app/api/articles/__tests__/id.test.ts index 4718c800..ced0ad36 100644 --- a/app/api/articles/__tests__/id.test.ts +++ b/app/api/articles/__tests__/id.test.ts @@ -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 } }) diff --git a/app/api/articles/__tests__/route.test.ts b/app/api/articles/__tests__/route.test.ts index 50f576e9..0320ecfd 100644 --- a/app/api/articles/__tests__/route.test.ts +++ b/app/api/articles/__tests__/route.test.ts @@ -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 () => { diff --git a/app/api/articles/route.ts b/app/api/articles/route.ts index 8b3047b7..63ee8920 100644 --- a/app/api/articles/route.ts +++ b/app/api/articles/route.ts @@ -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 }) } } diff --git a/app/api/bookkeeping/voucher-sequences/next/__tests__/route.test.ts b/app/api/bookkeeping/voucher-sequences/next/__tests__/route.test.ts index 5a2fac97..d181a19b 100644 --- a/app/api/bookkeeping/voucher-sequences/next/__tests__/route.test.ts +++ b/app/api/bookkeeping/voucher-sequences/next/__tests__/route.test.ts @@ -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() + }) }) diff --git a/app/api/bookkeeping/voucher-sequences/next/route.ts b/app/api/bookkeeping/voucher-sequences/next/route.ts index 36361246..a71cb97e 100644 --- a/app/api/bookkeeping/voucher-sequences/next/route.ts +++ b/app/api/bookkeeping/voucher-sequences/next/route.ts @@ -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 } }) diff --git a/app/api/invoices/[id]/convert/route.ts b/app/api/invoices/[id]/convert/route.ts index de737280..c9f01f12 100644 --- a/app/api/invoices/[id]/convert/route.ts +++ b/app/api/invoices/[id]/convert/route.ts @@ -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, diff --git a/app/api/invoices/[id]/mark-paid/route.ts b/app/api/invoices/[id]/mark-paid/route.ts index 3e8574d0..5c988146 100644 --- a/app/api/invoices/[id]/mark-paid/route.ts +++ b/app/api/invoices/[id]/mark-paid/route.ts @@ -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, diff --git a/app/api/invoices/__tests__/route.test.ts b/app/api/invoices/__tests__/route.test.ts index 08e59042..5d41ec87 100644 --- a/app/api/invoices/__tests__/route.test.ts +++ b/app/api/invoices/__tests__/route.test.ts @@ -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 }) diff --git a/app/api/invoices/route.ts b/app/api/invoices/route.ts index 0ba612bc..da6a60af 100644 --- a/app/api/invoices/route.ts +++ b/app/api/invoices/route.ts @@ -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, diff --git a/app/api/pending-operations/[id]/commit/__tests__/route.test.ts b/app/api/pending-operations/[id]/commit/__tests__/route.test.ts index 80fc8924..84732e37 100644 --- a/app/api/pending-operations/[id]/commit/__tests__/route.test.ts +++ b/app/api/pending-operations/[id]/commit/__tests__/route.test.ts @@ -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 diff --git a/app/api/salary/runs/[id]/approve/__tests__/route.test.ts b/app/api/salary/runs/[id]/approve/__tests__/route.test.ts new file mode 100644 index 00000000..420d6220 --- /dev/null +++ b/app/api/salary/runs/[id]/approve/__tests__/route.test.ts @@ -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) + }) +}) diff --git a/app/api/salary/runs/[id]/approve/route.ts b/app/api/salary/runs/[id]/approve/route.ts index 3ddda10c..66a53a1b 100644 --- a/app/api/salary/runs/[id]/approve/route.ts +++ b/app/api/salary/runs/[id]/approve/route.ts @@ -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)`) } diff --git a/app/api/salary/runs/[id]/payment/bg-lb/route.ts b/app/api/salary/runs/[id]/payment/bg-lb/route.ts index 7239128c..8a4bdfda 100644 --- a/app/api/salary/runs/[id]/payment/bg-lb/route.ts +++ b/app/api/salary/runs/[id]/payment/bg-lb/route.ts @@ -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 { diff --git a/app/api/salary/runs/[id]/payment/pain001/route.ts b/app/api/salary/runs/[id]/payment/pain001/route.ts index e2462ef6..9a67fa2f 100644 --- a/app/api/salary/runs/[id]/payment/pain001/route.ts +++ b/app/api/salary/runs/[id]/payment/pain001/route.ts @@ -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 } diff --git a/components/articles/ArticleForm.tsx b/components/articles/ArticleForm.tsx index a95665ff..86e89884 100644 --- a/components/articles/ArticleForm.tsx +++ b/components/articles/ArticleForm.tsx @@ -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([]) + // 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(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({ resolver: zodResolver(schema), @@ -232,13 +257,18 @@ export default function ArticleForm({
{/* Revenue account */}
- - {t('revenue_account_label')} + ( + setCreateAccountPrefill(prefill)} + /> + )} />

{t('revenue_account_hint')}

@@ -332,6 +362,31 @@ export default function ArticleForm({ )}
+ + {/* 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. */} + { + 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) + }} + /> ) } diff --git a/components/bookkeeping/ActivateAccountsDialog.tsx b/components/bookkeeping/ActivateAccountsDialog.tsx index ff24b0db..597556e1 100644 --- a/components/bookkeeping/ActivateAccountsDialog.tsx +++ b/components/bookkeeping/ActivateAccountsDialog.tsx @@ -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([]) const [loading, setLoading] = useState(false) @@ -149,7 +153,7 @@ export function ActivateAccountsDialog({ ) : ( <> - Aktivera och bokför + {confirmLabel ?? 'Aktivera och bokför'} )} diff --git a/components/invoices/InvoiceReviewContent.tsx b/components/invoices/InvoiceReviewContent.tsx index b42af8eb..76474be0 100644 --- a/components/invoices/InvoiceReviewContent.tsx +++ b/components/invoices/InvoiceReviewContent.tsx @@ -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() 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({ - {items.map((item, index) => ( - - {item.description} - {item.quantity} - {item.unit} - {formatCurrency(item.unit_price, currency)} - {showVatColumn && ( - {item.vat_rate ?? 0}% - )} - - {formatCurrency(item.quantity * item.unit_price, currency)} - - - ))} + {items.map((item, index) => + item.line_type === 'text' ? ( + + + {item.description || ' '} + + + ) : ( + + {item.description} + {item.quantity} + {item.unit} + {formatCurrency(item.unit_price, currency)} + {showVatColumn && ( + {item.vat_rate ?? 0}% + )} + + {formatCurrency(item.quantity * item.unit_price, currency)} + + + ) + )}
- {items.map((item, index) => ( -
-

{item.description}

-
- {item.quantity} {item.unit} × {formatCurrency(item.unit_price, currency)} - {showVatColumn && {t('mobile_vat_suffix', { rate: item.vat_rate ?? 0 })}} + {items.map((item, index) => + item.line_type === 'text' ? ( +

{item.description || ' '}

+ ) : ( +
+

{item.description}

+
+ {item.quantity} {item.unit} × {formatCurrency(item.unit_price, currency)} + {showVatColumn && {t('mobile_vat_suffix', { rate: item.vat_rate ?? 0 })}} +
+

+ {formatCurrency(item.quantity * item.unit_price, currency)} +

-

- {formatCurrency(item.quantity * item.unit_price, currency)} -

-
- ))} + ) + )}
{/* Totals */} diff --git a/components/invoices/LinkVoucherPicker.tsx b/components/invoices/LinkVoucherPicker.tsx index b88a8cc9..676c97c1 100644 --- a/components/invoices/LinkVoucherPicker.tsx +++ b/components/invoices/LinkVoucherPicker.tsx @@ -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 (
-

{t('intro')}

+

{t(introKey)}

@@ -194,7 +208,7 @@ export default function LinkVoucherPicker({ ) : filtered.length === 0 ? (

{t('empty_title')}

-

{t('empty_description')}

+

{t(emptyDescriptionKey)}

) : (
    diff --git a/components/invoices/PaymentBookingDialog.tsx b/components/invoices/PaymentBookingDialog.tsx index 834ff3c1..ade49a17 100644 --- a/components/invoices/PaymentBookingDialog.tsx +++ b/components/invoices/PaymentBookingDialog.tsx @@ -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(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({ - {t('title')}{invoice.invoice_number ? t('title_suffix', { number: invoice.invoice_number }) : ''} + + {t('title')}{invoice.invoice_number ? t('title_suffix', { number: invoice.invoice_number }) : ''} + {nextVoucher && ( + + ({nextVoucher.series}{nextVoucher.next}) + + )} + {formatCurrency(invoice.total, invoice.currency)} {invoice.currency !== 'SEK' && invoice.total_sek && ( @@ -328,6 +376,7 @@ export default function PaymentBookingDialog({ { 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" /> updateLine(index, 'credit_amount', e.target.value)} - className="font-mono text-right h-8" + className="font-mono text-right" /> +
    {children}
    +
+ + ) +} diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index ca9e9350..fe4c8c82 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -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, diff --git a/extensions/general/skatteverket/index.ts b/extensions/general/skatteverket/index.ts index ec75250f..0d743d02 100644 --- a/extensions/general/skatteverket/index.ts +++ b/extensions/general/skatteverket/index.ts @@ -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) diff --git a/lib/api/__tests__/schemas.test.ts b/lib/api/__tests__/schemas.test.ts index f8a97884..8ab1c645 100644 --- a/lib/api/__tests__/schemas.test.ts +++ b/lib/api/__tests__/schemas.test.ts @@ -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', () => { diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 24b1e57f..cfac6dad 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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() diff --git a/lib/articles/validate-revenue-account.ts b/lib/articles/validate-revenue-account.ts index 76cff674..c4b4b42e 100644 --- a/lib/articles/validate-revenue-account.ts +++ b/lib/articles/validate-revenue-account.ts @@ -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 { + 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 diff --git a/lib/bookkeeping/__tests__/voucher-series-resolver.test.ts b/lib/bookkeeping/__tests__/voucher-series-resolver.test.ts index c5902ae7..14026d0e 100644 --- a/lib/bookkeeping/__tests__/voucher-series-resolver.test.ts +++ b/lib/bookkeeping/__tests__/voucher-series-resolver.test.ts @@ -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') diff --git a/lib/bookkeeping/invoice-entries.ts b/lib/bookkeeping/invoice-entries.ts index cda12bed..063d345a 100644 --- a/lib/bookkeeping/invoice-entries.ts +++ b/lib/bookkeeping/invoice-entries.ts @@ -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 diff --git a/lib/bookkeeping/propose-payment-lines.ts b/lib/bookkeeping/propose-payment-lines.ts index dec2923b..2f244718 100644 --- a/lib/bookkeeping/propose-payment-lines.ts +++ b/lib/bookkeeping/propose-payment-lines.ts @@ -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() - 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 diff --git a/lib/bookkeeping/voucher-series-resolver.ts b/lib/bookkeeping/voucher-series-resolver.ts index 200b6148..5076ae8d 100644 --- a/lib/bookkeeping/voucher-series-resolver.ts +++ b/lib/bookkeeping/voucher-series-resolver.ts @@ -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). diff --git a/lib/errors/__tests__/get-error-message.test.ts b/lib/errors/__tests__/get-error-message.test.ts index f7b5d845..419a4603 100644 --- a/lib/errors/__tests__/get-error-message.test.ts +++ b/lib/errors/__tests__/get-error-message.test.ts @@ -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"') diff --git a/lib/errors/get-error-message.ts b/lib/errors/get-error-message.ts index b73c32a1..13f7719b 100644 --- a/lib/errors/get-error-message.ts +++ b/lib/errors/get-error-message.ts @@ -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 diff --git a/lib/invoices/__tests__/voucher-matching.pg.test.ts b/lib/invoices/__tests__/voucher-matching.pg.test.ts index 00eb0822..16515c99 100644 --- a/lib/invoices/__tests__/voucher-matching.pg.test.ts +++ b/lib/invoices/__tests__/voucher-matching.pg.test.ts @@ -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 { + 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 { + 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') + }) +}) diff --git a/lib/invoices/__tests__/voucher-matching.test.ts b/lib/invoices/__tests__/voucher-matching.test.ts index 9af9793e..c84a0ea9 100644 --- a/lib/invoices/__tests__/voucher-matching.test.ts +++ b/lib/invoices/__tests__/voucher-matching.test.ts @@ -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) + }) }) // ============================================================ diff --git a/lib/invoices/pdf-template.tsx b/lib/invoices/pdf-template.tsx index fa27c388..b93e3fb0 100644 --- a/lib/invoices/pdf-template.tsx +++ b/lib/invoices/pdf-template.tsx @@ -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() const showVatColumn = hasPerLineVat && uniqueRates.size > 1 // Calculate per-rate VAT breakdown for totals const vatByRate = new Map() 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 {/* Table rows */} - {items.map((item, index) => ( - - {item.description} - {item.quantity} - {item.unit} - {!isDeliveryNote && ( - {formatCurrency(item.unit_price, invoice.currency, lang)} - )} - {!isDeliveryNote && showVatColumn && ( - {item.vat_rate ?? 0}% - )} - {!isDeliveryNote && ( - {formatCurrency(item.line_total, invoice.currency, lang)} - )} - - ))} + {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. + + + {item.description || ' '} + + + ) : ( + + {item.description} + {item.quantity} + {item.unit} + {!isDeliveryNote && ( + {formatCurrency(item.unit_price, invoice.currency, lang)} + )} + {!isDeliveryNote && showVatColumn && ( + {item.vat_rate ?? 0}% + )} + {!isDeliveryNote && ( + {formatCurrency(item.line_total, invoice.currency, lang)} + )} + + ) + )} diff --git a/lib/invoices/vat-rules.ts b/lib/invoices/vat-rules.ts index 68b28f8b..9e3a2634 100644 --- a/lib/invoices/vat-rules.ts +++ b/lib/invoices/vat-rules.ts @@ -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, diff --git a/lib/invoices/voucher-matching.ts b/lib/invoices/voucher-matching.ts index d1cd7655..4525547e 100644 --- a/lib/invoices/voucher-matching.ts +++ b/lib/invoices/voucher-matching.ts @@ -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) diff --git a/lib/pending-operations/__tests__/create-invoice-executor.test.ts b/lib/pending-operations/__tests__/create-invoice-executor.test.ts new file mode 100644 index 00000000..cf7b0ccf --- /dev/null +++ b/lib/pending-operations/__tests__/create-invoice-executor.test.ts @@ -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 { + 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 = {} + + 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> + 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> + 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, + }) + }) +}) diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index 11e2ecdb..d4131cfa 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -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) => ({ invoice_id: invoice.id, sort_order: item.sort_order, + line_type: item.line_type ?? 'product', description: item.description, quantity: item.quantity, unit: item.unit, diff --git a/lib/salary/payment/__tests__/effective-net.test.ts b/lib/salary/payment/__tests__/effective-net.test.ts new file mode 100644 index 00000000..41a719dd --- /dev/null +++ b/lib/salary/payment/__tests__/effective-net.test.ts @@ -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) + }) +}) diff --git a/lib/salary/payment/effective-net.ts b/lib/salary/payment/effective-net.ts new file mode 100644 index 00000000..65ef092f --- /dev/null +++ b/lib/salary/payment/effective-net.ts @@ -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)) +} diff --git a/messages/en.json b/messages/en.json index eb0ac172..57c000eb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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 *", diff --git a/messages/sv.json b/messages/sv.json index 705d4270..7b3a06bb 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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 *", diff --git a/supabase/migrations/20260620130000_link_invoice_to_voucher_cash_method.sql b/supabase/migrations/20260620130000_link_invoice_to_voucher_cash_method.sql new file mode 100644 index 00000000..0b555ce7 --- /dev/null +++ b/supabase/migrations/20260620130000_link_invoice_to_voucher_cash_method.sql @@ -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'; diff --git a/supabase/migrations/20260620140000_invoice_items_line_type.sql b/supabase/migrations/20260620140000_invoice_items_line_type.sql new file mode 100644 index 00000000..e9699ab6 --- /dev/null +++ b/supabase/migrations/20260620140000_invoice_items_line_type.sql @@ -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'; diff --git a/types/index.ts b/types/index.ts index ea1fe623..0904dd03 100644 --- a/types/index.ts +++ b/types/index.ts @@ -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