Feat/voucher docs (#664)
* feat: implement inbox document picker and linking functionality * feat: implement self-billing invoice functionality - Added support for registering self-billed invoices received from customers. - Updated the invoice schema to include fields for self-billing metadata such as `is_self_billed`, `external_invoice_number`, `self_billing_agreement_ref`, and `received_date`. - Created API route for handling self-billed invoice submissions, including validation and error handling. - Implemented database migrations to add necessary columns and constraints for self-billing invoices. - Developed tests to ensure correct behavior of self-billing invoice creation and validation rules. - Updated Swedish localization files to include new terms related to self-billing. * feat: enforce SIE import requirement for non-Fortnox providers in migration process * feat: streamline invoice processing and enhance error logging across APIs
This commit is contained in:
@@ -12,7 +12,7 @@ import { Separator } from '@/components/ui/separator'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency, formatDate, cn } from '@/lib/utils'
|
||||
import { getVatTreatmentLabel } from '@/lib/invoices/vat-rules'
|
||||
import { invoiceNumberDisplay } from '@/lib/invoices/display'
|
||||
import { invoiceNumberDisplay, invoiceDisplayNumber } from '@/lib/invoices/display'
|
||||
import { getDisplayTotal } from '@/lib/invoices/rounding'
|
||||
import {
|
||||
Loader2,
|
||||
@@ -427,6 +427,9 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
const isProforma = docType === 'proforma'
|
||||
const isDeliveryNote = docType === 'delivery_note'
|
||||
const isRealInvoice = docType === 'invoice'
|
||||
// Self-billing invoices we received: the document is the counterparty's, so
|
||||
// there is no own PDF to render and no send step — it arrives already booked.
|
||||
const isSelfBilled = !!invoice.is_self_billed
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
@@ -437,13 +440,16 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
</Button>
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<h1 className={cn('font-display text-2xl sm:text-3xl font-medium tracking-tight', !invoice.invoice_number && 'italic text-muted-foreground')}>{invoiceNumberDisplay(invoice.invoice_number)}</h1>
|
||||
<h1 className={cn('font-display text-2xl sm:text-3xl font-medium tracking-tight', !invoice.invoice_number && !isSelfBilled && 'italic text-muted-foreground')}>{isSelfBilled ? invoiceDisplayNumber(invoice as Invoice) : invoiceNumberDisplay(invoice.invoice_number)}</h1>
|
||||
{isProforma && (
|
||||
<Badge variant="secondary" className="bg-primary/10 text-primary">{t('badge_proforma')}</Badge>
|
||||
)}
|
||||
{isDeliveryNote && (
|
||||
<Badge variant="secondary" className="bg-success/10 text-success">{t('badge_delivery_note')}</Badge>
|
||||
)}
|
||||
{isSelfBilled && (
|
||||
<Badge variant="outline">{t('badge_self_billed')}</Badge>
|
||||
)}
|
||||
<Badge variant={statusVariant as 'default' | 'secondary' | 'destructive'}>
|
||||
{statusLabel(invoice.status)}
|
||||
</Badge>
|
||||
@@ -516,14 +522,17 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
{t('mark_as_paid')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={downloadPDF} disabled={isDownloading}>
|
||||
{isDownloading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t('download_pdf')}
|
||||
</Button>
|
||||
{/* No own PDF for a received self-billing invoice — the verifikationsunderlag is the document the customer sent us. */}
|
||||
{!isSelfBilled && (
|
||||
<Button variant="outline" onClick={downloadPDF} disabled={isDownloading}>
|
||||
{isDownloading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t('download_pdf')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -706,9 +715,15 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('invoice_number_label')}</span>
|
||||
<span className={cn('font-medium', !invoice.invoice_number && 'italic text-muted-foreground')}>{invoiceNumberDisplay(invoice.invoice_number)}</span>
|
||||
<span className="text-muted-foreground">{isSelfBilled ? t('external_number_label') : t('invoice_number_label')}</span>
|
||||
<span className={cn('font-medium', !invoice.invoice_number && !isSelfBilled && 'italic text-muted-foreground')}>{isSelfBilled ? invoiceDisplayNumber(invoice as Invoice) : invoiceNumberDisplay(invoice.invoice_number)}</span>
|
||||
</div>
|
||||
{isSelfBilled && (invoice as Invoice).self_billing_agreement_ref && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('agreement_ref_label')}</span>
|
||||
<span className="font-medium">{(invoice as Invoice).self_billing_agreement_ref}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('invoice_date_label')}</span>
|
||||
<span>{formatDate(invoice.invoice_date)}</span>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
|
||||
@@ -54,6 +55,10 @@ export default function NewInvoicePage() {
|
||||
const { company } = useCompany()
|
||||
const supabase = createClient()
|
||||
const t = useTranslations('invoice_editor')
|
||||
const ts = useTranslations('self_billing')
|
||||
// Toggle between a normal customer invoice (default) and registering a
|
||||
// self-billing invoice we received (mottagen självfaktura, ML 17 kap 15§).
|
||||
const [mode, setMode] = useState<'invoice' | 'self_billed'>('invoice')
|
||||
|
||||
const schema = useMemo(() => {
|
||||
const itemSchema = z.object({
|
||||
@@ -79,6 +84,11 @@ export default function NewInvoicePage() {
|
||||
your_reference: z.string().optional(),
|
||||
our_reference: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
// Self-billing received (mottagen självfaktura). Present in the form for
|
||||
// both modes; required only in self_billed mode — enforced in onSubmit.
|
||||
external_invoice_number: z.string().optional(),
|
||||
self_billing_agreement_ref: z.string().optional(),
|
||||
received_date: z.string().optional(),
|
||||
// Invoice-level ROT/RUT claim info. Personnummer is plaintext on
|
||||
// the wire; the API encrypts it before storage.
|
||||
deduction_personnummer: z.string().optional(),
|
||||
@@ -122,6 +132,7 @@ export default function NewInvoicePage() {
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
setError,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
@@ -131,6 +142,9 @@ export default function NewInvoicePage() {
|
||||
due_date: '',
|
||||
currency: 'SEK',
|
||||
document_type: 'invoice' as InvoiceDocumentType,
|
||||
external_invoice_number: '',
|
||||
self_billing_agreement_ref: '',
|
||||
received_date: '',
|
||||
items: [{
|
||||
description: '',
|
||||
quantity: 1,
|
||||
@@ -151,6 +165,7 @@ export default function NewInvoicePage() {
|
||||
// Set date defaults on client only to avoid hydration mismatch
|
||||
useEffect(() => {
|
||||
setValue('invoice_date', format(new Date(), 'yyyy-MM-dd'))
|
||||
setValue('received_date', format(new Date(), 'yyyy-MM-dd'))
|
||||
setValue('due_date', format(addDays(new Date(), 30), 'yyyy-MM-dd'))
|
||||
}, [])
|
||||
|
||||
@@ -364,7 +379,9 @@ export default function NewInvoicePage() {
|
||||
// the API recomputes server-side as the source of truth. Skipped for
|
||||
// non-invoice document types (proformas and delivery notes don't book
|
||||
// a deduction).
|
||||
const isInvoiceDoc = watchDocumentType === 'invoice'
|
||||
const isSelfBilled = mode === 'self_billed'
|
||||
// ROT/RUT is an own-issued, B2C concept — never shown for a received self-bill.
|
||||
const isInvoiceDoc = watchDocumentType === 'invoice' && !isSelfBilled
|
||||
const deductionByKind = { rot: 0, rut: 0 }
|
||||
if (isInvoiceDoc) {
|
||||
for (const item of watchItems) {
|
||||
@@ -383,7 +400,69 @@ export default function NewInvoicePage() {
|
||||
const hasAnyRotLine = isInvoiceDoc && watchItems.some((i) => i.deduction_type === 'rot')
|
||||
const toPay = Math.round((total - deductionTotal) * 100) / 100
|
||||
|
||||
// Self-billing path: no review dialog, no PDF, no send — it arrives already
|
||||
// booked. POST straight to the dedicated endpoint and open the verifikat.
|
||||
async function handleSelfBilledSubmit(data: FormData) {
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const response = await fetch('/api/invoices/self-billed', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
customer_id: data.customer_id,
|
||||
external_invoice_number: data.external_invoice_number,
|
||||
self_billing_agreement_ref: data.self_billing_agreement_ref || undefined,
|
||||
invoice_date: data.invoice_date,
|
||||
received_date: data.received_date,
|
||||
due_date: data.due_date,
|
||||
currency: data.currency,
|
||||
notes: data.notes,
|
||||
items: data.items.map((i) => ({
|
||||
description: i.description,
|
||||
quantity: i.quantity,
|
||||
unit: i.unit,
|
||||
unit_price: i.unit_price,
|
||||
vat_rate: i.vat_rate,
|
||||
})),
|
||||
}),
|
||||
})
|
||||
const result = await response.json()
|
||||
if (!response.ok) {
|
||||
throw new Error(getErrorMessage(result, { context: 'invoice', statusCode: response.status }))
|
||||
}
|
||||
toast({
|
||||
title: ts('created_title'),
|
||||
description: ts('created_description', { number: data.external_invoice_number ?? '' }),
|
||||
})
|
||||
router.push(`/invoices/${result.data.id}`)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: ts('create_failed_title'),
|
||||
description: getErrorMessage(error, { context: 'invoice' }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit(data: FormData) {
|
||||
if (isSelfBilled) {
|
||||
// The two self-billing-only fields are optional in the shared schema —
|
||||
// enforce them here so the inline errors render under the right inputs.
|
||||
let valid = true
|
||||
if (!data.external_invoice_number?.trim()) {
|
||||
setError('external_invoice_number', { message: ts('validation_external_number_required') })
|
||||
valid = false
|
||||
}
|
||||
if (!data.received_date) {
|
||||
setError('received_date', { message: ts('validation_received_date_required') })
|
||||
valid = false
|
||||
}
|
||||
if (!valid) return
|
||||
await handleSelfBilledSubmit(data)
|
||||
return
|
||||
}
|
||||
setPendingData(data)
|
||||
// Re-fetch the preview right before review so the displayed number
|
||||
// reflects any concurrent invoice creations. Skip for delivery notes.
|
||||
@@ -583,12 +662,16 @@ export default function NewInvoicePage() {
|
||||
)
|
||||
}
|
||||
|
||||
const titleText = watchDocumentType === 'proforma'
|
||||
const titleText = isSelfBilled
|
||||
? ts('title')
|
||||
: watchDocumentType === 'proforma'
|
||||
? t('title_proforma')
|
||||
: watchDocumentType === 'delivery_note'
|
||||
? t('title_delivery_note')
|
||||
: t('title_invoice')
|
||||
const subtitleText = watchDocumentType === 'proforma'
|
||||
const subtitleText = isSelfBilled
|
||||
? ts('subtitle')
|
||||
: watchDocumentType === 'proforma'
|
||||
? t('subtitle_proforma')
|
||||
: watchDocumentType === 'delivery_note'
|
||||
? t('subtitle_delivery_note')
|
||||
@@ -603,7 +686,7 @@ export default function NewInvoicePage() {
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">
|
||||
{titleText}
|
||||
{numberPreview && (
|
||||
{numberPreview && !isSelfBilled && (
|
||||
<span className="ml-2 text-muted-foreground tabular-nums text-xl md:text-2xl">
|
||||
({numberPreview})
|
||||
</span>
|
||||
@@ -618,7 +701,14 @@ export default function NewInvoicePage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasBankDetails === false && (
|
||||
<Tabs value={mode} onValueChange={(v) => setMode(v as 'invoice' | 'self_billed')}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="invoice">{t('mode_invoice')}</TabsTrigger>
|
||||
<TabsTrigger value="self_billed">{t('mode_self_billed')}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{hasBankDetails === false && !isSelfBilled && (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-border/60 bg-muted/30 px-4 py-3 text-sm">
|
||||
<Landmark className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">{t('bank_missing_warning')}</p>
|
||||
@@ -635,8 +725,8 @@ export default function NewInvoicePage() {
|
||||
{/* Customer selection */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('customer_card_title')}<RequiredMark /></CardTitle>
|
||||
<CardDescription>{t('customer_card_description')}</CardDescription>
|
||||
<CardTitle>{isSelfBilled ? <>{ts('customer_label')}<RequiredMark /></> : <>{t('customer_card_title')}<RequiredMark /></>}</CardTitle>
|
||||
<CardDescription>{isSelfBilled ? ts('issuer_card_description') : t('customer_card_description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Controller
|
||||
@@ -671,6 +761,22 @@ export default function NewInvoicePage() {
|
||||
<p className="text-sm text-destructive mt-2">{errors.customer_id.message}</p>
|
||||
)}
|
||||
|
||||
{isSelfBilled && (
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>{ts('external_number_label')}<RequiredMark /></Label>
|
||||
<Input placeholder={ts('external_number_placeholder')} {...register('external_invoice_number')} />
|
||||
{errors.external_invoice_number && (
|
||||
<p className="text-sm text-destructive">{errors.external_invoice_number.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{ts('agreement_ref_label')}</Label>
|
||||
<Input placeholder={ts('agreement_ref_placeholder')} {...register('self_billing_agreement_ref')} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1035,25 +1141,27 @@ export default function NewInvoicePage() {
|
||||
<CardTitle>{t('details_card_title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('document_type_label')}</Label>
|
||||
<Controller
|
||||
name="document_type"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="invoice">{t('doctype_invoice')}</SelectItem>
|
||||
<SelectItem value="proforma">{t('doctype_proforma')}</SelectItem>
|
||||
<SelectItem value="delivery_note">{t('doctype_delivery_note')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{!isSelfBilled && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('document_type_label')}</Label>
|
||||
<Controller
|
||||
name="document_type"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="invoice">{t('doctype_invoice')}</SelectItem>
|
||||
<SelectItem value="proforma">{t('doctype_proforma')}</SelectItem>
|
||||
<SelectItem value="delivery_note">{t('doctype_delivery_note')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('currency_label')}</Label>
|
||||
@@ -1087,44 +1195,58 @@ export default function NewInvoicePage() {
|
||||
<Input type="date" {...register('due_date')} aria-required="true" />
|
||||
</div>
|
||||
|
||||
{watchDocumentType === 'invoice' && (
|
||||
{isSelfBilled && (
|
||||
<div className="space-y-2">
|
||||
<Label>{ts('received_date_label')}<RequiredMark /></Label>
|
||||
<Input type="date" {...register('received_date')} aria-required="true" />
|
||||
{errors.received_date && (
|
||||
<p className="text-sm text-destructive">{errors.received_date.message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{watchDocumentType === 'invoice' && !isSelfBilled && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('delivery_date_label')}</Label>
|
||||
<Input type="date" {...register('delivery_date')} placeholder={t('delivery_date_placeholder')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
{!isSelfBilled && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('your_reference_label')}</Label>
|
||||
<Controller
|
||||
name="your_reference"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TagInput
|
||||
value={field.value ?? ''}
|
||||
onChange={field.onChange}
|
||||
placeholder={t('your_reference_placeholder')}
|
||||
<div className="space-y-2">
|
||||
<Label>{t('your_reference_label')}</Label>
|
||||
<Controller
|
||||
name="your_reference"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TagInput
|
||||
value={field.value ?? ''}
|
||||
onChange={field.onChange}
|
||||
placeholder={t('your_reference_placeholder')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('our_reference_label')}</Label>
|
||||
<Controller
|
||||
name="our_reference"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TagInput
|
||||
value={field.value ?? ''}
|
||||
onChange={field.onChange}
|
||||
placeholder={t('our_reference_placeholder')}
|
||||
<div className="space-y-2">
|
||||
<Label>{t('our_reference_label')}</Label>
|
||||
<Controller
|
||||
name="our_reference"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TagInput
|
||||
value={field.value ?? ''}
|
||||
onChange={field.onChange}
|
||||
placeholder={t('our_reference_placeholder')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1191,7 +1313,7 @@ export default function NewInvoicePage() {
|
||||
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
|
||||
>
|
||||
{!canWrite && <Lock className="mr-2 h-4 w-4 inline" />}
|
||||
{t('review_and_create')}
|
||||
{isSelfBilled ? ts('register') : t('review_and_create')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1213,7 +1335,7 @@ export default function NewInvoicePage() {
|
||||
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
|
||||
>
|
||||
{!canWrite && <Lock className="mr-2 h-4 w-4 inline" />}
|
||||
{t('review_and_create')}
|
||||
{isSelfBilled ? ts('register') : t('review_and_create')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { invoiceNumberDisplay } from '@/lib/invoices/display'
|
||||
import { invoiceNumberDisplay, invoiceDisplayNumber } from '@/lib/invoices/display'
|
||||
import { getDisplayTotal } from '@/lib/invoices/rounding'
|
||||
import { Plus, Search, Receipt, Lock, Repeat } from 'lucide-react'
|
||||
import { EmptyInvoices } from '@/components/ui/empty-state'
|
||||
@@ -114,6 +114,7 @@ export default function InvoicesPage() {
|
||||
const filteredInvoices = invoices.filter((invoice) => {
|
||||
const matchesSearch =
|
||||
(invoice.invoice_number ?? '').toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(invoice.external_invoice_number ?? '').toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(invoice.customer as { name: string })?.name?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
@@ -305,8 +306,8 @@ export default function InvoicesPage() {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DataListPrimary className={cn(!invoice.invoice_number && 'italic text-muted-foreground')}>
|
||||
{invoiceNumberDisplay(invoice.invoice_number)}{' '}
|
||||
<DataListPrimary className={cn(!invoice.invoice_number && !invoice.external_invoice_number && 'italic text-muted-foreground')}>
|
||||
{invoice.is_self_billed ? invoiceDisplayNumber(invoice) : invoiceNumberDisplay(invoice.invoice_number)}{' '}
|
||||
<span className="font-normal text-muted-foreground">
|
||||
· {(invoice.customer as { name: string })?.name}
|
||||
</span>
|
||||
@@ -344,6 +345,14 @@ export default function InvoicesPage() {
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
{invoice.is_self_billed && (
|
||||
<>
|
||||
<DataListMetaSeparator />
|
||||
<Badge variant="outline" className="h-4 px-1.5 py-0 text-[10px]">
|
||||
{t('badge_self_billed')}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
{relativeTime && (
|
||||
<>
|
||||
<DataListMetaSeparator />
|
||||
|
||||
@@ -1,50 +1,38 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(),
|
||||
createServiceClient: vi.fn(),
|
||||
}))
|
||||
|
||||
import { createClient, createServiceClient } from '@/lib/supabase/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { GET } from '../route'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const mockCreateClient = vi.mocked(createClient)
|
||||
const mockCreateServiceClient = vi.mocked(createServiceClient)
|
||||
|
||||
function mockAuth(user: { id: string } | null) {
|
||||
mockCreateClient.mockResolvedValue({
|
||||
auth: { getUser: vi.fn().mockResolvedValue({ data: { user } }) },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any)
|
||||
}
|
||||
|
||||
/**
|
||||
* Service-role mock. Returns a match only if the incoming `.eq('org_number', X)`
|
||||
* value matches `existing`. Anything else (or empty `existing`) returns null.
|
||||
* Minimal authenticated-client mock. `companies.data` seeds what the RLS-scoped
|
||||
* `from('companies').select().eq().is()` chain resolves to. In production RLS
|
||||
* filters this to the caller's own memberships; the route does no extra
|
||||
* filtering, so the test just controls what the query returns.
|
||||
*/
|
||||
function mockService(existing?: string) {
|
||||
let lastOrgNumber: string | null = null
|
||||
function buildSupabase(opts: {
|
||||
user: { id: string } | null
|
||||
companies?: { data?: unknown; error?: unknown }
|
||||
}) {
|
||||
const result = {
|
||||
data: opts.companies?.data ?? null,
|
||||
error: opts.companies?.error ?? null,
|
||||
}
|
||||
const chain: Record<string, unknown> = {}
|
||||
const methods = ['select', 'eq', 'is', 'limit', 'maybeSingle']
|
||||
for (const m of methods) {
|
||||
chain[m] = (...args: unknown[]) => {
|
||||
if (m === 'eq' && args[0] === 'org_number') {
|
||||
lastOrgNumber = String(args[1])
|
||||
}
|
||||
if (m === 'maybeSingle') {
|
||||
return Promise.resolve({
|
||||
data: existing && lastOrgNumber === existing ? { id: 'other' } : null,
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
return chain
|
||||
}
|
||||
for (const m of ['select', 'eq', 'is', 'limit', 'order']) {
|
||||
chain[m] = () => chain
|
||||
}
|
||||
;(chain as { then?: unknown }).then = (resolve: (v: unknown) => void) => resolve(result)
|
||||
return {
|
||||
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: opts.user } }) },
|
||||
from: vi.fn(() => chain),
|
||||
}
|
||||
mockCreateServiceClient.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue(chain),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -53,68 +41,65 @@ beforeEach(() => {
|
||||
|
||||
describe('GET /api/company/check-org-number', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
mockAuth(null)
|
||||
mockService()
|
||||
const req = createMockRequest('/api/company/check-org-number?org_number=5560125790')
|
||||
const { status } = await parseJsonResponse(await GET(req))
|
||||
mockCreateClient.mockResolvedValue(buildSupabase({ user: null }) as never)
|
||||
const res = await GET(createMockRequest('/api/company/check-org-number?org_number=5560125790'))
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 when org_number is missing', async () => {
|
||||
mockAuth({ id: 'user-1' })
|
||||
mockService()
|
||||
const req = createMockRequest('/api/company/check-org-number')
|
||||
const { status } = await parseJsonResponse(await GET(req))
|
||||
mockCreateClient.mockResolvedValue(buildSupabase({ user: { id: 'u1' } }) as never)
|
||||
const res = await GET(createMockRequest('/api/company/check-org-number'))
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns exists=false when the org number is not registered', async () => {
|
||||
mockAuth({ id: 'user-1' })
|
||||
mockService(undefined) // no existing match
|
||||
const req = createMockRequest('/api/company/check-org-number?org_number=5560125790')
|
||||
const { status, body } = await parseJsonResponse(await GET(req))
|
||||
it('returns exists:false for malformed org_number without querying', async () => {
|
||||
const supabase = buildSupabase({ user: { id: 'u1' } })
|
||||
mockCreateClient.mockResolvedValue(supabase as never)
|
||||
const res = await GET(createMockRequest('/api/company/check-org-number?org_number=not-a-number'))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { exists: boolean; companies: unknown[] }
|
||||
}>(res)
|
||||
expect(status).toBe(200)
|
||||
expect((body as { data: { exists: boolean } }).data.exists).toBe(false)
|
||||
expect(body.data.exists).toBe(false)
|
||||
expect(body.data.companies).toEqual([])
|
||||
expect(supabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns exists=true when the org number is already registered', async () => {
|
||||
mockAuth({ id: 'user-1' })
|
||||
mockService('5560125790')
|
||||
const req = createMockRequest('/api/company/check-org-number?org_number=5560125790')
|
||||
const { status, body } = await parseJsonResponse(await GET(req))
|
||||
it("reports the user's own matching companies (account-scoped via RLS)", async () => {
|
||||
mockCreateClient.mockResolvedValue(
|
||||
buildSupabase({
|
||||
user: { id: 'u1' },
|
||||
companies: { data: [{ id: 'c1', name: 'Acme AB' }] },
|
||||
}) as never,
|
||||
)
|
||||
// Hyphenated input still matches the stored 10-digit canonical.
|
||||
const res = await GET(createMockRequest('/api/company/check-org-number?org_number=556012-5790'))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { exists: boolean; companies: { id: string; name: string }[] }
|
||||
}>(res)
|
||||
expect(status).toBe(200)
|
||||
expect((body as { data: { exists: boolean } }).data.exists).toBe(true)
|
||||
expect(body.data.exists).toBe(true)
|
||||
expect(body.data.companies).toEqual([{ id: 'c1', name: 'Acme AB' }])
|
||||
})
|
||||
|
||||
it('normalizes formatted org numbers before lookup (strips hyphens/spaces)', async () => {
|
||||
mockAuth({ id: 'user-1' })
|
||||
mockService('5560125790')
|
||||
const req = createMockRequest('/api/company/check-org-number?org_number=556012-5790')
|
||||
const { status, body } = await parseJsonResponse(await GET(req))
|
||||
it('returns exists:false when the user has no company with that org number', async () => {
|
||||
mockCreateClient.mockResolvedValue(
|
||||
buildSupabase({ user: { id: 'u1' }, companies: { data: [] } }) as never,
|
||||
)
|
||||
const res = await GET(createMockRequest('/api/company/check-org-number?org_number=5560125790'))
|
||||
const { status, body } = await parseJsonResponse<{ data: { exists: boolean } }>(res)
|
||||
expect(status).toBe(200)
|
||||
expect((body as { data: { exists: boolean } }).data.exists).toBe(true)
|
||||
expect(body.data.exists).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes 12-digit input to 10-digit canonical before lookup', async () => {
|
||||
// Stored form is 10-digit canonical (8001011231); user types 12-digit
|
||||
// personnummer with century prefix.
|
||||
mockAuth({ id: 'user-1' })
|
||||
mockService('8001011231')
|
||||
const req = createMockRequest('/api/company/check-org-number?org_number=198001011231')
|
||||
const { status, body } = await parseJsonResponse(await GET(req))
|
||||
expect(status).toBe(200)
|
||||
expect((body as { data: { exists: boolean } }).data.exists).toBe(true)
|
||||
})
|
||||
|
||||
it('returns exists=false for Luhn-invalid input (not a duplicate of anything)', async () => {
|
||||
// The submit-time server action will reject this as org_number_invalid;
|
||||
// here we just confirm the check endpoint doesn't produce a misleading
|
||||
// "exists=true" result by accidentally matching an invalid number.
|
||||
mockAuth({ id: 'user-1' })
|
||||
mockService('5560125790') // a real registered number
|
||||
const req = createMockRequest('/api/company/check-org-number?org_number=5560125791')
|
||||
const { status, body } = await parseJsonResponse(await GET(req))
|
||||
expect(status).toBe(200)
|
||||
expect((body as { data: { exists: boolean } }).data.exists).toBe(false)
|
||||
it('returns 500 when the query errors', async () => {
|
||||
mockCreateClient.mockResolvedValue(
|
||||
buildSupabase({ user: { id: 'u1' }, companies: { error: { message: 'boom' } } }) as never,
|
||||
)
|
||||
const res = await GET(createMockRequest('/api/company/check-org-number?org_number=5560125790'))
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(500)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,30 +1,31 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createClient, createServiceClient } from '@/lib/supabase/server'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
|
||||
|
||||
/**
|
||||
* GET /api/company/check-org-number?org_number=XXXXXXXXXX
|
||||
*
|
||||
* Returns `{ data: { exists: boolean } }` indicating whether the given
|
||||
* organisation number is already registered in any non-archived Accounted
|
||||
* company. Used by the onboarding wizard to warn users before they try to
|
||||
* create a duplicate.
|
||||
* Returns `{ data: { exists: boolean, companies: { id, name }[] } }` for the
|
||||
* companies the CURRENT USER already has with the given organisation number —
|
||||
* scoped to their own account only.
|
||||
*
|
||||
* Normalizes the input with the same rule as the server action
|
||||
* (`normalizeOrgNumber`) so that a 12-digit form typed in the UI still
|
||||
* matches a 10-digit stored canonical. Returns `exists: false` for
|
||||
* malformed input — the submit-time server action will reject it with
|
||||
* `org_number_invalid`, which is the right place to surface the error.
|
||||
* Org-number reuse across the platform is intentionally allowed (see
|
||||
* lib/company/actions.ts), so this is a soft, account-scoped warning, NOT a
|
||||
* uniqueness gate. It uses the normal authenticated client on purpose: the
|
||||
* `companies` SELECT RLS policy limits results to companies the caller is a
|
||||
* member of (id IN user_company_ids()), so it can never reveal another user's
|
||||
* companies and can't be used to enumerate org numbers platform-wide.
|
||||
*
|
||||
* Requires authentication so the endpoint can't be used to enumerate the
|
||||
* full set of org numbers on the platform. Uses the service role internally
|
||||
* because RLS hides rows the caller isn't a member of — which is exactly
|
||||
* what we need to detect ("owned by someone else").
|
||||
* Normalizes input with the same rule as the create action so a 12-digit form
|
||||
* still matches a stored 10-digit canonical. Returns no matches for malformed
|
||||
* input — the create action rejects that separately as `org_number_invalid`.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
// requireAuth() (not a raw getUser()) so MFA AAL2 is enforced on hosted before
|
||||
// we run the account-scoped lookup. The returned client carries the caller's
|
||||
// RLS context, which is what scopes the companies SELECT below.
|
||||
const { supabase, error: authError } = await requireAuth()
|
||||
if (authError) return authError
|
||||
|
||||
const url = new URL(request.url)
|
||||
const raw = url.searchParams.get('org_number') ?? ''
|
||||
@@ -34,22 +35,27 @@ export async function GET(request: Request) {
|
||||
|
||||
const canonical = normalizeOrgNumber(raw)
|
||||
if (!canonical) {
|
||||
// Invalid format/Luhn — not a duplicate of anything by definition.
|
||||
return NextResponse.json({ data: { exists: false } })
|
||||
// Malformed input is not a duplicate of anything by definition.
|
||||
return NextResponse.json({ data: { exists: false, companies: [] } })
|
||||
}
|
||||
|
||||
const service = createServiceClient()
|
||||
const { data, error } = await service
|
||||
// RLS scopes this SELECT to the caller's own memberships (companies_select:
|
||||
// id IN user_company_ids()), so the result is inherently account-scoped.
|
||||
const { data, error } = await supabase
|
||||
.from('companies')
|
||||
.select('id')
|
||||
.select('id, name')
|
||||
.eq('org_number', canonical)
|
||||
.is('archived_at', null)
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { exists: !!data } })
|
||||
const companies = (data ?? []).map((c: { id: string; name: string }) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
}))
|
||||
return NextResponse.json({
|
||||
data: { exists: companies.length > 0, companies },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import {
|
||||
parseJsonResponse,
|
||||
createMockRouteParams,
|
||||
createQueuedMockSupabase,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
eventBus.clear()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
vi.mocked(requireWritePermission).mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
function makeReq(body: unknown) {
|
||||
return new Request('http://localhost/api/documents/doc-1/link', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
describe('POST /api/documents/[id]/link', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
const res = await POST(makeReq({ journal_entry_id: 'je-1' }), createMockRouteParams({ id: 'doc-1' }))
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 403 when caller has read-only role', async () => {
|
||||
vi.mocked(requireWritePermission).mockResolvedValue({
|
||||
ok: false,
|
||||
response: NextResponse.json(
|
||||
{ error: 'Du har endast läsbehörighet i detta företag.' },
|
||||
{ status: 403 },
|
||||
),
|
||||
})
|
||||
const res = await POST(makeReq({ journal_entry_id: 'je-1' }), createMockRouteParams({ id: 'doc-1' }))
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(403)
|
||||
})
|
||||
|
||||
it('rejects a missing journal_entry_id', async () => {
|
||||
const res = await POST(makeReq({}), createMockRouteParams({ id: 'doc-1' }))
|
||||
const { body } = await parseJsonResponse<{ error: { code: string } }>(res)
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
})
|
||||
|
||||
it('links the document and stamps the inbox item when inbox_item_id is given', async () => {
|
||||
enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-1', file_name: 'x.pdf' } }) // link update
|
||||
enqueue({ data: null }) // inbox stamp update
|
||||
|
||||
const res = await POST(
|
||||
makeReq({ journal_entry_id: 'je-1', inbox_item_id: 'inbox-1' }),
|
||||
createMockRouteParams({ id: 'doc-1' }),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ data: { id: string } }>(res)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.id).toBe('doc-1')
|
||||
expect(mockSupabase.from).toHaveBeenCalledWith('document_attachments')
|
||||
expect(mockSupabase.from).toHaveBeenCalledWith('invoice_inbox_items')
|
||||
})
|
||||
|
||||
it('does not touch the inbox when no inbox_item_id is given', async () => {
|
||||
enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-1', file_name: 'x.pdf' } }) // link update
|
||||
|
||||
const res = await POST(
|
||||
makeReq({ journal_entry_id: 'je-1' }),
|
||||
createMockRouteParams({ id: 'doc-1' }),
|
||||
)
|
||||
const { status } = await parseJsonResponse(res)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(mockSupabase.from).not.toHaveBeenCalledWith('invoice_inbox_items')
|
||||
})
|
||||
|
||||
it('maps a period-lock trigger error to PERIOD_LOCKED', async () => {
|
||||
enqueue({
|
||||
data: null,
|
||||
error: { message: 'new row violates ... locked/closed fiscal period' },
|
||||
})
|
||||
const res = await POST(
|
||||
makeReq({ journal_entry_id: 'je-1', inbox_item_id: 'inbox-1' }),
|
||||
createMockRouteParams({ id: 'doc-1' }),
|
||||
)
|
||||
const { body } = await parseJsonResponse<{ error: { code: string } }>(res)
|
||||
expect(body.error.code).toBe('PERIOD_LOCKED')
|
||||
// The inbox stamp must not run when the link itself failed.
|
||||
expect(mockSupabase.from).not.toHaveBeenCalledWith('invoice_inbox_items')
|
||||
})
|
||||
|
||||
it('maps an already-linked error to DOC_LINK_ALREADY_LINKED', async () => {
|
||||
enqueue({
|
||||
data: null,
|
||||
error: { message: 'document already linked to another entry' },
|
||||
})
|
||||
const res = await POST(
|
||||
makeReq({ journal_entry_id: 'je-1' }),
|
||||
createMockRouteParams({ id: 'doc-1' }),
|
||||
)
|
||||
const { body } = await parseJsonResponse<{ error: { code: string } }>(res)
|
||||
expect(body.error.code).toBe('DOC_LINK_ALREADY_LINKED')
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,15 @@ ensureInitialized()
|
||||
/**
|
||||
* POST /api/documents/[id]/link — link a document to a journal entry.
|
||||
*
|
||||
* Body: { journal_entry_id: string, journal_entry_line_id?: string }
|
||||
* Body: { journal_entry_id: string, journal_entry_line_id?: string, inbox_item_id?: string }
|
||||
*
|
||||
* When `inbox_item_id` is supplied (the "choose from inbox" flow), the inbox
|
||||
* item is stamped with the verifikat id after a successful link so it drops out
|
||||
* of the active inbox into "Bokförda" — reusing the inbox's own
|
||||
* created_journal_entry_id lifecycle. The document link is the legally-relevant
|
||||
* write and happens first; the inbox stamp is operational housekeeping, so a
|
||||
* stamp failure is logged but does not fail the request (the doc is correctly
|
||||
* attached and the DB immutability trigger still blocks any double-link).
|
||||
*/
|
||||
export const POST = withRouteContext(
|
||||
'document.link',
|
||||
@@ -35,12 +43,47 @@ export const POST = withRouteContext(
|
||||
body.journal_entry_id,
|
||||
body.journal_entry_line_id,
|
||||
)
|
||||
|
||||
if (body.inbox_item_id) {
|
||||
const { data: stamped, error: inboxError } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({ created_journal_entry_id: body.journal_entry_id })
|
||||
.eq('id', body.inbox_item_id)
|
||||
.eq('company_id', companyId!)
|
||||
// Only stamp the inbox item that actually owns this document — a
|
||||
// mismatched pairing becomes a safe no-op rather than mis-marking an
|
||||
// unrelated item as consumed.
|
||||
.eq('document_id', id)
|
||||
.select('id')
|
||||
if (inboxError) {
|
||||
// Non-fatal — the verifikat ↔ underlag link already succeeded.
|
||||
opLog.warn('inbox item stamp after link failed', {
|
||||
inboxItemId: body.inbox_item_id,
|
||||
reason: inboxError.message,
|
||||
})
|
||||
} else if (!stamped || stamped.length === 0) {
|
||||
// Zero rows updated means the supplied inbox_item_id / document_id
|
||||
// pairing did not match (wrong company, wrong document, or a stale
|
||||
// id). The doc link itself still succeeded; surface the cross-resource
|
||||
// mismatch as an observable warning rather than silently ignoring it.
|
||||
opLog.warn('inbox item stamp matched no rows (cross-resource mismatch)', {
|
||||
inboxItemId: body.inbox_item_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: document })
|
||||
} catch (err) {
|
||||
opLog.error('document link failed', err as Error, {
|
||||
journalEntryId: body.journal_entry_id,
|
||||
})
|
||||
const message = err instanceof Error ? err.message : ''
|
||||
// Linking writes journal_entry_id on document_attachments; the
|
||||
// enforce_period_lock trigger blocks that when the target entry sits in a
|
||||
// closed/locked period.
|
||||
if (/locked\/closed fiscal period|Bokföringen är låst/i.test(message)) {
|
||||
return errorResponseFromCode('PERIOD_LOCKED', opLog, { requestId })
|
||||
}
|
||||
if (/journal entry not found/i.test(message)) {
|
||||
return errorResponseFromCode('DOC_LINK_ENTRY_NOT_FOUND', opLog, { requestId })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import {
|
||||
parseJsonResponse,
|
||||
createMockRouteParams,
|
||||
createQueuedMockSupabase,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
import { GET } from '../route'
|
||||
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
eventBus.clear()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
function makeReq() {
|
||||
return new Request('http://localhost/api/documents/inbox-available')
|
||||
}
|
||||
|
||||
describe('GET /api/documents/inbox-available', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
const res = await GET(makeReq(), createMockRouteParams({}))
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns [] when no eligible inbox items (no second query)', async () => {
|
||||
enqueue({ data: [] }) // inbox items
|
||||
const res = await GET(makeReq(), createMockRouteParams({}))
|
||||
const { status, body } = await parseJsonResponse<{ data: unknown[] }>(res)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual([])
|
||||
// Documents table never queried when there are no document ids.
|
||||
expect(mockSupabase.from).not.toHaveBeenCalledWith('document_attachments')
|
||||
})
|
||||
|
||||
it('joins inbox items to their documents and drops consumed/superseded ones', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'inbox-1',
|
||||
document_id: 'doc-1',
|
||||
source: 'email',
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
extracted_data: {
|
||||
supplier: { name: 'Acme AB' },
|
||||
totals: { total: 1250 },
|
||||
invoice: { currency: 'SEK', invoiceDate: '2026-04-28' },
|
||||
},
|
||||
},
|
||||
// doc-2's document is no longer current/unlinked → must be dropped.
|
||||
{
|
||||
id: 'inbox-2',
|
||||
document_id: 'doc-2',
|
||||
source: 'upload',
|
||||
created_at: '2026-05-02T00:00:00Z',
|
||||
extracted_data: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'doc-1',
|
||||
file_name: 'acme.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
file_size_bytes: 1000,
|
||||
journal_entry_id: null,
|
||||
is_current_version: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const res = await GET(makeReq(), createMockRouteParams({}))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: Array<Record<string, unknown>>
|
||||
}>(res)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toHaveLength(1)
|
||||
expect(body.data[0]).toEqual({
|
||||
inbox_item_id: 'inbox-1',
|
||||
document_id: 'doc-1',
|
||||
file_name: 'acme.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
file_size_bytes: 1000,
|
||||
source: 'email',
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
supplier_name: 'Acme AB',
|
||||
amount: 1250,
|
||||
currency: 'SEK',
|
||||
invoice_date: '2026-04-28',
|
||||
})
|
||||
expect(mockSupabase.from).toHaveBeenCalledWith('invoice_inbox_items')
|
||||
expect(mockSupabase.from).toHaveBeenCalledWith('document_attachments')
|
||||
})
|
||||
|
||||
it('returns an error envelope when the inbox query fails', async () => {
|
||||
enqueue({ data: null, error: { message: 'boom' } })
|
||||
const res = await GET(makeReq(), createMockRouteParams({}))
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBeGreaterThanOrEqual(500)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse } from '@/lib/errors/get-structured-error'
|
||||
import type { InvoiceExtractionResult } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* GET /api/documents/inbox-available — list invoice-inbox documents that are
|
||||
* available to attach as underlag to a verifikat.
|
||||
*
|
||||
* Returns only *unconsumed* inbox items: those that have a file but have not
|
||||
* yet become a supplier invoice, a direct journal entry, or been matched to a
|
||||
* bank transaction — and whose underlying document is not already linked to a
|
||||
* verifikation. This mirrors the inbox's own "Att göra" set, narrowed to items
|
||||
* with an attachable file. Re-pointing an already-linked document is forbidden
|
||||
* (BFL 7 kap — räkenskapsinformation is immutable), so those are excluded here
|
||||
* and the DB immutability trigger is the backstop.
|
||||
*
|
||||
* `invoice_inbox_items` is a core table, so a core route may read it directly
|
||||
* without importing from @/extensions. When the invoice-inbox extension is not
|
||||
* in use the table is simply empty and this returns [].
|
||||
*/
|
||||
|
||||
interface InboxRow {
|
||||
id: string
|
||||
document_id: string | null
|
||||
source: string | null
|
||||
created_at: string
|
||||
extracted_data: InvoiceExtractionResult | null
|
||||
}
|
||||
|
||||
interface DocRow {
|
||||
id: string
|
||||
file_name: string
|
||||
mime_type: string | null
|
||||
file_size_bytes: number
|
||||
journal_entry_id: string | null
|
||||
is_current_version: boolean
|
||||
}
|
||||
|
||||
export const GET = withRouteContext('document.inbox_available', async (_request, ctx) => {
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
|
||||
// 1) Eligible inbox items — company-scoped (defense in depth alongside RLS),
|
||||
// unconsumed, with a document.
|
||||
const { data: inboxRows, error: inboxError } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id, document_id, source, created_at, extracted_data')
|
||||
.eq('company_id', companyId)
|
||||
.not('document_id', 'is', null)
|
||||
.is('created_supplier_invoice_id', null)
|
||||
.is('created_journal_entry_id', null)
|
||||
.is('matched_transaction_id', null)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(100)
|
||||
|
||||
if (inboxError) {
|
||||
log.error('inbox-available item query failed', inboxError)
|
||||
return errorResponse(inboxError, log, { requestId })
|
||||
}
|
||||
|
||||
const rows = (inboxRows ?? []) as InboxRow[]
|
||||
const docIds = rows.map((r) => r.document_id).filter((id): id is string => !!id)
|
||||
|
||||
if (docIds.length === 0) {
|
||||
return NextResponse.json({ data: [] })
|
||||
}
|
||||
|
||||
// 2) The current, still-unlinked documents behind those items. Excluding
|
||||
// docs with a journal_entry_id (already underlag elsewhere) and superseded
|
||||
// versions keeps the picker honest even if an inbox column went stale.
|
||||
const { data: docRows, error: docError } = await supabase
|
||||
.from('document_attachments')
|
||||
.select('id, file_name, mime_type, file_size_bytes, journal_entry_id, is_current_version')
|
||||
.eq('company_id', companyId)
|
||||
.in('id', docIds)
|
||||
.is('journal_entry_id', null)
|
||||
.eq('is_current_version', true)
|
||||
|
||||
if (docError) {
|
||||
log.error('inbox-available document query failed', docError)
|
||||
return errorResponse(docError, log, { requestId })
|
||||
}
|
||||
|
||||
const docById = new Map<string, DocRow>()
|
||||
for (const d of (docRows ?? []) as DocRow[]) docById.set(d.id, d)
|
||||
|
||||
// Preserve the inbox ordering (newest first); drop items whose document is
|
||||
// gone, consumed, or superseded.
|
||||
const data = rows
|
||||
.map((row) => {
|
||||
const doc = row.document_id ? docById.get(row.document_id) : undefined
|
||||
if (!doc) return null
|
||||
const ex = row.extracted_data
|
||||
return {
|
||||
inbox_item_id: row.id,
|
||||
document_id: doc.id,
|
||||
file_name: doc.file_name,
|
||||
mime_type: doc.mime_type,
|
||||
file_size_bytes: doc.file_size_bytes,
|
||||
source: row.source,
|
||||
created_at: row.created_at,
|
||||
supplier_name: ex?.supplier?.name ?? null,
|
||||
amount: ex?.totals?.total ?? null,
|
||||
currency: ex?.invoice?.currency ?? 'SEK',
|
||||
invoice_date: ex?.invoice?.invoiceDate ?? null,
|
||||
}
|
||||
})
|
||||
.filter((x): x is NonNullable<typeof x> => x !== null)
|
||||
|
||||
return NextResponse.json({ data })
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import { prepareInvoicePdfRender } from '@/lib/invoices/pdf-render-helpers'
|
||||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import type { CompanySettings, Customer, EntityType, Invoice, InvoiceItem } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
@@ -37,6 +38,7 @@ export async function POST(
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
const log = createLogger('invoice.mark-sent', { companyId, invoiceId: id })
|
||||
|
||||
// Fetch invoice
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
@@ -61,7 +63,7 @@ export async function POST(
|
||||
try {
|
||||
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
|
||||
} catch (err) {
|
||||
console.error('Failed to assign invoice number on mark-sent:', err)
|
||||
log.error('failed to assign invoice number on mark-sent', err as Error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Kunde inte tilldela fakturanummer. Försök igen.' },
|
||||
{ status: 500 }
|
||||
@@ -103,13 +105,24 @@ export async function POST(
|
||||
)
|
||||
if (journalEntry) {
|
||||
journalEntryId = journalEntry.id
|
||||
await supabase
|
||||
const { error: linkError } = await supabase
|
||||
.from('invoices')
|
||||
.update({ journal_entry_id: journalEntry.id })
|
||||
.eq('id', id)
|
||||
if (linkError) {
|
||||
// Don't fail mark-sent — the verifikat committed; only the link
|
||||
// failed. But log it through the structured logger so it reaches log
|
||||
// aggregation/alerting: this write silently no-ops when the
|
||||
// journal_entry_id column is missing (it was absent in prod until the
|
||||
// 20260613100000 migration), which leaves mark-paid unable to detect
|
||||
// an already-booked sale.
|
||||
log.error('mark-sent: journal_entry_id link to invoice failed', linkError, {
|
||||
journalEntryId: journalEntry.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create invoice journal entry on mark-sent:', err)
|
||||
log.error('failed to create invoice journal entry on mark-sent', err as Error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +175,7 @@ export async function POST(
|
||||
journal_entry_id: journalEntryId ?? undefined,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to archive invoice PDF on mark-sent:', err)
|
||||
log.error('failed to archive invoice PDF on mark-sent', err as Error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createQueuedMockSupabase,
|
||||
makeInvoice,
|
||||
makeCustomer,
|
||||
} from '@/tests/helpers'
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
const mockGetVatRules = vi.fn()
|
||||
const mockGetAvailableVatRates = vi.fn()
|
||||
vi.mock('@/lib/invoices/vat-rules', () => ({
|
||||
getVatRules: (...args: unknown[]) => mockGetVatRules(...args),
|
||||
getAvailableVatRates: (...args: unknown[]) => mockGetAvailableVatRates(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/currency/riksbanken', () => ({
|
||||
fetchExchangeRate: vi.fn().mockResolvedValue(null),
|
||||
convertToSEK: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockCreateInvoiceJournalEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
||||
createInvoiceJournalEntry: (...args: unknown[]) => mockCreateInvoiceJournalEntry(...args),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000'
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
const validBody = {
|
||||
customer_id: VALID_UUID,
|
||||
external_invoice_number: 'KUND-55012',
|
||||
self_billing_agreement_ref: 'Avtal 2026-01',
|
||||
invoice_date: '2026-06-01',
|
||||
received_date: '2026-06-02',
|
||||
due_date: '2026-06-30',
|
||||
currency: 'SEK',
|
||||
items: [{ description: 'Konsulttjänst', quantity: 10, unit: 'tim', unit_price: 1000 }],
|
||||
}
|
||||
|
||||
function mockDomesticVat() {
|
||||
mockGetVatRules.mockReturnValue({
|
||||
treatment: 'standard_25',
|
||||
rate: 25,
|
||||
momsRuta: '05',
|
||||
reverseChargeText: null,
|
||||
})
|
||||
mockGetAvailableVatRates.mockReturnValue([
|
||||
{ rate: 25, label: '25%', treatment: 'standard_25' },
|
||||
{ rate: 12, label: '12%', treatment: 'reduced_12' },
|
||||
{ rate: 6, label: '6%', treatment: 'reduced_6' },
|
||||
{ rate: 0, label: '0% (momsfri)', treatment: 'exempt' },
|
||||
])
|
||||
}
|
||||
|
||||
describe('POST /api/invoices/self-billed', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
eventBus.clear()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
|
||||
const request = createMockRequest('/api/invoices/self-billed', { method: 'POST', body: validBody })
|
||||
const response = await POST(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 when external_invoice_number is missing', async () => {
|
||||
const { external_invoice_number, ...rest } = validBody
|
||||
void external_invoice_number
|
||||
|
||||
const request = createMockRequest('/api/invoices/self-billed', { method: 'POST', body: rest })
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ type: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.type).toBe('validation_error')
|
||||
})
|
||||
|
||||
it('returns 404 when the customer (issuer) is not found', async () => {
|
||||
enqueue({ data: null, error: { message: 'Not found' } })
|
||||
|
||||
const request = createMockRequest('/api/invoices/self-billed', { method: 'POST', body: validBody })
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error.code).toBe('INVOICE_CUSTOMER_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('rejects an item VAT rate the customer is not allowed to use', async () => {
|
||||
mockGetVatRules.mockReturnValue({ treatment: 'standard_25', rate: 25, momsRuta: '05', reverseChargeText: null })
|
||||
// Domestic-only set: 0% is NOT allowed for this customer.
|
||||
mockGetAvailableVatRates.mockReturnValue([
|
||||
{ rate: 25, label: '25%', treatment: 'standard_25' },
|
||||
{ rate: 12, label: '12%', treatment: 'reduced_12' },
|
||||
{ rate: 6, label: '6%', treatment: 'reduced_6' },
|
||||
])
|
||||
enqueue({ data: makeCustomer({ id: VALID_UUID }), error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/self-billed', {
|
||||
method: 'POST',
|
||||
body: { ...validBody, items: [{ description: 'X', quantity: 1, unit: 'st', unit_price: 100, vat_rate: 0 }] },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('INVOICE_CREATE_VAT_RULE_VIOLATION')
|
||||
})
|
||||
|
||||
it('creates a self-billed sale, books it (accrual), skips own numbering, and emits invoice.created', async () => {
|
||||
mockDomesticVat()
|
||||
const customer = makeCustomer({ id: VALID_UUID, name: 'Stora Bolaget AB' })
|
||||
const created = makeInvoice({
|
||||
id: 'inv-1',
|
||||
invoice_number: null,
|
||||
is_self_billed: true,
|
||||
external_invoice_number: 'KUND-55012',
|
||||
total: 12500,
|
||||
})
|
||||
|
||||
enqueue({ data: customer, error: null }) // fetch customer
|
||||
enqueue({ data: created, error: null }) // insert invoice
|
||||
enqueue({ data: null, error: null }) // insert items
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings
|
||||
enqueue({ data: { ...created, customer, items: [] }, error: null }) // fetch complete
|
||||
enqueue({ data: null, error: null }) // update journal_entry_id
|
||||
enqueue({ data: { ...created, customer, items: [], journal_entry_id: 'je-1' }, error: null }) // fetch final
|
||||
|
||||
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
const emitSpy = vi.spyOn(eventBus, 'emit')
|
||||
|
||||
const request = createMockRequest('/api/invoices/self-billed', { method: 'POST', body: validBody })
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toBeTruthy()
|
||||
|
||||
// Booked as a sale with the self-billing label + the counterparty's number.
|
||||
expect(mockCreateInvoiceJournalEntry).toHaveBeenCalledTimes(1)
|
||||
const opts = mockCreateInvoiceJournalEntry.mock.calls[0][6]
|
||||
expect(opts).toEqual({ descriptionPrefix: 'Självfaktura', numberOverride: 'KUND-55012' })
|
||||
|
||||
// Never consumes our own invoice-number series.
|
||||
expect(mockSupabase.rpc).not.toHaveBeenCalledWith('generate_invoice_number', expect.anything())
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'invoice.created' }))
|
||||
})
|
||||
|
||||
it('does NOT book at registration under kontantmetoden (cash) — books at payment instead', async () => {
|
||||
mockDomesticVat()
|
||||
const customer = makeCustomer({ id: VALID_UUID })
|
||||
const created = makeInvoice({ id: 'inv-1', invoice_number: null, is_self_billed: true, external_invoice_number: 'KUND-55012' })
|
||||
|
||||
enqueue({ data: customer, error: null }) // fetch customer
|
||||
enqueue({ data: created, error: null }) // insert invoice
|
||||
enqueue({ data: null, error: null }) // insert items
|
||||
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null }) // settings
|
||||
enqueue({ data: { ...created, customer, items: [] }, error: null }) // fetch complete
|
||||
enqueue({ data: { ...created, customer, items: [] }, error: null }) // fetch final
|
||||
|
||||
const request = createMockRequest('/api/invoices/self-billed', { method: 'POST', body: validBody })
|
||||
const response = await POST(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rolls back when there is no open fiscal period for the invoice date', async () => {
|
||||
mockDomesticVat()
|
||||
const customer = makeCustomer({ id: VALID_UUID })
|
||||
const created = makeInvoice({ id: 'inv-1', invoice_number: null, is_self_billed: true, external_invoice_number: 'KUND-55012' })
|
||||
|
||||
enqueue({ data: customer, error: null }) // fetch customer
|
||||
enqueue({ data: created, error: null }) // insert invoice
|
||||
enqueue({ data: null, error: null }) // insert items
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings
|
||||
enqueue({ data: { ...created, customer, items: [] }, error: null }) // fetch complete
|
||||
enqueue({ data: null, error: null }) // rollback delete
|
||||
|
||||
mockCreateInvoiceJournalEntry.mockResolvedValue(null) // no fiscal period
|
||||
|
||||
const request = createMockRequest('/api/invoices/self-billed', { method: 'POST', body: validBody })
|
||||
const response = await POST(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(mockSupabase.from).toHaveBeenCalledWith('invoices')
|
||||
})
|
||||
|
||||
it('rejects a foreign-currency self-billed invoice when no FX rate is available', async () => {
|
||||
mockDomesticVat()
|
||||
// fetchExchangeRate is mocked to resolve null (rate unavailable for the
|
||||
// invoice date). Booking would otherwise fall through to a silent 1:1 SEK
|
||||
// conversion, so the route must refuse up front — before any insert.
|
||||
enqueue({ data: makeCustomer({ id: VALID_UUID }), error: null }) // fetch customer
|
||||
|
||||
const request = createMockRequest('/api/invoices/self-billed', {
|
||||
method: 'POST',
|
||||
body: { ...validBody, currency: 'EUR' },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ type: string; error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toMatch(/växelkurs/i)
|
||||
// Never books a wrong-magnitude verifikat and never inserts the invoice.
|
||||
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,311 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { CreateSelfBillingInvoiceSchema } from '@/lib/api/schemas'
|
||||
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
|
||||
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
|
||||
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import type { EntityType, Invoice } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* POST /api/invoices/self-billed
|
||||
*
|
||||
* Register a self-billing invoice we RECEIVED (mottagen självfaktura, ML 17 kap
|
||||
* 15§). The customer issued the invoice on our behalf; for us it is a sale, so
|
||||
* it books exactly like a customer invoice (Debit 1510, Credit 30xx + 26xx) and
|
||||
* the output VAT lands in our momsdeklaration.
|
||||
*
|
||||
* It differs from a normal customer invoice in two ways:
|
||||
* - We do NOT assign a number from our own series — the counterparty's number
|
||||
* is stored in external_invoice_number and our invoice_number stays null
|
||||
* (BFL 5 kap 6§). Enforced by the invoices_self_billed_numbering constraint.
|
||||
* - There is no send step. Under faktureringsmetoden (accrual) we book the
|
||||
* registration entry here. Under kontantmetoden (cash) we leave it unbooked
|
||||
* until payment — identical to a normal invoice — and the existing mark-paid
|
||||
* flow books the cash entry then.
|
||||
*
|
||||
* Payment is handled by the existing flows: the row is created with status
|
||||
* 'sent', so "Markera som betald" / bank matching work unchanged.
|
||||
*/
|
||||
export const POST = withRouteContext(
|
||||
'invoice.self_billed.create',
|
||||
async (request, ctx) => {
|
||||
const { user, supabase, companyId, log, requestId } = ctx
|
||||
|
||||
let rawBody: unknown
|
||||
try {
|
||||
rawBody = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid JSON in request body', type: 'validation_error' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = CreateSelfBillingInvoiceSchema.safeParse(rawBody)
|
||||
if (!parsed.success) {
|
||||
log.warn('self-billed invoice validation failed', { issueCount: parsed.error.issues.length })
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Validation failed',
|
||||
type: 'validation_error',
|
||||
errors: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message, code: i.code })),
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
const input = parsed.data
|
||||
|
||||
// The issuer of a self-billing invoice is, in our books, the customer we
|
||||
// sold to. Require an existing customer row so VAT rules + reporting work.
|
||||
// Project only the fields used below (data minimisation — GDPR Art. 25 /
|
||||
// SOC 2 CC6.3): VAT treatment derivation and the verifikat description.
|
||||
const { data: customer, error: customerError } = await supabase
|
||||
.from('customers')
|
||||
.select('id, name, customer_type, vat_number_validated')
|
||||
.eq('id', input.customer_id)
|
||||
.eq('company_id', companyId!)
|
||||
.single()
|
||||
|
||||
if (customerError || !customer) {
|
||||
return errorResponseFromCode('INVOICE_CUSTOMER_NOT_FOUND', log, {
|
||||
requestId,
|
||||
details: { customerId: input.customer_id },
|
||||
})
|
||||
}
|
||||
|
||||
// VAT treatment is driven by who the customer is (domestic / EU reverse
|
||||
// charge / export), exactly like an own-issued invoice.
|
||||
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
|
||||
const availableRates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated)
|
||||
const allowedRates = new Set(availableRates.map((r) => r.rate))
|
||||
|
||||
let vatAmount = 0
|
||||
for (const item of input.items) {
|
||||
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
|
||||
if (!allowedRates.has(itemRate)) {
|
||||
return errorResponseFromCode('INVOICE_CREATE_VAT_RULE_VIOLATION', log, {
|
||||
requestId,
|
||||
details: {
|
||||
attemptedRate: itemRate,
|
||||
allowedRates: Array.from(allowedRates),
|
||||
customerType: customer.customer_type,
|
||||
},
|
||||
})
|
||||
}
|
||||
const lineTotal = item.quantity * item.unit_price
|
||||
vatAmount += roundOre((lineTotal * itemRate) / 100)
|
||||
}
|
||||
|
||||
const subtotal = input.items.reduce((sum, item) => sum + item.quantity * item.unit_price, 0)
|
||||
const total = roundOre(subtotal + vatAmount)
|
||||
|
||||
const uniqueRates = new Set(input.items.map((item) => item.vat_rate ?? vatRules.rate))
|
||||
const isMixedRate = uniqueRates.size > 1
|
||||
|
||||
// Foreign currency: convert using the rate on the INVOICE date (ML 7 kap 7§),
|
||||
// not today's rate.
|
||||
let exchangeRate: number | null = null
|
||||
let exchangeRateDate: string | null = null
|
||||
let subtotalSek: number | null = null
|
||||
let vatAmountSek: number | null = null
|
||||
let totalSek: number | null = null
|
||||
if (input.currency !== 'SEK') {
|
||||
const rateData = await fetchExchangeRate(input.currency, new Date(input.invoice_date))
|
||||
if (!rateData) {
|
||||
// No FX rate for the invoice date — refuse rather than letting the
|
||||
// booking fall through to resolveSekAmount's legacy 1:1 fallback, which
|
||||
// would treat e.g. 1 000 USD as 1 000 SEK and commit a balanced but
|
||||
// silently wrong-magnitude verifikat. ML 7 kap 7§ requires the
|
||||
// invoice-date rate; we never substitute today's. The user can retry
|
||||
// once the rate is published.
|
||||
log.warn('self-billed invoice rejected: no FX rate for invoice date', {
|
||||
currency: input.currency,
|
||||
invoiceDate: input.invoice_date,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Kunde inte hämta växelkurs för ${input.currency} på fakturadatumet (${input.invoice_date}). Försök igen senare.`,
|
||||
type: 'validation_error',
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
exchangeRate = rateData.rate
|
||||
exchangeRateDate = rateData.date
|
||||
subtotalSek = convertToSEK(subtotal, exchangeRate)
|
||||
vatAmountSek = convertToSEK(vatAmount, exchangeRate)
|
||||
totalSek = convertToSEK(total, exchangeRate)
|
||||
}
|
||||
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
.from('invoices')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
company_id: companyId,
|
||||
customer_id: input.customer_id,
|
||||
// No own number — the counterparty's number lives in external_invoice_number.
|
||||
invoice_number: null,
|
||||
is_self_billed: true,
|
||||
external_invoice_number: input.external_invoice_number,
|
||||
self_billing_agreement_ref: input.self_billing_agreement_ref ?? null,
|
||||
received_date: input.received_date,
|
||||
invoice_date: input.invoice_date,
|
||||
due_date: input.due_date,
|
||||
// Booked + awaiting/with payment — never a draft, so it shows in the AR
|
||||
// ledger and is payable via the existing mark-paid / matching flows.
|
||||
status: 'sent',
|
||||
currency: input.currency,
|
||||
exchange_rate: exchangeRate,
|
||||
exchange_rate_date: exchangeRateDate,
|
||||
subtotal,
|
||||
subtotal_sek: subtotalSek,
|
||||
vat_amount: vatAmount,
|
||||
vat_amount_sek: vatAmountSek,
|
||||
total,
|
||||
total_sek: totalSek,
|
||||
remaining_amount: total,
|
||||
vat_treatment: vatRules.treatment,
|
||||
vat_rate: isMixedRate ? null : (uniqueRates.values().next().value ?? vatRules.rate),
|
||||
moms_ruta: vatRules.momsRuta,
|
||||
reverse_charge_text: vatRules.reverseChargeText || null,
|
||||
notes: input.notes,
|
||||
document_type: 'invoice',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (invoiceError || !invoice) {
|
||||
log.error('self-billed invoice insert failed', invoiceError)
|
||||
return errorResponseFromCode('INVOICE_CREATE_INSERT_FAILED', log, {
|
||||
requestId,
|
||||
details: { pgCode: invoiceError?.code, pgMessage: invoiceError?.message },
|
||||
})
|
||||
}
|
||||
|
||||
const items = input.items.map((item, index) => {
|
||||
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
|
||||
const lineTotal = item.quantity * item.unit_price
|
||||
return {
|
||||
invoice_id: invoice.id,
|
||||
sort_order: index,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
line_total: lineTotal,
|
||||
vat_rate: itemRate,
|
||||
vat_amount: roundOre((lineTotal * itemRate) / 100),
|
||||
}
|
||||
})
|
||||
|
||||
const { error: itemsError } = await supabase.from('invoice_items').insert(items)
|
||||
if (itemsError) {
|
||||
// The item insert failed, so nothing was written there — just remove the
|
||||
// orphaned invoice header.
|
||||
await supabase.from('invoices').delete().eq('id', invoice.id)
|
||||
log.error('self-billed invoice items insert failed; rolled back', itemsError, { invoiceId: invoice.id })
|
||||
return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', log, {
|
||||
requestId,
|
||||
details: { pgCode: itemsError.code, pgMessage: itemsError.message },
|
||||
})
|
||||
}
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method, entity_type')
|
||||
.eq('company_id', companyId!)
|
||||
.single()
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
const { data: completeInvoice } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('id', invoice.id)
|
||||
.single()
|
||||
|
||||
// Faktureringsmetoden: book the registration entry now (Debit 1510, Credit
|
||||
// 30xx + 26xx). Kontantmetoden: leave unbooked until payment, exactly like a
|
||||
// normal invoice — the mark-paid flow books the cash entry then.
|
||||
if (accountingMethod === 'accrual') {
|
||||
if (!completeInvoice) {
|
||||
// The row was inserted but the re-fetch came back empty (transient DB
|
||||
// issue). Roll back rather than crash on a null cast inside the engine —
|
||||
// and surface it as a fetch failure, not an opaque booking error.
|
||||
await supabase.from('invoices').delete().eq('id', invoice.id)
|
||||
log.error('self-billed invoice re-fetch returned no row before booking; rolled back', undefined, {
|
||||
invoiceId: invoice.id,
|
||||
})
|
||||
return errorResponseFromCode('INVOICE_CREATE_INSERT_FAILED', log, {
|
||||
requestId,
|
||||
details: { stage: 'refetch_before_booking' },
|
||||
})
|
||||
}
|
||||
try {
|
||||
const journalEntry = await createInvoiceJournalEntry(
|
||||
supabase,
|
||||
companyId!,
|
||||
user.id,
|
||||
completeInvoice as Invoice,
|
||||
entityType,
|
||||
customer.name,
|
||||
{ descriptionPrefix: 'Självfaktura', numberOverride: input.external_invoice_number },
|
||||
)
|
||||
if (!journalEntry) {
|
||||
// No open fiscal period for the invoice date — roll the row back so we
|
||||
// never leave an unbooked self-billing sale sitting as 'sent'.
|
||||
await supabase.from('invoices').delete().eq('id', invoice.id)
|
||||
return NextResponse.json(
|
||||
{ error: 'Ingen öppen bokföringsperiod för fakturadatumet', type: 'validation_error' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
const { error: linkError } = await supabase
|
||||
.from('invoices')
|
||||
.update({ journal_entry_id: journalEntry.id })
|
||||
.eq('id', invoice.id)
|
||||
.eq('company_id', companyId!)
|
||||
if (linkError) {
|
||||
// The verifikat is already committed (immutable) — don't roll it back
|
||||
// over a failed convenience link. Log loudly: this is the exact write
|
||||
// that silently no-ops if the journal_entry_id column is ever missing
|
||||
// again (it was absent in prod for months before 20260613100000).
|
||||
log.error('self-billed invoice booked but journal_entry_id link failed', linkError, {
|
||||
invoiceId: invoice.id,
|
||||
journalEntryId: journalEntry.id,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
await supabase.from('invoices').delete().eq('id', invoice.id)
|
||||
log.error('failed to book self-billed invoice; rolled back', err as Error, { invoiceId: invoice.id })
|
||||
return errorResponse(err, log, { requestId })
|
||||
}
|
||||
}
|
||||
|
||||
const { data: finalInvoice } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('id', invoice.id)
|
||||
.single()
|
||||
|
||||
// The invoice is committed (and, under accrual, booked) by this point. If the
|
||||
// final re-fetch comes back empty under transient load, fall back to the
|
||||
// shapes we already hold so the 200 always carries a usable id — otherwise
|
||||
// the client's redirect to /invoices/{id} would throw on a null result.
|
||||
const responseInvoice = (finalInvoice ?? completeInvoice ?? invoice) as Invoice
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'invoice.created',
|
||||
payload: { invoice: responseInvoice, companyId: companyId!, userId: user.id },
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: responseInvoice })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -792,7 +792,6 @@ export async function POST(request: Request) {
|
||||
company_id: companyId,
|
||||
status: 'received',
|
||||
source: 'upload',
|
||||
document_type: 'supplier_invoice',
|
||||
matched_supplier_id: supplierMap['Demokafé AB'],
|
||||
extracted_data: {
|
||||
supplier: { name: 'Demokafé AB' },
|
||||
|
||||
@@ -69,6 +69,7 @@ function enqueueHappyPath(opts: {
|
||||
remaining_amount?: number
|
||||
paid_amount?: number
|
||||
}
|
||||
accountingMethod?: string
|
||||
}) {
|
||||
// 1. transactions fetch
|
||||
enqueue({
|
||||
@@ -98,7 +99,7 @@ function enqueueHappyPath(opts: {
|
||||
error: null,
|
||||
})
|
||||
// 3. company_settings fetch
|
||||
enqueue({ data: { accounting_method: 'accrual' }, error: null })
|
||||
enqueue({ data: { accounting_method: opts.accountingMethod ?? 'accrual' }, error: null })
|
||||
// 4. supplier_invoices update (CAS)
|
||||
enqueue({ data: [{ id: SI_UUID }], error: null })
|
||||
// 5. supplier_invoice_payments insert
|
||||
@@ -248,3 +249,69 @@ describe('POST /api/transactions/[id]/match-supplier-invoice — non-FX paths',
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/transactions/[id]/match-supplier-invoice — cash method + FX', () => {
|
||||
it('full cross-currency settlement books at the payment rate (no FX-unsupported error)', async () => {
|
||||
// Cash method, SEK account paying a 25 USD invoice. The invoice's stored
|
||||
// rate (9.20 → 230 SEK) differs from the 239 SEK that actually left the
|
||||
// bank — previously this was blocked. It must now succeed and hand the
|
||||
// cash builder the real bank SEK so 1930 matches the bank line.
|
||||
enqueueHappyPath({
|
||||
transaction: { amount: -239, currency: 'SEK' },
|
||||
invoice: { currency: 'USD', exchange_rate: 9.20, remaining_amount: 25 },
|
||||
accountingMethod: 'cash',
|
||||
})
|
||||
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockCreateCashEntry).toHaveBeenCalledTimes(1)
|
||||
expect(mockCreatePaymentEntry).not.toHaveBeenCalled()
|
||||
// settledBankSek is the 10th positional arg (index 9).
|
||||
expect(mockCreateCashEntry.mock.calls[0][9]).toBe(239)
|
||||
})
|
||||
|
||||
it('full same-currency foreign settlement passes the actual bank SEK to the cash builder', async () => {
|
||||
// 19 USD invoice paid from a USD card showing amount_sek = 175.28, while
|
||||
// the invoice was captured at 9.20 (174.80). Full settlement → booked at
|
||||
// the payment rate (175.28), no kursdifferens.
|
||||
enqueueHappyPath({
|
||||
transaction: { amount: -19, currency: 'USD', amount_sek: -175.28 },
|
||||
invoice: { currency: 'USD', exchange_rate: 9.20, remaining_amount: 19 },
|
||||
accountingMethod: 'cash',
|
||||
})
|
||||
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockCreateCashEntry.mock.calls[0][9]).toBe(175.28)
|
||||
})
|
||||
|
||||
it('foreign tx with no amount_sek books at the invoice rate, not the raw foreign amount', async () => {
|
||||
// The bank line carries no stored SEK (amount_sek null). The old fallback
|
||||
// treated 19 USD as 19 SEK → "19 kr". We must instead use the invoice's
|
||||
// rate (≈175 kr): no settledBankSek override is passed (FX diff is 0,
|
||||
// there's no independent bank figure) and the entry is NOT blocked.
|
||||
enqueueHappyPath({
|
||||
transaction: { amount: -19, currency: 'USD', amount_sek: null },
|
||||
invoice: { currency: 'USD', exchange_rate: 9.225, remaining_amount: 19 },
|
||||
accountingMethod: 'cash',
|
||||
})
|
||||
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockCreateCashEntry).toHaveBeenCalledTimes(1)
|
||||
// No bogus settledBankSek=19 override — the builder uses the invoice rate.
|
||||
expect(mockCreateCashEntry.mock.calls[0][9]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('PARTIAL foreign payment under the cash method is still rejected', async () => {
|
||||
// Paying only 10 of 19 USD remaining. The cash builder books the whole
|
||||
// invoice, so a partial bank amount cannot pin the entry — still blocked.
|
||||
enqueueHappyPath({
|
||||
transaction: { amount: -10, currency: 'USD', amount_sek: -92.25 },
|
||||
invoice: { currency: 'USD', exchange_rate: 9.20, remaining_amount: 19 },
|
||||
accountingMethod: 'cash',
|
||||
})
|
||||
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res)
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('MATCH_SI_CASH_FX_UNSUPPORTED')
|
||||
expect(mockCreateCashEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -45,7 +45,10 @@ export const GET = withRouteContext(
|
||||
|
||||
const { data: transaction, error: txErr } = await supabase
|
||||
.from('transactions')
|
||||
.select('id, date, amount, currency')
|
||||
// amount_sek is needed for the cash-method preview: a foreign-currency
|
||||
// settlement is translated at the payment-date rate (the SEK that left
|
||||
// the bank), mirroring the committed verifikat from the POST handler.
|
||||
.select('id, date, amount, currency, amount_sek')
|
||||
.eq('id', transactionId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
@@ -84,6 +87,23 @@ export const GET = withRouteContext(
|
||||
const si = invoice as SupplierInvoice & { items?: SupplierInvoiceItem[] }
|
||||
const items = si.items ?? []
|
||||
|
||||
// Kontantmetoden books the expense AT PAYMENT at the payment-date rate
|
||||
// (the SEK that actually left the bank), so translate this preview the
|
||||
// same way the committed verifikat does. The bank SEK is only known when
|
||||
// the transaction is in SEK or carries a stored amount_sek; for a foreign
|
||||
// transaction without it we fall back to the invoice's own rate (the raw
|
||||
// foreign amount must never be used — that would render 19 USD as 19 kr).
|
||||
const bankSek =
|
||||
transaction.currency === 'SEK'
|
||||
? Math.abs(transaction.amount)
|
||||
: transaction.amount_sek != null
|
||||
? Math.abs(transaction.amount_sek)
|
||||
: null
|
||||
const cashRate =
|
||||
bankSek != null && si.currency !== 'SEK' && si.total > 0
|
||||
? bankSek / si.total
|
||||
: si.exchange_rate
|
||||
|
||||
// Mirror createSupplierInvoiceCashEntry: per-item expense debit + VAT
|
||||
// debit + bank credit. We only need a faithful preview, not exact
|
||||
// account-mapping fidelity — show one aggregate expense line per item
|
||||
@@ -92,8 +112,8 @@ export const GET = withRouteContext(
|
||||
let totalVatSek = 0
|
||||
if (items.length > 0) {
|
||||
for (const it of items) {
|
||||
const lineTotal = resolveSekAmount(it.line_total, null, si.currency, si.exchange_rate)
|
||||
const vat = resolveSekAmount(it.vat_amount, null, si.currency, si.exchange_rate)
|
||||
const lineTotal = resolveSekAmount(it.line_total, null, si.currency, cashRate)
|
||||
const vat = resolveSekAmount(it.vat_amount, null, si.currency, cashRate)
|
||||
const expenseAcct = (it as { expense_account?: string | null }).expense_account ?? '4000'
|
||||
lines.push({
|
||||
account_number: expenseAcct,
|
||||
@@ -105,8 +125,11 @@ export const GET = withRouteContext(
|
||||
totalVatSek += vat
|
||||
}
|
||||
} else {
|
||||
const subSek = resolveSekAmount(si.subtotal, si.subtotal_sek, si.currency, si.exchange_rate)
|
||||
const vatSek = resolveSekAmount(si.vat_amount, si.vat_amount_sek, si.currency, si.exchange_rate)
|
||||
// Pass null for the pre-computed SEK so cashRate (payment-date rate)
|
||||
// drives the translation — resolveSekAmount would otherwise prefer the
|
||||
// invoice-rate *_sek columns and ignore the rate.
|
||||
const subSek = resolveSekAmount(si.subtotal, null, si.currency, cashRate)
|
||||
const vatSek = resolveSekAmount(si.vat_amount, null, si.currency, cashRate)
|
||||
lines.push({
|
||||
account_number: '4000',
|
||||
debit_amount: Math.round(subSek * 100) / 100,
|
||||
|
||||
@@ -116,32 +116,38 @@ export const POST = withRouteContext(
|
||||
? txAmountAbs
|
||||
: invoice.remaining_amount
|
||||
|
||||
// Actual SEK leaving the bank — what really moved out of 1930. For a
|
||||
// SEK transaction this is just the absolute amount; for a foreign-
|
||||
// currency transaction we use the SEK conversion stored at import.
|
||||
const actualBankSek =
|
||||
// SEK that actually left the bank, when we know it. SEK transaction → the
|
||||
// absolute amount; foreign transaction with a stored amount_sek → that
|
||||
// value; foreign transaction WITHOUT amount_sek → unknown (null). The raw
|
||||
// foreign amount must never stand in here — treating 19 USD as 19 SEK is
|
||||
// exactly the bug that books "19 kr" on a ~175 kr payment.
|
||||
const bankSekStored =
|
||||
transaction.currency === 'SEK'
|
||||
? txAmountAbs
|
||||
: (transaction.amount_sek != null
|
||||
? Math.abs(transaction.amount_sek)
|
||||
: txAmountAbs)
|
||||
: transaction.amount_sek != null
|
||||
? Math.abs(transaction.amount_sek)
|
||||
: null
|
||||
|
||||
// SEK value that's actually sitting on 2440 for this payment portion:
|
||||
// SEK the invoice was booked at for this payment portion:
|
||||
// - SEK invoice: face value = paymentAmountInvoiceCurrency
|
||||
// - Non-SEK invoice w/ exchange_rate: portion × rate
|
||||
// - Non-SEK invoice w/o exchange_rate: can't compute precisely; fall
|
||||
// back to actualBankSek (no FX diff, plain SEK booking)
|
||||
// FX diff hits 7960/3960 so 2440 clears cleanly instead of leaving a
|
||||
// residual. Triggered whenever bank-paid SEK differs from booked SEK —
|
||||
// happens for any currency mismatch (SEK→EUR, EUR→SEK, EUR→USD), not
|
||||
// just non-SEK invoices.
|
||||
// - Non-SEK invoice w/o exchange_rate: can't compute (null)
|
||||
const invoiceFxRate = invoice.exchange_rate ?? null
|
||||
const originalBookedSek =
|
||||
const bookedSek =
|
||||
invoice.currency === 'SEK'
|
||||
? paymentAmountInvoiceCurrency
|
||||
: invoiceFxRate && invoiceFxRate > 0
|
||||
? Math.round(paymentAmountInvoiceCurrency * invoiceFxRate * 100) / 100
|
||||
: actualBankSek
|
||||
: null
|
||||
|
||||
// Actual SEK leaving the bank. Prefer the stored bank figure; if a foreign
|
||||
// transaction has no amount_sek, fall back to the invoice's booked SEK so
|
||||
// the magnitude is right (→ exchangeRateDifference 0, i.e. "no independent
|
||||
// bank figure to reconcile against"). Last resort, with no invoice rate
|
||||
// either, is the raw amount. The FX diff hits 7960/3960 so 2440 clears
|
||||
// cleanly whenever bank-paid SEK genuinely differs from booked SEK.
|
||||
const actualBankSek = bankSekStored ?? bookedSek ?? txAmountAbs
|
||||
const originalBookedSek = bookedSek ?? actualBankSek
|
||||
|
||||
// Positive = gain (AP credited at more SEK than the bank actually paid).
|
||||
// Negative = loss (bank paid more SEK than the AP we owed).
|
||||
@@ -171,15 +177,24 @@ export const POST = withRouteContext(
|
||||
const siAlreadyBooked = !!(invoice as { registration_journal_entry_id?: string | null }).registration_journal_entry_id
|
||||
const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash'
|
||||
|
||||
// A full settlement pays off the whole remaining balance. Cross-currency
|
||||
// matches always do (paymentAmountInvoiceCurrency is clamped to
|
||||
// invoice.remaining_amount above); same-currency does when the bank amount
|
||||
// covers the remaining balance.
|
||||
const fullSettlement =
|
||||
transaction.currency !== invoice.currency ||
|
||||
txAmountAbs >= invoice.remaining_amount - 0.005
|
||||
|
||||
// Cash method (kontantmetoden) collapses registration + payment into a
|
||||
// single entry that credits 1930 at sum(expenses_SEK). It has no
|
||||
// exchange_rate_difference path — if the actual bank SEK differs from
|
||||
// the invoice's booked SEK, the 1930 credit won't match the bank
|
||||
// transaction and we'd silently leave a reconciliation gap. Block the
|
||||
// combination and ask the user to switch to accrual or do a manual JE.
|
||||
// Only applies to true cash-method invoices — accrual-booked invoices
|
||||
// never hit the cash branch.
|
||||
if (useCashEntry && exchangeRateDifference !== 0) {
|
||||
// single entry. Under the cash method the expense is recognised AT PAYMENT
|
||||
// at the payment-date rate, so there is no kursvinst/kursförlust — we hand
|
||||
// the builder the actual bank SEK (settledBankSek) and it translates the
|
||||
// whole verifikat to that, leaving 1930 equal to the bank transaction.
|
||||
// The only combination we still can't model is a PARTIAL cash-method
|
||||
// payment across rates: the cash builder books the full invoice, so a
|
||||
// partial bank amount can't pin the entry cleanly. That narrow case stays
|
||||
// blocked (switch to accrual or book manually).
|
||||
if (useCashEntry && exchangeRateDifference !== 0 && !fullSettlement) {
|
||||
return errorResponseFromCode('MATCH_SI_CASH_FX_UNSUPPORTED', txLog, {
|
||||
requestId,
|
||||
details: {
|
||||
@@ -229,6 +244,12 @@ export const POST = withRouteContext(
|
||||
(invoice.items || []) as SupplierInvoiceItem[],
|
||||
transaction.date,
|
||||
invoice.supplier?.supplier_type || 'swedish_business',
|
||||
undefined, // supplierName (unchanged default)
|
||||
undefined, // paymentAccount (unchanged default 1930)
|
||||
// Pin a foreign-currency settlement to the payment-date rate so 1930
|
||||
// equals the bank movement (kontantmetoden books the expense at
|
||||
// payment). No-op for SEK invoices and same-rate settlements.
|
||||
exchangeRateDifference !== 0 && fullSettlement ? actualBankSek : undefined,
|
||||
)
|
||||
if (journalEntry) journalEntryId = journalEntry.id
|
||||
} else {
|
||||
|
||||
@@ -42,7 +42,7 @@ registerEndpoint({
|
||||
doNotUseFor:
|
||||
'Categorizing a direct supplier expense without an invoice — use `:categorize`. Matching to a customer invoice — use `:match-invoice`. Bulk auto-match — `POST /reconciliation/bank/run`.',
|
||||
pitfalls: [
|
||||
'Cash-method companies cannot match across currencies (MATCH_SI_CASH_FX_UNSUPPORTED) — switch to accrual or book FX manually.',
|
||||
'Cash-method companies can settle a foreign invoice in full (booked at the payment-date rate); only a PARTIAL cash-method payment across currencies is rejected (MATCH_SI_CASH_FX_UNSUPPORTED) — pay in full, switch to accrual, or book manually.',
|
||||
'Transaction must be negative (amount < 0). Positive returns MATCH_SI_NOT_EXPENSE.',
|
||||
'Supplier invoice must NOT be paid/credited already. paid/credited returns MATCH_SI_ALREADY_PAID; registered/approved/partially_paid/overdue are matchable.',
|
||||
'Idempotency-Key is mandatory.',
|
||||
@@ -182,19 +182,28 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
const txAmountAbs = Math.abs(transaction.amount)
|
||||
const paymentAmountInvoiceCurrency =
|
||||
transaction.currency === invoice.currency ? txAmountAbs : invoice.remaining_amount
|
||||
const actualBankSek =
|
||||
// SEK that actually left the bank, when known. A foreign transaction with
|
||||
// no stored amount_sek is `null` here — the raw foreign amount must never
|
||||
// stand in (treating 19 USD as 19 SEK books "19 kr" on a ~175 kr payment).
|
||||
const bankSekStored =
|
||||
transaction.currency === 'SEK'
|
||||
? txAmountAbs
|
||||
: transaction.amount_sek != null
|
||||
? Math.abs(transaction.amount_sek)
|
||||
: txAmountAbs
|
||||
: null
|
||||
const invoiceFxRate = invoice.exchange_rate ?? null
|
||||
const originalBookedSek =
|
||||
// SEK the invoice was booked at for this payment portion (null if the
|
||||
// invoice is foreign and carries no exchange_rate).
|
||||
const bookedSek =
|
||||
invoice.currency === 'SEK'
|
||||
? paymentAmountInvoiceCurrency
|
||||
: invoiceFxRate && invoiceFxRate > 0
|
||||
? Math.round(paymentAmountInvoiceCurrency * invoiceFxRate * 100) / 100
|
||||
: actualBankSek
|
||||
: null
|
||||
// Prefer the stored bank SEK; fall back to the invoice's booked SEK (right
|
||||
// magnitude, FX diff 0); last resort the raw amount.
|
||||
const actualBankSek = bankSekStored ?? bookedSek ?? txAmountAbs
|
||||
const originalBookedSek = bookedSek ?? actualBankSek
|
||||
const exchangeRateDifference =
|
||||
Math.round((originalBookedSek - actualBankSek) * 100) / 100
|
||||
const paymentAmountSek =
|
||||
@@ -215,7 +224,20 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
const siAlreadyBooked = !!(invoice as { registration_journal_entry_id?: string | null }).registration_journal_entry_id
|
||||
const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash'
|
||||
|
||||
if (useCashEntry && exchangeRateDifference !== 0) {
|
||||
// Full settlement = the bank amount pays off the whole remaining balance.
|
||||
// Cross-currency always settles the remaining (paymentAmountInvoiceCurrency
|
||||
// is clamped to invoice.remaining_amount above).
|
||||
const fullSettlement =
|
||||
transaction.currency !== invoice.currency ||
|
||||
txAmountAbs >= invoice.remaining_amount - 0.005
|
||||
|
||||
// Under kontantmetoden the expense is recognised AT PAYMENT (payment-date
|
||||
// rate), so a full foreign-currency settlement has no kursdifferens — the
|
||||
// builder translates the whole entry to the actual bank SEK (settledBankSek)
|
||||
// below, leaving 1930 equal to the bank line. Only a PARTIAL cash-method
|
||||
// payment across rates can't be modelled cleanly (the builder books the
|
||||
// full invoice), so that narrow case stays blocked.
|
||||
if (useCashEntry && exchangeRateDifference !== 0 && !fullSettlement) {
|
||||
return v1ErrorResponseFromCode('MATCH_SI_CASH_FX_UNSUPPORTED', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
@@ -268,6 +290,11 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
(invoice.items || []) as SupplierInvoiceItem[],
|
||||
transaction.date,
|
||||
invoice.supplier?.supplier_type || 'swedish_business',
|
||||
undefined, // supplierName (unchanged default)
|
||||
undefined, // paymentAccount (unchanged default 1930)
|
||||
// Pin a foreign-currency settlement to the payment-date rate so 1930
|
||||
// equals the bank movement. No-op for SEK / same-rate settlements.
|
||||
exchangeRateDifference !== 0 && fullSettlement ? actualBankSek : undefined,
|
||||
)
|
||||
if (je) journalEntryId = je.id
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { cn, formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { FileText, ImageIcon, Loader2, Search, Inbox, Eye } from 'lucide-react'
|
||||
|
||||
// InboxDocumentPicker
|
||||
//
|
||||
// Opens from JournalEntryAttachments ("Välj från inkorgen"). Lists invoice-inbox
|
||||
// documents that have not yet been consumed (no supplier invoice, no journal
|
||||
// entry, not matched to a transaction, document not already linked) so the user
|
||||
// can attach one as underlag to the current verifikat. Picking one links the
|
||||
// document to the journal entry AND stamps the inbox item so it drops out of the
|
||||
// active inbox — see app/api/documents/[id]/link/route.ts.
|
||||
//
|
||||
// Each row carries a preview button (eye) that opens a quick dialog rendering
|
||||
// the document inline, so the user can confirm the right file before attaching.
|
||||
// Attaching is the row's primary click (fast path) and is also offered from
|
||||
// inside the preview dialog (preview → confirm).
|
||||
|
||||
interface AvailableInboxDoc {
|
||||
inbox_item_id: string
|
||||
document_id: string
|
||||
file_name: string
|
||||
mime_type: string | null
|
||||
file_size_bytes: number
|
||||
source: string | null
|
||||
created_at: string
|
||||
supplier_name: string | null
|
||||
amount: number | null
|
||||
currency: string | null
|
||||
invoice_date: string | null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
journalEntryId: string
|
||||
/** Called after a successful link so the parent can refresh its document list. */
|
||||
onLinked: () => void
|
||||
}
|
||||
|
||||
function isImageType(type: string | null): boolean {
|
||||
return type?.startsWith('image/') ?? false
|
||||
}
|
||||
|
||||
function isPdfType(type: string | null): boolean {
|
||||
return type === 'application/pdf'
|
||||
}
|
||||
|
||||
function DocIcon({ mime }: { mime: string | null }) {
|
||||
if (isImageType(mime)) {
|
||||
return <ImageIcon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
}
|
||||
return <FileText className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
}
|
||||
|
||||
export default function InboxDocumentPicker({ open, onClose, journalEntryId, onLinked }: Props) {
|
||||
const t = useTranslations('journal_attachments')
|
||||
const { toast } = useToast()
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [items, setItems] = useState<AvailableInboxDoc[]>([])
|
||||
const [search, setSearch] = useState('')
|
||||
const [linkingId, setLinkingId] = useState<string | null>(null)
|
||||
const [previewItem, setPreviewItem] = useState<AvailableInboxDoc | null>(null)
|
||||
|
||||
// Reset + fetch each time the dialog opens.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setSearch('')
|
||||
setItems([])
|
||||
setPreviewItem(null)
|
||||
setLoading(true)
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/documents/inbox-available')
|
||||
const json = (await res.json().catch(() => ({}))) as { data?: AvailableInboxDoc[] }
|
||||
if (cancelled) return
|
||||
setItems(json.data ?? [])
|
||||
} catch {
|
||||
if (!cancelled) setItems([])
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
if (!q) return items
|
||||
return items.filter((it) =>
|
||||
`${it.supplier_name ?? ''} ${it.file_name}`.toLowerCase().includes(q),
|
||||
)
|
||||
}, [items, search])
|
||||
|
||||
async function handlePick(item: AvailableInboxDoc) {
|
||||
setLinkingId(item.document_id)
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${item.document_id}/link`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
journal_entry_id: journalEntryId,
|
||||
inbox_item_id: item.inbox_item_id,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const json = (await res.json().catch(() => ({}))) as {
|
||||
error?: string | { message?: string }
|
||||
}
|
||||
const description =
|
||||
typeof json.error === 'string' ? json.error : json.error?.message
|
||||
toast({ title: t('picker_link_failed'), description, variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
toast({ title: t('picker_linked') })
|
||||
onLinked()
|
||||
onClose()
|
||||
} catch {
|
||||
toast({ title: t('picker_link_failed'), variant: 'destructive' })
|
||||
} finally {
|
||||
setLinkingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const hasSearch = search.trim().length > 0
|
||||
const previewSrc = previewItem ? `/api/documents/${previewItem.document_id}/inline` : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('picker_title')}</DialogTitle>
|
||||
<DialogDescription>{t('picker_description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('picker_search_placeholder')}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!loading && filtered.length > 0 && (
|
||||
<div className="flex justify-end px-1 text-[11px] text-muted-foreground tabular-nums">
|
||||
{t('picker_results', { count: filtered.length })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-[55vh] overflow-y-auto -mx-6 px-6 divide-y">
|
||||
{loading ? (
|
||||
<div className="space-y-3 py-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="py-10 flex flex-col items-center gap-2 text-center">
|
||||
<Inbox className="h-6 w-6 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{hasSearch ? t('picker_empty_search', { query: search.trim() }) : t('picker_empty')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((it) => {
|
||||
const isLinking = linkingId === it.document_id
|
||||
const sourceLabel =
|
||||
it.source === 'email' ? t('picker_source_email') : t('picker_source_upload')
|
||||
return (
|
||||
<div key={it.inbox_item_id} className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handlePick(it)}
|
||||
disabled={!!linkingId}
|
||||
className={cn(
|
||||
'flex-1 min-w-0 text-left flex items-center gap-3 py-3 px-2 -ml-2 rounded transition-colors hover:bg-secondary/60',
|
||||
linkingId && !isLinking && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
<DocIcon mime={it.mime_type} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{it.supplier_name ?? it.file_name}
|
||||
</span>
|
||||
<Badge variant="outline" className="shrink-0 text-[10px]">
|
||||
{sourceLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground tabular-nums">
|
||||
{it.invoice_date && <span>{formatDate(it.invoice_date)}</span>}
|
||||
{it.supplier_name && (
|
||||
<span className="truncate font-normal">{it.file_name}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{it.amount != null && (
|
||||
<span className="text-sm font-medium tabular-nums shrink-0">
|
||||
{formatCurrency(it.amount, it.currency ?? 'SEK')}
|
||||
</span>
|
||||
)}
|
||||
{isLinking && (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10 shrink-0"
|
||||
aria-label={t('picker_preview')}
|
||||
title={t('picker_preview')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setPreviewItem(it)
|
||||
}}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={previewItem !== null} onOpenChange={(o) => !o && setPreviewItem(null)}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="truncate pr-6">{previewItem?.file_name}</DialogTitle>
|
||||
{previewItem && (previewItem.supplier_name || previewItem.amount != null) && (
|
||||
<DialogDescription className="flex items-center gap-2 tabular-nums">
|
||||
{previewItem.supplier_name && <span>{previewItem.supplier_name}</span>}
|
||||
{previewItem.amount != null && (
|
||||
<span>{formatCurrency(previewItem.amount, previewItem.currency ?? 'SEK')}</span>
|
||||
)}
|
||||
{previewItem.invoice_date && <span>{formatDate(previewItem.invoice_date)}</span>}
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
{previewItem && previewSrc && (
|
||||
<div className="py-1">
|
||||
{isImageType(previewItem.mime_type) ? (
|
||||
<img
|
||||
src={previewSrc}
|
||||
alt={previewItem.file_name}
|
||||
className="max-h-[70vh] w-full rounded-lg border object-contain"
|
||||
/>
|
||||
) : isPdfType(previewItem.mime_type) ? (
|
||||
// <object> + type="application/pdf" invokes Chrome's PDF plugin
|
||||
// directly; <iframe> intermittently shows a blocked-content
|
||||
// notice even with a permissive CSP. Mirrors JournalEntryAttachments.
|
||||
<object
|
||||
data={previewSrc}
|
||||
type="application/pdf"
|
||||
aria-label={previewItem.file_name}
|
||||
className="w-full h-[70vh] rounded-lg border"
|
||||
>
|
||||
<a
|
||||
href={previewSrc}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block px-4 py-2 text-sm text-muted-foreground underline"
|
||||
>
|
||||
{t('picker_preview_unavailable')}
|
||||
</a>
|
||||
</object>
|
||||
) : (
|
||||
<a
|
||||
href={previewSrc}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block px-4 py-6 text-sm text-muted-foreground underline text-center"
|
||||
>
|
||||
{t('picker_preview_unavailable')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setPreviewItem(null)}>
|
||||
{t('picker_close')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (previewItem) void handlePick(previewItem)
|
||||
}}
|
||||
disabled={!!linkingId}
|
||||
>
|
||||
{previewItem && linkingId === previewItem.document_id ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('picker_attach')}
|
||||
</>
|
||||
) : (
|
||||
t('picker_attach')
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -24,9 +24,11 @@ import {
|
||||
Loader2,
|
||||
Lock,
|
||||
AlertTriangle,
|
||||
Inbox,
|
||||
} from 'lucide-react'
|
||||
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import InboxDocumentPicker from '@/components/bookkeeping/InboxDocumentPicker'
|
||||
|
||||
interface DocumentRecord {
|
||||
id: string
|
||||
@@ -71,6 +73,7 @@ export default function JournalEntryAttachments({
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expandedDoc, setExpandedDoc] = useState<string | null>(null)
|
||||
const [showUpload, setShowUpload] = useState(false)
|
||||
const [showInboxPicker, setShowInboxPicker] = useState(false)
|
||||
const [uploadFiles, setUploadFiles] = useState<UploadedFile[]>([])
|
||||
|
||||
// Docs listed here are filtered by journal_entry_id, so every row is bound
|
||||
@@ -215,15 +218,26 @@ export default function JournalEntryAttachments({
|
||||
<h4 className="text-sm font-medium">
|
||||
{t('title')} {documents.length > 0 && `(${documents.length})`}
|
||||
</h4>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setShowUpload(!showUpload)}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
{t('add')}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setShowInboxPicker(true)}
|
||||
>
|
||||
<Inbox className="h-3 w-3 mr-1" />
|
||||
{t('choose_from_inbox')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setShowUpload(!showUpload)}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
{t('add')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showUpload && (
|
||||
@@ -402,6 +416,13 @@ export default function JournalEntryAttachments({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<InboxDocumentPicker
|
||||
open={showInboxPicker}
|
||||
onClose={() => setShowInboxPicker(false)}
|
||||
journalEntryId={journalEntryId}
|
||||
onLinked={fetchDocuments}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -208,11 +208,7 @@ export default function WelcomeOnboarding({
|
||||
let title = t('toast_error_title')
|
||||
let description: string = result.error || t('toast_create_failed')
|
||||
let backToStep2 = false
|
||||
if (result.error === 'org_number_exists') {
|
||||
title = t('toast_company_exists_title')
|
||||
description = t('toast_company_exists_description', { appName: branding.appName.toLowerCase() })
|
||||
backToStep2 = true
|
||||
} else if (result.error === 'org_number_invalid') {
|
||||
if (result.error === 'org_number_invalid') {
|
||||
title = t('toast_org_invalid_title')
|
||||
description = t('toast_org_invalid_description')
|
||||
backToStep2 = true
|
||||
|
||||
@@ -42,7 +42,7 @@ type ArcimProvider = 'fortnox' | 'visma' | 'briox' | 'bokio' | 'bjornlunden'
|
||||
|
||||
const ARCIM_PROVIDERS: { id: ArcimProvider; name: string; authType: 'oauth' | 'token' }[] = [
|
||||
{ id: 'fortnox', name: 'Fortnox', authType: 'oauth' },
|
||||
{ id: 'visma', name: 'Visma eEkonomi', authType: 'oauth' },
|
||||
{ id: 'visma', name: 'Visma', authType: 'oauth' },
|
||||
{ id: 'bokio', name: 'Bokio', authType: 'token' },
|
||||
{ id: 'bjornlunden', name: 'Björn Lundén', authType: 'token' },
|
||||
{ id: 'briox', name: 'Briox', authType: 'token' },
|
||||
@@ -349,16 +349,24 @@ function ProviderStep({
|
||||
{ARCIM_PROVIDERS.map((provider) => {
|
||||
const comingSoon = COMING_SOON_PROVIDERS.has(provider.id)
|
||||
const alreadyConnected = activeConsents.some(c => c.provider === provider.id)
|
||||
// Non-Fortnox providers only expose entity data (customers,
|
||||
// suppliers, invoices) via API — the ledger must arrive via SIE
|
||||
// first. Gate the connection entry until a completed SIE import
|
||||
// exists so users don't authenticate into a flow that can't
|
||||
// import anything yet. The /migrate route enforces this
|
||||
// server-side regardless; this is just the matching UX.
|
||||
const needsSieFirst = !hasSieImport && provider.id !== 'fortnox'
|
||||
const isDisabled = comingSoon || alreadyConnected || needsSieFirst
|
||||
return (
|
||||
<button
|
||||
key={provider.id}
|
||||
disabled={comingSoon || alreadyConnected}
|
||||
disabled={isDisabled}
|
||||
className={`relative flex items-center gap-4 rounded-lg border p-4 text-left transition-all ${
|
||||
comingSoon || alreadyConnected
|
||||
isDisabled
|
||||
? 'cursor-not-allowed border-border/50 opacity-60'
|
||||
: 'border-border hover:border-primary/50 hover:bg-accent/50 active:scale-[0.98]'
|
||||
}`}
|
||||
onClick={() => !comingSoon && !alreadyConnected && onSelect(provider.id)}
|
||||
onClick={() => !isDisabled && onSelect(provider.id)}
|
||||
>
|
||||
<img
|
||||
src={PROVIDER_LOGOS[provider.id]}
|
||||
@@ -378,9 +386,20 @@ function ProviderStep({
|
||||
Ansluten
|
||||
</span>
|
||||
)}
|
||||
{needsSieFirst && !comingSoon && !alreadyConnected && (
|
||||
<span className="inline-flex items-center rounded-full bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-500">
|
||||
SIE krävs först
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{alreadyConnected ? 'Använd "Synka igen" ovan' : provider.authType === 'oauth' ? 'Anslut via inloggning' : 'Anslut med API-nyckel'}
|
||||
{alreadyConnected
|
||||
? 'Använd "Synka igen" ovan'
|
||||
: needsSieFirst
|
||||
? 'Importera SIE-fil först'
|
||||
: provider.authType === 'oauth'
|
||||
? 'Anslut via inloggning'
|
||||
: 'Anslut med API-nyckel'}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -301,7 +301,7 @@ export default function SIEUploadStep({ onFileSelect, isLoading, error, errorTyp
|
||||
<p className="text-muted-foreground">Inställningar → Import/Export → Exportera SIE</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Visma eEkonomi</p>
|
||||
<p className="font-medium">Visma</p>
|
||||
<p className="text-muted-foreground">Rapporter → Övrigt → Exportera till SIE</p>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -190,28 +190,16 @@ export default function BankIdCompanyPicker({
|
||||
const entityLabel = humanTicEntityType(role.legalEntityType)
|
||||
const mappable = mapEntityType(role.legalEntityType) !== null
|
||||
|
||||
if (status === 'exists') {
|
||||
return (
|
||||
<li key={cleaned}>
|
||||
<div className="w-full rounded-lg border bg-muted/20 p-4 text-left opacity-70">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-sm truncate">{role.legalName}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{cleaned} · {entityLabel}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground flex-shrink-0">
|
||||
{t('already_in_app', { appName: branding.appName.toLowerCase() })}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground/70 mt-2">
|
||||
{t('ask_admin_invite')}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
// Companies already in gnubok under another account are still
|
||||
// offered for setup — org-number reuse is allowed (a director
|
||||
// may keep a separate test copy). Keep a muted note so the
|
||||
// "also already in {app}" context isn't lost.
|
||||
const existsNote =
|
||||
status === 'exists' ? (
|
||||
<p className="text-xs text-muted-foreground/70 mt-2">
|
||||
{t('already_in_app', { appName: branding.appName.toLowerCase() })}
|
||||
</p>
|
||||
) : null
|
||||
|
||||
if (!mappable) {
|
||||
return (
|
||||
@@ -236,6 +224,7 @@ export default function BankIdCompanyPicker({
|
||||
{t('setup_manually')}
|
||||
</span>
|
||||
</div>
|
||||
{existsNote}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
@@ -263,6 +252,7 @@ export default function BankIdCompanyPicker({
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
</div>
|
||||
{existsNote}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
|
||||
@@ -12,11 +12,8 @@ import { Label } from '@/components/ui/label'
|
||||
import { Loader2, ArrowRight, ArrowLeft, CheckCircle2, AlertTriangle } from 'lucide-react'
|
||||
import type { EntityType } from '@/types'
|
||||
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
|
||||
|
||||
const branding = getBranding()
|
||||
|
||||
const schema = z.object({
|
||||
company_name: z.string().min(1, 'Företagsnamn krävs'),
|
||||
org_number: z.string()
|
||||
@@ -82,7 +79,7 @@ export default function Step2CompanyDetails({
|
||||
const [isLooking, setIsLooking] = useState(false)
|
||||
const [lookupError, setLookupError] = useState<string | null>(null)
|
||||
const [lookupDone, setLookupDone] = useState<CompanyLookupResult | null>(null)
|
||||
const [orgNumberExists, setOrgNumberExists] = useState(false)
|
||||
const [existingOwn, setExistingOwn] = useState<{ id: string; name: string } | null>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const dupAbortRef = useRef<AbortController | null>(null)
|
||||
// Tracks an orgnr that's been pre-verified (BankID CompanyRoles match) so
|
||||
@@ -96,13 +93,14 @@ export default function Step2CompanyDetails({
|
||||
|
||||
const orgNumber = watch('org_number')
|
||||
|
||||
// Debounced duplicate check against Accounted's own companies table. Runs in
|
||||
// parallel with the TIC lookup — they don't conflict. On match, the submit
|
||||
// button is disabled; the server action would also reject ('org_number_exists')
|
||||
// but blocking client-side avoids a wasted roundtrip.
|
||||
// Soft, account-scoped duplicate warning: if THIS user already has a
|
||||
// (non-archived) company with the same org number, surface a non-blocking
|
||||
// note. Org-number reuse is allowed (see lib/company/actions.ts), so this
|
||||
// never disables submit. The endpoint is RLS-scoped to the caller's own
|
||||
// companies, so it can't reveal or count another account's.
|
||||
useEffect(() => {
|
||||
if (!orgNumber || normalizeOrgNumber(orgNumber) === null) {
|
||||
setOrgNumberExists(false)
|
||||
setExistingOwn(null)
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
@@ -115,10 +113,10 @@ export default function Step2CompanyDetails({
|
||||
.then(async (res) => {
|
||||
if (controller.signal.aborted || !res.ok) return
|
||||
const { data } = await res.json()
|
||||
setOrgNumberExists(!!data?.exists)
|
||||
setExistingOwn(data?.companies?.[0] ?? null)
|
||||
})
|
||||
.catch(() => {
|
||||
// Network failure is non-fatal — the server action will re-check.
|
||||
// Advisory only — never blocks creation.
|
||||
})
|
||||
}, 500)
|
||||
return () => {
|
||||
@@ -267,12 +265,10 @@ export default function Step2CompanyDetails({
|
||||
{ticEnabled && lookupError && (
|
||||
<p className="text-xs text-muted-foreground">{lookupError}</p>
|
||||
)}
|
||||
{orgNumberExists && (
|
||||
<div className="flex items-start gap-2 text-sm text-destructive">
|
||||
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
{t('step2_company_exists', { appName: branding.appName.toLowerCase() })}
|
||||
</span>
|
||||
{existingOwn && (
|
||||
<div className="flex items-start gap-2 rounded-md bg-warning/15 px-3 py-2 text-sm text-warning-foreground">
|
||||
<AlertTriangle className="h-4 w-4 mt-0.5 flex-shrink-0 text-warning" />
|
||||
<span>{t('step2_company_exists_own', { name: existingOwn.name })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -338,7 +334,7 @@ export default function Step2CompanyDetails({
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSaving || orgNumberExists}
|
||||
disabled={isSaving}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isSaving ? (
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { AlertCircle, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { ReportExportMenu } from '@/components/reports/ReportExportMenu'
|
||||
@@ -122,7 +123,7 @@ export function TrialBalanceView({ periodId, onNavigateToAccount }: { periodId:
|
||||
// Credit-normal accounts (liabilities/equity class 2, revenue class 3): positive when credit > debit
|
||||
// Debit-normal accounts (assets class 1, expenses class 4-9): positive when debit > credit
|
||||
const creditNormal = row.account_class === 2 || row.account_class === 3
|
||||
return Math.round((creditNormal ? credit - debit : debit - credit) * 100) / 100
|
||||
return roundOre(creditNormal ? credit - debit : debit - credit)
|
||||
}
|
||||
|
||||
function formatSigned(amount: number): string {
|
||||
|
||||
@@ -637,18 +637,25 @@ export default function InvoiceMatchDialog({
|
||||
<div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground text-right">
|
||||
{t('booking_credit')}
|
||||
</div>
|
||||
{/* Verifikat amounts are always denominated in SEK (the
|
||||
bookkeeping home currency) — the preview route builds
|
||||
every line via resolveSekAmount. Format them as SEK,
|
||||
NOT transaction.currency, otherwise a foreign-currency
|
||||
payment (e.g. 19 USD) shows the converted SEK figure
|
||||
with the wrong symbol ("175,28 US$" instead of
|
||||
"175,28 kr"). */}
|
||||
{preview.lines.map((line, i) => (
|
||||
<div key={i} className="contents">
|
||||
<div className="font-medium">{line.account_number}</div>
|
||||
<div className="text-muted-foreground truncate">{line.description}</div>
|
||||
<div className="text-right">
|
||||
{line.debit_amount > 0
|
||||
? formatCurrency(line.debit_amount, transaction.currency)
|
||||
? formatCurrency(line.debit_amount, 'SEK')
|
||||
: ''}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{line.credit_amount > 0
|
||||
? formatCurrency(line.credit_amount, transaction.currency)
|
||||
? formatCurrency(line.credit_amount, 'SEK')
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
@@ -730,16 +737,17 @@ export default function InvoiceMatchDialog({
|
||||
{t('booking_add_line')}
|
||||
</Button>
|
||||
<div className="text-xs tabular-nums text-muted-foreground">
|
||||
{t('booking_debit')} {formatCurrency(editValidation.totalDebit, transaction.currency)}
|
||||
{/* SEK: edited verifikat rows are home-currency, like the read-only preview above. */}
|
||||
{t('booking_debit')} {formatCurrency(editValidation.totalDebit, 'SEK')}
|
||||
{' / '}
|
||||
{t('booking_credit')} {formatCurrency(editValidation.totalCredit, transaction.currency)}
|
||||
{t('booking_credit')} {formatCurrency(editValidation.totalCredit, 'SEK')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!editValidation.isBalanced && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t('booking_unbalanced', {
|
||||
diff: formatCurrency(Math.abs(editValidation.diff), transaction.currency),
|
||||
diff: formatCurrency(Math.abs(editValidation.diff), 'SEK'),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'
|
||||
import { createMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
import type { ExtensionContext } from '@/lib/extensions/types'
|
||||
|
||||
/**
|
||||
* Guards the server-side "SIE import required first" rule on the entity-migration
|
||||
* route (POST /migrate).
|
||||
*
|
||||
* Provider API import only ever writes subledger entities (customers, suppliers,
|
||||
* invoices) — it never posts to the general ledger. The GL (kontoplan, ingående
|
||||
* balanser, verifikationer) arrives via SIE. Importing entities without the
|
||||
* SIE-derived ledger leaves an incomplete bokföring under BFL, so the route MUST
|
||||
* refuse to run for non-Fortnox providers until a completed SIE import exists.
|
||||
* Fortnox is exempt because it pulls SIE itself via API.
|
||||
*
|
||||
* Previously this was only an advisory banner + step-gating in the React wizard,
|
||||
* which a direct API call or a stale client could bypass. This test locks the
|
||||
* enforcement at the authoritative seam: the route handler.
|
||||
*/
|
||||
|
||||
vi.mock('../lib/migration-orchestrator', () => ({
|
||||
executeMigration: vi.fn().mockResolvedValue({ customers: { total: 0, imported: 0, skipped: 0 } }),
|
||||
}))
|
||||
|
||||
// index.ts imports many helpers from provider-client at module load; stub the
|
||||
// whole module and give getConsent/acceptConsent controllable behaviour.
|
||||
vi.mock('../lib/provider-client', () => ({
|
||||
createConsent: vi.fn(),
|
||||
getConsent: vi.fn(),
|
||||
listConsents: vi.fn(),
|
||||
generateOtc: vi.fn(),
|
||||
getAuthUrl: vi.fn(),
|
||||
exchangeAuthToken: vi.fn(),
|
||||
submitProviderToken: vi.fn(),
|
||||
acceptConsent: vi.fn().mockResolvedValue(undefined),
|
||||
deleteConsent: vi.fn(),
|
||||
resolveConsent: vi.fn(),
|
||||
fetchCompanyInfoDirect: vi.fn(),
|
||||
}))
|
||||
|
||||
import { arcimMigrationExtension } from '../index'
|
||||
import { executeMigration } from '../lib/migration-orchestrator'
|
||||
import { getConsent } from '../lib/provider-client'
|
||||
|
||||
const migrateRoute = (arcimMigrationExtension.apiRoutes ?? []).find(
|
||||
(r) => r.method === 'POST' && r.path === '/migrate',
|
||||
)!
|
||||
|
||||
type RouteHandler = (request: Request, ctx?: ExtensionContext) => Promise<Response>
|
||||
const handler = migrateRoute.handler as RouteHandler
|
||||
|
||||
function buildCtx(count: number | null): ExtensionContext {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
// The guard awaits `from('sie_imports').select(..,{count,head}).eq().eq()`.
|
||||
mockResult({ count })
|
||||
;(supabase as unknown as { auth: unknown }).auth = {
|
||||
getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } } }),
|
||||
}
|
||||
return { supabase, companyId: 'company-1' } as unknown as ExtensionContext
|
||||
}
|
||||
|
||||
function migrateRequest() {
|
||||
return createMockRequest('http://localhost/api/extensions/ext/arcim-migration/migrate', {
|
||||
method: 'POST',
|
||||
body: { consentId: 'consent-1' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('POST /migrate — SIE-import-required guard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('blocks a non-Fortnox provider when no completed SIE import exists', async () => {
|
||||
;(getConsent as Mock).mockResolvedValue({ id: 'consent-1', status: 1, provider: 'visma' })
|
||||
|
||||
const res = await handler(migrateRequest(), buildCtx(0))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('PROVIDER_SIE_IMPORT_REQUIRED')
|
||||
expect(executeMigration).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows a non-Fortnox provider once a completed SIE import exists', async () => {
|
||||
;(getConsent as Mock).mockResolvedValue({ id: 'consent-1', status: 1, provider: 'visma' })
|
||||
|
||||
const res = await handler(migrateRequest(), buildCtx(1))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(executeMigration).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('exempts Fortnox — entity import runs even with no SIE import (SIE comes via API)', async () => {
|
||||
;(getConsent as Mock).mockResolvedValue({ id: 'consent-1', status: 1, provider: 'fortnox' })
|
||||
|
||||
const res = await handler(migrateRequest(), buildCtx(0))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(executeMigration).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1025,6 +1025,30 @@ export const arcimMigrationExtension: Extension = {
|
||||
})
|
||||
}
|
||||
|
||||
// ── Guard: a completed SIE import is required before entity import ──
|
||||
// Every provider except Fortnox exposes ONLY entity data (customers,
|
||||
// suppliers, invoices) via API — never the general ledger. Fortnox pulls
|
||||
// the GL itself via SIE-over-API. Importing entities without the
|
||||
// SIE-derived ledger (kontoplan, ingående balanser, verifikationer)
|
||||
// would leave an incomplete bokföring under BFL: a subledger with no
|
||||
// chart of accounts and no opening balances, so every subsequent posting
|
||||
// and balance is wrong. The wizard surfaces this as an advisory banner,
|
||||
// but it must be enforced here so the rule cannot be bypassed by a direct
|
||||
// API call, a skipped wizard step, or a stale client.
|
||||
if (consent.provider !== 'fortnox') {
|
||||
const { count: completedSieImports } = await supabase
|
||||
.from('sie_imports')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'completed')
|
||||
|
||||
if (!completedSieImports || completedSieImports < 1) {
|
||||
return errorResponseFromCode('PROVIDER_SIE_IMPORT_REQUIRED', moduleLog, {
|
||||
details: { provider: consent.provider },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
log.info(`Starting migration for user ${user.id} from ${consent.provider}`)
|
||||
|
||||
const results = await executeMigration({
|
||||
|
||||
@@ -216,6 +216,34 @@ export const CreateCreditNoteSchema = z.object({
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
|
||||
// Self-billing received (mottagen självfaktura, ML 17 kap 15§). The customer
|
||||
// issued the invoice on our behalf; for us it is a sale. We store the
|
||||
// counterparty's number in external_invoice_number and never assign one from
|
||||
// our own series. No ROT/RUT (that is a B2C, own-issued concept), so the item
|
||||
// schema is the lean revenue-only shape — vat_rate is constrained to the legal
|
||||
// Swedish set so the booked output VAT is always reportable.
|
||||
export const SelfBillingInvoiceItemSchema = 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').default('st'),
|
||||
unit_price: z.number(),
|
||||
vat_rate: z
|
||||
.union([z.literal(0), z.literal(6), z.literal(12), z.literal(25)])
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export const CreateSelfBillingInvoiceSchema = z.object({
|
||||
customer_id: uuid,
|
||||
external_invoice_number: z.string().min(1, 'External invoice number is required').max(64),
|
||||
self_billing_agreement_ref: z.string().max(128).optional(),
|
||||
invoice_date: isoDate,
|
||||
received_date: isoDate,
|
||||
due_date: isoDate,
|
||||
currency: CurrencySchema,
|
||||
notes: z.string().optional(),
|
||||
items: z.array(SelfBillingInvoiceItemSchema).min(1, 'At least one item is required'),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Recurring invoice schedule schemas
|
||||
// ============================================================
|
||||
|
||||
@@ -1205,6 +1205,111 @@ describe('createSupplierInvoiceCashEntry', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// createSupplierInvoiceCashEntry — foreign-currency settlement
|
||||
// (kontantmetoden books the expense at the PAYMENT-date rate; the
|
||||
// payment-account credit must equal the SEK that left the bank)
|
||||
// ============================================================
|
||||
|
||||
describe('createSupplierInvoiceCashEntry — foreign-currency settlement', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockedFindFiscalPeriod.mockResolvedValue('period-1')
|
||||
})
|
||||
|
||||
it('books a no-VAT foreign invoice at the payment-date rate, not the invoice rate (the reported bug)', async () => {
|
||||
// 19 USD invoice. The invoice was captured at rate 9.20 (→ 174.80 SEK),
|
||||
// but the bank actually paid 175.28 SEK at the payment-date rate. Under
|
||||
// kontantmetoden the expense belongs at the payment rate, so 1930 must
|
||||
// equal the bank movement exactly — and there is NO kursdifferens.
|
||||
const invoice = makeSupplierInvoice({
|
||||
currency: 'USD', exchange_rate: 9.20, subtotal: 19, vat_amount: 0, total: 19,
|
||||
})
|
||||
const items = [makeItem({ line_total: 19, account_number: '4000', vat_rate: 0, vat_amount: 0 })]
|
||||
|
||||
await createSupplierInvoiceCashEntry(
|
||||
null as never, 'company-1', 'user-1', invoice, items, '2026-01-19', 'non_eu_business',
|
||||
undefined, undefined, 175.28,
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
// Payment-date rate (175.28 / 19), NOT the invoice's 9.20 (which would give 174.80).
|
||||
expect(findByAccount(input.lines, '4000')[0].debit_amount).toBe(175.28)
|
||||
expect(findByAccount(input.lines, '1930')[0].credit_amount).toBe(175.28)
|
||||
// No kursvinst/kursförlust under the cash method.
|
||||
expect(findByAccount(input.lines, '7960')).toHaveLength(0)
|
||||
expect(findByAccount(input.lines, '3960')).toHaveLength(0)
|
||||
expect(findByAccount(input.lines, '2641')).toHaveLength(0)
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('translates a foreign reverse-charge invoice (fiktiv moms base) at the payment rate', async () => {
|
||||
// 100 USD EU-service invoice, reverse charge. Bank paid 922.50 SEK.
|
||||
const invoice = makeSupplierInvoice({
|
||||
currency: 'USD', exchange_rate: 9.20, subtotal: 100, vat_amount: 0, total: 100, reverse_charge: true,
|
||||
})
|
||||
const items = [makeItem({ line_total: 100, account_number: '6540', vat_rate: 0.25, vat_amount: 0 })]
|
||||
|
||||
await createSupplierInvoiceCashEntry(
|
||||
null as never, 'company-1', 'user-1', invoice, items, '2026-01-19', 'eu_business',
|
||||
undefined, undefined, 922.50,
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
expect(findByAccount(input.lines, '6540')[0].debit_amount).toBe(922.50)
|
||||
// Fiktiv moms on the payment-rate base (922.50 × 25%), and it nets out so
|
||||
// 1930 still equals the bank movement.
|
||||
expect(findByAccount(input.lines, '2645')[0].debit_amount).toBeCloseTo(230.63, 2)
|
||||
expect(findByAccount(input.lines, '2614')[0].credit_amount).toBeCloseTo(230.63, 2)
|
||||
expect(findByAccount(input.lines, '1930')[0].credit_amount).toBe(922.50)
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('folds a sub-öre rounding residual into the largest expense line so 1930 = bank SEK', async () => {
|
||||
// Two expense lines whose per-line payment-rate rounding sums to 175.29,
|
||||
// one öre over the 175.28 that actually left the bank. The residual is
|
||||
// folded into the larger line so the bank credit lands exactly on 175.28.
|
||||
const invoice = makeSupplierInvoice({
|
||||
currency: 'USD', exchange_rate: 1.75, subtotal: 100, vat_amount: 0, total: 100,
|
||||
})
|
||||
const items = [
|
||||
makeItem({ id: 'a', line_total: 33.33, account_number: '4000', vat_rate: 0, vat_amount: 0 }),
|
||||
makeItem({ id: 'b', line_total: 66.67, account_number: '5000', vat_rate: 0, vat_amount: 0 }),
|
||||
]
|
||||
|
||||
await createSupplierInvoiceCashEntry(
|
||||
null as never, 'company-1', 'user-1', invoice, items, '2026-01-19', 'swedish_business',
|
||||
undefined, undefined, 175.28,
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
const debitSum = input.lines
|
||||
.filter((l) => l.debit_amount > 0)
|
||||
.reduce((s, l) => s + l.debit_amount, 0)
|
||||
expect(Math.round(debitSum * 100) / 100).toBe(175.28)
|
||||
expect(findByAccount(input.lines, '1930')[0].credit_amount).toBe(175.28)
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('ignores settledBankSek for a SEK invoice (behaviour unchanged)', async () => {
|
||||
const invoice = makeSupplierInvoice({
|
||||
currency: 'SEK', subtotal: 8000, vat_amount: 2000, total: 10000,
|
||||
})
|
||||
const items = [makeItem({ line_total: 8000, account_number: '6200', vat_rate: 0.25 })]
|
||||
|
||||
await createSupplierInvoiceCashEntry(
|
||||
null as never, 'company-1', 'user-1', invoice, items, '2024-07-01', 'swedish_business',
|
||||
undefined, undefined, 9999, // bogus settlement SEK must be ignored for a SEK invoice
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
expect(findByAccount(input.lines, '6200')[0].debit_amount).toBe(8000)
|
||||
expect(findByAccount(input.lines, '2641')[0].debit_amount).toBe(2000)
|
||||
expect(findByAccount(input.lines, '1930')[0].credit_amount).toBe(10000)
|
||||
assertBalanced(input)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// createSupplierCreditNoteEntry
|
||||
// ============================================================
|
||||
|
||||
@@ -231,7 +231,15 @@ export async function createInvoiceJournalEntry(
|
||||
userId: string,
|
||||
invoice: Invoice,
|
||||
entityType: EntityType = 'enskild_firma',
|
||||
customerName?: string
|
||||
customerName?: string,
|
||||
/**
|
||||
* Overrides for non-standard sales that still book identically to a customer
|
||||
* invoice. Used by self-billing received (mottagen självfaktura): the
|
||||
* verifikation should read "Självfaktura <external number>" rather than
|
||||
* "Kundfaktura <our number>", and the number tag must be the counterparty's
|
||||
* external number because the row has no own `invoice_number`.
|
||||
*/
|
||||
options?: { descriptionPrefix?: string; numberOverride?: string | null }
|
||||
): Promise<JournalEntry | null> {
|
||||
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, invoice.invoice_date)
|
||||
if (!fiscalPeriodId) {
|
||||
@@ -241,7 +249,7 @@ export async function createInvoiceJournalEntry(
|
||||
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
const isForeign = invoice.currency !== 'SEK'
|
||||
const tag = invoiceTag(invoice)
|
||||
const tag = options?.numberOverride ?? invoiceTag(invoice)
|
||||
|
||||
// Credit lines: revenue + VAT per rate group (compute first to guarantee balance)
|
||||
const creditLines: CreateJournalEntryLineInput[] = []
|
||||
@@ -314,7 +322,12 @@ export async function createInvoiceJournalEntry(
|
||||
const input: CreateJournalEntryInput = {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: invoice.invoice_date,
|
||||
description: buildInvoiceDescription('Kundfaktura', invoice.invoice_number, customerName, invoice.id),
|
||||
description: buildInvoiceDescription(
|
||||
options?.descriptionPrefix ?? 'Kundfaktura',
|
||||
options?.numberOverride ?? invoice.invoice_number,
|
||||
customerName,
|
||||
invoice.id,
|
||||
),
|
||||
source_type: 'invoice_created',
|
||||
source_id: invoice.id,
|
||||
lines,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
resolveReverseChargeRate,
|
||||
} from './vat-entries'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type {
|
||||
CreateJournalEntryInput,
|
||||
@@ -282,7 +283,12 @@ export async function createSupplierInvoiceCashEntry(
|
||||
paymentDate: string,
|
||||
supplierType: string,
|
||||
supplierName?: string,
|
||||
paymentAccount?: string
|
||||
paymentAccount?: string,
|
||||
// SEK that actually settled the invoice (the amount that left the bank). For
|
||||
// a foreign-currency invoice this pins the whole entry to the PAYMENT-date
|
||||
// rate — see the kontantmetoden note below. Omit for SEK invoices and the
|
||||
// behaviour is byte-identical to before.
|
||||
settledBankSek?: number
|
||||
): Promise<JournalEntry | null> {
|
||||
const creditAccount = paymentAccount || '1930'
|
||||
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, paymentDate)
|
||||
@@ -291,25 +297,46 @@ export async function createSupplierInvoiceCashEntry(
|
||||
return null
|
||||
}
|
||||
|
||||
// Under kontantmetoden the booked affärshändelse IS the payment (BFL 5 kap —
|
||||
// "bokföring vid betalningstillfället"), so the entire verifikat is translated
|
||||
// at the PAYMENT-date rate (ÅRL 4 kap 6 §). There is no kursvinst/kursförlust
|
||||
// because no leverantörsskuld was ever carried at a historical rate — that
|
||||
// only happens under faktureringsmetoden (handled by the 2440-clearing path
|
||||
// with 7960/3960). When the caller passes the SEK that actually settled the
|
||||
// invoice, we derive the implied payment-date rate from it so the payment-
|
||||
// account credit equals the bank movement to the öre. For SEK invoices, or
|
||||
// when no settlement SEK is supplied, we keep the invoice's stored rate.
|
||||
const isForeign = invoice.currency !== 'SEK'
|
||||
const useSettlementRate =
|
||||
settledBankSek != null && settledBankSek > 0 && isForeign && invoice.total > 0
|
||||
const effectiveRate = useSettlementRate
|
||||
? settledBankSek / invoice.total
|
||||
: invoice.exchange_rate
|
||||
|
||||
const desc = buildSupplierDescription('Kontantbetalning leverantörsfaktura', invoice.supplier_invoice_number, supplierName)
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
// Expense debit lines tracked separately so a sub-öre translation residual
|
||||
// can be folded into the largest one (öresavrundning step below).
|
||||
const expenseLines: CreateJournalEntryLineInput[] = []
|
||||
|
||||
// Aggregate expense amounts by account number and convert to SEK
|
||||
const expenseByAccount = new Map<string, number>()
|
||||
for (const item of items) {
|
||||
const current = expenseByAccount.get(item.account_number) || 0
|
||||
const itemSek = resolveSekAmount(item.line_total, null, invoice.currency, invoice.exchange_rate)
|
||||
const itemSek = resolveSekAmount(item.line_total, null, invoice.currency, effectiveRate)
|
||||
expenseByAccount.set(item.account_number, current + itemSek)
|
||||
}
|
||||
|
||||
// Debit: Expense accounts (in SEK)
|
||||
for (const [accountNumber, amount] of expenseByAccount) {
|
||||
lines.push({
|
||||
const line: CreateJournalEntryLineInput = {
|
||||
account_number: accountNumber,
|
||||
debit_amount: Math.round(amount * 100) / 100,
|
||||
credit_amount: 0,
|
||||
line_description: desc,
|
||||
})
|
||||
}
|
||||
lines.push(line)
|
||||
expenseLines.push(line)
|
||||
}
|
||||
|
||||
const isReverseCharge = (supplierType === 'eu_business' || supplierType === 'non_eu_business' || supplierType === 'swedish_business') && invoice.reverse_charge
|
||||
@@ -327,8 +354,10 @@ export async function createSupplierInvoiceCashEntry(
|
||||
// Per-rate bucketing: see registration entry above for the FK004 rationale.
|
||||
// Drive iteration off the basis (line_total per rate) — fiktiv moms is
|
||||
// always statutory base × rate; manual vat_amount overrides don't apply.
|
||||
const baseByRate = groupBaseByRate(items, invoice.currency, invoice.exchange_rate)
|
||||
const nonBasisBaseByRate = groupNonBasisBaseByRate(items, invoice.currency, invoice.exchange_rate)
|
||||
// effectiveRate (payment-date rate under kontantmetoden) keeps the fiktiv
|
||||
// moms base consistent with the expense lines above.
|
||||
const baseByRate = groupBaseByRate(items, invoice.currency, effectiveRate)
|
||||
const nonBasisBaseByRate = groupNonBasisBaseByRate(items, invoice.currency, effectiveRate)
|
||||
const rcSupplierType = supplierType as 'eu_business' | 'non_eu_business' | 'swedish_business'
|
||||
for (const [rate, baseAmount] of baseByRate) {
|
||||
if (rate > 0 && baseAmount > 0) {
|
||||
@@ -342,8 +371,9 @@ export async function createSupplierInvoiceCashEntry(
|
||||
}
|
||||
}
|
||||
} else if (invoice.vat_amount > 0) {
|
||||
// Domestic standard: Debit ingående moms per rate group
|
||||
const vatByRate = groupVatByRate(items, invoice.currency, invoice.exchange_rate)
|
||||
// Domestic standard: Debit ingående moms per rate group (at the payment-
|
||||
// date rate when settling a foreign invoice — see effectiveRate above).
|
||||
const vatByRate = groupVatByRate(items, invoice.currency, effectiveRate)
|
||||
for (const [rate, amount] of vatByRate) {
|
||||
if (amount > 0) {
|
||||
lines.push({
|
||||
@@ -356,6 +386,24 @@ export async function createSupplierInvoiceCashEntry(
|
||||
}
|
||||
}
|
||||
|
||||
// Öresavrundning: when translating a foreign invoice at the payment-date
|
||||
// rate, per-line rounding can drift the implied bank total by an öre or two.
|
||||
// Fold that residual into the largest expense line so the payment-account
|
||||
// credit lands exactly on the SEK that left the bank (1930 reconciles to the
|
||||
// bank transaction). Immaterial to the momsdeklaration — rutor are whole
|
||||
// kronor. The |residual| ≤ 1 guard ensures we only absorb rounding noise,
|
||||
// never a real shortfall (a partial settlement is blocked upstream).
|
||||
if (useSettlementRate && expenseLines.length > 0) {
|
||||
const debitSum = lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
||||
const creditSum = lines.reduce((sum, l) => sum + l.credit_amount, 0)
|
||||
const provisionalCredit = roundOre(debitSum - creditSum)
|
||||
const residual = roundOre(settledBankSek! - provisionalCredit)
|
||||
if (residual !== 0 && Math.abs(residual) <= 1) {
|
||||
const target = expenseLines.reduce((a, b) => (b.debit_amount >= a.debit_amount ? b : a))
|
||||
target.debit_amount = roundOre(target.debit_amount + residual)
|
||||
}
|
||||
}
|
||||
|
||||
// Credit: payment account — balance guarantee: ensures sum(debits) === sum(credits)
|
||||
// For reverse charge, intermediate credits (2614/2624/2634) already exist, so we subtract them
|
||||
const totalDebits = lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
||||
|
||||
@@ -6,44 +6,16 @@ vi.mock('next/cache', () => ({
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(),
|
||||
createServiceClient: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
setActiveCompany: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
import { createClient, createServiceClient } from '@/lib/supabase/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { createCompanyFromOnboarding } from '../actions'
|
||||
|
||||
const mockCreateClient = vi.mocked(createClient)
|
||||
const mockCreateServiceClient = vi.mocked(createServiceClient)
|
||||
|
||||
/**
|
||||
* Build a service-role client mock. Seed `existingOrgNumber` when you want
|
||||
* the duplicate-org guard in createCompanyFromOnboarding to find a match.
|
||||
* Any other service-role query resolves to `{ data: null, error: null }`.
|
||||
*/
|
||||
function mockServiceClientForOrgNumber(existingOrgNumber?: string) {
|
||||
const serviceFrom = vi.fn().mockImplementation(() => {
|
||||
const chain: Record<string, unknown> = {}
|
||||
const methods = ['select', 'eq', 'is', 'in', 'order', 'limit', 'maybeSingle']
|
||||
for (const m of methods) {
|
||||
chain[m] = () => {
|
||||
if (m === 'maybeSingle') {
|
||||
return Promise.resolve({
|
||||
data: existingOrgNumber ? { id: 'other-company', name: 'Other AB' } : null,
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
return chain
|
||||
}
|
||||
}
|
||||
chain.then = (resolve: (v: unknown) => void) => resolve({ data: null, error: null })
|
||||
return chain
|
||||
})
|
||||
mockCreateServiceClient.mockReturnValue({ from: serviceFrom } as never)
|
||||
}
|
||||
|
||||
type CapturedCall = { table: string; method: string; args: unknown[] }
|
||||
|
||||
@@ -106,101 +78,9 @@ function buildSupabase(opts: {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Default: no existing company with this org_number. Individual tests can
|
||||
// override by calling mockServiceClientForOrgNumber('...') inside the test.
|
||||
mockServiceClientForOrgNumber(undefined)
|
||||
})
|
||||
|
||||
describe('createCompanyFromOnboarding — duplicate org_number guard', () => {
|
||||
it('refuses to create a company when the org number already exists', async () => {
|
||||
const { supabase, calls } = buildSupabase({
|
||||
user: { id: 'user-1' },
|
||||
rpcResults: {
|
||||
create_company_with_owner: { data: 'should-not-be-called' },
|
||||
},
|
||||
})
|
||||
mockCreateClient.mockResolvedValue(supabase as never)
|
||||
mockServiceClientForOrgNumber('5560125790') // pretend this org is already taken
|
||||
|
||||
const result = await createCompanyFromOnboarding({
|
||||
teamId: 'team-1',
|
||||
settings: {
|
||||
entity_type: 'aktiebolag',
|
||||
company_name: 'Acme AB',
|
||||
org_number: '5560125790',
|
||||
},
|
||||
fiscalPeriod: {
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2026-12-31',
|
||||
name: 'Räkenskapsår 2026',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.error).toBe('org_number_exists')
|
||||
expect(result.companyId).toBeUndefined()
|
||||
|
||||
// Guard must short-circuit before the create RPC runs — otherwise we'd
|
||||
// leave a ghost company behind when the duplicate is detected.
|
||||
const rpcCreate = supabase.rpc.mock.calls.find(([name]) => name === 'create_company_with_owner')
|
||||
expect(rpcCreate).toBeUndefined()
|
||||
// And no company_settings upsert should have happened.
|
||||
expect(calls.find((c) => c.table === 'company_settings' && c.method === 'upsert')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('tolerates formatted org_numbers when detecting duplicates (hyphens/spaces stripped)', async () => {
|
||||
const { supabase } = buildSupabase({
|
||||
user: { id: 'user-1' },
|
||||
rpcResults: { create_company_with_owner: { data: 'x' } },
|
||||
})
|
||||
mockCreateClient.mockResolvedValue(supabase as never)
|
||||
mockServiceClientForOrgNumber('5560125790')
|
||||
|
||||
const result = await createCompanyFromOnboarding({
|
||||
teamId: 'team-1',
|
||||
settings: {
|
||||
entity_type: 'aktiebolag',
|
||||
company_name: 'Acme AB',
|
||||
// User-typed format — the guard should still catch this as a duplicate.
|
||||
org_number: '556677-8899',
|
||||
},
|
||||
fiscalPeriod: {
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2026-12-31',
|
||||
name: 'Räkenskapsår 2026',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.error).toBe('org_number_exists')
|
||||
})
|
||||
|
||||
it('normalizes 12-digit personnummer input down to the 10-digit canonical form', async () => {
|
||||
const { supabase } = buildSupabase({
|
||||
user: { id: 'user-1' },
|
||||
rpcResults: { create_company_with_owner: { data: 'x' } },
|
||||
})
|
||||
mockCreateClient.mockResolvedValue(supabase as never)
|
||||
// The existing company is stored as the 10-digit canonical form.
|
||||
mockServiceClientForOrgNumber('8001011231')
|
||||
|
||||
const result = await createCompanyFromOnboarding({
|
||||
teamId: 'team-1',
|
||||
settings: {
|
||||
entity_type: 'enskild_firma',
|
||||
company_name: 'Anna EF',
|
||||
// User types full 12-digit personnummer with century prefix.
|
||||
org_number: '19800101-1231',
|
||||
},
|
||||
fiscalPeriod: {
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2026-12-31',
|
||||
name: 'Räkenskapsår 2026',
|
||||
},
|
||||
})
|
||||
|
||||
// Should detect the duplicate despite the 12-digit input.
|
||||
expect(result.error).toBe('org_number_exists')
|
||||
})
|
||||
|
||||
describe('createCompanyFromOnboarding — org_number validation', () => {
|
||||
it('rejects malformed org_numbers at the guard boundary', async () => {
|
||||
const { supabase } = buildSupabase({
|
||||
user: { id: 'user-1' },
|
||||
@@ -258,51 +138,6 @@ describe('createCompanyFromOnboarding — duplicate org_number guard', () => {
|
||||
const rpcCreate = supabase.rpc.mock.calls.find(([name]) => name === 'create_company_with_owner')
|
||||
expect(rpcCreate).toBeUndefined()
|
||||
})
|
||||
|
||||
it('fails closed when the duplicate lookup errors out (does not silently allow duplicates)', async () => {
|
||||
const { supabase } = buildSupabase({
|
||||
user: { id: 'user-1' },
|
||||
rpcResults: { create_company_with_owner: { data: 'x' } },
|
||||
})
|
||||
mockCreateClient.mockResolvedValue(supabase as never)
|
||||
|
||||
// Seed a service client that errors on maybeSingle — simulating a DB
|
||||
// outage or RLS misconfiguration.
|
||||
mockCreateServiceClient.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
select: vi.fn().mockReturnThis(),
|
||||
eq: vi.fn().mockReturnThis(),
|
||||
is: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
maybeSingle: vi.fn().mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: 'connection lost' },
|
||||
}),
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any)
|
||||
|
||||
const result = await createCompanyFromOnboarding({
|
||||
teamId: 'team-1',
|
||||
settings: {
|
||||
entity_type: 'aktiebolag',
|
||||
company_name: 'Acme AB',
|
||||
org_number: '5560125790',
|
||||
},
|
||||
fiscalPeriod: {
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2026-12-31',
|
||||
name: 'Räkenskapsår 2026',
|
||||
},
|
||||
})
|
||||
|
||||
// Must return a user-facing error, NOT silently proceed with creation.
|
||||
expect(result.companyId).toBeUndefined()
|
||||
expect(result.error).toBeTruthy()
|
||||
// And the create RPC must not have been called.
|
||||
const rpcCreate = supabase.rpc.mock.calls.find(([name]) => name === 'create_company_with_owner')
|
||||
expect(rpcCreate).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createCompanyFromOnboarding — TIC snapshot persistence', () => {
|
||||
|
||||
+6
-50
@@ -1,41 +1,11 @@
|
||||
'use server'
|
||||
|
||||
import { createClient, createServiceClient } from '@/lib/supabase/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { setActiveCompany } from '@/lib/company/context'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
|
||||
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
|
||||
|
||||
/**
|
||||
* Check whether an org number is already registered in any non-archived
|
||||
* Accounted company. Uses the service role because RLS hides rows the caller
|
||||
* isn't a member of — and "other users' duplicates" is exactly what we
|
||||
* need to detect. Returns null when `orgNumber` is empty/malformed. Throws
|
||||
* if the underlying query fails — callers must not silently treat that as
|
||||
* "no duplicate," or the whole guard gets bypassed on transient DB errors.
|
||||
*/
|
||||
async function findExistingCompanyByOrgNumber(
|
||||
orgNumber: string | null | undefined,
|
||||
): Promise<{ id: string; name: string } | null> {
|
||||
const cleaned = normalizeOrgNumber(orgNumber)
|
||||
if (!cleaned) return null
|
||||
|
||||
const service = createServiceClient()
|
||||
const { data, error } = await service
|
||||
.from('companies')
|
||||
.select('id, name')
|
||||
.eq('org_number', cleaned)
|
||||
.is('archived_at', null)
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Duplicate-org lookup failed: ${error.message}`)
|
||||
}
|
||||
|
||||
return data ?? null
|
||||
}
|
||||
|
||||
export async function switchCompany(companyId: string): Promise<{ error?: string }> {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
@@ -113,11 +83,11 @@ async function createCompanyFromOnboardingImpl(params: {
|
||||
|
||||
const companyName = (params.settings.company_name as string | undefined) || 'Mitt företag'
|
||||
|
||||
// Duplicate-org guard. We don't have a DB unique constraint on
|
||||
// companies.org_number (can't add one safely without cleaning up any
|
||||
// existing duplicates first), so enforce uniqueness at the application
|
||||
// boundary. Must run before the create RPC so we don't leave a ghost
|
||||
// company if the duplicate is detected mid-flow.
|
||||
// Org-number format validation. We intentionally do NOT enforce
|
||||
// uniqueness: the same org number may legitimately appear on multiple
|
||||
// companies (a separate test copy of your real company, or a consultant
|
||||
// and the owner each tracking the same entity). Tenant isolation
|
||||
// (RLS + company_id) is the real boundary — not org-number uniqueness.
|
||||
//
|
||||
// normalizeOrgNumber returns null for malformed input — we refuse rather
|
||||
// than storing a value that would break SIE/SRU exports later.
|
||||
@@ -126,20 +96,6 @@ async function createCompanyFromOnboardingImpl(params: {
|
||||
if (rawOrgNumber && rawOrgNumber.trim() && !cleanedOrgNumber) {
|
||||
return { error: 'org_number_invalid' }
|
||||
}
|
||||
if (cleanedOrgNumber) {
|
||||
try {
|
||||
const existing = await findExistingCompanyByOrgNumber(cleanedOrgNumber)
|
||||
if (existing) {
|
||||
return { error: 'org_number_exists' }
|
||||
}
|
||||
} catch (err) {
|
||||
// Guard must fail closed: if we can't confirm uniqueness, don't create
|
||||
// a company. A silent pass-through would let transient DB errors
|
||||
// through as duplicates (exactly the bug Greptile flagged).
|
||||
console.error('[createCompanyFromOnboarding] duplicate-org lookup failed', err)
|
||||
return { error: 'Kunde inte verifiera organisationsnummer. Försök igen.' }
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Create company + owner membership atomically via RPC
|
||||
const { data: newCompanyId, error: companyError } = await supabase.rpc('create_company_with_owner', {
|
||||
|
||||
@@ -534,9 +534,9 @@ const MATCH_SI: Record<string, StructuredErrorEntry> = {
|
||||
MATCH_SI_CASH_FX_UNSUPPORTED: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Kontantmetoden stödjer inte valutakursdifferenser. Byt till löpande bokföring eller bokför valutakursdifferensen manuellt.',
|
||||
'Kontantmetoden kan inte dela upp en delbetalning i utländsk valuta. Betala hela fakturan på en gång, byt till löpande bokföring eller bokför betalningen manuellt.',
|
||||
message_en:
|
||||
'Cash accounting does not support exchange-rate differences. Switch to accrual or book the FX difference manually.',
|
||||
'The cash method cannot handle a partial foreign-currency payment. Pay the invoice in full, switch to accrual, or book the payment manually.',
|
||||
},
|
||||
MATCH_SI_AMOUNT_EXCEEDS_REMAINING: {
|
||||
httpStatus: 400,
|
||||
@@ -1276,6 +1276,13 @@ const PROVIDER_MIGRATION: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'SIE-export stöds för närvarande endast för Fortnox.',
|
||||
message_en: 'SIE export is currently only supported for Fortnox.',
|
||||
},
|
||||
PROVIDER_SIE_IMPORT_REQUIRED: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'Bokföringsdata (SIE) måste importeras först. Ladda upp en SIE-fil med kontoplan, ingående balanser och verifikationer innan du hämtar kunder, leverantörer och fakturor från den här leverantören.',
|
||||
message_en:
|
||||
'A completed SIE import is required first. Import the SIE file (chart of accounts, opening balances and verifications) before importing customers, suppliers and invoices from this provider.',
|
||||
},
|
||||
PROVIDER_MIGRATE_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Migrationen från leverantören misslyckades.',
|
||||
|
||||
@@ -86,4 +86,34 @@ describe('ensureInvoiceNumber', () => {
|
||||
ensureInvoiceNumber(supabase as never, 'company-1', invoice)
|
||||
).rejects.toThrow('no value returned')
|
||||
})
|
||||
|
||||
it('returns the external number for a self-billed invoice without touching the RPC', async () => {
|
||||
// A received self-billing invoice carries the counterparty's number; we must
|
||||
// never consume our own löpnummerserie (BFL 5 kap 6§).
|
||||
const invoice = {
|
||||
id: 'inv-1',
|
||||
invoice_number: null,
|
||||
is_self_billed: true,
|
||||
external_invoice_number: 'KUND-55012',
|
||||
}
|
||||
|
||||
const result = await ensureInvoiceNumber(supabase as never, 'company-1', invoice)
|
||||
|
||||
expect(result).toBe('KUND-55012')
|
||||
expect(supabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws when a self-billed invoice is missing the external number', async () => {
|
||||
const invoice = {
|
||||
id: 'inv-1',
|
||||
invoice_number: null,
|
||||
is_self_billed: true,
|
||||
external_invoice_number: null,
|
||||
}
|
||||
|
||||
await expect(
|
||||
ensureInvoiceNumber(supabase as never, 'company-1', invoice)
|
||||
).rejects.toThrow('missing external_invoice_number')
|
||||
expect(supabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,3 +3,16 @@ export const INVOICE_NUMBER_DRAFT_LABEL = '(Utkast)'
|
||||
export function invoiceNumberDisplay(value: string | null | undefined): string {
|
||||
return value ?? INVOICE_NUMBER_DRAFT_LABEL
|
||||
}
|
||||
|
||||
/**
|
||||
* The number to show for an invoice. Self-billing invoices we received carry
|
||||
* the counterparty's number in `external_invoice_number` (our own
|
||||
* `invoice_number` is null by design), so fall back to it before the draft
|
||||
* label.
|
||||
*/
|
||||
export function invoiceDisplayNumber(invoice: {
|
||||
invoice_number?: string | null
|
||||
external_invoice_number?: string | null
|
||||
}): string {
|
||||
return invoice.invoice_number ?? invoice.external_invoice_number ?? INVOICE_NUMBER_DRAFT_LABEL
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { Invoice, InvoiceDocumentType } from '@/types'
|
||||
type InvoiceShape = Pick<Invoice, 'id' | 'invoice_number'> & {
|
||||
invoice_number: string | null
|
||||
document_type?: InvoiceDocumentType | null
|
||||
is_self_billed?: boolean | null
|
||||
external_invoice_number?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,6 +26,17 @@ export async function ensureInvoiceNumber(
|
||||
return invoice.invoice_number
|
||||
}
|
||||
|
||||
// Self-billing invoices we received carry the COUNTERPARTY's number; we must
|
||||
// never consume our own löpnummerserie for them (BFL 5 kap 6§). The DB
|
||||
// constraint invoices_self_billed_numbering guarantees the external number is
|
||||
// present, but guard here so a future caller can't silently mint an F-number.
|
||||
if (invoice.is_self_billed) {
|
||||
if (!invoice.external_invoice_number) {
|
||||
throw new Error('Self-billed invoice is missing external_invoice_number')
|
||||
}
|
||||
return invoice.external_invoice_number
|
||||
}
|
||||
|
||||
const { data: assigned, error: rpcError } = await supabase.rpc('generate_invoice_number', {
|
||||
p_company_id: companyId,
|
||||
p_invoice_id: invoice.id,
|
||||
|
||||
@@ -129,7 +129,9 @@ export async function generateARLedger(
|
||||
// Add invoice detail (always — even if unconvertible, so it's visible)
|
||||
entry.invoices.push({
|
||||
invoice_id: inv.id,
|
||||
invoice_number: inv.invoice_number || '',
|
||||
// Self-billing invoices we received have no own number — show the
|
||||
// counterparty's external number instead.
|
||||
invoice_number: inv.invoice_number || inv.external_invoice_number || '',
|
||||
invoice_date: inv.invoice_date || '',
|
||||
due_date: inv.due_date,
|
||||
total,
|
||||
|
||||
+72
-7
@@ -793,8 +793,6 @@
|
||||
"toast_invalid_fiscal_year": "Invalid fiscal year",
|
||||
"toast_error_title": "Error",
|
||||
"toast_create_failed": "Could not create company. Please try again.",
|
||||
"toast_company_exists_title": "Company already exists",
|
||||
"toast_company_exists_description": "This company already exists in {appName}. Ask an existing administrator to invite you.",
|
||||
"toast_org_invalid_title": "Invalid organisation number",
|
||||
"toast_org_invalid_description": "Check that you entered a valid 10- or 12-digit organisation number.",
|
||||
"toast_welcome_title": "Welcome!",
|
||||
@@ -820,7 +818,7 @@
|
||||
"step2_lookup_not_found": "No company found with that organisation number.",
|
||||
"step2_lookup_failed": "Could not fetch company details. You can fill them in manually.",
|
||||
"step2_ceased_inline": "{companyName} — the company is deregistered",
|
||||
"step2_company_exists": "This company already exists in {appName}. Ask an existing administrator to invite you.",
|
||||
"step2_company_exists_own": "You already have {name} with this organisation number. You can still continue.",
|
||||
"step2_company_name_ab": "Company name *",
|
||||
"step2_company_name_ef": "Business name (or your name for EF) *",
|
||||
"step2_company_name_placeholder_ab": "AB Företaget",
|
||||
@@ -906,7 +904,6 @@
|
||||
"section_your_companies": "Your companies in {appName}",
|
||||
"section_bankid_companies": "Companies linked to your BankID",
|
||||
"already_in_app": "Already in {appName}",
|
||||
"ask_admin_invite": "Ask an existing administrator to invite you.",
|
||||
"setup_manually": "Set up manually",
|
||||
"no_companies_found": "No companies found. Add your first company below.",
|
||||
"or_separator": "or",
|
||||
@@ -915,8 +912,6 @@
|
||||
"toast_lookup_failed_description": "Fill in the remaining details manually.",
|
||||
"toast_company_ceased_title": "The company is deregistered",
|
||||
"toast_company_ceased_description": "You cannot set up bookkeeping for a deregistered company.",
|
||||
"toast_company_exists_title": "Company already exists",
|
||||
"toast_company_exists_description": "Ask an existing administrator to invite you.",
|
||||
"toast_org_invalid_title": "Invalid organisation number",
|
||||
"toast_org_invalid_description": "Continue with manual setup.",
|
||||
"toast_create_failed_title": "Could not create company",
|
||||
@@ -2101,6 +2096,8 @@
|
||||
"vat_label_short": "VAT",
|
||||
"total_label": "Total",
|
||||
"review_and_create": "Review & create",
|
||||
"mode_invoice": "Invoice",
|
||||
"mode_self_billed": "Self-billing",
|
||||
"viewer_disabled_tooltip": "You only have read-only access to this company",
|
||||
"review_dialog_title_invoice": "Review invoice",
|
||||
"review_dialog_title_proforma": "Review proforma invoice",
|
||||
@@ -2209,6 +2206,9 @@
|
||||
"status_credited": "Credited",
|
||||
"badge_proforma": "Proforma",
|
||||
"badge_delivery_note": "Delivery note",
|
||||
"badge_self_billed": "Self-billing",
|
||||
"external_number_label": "Invoice number (customer's)",
|
||||
"agreement_ref_label": "Agreement reference",
|
||||
"created_at": "Created {date}",
|
||||
"sent_at_suffix": " • Sent {date}",
|
||||
"convert_to_invoice": "Convert to invoice",
|
||||
@@ -2892,7 +2892,22 @@
|
||||
"remove_blocked_cancel_cta": "Close",
|
||||
"replace_uploading": "Replacing...",
|
||||
"remove_failed": "Could not remove the document.",
|
||||
"replace_failed": "Could not upload new version."
|
||||
"replace_failed": "Could not upload new version.",
|
||||
"choose_from_inbox": "Choose from inbox",
|
||||
"picker_title": "Choose a document from the inbox",
|
||||
"picker_description": "Documents received by email or upload that haven't been used yet.",
|
||||
"picker_search_placeholder": "Search supplier or file name…",
|
||||
"picker_results": "{count}",
|
||||
"picker_empty": "No unused documents in the inbox.",
|
||||
"picker_empty_search": "No documents match \"{query}\".",
|
||||
"picker_source_email": "Email",
|
||||
"picker_source_upload": "Upload",
|
||||
"picker_linked": "Document attached.",
|
||||
"picker_link_failed": "Could not attach the document.",
|
||||
"picker_preview": "Preview",
|
||||
"picker_attach": "Attach document",
|
||||
"picker_close": "Close",
|
||||
"picker_preview_unavailable": "Preview could not be displayed."
|
||||
},
|
||||
"journal_status": {
|
||||
"status_draft": "Draft",
|
||||
@@ -3567,10 +3582,59 @@
|
||||
"ref_transfer_rollback": "Rollback",
|
||||
"ref_manual": "Manual"
|
||||
},
|
||||
"self_billing": {
|
||||
"title": "Register self-billing invoice",
|
||||
"subtitle": "A self-billing invoice you received — the customer invoiced in your name. For you it is a sale with output VAT.",
|
||||
"back": "Back",
|
||||
"issuer_card_title": "Issuer and invoice reference",
|
||||
"issuer_card_description": "The customer who issued the self-billing invoice, and the number they assigned.",
|
||||
"customer_label": "Customer (issuer)",
|
||||
"select_customer_placeholder": "Select customer",
|
||||
"external_number_label": "Invoice number (customer's)",
|
||||
"external_number_placeholder": "e.g. SF-2026-014",
|
||||
"agreement_ref_label": "Agreement reference",
|
||||
"agreement_ref_placeholder": "Self-billing agreement",
|
||||
"items_card_title": "Lines",
|
||||
"items_card_description": "The amounts from the received self-billing invoice.",
|
||||
"description_label": "Description",
|
||||
"description_placeholder": "Description",
|
||||
"quantity_label": "Qty",
|
||||
"unit_label": "Unit",
|
||||
"unit_price_label": "Unit price",
|
||||
"vat_label": "VAT",
|
||||
"row_label": "Row {index}",
|
||||
"add_row": "Add row",
|
||||
"notes_card_title": "Notes",
|
||||
"notes_placeholder": "Internal notes (optional)",
|
||||
"details_card_title": "Details",
|
||||
"currency_label": "Currency",
|
||||
"invoice_date_label": "Invoice date",
|
||||
"received_date_label": "Received date",
|
||||
"due_date_label": "Due date",
|
||||
"summary_card_title": "Summary",
|
||||
"subtotal_label": "Net",
|
||||
"output_vat_label": "Output VAT",
|
||||
"total_label": "Total",
|
||||
"register": "Register self-billing invoice",
|
||||
"viewer_disabled_tooltip": "You only have viewer access in this company",
|
||||
"load_customers_failed": "Could not load customers",
|
||||
"created_title": "Self-billing invoice registered",
|
||||
"created_description": "Self-billing invoice {number} has been booked as a sale.",
|
||||
"create_failed_title": "Could not register the self-billing invoice",
|
||||
"validation_customer_required": "Select a customer",
|
||||
"validation_external_number_required": "Invoice number is required",
|
||||
"validation_invoice_date_required": "Invoice date is required",
|
||||
"validation_received_date_required": "Received date is required",
|
||||
"validation_due_date_required": "Due date is required",
|
||||
"validation_description_required": "Description is required",
|
||||
"validation_quantity_min": "Quantity must be at least 0.01",
|
||||
"validation_min_one_row": "At least one row is required"
|
||||
},
|
||||
"invoices": {
|
||||
"title": "Invoices",
|
||||
"recurring": "Recurring",
|
||||
"new_invoice": "New invoice",
|
||||
"new_self_billed": "Self-billing",
|
||||
"viewer_disabled_tooltip": "You only have viewer access in this company",
|
||||
"load_failed_title": "Could not load invoices",
|
||||
"load_failed_description": "Check your connection and try again.",
|
||||
@@ -3595,6 +3659,7 @@
|
||||
"badge_credit": "Credit",
|
||||
"badge_proforma": "Proforma",
|
||||
"badge_delivery_note": "Delivery note",
|
||||
"badge_self_billed": "Self-billing",
|
||||
"status_draft": "Draft",
|
||||
"status_sent": "Sent",
|
||||
"status_paid": "Paid",
|
||||
|
||||
+72
-7
@@ -793,8 +793,6 @@
|
||||
"toast_invalid_fiscal_year": "Ogiltigt räkenskapsår",
|
||||
"toast_error_title": "Fel",
|
||||
"toast_create_failed": "Kunde inte skapa företag. Försök igen.",
|
||||
"toast_company_exists_title": "Företaget finns redan",
|
||||
"toast_company_exists_description": "Det här företaget finns redan i {appName}. Be en befintlig administratör att bjuda in dig.",
|
||||
"toast_org_invalid_title": "Ogiltigt organisationsnummer",
|
||||
"toast_org_invalid_description": "Kontrollera att du angett ett giltigt 10- eller 12-siffrigt organisationsnummer.",
|
||||
"toast_welcome_title": "Välkommen!",
|
||||
@@ -820,7 +818,7 @@
|
||||
"step2_lookup_not_found": "Inget företag hittades med det organisationsnumret.",
|
||||
"step2_lookup_failed": "Kunde inte hämta företagsuppgifter. Du kan fylla i manuellt.",
|
||||
"step2_ceased_inline": "{companyName} — företaget är avregistrerat",
|
||||
"step2_company_exists": "Det här företaget finns redan i {appName}. Be en befintlig administratör att bjuda in dig.",
|
||||
"step2_company_exists_own": "Du har redan företaget {name} med detta organisationsnummer. Du kan ändå fortsätta.",
|
||||
"step2_company_name_ab": "Företagsnamn *",
|
||||
"step2_company_name_ef": "Verksamhetsnamn (Eller ditt namn vid EF) *",
|
||||
"step2_company_name_placeholder_ab": "AB Företaget",
|
||||
@@ -906,7 +904,6 @@
|
||||
"section_your_companies": "Dina företag i {appName}",
|
||||
"section_bankid_companies": "Företag kopplade till ditt BankID",
|
||||
"already_in_app": "Finns redan i {appName}",
|
||||
"ask_admin_invite": "Be en befintlig administratör att bjuda in dig.",
|
||||
"setup_manually": "Sätts upp manuellt",
|
||||
"no_companies_found": "Inga företag hittades. Lägg till ditt första företag nedan.",
|
||||
"or_separator": "eller",
|
||||
@@ -915,8 +912,6 @@
|
||||
"toast_lookup_failed_description": "Fyll i resterande uppgifter manuellt.",
|
||||
"toast_company_ceased_title": "Företaget är avregistrerat",
|
||||
"toast_company_ceased_description": "Det går inte att sätta upp bokföring för ett avregistrerat företag.",
|
||||
"toast_company_exists_title": "Företaget finns redan",
|
||||
"toast_company_exists_description": "Be en befintlig administratör att bjuda in dig.",
|
||||
"toast_org_invalid_title": "Ogiltigt organisationsnummer",
|
||||
"toast_org_invalid_description": "Fortsätt med manuell uppsättning.",
|
||||
"toast_create_failed_title": "Kunde inte skapa företag",
|
||||
@@ -2101,6 +2096,8 @@
|
||||
"vat_label_short": "Moms",
|
||||
"total_label": "Totalt",
|
||||
"review_and_create": "Granska & skapa",
|
||||
"mode_invoice": "Faktura",
|
||||
"mode_self_billed": "Självfaktura",
|
||||
"viewer_disabled_tooltip": "Du har endast läsbehörighet i detta företag",
|
||||
"review_dialog_title_invoice": "Granska faktura",
|
||||
"review_dialog_title_proforma": "Granska proformafaktura",
|
||||
@@ -2209,6 +2206,9 @@
|
||||
"status_credited": "Krediterad",
|
||||
"badge_proforma": "Proforma",
|
||||
"badge_delivery_note": "Följesedel",
|
||||
"badge_self_billed": "Självfaktura",
|
||||
"external_number_label": "Fakturanummer (kundens)",
|
||||
"agreement_ref_label": "Avtalsreferens",
|
||||
"created_at": "Skapad {date}",
|
||||
"sent_at_suffix": " • Skickad {date}",
|
||||
"convert_to_invoice": "Konvertera till faktura",
|
||||
@@ -2892,7 +2892,22 @@
|
||||
"remove_blocked_cancel_cta": "Stäng",
|
||||
"replace_uploading": "Ersätter...",
|
||||
"remove_failed": "Kunde inte ta bort underlaget.",
|
||||
"replace_failed": "Kunde inte ladda upp ny version."
|
||||
"replace_failed": "Kunde inte ladda upp ny version.",
|
||||
"choose_from_inbox": "Välj från inkorgen",
|
||||
"picker_title": "Välj underlag från inkorgen",
|
||||
"picker_description": "Underlag som kommit in via e-post eller uppladdning och ännu inte använts.",
|
||||
"picker_search_placeholder": "Sök leverantör eller filnamn…",
|
||||
"picker_results": "{count} st",
|
||||
"picker_empty": "Inga oanvända underlag i inkorgen.",
|
||||
"picker_empty_search": "Inga underlag matchar \"{query}\".",
|
||||
"picker_source_email": "E-post",
|
||||
"picker_source_upload": "Uppladdning",
|
||||
"picker_linked": "Underlag kopplat.",
|
||||
"picker_link_failed": "Kunde inte koppla underlaget.",
|
||||
"picker_preview": "Förhandsgranska",
|
||||
"picker_attach": "Koppla underlag",
|
||||
"picker_close": "Stäng",
|
||||
"picker_preview_unavailable": "Förhandsgranskning kunde inte visas."
|
||||
},
|
||||
"journal_status": {
|
||||
"status_draft": "Utkast",
|
||||
@@ -3567,10 +3582,59 @@
|
||||
"ref_transfer_rollback": "Återställning",
|
||||
"ref_manual": "Manuell"
|
||||
},
|
||||
"self_billing": {
|
||||
"title": "Registrera självfaktura",
|
||||
"subtitle": "En självfaktura du tagit emot — kunden har fakturerat i ditt namn. För dig är det en försäljning med utgående moms.",
|
||||
"back": "Tillbaka",
|
||||
"issuer_card_title": "Utställare och fakturareferens",
|
||||
"issuer_card_description": "Kunden som ställt ut självfakturan, och fakturanumret de tilldelat.",
|
||||
"customer_label": "Kund (utställare)",
|
||||
"select_customer_placeholder": "Välj kund",
|
||||
"external_number_label": "Fakturanummer (kundens)",
|
||||
"external_number_placeholder": "t.ex. SF-2026-014",
|
||||
"agreement_ref_label": "Avtalsreferens",
|
||||
"agreement_ref_placeholder": "Självfaktureringsavtal",
|
||||
"items_card_title": "Rader",
|
||||
"items_card_description": "Beloppen från den mottagna självfakturan.",
|
||||
"description_label": "Beskrivning",
|
||||
"description_placeholder": "Beskrivning",
|
||||
"quantity_label": "Antal",
|
||||
"unit_label": "Enhet",
|
||||
"unit_price_label": "À-pris",
|
||||
"vat_label": "Moms",
|
||||
"row_label": "Rad {index}",
|
||||
"add_row": "Lägg till rad",
|
||||
"notes_card_title": "Anteckningar",
|
||||
"notes_placeholder": "Interna anteckningar (valfritt)",
|
||||
"details_card_title": "Uppgifter",
|
||||
"currency_label": "Valuta",
|
||||
"invoice_date_label": "Fakturadatum",
|
||||
"received_date_label": "Mottaget datum",
|
||||
"due_date_label": "Förfallodatum",
|
||||
"summary_card_title": "Sammanfattning",
|
||||
"subtotal_label": "Netto",
|
||||
"output_vat_label": "Utgående moms",
|
||||
"total_label": "Totalt",
|
||||
"register": "Registrera självfaktura",
|
||||
"viewer_disabled_tooltip": "Du har endast läsbehörighet i detta företag",
|
||||
"load_customers_failed": "Kunde inte ladda kunder",
|
||||
"created_title": "Självfaktura registrerad",
|
||||
"created_description": "Självfaktura {number} har bokförts som försäljning.",
|
||||
"create_failed_title": "Kunde inte registrera självfakturan",
|
||||
"validation_customer_required": "Välj en kund",
|
||||
"validation_external_number_required": "Fakturanummer krävs",
|
||||
"validation_invoice_date_required": "Fakturadatum krävs",
|
||||
"validation_received_date_required": "Mottaget datum krävs",
|
||||
"validation_due_date_required": "Förfallodatum krävs",
|
||||
"validation_description_required": "Beskrivning krävs",
|
||||
"validation_quantity_min": "Antal måste vara minst 0,01",
|
||||
"validation_min_one_row": "Minst en rad krävs"
|
||||
},
|
||||
"invoices": {
|
||||
"title": "Fakturor",
|
||||
"recurring": "Återkommande",
|
||||
"new_invoice": "Ny faktura",
|
||||
"new_self_billed": "Självfaktura",
|
||||
"viewer_disabled_tooltip": "Du har endast läsbehörighet i detta företag",
|
||||
"load_failed_title": "Kunde inte ladda fakturor",
|
||||
"load_failed_description": "Kontrollera din anslutning och försök igen.",
|
||||
@@ -3595,6 +3659,7 @@
|
||||
"badge_credit": "Kredit",
|
||||
"badge_proforma": "Proforma",
|
||||
"badge_delivery_note": "Följesedel",
|
||||
"badge_self_billed": "Självfaktura",
|
||||
"status_draft": "Utkast",
|
||||
"status_sent": "Skickad",
|
||||
"status_paid": "Betald",
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
-- Migration: drop the rogue create_company_with_owner(...p_business_profile)
|
||||
-- overload that reintroduced PGRST203 ambiguity.
|
||||
--
|
||||
-- Background: the canonical signature (per 20260519180000_enforce_team_
|
||||
-- membership_in_create_company.sql) is the 4-arg form:
|
||||
-- create_company_with_owner(p_name text, p_entity_type text,
|
||||
-- p_set_active boolean DEFAULT true,
|
||||
-- p_team_id uuid DEFAULT NULL)
|
||||
--
|
||||
-- A 5-arg variant with a trailing `p_business_profile text` was created on
|
||||
-- some database(s) outside the tracked migrations. A CREATE OR REPLACE with a
|
||||
-- changed signature creates a *second* function rather than replacing it --
|
||||
-- the exact failure mode fixed once before in
|
||||
-- 20260519170000_fix_create_company_with_owner_overload.sql. Because both
|
||||
-- overloads are callable with the three named args the app passes (p_name,
|
||||
-- p_entity_type, p_team_id -- p_set_active and p_business_profile both have
|
||||
-- defaults), PostgREST cannot resolve the call and returns PGRST203, breaking
|
||||
-- company creation:
|
||||
-- "Could not choose the best candidate function between:
|
||||
-- create_company_with_owner(p_name, p_entity_type, p_set_active, p_team_id)
|
||||
-- create_company_with_owner(p_name, p_entity_type, p_set_active, p_team_id, p_business_profile)"
|
||||
--
|
||||
-- `business_profile` is not referenced anywhere in the codebase, so the 5-arg
|
||||
-- overload is dropped outright. IF EXISTS keeps this a safe no-op on databases
|
||||
-- that never acquired the orphan (e.g. production, which has only the 4-arg
|
||||
-- form).
|
||||
|
||||
DROP FUNCTION IF EXISTS public.create_company_with_owner(text, text, boolean, uuid, text);
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,79 @@
|
||||
-- Self-billing received (mottagna självfakturor) — ML 17 kap 15§
|
||||
--
|
||||
-- A self-billing invoice we RECEIVE is a SALE for us (we are the seller); our
|
||||
-- customer issues the invoice document on our behalf. For our books it is an
|
||||
-- ordinary customer invoice: it books revenue + OUTPUT VAT and we remain
|
||||
-- responsible for reporting that VAT. We model it as a flag on `invoices` so we
|
||||
-- reuse the whole customer-invoice stack (booking, AR ledger, VAT declaration,
|
||||
-- payment matching) instead of duplicating it on the supplier side (which would
|
||||
-- book the VAT on the wrong side entirely).
|
||||
--
|
||||
-- Two things must differ from a normal customer invoice:
|
||||
-- 1. The invoice number belongs to the CUSTOMER's series, not ours. We must
|
||||
-- not consume our own löpnummerserie (BFL 5 kap 6§). The counterparty's
|
||||
-- number lives in external_invoice_number; invoice_number stays NULL.
|
||||
-- 2. There is no send step — the document is received, so it is booked on
|
||||
-- registration.
|
||||
--
|
||||
-- Idempotent (IF NOT EXISTS / DROP-then-ADD) so it is safe to apply to a
|
||||
-- preview/staging branch ahead of the repo sync without colliding.
|
||||
|
||||
-- 1. Self-billing metadata --------------------------------------------------
|
||||
ALTER TABLE public.invoices
|
||||
ADD COLUMN IF NOT EXISTS is_self_billed boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS external_invoice_number text,
|
||||
ADD COLUMN IF NOT EXISTS self_billing_agreement_ref text,
|
||||
ADD COLUMN IF NOT EXISTS received_date date;
|
||||
|
||||
COMMENT ON COLUMN public.invoices.is_self_billed IS
|
||||
'True when this row is a self-billing invoice we received (ML 17 kap 15§). The counterparty issued it; for us it is a sale booked with output VAT.';
|
||||
COMMENT ON COLUMN public.invoices.external_invoice_number IS
|
||||
'The invoice number assigned by the customer (issuer) on a received self-billing invoice. Our own invoice_number stays NULL so we never touch our löpnummerserie.';
|
||||
COMMENT ON COLUMN public.invoices.self_billing_agreement_ref IS
|
||||
'Reference to the self-billing agreement (avtal i förväg) required by ML 17 kap 15§ p.1.';
|
||||
|
||||
-- 2. journal_entry_id -------------------------------------------------------
|
||||
-- Referenced by the send / mark-sent / mark-paid routes
|
||||
-- (invoices.update({ journal_entry_id }) and mark-paid's "already booked"
|
||||
-- detection) but never created by any migration, so those writes silently
|
||||
-- no-op on every database. Adding it here (IF NOT EXISTS — no-op where it was
|
||||
-- patched in by hand) makes the linkage real, and lets mark-paid recognise an
|
||||
-- already-booked self-billing sale and clear 1510 instead of re-recognising
|
||||
-- revenue (which would double-count under kontantmetoden).
|
||||
ALTER TABLE public.invoices
|
||||
ADD COLUMN IF NOT EXISTS journal_entry_id uuid
|
||||
REFERENCES public.journal_entries(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_journal_entry_id
|
||||
ON public.invoices (journal_entry_id) WHERE journal_entry_id IS NOT NULL;
|
||||
|
||||
-- 3. Numbering integrity ----------------------------------------------------
|
||||
-- A self-billed row carries the counterparty's number in external_invoice_number
|
||||
-- and never one from our own series in invoice_number.
|
||||
ALTER TABLE public.invoices DROP CONSTRAINT IF EXISTS invoices_self_billed_numbering;
|
||||
ALTER TABLE public.invoices
|
||||
ADD CONSTRAINT invoices_self_billed_numbering CHECK (
|
||||
NOT is_self_billed
|
||||
OR (external_invoice_number IS NOT NULL AND invoice_number IS NULL)
|
||||
);
|
||||
|
||||
-- 4. Loosen the sent-requires-number rule -----------------------------------
|
||||
-- 20260427150000 added: status IN ('draft','cancelled') OR invoice_number IS NOT NULL.
|
||||
-- A received self-billing invoice is 'sent' (booked, awaiting/with payment) yet
|
||||
-- legitimately has a NULL own number — its löpnummer is the customer's
|
||||
-- external_invoice_number, guaranteed present by invoices_self_billed_numbering.
|
||||
-- This preserves the ML 17 kap 24§ intent (every non-draft invoice carries a
|
||||
-- number, ours or the counterparty's).
|
||||
ALTER TABLE public.invoices DROP CONSTRAINT IF EXISTS invoices_sent_requires_number;
|
||||
ALTER TABLE public.invoices
|
||||
ADD CONSTRAINT invoices_sent_requires_number CHECK (
|
||||
status IN ('draft', 'cancelled')
|
||||
OR invoice_number IS NOT NULL
|
||||
OR is_self_billed
|
||||
);
|
||||
|
||||
-- 5. Reporting / list filter ------------------------------------------------
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_self_billed
|
||||
ON public.invoices (company_id, is_self_billed) WHERE is_self_billed;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,102 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getPool } from '@/tests/pg/setup'
|
||||
import { seedCompany } from '@/tests/pg/fixtures'
|
||||
|
||||
/**
|
||||
* Constraints added by 20260613100000_self_billing_received_invoices.sql:
|
||||
* - invoices_self_billed_numbering: a self-billed row carries the
|
||||
* counterparty's number in external_invoice_number and never an own one.
|
||||
* - invoices_sent_requires_number: loosened so a self-billed row may be 'sent'
|
||||
* with a NULL own invoice_number (its löpnummer is the external number).
|
||||
*/
|
||||
describe('self-billing invoice constraints', () => {
|
||||
async function insertSelfBilled(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
status?: string
|
||||
invoiceNumber?: string | null
|
||||
externalNumber?: string | null
|
||||
isSelfBilled?: boolean
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
const customerId = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.customers (id, user_id, company_id, name)
|
||||
VALUES ($1, $2, $3, 'Stora Bolaget AB')`,
|
||||
[customerId, params.userId, params.companyId],
|
||||
)
|
||||
await getPool().query(
|
||||
`INSERT INTO public.invoices
|
||||
(id, user_id, company_id, customer_id, invoice_number,
|
||||
is_self_billed, external_invoice_number, received_date,
|
||||
invoice_date, due_date, currency, subtotal, vat_amount, total,
|
||||
vat_treatment, vat_rate, moms_ruta, status)
|
||||
VALUES ($1, $2, $3, $4, $5,
|
||||
$6, $7, '2026-06-02',
|
||||
'2026-06-01', '2026-06-30', 'SEK', 10000, 2500, 12500,
|
||||
'standard_25', 25, '05', $8)`,
|
||||
[
|
||||
id,
|
||||
params.userId,
|
||||
params.companyId,
|
||||
customerId,
|
||||
params.invoiceNumber ?? null,
|
||||
params.isSelfBilled ?? true,
|
||||
params.externalNumber ?? null,
|
||||
params.status ?? 'sent',
|
||||
],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
it('accepts a self-billed sale: external number set, own number null, status sent', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
|
||||
const id = await insertSelfBilled({
|
||||
userId,
|
||||
companyId,
|
||||
externalNumber: 'KUND-55012',
|
||||
invoiceNumber: null,
|
||||
status: 'sent',
|
||||
})
|
||||
|
||||
const { rows } = await getPool().query<{ is_self_billed: boolean; external_invoice_number: string }>(
|
||||
'SELECT is_self_billed, external_invoice_number FROM public.invoices WHERE id = $1',
|
||||
[id],
|
||||
)
|
||||
expect(rows[0]!.is_self_billed).toBe(true)
|
||||
expect(rows[0]!.external_invoice_number).toBe('KUND-55012')
|
||||
})
|
||||
|
||||
it('rejects a self-billed row that also carries an own invoice_number', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
|
||||
await expect(
|
||||
insertSelfBilled({ userId, companyId, externalNumber: 'KUND-55012', invoiceNumber: 'F-2026001' }),
|
||||
).rejects.toThrow(/invoices_self_billed_numbering/i)
|
||||
})
|
||||
|
||||
it('rejects a self-billed row with no external_invoice_number', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
|
||||
await expect(
|
||||
insertSelfBilled({ userId, companyId, externalNumber: null, invoiceNumber: null }),
|
||||
).rejects.toThrow(/invoices_self_billed_numbering/i)
|
||||
})
|
||||
|
||||
it('still rejects a NON-self-billed sent invoice with a NULL number', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
|
||||
await expect(
|
||||
insertSelfBilled({
|
||||
userId,
|
||||
companyId,
|
||||
isSelfBilled: false,
|
||||
externalNumber: null,
|
||||
invoiceNumber: null,
|
||||
status: 'sent',
|
||||
}),
|
||||
).rejects.toThrow(/invoices_sent_requires_number/i)
|
||||
})
|
||||
})
|
||||
@@ -762,6 +762,21 @@ export interface Invoice {
|
||||
// Conversion tracking (proforma -> invoice)
|
||||
converted_from_id: string | null
|
||||
|
||||
// Self-billing received (mottagen självfaktura, ML 17 kap 15§). When
|
||||
// `is_self_billed` is true the customer issued the invoice on our behalf;
|
||||
// for us it is a sale. The counterparty's number lives in
|
||||
// `external_invoice_number` and our own `invoice_number` stays null so we
|
||||
// never consume our löpnummerserie (BFL 5 kap 6§).
|
||||
is_self_billed?: boolean
|
||||
external_invoice_number?: string | null
|
||||
self_billing_agreement_ref?: string | null
|
||||
received_date?: string | null
|
||||
|
||||
// Verifikation produced when the invoice was booked (registration entry).
|
||||
// Lets the payment flow detect an already-booked sale and clear 1510 rather
|
||||
// than re-recognising revenue.
|
||||
journal_entry_id?: string | null
|
||||
|
||||
// Payment tracking
|
||||
paid_at: string | null
|
||||
paid_amount: number | null
|
||||
|
||||
Reference in New Issue
Block a user