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:
Mattsson
2026-06-04 13:14:57 +02:00
committed by GitHub
parent c1be9f15dd
commit 3e42fc6f32
45 changed files with 2675 additions and 529 deletions
+27 -12
View File
@@ -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>
+178 -56
View File
@@ -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>
+12 -3
View File
@@ -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)
})
})
+31 -25
View File
@@ -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')
})
})
+44 -1
View File
@@ -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)
})
})
+114
View File
@@ -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 })
})
+17 -4
View File
@@ -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()
})
})
+311
View File
@@ -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 },
)
-1
View File
@@ -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>
)
}
+1 -5
View File
@@ -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>
+1 -1
View File
@@ -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>
+12 -22
View File
@@ -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>
)
+14 -18
View File
@@ -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 ? (
+2 -1
View File
@@ -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 {
+13 -5
View File
@@ -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({
+28
View File
@@ -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
// ============================================================
+16 -3
View File
@@ -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,
+56 -8
View File
@@ -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)
+2 -167
View File
@@ -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
View File
@@ -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', {
+9 -2
View File
@@ -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()
})
})
+13
View File
@@ -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
}
+13
View File
@@ -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,
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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",
@@ -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';
+102
View File
@@ -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)
})
})
+15
View File
@@ -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