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