From 3e42fc6f323cbc43070579bec20cade77f26b1cb Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:14:57 +0200 Subject: [PATCH] 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 --- app/(dashboard)/invoices/[id]/page.tsx | 39 ++- app/(dashboard)/invoices/new/page.tsx | 234 ++++++++++--- app/(dashboard)/invoices/page.tsx | 15 +- .../check-org-number/__tests__/route.test.ts | 145 ++++---- app/api/company/check-org-number/route.ts | 56 +-- .../[id]/link/__tests__/route.test.ts | 132 +++++++ app/api/documents/[id]/link/route.ts | 45 ++- .../inbox-available/__tests__/route.test.ts | 127 +++++++ app/api/documents/inbox-available/route.ts | 114 ++++++ app/api/invoices/[id]/mark-sent/route.ts | 21 +- .../self-billed/__tests__/route.test.ts | 240 +++++++++++++ app/api/invoices/self-billed/route.ts | 311 +++++++++++++++++ app/api/sandbox/seed/route.ts | 1 - .../__tests__/route.test.ts | 69 +++- .../match-supplier-invoice/preview/route.ts | 33 +- .../[id]/match-supplier-invoice/route.ts | 69 ++-- .../[id]/match-supplier-invoice/route.ts | 39 ++- .../bookkeeping/InboxDocumentPicker.tsx | 329 ++++++++++++++++++ .../bookkeeping/JournalEntryAttachments.tsx | 39 ++- components/dashboard/WelcomeOnboarding.tsx | 6 +- .../general/ArcimMigrationWorkspace.tsx | 29 +- components/import/SIEUploadStep.tsx | 2 +- components/onboarding/BankIdCompanyPicker.tsx | 34 +- components/onboarding/Step2CompanyDetails.tsx | 32 +- components/reports/views/index.tsx | 3 +- .../transactions/InvoiceMatchDialog.tsx | 18 +- .../__tests__/migrate-guard.test.ts | 102 ++++++ extensions/general/arcim-migration/index.ts | 24 ++ lib/api/schemas.ts | 28 ++ .../supplier-invoice-entries.test.ts | 105 ++++++ lib/bookkeeping/invoice-entries.ts | 19 +- lib/bookkeeping/supplier-invoice-entries.ts | 64 +++- lib/company/__tests__/actions.test.ts | 169 +-------- lib/company/actions.ts | 56 +-- lib/errors/structured-errors.ts | 11 +- .../__tests__/ensure-invoice-number.test.ts | 30 ++ lib/invoices/display.ts | 13 + lib/invoices/ensure-invoice-number.ts | 13 + lib/reports/ar-ledger.ts | 4 +- messages/en.json | 79 ++++- messages/sv.json | 79 ++++- ...y_with_owner_business_profile_overload.sql | 30 ++ ...3100000_self_billing_received_invoices.sql | 79 +++++ tests/pg/self-billing-invoice.pg.test.ts | 102 ++++++ types/index.ts | 15 + 45 files changed, 2675 insertions(+), 529 deletions(-) create mode 100644 app/api/documents/[id]/link/__tests__/route.test.ts create mode 100644 app/api/documents/inbox-available/__tests__/route.test.ts create mode 100644 app/api/documents/inbox-available/route.ts create mode 100644 app/api/invoices/self-billed/__tests__/route.test.ts create mode 100644 app/api/invoices/self-billed/route.ts create mode 100644 components/bookkeeping/InboxDocumentPicker.tsx create mode 100644 extensions/general/arcim-migration/__tests__/migrate-guard.test.ts create mode 100644 supabase/migrations/20260612130000_drop_create_company_with_owner_business_profile_overload.sql create mode 100644 supabase/migrations/20260613100000_self_billing_received_invoices.sql create mode 100644 tests/pg/self-billing-invoice.pg.test.ts diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index ba431916..20380e7c 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -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 (
{/* Header */} @@ -437,13 +440,16 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
-

{invoiceNumberDisplay(invoice.invoice_number)}

+

{isSelfBilled ? invoiceDisplayNumber(invoice as Invoice) : invoiceNumberDisplay(invoice.invoice_number)}

{isProforma && ( {t('badge_proforma')} )} {isDeliveryNote && ( {t('badge_delivery_note')} )} + {isSelfBilled && ( + {t('badge_self_billed')} + )} {statusLabel(invoice.status)} @@ -516,14 +522,17 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st {t('mark_as_paid')} )} - + {/* No own PDF for a received self-billing invoice — the verifikationsunderlag is the document the customer sent us. */} + {!isSelfBilled && ( + + )}
@@ -706,9 +715,15 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
- {t('invoice_number_label')} - {invoiceNumberDisplay(invoice.invoice_number)} + {isSelfBilled ? t('external_number_label') : t('invoice_number_label')} + {isSelfBilled ? invoiceDisplayNumber(invoice as Invoice) : invoiceNumberDisplay(invoice.invoice_number)}
+ {isSelfBilled && (invoice as Invoice).self_billing_agreement_ref && ( +
+ {t('agreement_ref_label')} + {(invoice as Invoice).self_billing_agreement_ref} +
+ )}
{t('invoice_date_label')} {formatDate(invoice.invoice_date)} diff --git a/app/(dashboard)/invoices/new/page.tsx b/app/(dashboard)/invoices/new/page.tsx index 60057019..4de077c9 100644 --- a/app/(dashboard)/invoices/new/page.tsx +++ b/app/(dashboard)/invoices/new/page.tsx @@ -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({ 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() {

{titleText} - {numberPreview && ( + {numberPreview && !isSelfBilled && ( ({numberPreview}) @@ -618,7 +701,14 @@ export default function NewInvoicePage() { />

- {hasBankDetails === false && ( + setMode(v as 'invoice' | 'self_billed')}> + + {t('mode_invoice')} + {t('mode_self_billed')} + + + + {hasBankDetails === false && !isSelfBilled && (

{t('bank_missing_warning')}

@@ -635,8 +725,8 @@ export default function NewInvoicePage() { {/* Customer selection */} - {t('customer_card_title')} - {t('customer_card_description')} + {isSelfBilled ? <>{ts('customer_label')} : <>{t('customer_card_title')}} + {isSelfBilled ? ts('issuer_card_description') : t('customer_card_description')} {errors.customer_id.message}

)} + {isSelfBilled && ( +
+
+ + + {errors.external_invoice_number && ( +

{errors.external_invoice_number.message}

+ )} +
+
+ + +
+
+ )} +
@@ -1035,25 +1141,27 @@ export default function NewInvoicePage() { {t('details_card_title')} -
- - ( - - )} - /> -
+ {!isSelfBilled && ( +
+ + ( + + )} + /> +
+ )}
@@ -1087,44 +1195,58 @@ export default function NewInvoicePage() {
- {watchDocumentType === 'invoice' && ( + {isSelfBilled && ( +
+ + + {errors.received_date && ( +

{errors.received_date.message}

+ )} +
+ )} + + {watchDocumentType === 'invoice' && !isSelfBilled && (
)} - + {!isSelfBilled && ( + <> + -
- - ( - + + ( + + )} /> - )} - /> -
+
-
- - ( - + + ( + + )} /> - )} - /> -
+
+ + )}
@@ -1191,7 +1313,7 @@ export default function NewInvoicePage() { title={!canWrite ? t('viewer_disabled_tooltip') : undefined} > {!canWrite && } - {t('review_and_create')} + {isSelfBilled ? ts('register') : t('review_and_create')}
@@ -1213,7 +1335,7 @@ export default function NewInvoicePage() { title={!canWrite ? t('viewer_disabled_tooltip') : undefined} > {!canWrite && } - {t('review_and_create')} + {isSelfBilled ? ts('register') : t('review_and_create')} diff --git a/app/(dashboard)/invoices/page.tsx b/app/(dashboard)/invoices/page.tsx index 29a4b259..e058bfe0 100644 --- a/app/(dashboard)/invoices/page.tsx +++ b/app/(dashboard)/invoices/page.tsx @@ -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() { } > - - {invoiceNumberDisplay(invoice.invoice_number)}{' '} + + {invoice.is_self_billed ? invoiceDisplayNumber(invoice) : invoiceNumberDisplay(invoice.invoice_number)}{' '} · {(invoice.customer as { name: string })?.name} @@ -344,6 +345,14 @@ export default function InvoicesPage() { )} + {invoice.is_self_billed && ( + <> + + + {t('badge_self_billed')} + + + )} {relativeTime && ( <> diff --git a/app/api/company/check-org-number/__tests__/route.test.ts b/app/api/company/check-org-number/__tests__/route.test.ts index e6cbfc7d..212d8ccb 100644 --- a/app/api/company/check-org-number/__tests__/route.test.ts +++ b/app/api/company/check-org-number/__tests__/route.test.ts @@ -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 = {} - 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) }) }) diff --git a/app/api/company/check-org-number/route.ts b/app/api/company/check-org-number/route.ts index 092122e8..711ca69d 100644 --- a/app/api/company/check-org-number/route.ts +++ b/app/api/company/check-org-number/route.ts @@ -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 }, + }) } diff --git a/app/api/documents/[id]/link/__tests__/route.test.ts b/app/api/documents/[id]/link/__tests__/route.test.ts new file mode 100644 index 00000000..497c0341 --- /dev/null +++ b/app/api/documents/[id]/link/__tests__/route.test.ts @@ -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') + }) +}) diff --git a/app/api/documents/[id]/link/route.ts b/app/api/documents/[id]/link/route.ts index c2465a4e..33f8edfb 100644 --- a/app/api/documents/[id]/link/route.ts +++ b/app/api/documents/[id]/link/route.ts @@ -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 }) } diff --git a/app/api/documents/inbox-available/__tests__/route.test.ts b/app/api/documents/inbox-available/__tests__/route.test.ts new file mode 100644 index 00000000..46e3c5cf --- /dev/null +++ b/app/api/documents/inbox-available/__tests__/route.test.ts @@ -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> + }>(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) + }) +}) diff --git a/app/api/documents/inbox-available/route.ts b/app/api/documents/inbox-available/route.ts new file mode 100644 index 00000000..ef0c63bf --- /dev/null +++ b/app/api/documents/inbox-available/route.ts @@ -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() + 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 => x !== null) + + return NextResponse.json({ data }) +}) diff --git a/app/api/invoices/[id]/mark-sent/route.ts b/app/api/invoices/[id]/mark-sent/route.ts index c2c177b1..baddb8f1 100644 --- a/app/api/invoices/[id]/mark-sent/route.ts +++ b/app/api/invoices/[id]/mark-sent/route.ts @@ -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) } } diff --git a/app/api/invoices/self-billed/__tests__/route.test.ts b/app/api/invoices/self-billed/__tests__/route.test.ts new file mode 100644 index 00000000..3595a9d3 --- /dev/null +++ b/app/api/invoices/self-billed/__tests__/route.test.ts @@ -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() + }) +}) diff --git a/app/api/invoices/self-billed/route.ts b/app/api/invoices/self-billed/route.ts new file mode 100644 index 00000000..0d1b75e1 --- /dev/null +++ b/app/api/invoices/self-billed/route.ts @@ -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 }, +) diff --git a/app/api/sandbox/seed/route.ts b/app/api/sandbox/seed/route.ts index ce5fb417..db2c9017 100644 --- a/app/api/sandbox/seed/route.ts +++ b/app/api/sandbox/seed/route.ts @@ -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' }, diff --git a/app/api/transactions/[id]/match-supplier-invoice/__tests__/route.test.ts b/app/api/transactions/[id]/match-supplier-invoice/__tests__/route.test.ts index ab6101af..27608ab3 100644 --- a/app/api/transactions/[id]/match-supplier-invoice/__tests__/route.test.ts +++ b/app/api/transactions/[id]/match-supplier-invoice/__tests__/route.test.ts @@ -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() + }) +}) diff --git a/app/api/transactions/[id]/match-supplier-invoice/preview/route.ts b/app/api/transactions/[id]/match-supplier-invoice/preview/route.ts index e5de4b75..07172ca8 100644 --- a/app/api/transactions/[id]/match-supplier-invoice/preview/route.ts +++ b/app/api/transactions/[id]/match-supplier-invoice/preview/route.ts @@ -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, diff --git a/app/api/transactions/[id]/match-supplier-invoice/route.ts b/app/api/transactions/[id]/match-supplier-invoice/route.ts index 5c2fbc9c..102b135d 100644 --- a/app/api/transactions/[id]/match-supplier-invoice/route.ts +++ b/app/api/transactions/[id]/match-supplier-invoice/route.ts @@ -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 { diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts index 04bc3046..63c68e2b 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts @@ -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 { diff --git a/components/bookkeeping/InboxDocumentPicker.tsx b/components/bookkeeping/InboxDocumentPicker.tsx new file mode 100644 index 00000000..be674ea9 --- /dev/null +++ b/components/bookkeeping/InboxDocumentPicker.tsx @@ -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 + } + return +} + +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([]) + const [search, setSearch] = useState('') + const [linkingId, setLinkingId] = useState(null) + const [previewItem, setPreviewItem] = useState(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 ( + <> + !o && onClose()}> + + + {t('picker_title')} + {t('picker_description')} + + +
+ + setSearch(e.target.value)} + placeholder={t('picker_search_placeholder')} + className="pl-9" + /> +
+ + {!loading && filtered.length > 0 && ( +
+ {t('picker_results', { count: filtered.length })} +
+ )} + +
+ {loading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : filtered.length === 0 ? ( +
+ +

+ {hasSearch ? t('picker_empty_search', { query: search.trim() }) : t('picker_empty')} +

+
+ ) : ( + filtered.map((it) => { + const isLinking = linkingId === it.document_id + const sourceLabel = + it.source === 'email' ? t('picker_source_email') : t('picker_source_upload') + return ( +
+ + +
+ ) + }) + )} +
+
+
+ + !o && setPreviewItem(null)}> + + + {previewItem?.file_name} + {previewItem && (previewItem.supplier_name || previewItem.amount != null) && ( + + {previewItem.supplier_name && {previewItem.supplier_name}} + {previewItem.amount != null && ( + {formatCurrency(previewItem.amount, previewItem.currency ?? 'SEK')} + )} + {previewItem.invoice_date && {formatDate(previewItem.invoice_date)}} + + )} + + + {previewItem && previewSrc && ( +
+ {isImageType(previewItem.mime_type) ? ( + {previewItem.file_name} + ) : isPdfType(previewItem.mime_type) ? ( + // + type="application/pdf" invokes Chrome's PDF plugin + // directly;