Feat/voucher docs (#664)

* feat: implement inbox document picker and linking functionality

* feat: implement self-billing invoice functionality

- Added support for registering self-billed invoices received from customers.
- Updated the invoice schema to include fields for self-billing metadata such as `is_self_billed`, `external_invoice_number`, `self_billing_agreement_ref`, and `received_date`.
- Created API route for handling self-billed invoice submissions, including validation and error handling.
- Implemented database migrations to add necessary columns and constraints for self-billing invoices.
- Developed tests to ensure correct behavior of self-billing invoice creation and validation rules.
- Updated Swedish localization files to include new terms related to self-billing.

* feat: enforce SIE import requirement for non-Fortnox providers in migration process

* feat: streamline invoice processing and enhance error logging across APIs
This commit is contained in:
Mattsson
2026-06-04 13:14:57 +02:00
committed by GitHub
parent c1be9f15dd
commit 3e42fc6f32
45 changed files with 2675 additions and 529 deletions
@@ -1,50 +1,38 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
createServiceClient: vi.fn(),
}))
import { createClient, createServiceClient } from '@/lib/supabase/server'
import { createClient } from '@/lib/supabase/server'
import { GET } from '../route'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
const mockCreateClient = vi.mocked(createClient)
const mockCreateServiceClient = vi.mocked(createServiceClient)
function mockAuth(user: { id: string } | null) {
mockCreateClient.mockResolvedValue({
auth: { getUser: vi.fn().mockResolvedValue({ data: { user } }) },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)
}
/**
* Service-role mock. Returns a match only if the incoming `.eq('org_number', X)`
* value matches `existing`. Anything else (or empty `existing`) returns null.
* Minimal authenticated-client mock. `companies.data` seeds what the RLS-scoped
* `from('companies').select().eq().is()` chain resolves to. In production RLS
* filters this to the caller's own memberships; the route does no extra
* filtering, so the test just controls what the query returns.
*/
function mockService(existing?: string) {
let lastOrgNumber: string | null = null
function buildSupabase(opts: {
user: { id: string } | null
companies?: { data?: unknown; error?: unknown }
}) {
const result = {
data: opts.companies?.data ?? null,
error: opts.companies?.error ?? null,
}
const chain: Record<string, unknown> = {}
const methods = ['select', 'eq', 'is', 'limit', 'maybeSingle']
for (const m of methods) {
chain[m] = (...args: unknown[]) => {
if (m === 'eq' && args[0] === 'org_number') {
lastOrgNumber = String(args[1])
}
if (m === 'maybeSingle') {
return Promise.resolve({
data: existing && lastOrgNumber === existing ? { id: 'other' } : null,
error: null,
})
}
return chain
}
for (const m of ['select', 'eq', 'is', 'limit', 'order']) {
chain[m] = () => chain
}
;(chain as { then?: unknown }).then = (resolve: (v: unknown) => void) => resolve(result)
return {
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: opts.user } }) },
from: vi.fn(() => chain),
}
mockCreateServiceClient.mockReturnValue({
from: vi.fn().mockReturnValue(chain),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)
}
beforeEach(() => {
@@ -53,68 +41,65 @@ beforeEach(() => {
describe('GET /api/company/check-org-number', () => {
it('returns 401 when unauthenticated', async () => {
mockAuth(null)
mockService()
const req = createMockRequest('/api/company/check-org-number?org_number=5560125790')
const { status } = await parseJsonResponse(await GET(req))
mockCreateClient.mockResolvedValue(buildSupabase({ user: null }) as never)
const res = await GET(createMockRequest('/api/company/check-org-number?org_number=5560125790'))
const { status } = await parseJsonResponse(res)
expect(status).toBe(401)
})
it('returns 400 when org_number is missing', async () => {
mockAuth({ id: 'user-1' })
mockService()
const req = createMockRequest('/api/company/check-org-number')
const { status } = await parseJsonResponse(await GET(req))
mockCreateClient.mockResolvedValue(buildSupabase({ user: { id: 'u1' } }) as never)
const res = await GET(createMockRequest('/api/company/check-org-number'))
const { status } = await parseJsonResponse(res)
expect(status).toBe(400)
})
it('returns exists=false when the org number is not registered', async () => {
mockAuth({ id: 'user-1' })
mockService(undefined) // no existing match
const req = createMockRequest('/api/company/check-org-number?org_number=5560125790')
const { status, body } = await parseJsonResponse(await GET(req))
it('returns exists:false for malformed org_number without querying', async () => {
const supabase = buildSupabase({ user: { id: 'u1' } })
mockCreateClient.mockResolvedValue(supabase as never)
const res = await GET(createMockRequest('/api/company/check-org-number?org_number=not-a-number'))
const { status, body } = await parseJsonResponse<{
data: { exists: boolean; companies: unknown[] }
}>(res)
expect(status).toBe(200)
expect((body as { data: { exists: boolean } }).data.exists).toBe(false)
expect(body.data.exists).toBe(false)
expect(body.data.companies).toEqual([])
expect(supabase.from).not.toHaveBeenCalled()
})
it('returns exists=true when the org number is already registered', async () => {
mockAuth({ id: 'user-1' })
mockService('5560125790')
const req = createMockRequest('/api/company/check-org-number?org_number=5560125790')
const { status, body } = await parseJsonResponse(await GET(req))
it("reports the user's own matching companies (account-scoped via RLS)", async () => {
mockCreateClient.mockResolvedValue(
buildSupabase({
user: { id: 'u1' },
companies: { data: [{ id: 'c1', name: 'Acme AB' }] },
}) as never,
)
// Hyphenated input still matches the stored 10-digit canonical.
const res = await GET(createMockRequest('/api/company/check-org-number?org_number=556012-5790'))
const { status, body } = await parseJsonResponse<{
data: { exists: boolean; companies: { id: string; name: string }[] }
}>(res)
expect(status).toBe(200)
expect((body as { data: { exists: boolean } }).data.exists).toBe(true)
expect(body.data.exists).toBe(true)
expect(body.data.companies).toEqual([{ id: 'c1', name: 'Acme AB' }])
})
it('normalizes formatted org numbers before lookup (strips hyphens/spaces)', async () => {
mockAuth({ id: 'user-1' })
mockService('5560125790')
const req = createMockRequest('/api/company/check-org-number?org_number=556012-5790')
const { status, body } = await parseJsonResponse(await GET(req))
it('returns exists:false when the user has no company with that org number', async () => {
mockCreateClient.mockResolvedValue(
buildSupabase({ user: { id: 'u1' }, companies: { data: [] } }) as never,
)
const res = await GET(createMockRequest('/api/company/check-org-number?org_number=5560125790'))
const { status, body } = await parseJsonResponse<{ data: { exists: boolean } }>(res)
expect(status).toBe(200)
expect((body as { data: { exists: boolean } }).data.exists).toBe(true)
expect(body.data.exists).toBe(false)
})
it('normalizes 12-digit input to 10-digit canonical before lookup', async () => {
// Stored form is 10-digit canonical (8001011231); user types 12-digit
// personnummer with century prefix.
mockAuth({ id: 'user-1' })
mockService('8001011231')
const req = createMockRequest('/api/company/check-org-number?org_number=198001011231')
const { status, body } = await parseJsonResponse(await GET(req))
expect(status).toBe(200)
expect((body as { data: { exists: boolean } }).data.exists).toBe(true)
})
it('returns exists=false for Luhn-invalid input (not a duplicate of anything)', async () => {
// The submit-time server action will reject this as org_number_invalid;
// here we just confirm the check endpoint doesn't produce a misleading
// "exists=true" result by accidentally matching an invalid number.
mockAuth({ id: 'user-1' })
mockService('5560125790') // a real registered number
const req = createMockRequest('/api/company/check-org-number?org_number=5560125791')
const { status, body } = await parseJsonResponse(await GET(req))
expect(status).toBe(200)
expect((body as { data: { exists: boolean } }).data.exists).toBe(false)
it('returns 500 when the query errors', async () => {
mockCreateClient.mockResolvedValue(
buildSupabase({ user: { id: 'u1' }, companies: { error: { message: 'boom' } } }) as never,
)
const res = await GET(createMockRequest('/api/company/check-org-number?org_number=5560125790'))
const { status } = await parseJsonResponse(res)
expect(status).toBe(500)
})
})
+31 -25
View File
@@ -1,30 +1,31 @@
import { NextResponse } from 'next/server'
import { createClient, createServiceClient } from '@/lib/supabase/server'
import { requireAuth } from '@/lib/auth/require-auth'
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
/**
* GET /api/company/check-org-number?org_number=XXXXXXXXXX
*
* Returns `{ data: { exists: boolean } }` indicating whether the given
* organisation number is already registered in any non-archived Accounted
* company. Used by the onboarding wizard to warn users before they try to
* create a duplicate.
* Returns `{ data: { exists: boolean, companies: { id, name }[] } }` for the
* companies the CURRENT USER already has with the given organisation number —
* scoped to their own account only.
*
* Normalizes the input with the same rule as the server action
* (`normalizeOrgNumber`) so that a 12-digit form typed in the UI still
* matches a 10-digit stored canonical. Returns `exists: false` for
* malformed input — the submit-time server action will reject it with
* `org_number_invalid`, which is the right place to surface the error.
* Org-number reuse across the platform is intentionally allowed (see
* lib/company/actions.ts), so this is a soft, account-scoped warning, NOT a
* uniqueness gate. It uses the normal authenticated client on purpose: the
* `companies` SELECT RLS policy limits results to companies the caller is a
* member of (id IN user_company_ids()), so it can never reveal another user's
* companies and can't be used to enumerate org numbers platform-wide.
*
* Requires authentication so the endpoint can't be used to enumerate the
* full set of org numbers on the platform. Uses the service role internally
* because RLS hides rows the caller isn't a member of — which is exactly
* what we need to detect ("owned by someone else").
* Normalizes input with the same rule as the create action so a 12-digit form
* still matches a stored 10-digit canonical. Returns no matches for malformed
* input — the create action rejects that separately as `org_number_invalid`.
*/
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
// requireAuth() (not a raw getUser()) so MFA AAL2 is enforced on hosted before
// we run the account-scoped lookup. The returned client carries the caller's
// RLS context, which is what scopes the companies SELECT below.
const { supabase, error: authError } = await requireAuth()
if (authError) return authError
const url = new URL(request.url)
const raw = url.searchParams.get('org_number') ?? ''
@@ -34,22 +35,27 @@ export async function GET(request: Request) {
const canonical = normalizeOrgNumber(raw)
if (!canonical) {
// Invalid format/Luhn — not a duplicate of anything by definition.
return NextResponse.json({ data: { exists: false } })
// Malformed input is not a duplicate of anything by definition.
return NextResponse.json({ data: { exists: false, companies: [] } })
}
const service = createServiceClient()
const { data, error } = await service
// RLS scopes this SELECT to the caller's own memberships (companies_select:
// id IN user_company_ids()), so the result is inherently account-scoped.
const { data, error } = await supabase
.from('companies')
.select('id')
.select('id, name')
.eq('org_number', canonical)
.is('archived_at', null)
.limit(1)
.maybeSingle()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data: { exists: !!data } })
const companies = (data ?? []).map((c: { id: string; name: string }) => ({
id: c.id,
name: c.name,
}))
return NextResponse.json({
data: { exists: companies.length > 0, companies },
})
}
@@ -0,0 +1,132 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { eventBus } from '@/lib/events/bus'
import {
parseJsonResponse,
createMockRouteParams,
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
import { POST } from '../route'
import { requireWritePermission } from '@/lib/auth/require-write'
import { NextResponse } from 'next/server'
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
vi.mocked(requireWritePermission).mockResolvedValue({ ok: true })
})
function makeReq(body: unknown) {
return new Request('http://localhost/api/documents/doc-1/link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
describe('POST /api/documents/[id]/link', () => {
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const res = await POST(makeReq({ journal_entry_id: 'je-1' }), createMockRouteParams({ id: 'doc-1' }))
const { status } = await parseJsonResponse(res)
expect(status).toBe(401)
})
it('returns 403 when caller has read-only role', async () => {
vi.mocked(requireWritePermission).mockResolvedValue({
ok: false,
response: NextResponse.json(
{ error: 'Du har endast läsbehörighet i detta företag.' },
{ status: 403 },
),
})
const res = await POST(makeReq({ journal_entry_id: 'je-1' }), createMockRouteParams({ id: 'doc-1' }))
const { status } = await parseJsonResponse(res)
expect(status).toBe(403)
})
it('rejects a missing journal_entry_id', async () => {
const res = await POST(makeReq({}), createMockRouteParams({ id: 'doc-1' }))
const { body } = await parseJsonResponse<{ error: { code: string } }>(res)
expect(body.error.code).toBe('VALIDATION_ERROR')
})
it('links the document and stamps the inbox item when inbox_item_id is given', async () => {
enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-1', file_name: 'x.pdf' } }) // link update
enqueue({ data: null }) // inbox stamp update
const res = await POST(
makeReq({ journal_entry_id: 'je-1', inbox_item_id: 'inbox-1' }),
createMockRouteParams({ id: 'doc-1' }),
)
const { status, body } = await parseJsonResponse<{ data: { id: string } }>(res)
expect(status).toBe(200)
expect(body.data.id).toBe('doc-1')
expect(mockSupabase.from).toHaveBeenCalledWith('document_attachments')
expect(mockSupabase.from).toHaveBeenCalledWith('invoice_inbox_items')
})
it('does not touch the inbox when no inbox_item_id is given', async () => {
enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-1', file_name: 'x.pdf' } }) // link update
const res = await POST(
makeReq({ journal_entry_id: 'je-1' }),
createMockRouteParams({ id: 'doc-1' }),
)
const { status } = await parseJsonResponse(res)
expect(status).toBe(200)
expect(mockSupabase.from).not.toHaveBeenCalledWith('invoice_inbox_items')
})
it('maps a period-lock trigger error to PERIOD_LOCKED', async () => {
enqueue({
data: null,
error: { message: 'new row violates ... locked/closed fiscal period' },
})
const res = await POST(
makeReq({ journal_entry_id: 'je-1', inbox_item_id: 'inbox-1' }),
createMockRouteParams({ id: 'doc-1' }),
)
const { body } = await parseJsonResponse<{ error: { code: string } }>(res)
expect(body.error.code).toBe('PERIOD_LOCKED')
// The inbox stamp must not run when the link itself failed.
expect(mockSupabase.from).not.toHaveBeenCalledWith('invoice_inbox_items')
})
it('maps an already-linked error to DOC_LINK_ALREADY_LINKED', async () => {
enqueue({
data: null,
error: { message: 'document already linked to another entry' },
})
const res = await POST(
makeReq({ journal_entry_id: 'je-1' }),
createMockRouteParams({ id: 'doc-1' }),
)
const { body } = await parseJsonResponse<{ error: { code: string } }>(res)
expect(body.error.code).toBe('DOC_LINK_ALREADY_LINKED')
})
})
+44 -1
View File
@@ -9,7 +9,15 @@ ensureInitialized()
/**
* POST /api/documents/[id]/link — link a document to a journal entry.
*
* Body: { journal_entry_id: string, journal_entry_line_id?: string }
* Body: { journal_entry_id: string, journal_entry_line_id?: string, inbox_item_id?: string }
*
* When `inbox_item_id` is supplied (the "choose from inbox" flow), the inbox
* item is stamped with the verifikat id after a successful link so it drops out
* of the active inbox into "Bokförda" — reusing the inbox's own
* created_journal_entry_id lifecycle. The document link is the legally-relevant
* write and happens first; the inbox stamp is operational housekeeping, so a
* stamp failure is logged but does not fail the request (the doc is correctly
* attached and the DB immutability trigger still blocks any double-link).
*/
export const POST = withRouteContext(
'document.link',
@@ -35,12 +43,47 @@ export const POST = withRouteContext(
body.journal_entry_id,
body.journal_entry_line_id,
)
if (body.inbox_item_id) {
const { data: stamped, error: inboxError } = await supabase
.from('invoice_inbox_items')
.update({ created_journal_entry_id: body.journal_entry_id })
.eq('id', body.inbox_item_id)
.eq('company_id', companyId!)
// Only stamp the inbox item that actually owns this document — a
// mismatched pairing becomes a safe no-op rather than mis-marking an
// unrelated item as consumed.
.eq('document_id', id)
.select('id')
if (inboxError) {
// Non-fatal — the verifikat ↔ underlag link already succeeded.
opLog.warn('inbox item stamp after link failed', {
inboxItemId: body.inbox_item_id,
reason: inboxError.message,
})
} else if (!stamped || stamped.length === 0) {
// Zero rows updated means the supplied inbox_item_id / document_id
// pairing did not match (wrong company, wrong document, or a stale
// id). The doc link itself still succeeded; surface the cross-resource
// mismatch as an observable warning rather than silently ignoring it.
opLog.warn('inbox item stamp matched no rows (cross-resource mismatch)', {
inboxItemId: body.inbox_item_id,
})
}
}
return NextResponse.json({ data: document })
} catch (err) {
opLog.error('document link failed', err as Error, {
journalEntryId: body.journal_entry_id,
})
const message = err instanceof Error ? err.message : ''
// Linking writes journal_entry_id on document_attachments; the
// enforce_period_lock trigger blocks that when the target entry sits in a
// closed/locked period.
if (/locked\/closed fiscal period|Bokföringen är låst/i.test(message)) {
return errorResponseFromCode('PERIOD_LOCKED', opLog, { requestId })
}
if (/journal entry not found/i.test(message)) {
return errorResponseFromCode('DOC_LINK_ENTRY_NOT_FOUND', opLog, { requestId })
}
@@ -0,0 +1,127 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { eventBus } from '@/lib/events/bus'
import {
parseJsonResponse,
createMockRouteParams,
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
import { GET } from '../route'
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
function makeReq() {
return new Request('http://localhost/api/documents/inbox-available')
}
describe('GET /api/documents/inbox-available', () => {
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const res = await GET(makeReq(), createMockRouteParams({}))
const { status } = await parseJsonResponse(res)
expect(status).toBe(401)
})
it('returns [] when no eligible inbox items (no second query)', async () => {
enqueue({ data: [] }) // inbox items
const res = await GET(makeReq(), createMockRouteParams({}))
const { status, body } = await parseJsonResponse<{ data: unknown[] }>(res)
expect(status).toBe(200)
expect(body.data).toEqual([])
// Documents table never queried when there are no document ids.
expect(mockSupabase.from).not.toHaveBeenCalledWith('document_attachments')
})
it('joins inbox items to their documents and drops consumed/superseded ones', async () => {
enqueue({
data: [
{
id: 'inbox-1',
document_id: 'doc-1',
source: 'email',
created_at: '2026-05-01T00:00:00Z',
extracted_data: {
supplier: { name: 'Acme AB' },
totals: { total: 1250 },
invoice: { currency: 'SEK', invoiceDate: '2026-04-28' },
},
},
// doc-2's document is no longer current/unlinked → must be dropped.
{
id: 'inbox-2',
document_id: 'doc-2',
source: 'upload',
created_at: '2026-05-02T00:00:00Z',
extracted_data: null,
},
],
})
enqueue({
data: [
{
id: 'doc-1',
file_name: 'acme.pdf',
mime_type: 'application/pdf',
file_size_bytes: 1000,
journal_entry_id: null,
is_current_version: true,
},
],
})
const res = await GET(makeReq(), createMockRouteParams({}))
const { status, body } = await parseJsonResponse<{
data: Array<Record<string, unknown>>
}>(res)
expect(status).toBe(200)
expect(body.data).toHaveLength(1)
expect(body.data[0]).toEqual({
inbox_item_id: 'inbox-1',
document_id: 'doc-1',
file_name: 'acme.pdf',
mime_type: 'application/pdf',
file_size_bytes: 1000,
source: 'email',
created_at: '2026-05-01T00:00:00Z',
supplier_name: 'Acme AB',
amount: 1250,
currency: 'SEK',
invoice_date: '2026-04-28',
})
expect(mockSupabase.from).toHaveBeenCalledWith('invoice_inbox_items')
expect(mockSupabase.from).toHaveBeenCalledWith('document_attachments')
})
it('returns an error envelope when the inbox query fails', async () => {
enqueue({ data: null, error: { message: 'boom' } })
const res = await GET(makeReq(), createMockRouteParams({}))
const { status } = await parseJsonResponse(res)
expect(status).toBeGreaterThanOrEqual(500)
})
})
+114
View File
@@ -0,0 +1,114 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse } from '@/lib/errors/get-structured-error'
import type { InvoiceExtractionResult } from '@/types'
ensureInitialized()
/**
* GET /api/documents/inbox-available — list invoice-inbox documents that are
* available to attach as underlag to a verifikat.
*
* Returns only *unconsumed* inbox items: those that have a file but have not
* yet become a supplier invoice, a direct journal entry, or been matched to a
* bank transaction — and whose underlying document is not already linked to a
* verifikation. This mirrors the inbox's own "Att göra" set, narrowed to items
* with an attachable file. Re-pointing an already-linked document is forbidden
* (BFL 7 kap — räkenskapsinformation is immutable), so those are excluded here
* and the DB immutability trigger is the backstop.
*
* `invoice_inbox_items` is a core table, so a core route may read it directly
* without importing from @/extensions. When the invoice-inbox extension is not
* in use the table is simply empty and this returns [].
*/
interface InboxRow {
id: string
document_id: string | null
source: string | null
created_at: string
extracted_data: InvoiceExtractionResult | null
}
interface DocRow {
id: string
file_name: string
mime_type: string | null
file_size_bytes: number
journal_entry_id: string | null
is_current_version: boolean
}
export const GET = withRouteContext('document.inbox_available', async (_request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
// 1) Eligible inbox items — company-scoped (defense in depth alongside RLS),
// unconsumed, with a document.
const { data: inboxRows, error: inboxError } = await supabase
.from('invoice_inbox_items')
.select('id, document_id, source, created_at, extracted_data')
.eq('company_id', companyId)
.not('document_id', 'is', null)
.is('created_supplier_invoice_id', null)
.is('created_journal_entry_id', null)
.is('matched_transaction_id', null)
.order('created_at', { ascending: false })
.limit(100)
if (inboxError) {
log.error('inbox-available item query failed', inboxError)
return errorResponse(inboxError, log, { requestId })
}
const rows = (inboxRows ?? []) as InboxRow[]
const docIds = rows.map((r) => r.document_id).filter((id): id is string => !!id)
if (docIds.length === 0) {
return NextResponse.json({ data: [] })
}
// 2) The current, still-unlinked documents behind those items. Excluding
// docs with a journal_entry_id (already underlag elsewhere) and superseded
// versions keeps the picker honest even if an inbox column went stale.
const { data: docRows, error: docError } = await supabase
.from('document_attachments')
.select('id, file_name, mime_type, file_size_bytes, journal_entry_id, is_current_version')
.eq('company_id', companyId)
.in('id', docIds)
.is('journal_entry_id', null)
.eq('is_current_version', true)
if (docError) {
log.error('inbox-available document query failed', docError)
return errorResponse(docError, log, { requestId })
}
const docById = new Map<string, DocRow>()
for (const d of (docRows ?? []) as DocRow[]) docById.set(d.id, d)
// Preserve the inbox ordering (newest first); drop items whose document is
// gone, consumed, or superseded.
const data = rows
.map((row) => {
const doc = row.document_id ? docById.get(row.document_id) : undefined
if (!doc) return null
const ex = row.extracted_data
return {
inbox_item_id: row.id,
document_id: doc.id,
file_name: doc.file_name,
mime_type: doc.mime_type,
file_size_bytes: doc.file_size_bytes,
source: row.source,
created_at: row.created_at,
supplier_name: ex?.supplier?.name ?? null,
amount: ex?.totals?.total ?? null,
currency: ex?.invoice?.currency ?? 'SEK',
invoice_date: ex?.invoice?.invoiceDate ?? null,
}
})
.filter((x): x is NonNullable<typeof x> => x !== null)
return NextResponse.json({ data })
})
+17 -4
View File
@@ -9,6 +9,7 @@ import { prepareInvoicePdfRender } from '@/lib/invoices/pdf-render-helpers'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { createLogger } from '@/lib/logger'
import type { CompanySettings, Customer, EntityType, Invoice, InvoiceItem } from '@/types'
ensureInitialized()
@@ -37,6 +38,7 @@ export async function POST(
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const log = createLogger('invoice.mark-sent', { companyId, invoiceId: id })
// Fetch invoice
const { data: invoice, error: invoiceError } = await supabase
@@ -61,7 +63,7 @@ export async function POST(
try {
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
} catch (err) {
console.error('Failed to assign invoice number on mark-sent:', err)
log.error('failed to assign invoice number on mark-sent', err as Error)
return NextResponse.json(
{ error: 'Kunde inte tilldela fakturanummer. Försök igen.' },
{ status: 500 }
@@ -103,13 +105,24 @@ export async function POST(
)
if (journalEntry) {
journalEntryId = journalEntry.id
await supabase
const { error: linkError } = await supabase
.from('invoices')
.update({ journal_entry_id: journalEntry.id })
.eq('id', id)
if (linkError) {
// Don't fail mark-sent — the verifikat committed; only the link
// failed. But log it through the structured logger so it reaches log
// aggregation/alerting: this write silently no-ops when the
// journal_entry_id column is missing (it was absent in prod until the
// 20260613100000 migration), which leaves mark-paid unable to detect
// an already-booked sale.
log.error('mark-sent: journal_entry_id link to invoice failed', linkError, {
journalEntryId: journalEntry.id,
})
}
}
} catch (err) {
console.error('Failed to create invoice journal entry on mark-sent:', err)
log.error('failed to create invoice journal entry on mark-sent', err as Error)
}
}
@@ -162,7 +175,7 @@ export async function POST(
journal_entry_id: journalEntryId ?? undefined,
})
} catch (err) {
console.error('Failed to archive invoice PDF on mark-sent:', err)
log.error('failed to archive invoice PDF on mark-sent', err as Error)
}
}
@@ -0,0 +1,240 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createQueuedMockSupabase,
makeInvoice,
makeCustomer,
} from '@/tests/helpers'
import { eventBus } from '@/lib/events'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
const mockGetVatRules = vi.fn()
const mockGetAvailableVatRates = vi.fn()
vi.mock('@/lib/invoices/vat-rules', () => ({
getVatRules: (...args: unknown[]) => mockGetVatRules(...args),
getAvailableVatRates: (...args: unknown[]) => mockGetAvailableVatRates(...args),
}))
vi.mock('@/lib/currency/riksbanken', () => ({
fetchExchangeRate: vi.fn().mockResolvedValue(null),
convertToSEK: vi.fn(),
}))
const mockCreateInvoiceJournalEntry = vi.fn()
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
createInvoiceJournalEntry: (...args: unknown[]) => mockCreateInvoiceJournalEntry(...args),
}))
import { POST } from '../route'
const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000'
const mockUser = { id: 'user-1', email: 'test@test.se' }
const validBody = {
customer_id: VALID_UUID,
external_invoice_number: 'KUND-55012',
self_billing_agreement_ref: 'Avtal 2026-01',
invoice_date: '2026-06-01',
received_date: '2026-06-02',
due_date: '2026-06-30',
currency: 'SEK',
items: [{ description: 'Konsulttjänst', quantity: 10, unit: 'tim', unit_price: 1000 }],
}
function mockDomesticVat() {
mockGetVatRules.mockReturnValue({
treatment: 'standard_25',
rate: 25,
momsRuta: '05',
reverseChargeText: null,
})
mockGetAvailableVatRates.mockReturnValue([
{ rate: 25, label: '25%', treatment: 'standard_25' },
{ rate: 12, label: '12%', treatment: 'reduced_12' },
{ rate: 6, label: '6%', treatment: 'reduced_6' },
{ rate: 0, label: '0% (momsfri)', treatment: 'exempt' },
])
}
describe('POST /api/invoices/self-billed', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const request = createMockRequest('/api/invoices/self-billed', { method: 'POST', body: validBody })
const response = await POST(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(401)
})
it('returns 400 when external_invoice_number is missing', async () => {
const { external_invoice_number, ...rest } = validBody
void external_invoice_number
const request = createMockRequest('/api/invoices/self-billed', { method: 'POST', body: rest })
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ type: string }>(response)
expect(status).toBe(400)
expect(body.type).toBe('validation_error')
})
it('returns 404 when the customer (issuer) is not found', async () => {
enqueue({ data: null, error: { message: 'Not found' } })
const request = createMockRequest('/api/invoices/self-billed', { method: 'POST', body: validBody })
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(404)
expect(body.error.code).toBe('INVOICE_CUSTOMER_NOT_FOUND')
})
it('rejects an item VAT rate the customer is not allowed to use', async () => {
mockGetVatRules.mockReturnValue({ treatment: 'standard_25', rate: 25, momsRuta: '05', reverseChargeText: null })
// Domestic-only set: 0% is NOT allowed for this customer.
mockGetAvailableVatRates.mockReturnValue([
{ rate: 25, label: '25%', treatment: 'standard_25' },
{ rate: 12, label: '12%', treatment: 'reduced_12' },
{ rate: 6, label: '6%', treatment: 'reduced_6' },
])
enqueue({ data: makeCustomer({ id: VALID_UUID }), error: null })
const request = createMockRequest('/api/invoices/self-billed', {
method: 'POST',
body: { ...validBody, items: [{ description: 'X', quantity: 1, unit: 'st', unit_price: 100, vat_rate: 0 }] },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('INVOICE_CREATE_VAT_RULE_VIOLATION')
})
it('creates a self-billed sale, books it (accrual), skips own numbering, and emits invoice.created', async () => {
mockDomesticVat()
const customer = makeCustomer({ id: VALID_UUID, name: 'Stora Bolaget AB' })
const created = makeInvoice({
id: 'inv-1',
invoice_number: null,
is_self_billed: true,
external_invoice_number: 'KUND-55012',
total: 12500,
})
enqueue({ data: customer, error: null }) // fetch customer
enqueue({ data: created, error: null }) // insert invoice
enqueue({ data: null, error: null }) // insert items
enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings
enqueue({ data: { ...created, customer, items: [] }, error: null }) // fetch complete
enqueue({ data: null, error: null }) // update journal_entry_id
enqueue({ data: { ...created, customer, items: [], journal_entry_id: 'je-1' }, error: null }) // fetch final
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
const emitSpy = vi.spyOn(eventBus, 'emit')
const request = createMockRequest('/api/invoices/self-billed', { method: 'POST', body: validBody })
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response)
expect(status).toBe(200)
expect(body.data).toBeTruthy()
// Booked as a sale with the self-billing label + the counterparty's number.
expect(mockCreateInvoiceJournalEntry).toHaveBeenCalledTimes(1)
const opts = mockCreateInvoiceJournalEntry.mock.calls[0][6]
expect(opts).toEqual({ descriptionPrefix: 'Självfaktura', numberOverride: 'KUND-55012' })
// Never consumes our own invoice-number series.
expect(mockSupabase.rpc).not.toHaveBeenCalledWith('generate_invoice_number', expect.anything())
expect(emitSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'invoice.created' }))
})
it('does NOT book at registration under kontantmetoden (cash) — books at payment instead', async () => {
mockDomesticVat()
const customer = makeCustomer({ id: VALID_UUID })
const created = makeInvoice({ id: 'inv-1', invoice_number: null, is_self_billed: true, external_invoice_number: 'KUND-55012' })
enqueue({ data: customer, error: null }) // fetch customer
enqueue({ data: created, error: null }) // insert invoice
enqueue({ data: null, error: null }) // insert items
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null }) // settings
enqueue({ data: { ...created, customer, items: [] }, error: null }) // fetch complete
enqueue({ data: { ...created, customer, items: [] }, error: null }) // fetch final
const request = createMockRequest('/api/invoices/self-billed', { method: 'POST', body: validBody })
const response = await POST(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
})
it('rolls back when there is no open fiscal period for the invoice date', async () => {
mockDomesticVat()
const customer = makeCustomer({ id: VALID_UUID })
const created = makeInvoice({ id: 'inv-1', invoice_number: null, is_self_billed: true, external_invoice_number: 'KUND-55012' })
enqueue({ data: customer, error: null }) // fetch customer
enqueue({ data: created, error: null }) // insert invoice
enqueue({ data: null, error: null }) // insert items
enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings
enqueue({ data: { ...created, customer, items: [] }, error: null }) // fetch complete
enqueue({ data: null, error: null }) // rollback delete
mockCreateInvoiceJournalEntry.mockResolvedValue(null) // no fiscal period
const request = createMockRequest('/api/invoices/self-billed', { method: 'POST', body: validBody })
const response = await POST(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
expect(mockSupabase.from).toHaveBeenCalledWith('invoices')
})
it('rejects a foreign-currency self-billed invoice when no FX rate is available', async () => {
mockDomesticVat()
// fetchExchangeRate is mocked to resolve null (rate unavailable for the
// invoice date). Booking would otherwise fall through to a silent 1:1 SEK
// conversion, so the route must refuse up front — before any insert.
enqueue({ data: makeCustomer({ id: VALID_UUID }), error: null }) // fetch customer
const request = createMockRequest('/api/invoices/self-billed', {
method: 'POST',
body: { ...validBody, currency: 'EUR' },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ type: string; error: string }>(response)
expect(status).toBe(400)
expect(body.error).toMatch(/växelkurs/i)
// Never books a wrong-magnitude verifikat and never inserts the invoice.
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
})
})
+311
View File
@@ -0,0 +1,311 @@
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { CreateSelfBillingInvoiceSchema } from '@/lib/api/schemas'
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { roundOre } from '@/lib/money'
import type { EntityType, Invoice } from '@/types'
ensureInitialized()
/**
* POST /api/invoices/self-billed
*
* Register a self-billing invoice we RECEIVED (mottagen självfaktura, ML 17 kap
* 15§). The customer issued the invoice on our behalf; for us it is a sale, so
* it books exactly like a customer invoice (Debit 1510, Credit 30xx + 26xx) and
* the output VAT lands in our momsdeklaration.
*
* It differs from a normal customer invoice in two ways:
* - We do NOT assign a number from our own series — the counterparty's number
* is stored in external_invoice_number and our invoice_number stays null
* (BFL 5 kap 6§). Enforced by the invoices_self_billed_numbering constraint.
* - There is no send step. Under faktureringsmetoden (accrual) we book the
* registration entry here. Under kontantmetoden (cash) we leave it unbooked
* until payment — identical to a normal invoice — and the existing mark-paid
* flow books the cash entry then.
*
* Payment is handled by the existing flows: the row is created with status
* 'sent', so "Markera som betald" / bank matching work unchanged.
*/
export const POST = withRouteContext(
'invoice.self_billed.create',
async (request, ctx) => {
const { user, supabase, companyId, log, requestId } = ctx
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return NextResponse.json(
{ error: 'Invalid JSON in request body', type: 'validation_error' },
{ status: 400 },
)
}
const parsed = CreateSelfBillingInvoiceSchema.safeParse(rawBody)
if (!parsed.success) {
log.warn('self-billed invoice validation failed', { issueCount: parsed.error.issues.length })
return NextResponse.json(
{
error: 'Validation failed',
type: 'validation_error',
errors: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message, code: i.code })),
},
{ status: 400 },
)
}
const input = parsed.data
// The issuer of a self-billing invoice is, in our books, the customer we
// sold to. Require an existing customer row so VAT rules + reporting work.
// Project only the fields used below (data minimisation — GDPR Art. 25 /
// SOC 2 CC6.3): VAT treatment derivation and the verifikat description.
const { data: customer, error: customerError } = await supabase
.from('customers')
.select('id, name, customer_type, vat_number_validated')
.eq('id', input.customer_id)
.eq('company_id', companyId!)
.single()
if (customerError || !customer) {
return errorResponseFromCode('INVOICE_CUSTOMER_NOT_FOUND', log, {
requestId,
details: { customerId: input.customer_id },
})
}
// VAT treatment is driven by who the customer is (domestic / EU reverse
// charge / export), exactly like an own-issued invoice.
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
const availableRates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated)
const allowedRates = new Set(availableRates.map((r) => r.rate))
let vatAmount = 0
for (const item of input.items) {
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
if (!allowedRates.has(itemRate)) {
return errorResponseFromCode('INVOICE_CREATE_VAT_RULE_VIOLATION', log, {
requestId,
details: {
attemptedRate: itemRate,
allowedRates: Array.from(allowedRates),
customerType: customer.customer_type,
},
})
}
const lineTotal = item.quantity * item.unit_price
vatAmount += roundOre((lineTotal * itemRate) / 100)
}
const subtotal = input.items.reduce((sum, item) => sum + item.quantity * item.unit_price, 0)
const total = roundOre(subtotal + vatAmount)
const uniqueRates = new Set(input.items.map((item) => item.vat_rate ?? vatRules.rate))
const isMixedRate = uniqueRates.size > 1
// Foreign currency: convert using the rate on the INVOICE date (ML 7 kap 7§),
// not today's rate.
let exchangeRate: number | null = null
let exchangeRateDate: string | null = null
let subtotalSek: number | null = null
let vatAmountSek: number | null = null
let totalSek: number | null = null
if (input.currency !== 'SEK') {
const rateData = await fetchExchangeRate(input.currency, new Date(input.invoice_date))
if (!rateData) {
// No FX rate for the invoice date — refuse rather than letting the
// booking fall through to resolveSekAmount's legacy 1:1 fallback, which
// would treat e.g. 1 000 USD as 1 000 SEK and commit a balanced but
// silently wrong-magnitude verifikat. ML 7 kap 7§ requires the
// invoice-date rate; we never substitute today's. The user can retry
// once the rate is published.
log.warn('self-billed invoice rejected: no FX rate for invoice date', {
currency: input.currency,
invoiceDate: input.invoice_date,
})
return NextResponse.json(
{
error: `Kunde inte hämta växelkurs för ${input.currency} på fakturadatumet (${input.invoice_date}). Försök igen senare.`,
type: 'validation_error',
},
{ status: 400 },
)
}
exchangeRate = rateData.rate
exchangeRateDate = rateData.date
subtotalSek = convertToSEK(subtotal, exchangeRate)
vatAmountSek = convertToSEK(vatAmount, exchangeRate)
totalSek = convertToSEK(total, exchangeRate)
}
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.insert({
user_id: user.id,
company_id: companyId,
customer_id: input.customer_id,
// No own number — the counterparty's number lives in external_invoice_number.
invoice_number: null,
is_self_billed: true,
external_invoice_number: input.external_invoice_number,
self_billing_agreement_ref: input.self_billing_agreement_ref ?? null,
received_date: input.received_date,
invoice_date: input.invoice_date,
due_date: input.due_date,
// Booked + awaiting/with payment — never a draft, so it shows in the AR
// ledger and is payable via the existing mark-paid / matching flows.
status: 'sent',
currency: input.currency,
exchange_rate: exchangeRate,
exchange_rate_date: exchangeRateDate,
subtotal,
subtotal_sek: subtotalSek,
vat_amount: vatAmount,
vat_amount_sek: vatAmountSek,
total,
total_sek: totalSek,
remaining_amount: total,
vat_treatment: vatRules.treatment,
vat_rate: isMixedRate ? null : (uniqueRates.values().next().value ?? vatRules.rate),
moms_ruta: vatRules.momsRuta,
reverse_charge_text: vatRules.reverseChargeText || null,
notes: input.notes,
document_type: 'invoice',
})
.select()
.single()
if (invoiceError || !invoice) {
log.error('self-billed invoice insert failed', invoiceError)
return errorResponseFromCode('INVOICE_CREATE_INSERT_FAILED', log, {
requestId,
details: { pgCode: invoiceError?.code, pgMessage: invoiceError?.message },
})
}
const items = input.items.map((item, index) => {
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
const lineTotal = item.quantity * item.unit_price
return {
invoice_id: invoice.id,
sort_order: index,
description: item.description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
line_total: lineTotal,
vat_rate: itemRate,
vat_amount: roundOre((lineTotal * itemRate) / 100),
}
})
const { error: itemsError } = await supabase.from('invoice_items').insert(items)
if (itemsError) {
// The item insert failed, so nothing was written there — just remove the
// orphaned invoice header.
await supabase.from('invoices').delete().eq('id', invoice.id)
log.error('self-billed invoice items insert failed; rolled back', itemsError, { invoiceId: invoice.id })
return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', log, {
requestId,
details: { pgCode: itemsError.code, pgMessage: itemsError.message },
})
}
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method, entity_type')
.eq('company_id', companyId!)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
const { data: completeInvoice } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoice.id)
.single()
// Faktureringsmetoden: book the registration entry now (Debit 1510, Credit
// 30xx + 26xx). Kontantmetoden: leave unbooked until payment, exactly like a
// normal invoice — the mark-paid flow books the cash entry then.
if (accountingMethod === 'accrual') {
if (!completeInvoice) {
// The row was inserted but the re-fetch came back empty (transient DB
// issue). Roll back rather than crash on a null cast inside the engine —
// and surface it as a fetch failure, not an opaque booking error.
await supabase.from('invoices').delete().eq('id', invoice.id)
log.error('self-billed invoice re-fetch returned no row before booking; rolled back', undefined, {
invoiceId: invoice.id,
})
return errorResponseFromCode('INVOICE_CREATE_INSERT_FAILED', log, {
requestId,
details: { stage: 'refetch_before_booking' },
})
}
try {
const journalEntry = await createInvoiceJournalEntry(
supabase,
companyId!,
user.id,
completeInvoice as Invoice,
entityType,
customer.name,
{ descriptionPrefix: 'Självfaktura', numberOverride: input.external_invoice_number },
)
if (!journalEntry) {
// No open fiscal period for the invoice date — roll the row back so we
// never leave an unbooked self-billing sale sitting as 'sent'.
await supabase.from('invoices').delete().eq('id', invoice.id)
return NextResponse.json(
{ error: 'Ingen öppen bokföringsperiod för fakturadatumet', type: 'validation_error' },
{ status: 400 },
)
}
const { error: linkError } = await supabase
.from('invoices')
.update({ journal_entry_id: journalEntry.id })
.eq('id', invoice.id)
.eq('company_id', companyId!)
if (linkError) {
// The verifikat is already committed (immutable) — don't roll it back
// over a failed convenience link. Log loudly: this is the exact write
// that silently no-ops if the journal_entry_id column is ever missing
// again (it was absent in prod for months before 20260613100000).
log.error('self-billed invoice booked but journal_entry_id link failed', linkError, {
invoiceId: invoice.id,
journalEntryId: journalEntry.id,
})
}
} catch (err) {
await supabase.from('invoices').delete().eq('id', invoice.id)
log.error('failed to book self-billed invoice; rolled back', err as Error, { invoiceId: invoice.id })
return errorResponse(err, log, { requestId })
}
}
const { data: finalInvoice } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoice.id)
.single()
// The invoice is committed (and, under accrual, booked) by this point. If the
// final re-fetch comes back empty under transient load, fall back to the
// shapes we already hold so the 200 always carries a usable id — otherwise
// the client's redirect to /invoices/{id} would throw on a null result.
const responseInvoice = (finalInvoice ?? completeInvoice ?? invoice) as Invoice
await eventBus.emit({
type: 'invoice.created',
payload: { invoice: responseInvoice, companyId: companyId!, userId: user.id },
})
return NextResponse.json({ data: responseInvoice })
},
{ requireWrite: true },
)
-1
View File
@@ -792,7 +792,6 @@ export async function POST(request: Request) {
company_id: companyId,
status: 'received',
source: 'upload',
document_type: 'supplier_invoice',
matched_supplier_id: supplierMap['Demokafé AB'],
extracted_data: {
supplier: { name: 'Demokafé AB' },
@@ -69,6 +69,7 @@ function enqueueHappyPath(opts: {
remaining_amount?: number
paid_amount?: number
}
accountingMethod?: string
}) {
// 1. transactions fetch
enqueue({
@@ -98,7 +99,7 @@ function enqueueHappyPath(opts: {
error: null,
})
// 3. company_settings fetch
enqueue({ data: { accounting_method: 'accrual' }, error: null })
enqueue({ data: { accounting_method: opts.accountingMethod ?? 'accrual' }, error: null })
// 4. supplier_invoices update (CAS)
enqueue({ data: [{ id: SI_UUID }], error: null })
// 5. supplier_invoice_payments insert
@@ -248,3 +249,69 @@ describe('POST /api/transactions/[id]/match-supplier-invoice — non-FX paths',
expect(res.status).toBe(200)
})
})
describe('POST /api/transactions/[id]/match-supplier-invoice — cash method + FX', () => {
it('full cross-currency settlement books at the payment rate (no FX-unsupported error)', async () => {
// Cash method, SEK account paying a 25 USD invoice. The invoice's stored
// rate (9.20 → 230 SEK) differs from the 239 SEK that actually left the
// bank — previously this was blocked. It must now succeed and hand the
// cash builder the real bank SEK so 1930 matches the bank line.
enqueueHappyPath({
transaction: { amount: -239, currency: 'SEK' },
invoice: { currency: 'USD', exchange_rate: 9.20, remaining_amount: 25 },
accountingMethod: 'cash',
})
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
expect(res.status).toBe(200)
expect(mockCreateCashEntry).toHaveBeenCalledTimes(1)
expect(mockCreatePaymentEntry).not.toHaveBeenCalled()
// settledBankSek is the 10th positional arg (index 9).
expect(mockCreateCashEntry.mock.calls[0][9]).toBe(239)
})
it('full same-currency foreign settlement passes the actual bank SEK to the cash builder', async () => {
// 19 USD invoice paid from a USD card showing amount_sek = 175.28, while
// the invoice was captured at 9.20 (174.80). Full settlement → booked at
// the payment rate (175.28), no kursdifferens.
enqueueHappyPath({
transaction: { amount: -19, currency: 'USD', amount_sek: -175.28 },
invoice: { currency: 'USD', exchange_rate: 9.20, remaining_amount: 19 },
accountingMethod: 'cash',
})
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
expect(res.status).toBe(200)
expect(mockCreateCashEntry.mock.calls[0][9]).toBe(175.28)
})
it('foreign tx with no amount_sek books at the invoice rate, not the raw foreign amount', async () => {
// The bank line carries no stored SEK (amount_sek null). The old fallback
// treated 19 USD as 19 SEK → "19 kr". We must instead use the invoice's
// rate (≈175 kr): no settledBankSek override is passed (FX diff is 0,
// there's no independent bank figure) and the entry is NOT blocked.
enqueueHappyPath({
transaction: { amount: -19, currency: 'USD', amount_sek: null },
invoice: { currency: 'USD', exchange_rate: 9.225, remaining_amount: 19 },
accountingMethod: 'cash',
})
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
expect(res.status).toBe(200)
expect(mockCreateCashEntry).toHaveBeenCalledTimes(1)
// No bogus settledBankSek=19 override — the builder uses the invoice rate.
expect(mockCreateCashEntry.mock.calls[0][9]).toBeUndefined()
})
it('PARTIAL foreign payment under the cash method is still rejected', async () => {
// Paying only 10 of 19 USD remaining. The cash builder books the whole
// invoice, so a partial bank amount cannot pin the entry — still blocked.
enqueueHappyPath({
transaction: { amount: -10, currency: 'USD', amount_sek: -92.25 },
invoice: { currency: 'USD', exchange_rate: 9.20, remaining_amount: 19 },
accountingMethod: 'cash',
})
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res)
expect(status).toBe(400)
expect(body.error.code).toBe('MATCH_SI_CASH_FX_UNSUPPORTED')
expect(mockCreateCashEntry).not.toHaveBeenCalled()
})
})
@@ -45,7 +45,10 @@ export const GET = withRouteContext(
const { data: transaction, error: txErr } = await supabase
.from('transactions')
.select('id, date, amount, currency')
// amount_sek is needed for the cash-method preview: a foreign-currency
// settlement is translated at the payment-date rate (the SEK that left
// the bank), mirroring the committed verifikat from the POST handler.
.select('id, date, amount, currency, amount_sek')
.eq('id', transactionId)
.eq('company_id', companyId)
.single()
@@ -84,6 +87,23 @@ export const GET = withRouteContext(
const si = invoice as SupplierInvoice & { items?: SupplierInvoiceItem[] }
const items = si.items ?? []
// Kontantmetoden books the expense AT PAYMENT at the payment-date rate
// (the SEK that actually left the bank), so translate this preview the
// same way the committed verifikat does. The bank SEK is only known when
// the transaction is in SEK or carries a stored amount_sek; for a foreign
// transaction without it we fall back to the invoice's own rate (the raw
// foreign amount must never be used — that would render 19 USD as 19 kr).
const bankSek =
transaction.currency === 'SEK'
? Math.abs(transaction.amount)
: transaction.amount_sek != null
? Math.abs(transaction.amount_sek)
: null
const cashRate =
bankSek != null && si.currency !== 'SEK' && si.total > 0
? bankSek / si.total
: si.exchange_rate
// Mirror createSupplierInvoiceCashEntry: per-item expense debit + VAT
// debit + bank credit. We only need a faithful preview, not exact
// account-mapping fidelity — show one aggregate expense line per item
@@ -92,8 +112,8 @@ export const GET = withRouteContext(
let totalVatSek = 0
if (items.length > 0) {
for (const it of items) {
const lineTotal = resolveSekAmount(it.line_total, null, si.currency, si.exchange_rate)
const vat = resolveSekAmount(it.vat_amount, null, si.currency, si.exchange_rate)
const lineTotal = resolveSekAmount(it.line_total, null, si.currency, cashRate)
const vat = resolveSekAmount(it.vat_amount, null, si.currency, cashRate)
const expenseAcct = (it as { expense_account?: string | null }).expense_account ?? '4000'
lines.push({
account_number: expenseAcct,
@@ -105,8 +125,11 @@ export const GET = withRouteContext(
totalVatSek += vat
}
} else {
const subSek = resolveSekAmount(si.subtotal, si.subtotal_sek, si.currency, si.exchange_rate)
const vatSek = resolveSekAmount(si.vat_amount, si.vat_amount_sek, si.currency, si.exchange_rate)
// Pass null for the pre-computed SEK so cashRate (payment-date rate)
// drives the translation — resolveSekAmount would otherwise prefer the
// invoice-rate *_sek columns and ignore the rate.
const subSek = resolveSekAmount(si.subtotal, null, si.currency, cashRate)
const vatSek = resolveSekAmount(si.vat_amount, null, si.currency, cashRate)
lines.push({
account_number: '4000',
debit_amount: Math.round(subSek * 100) / 100,
@@ -116,32 +116,38 @@ export const POST = withRouteContext(
? txAmountAbs
: invoice.remaining_amount
// Actual SEK leaving the bank — what really moved out of 1930. For a
// SEK transaction this is just the absolute amount; for a foreign-
// currency transaction we use the SEK conversion stored at import.
const actualBankSek =
// SEK that actually left the bank, when we know it. SEK transaction → the
// absolute amount; foreign transaction with a stored amount_sek → that
// value; foreign transaction WITHOUT amount_sek → unknown (null). The raw
// foreign amount must never stand in here — treating 19 USD as 19 SEK is
// exactly the bug that books "19 kr" on a ~175 kr payment.
const bankSekStored =
transaction.currency === 'SEK'
? txAmountAbs
: (transaction.amount_sek != null
? Math.abs(transaction.amount_sek)
: txAmountAbs)
: transaction.amount_sek != null
? Math.abs(transaction.amount_sek)
: null
// SEK value that's actually sitting on 2440 for this payment portion:
// SEK the invoice was booked at for this payment portion:
// - SEK invoice: face value = paymentAmountInvoiceCurrency
// - Non-SEK invoice w/ exchange_rate: portion × rate
// - Non-SEK invoice w/o exchange_rate: can't compute precisely; fall
// back to actualBankSek (no FX diff, plain SEK booking)
// FX diff hits 7960/3960 so 2440 clears cleanly instead of leaving a
// residual. Triggered whenever bank-paid SEK differs from booked SEK —
// happens for any currency mismatch (SEK→EUR, EUR→SEK, EUR→USD), not
// just non-SEK invoices.
// - Non-SEK invoice w/o exchange_rate: can't compute (null)
const invoiceFxRate = invoice.exchange_rate ?? null
const originalBookedSek =
const bookedSek =
invoice.currency === 'SEK'
? paymentAmountInvoiceCurrency
: invoiceFxRate && invoiceFxRate > 0
? Math.round(paymentAmountInvoiceCurrency * invoiceFxRate * 100) / 100
: actualBankSek
: null
// Actual SEK leaving the bank. Prefer the stored bank figure; if a foreign
// transaction has no amount_sek, fall back to the invoice's booked SEK so
// the magnitude is right (→ exchangeRateDifference 0, i.e. "no independent
// bank figure to reconcile against"). Last resort, with no invoice rate
// either, is the raw amount. The FX diff hits 7960/3960 so 2440 clears
// cleanly whenever bank-paid SEK genuinely differs from booked SEK.
const actualBankSek = bankSekStored ?? bookedSek ?? txAmountAbs
const originalBookedSek = bookedSek ?? actualBankSek
// Positive = gain (AP credited at more SEK than the bank actually paid).
// Negative = loss (bank paid more SEK than the AP we owed).
@@ -171,15 +177,24 @@ export const POST = withRouteContext(
const siAlreadyBooked = !!(invoice as { registration_journal_entry_id?: string | null }).registration_journal_entry_id
const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash'
// A full settlement pays off the whole remaining balance. Cross-currency
// matches always do (paymentAmountInvoiceCurrency is clamped to
// invoice.remaining_amount above); same-currency does when the bank amount
// covers the remaining balance.
const fullSettlement =
transaction.currency !== invoice.currency ||
txAmountAbs >= invoice.remaining_amount - 0.005
// Cash method (kontantmetoden) collapses registration + payment into a
// single entry that credits 1930 at sum(expenses_SEK). It has no
// exchange_rate_difference path — if the actual bank SEK differs from
// the invoice's booked SEK, the 1930 credit won't match the bank
// transaction and we'd silently leave a reconciliation gap. Block the
// combination and ask the user to switch to accrual or do a manual JE.
// Only applies to true cash-method invoices — accrual-booked invoices
// never hit the cash branch.
if (useCashEntry && exchangeRateDifference !== 0) {
// single entry. Under the cash method the expense is recognised AT PAYMENT
// at the payment-date rate, so there is no kursvinst/kursförlust — we hand
// the builder the actual bank SEK (settledBankSek) and it translates the
// whole verifikat to that, leaving 1930 equal to the bank transaction.
// The only combination we still can't model is a PARTIAL cash-method
// payment across rates: the cash builder books the full invoice, so a
// partial bank amount can't pin the entry cleanly. That narrow case stays
// blocked (switch to accrual or book manually).
if (useCashEntry && exchangeRateDifference !== 0 && !fullSettlement) {
return errorResponseFromCode('MATCH_SI_CASH_FX_UNSUPPORTED', txLog, {
requestId,
details: {
@@ -229,6 +244,12 @@ export const POST = withRouteContext(
(invoice.items || []) as SupplierInvoiceItem[],
transaction.date,
invoice.supplier?.supplier_type || 'swedish_business',
undefined, // supplierName (unchanged default)
undefined, // paymentAccount (unchanged default 1930)
// Pin a foreign-currency settlement to the payment-date rate so 1930
// equals the bank movement (kontantmetoden books the expense at
// payment). No-op for SEK invoices and same-rate settlements.
exchangeRateDifference !== 0 && fullSettlement ? actualBankSek : undefined,
)
if (journalEntry) journalEntryId = journalEntry.id
} else {
@@ -42,7 +42,7 @@ registerEndpoint({
doNotUseFor:
'Categorizing a direct supplier expense without an invoice — use `:categorize`. Matching to a customer invoice — use `:match-invoice`. Bulk auto-match — `POST /reconciliation/bank/run`.',
pitfalls: [
'Cash-method companies cannot match across currencies (MATCH_SI_CASH_FX_UNSUPPORTED) — switch to accrual or book FX manually.',
'Cash-method companies can settle a foreign invoice in full (booked at the payment-date rate); only a PARTIAL cash-method payment across currencies is rejected (MATCH_SI_CASH_FX_UNSUPPORTED) — pay in full, switch to accrual, or book manually.',
'Transaction must be negative (amount < 0). Positive returns MATCH_SI_NOT_EXPENSE.',
'Supplier invoice must NOT be paid/credited already. paid/credited returns MATCH_SI_ALREADY_PAID; registered/approved/partially_paid/overdue are matchable.',
'Idempotency-Key is mandatory.',
@@ -182,19 +182,28 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
const txAmountAbs = Math.abs(transaction.amount)
const paymentAmountInvoiceCurrency =
transaction.currency === invoice.currency ? txAmountAbs : invoice.remaining_amount
const actualBankSek =
// SEK that actually left the bank, when known. A foreign transaction with
// no stored amount_sek is `null` here — the raw foreign amount must never
// stand in (treating 19 USD as 19 SEK books "19 kr" on a ~175 kr payment).
const bankSekStored =
transaction.currency === 'SEK'
? txAmountAbs
: transaction.amount_sek != null
? Math.abs(transaction.amount_sek)
: txAmountAbs
: null
const invoiceFxRate = invoice.exchange_rate ?? null
const originalBookedSek =
// SEK the invoice was booked at for this payment portion (null if the
// invoice is foreign and carries no exchange_rate).
const bookedSek =
invoice.currency === 'SEK'
? paymentAmountInvoiceCurrency
: invoiceFxRate && invoiceFxRate > 0
? Math.round(paymentAmountInvoiceCurrency * invoiceFxRate * 100) / 100
: actualBankSek
: null
// Prefer the stored bank SEK; fall back to the invoice's booked SEK (right
// magnitude, FX diff 0); last resort the raw amount.
const actualBankSek = bankSekStored ?? bookedSek ?? txAmountAbs
const originalBookedSek = bookedSek ?? actualBankSek
const exchangeRateDifference =
Math.round((originalBookedSek - actualBankSek) * 100) / 100
const paymentAmountSek =
@@ -215,7 +224,20 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
const siAlreadyBooked = !!(invoice as { registration_journal_entry_id?: string | null }).registration_journal_entry_id
const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash'
if (useCashEntry && exchangeRateDifference !== 0) {
// Full settlement = the bank amount pays off the whole remaining balance.
// Cross-currency always settles the remaining (paymentAmountInvoiceCurrency
// is clamped to invoice.remaining_amount above).
const fullSettlement =
transaction.currency !== invoice.currency ||
txAmountAbs >= invoice.remaining_amount - 0.005
// Under kontantmetoden the expense is recognised AT PAYMENT (payment-date
// rate), so a full foreign-currency settlement has no kursdifferens — the
// builder translates the whole entry to the actual bank SEK (settledBankSek)
// below, leaving 1930 equal to the bank line. Only a PARTIAL cash-method
// payment across rates can't be modelled cleanly (the builder books the
// full invoice), so that narrow case stays blocked.
if (useCashEntry && exchangeRateDifference !== 0 && !fullSettlement) {
return v1ErrorResponseFromCode('MATCH_SI_CASH_FX_UNSUPPORTED', txLog, {
requestId: ctx.requestId,
details: {
@@ -268,6 +290,11 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
(invoice.items || []) as SupplierInvoiceItem[],
transaction.date,
invoice.supplier?.supplier_type || 'swedish_business',
undefined, // supplierName (unchanged default)
undefined, // paymentAccount (unchanged default 1930)
// Pin a foreign-currency settlement to the payment-date rate so 1930
// equals the bank movement. No-op for SEK / same-rate settlements.
exchangeRateDifference !== 0 && fullSettlement ? actualBankSek : undefined,
)
if (je) journalEntryId = je.id
} else {