Fix/supp ag fb (#1023)
* fix: prevent credit notes from entering payment flow * fix: persist and display customer personal numbers * feat: configure automatic invoice reminder days * fix: issue credit notes through send flow * chore: add repository agent guidance * feat(mcp): route tools across user companies * fix(articles): delete unused register entries * feat(invoices): improve issued invoice actions * feat(supplier-invoices): retain uploaded source documents * docs: record implementation decisions * feat: enhance customer personal number handling and validation - Updated CustomerForm to allow personal numbers in the format of "********-1234" for individual customers. - Added validation to ensure personal numbers are only accepted for individual customers in CreateCustomerSchema. - Implemented masking and encryption for personal numbers to enhance data protection. - Introduced new utility functions for masking and encrypting personal numbers. - Added database migration to enforce unique constraints on credit note relationships and prevent duplicate entries. - Enhanced error handling and logging for credit note issuance and invoice processing. - Updated tests to cover new credit note creation guards and personal number handling. * test: enhance list companies test with supabase query mocks
This commit is contained in:
@@ -111,10 +111,9 @@ export const PATCH = withRouteContext(
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
// DELETE soft-deactivates (active = false) rather than hard-deleting. Articles
|
||||
// are master data referenced by historical invoice lines via a (frozen) copy;
|
||||
// keeping the row preserves the register's audit trail and the article number.
|
||||
// Re-activate by PATCHing { active: true }.
|
||||
// Articles are master data, while invoice lines hold frozen copies of the
|
||||
// accounting values. An article may therefore be deleted only while no invoice
|
||||
// line references it. The preflight also covers draft invoices.
|
||||
export const DELETE = withRouteContext(
|
||||
'article.delete',
|
||||
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||||
@@ -122,28 +121,66 @@ export const DELETE = withRouteContext(
|
||||
const { user, supabase, companyId, log, requestId } = ctx
|
||||
const opLog = log.child({ articleId: id })
|
||||
|
||||
const { data, error } = await supabase
|
||||
const { error: articleError } = await supabase
|
||||
.from('articles')
|
||||
.update({ active: false })
|
||||
.select('id')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
if (articleError) {
|
||||
if (articleError.code === 'PGRST116') {
|
||||
return errorResponseFromCode('ARTICLE_NOT_FOUND', opLog, { requestId })
|
||||
}
|
||||
opLog.error('article deactivate failed', error)
|
||||
return errorResponseFromCode('ARTICLE_UPDATE_FAILED', opLog, {
|
||||
opLog.error('article lookup before delete failed', articleError)
|
||||
return errorResponseFromCode('ARTICLE_DELETE_FAILED', opLog, {
|
||||
requestId,
|
||||
details: { reason: error.message },
|
||||
details: { reason: articleError.message },
|
||||
})
|
||||
}
|
||||
|
||||
const { count: usageCount, error: usageError } = await supabase
|
||||
.from('invoice_items')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('article_id', id)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (usageError) {
|
||||
opLog.error('article usage check failed', usageError)
|
||||
return errorResponseFromCode('ARTICLE_DELETE_FAILED', opLog, {
|
||||
requestId,
|
||||
details: { reason: usageError.message },
|
||||
})
|
||||
}
|
||||
|
||||
if ((usageCount ?? 0) > 0) {
|
||||
return errorResponseFromCode('ARTICLE_IN_USE', opLog, { requestId })
|
||||
}
|
||||
|
||||
const { error: deleteError, count: deletedCount } = await supabase
|
||||
.from('articles')
|
||||
.delete({ count: 'exact' })
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (deleteError) {
|
||||
if (deleteError.code === '23503') {
|
||||
return errorResponseFromCode('ARTICLE_IN_USE', opLog, { requestId })
|
||||
}
|
||||
opLog.error('article delete failed', deleteError)
|
||||
return errorResponseFromCode('ARTICLE_DELETE_FAILED', opLog, {
|
||||
requestId,
|
||||
details: { reason: deleteError.message },
|
||||
})
|
||||
}
|
||||
|
||||
if (deletedCount === 0) {
|
||||
return errorResponseFromCode('ARTICLE_NOT_FOUND', opLog, { requestId })
|
||||
}
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'article.updated',
|
||||
payload: { article: data as Article, companyId: companyId!, userId: user.id },
|
||||
type: 'article.deleted',
|
||||
payload: { articleId: id, companyId, userId: user.id },
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* Tests for GET/PATCH/DELETE /api/articles/[id] (artikelregister).
|
||||
*
|
||||
* DELETE soft-deactivates (active = false) rather than hard-deleting, so the
|
||||
* article and its number survive for history. PATCH is a sparse update.
|
||||
* DELETE permanently removes only articles that have never been used on an
|
||||
* invoice line. PATCH is a sparse update.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createQueuedMockSupabase, createMockRequest, createMockRouteParams, parseJsonResponse } from '@/tests/helpers'
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
@@ -97,13 +99,69 @@ describe('GET/PATCH/DELETE /api/articles/[id]', () => {
|
||||
expect(body.error.code).toBe('ARTICLE_REVENUE_ACCOUNT_INVALID')
|
||||
})
|
||||
|
||||
it('DELETE soft-deactivates and returns success', async () => {
|
||||
enqueue({ data: { id: 'a1', active: false } })
|
||||
it('DELETE returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const response = await DELETE(createMockRequest('/api/articles/a1', { method: 'DELETE' }), createMockRouteParams({ id: 'a1' }))
|
||||
const response = await DELETE(
|
||||
createMockRequest('/api/articles/a1', { method: 'DELETE' }),
|
||||
createMockRouteParams({ id: 'a1' }),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('DELETE returns 404 when the article is not found', async () => {
|
||||
enqueue({ data: null, error: { code: 'PGRST116', message: 'not found' } })
|
||||
|
||||
const response = await DELETE(
|
||||
createMockRequest('/api/articles/a1', { method: 'DELETE' }),
|
||||
createMockRouteParams({ id: 'a1' }),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error.code).toBe('ARTICLE_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('DELETE rejects an article used on an invoice line', async () => {
|
||||
enqueue({ data: { id: 'a1' }, error: null })
|
||||
enqueue({ data: null, error: null, count: 1 })
|
||||
|
||||
const response = await DELETE(
|
||||
createMockRequest('/api/articles/a1', { method: 'DELETE' }),
|
||||
createMockRouteParams({ id: 'a1' }),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('ARTICLE_IN_USE')
|
||||
expect(supabase.from).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('DELETE permanently removes an unused article', async () => {
|
||||
enqueue({ data: { id: 'a1' }, error: null })
|
||||
enqueue({ data: null, error: null, count: 0 })
|
||||
enqueue({ data: null, error: null, count: 1 })
|
||||
|
||||
const emitSpy = vi.spyOn(eventBus, 'emit')
|
||||
const response = await DELETE(
|
||||
createMockRequest('/api/articles/a1', { method: 'DELETE' }),
|
||||
createMockRouteParams({ id: 'a1' }),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ success: boolean }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(supabase.from).toHaveBeenNthCalledWith(1, 'articles')
|
||||
expect(supabase.from).toHaveBeenNthCalledWith(2, 'invoice_items')
|
||||
expect(supabase.from).toHaveBeenNthCalledWith(3, 'articles')
|
||||
expect(emitSpy).toHaveBeenCalledWith({
|
||||
type: 'article.deleted',
|
||||
payload: { articleId: 'a1', companyId: 'company-1', userId: 'user-1' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { UpdateCustomerSchema } from '@/lib/api/schemas'
|
||||
import { validateVatNumber } from '@/lib/vat/vies-client'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { encryptCustomerPersonalNumber, maskCustomerRow } from '@/lib/customers/protect-personal-number'
|
||||
|
||||
export const GET = withRouteContext(
|
||||
'customer.get',
|
||||
@@ -37,7 +38,7 @@ export const GET = withRouteContext(
|
||||
.eq('company_id', companyId)
|
||||
.order('invoice_date', { ascending: false })
|
||||
|
||||
return NextResponse.json({ data: { ...data, invoices: invoices || [] } })
|
||||
return NextResponse.json({ data: { ...maskCustomerRow(data), invoices: invoices || [] } })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -55,6 +56,26 @@ export const PATCH = withRouteContext(
|
||||
if (!result.success) return result.response
|
||||
const body = result.data
|
||||
|
||||
const { data: existing, error: existingError } = await supabase
|
||||
.from('customers')
|
||||
.select('id, customer_type')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (existingError || !existing) {
|
||||
if (existingError?.code === 'PGRST116') {
|
||||
return errorResponseFromCode('CUSTOMER_NOT_FOUND', opLog, { requestId })
|
||||
}
|
||||
opLog.error('customer lookup before update failed', existingError)
|
||||
return errorResponseFromCode('CUSTOMER_UPDATE_FAILED', opLog, { requestId })
|
||||
}
|
||||
|
||||
const effectiveType = body.customer_type ?? existing.customer_type
|
||||
if (body.personal_number && effectiveType !== 'individual') {
|
||||
return errorResponseFromCode('CUSTOMER_PERSONAL_NUMBER_NOT_ALLOWED', opLog, { requestId })
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {}
|
||||
if (body.name !== undefined) updateData.name = body.name
|
||||
if (body.customer_type !== undefined) updateData.customer_type = body.customer_type
|
||||
@@ -69,6 +90,11 @@ export const PATCH = withRouteContext(
|
||||
if (body.country !== undefined) updateData.country = body.country
|
||||
if (body.org_number !== undefined) updateData.org_number = body.org_number
|
||||
if (body.vat_number !== undefined) updateData.vat_number = body.vat_number
|
||||
if (body.personal_number !== undefined) {
|
||||
updateData.personal_number = encryptCustomerPersonalNumber(body.personal_number)
|
||||
} else if (body.customer_type !== undefined && effectiveType !== 'individual') {
|
||||
updateData.personal_number = null
|
||||
}
|
||||
if (body.language !== undefined) updateData.language = body.language
|
||||
if (body.default_payment_terms !== undefined) updateData.default_payment_terms = body.default_payment_terms
|
||||
if (body.notes !== undefined) updateData.notes = body.notes
|
||||
@@ -82,6 +108,9 @@ export const PATCH = withRouteContext(
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
return errorResponseFromCode('CUSTOMER_NOT_FOUND', opLog, { requestId })
|
||||
}
|
||||
if (error.code === '23505') {
|
||||
return errorResponseFromCode('CUSTOMER_DUPLICATE_ORG_NUMBER', opLog, {
|
||||
requestId,
|
||||
@@ -127,7 +156,7 @@ export const PATCH = withRouteContext(
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
return NextResponse.json({ data: maskCustomerRow(data) })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
import { decryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
|
||||
const captured: { insert: unknown[]; update: unknown[] } = { insert: [], update: [] }
|
||||
let queryResult: { data: unknown; error: unknown } = { data: null, error: null }
|
||||
|
||||
const buildChain = (): unknown =>
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (value: unknown) => void) => resolve(queryResult)
|
||||
}
|
||||
return (...args: unknown[]) => {
|
||||
if (prop === 'insert') captured.insert.push(args[0])
|
||||
if (prop === 'update') captured.update.push(args[0])
|
||||
return buildChain()
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const supabase = {
|
||||
from: vi.fn(() => buildChain()),
|
||||
rpc: vi.fn(() => buildChain()),
|
||||
}
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const requireWriteMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
|
||||
import { POST } from '../route'
|
||||
import { PATCH } from '../[id]/route'
|
||||
|
||||
type CustomerWrite = { personal_number?: string | null }
|
||||
|
||||
describe('personal_number on customer routes', () => {
|
||||
const routeParams = { params: Promise.resolve({ id: 'customer-1' }) }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
captured.insert.length = 0
|
||||
captured.update.length = 0
|
||||
queryResult = { data: null, error: null }
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
it('returns 401 before creating a customer when unauthenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('/api/customers', {
|
||||
method: 'POST',
|
||||
body: { name: 'Anna Andersson', customer_type: 'individual' },
|
||||
}),
|
||||
{ params: Promise.resolve({}) },
|
||||
)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(captured.insert).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns 400 for an invalid personal number', async () => {
|
||||
const response = await POST(
|
||||
createMockRequest('/api/customers', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: 'Anna Andersson',
|
||||
customer_type: 'individual',
|
||||
personal_number: 'not-a-personal-number',
|
||||
},
|
||||
}),
|
||||
{ params: Promise.resolve({}) },
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(captured.insert).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('stores the personal number when creating a private customer', async () => {
|
||||
queryResult = {
|
||||
data: {
|
||||
id: 'customer-1',
|
||||
name: 'Anna Andersson',
|
||||
customer_type: 'individual',
|
||||
personal_number: '19900101-1234',
|
||||
},
|
||||
error: null,
|
||||
}
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('/api/customers', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: 'Anna Andersson',
|
||||
customer_type: 'individual',
|
||||
personal_number: '19900101-1234',
|
||||
},
|
||||
}),
|
||||
{ params: Promise.resolve({}) },
|
||||
)
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ data: { personal_number: string } }>(response)
|
||||
expect(status).toBe(200)
|
||||
const encrypted = (captured.insert[0] as CustomerWrite).personal_number as string
|
||||
expect(encrypted).not.toBe('19900101-1234')
|
||||
expect(decryptPersonnummer(encrypted)).toBe('19900101-1234')
|
||||
expect(body.data.personal_number).toBe('********-1234')
|
||||
})
|
||||
|
||||
it('updates the personal number for an existing private customer', async () => {
|
||||
queryResult = {
|
||||
data: {
|
||||
id: 'customer-1',
|
||||
customer_type: 'individual',
|
||||
personal_number: '900101-1234',
|
||||
},
|
||||
error: null,
|
||||
}
|
||||
|
||||
const response = await PATCH(
|
||||
createMockRequest('/api/customers/customer-1', {
|
||||
method: 'PATCH',
|
||||
body: { personal_number: '900101-1234' },
|
||||
}),
|
||||
routeParams,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const encrypted = (captured.update[0] as CustomerWrite).personal_number as string
|
||||
expect(encrypted).not.toBe('900101-1234')
|
||||
expect(decryptPersonnummer(encrypted)).toBe('900101-1234')
|
||||
})
|
||||
|
||||
it('clears the personal number when null is sent', async () => {
|
||||
queryResult = {
|
||||
data: { id: 'customer-1', customer_type: 'individual', personal_number: null },
|
||||
error: null,
|
||||
}
|
||||
|
||||
const response = await PATCH(
|
||||
createMockRequest('/api/customers/customer-1', {
|
||||
method: 'PATCH',
|
||||
body: { personal_number: null },
|
||||
}),
|
||||
routeParams,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect((captured.update[0] as CustomerWrite).personal_number).toBeNull()
|
||||
})
|
||||
|
||||
it('does not change the personal number when the field is omitted', async () => {
|
||||
queryResult = {
|
||||
data: { id: 'customer-1', customer_type: 'individual', name: 'Anna A' },
|
||||
error: null,
|
||||
}
|
||||
|
||||
const response = await PATCH(
|
||||
createMockRequest('/api/customers/customer-1', {
|
||||
method: 'PATCH',
|
||||
body: { name: 'Anna A' },
|
||||
}),
|
||||
routeParams,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(captured.update[0]).not.toHaveProperty('personal_number')
|
||||
})
|
||||
|
||||
it('rejects a personal number for a corporate customer', async () => {
|
||||
queryResult = {
|
||||
data: { id: 'customer-1', customer_type: 'swedish_business' },
|
||||
error: null,
|
||||
}
|
||||
|
||||
const response = await PATCH(
|
||||
createMockRequest('/api/customers/customer-1', {
|
||||
method: 'PATCH',
|
||||
body: { personal_number: '900101-1234' },
|
||||
}),
|
||||
routeParams,
|
||||
)
|
||||
|
||||
const { body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
expect(response.status).toBe(400)
|
||||
expect(body.error.code).toBe('CUSTOMER_PERSONAL_NUMBER_NOT_ALLOWED')
|
||||
expect(captured.update).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns 404 when the customer does not exist', async () => {
|
||||
queryResult = {
|
||||
data: null,
|
||||
error: { code: 'PGRST116', message: 'No rows returned' },
|
||||
}
|
||||
|
||||
const response = await PATCH(
|
||||
createMockRequest('/api/customers/missing', {
|
||||
method: 'PATCH',
|
||||
body: { personal_number: '900101-1234' },
|
||||
}),
|
||||
{ params: Promise.resolve({ id: 'missing' }) },
|
||||
)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import { validateVatNumber } from '@/lib/vat/vies-client'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import type { Customer } from '@/types'
|
||||
import { encryptCustomerPersonalNumber, maskCustomerRow } from '@/lib/customers/protect-personal-number'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -26,7 +27,7 @@ export const GET = withRouteContext(
|
||||
return errorResponse(error, log, { requestId })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
return NextResponse.json({ data: (data ?? []).map(maskCustomerRow) })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -59,6 +60,7 @@ export const POST = withRouteContext(
|
||||
country: body.country || 'Sweden',
|
||||
org_number: body.org_number,
|
||||
vat_number: body.vat_number,
|
||||
personal_number: encryptCustomerPersonalNumber(body.personal_number),
|
||||
language: body.language || 'sv',
|
||||
default_payment_terms: body.default_payment_terms || 30,
|
||||
notes: body.notes,
|
||||
@@ -104,12 +106,13 @@ export const POST = withRouteContext(
|
||||
}
|
||||
}
|
||||
|
||||
const safeCustomer = maskCustomerRow(data)
|
||||
await eventBus.emit({
|
||||
type: 'customer.created',
|
||||
payload: { customer: data as Customer, companyId: companyId!, userId: user.id },
|
||||
payload: { customer: safeCustomer as Customer, companyId: companyId!, userId: user.id },
|
||||
})
|
||||
|
||||
return NextResponse.json({ data })
|
||||
return NextResponse.json({ data: safeCustomer })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -18,13 +18,14 @@ vi.mock('@/lib/init', () => ({
|
||||
|
||||
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 }),
|
||||
}))
|
||||
|
||||
import { DELETE } from '../route'
|
||||
import { DELETE, PATCH } from '../route'
|
||||
|
||||
describe('DELETE /api/invoices/[id]', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
@@ -175,3 +176,53 @@ describe('DELETE /api/invoices/[id]', () => {
|
||||
expect(status).toBe(500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PATCH /api/invoices/[id]', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
eventBus.clear()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({
|
||||
data: { user: { id: 'user-1', email: 'test@test.se' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects editing a credit-note draft', async () => {
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'credit-1',
|
||||
status: 'draft',
|
||||
invoice_number: 'KR-F-2026001',
|
||||
journal_entry_id: null,
|
||||
is_self_billed: false,
|
||||
credited_invoice_id: '11111111-1111-4111-8111-111111111111',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
|
||||
const response = await PATCH(
|
||||
createMockRequest('/api/invoices/credit-1', {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
customer_id: '22222222-2222-4222-8222-222222222222',
|
||||
invoice_date: '2026-07-14',
|
||||
due_date: '2026-07-14',
|
||||
currency: 'SEK',
|
||||
items: [
|
||||
{
|
||||
description: 'Kredit',
|
||||
quantity: 1,
|
||||
unit: 'st',
|
||||
unit_price: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
createMockRouteParams({ id: 'credit-1' }),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('INVOICE_UPDATE_NOT_DRAFT')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -114,6 +114,42 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects a sent credit note before booking a payment', async () => {
|
||||
const invoice = makeInvoice({
|
||||
status: 'sent',
|
||||
credited_invoice_id: 'original-invoice-1',
|
||||
})
|
||||
enqueue({ data: invoice, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string; details?: unknown } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('INVOICE_PAID_NOT_PAYABLE')
|
||||
expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled()
|
||||
expect(mockCreateInvoiceCashEntry).not.toHaveBeenCalled()
|
||||
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an original invoice while an active credit-note draft exists', async () => {
|
||||
const invoice = {
|
||||
...makeInvoice({ status: 'sent', credited_invoice_id: null }),
|
||||
credit_notes: [{ id: 'credit-1', status: 'draft', creation_complete: true }],
|
||||
}
|
||||
enqueue({ data: invoice, error: null })
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' }),
|
||||
createMockRouteParams({ id: 'inv-1' }),
|
||||
)
|
||||
const { body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(body.error.code).toBe('INVOICE_PAID_NOT_PAYABLE')
|
||||
expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marks sent invoice as paid with accrual method', async () => {
|
||||
const customer = makeCustomer()
|
||||
const invoice = makeInvoice({
|
||||
|
||||
@@ -31,7 +31,7 @@ export const POST = withRouteContext(
|
||||
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.select('*, customer:customers(*), items:invoice_items(*), credit_notes:invoices!credited_invoice_id(id, status, creation_complete)')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
@@ -40,6 +40,26 @@ export const POST = withRouteContext(
|
||||
return errorResponseFromCode('INVOICE_PAID_NOT_FOUND', opLog, { requestId })
|
||||
}
|
||||
|
||||
if (invoice.credited_invoice_id) {
|
||||
return errorResponseFromCode('INVOICE_PAID_NOT_PAYABLE', opLog, {
|
||||
requestId,
|
||||
details: { reason: 'credit_note' },
|
||||
})
|
||||
}
|
||||
|
||||
const activeCreditNotes = ((invoice as { credit_notes?: Array<{
|
||||
status: string
|
||||
creation_complete?: boolean
|
||||
}> }).credit_notes ?? []).filter(
|
||||
(creditNote) => creditNote.status !== 'cancelled' && creditNote.creation_complete !== false,
|
||||
)
|
||||
if (activeCreditNotes.length > 0) {
|
||||
return errorResponseFromCode('INVOICE_PAID_NOT_PAYABLE', opLog, {
|
||||
requestId,
|
||||
details: { reason: 'active_credit_note' },
|
||||
})
|
||||
}
|
||||
|
||||
if (invoice.status !== 'sent' && invoice.status !== 'overdue') {
|
||||
return errorResponseFromCode('INVOICE_PAID_NOT_PAYABLE', opLog, {
|
||||
requestId,
|
||||
|
||||
@@ -52,6 +52,17 @@ vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
||||
mockCreateInvoiceJournalEntry(...args),
|
||||
}))
|
||||
|
||||
const mockIssueCreditNote = vi.fn()
|
||||
vi.mock('@/lib/invoices/issue-credit-note', () => ({
|
||||
issueCreditNote: (...args: unknown[]) => mockIssueCreditNote(...args),
|
||||
creditNoteNeedsJournalEntry: (method: string, original: { status: string; journal_entry_id?: string | null; paid_at?: string | null; paid_amount?: number | null }) =>
|
||||
method === 'accrual' ||
|
||||
!!original.journal_entry_id ||
|
||||
original.status === 'paid' ||
|
||||
!!original.paid_at ||
|
||||
Math.abs(original.paid_amount ?? 0) > 0,
|
||||
}))
|
||||
|
||||
const mockUploadDocument = vi.fn()
|
||||
vi.mock('@/lib/core/documents/document-service', () => ({
|
||||
uploadDocument: (...args: unknown[]) => mockUploadDocument(...args),
|
||||
@@ -94,18 +105,66 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
|
||||
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null })
|
||||
mockRenderToBuffer.mockResolvedValue(Buffer.from('fake-pdf'))
|
||||
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
|
||||
mockIssueCreditNote.mockResolvedValue({
|
||||
complete: true,
|
||||
journalEntryId: 'credit-je-1',
|
||||
journalEntryRequired: true,
|
||||
failures: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase: mockSupabase,
|
||||
error: new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-sent', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 404 when the invoice does not exist', async () => {
|
||||
enqueue({ data: null, error: { message: 'not found' } })
|
||||
|
||||
const request = createMockRequest('/api/invoices/missing/mark-sent', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'missing' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 400 when the invoice is not a draft', async () => {
|
||||
enqueue({ data: makeInvoice({ id: 'inv-1', status: 'sent' }), error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-sent', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('archives the rendered PDF as underlag linked to the journal entry', async () => {
|
||||
enqueue({ data: invoice, error: null }) // fetch invoice
|
||||
enqueue({ data: null, error: null }) // status update
|
||||
enqueue({ data: company, error: null }) // settings
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null }) // status update
|
||||
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-7' })
|
||||
enqueue({ data: null, error: null }) // update invoice with journal_entry_id
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-sent', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ success: boolean; journal_entry_id: string | null }>(response)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
journal_entry_id: string | null
|
||||
partial?: boolean
|
||||
partial_failures?: Array<{ step: string }>
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
@@ -128,37 +187,25 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('archives the PDF even when journal entry creation fails (non-blocking)', async () => {
|
||||
it('restores the draft and fails closed when journal entry creation fails', async () => {
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
mockCreateInvoiceJournalEntry.mockRejectedValue(new Error('Period locked'))
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-sent', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ success: boolean; journal_entry_id: string | null }>(response)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.journal_entry_id).toBeNull()
|
||||
|
||||
expect(mockUploadDocument).toHaveBeenCalledTimes(1)
|
||||
expect(mockUploadDocument).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'user-1',
|
||||
'company-1',
|
||||
expect.objectContaining({ name: 'faktura-F-2026010.pdf' }),
|
||||
expect.objectContaining({
|
||||
upload_source: 'system',
|
||||
journal_entry_id: undefined,
|
||||
})
|
||||
)
|
||||
expect(status).toBe(500)
|
||||
expect(body.error.code).toBe('INVOICE_MARK_SENT_BOOK_FAILED')
|
||||
expect(mockUploadDocument).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still returns 200 when PDF archival itself fails', async () => {
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-8' })
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
@@ -183,8 +230,8 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
|
||||
})
|
||||
|
||||
enqueue({ data: proforma, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
enqueue({ data: [{ id: 'inv-2' }], error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-2/mark-sent', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-2' }))
|
||||
@@ -196,10 +243,10 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
|
||||
expect(mockRenderToBuffer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses kreditfaktura filename when archiving a credit note', async () => {
|
||||
it('issues the credit note and uses a credit-note filename when archiving it', async () => {
|
||||
const creditNote = makeInvoice({
|
||||
id: 'inv-3',
|
||||
invoice_number: 'F-2026011',
|
||||
invoice_number: 'KR-F-2026010',
|
||||
status: 'draft',
|
||||
credited_invoice_id: 'inv-1',
|
||||
customer,
|
||||
@@ -207,31 +254,132 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
|
||||
})
|
||||
|
||||
enqueue({ data: creditNote, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-9' })
|
||||
enqueue({ data: null, error: null })
|
||||
// Lookup of the original invoice's number for the credit note PDF
|
||||
enqueue({ data: { invoice_number: 'F-2026010' }, error: null })
|
||||
const original = {
|
||||
id: 'inv-1',
|
||||
invoice_number: 'F-2026010',
|
||||
status: 'sent',
|
||||
journal_entry_id: 'original-je-1',
|
||||
paid_at: null,
|
||||
paid_amount: null,
|
||||
total: 12500,
|
||||
}
|
||||
enqueue({ data: original, error: null })
|
||||
enqueue({ data: [{ id: 'inv-3' }], error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-3/mark-sent', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-3' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(mockIssueCreditNote).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
creditNote: expect.objectContaining({ id: 'inv-3' }),
|
||||
originalInvoice: original,
|
||||
accountingMethod: 'accrual',
|
||||
}),
|
||||
)
|
||||
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
|
||||
expect(mockUploadDocument).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'user-1',
|
||||
'company-1',
|
||||
expect.objectContaining({ name: 'kreditfaktura-F-2026011.pdf' }),
|
||||
expect.objectContaining({ name: 'kreditfaktura-KR-F-2026010.pdf' }),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed and restores the draft when credit-note booking cannot start', async () => {
|
||||
const creditNote = makeInvoice({
|
||||
id: 'credit-1',
|
||||
invoice_number: 'KR-F-2026010',
|
||||
status: 'draft',
|
||||
credited_invoice_id: 'inv-1',
|
||||
customer,
|
||||
items: invoice.items,
|
||||
})
|
||||
enqueue({ data: creditNote, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'inv-1',
|
||||
invoice_number: 'F-2026010',
|
||||
status: 'sent',
|
||||
journal_entry_id: 'original-je-1',
|
||||
paid_at: null,
|
||||
paid_amount: null,
|
||||
total: 12500,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [{ id: 'credit-1' }], error: null })
|
||||
mockIssueCreditNote.mockResolvedValue({
|
||||
complete: false,
|
||||
journalEntryId: null,
|
||||
journalEntryRequired: true,
|
||||
failures: [{ step: 'journal_entry', reason: 'Perioden är låst' }],
|
||||
})
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/credit-1/mark-sent', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'credit-1' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(mockUploadDocument).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('repairs a sent credit note without running the draft status transition again', async () => {
|
||||
const creditNote = makeInvoice({
|
||||
id: 'credit-1',
|
||||
invoice_number: 'KR-F-2026010',
|
||||
status: 'sent',
|
||||
credited_invoice_id: 'inv-1',
|
||||
journal_entry_id: null,
|
||||
customer,
|
||||
items: invoice.items,
|
||||
})
|
||||
enqueue({ data: creditNote, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'inv-1',
|
||||
invoice_number: 'F-2026010',
|
||||
status: 'sent',
|
||||
journal_entry_id: 'original-je-1',
|
||||
paid_at: null,
|
||||
paid_amount: null,
|
||||
total: 12500,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/invoices/credit-1/mark-sent', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'credit-1' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(mockIssueCreditNote).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('returns 409 when another request already marked the draft as sent', async () => {
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-sent', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
|
||||
expect(mockUploadDocument).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders the archived PDF as if already sent (no UTKAST banner)', async () => {
|
||||
enqueue({ data: invoice, error: null }) // fetch invoice (status: 'draft')
|
||||
enqueue({ data: null, error: null }) // status update
|
||||
enqueue({ data: company, error: null }) // settings
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null }) // status update
|
||||
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-99' })
|
||||
enqueue({ data: null, error: null }) // update invoice with journal_entry_id
|
||||
|
||||
|
||||
@@ -2,13 +2,28 @@ import { NextResponse } from 'next/server'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
|
||||
import {
|
||||
creditNoteNeedsJournalEntry,
|
||||
issueCreditNote,
|
||||
type CreditNoteOriginalInvoice,
|
||||
} from '@/lib/invoices/issue-credit-note'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { InvoicePDF } from '@/lib/invoices/pdf-template'
|
||||
import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
|
||||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
||||
import type { CompanySettings, Customer, EntityType, Invoice, InvoiceItem } from '@/types'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import type {
|
||||
AccountingMethod,
|
||||
CompanySettings,
|
||||
CreditNote,
|
||||
Customer,
|
||||
EntityType,
|
||||
Invoice,
|
||||
InvoiceItem,
|
||||
} from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -21,7 +36,7 @@ ensureInitialized()
|
||||
*/
|
||||
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'invoice.mark_sent',
|
||||
async (request, { supabase, user, companyId, log }, { params }) => {
|
||||
async (_request, { supabase, user, companyId, log, requestId }, { params }) => {
|
||||
const { id } = await params
|
||||
|
||||
// Fetch invoice
|
||||
@@ -33,14 +48,16 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
.single()
|
||||
|
||||
if (invoiceError || !invoice) {
|
||||
return NextResponse.json({ error: 'Fakturan hittades inte' }, { status: 404 })
|
||||
return errorResponseFromCode('INVOICE_NOT_FOUND', log, { requestId })
|
||||
}
|
||||
|
||||
if (invoice.status !== 'draft') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Endast utkast kan markeras som skickade' },
|
||||
{ status: 400 }
|
||||
)
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
|
||||
if (!isCreditNote && invoice.status !== 'draft') {
|
||||
return errorResponseFromCode('INVOICE_MARK_SENT_INVALID_STATUS', log, { requestId })
|
||||
}
|
||||
if (isCreditNote && !['draft', 'sent'].includes(invoice.status)) {
|
||||
return errorResponseFromCode('INVOICE_MARK_SENT_INVALID_STATUS', log, { requestId })
|
||||
}
|
||||
|
||||
// Assign invoice number now if this draft doesn't have one yet
|
||||
@@ -48,43 +65,126 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
|
||||
} catch (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 }
|
||||
)
|
||||
}
|
||||
|
||||
// Update status to sent
|
||||
const { error: updateError } = await supabase
|
||||
.from('invoices')
|
||||
.update({ status: 'sent' })
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: 'Kunde inte uppdatera status' }, { status: 500 })
|
||||
return errorResponseFromCode('INVOICE_CREATE_NUMBER_ASSIGN_FAILED', log, { requestId })
|
||||
}
|
||||
|
||||
// Fetch full company settings for PDF rendering and accounting method
|
||||
const { data: settings } = await supabase
|
||||
const { data: settings, error: settingsError } = await supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
if (settingsError || !settings) {
|
||||
return errorResponseFromCode('INVOICE_SEND_COMPANY_SETTINGS_MISSING', log, { requestId })
|
||||
}
|
||||
|
||||
const accountingMethod = (settings.accounting_method || 'accrual') as AccountingMethod
|
||||
const entityType = (settings.entity_type as EntityType) || 'enskild_firma'
|
||||
let originalInvoice: CreditNoteOriginalInvoice | undefined
|
||||
let originalInvoiceNumber: string | undefined
|
||||
|
||||
if (invoice.credited_invoice_id) {
|
||||
const { data: original } = await supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, status, journal_entry_id, paid_at, paid_amount, total')
|
||||
.eq('id', invoice.credited_invoice_id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!original) {
|
||||
return errorResponseFromCode('INVOICE_CREDIT_ORIGINAL_NOT_FOUND', log, { requestId })
|
||||
}
|
||||
|
||||
originalInvoice = original as CreditNoteOriginalInvoice
|
||||
originalInvoiceNumber = original.invoice_number ?? undefined
|
||||
}
|
||||
|
||||
const journalEntryRequired = originalInvoice
|
||||
? creditNoteNeedsJournalEntry(accountingMethod, originalInvoice)
|
||||
: false
|
||||
const isRecovery = isCreditNote && invoice.status === 'sent'
|
||||
|
||||
if (
|
||||
isRecovery &&
|
||||
originalInvoice?.status === 'credited' &&
|
||||
(!journalEntryRequired || !!invoice.journal_entry_id)
|
||||
) {
|
||||
return errorResponseFromCode('INVOICE_CREDIT_ALREADY_ISSUED', log, { requestId })
|
||||
}
|
||||
|
||||
// Compare-and-set prevents two concurrent requests from posting two journal
|
||||
// entries for the same draft.
|
||||
let statusFlipped = false
|
||||
if (!isRecovery) {
|
||||
const { data: updatedRows, error: updateError } = await supabase
|
||||
.from('invoices')
|
||||
.update({ status: 'sent' })
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'draft')
|
||||
.select('id')
|
||||
|
||||
if (updateError) {
|
||||
log.error('invoice mark-sent status update failed', updateError)
|
||||
return errorResponseFromCode('INVOICE_MARK_SENT_STATUS_FAILED', log, { requestId })
|
||||
}
|
||||
if (!updatedRows || updatedRows.length === 0) {
|
||||
return errorResponseFromCode('INVOICE_MARK_SENT_RACE', log, { requestId })
|
||||
}
|
||||
statusFlipped = true
|
||||
}
|
||||
|
||||
// Only create journal entries for real invoices (not proformas or delivery notes)
|
||||
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
|
||||
let journalEntryId: string | null = null
|
||||
if (isRealInvoice && accountingMethod === 'accrual') {
|
||||
const partialFailures: Array<{ step: string; reason: string }> = []
|
||||
|
||||
if (isCreditNote && originalInvoice) {
|
||||
const issueResult = await issueCreditNote({
|
||||
supabase,
|
||||
companyId,
|
||||
userId: user.id,
|
||||
creditNote: invoice as CreditNote,
|
||||
originalInvoice,
|
||||
entityType,
|
||||
accountingMethod,
|
||||
log,
|
||||
})
|
||||
journalEntryId = issueResult.journalEntryId
|
||||
partialFailures.push(...issueResult.failures)
|
||||
|
||||
if (!issueResult.complete) {
|
||||
// If no immutable entry was created, restoring the draft is safe and
|
||||
// lets the user fix the period/account issue before trying again.
|
||||
if (statusFlipped && issueResult.journalEntryRequired && !issueResult.journalEntryId) {
|
||||
await supabase
|
||||
.from('invoices')
|
||||
.update({ status: 'draft' })
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'sent')
|
||||
.is('journal_entry_id', null)
|
||||
}
|
||||
return errorResponseFromCode(
|
||||
issueResult.repairRequired
|
||||
? 'INVOICE_CREDIT_REPAIR_REQUIRED'
|
||||
: 'INVOICE_CREDIT_ISSUE_INCOMPLETE',
|
||||
log,
|
||||
{
|
||||
requestId,
|
||||
details: { failure_steps: issueResult.failures.map((failure) => failure.step) },
|
||||
},
|
||||
)
|
||||
}
|
||||
} else if (isRealInvoice && accountingMethod === 'accrual') {
|
||||
try {
|
||||
const journalEntry = await createInvoiceJournalEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
(settings?.entity_type as EntityType) || 'enskild_firma',
|
||||
entityType,
|
||||
invoice.customer?.name
|
||||
)
|
||||
if (journalEntry) {
|
||||
@@ -100,12 +200,16 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
invoice as Invoice,
|
||||
(invoice.items as InvoiceItem[] | null) ?? [],
|
||||
journalEntry.id,
|
||||
(settings?.entity_type as EntityType) || 'enskild_firma',
|
||||
entityType,
|
||||
)
|
||||
if (accrual.failed > 0) {
|
||||
log.error('accrual schedule creation failed on mark-sent', {
|
||||
failed: accrual.failed,
|
||||
})
|
||||
partialFailures.push({
|
||||
step: 'accrual_schedules',
|
||||
reason: `${accrual.failed} periodisering(ar) kunde inte skapas`,
|
||||
})
|
||||
}
|
||||
|
||||
const { error: linkError } = await supabase
|
||||
@@ -122,32 +226,55 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
log.error('mark-sent: journal_entry_id link to invoice failed', linkError, {
|
||||
journalEntryId: journalEntry.id,
|
||||
})
|
||||
partialFailures.push({
|
||||
step: 'journal_link',
|
||||
reason: 'Verifikatet skapades men kunde inte kopplas till fakturan.',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
partialFailures.push({
|
||||
step: 'journal_entry',
|
||||
reason: 'Ingen öppen bokföringsperiod hittades för fakturans datum.',
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('failed to create invoice journal entry on mark-sent', err as Error)
|
||||
partialFailures.push({
|
||||
step: 'journal_entry',
|
||||
reason: 'Fakturans verifikat kunde inte skapas.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (isRealInvoice && accountingMethod === 'accrual' && !isCreditNote && !journalEntryId) {
|
||||
if (statusFlipped) {
|
||||
const { error: rollbackError } = await supabase
|
||||
.from('invoices')
|
||||
.update({ status: 'draft' })
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'sent')
|
||||
.is('journal_entry_id', null)
|
||||
if (rollbackError) log.error('failed to restore draft after mark-sent booking failure', rollbackError)
|
||||
}
|
||||
return errorResponseFromCode('INVOICE_MARK_SENT_BOOK_FAILED', log, { requestId })
|
||||
}
|
||||
|
||||
if (partialFailures.some((failure) => failure.step === 'journal_link')) {
|
||||
return errorResponseFromCode('INVOICE_MARK_SENT_REPAIR_REQUIRED', log, {
|
||||
requestId,
|
||||
details: { failure_steps: ['journal_link'] },
|
||||
})
|
||||
}
|
||||
|
||||
// Render and archive the PDF as underlag so it remains retrievable even if
|
||||
// the invoice row is later cancelled. Mirrors the send route.
|
||||
if (isRealInvoice && settings) {
|
||||
if (isRealInvoice) {
|
||||
try {
|
||||
const items = (invoice.items as InvoiceItem[] | null ?? []).slice().sort(
|
||||
(a, b) => a.sort_order - b.sort_order
|
||||
)
|
||||
|
||||
let originalInvoiceNumber: string | undefined
|
||||
if (invoice.credited_invoice_id) {
|
||||
const { data: originalInvoice } = await supabase
|
||||
.from('invoices')
|
||||
.select('invoice_number')
|
||||
.eq('id', invoice.credited_invoice_id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
originalInvoiceNumber = originalInvoice?.invoice_number ?? undefined
|
||||
}
|
||||
|
||||
// The DB status flip already happened above, but the in-memory `invoice`
|
||||
// is stale and still reads 'draft': override here so the archived
|
||||
// underlag isn't stamped "UTKAST: inte en giltig faktura".
|
||||
@@ -183,13 +310,27 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
})
|
||||
} catch (err) {
|
||||
log.error('failed to archive invoice PDF on mark-sent', err as Error)
|
||||
partialFailures.push({
|
||||
step: 'pdf_archive',
|
||||
reason: 'Fakturans PDF kunde inte arkiveras.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!isCreditNote) {
|
||||
await eventBus.emit({
|
||||
type: 'invoice.sent',
|
||||
payload: { invoice: { ...(invoice as Invoice), status: 'sent' }, companyId, userId: user.id },
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
status: 'sent',
|
||||
journal_entry_id: journalEntryId,
|
||||
...(partialFailures.length > 0
|
||||
? { partial: true, partial_failures: partialFailures }
|
||||
: {}),
|
||||
})
|
||||
},
|
||||
{ requireWrite: true },
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { UpdateInvoiceSchema } from '@/lib/api/schemas'
|
||||
@@ -15,8 +11,6 @@ import type { InvoiceDocumentType } from '@/types'
|
||||
|
||||
ensureInitialized() // Module-level: wires the audit-log handler for invoice.draft_deleted.
|
||||
|
||||
const log = createLogger('api.invoices.cancel')
|
||||
|
||||
/**
|
||||
* DELETE /api/invoices/[id]
|
||||
*
|
||||
@@ -32,37 +26,25 @@ const log = createLogger('api.invoices.cancel')
|
||||
* Only drafts may be removed either way. Sent / paid invoices are immutable per
|
||||
* BFL and must be reversed via a credit note instead.
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'invoice.delete',
|
||||
async (_request, { user, supabase, companyId, log, requestId }, { params }) => {
|
||||
const { id } = await params
|
||||
const opLog = log.child({ invoiceId: id })
|
||||
|
||||
const { data: invoice, error: fetchError } = await supabase
|
||||
.from('invoices')
|
||||
.select('id, status, invoice_number, user_id')
|
||||
.select('id, status, invoice_number, user_id, credited_invoice_id, journal_entry_id')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !invoice) {
|
||||
return NextResponse.json({ error: 'Invoice not found' }, { status: 404 })
|
||||
return errorResponseFromCode('INVOICE_NOT_FOUND', opLog, { requestId })
|
||||
}
|
||||
|
||||
if (invoice.status !== 'draft') {
|
||||
return errorResponseFromCode('INVOICE_DELETE_NOT_DRAFT', log)
|
||||
return errorResponseFromCode('INVOICE_DELETE_NOT_DRAFT', opLog, { requestId })
|
||||
}
|
||||
|
||||
// Unnumbered drafts (saved via "Spara som utkast", never finalized) are not
|
||||
@@ -82,13 +64,14 @@ export async function DELETE(
|
||||
.select('id')
|
||||
|
||||
if (removeError) {
|
||||
return NextResponse.json({ error: removeError.message }, { status: 500 })
|
||||
opLog.error('invoice draft delete failed', removeError)
|
||||
return errorResponseFromCode('INVOICE_DELETE_FAILED', opLog, { requestId })
|
||||
}
|
||||
|
||||
if (!removed || removed.length === 0) {
|
||||
// Finalized between fetch and delete: refuse rather than fall through to
|
||||
// makulering of a now-issued invoice.
|
||||
return errorResponseFromCode('INVOICE_CANCEL_RACE', log)
|
||||
return errorResponseFromCode('INVOICE_CANCEL_RACE', opLog, { requestId })
|
||||
}
|
||||
|
||||
// The row is gone, so there's no journal trace of the removal. Emit an
|
||||
@@ -100,7 +83,7 @@ export async function DELETE(
|
||||
payload: { invoiceId: id, companyId, userId: user.id },
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: { deleted: true } })
|
||||
return NextResponse.json({ data: { deleted: true } })
|
||||
}
|
||||
|
||||
// Numbered draft: retain the row and its number, flip to 'cancelled'
|
||||
@@ -118,15 +101,18 @@ export async function DELETE(
|
||||
.select('id')
|
||||
|
||||
if (cancelError) {
|
||||
return NextResponse.json({ error: cancelError.message }, { status: 500 })
|
||||
opLog.error('invoice cancellation failed', cancelError)
|
||||
return errorResponseFromCode('INVOICE_DELETE_FAILED', opLog, { requestId })
|
||||
}
|
||||
|
||||
if (!updated || updated.length === 0) {
|
||||
return errorResponseFromCode('INVOICE_CANCEL_RACE', log)
|
||||
return errorResponseFromCode('INVOICE_CANCEL_RACE', opLog, { requestId })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { cancelled: true, invoice_number: invoice.invoice_number } })
|
||||
}
|
||||
return NextResponse.json({ data: { cancelled: true, invoice_number: invoice.invoice_number } })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
/**
|
||||
* PATCH /api/invoices/[id]
|
||||
@@ -161,7 +147,7 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
// received self-billing document) may be edited.
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
.from('invoices')
|
||||
.select('id, status, invoice_number, journal_entry_id, is_self_billed')
|
||||
.select('id, status, invoice_number, journal_entry_id, is_self_billed, credited_invoice_id')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId!)
|
||||
.single()
|
||||
|
||||
@@ -66,6 +66,11 @@ vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
||||
mockCreateInvoiceJournalEntry(...args),
|
||||
}))
|
||||
|
||||
const mockIssueCreditNote = vi.fn()
|
||||
vi.mock('@/lib/invoices/issue-credit-note', () => ({
|
||||
issueCreditNote: (...args: unknown[]) => mockIssueCreditNote(...args),
|
||||
}))
|
||||
|
||||
// The sandbox guard issues a company_settings query at the top of the route;
|
||||
// short-circuit it in tests since the queued mock-supabase is shaped for the
|
||||
// route's existing fetch chain, not an extra pre-flight read.
|
||||
@@ -113,6 +118,12 @@ describe('POST /api/invoices/[id]/send', () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
mockIsConfigured.mockReturnValue(true)
|
||||
mockRenderToBuffer.mockResolvedValue(Buffer.from('fake-pdf'))
|
||||
mockIssueCreditNote.mockResolvedValue({
|
||||
complete: true,
|
||||
journalEntryId: 'credit-je-1',
|
||||
journalEntryRequired: true,
|
||||
failures: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
@@ -314,6 +325,144 @@ describe('POST /api/invoices/[id]/send', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('issues and books a credit-note draft through the email send flow', async () => {
|
||||
const creditNote = makeInvoice({
|
||||
id: 'credit-1',
|
||||
invoice_number: 'KR-F-2024001',
|
||||
status: 'draft',
|
||||
credited_invoice_id: 'inv-1',
|
||||
customer,
|
||||
items: (invoice.items ?? []).map((item) => ({
|
||||
...item,
|
||||
invoice_id: 'credit-1',
|
||||
quantity: -Math.abs(item.quantity),
|
||||
line_total: -Math.abs(item.line_total),
|
||||
vat_amount: -Math.abs(item.vat_amount ?? 0),
|
||||
})),
|
||||
subtotal: -10000,
|
||||
vat_amount: -2500,
|
||||
total: -12500,
|
||||
})
|
||||
const original = {
|
||||
id: 'inv-1',
|
||||
invoice_number: 'F-2024001',
|
||||
status: 'sent',
|
||||
journal_entry_id: 'original-je-1',
|
||||
paid_at: null,
|
||||
paid_amount: null,
|
||||
total: 12500,
|
||||
}
|
||||
|
||||
enqueue({ data: creditNote, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
enqueue({ data: original, error: null })
|
||||
mockSendEmail.mockResolvedValue({ success: true, messageId: 'credit-message-1' })
|
||||
enqueue({ data: [{ id: 'credit-1' }], error: null })
|
||||
const emitSpy = vi.spyOn(eventBus, 'emit')
|
||||
|
||||
const request = createMockRequest('/api/invoices/credit-1/send', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'credit-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ success: boolean; message: string }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.message).toContain('Kreditfakturan har skickats')
|
||||
expect(mockIssueCreditNote).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
companyId: 'company-1',
|
||||
creditNote: expect.objectContaining({ id: 'credit-1' }),
|
||||
originalInvoice: original,
|
||||
accountingMethod: 'accrual',
|
||||
}),
|
||||
)
|
||||
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
|
||||
expect(mockSendEmail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
attachments: [
|
||||
expect.objectContaining({ filename: 'kreditfaktura-KR-F-2024001.pdf' }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(emitSpy).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'invoice.sent' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not email a credit note when its bookkeeping cannot be completed', async () => {
|
||||
const creditNote = makeInvoice({
|
||||
id: 'credit-1',
|
||||
invoice_number: 'KR-F-2024001',
|
||||
status: 'draft',
|
||||
credited_invoice_id: 'inv-1',
|
||||
customer,
|
||||
items: invoice.items,
|
||||
})
|
||||
enqueue({ data: creditNote, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'inv-1',
|
||||
invoice_number: 'F-2024001',
|
||||
status: 'sent',
|
||||
journal_entry_id: 'original-je-1',
|
||||
paid_at: null,
|
||||
paid_amount: null,
|
||||
total: 12500,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [{ id: 'credit-1' }], error: null })
|
||||
mockIssueCreditNote.mockResolvedValue({
|
||||
complete: false,
|
||||
journalEntryId: null,
|
||||
journalEntryRequired: true,
|
||||
failures: [{ step: 'journal_entry', reason: 'Perioden är låst' }],
|
||||
})
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/credit-1/send', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'credit-1' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(mockSendEmail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retries delivery for an already-issued credit note after provider failure', async () => {
|
||||
const creditNote = makeInvoice({
|
||||
id: 'credit-1',
|
||||
invoice_number: 'KR-F-2024001',
|
||||
status: 'sent',
|
||||
credited_invoice_id: 'inv-1',
|
||||
customer,
|
||||
items: invoice.items,
|
||||
})
|
||||
enqueue({ data: creditNote, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'inv-1',
|
||||
invoice_number: 'F-2024001',
|
||||
status: 'credited',
|
||||
journal_entry_id: 'original-je-1',
|
||||
paid_at: null,
|
||||
paid_amount: null,
|
||||
total: 12500,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
mockSendEmail.mockResolvedValue({ success: true, messageId: 'retry-message-1' })
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('/api/invoices/credit-1/send', { method: 'POST' }),
|
||||
createMockRouteParams({ id: 'credit-1' }),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockIssueCreditNote).toHaveBeenCalledTimes(1)
|
||||
expect(mockSendEmail).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('skips journal entry for cash method', async () => {
|
||||
const cashCompany = makeCompanySettings({ accounting_method: 'cash' })
|
||||
enqueue({ data: invoice, error: null })
|
||||
@@ -451,12 +600,10 @@ describe('POST /api/invoices/[id]/send', () => {
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
// Provider errors map to 502 PROVIDER_FAILED with the provider message in details.
|
||||
// Provider errors map to a safe retryable response without leaking provider text.
|
||||
expect(status).toBe(502)
|
||||
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_SEND_PROVIDER_FAILED')
|
||||
expect(
|
||||
(body.error as unknown as { details?: { providerError?: string } }).details?.providerError,
|
||||
).toContain('SMTP error')
|
||||
expect((body.error as unknown as { details?: { retryable?: boolean } }).details?.retryable).toBe(true)
|
||||
})
|
||||
|
||||
it('renders the final PDF as if already sent (no UTKAST banner)', async () => {
|
||||
|
||||
@@ -14,13 +14,25 @@ import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
|
||||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
||||
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
|
||||
import {
|
||||
issueCreditNote,
|
||||
type CreditNoteOriginalInvoice,
|
||||
} from '@/lib/invoices/issue-credit-note'
|
||||
import { applyPaymentLinkToInvoice } from '@/lib/extensions/payment-links'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { guardSandbox } from '@/lib/sandbox/guard'
|
||||
import { requireCapability } from '@/lib/entitlements/has-capability'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
|
||||
import type {
|
||||
AccountingMethod,
|
||||
CompanySettings,
|
||||
CreditNote,
|
||||
Customer,
|
||||
EntityType,
|
||||
Invoice,
|
||||
InvoiceItem,
|
||||
} from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -59,6 +71,9 @@ export const POST = withRouteContext(
|
||||
return errorResponseFromCode('INVOICE_PAID_NOT_FOUND', opLog, { requestId })
|
||||
}
|
||||
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
const isCreditDeliveryRetry = isCreditNote && invoice.status === 'sent'
|
||||
|
||||
// A cancelled invoice keeps its F-series number for compliance with ML 17
|
||||
// kap 24§ but is not a valid faktura: sending it would deliver a
|
||||
// "MAKULERAD" PDF as if it were live. Checked before the generic draft
|
||||
@@ -73,7 +88,7 @@ export const POST = withRouteContext(
|
||||
// (createInvoiceJournalEntry has no dedup), overwriting journal_entry_id
|
||||
// and orphaning the first entry. Mirrors the v1 route and the MCP commit
|
||||
// executor, which both reject non-drafts.
|
||||
if (invoice.status !== 'draft') {
|
||||
if (invoice.status !== 'draft' && !isCreditDeliveryRetry) {
|
||||
return errorResponseFromCode('INVOICE_ALREADY_SENT', opLog, {
|
||||
requestId,
|
||||
details: { currentStatus: invoice.status },
|
||||
@@ -100,18 +115,22 @@ export const POST = withRouteContext(
|
||||
|
||||
const items = (invoice.items as InvoiceItem[]).sort((a, b) => a.sort_order - b.sort_order)
|
||||
|
||||
let originalInvoice: CreditNoteOriginalInvoice | undefined
|
||||
let originalInvoiceNumber: string | undefined
|
||||
if (invoice.credited_invoice_id) {
|
||||
const { data: originalInvoice } = await supabase
|
||||
const { data: original } = await supabase
|
||||
.from('invoices')
|
||||
.select('invoice_number')
|
||||
.select('id, invoice_number, status, journal_entry_id, paid_at, paid_amount, total')
|
||||
.eq('id', invoice.credited_invoice_id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (originalInvoice) {
|
||||
originalInvoiceNumber = originalInvoice.invoice_number
|
||||
if (!original) {
|
||||
return errorResponseFromCode('INVOICE_CREDIT_ORIGINAL_NOT_FOUND', opLog, { requestId })
|
||||
}
|
||||
|
||||
originalInvoice = original as CreditNoteOriginalInvoice
|
||||
originalInvoiceNumber = original.invoice_number ?? undefined
|
||||
}
|
||||
|
||||
// Preflight render: validate the PDF pipeline BEFORE consuming an F-series
|
||||
@@ -149,13 +168,15 @@ export const POST = withRouteContext(
|
||||
// that the number exists, so the email button and PDF QR carry it. A
|
||||
// failure never blocks the send: the faktura is legally valid without a
|
||||
// link, so it degrades to a PARTIAL warning instead.
|
||||
const { failure: paymentLinkFailure } = await applyPaymentLinkToInvoice(
|
||||
supabase,
|
||||
companyId!,
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
opLog,
|
||||
)
|
||||
const { failure: paymentLinkFailure } = isCreditNote
|
||||
? { failure: undefined }
|
||||
: await applyPaymentLinkToInvoice(
|
||||
supabase,
|
||||
companyId!,
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
opLog,
|
||||
)
|
||||
|
||||
// Final render with the assigned number: this is the buffer attached to
|
||||
// the email and later archived as underlag. Override status to 'sent' on
|
||||
@@ -182,12 +203,11 @@ export const POST = withRouteContext(
|
||||
)
|
||||
|
||||
const emailData = {
|
||||
invoice: invoice as Invoice,
|
||||
invoice: renderableInvoice,
|
||||
customer,
|
||||
company: company as CompanySettings,
|
||||
}
|
||||
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
const docType = invoice.document_type || 'invoice'
|
||||
let filename: string
|
||||
if (isCreditNote) {
|
||||
@@ -201,6 +221,78 @@ export const POST = withRouteContext(
|
||||
}
|
||||
|
||||
const ccAddress = company.email || user.email
|
||||
const partialFailures: Array<{ step: string; reason: string }> = []
|
||||
if (paymentLinkFailure) {
|
||||
partialFailures.push({ step: 'payment_link', reason: paymentLinkFailure })
|
||||
}
|
||||
|
||||
let statusFlipped = isCreditDeliveryRetry
|
||||
let creditJournalEntryId: string | null = null
|
||||
|
||||
// Credit notes must be fully issued and booked before delivery. The CAS is
|
||||
// the single-winner lock; the idempotent issue service can repair any
|
||||
// immutable entry that committed before a later database step failed.
|
||||
if (isCreditNote && originalInvoice) {
|
||||
if (!isCreditDeliveryRetry) {
|
||||
const { data: flipRows, error: updateError } = await supabase
|
||||
.from('invoices')
|
||||
.update({ status: 'sent' })
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'draft')
|
||||
.select('id')
|
||||
|
||||
if (updateError) {
|
||||
opLog.error('credit note status update failed before issue', updateError)
|
||||
return errorResponseFromCode('INVOICE_CREDIT_ISSUE_INCOMPLETE', opLog, {
|
||||
requestId,
|
||||
details: { failure_steps: ['status_update'] },
|
||||
})
|
||||
}
|
||||
if (!flipRows || flipRows.length === 0) {
|
||||
return errorResponseFromCode('INVOICE_ALREADY_SENT', opLog, {
|
||||
requestId,
|
||||
details: { currentStatus: 'sent' },
|
||||
})
|
||||
}
|
||||
statusFlipped = true
|
||||
}
|
||||
|
||||
const issueResult = await issueCreditNote({
|
||||
supabase,
|
||||
companyId: companyId!,
|
||||
userId: user.id,
|
||||
creditNote: invoice as CreditNote,
|
||||
originalInvoice,
|
||||
entityType: ((company as CompanySettings).entity_type as EntityType) || 'enskild_firma',
|
||||
accountingMethod: ((company as Record<string, unknown>).accounting_method || 'accrual') as AccountingMethod,
|
||||
log: opLog,
|
||||
})
|
||||
creditJournalEntryId = issueResult.journalEntryId
|
||||
|
||||
if (!issueResult.complete) {
|
||||
if (issueResult.journalEntryRequired && !issueResult.journalEntryId) {
|
||||
await supabase
|
||||
.from('invoices')
|
||||
.update({ status: 'draft' })
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'sent')
|
||||
.is('journal_entry_id', null)
|
||||
}
|
||||
return errorResponseFromCode(
|
||||
issueResult.repairRequired
|
||||
? 'INVOICE_CREDIT_REPAIR_REQUIRED'
|
||||
: 'INVOICE_CREDIT_ISSUE_INCOMPLETE',
|
||||
opLog,
|
||||
{
|
||||
requestId,
|
||||
details: { failure_steps: issueResult.failures.map((failure) => failure.step) },
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const result = await emailService.sendEmail({
|
||||
to: customer.email,
|
||||
cc: ccAddress,
|
||||
@@ -222,7 +314,7 @@ export const POST = withRouteContext(
|
||||
opLog.error('email provider failed to send invoice', new Error(result.error || 'Unknown'))
|
||||
return errorResponseFromCode('INVOICE_SEND_PROVIDER_FAILED', opLog, {
|
||||
requestId,
|
||||
details: { providerError: result.error },
|
||||
details: { retryable: true },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -230,12 +322,6 @@ export const POST = withRouteContext(
|
||||
// follow-up steps degrade the response to PARTIAL: the user gets a
|
||||
// success toast with a sub-warning, and the audit trail records exactly
|
||||
// which sub-step broke.
|
||||
const partialFailures: Array<{ step: string; reason: string }> = []
|
||||
|
||||
if (paymentLinkFailure) {
|
||||
partialFailures.push({ step: 'payment_link', reason: paymentLinkFailure })
|
||||
}
|
||||
|
||||
// Optimistic-locked flip (draft → sent). Two concurrent sends can both
|
||||
// pass the draft guard above and both email the customer, but only the
|
||||
// request that wins this compare-and-set runs the bookkeeping steps
|
||||
@@ -245,8 +331,7 @@ export const POST = withRouteContext(
|
||||
// A genuine update error also skips the follow-ups: the row is still
|
||||
// 'draft', so a later retry re-runs the whole pipeline and ends with
|
||||
// exactly one journal entry (at the cost of a duplicate email).
|
||||
let statusFlipped = false
|
||||
{
|
||||
if (!isCreditNote) {
|
||||
const { data: flipRows, error: updateError } = await supabase
|
||||
.from('invoices')
|
||||
.update({ status: 'sent' })
|
||||
@@ -270,10 +355,10 @@ export const POST = withRouteContext(
|
||||
}
|
||||
|
||||
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
|
||||
const accountingMethod = (company as Record<string, unknown>).accounting_method as string | undefined
|
||||
let createdJournalEntryId: string | undefined
|
||||
const accountingMethod = ((company as Record<string, unknown>).accounting_method || 'accrual') as AccountingMethod
|
||||
let createdJournalEntryId: string | undefined = creditJournalEntryId ?? undefined
|
||||
|
||||
if (statusFlipped && isRealInvoice && (!accountingMethod || accountingMethod === 'accrual')) {
|
||||
if (statusFlipped && !isCreditNote && isRealInvoice && accountingMethod === 'accrual') {
|
||||
try {
|
||||
const journalEntry = await createInvoiceJournalEntry(
|
||||
supabase,
|
||||
@@ -312,7 +397,7 @@ export const POST = withRouteContext(
|
||||
opLog.error('failed to create invoice journal entry on send', err as Error)
|
||||
partialFailures.push({
|
||||
step: 'journal_entry',
|
||||
reason: err instanceof Error ? err.message : 'unknown',
|
||||
reason: 'Fakturans verifikat kunde inte skapas.',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -332,7 +417,7 @@ export const POST = withRouteContext(
|
||||
opLog.error('failed to store invoice PDF as underlag', err as Error)
|
||||
partialFailures.push({
|
||||
step: 'pdf_archive',
|
||||
reason: err instanceof Error ? err.message : 'unknown',
|
||||
reason: 'Fakturans PDF kunde inte arkiveras.',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -340,7 +425,7 @@ export const POST = withRouteContext(
|
||||
// Gated like the steps above: on a lost race the winning request emits
|
||||
// it; on a flip error the row is still 'draft', so emitting would
|
||||
// contradict DB state and the retry emits it instead.
|
||||
if (statusFlipped) {
|
||||
if (statusFlipped && !isCreditNote) {
|
||||
await eventBus.emit({
|
||||
type: 'invoice.sent',
|
||||
payload: { invoice: invoice as Invoice, companyId: companyId!, userId: user.id },
|
||||
@@ -356,7 +441,7 @@ export const POST = withRouteContext(
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Fakturan har skickats till ${customer.email} (kopia till ${ccAddress})`,
|
||||
message: `${isCreditNote ? 'Kreditfakturan' : 'Fakturan'} har skickats till ${customer.email} (kopia till ${ccAddress})`,
|
||||
messageId: result.messageId,
|
||||
...(partialFailures.length > 0
|
||||
? { partial: true, partial_failures: partialFailures }
|
||||
|
||||
@@ -41,12 +41,6 @@ vi.mock('@/lib/currency/riksbanken', () => ({
|
||||
convertToSEK: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockCreateCreditNoteJournalEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
||||
createCreditNoteJournalEntry: (...args: unknown[]) =>
|
||||
mockCreateCreditNoteJournalEntry(...args),
|
||||
}))
|
||||
|
||||
import { GET, POST } from '../route'
|
||||
|
||||
describe('GET /api/invoices', () => {
|
||||
@@ -419,7 +413,7 @@ describe('POST /api/invoices (create credit note)', () => {
|
||||
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREDIT_NOT_SENT')
|
||||
})
|
||||
|
||||
it('creates credit note with negated amounts and emits event', async () => {
|
||||
it('creates a credit note draft without booking or crediting the original', async () => {
|
||||
const items = [
|
||||
{
|
||||
id: 'item-1',
|
||||
@@ -449,25 +443,21 @@ describe('POST /api/invoices (create credit note)', () => {
|
||||
subtotal: -10000,
|
||||
vat_amount: -2500,
|
||||
total: -12500,
|
||||
status: 'sent',
|
||||
status: 'draft',
|
||||
})
|
||||
|
||||
// Fetch original invoice
|
||||
enqueue({ data: original, error: null })
|
||||
// No existing credit-note draft
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert credit note
|
||||
enqueue({ data: creditNote, error: null })
|
||||
// Insert credit note items
|
||||
enqueue({ data: null, error: null })
|
||||
// Update original status to 'credited'
|
||||
// Mark creation complete
|
||||
enqueue({ data: null, error: null })
|
||||
// Fetch complete credit note
|
||||
enqueue({ data: { ...creditNote, items: [] }, error: null })
|
||||
// Fetch company settings for entity type
|
||||
enqueue({ data: { entity_type: 'enskild_firma' }, error: null })
|
||||
|
||||
mockCreateCreditNoteJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
// Update credit note with journal_entry_id
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const emitSpy = vi.spyOn(eventBus, 'emit')
|
||||
|
||||
@@ -476,13 +466,35 @@ describe('POST /api/invoices (create credit note)', () => {
|
||||
body: { credited_invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ data: unknown }>(response)
|
||||
const { status, body } = await parseJsonResponse<{ data: { status: string } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toBeTruthy()
|
||||
expect(emitSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'credit_note.created' })
|
||||
)
|
||||
expect(body.data.status).toBe('draft')
|
||||
expect(emitSpy).not.toHaveBeenCalled()
|
||||
expect(mockSupabase.from).toHaveBeenCalledTimes(6)
|
||||
})
|
||||
|
||||
it('returns an existing credit-note draft instead of creating a duplicate', async () => {
|
||||
const original = makeInvoice({ id: VALID_UUID, status: 'sent' })
|
||||
const existing = makeInvoice({
|
||||
id: 'credit-existing',
|
||||
invoice_number: 'KR-F-2024001',
|
||||
status: 'draft',
|
||||
credited_invoice_id: VALID_UUID,
|
||||
})
|
||||
enqueue({ data: original, error: null })
|
||||
enqueue({ data: existing, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
method: 'POST',
|
||||
body: { credited_invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.id).toBe('credit-existing')
|
||||
expect(mockSupabase.from).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('rolls back credit note when items insertion fails', async () => {
|
||||
@@ -508,6 +520,7 @@ describe('POST /api/invoices (create credit note)', () => {
|
||||
const creditNote = makeInvoice({ id: 'cn-1' })
|
||||
|
||||
enqueue({ data: original, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: creditNote, error: null })
|
||||
// Items fail
|
||||
enqueue({ data: null, error: { message: 'Items insert failed' } })
|
||||
|
||||
+107
-115
@@ -3,11 +3,10 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { CreateInvoiceSchema, CreateCreditNoteSchema } from '@/lib/api/schemas'
|
||||
import type { EntityType, AccountingMethod, Invoice, CreditNote, InvoiceDocumentType } from '@/types'
|
||||
import { createCreditNoteJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { cancelSchedulesForSource } from '@/lib/bookkeeping/accruals/service'
|
||||
import type { Invoice, InvoiceDocumentType, InvoiceItem } from '@/types'
|
||||
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
|
||||
import { buildInvoiceWriteData } from '@/lib/invoices/build-invoice-write'
|
||||
import { buildCreditNoteItem } from '@/lib/invoices/build-credit-note-item'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import type { Logger } from '@/lib/logger'
|
||||
@@ -253,10 +252,6 @@ async function createCreditNote(
|
||||
log: Logger,
|
||||
requestId: string,
|
||||
) {
|
||||
// Non-blocking issues (e.g. partial accrual cancellation) surfaced to the
|
||||
// caller alongside the created credit note.
|
||||
const warnings: Array<{ code: string; message: string }> = []
|
||||
|
||||
const { data: originalInvoice, error: originalError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, items:invoice_items(*)')
|
||||
@@ -286,6 +281,59 @@ async function createCreditNote(
|
||||
})
|
||||
}
|
||||
|
||||
// Returning the existing credit note makes the action idempotent. A
|
||||
// cancelled, unissued draft is reopened so the deterministic KR number can
|
||||
// be reused without colliding with the company-wide invoice-number key.
|
||||
const { data: existingCreditNote, error: existingCreditNoteError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('credited_invoice_id', input.credited_invoice_id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('creation_complete', true)
|
||||
.maybeSingle()
|
||||
|
||||
if (existingCreditNoteError) {
|
||||
log.error('failed to check for an existing credit note', existingCreditNoteError)
|
||||
return errorResponse(existingCreditNoteError, log, { requestId })
|
||||
}
|
||||
if (existingCreditNote) {
|
||||
if (existingCreditNote.status === 'cancelled' && !existingCreditNote.journal_entry_id) {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const { error: reopenError } = await supabase
|
||||
.from('invoices')
|
||||
.update({
|
||||
status: 'draft',
|
||||
invoice_date: today,
|
||||
due_date: today,
|
||||
notes: input.reason || `Krediterar faktura ${originalInvoice.invoice_number}`,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', existingCreditNote.id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'cancelled')
|
||||
|
||||
if (reopenError) {
|
||||
log.error('failed to reopen cancelled credit note draft', reopenError)
|
||||
return errorResponse(reopenError, log, { requestId })
|
||||
}
|
||||
|
||||
const { data: reopenedCreditNote, error: reopenedError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('id', existingCreditNote.id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (reopenedError || !reopenedCreditNote) {
|
||||
return errorResponse(reopenedError ?? new Error('Credit note draft not found'), log, {
|
||||
requestId,
|
||||
})
|
||||
}
|
||||
return NextResponse.json({ data: reopenedCreditNote })
|
||||
}
|
||||
return NextResponse.json({ data: existingCreditNote })
|
||||
}
|
||||
|
||||
const creditNoteNumber = `KR-${originalInvoice.invoice_number}`
|
||||
|
||||
const { data: creditNote, error: creditNoteError } = await supabase
|
||||
@@ -313,17 +361,33 @@ async function createCreditNote(
|
||||
reverse_charge_text: originalInvoice.reverse_charge_text,
|
||||
your_reference: originalInvoice.your_reference,
|
||||
our_reference: originalInvoice.our_reference,
|
||||
deduction_total: originalInvoice.deduction_total
|
||||
? -Math.abs(originalInvoice.deduction_total)
|
||||
: 0,
|
||||
deduction_personnummer_encrypted: originalInvoice.deduction_personnummer_encrypted ?? null,
|
||||
deduction_personnummer_last4: originalInvoice.deduction_personnummer_last4 ?? null,
|
||||
notes: input.reason || `Krediterar faktura ${originalInvoice.invoice_number}`,
|
||||
credited_invoice_id: input.credited_invoice_id,
|
||||
// Copy the original's dimension bag so the credit-note verifikat nets
|
||||
// against the same dimension cells in reports (dimensions PR7).
|
||||
default_dimensions: originalInvoice.default_dimensions ?? {},
|
||||
status: 'sent',
|
||||
status: 'draft',
|
||||
creation_complete: false,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (creditNoteError) {
|
||||
if (creditNoteError.code === '23505') {
|
||||
const { data: racedCreditNote } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('credited_invoice_id', input.credited_invoice_id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('creation_complete', true)
|
||||
.maybeSingle()
|
||||
if (racedCreditNote) return NextResponse.json({ data: racedCreditNote })
|
||||
}
|
||||
log.error('credit note insert failed', creditNoteError)
|
||||
return errorResponseFromCode('INVOICE_CREATE_INSERT_FAILED', log, {
|
||||
requestId,
|
||||
@@ -331,40 +395,24 @@ async function createCreditNote(
|
||||
})
|
||||
}
|
||||
|
||||
const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; line_type?: 'product' | 'text'; description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate?: number; vat_amount?: number; revenue_account?: string | null; article_id?: string | null; accrual_period_start?: string | null; accrual_period_end?: string | null; accrual_balance_account?: string | null; dimensions?: Record<string, string> }) => ({
|
||||
invoice_id: creditNote.id,
|
||||
sort_order: item.sort_order,
|
||||
line_type: item.line_type ?? 'product',
|
||||
description: item.description,
|
||||
quantity: -Math.abs(item.quantity),
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
line_total: -Math.abs(item.line_total),
|
||||
vat_rate: item.vat_rate ?? 0,
|
||||
vat_amount: -(item.vat_amount ? Math.abs(item.vat_amount) : 0),
|
||||
// Carry the original's per-line revenue-account override so the reversal
|
||||
// hits the SAME account it originally credited (e.g. 3041, not the
|
||||
// VAT-derived 3001): otherwise the override account keeps a dangling
|
||||
// balance. article_id is preserved for the usage history.
|
||||
revenue_account: item.revenue_account ?? null,
|
||||
article_id: item.article_id ?? null,
|
||||
// Same reasoning for periodiserade lines: the credit-note verifikat must
|
||||
// reverse against the 29xx interim account the original credited, not the
|
||||
// revenue account. generatePerRateLines reads these fields to substitute.
|
||||
// No schedule is ever created for a credit note (only send/mark-sent
|
||||
// create schedules); the original's schedule is cancelled below.
|
||||
accrual_period_start: item.accrual_period_start ?? null,
|
||||
accrual_period_end: item.accrual_period_end ?? null,
|
||||
accrual_balance_account: item.accrual_balance_account ?? null,
|
||||
// Same reasoning as revenue_account: the reversal must carry the exact
|
||||
// per-item bag the original booked with (dimensions PR7).
|
||||
dimensions: item.dimensions ?? {},
|
||||
}))
|
||||
const creditNoteItems = (originalInvoice.items || []).map((item: InvoiceItem) =>
|
||||
buildCreditNoteItem(creditNote.id, item)
|
||||
)
|
||||
|
||||
const { error: itemsError } = await supabase.from('invoice_items').insert(creditNoteItems)
|
||||
|
||||
if (itemsError) {
|
||||
await supabase.from('invoices').delete().eq('id', creditNote.id)
|
||||
const { error: cleanupError } = await supabase
|
||||
.from('invoices')
|
||||
.delete()
|
||||
.eq('id', creditNote.id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('creation_complete', false)
|
||||
if (cleanupError) {
|
||||
log.error('failed to clean up incomplete credit note', cleanupError, {
|
||||
creditNoteId: creditNote.id,
|
||||
})
|
||||
}
|
||||
log.error('credit note items insert failed; rolled back', itemsError, {
|
||||
creditNoteId: creditNote.id,
|
||||
})
|
||||
@@ -374,91 +422,35 @@ async function createCreditNote(
|
||||
})
|
||||
}
|
||||
|
||||
await supabase
|
||||
const { error: completionError } = await supabase
|
||||
.from('invoices')
|
||||
.update({ status: 'credited' })
|
||||
.eq('id', input.credited_invoice_id)
|
||||
.update({ creation_complete: true, updated_at: new Date().toISOString() })
|
||||
.eq('id', creditNote.id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('creation_complete', false)
|
||||
|
||||
const { data: completeCreditNote } = await supabase
|
||||
if (completionError) {
|
||||
log.error('failed to mark credit note creation complete', completionError, {
|
||||
creditNoteId: creditNote.id,
|
||||
})
|
||||
return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', log, { requestId })
|
||||
}
|
||||
|
||||
const { data: completeCreditNote, error: completeCreditNoteError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('id', creditNote.id)
|
||||
.single()
|
||||
|
||||
const { data: creditNoteSettings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type, accounting_method')
|
||||
.eq('company_id', companyId)
|
||||
.eq('creation_complete', true)
|
||||
.single()
|
||||
|
||||
const entityType = (creditNoteSettings?.entity_type as EntityType) || 'enskild_firma'
|
||||
const accountingMethod = (creditNoteSettings?.accounting_method as AccountingMethod) || 'accrual'
|
||||
|
||||
// Cash method skips: there's no original invoice JE to reverse, recognition
|
||||
// is deferred until refund.
|
||||
if (completeCreditNote && accountingMethod === 'accrual') {
|
||||
try {
|
||||
const journalEntry = await createCreditNoteJournalEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
completeCreditNote as Invoice,
|
||||
entityType,
|
||||
completeCreditNote.customer?.name,
|
||||
)
|
||||
if (journalEntry) {
|
||||
await supabase
|
||||
.from('invoices')
|
||||
.update({ journal_entry_id: journalEntry.id })
|
||||
.eq('id', creditNote.id)
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('failed to create credit note journal entry', err as Error, {
|
||||
creditNoteId: creditNote.id,
|
||||
})
|
||||
// Non-blocking: credit note still exists.
|
||||
}
|
||||
|
||||
// Periodisering interplay: cancel remaining months and storno posted
|
||||
// dissolutions so origin + dissolutions + stornos + credit net to zero on
|
||||
// both 29xx and 3xxx. Best-effort: never blocks the credit itself, but
|
||||
// partial reversals are surfaced as a response warning so the user knows
|
||||
// the schedule stayed active.
|
||||
try {
|
||||
const cancelResult = await cancelSchedulesForSource(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
{ invoiceId: input.credited_invoice_id },
|
||||
{ reversalDate: creditNote.invoice_date },
|
||||
)
|
||||
if (cancelResult.failedReversals > 0) {
|
||||
warnings.push({
|
||||
code: 'ACCRUAL_CANCEL_PARTIAL',
|
||||
message:
|
||||
'Fakturan krediterades, men en eller flera periodiseringsverifikat ' +
|
||||
'kunde inte vändas. Periodiseringen är fortfarande aktiv: ' +
|
||||
'kontrollera under Bokföring → Periodiseringar.',
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('failed to cancel accrual schedules for credited invoice', err as Error)
|
||||
warnings.push({
|
||||
code: 'ACCRUAL_CANCEL_PARTIAL',
|
||||
message:
|
||||
'Fakturan krediterades, men periodiseringarna kunde inte avslutas. ' +
|
||||
'Kontrollera under Bokföring → Periodiseringar.',
|
||||
})
|
||||
}
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'credit_note.created',
|
||||
payload: { creditNote: completeCreditNote as CreditNote, companyId, userId },
|
||||
})
|
||||
if (completeCreditNoteError || !completeCreditNote) {
|
||||
log.error('failed to read completed credit note', completeCreditNoteError)
|
||||
return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', log, { requestId })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: completeCreditNote,
|
||||
...(warnings.length > 0 ? { warnings } : {}),
|
||||
})
|
||||
// A credit note is only issued when the user sends it or marks it as sent.
|
||||
// Until then it is a non-editable draft: no journal entry is created and
|
||||
// the original invoice remains in its current state.
|
||||
return NextResponse.json({ data: completeCreditNote })
|
||||
}
|
||||
|
||||
@@ -84,6 +84,88 @@ describe('PUT /api/settings', () => {
|
||||
expect(body.data.company_name).toBe('New Name')
|
||||
})
|
||||
|
||||
it('updates all three reminder thresholds', async () => {
|
||||
enqueueMany([
|
||||
{
|
||||
data: {
|
||||
entity_type: 'aktiebolag',
|
||||
onboarding_complete: true,
|
||||
reminder_days_level_1: 15,
|
||||
reminder_days_level_2: 30,
|
||||
reminder_days_level_3: 45,
|
||||
},
|
||||
},
|
||||
{
|
||||
data: {
|
||||
id: 's1',
|
||||
reminder_days_level_1: 7,
|
||||
reminder_days_level_2: 21,
|
||||
reminder_days_level_3: 35,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const request = createMockRequest('/api/settings', {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
reminder_days_level_1: 7,
|
||||
reminder_days_level_2: 21,
|
||||
reminder_days_level_3: 35,
|
||||
},
|
||||
})
|
||||
const response = await PUT(request, { params: Promise.resolve({}) })
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { reminder_days_level_1: number; reminder_days_level_2: number; reminder_days_level_3: number }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toMatchObject({
|
||||
reminder_days_level_1: 7,
|
||||
reminder_days_level_2: 21,
|
||||
reminder_days_level_3: 35,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 400 when reminder thresholds are not increasing', async () => {
|
||||
enqueue({
|
||||
data: {
|
||||
reminder_days_level_1: 15,
|
||||
reminder_days_level_2: 30,
|
||||
reminder_days_level_3: 45,
|
||||
},
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/settings', {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
reminder_days_level_1: 30,
|
||||
reminder_days_level_2: 20,
|
||||
reminder_days_level_3: 45,
|
||||
},
|
||||
})
|
||||
const response = await PUT(request, { params: Promise.resolve({}) })
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(supabase.from).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns 404 when the settings row does not exist', async () => {
|
||||
enqueueMany([
|
||||
{ data: { onboarding_complete: false } },
|
||||
{ data: null, error: { code: 'PGRST116', message: 'No rows returned' } },
|
||||
])
|
||||
|
||||
const request = createMockRequest('/api/settings', {
|
||||
method: 'PUT',
|
||||
body: { reminder_days_level_1: 10 },
|
||||
})
|
||||
const response = await PUT(request, { params: Promise.resolve({}) })
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
})
|
||||
|
||||
it('blocks a vacation-year basis change while open balances exist', async () => {
|
||||
enqueueMany([
|
||||
{ data: { salary_vacation_year_basis: 'calendar', onboarding_complete: true } }, // oldSettings
|
||||
|
||||
@@ -40,7 +40,7 @@ export const PUT = withRouteContext(
|
||||
// Fetch current settings to check for tax-relevant changes
|
||||
const { data: oldSettings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type, moms_period, f_skatt, vat_registered, vat_number, pays_salaries, fiscal_year_start_month, onboarding_complete, salary_vacation_year_basis')
|
||||
.select('entity_type, moms_period, f_skatt, vat_registered, vat_number, pays_salaries, fiscal_year_start_month, onboarding_complete, salary_vacation_year_basis, reminder_days_level_1, reminder_days_level_2, reminder_days_level_3')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
@@ -48,6 +48,18 @@ export const PUT = withRouteContext(
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
const reminderDays = [
|
||||
body.reminder_days_level_1 ?? oldSettings?.reminder_days_level_1 ?? 15,
|
||||
body.reminder_days_level_2 ?? oldSettings?.reminder_days_level_2 ?? 30,
|
||||
body.reminder_days_level_3 ?? oldSettings?.reminder_days_level_3 ?? 45,
|
||||
]
|
||||
if (!(reminderDays[0] < reminderDays[1] && reminderDays[1] < reminderDays[2])) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Påminnelsedagarna måste ligga i stigande ordning.' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// Lock org_number after onboarding is complete (legal identifier: changing it
|
||||
// would orphan vouchers, SIE history, and tax filings). company_name remains
|
||||
// editable so users can update their display/brand name (e.g. särskilt företagsnamn).
|
||||
@@ -121,6 +133,9 @@ export const PUT = withRouteContext(
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Inställningarna hittades inte.' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,11 @@ vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
|
||||
mockCreateSupplierInvoicePrivatelyPaidEntry(...args),
|
||||
}))
|
||||
|
||||
const mockLinkToJournalEntry = vi.fn()
|
||||
vi.mock('@/lib/core/documents/document-service', () => ({
|
||||
linkToJournalEntry: (...args: unknown[]) => mockLinkToJournalEntry(...args),
|
||||
}))
|
||||
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
import { GET, POST } from '../route'
|
||||
@@ -128,6 +133,7 @@ describe('GET /api/supplier-invoices', () => {
|
||||
|
||||
const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000'
|
||||
const VALID_UUID_2 = '550e8400-e29b-41d4-a716-446655440001'
|
||||
const DOCUMENT_UUID = '550e8400-e29b-41d4-a716-446655440002'
|
||||
|
||||
describe('POST /api/supplier-invoices', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
@@ -221,6 +227,77 @@ describe('POST /api/supplier-invoices', () => {
|
||||
expect(mockCreateSupplierInvoiceRegistrationEntry).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stores an uploaded document and links it to the registration entry', async () => {
|
||||
const supplier = makeSupplier({ id: VALID_UUID })
|
||||
const createdInvoice = makeSupplierInvoice({ id: 'si-with-document', document_id: DOCUMENT_UUID })
|
||||
|
||||
enqueue({ data: { id: DOCUMENT_UUID, journal_entry_id: null }, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: supplier, error: null })
|
||||
enqueue({ data: 6 })
|
||||
enqueue({ data: createdInvoice, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual' }, error: null })
|
||||
mockCreateSupplierInvoiceRegistrationEntry.mockResolvedValue({ id: 'je-document' })
|
||||
enqueue({ data: null, error: null })
|
||||
mockLinkToJournalEntry.mockResolvedValue({ id: DOCUMENT_UUID })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
supplier_id: VALID_UUID,
|
||||
document_id: DOCUMENT_UUID,
|
||||
supplier_invoice_number: 'LF-DOCUMENT',
|
||||
invoice_date: '2024-06-01',
|
||||
due_date: '2024-07-01',
|
||||
items: [
|
||||
{ description: 'Service', quantity: 1, unit_price: 1000, account_number: '6200' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { document_id: string; registration_journal_entry_id: string }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.document_id).toBe(DOCUMENT_UUID)
|
||||
expect(body.data.registration_journal_entry_id).toBe('je-document')
|
||||
expect(mockLinkToJournalEntry).toHaveBeenCalledWith(
|
||||
mockSupabase,
|
||||
'company-1',
|
||||
DOCUMENT_UUID,
|
||||
'je-document',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a document that is missing or outside the active company', async () => {
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
supplier_id: VALID_UUID,
|
||||
document_id: DOCUMENT_UUID,
|
||||
supplier_invoice_number: 'LF-INVALID-DOCUMENT',
|
||||
invoice_date: '2024-06-01',
|
||||
due_date: '2024-07-01',
|
||||
items: [
|
||||
{ description: 'Service', quantity: 1, unit_price: 1000, account_number: '6200' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('SI_CREATE_INVALID_INPUT')
|
||||
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
|
||||
expect(mockLinkToJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits supplier_invoice.registered event', async () => {
|
||||
const supplier = makeSupplier({ id: VALID_UUID })
|
||||
const createdInvoice = makeSupplierInvoice({ id: 'si-1' })
|
||||
|
||||
@@ -12,6 +12,7 @@ import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateSupplierInvoiceSchema } from '@/lib/api/schemas'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
@@ -68,6 +69,42 @@ export const POST = withRouteContext(
|
||||
const body = validation.data
|
||||
const paidPrivately = body.paid_with_private_funds === true
|
||||
|
||||
if (body.document_id) {
|
||||
const { data: document, error: documentError } = await supabase
|
||||
.from('document_attachments')
|
||||
.select('id, journal_entry_id')
|
||||
.eq('id', body.document_id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (documentError || !document || document.journal_entry_id) {
|
||||
return errorResponseFromCode('SI_CREATE_INVALID_INPUT', log, {
|
||||
requestId,
|
||||
details: { reason: 'document_id is missing, belongs to another company, or is already linked' },
|
||||
})
|
||||
}
|
||||
|
||||
const { data: existingDocumentUse, error: existingDocumentUseError } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('document_id', body.document_id)
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
if (existingDocumentUseError) {
|
||||
log.error('supplier invoice document usage lookup failed', existingDocumentUseError)
|
||||
return errorResponse(existingDocumentUseError, log, { requestId })
|
||||
}
|
||||
|
||||
if (existingDocumentUse) {
|
||||
return errorResponseFromCode('SI_CREATE_INVALID_INPUT', log, {
|
||||
requestId,
|
||||
details: { reason: 'document_id is already used by a supplier invoice' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (paidPrivately && body.reverse_charge) {
|
||||
// RC invoices come from registered businesses with formal invoices and
|
||||
// go through normal AP. "Privately paid" only makes sense for
|
||||
@@ -236,6 +273,7 @@ export const POST = withRouteContext(
|
||||
user_id: user.id,
|
||||
company_id: companyId,
|
||||
supplier_id: body.supplier_id,
|
||||
document_id: body.document_id || null,
|
||||
arrival_number: arrivalNum,
|
||||
supplier_invoice_number: body.supplier_invoice_number,
|
||||
invoice_date: body.invoice_date,
|
||||
@@ -502,6 +540,28 @@ export const POST = withRouteContext(
|
||||
}
|
||||
}
|
||||
|
||||
const primaryJournalEntryId = paymentJournalEntryId || registrationJournalEntryId
|
||||
if (body.document_id && primaryJournalEntryId) {
|
||||
try {
|
||||
await linkToJournalEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
body.document_id,
|
||||
primaryJournalEntryId,
|
||||
)
|
||||
} catch (err) {
|
||||
log.warn('supplier invoice document could not be linked to journal entry', {
|
||||
documentId: body.document_id,
|
||||
journalEntryId: primaryJournalEntryId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
warnings.push({
|
||||
code: 'DOCUMENT_LINK_FAILED',
|
||||
message: 'Fakturan registrerades, men underlaget kunde inte kopplas till verifikationen.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await eventBus.emit({
|
||||
type: 'supplier_invoice.registered',
|
||||
|
||||
@@ -309,6 +309,43 @@ describe('POST /api/transactions/[id]/link-journal-entry', () => {
|
||||
expect(body.error.code).toBe('LINK_TX_INVOICE_NOT_OPEN')
|
||||
})
|
||||
|
||||
it('returns 400 before linking when supplied invoice is a credit note', async () => {
|
||||
enqueue({
|
||||
data: makeTransaction({ id: TX_UUID, journal_entry_id: null, amount: 1000 }),
|
||||
error: null,
|
||||
})
|
||||
enqueue({
|
||||
data: {
|
||||
id: JE_UUID,
|
||||
status: 'posted',
|
||||
voucher_series: 'A',
|
||||
voucher_number: 1,
|
||||
entry_date: '2026-05-15',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
enqueue({
|
||||
data: makeInvoice({
|
||||
id: INV_UUID,
|
||||
status: 'sent',
|
||||
total: -1000,
|
||||
remaining_amount: -1000,
|
||||
credited_invoice_id: 'original-invoice-1',
|
||||
}),
|
||||
error: null,
|
||||
})
|
||||
|
||||
const request = createMockRequest(`/api/transactions/${TX_UUID}/link-journal-entry`, {
|
||||
method: 'POST',
|
||||
body: { journal_entry_id: JE_UUID, invoice_id: INV_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: TX_UUID }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('LINK_TX_INVOICE_CREDIT_NOTE')
|
||||
})
|
||||
|
||||
it('returns 409 LINK_TX_INVOICE_RACE when optimistic lock loses and rolls back the tx link', async () => {
|
||||
enqueue({
|
||||
data: makeTransaction({ id: TX_UUID, journal_entry_id: null, amount: 1000, date: '2026-05-15' }),
|
||||
|
||||
@@ -196,6 +196,51 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
expect((body.error as unknown as { code: string }).code).toBe('MATCH_INVOICE_NOT_INVOICE_TYPE')
|
||||
})
|
||||
|
||||
it('rejects matching an original invoice with an active credit-note draft', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null })
|
||||
const invoice = {
|
||||
...makeInvoice({ id: VALID_UUID, status: 'sent', credited_invoice_id: null }),
|
||||
credit_notes: [{ id: 'credit-1', status: 'draft', creation_complete: true }],
|
||||
}
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: invoice, error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(body.error.code).toBe('MATCH_INVOICE_CREDIT_NOTE')
|
||||
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 before booking when matching against a credit note', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null })
|
||||
const creditNote = makeInvoice({
|
||||
id: VALID_UUID,
|
||||
status: 'sent',
|
||||
total: -12500,
|
||||
credited_invoice_id: 'original-invoice-1',
|
||||
})
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: creditNote, error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('MATCH_INVOICE_CREDIT_NOTE')
|
||||
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
|
||||
expect(mockCreateInvoiceCashEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 when invoice is not in unpaid state', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null })
|
||||
const invoice = makeInvoice({ id: VALID_UUID, status: 'paid' })
|
||||
|
||||
@@ -82,7 +82,7 @@ export const POST = withRouteContext(
|
||||
|
||||
const { data: invoice, error: fetchInvError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.select('*, customer:customers(*), items:invoice_items(*), credit_notes:invoices!credited_invoice_id(id, status, creation_complete)')
|
||||
.eq('id', invoice_id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
@@ -104,6 +104,23 @@ export const POST = withRouteContext(
|
||||
})
|
||||
}
|
||||
|
||||
if (invoice.credited_invoice_id) {
|
||||
return errorResponseFromCode('MATCH_INVOICE_CREDIT_NOTE', txLog, { requestId })
|
||||
}
|
||||
|
||||
const activeCreditNotes = ((invoice as { credit_notes?: Array<{
|
||||
status: string
|
||||
creation_complete?: boolean
|
||||
}> }).credit_notes ?? []).filter(
|
||||
(creditNote) => creditNote.status !== 'cancelled' && creditNote.creation_complete !== false,
|
||||
)
|
||||
if (activeCreditNotes.length > 0) {
|
||||
return errorResponseFromCode('MATCH_INVOICE_CREDIT_NOTE', txLog, {
|
||||
requestId,
|
||||
details: { reason: 'active_credit_note' },
|
||||
})
|
||||
}
|
||||
|
||||
if (invoice.status !== 'sent' && invoice.status !== 'overdue' && invoice.status !== 'partially_paid') {
|
||||
return errorResponseFromCode('MATCH_INVOICE_NOT_OPEN', txLog, {
|
||||
requestId,
|
||||
|
||||
@@ -571,6 +571,47 @@ describe('POST :id/match-invoice', () => {
|
||||
expect((await res.json()).error.code).toBe('MATCH_INVOICE_TX_ALREADY_LINKED')
|
||||
})
|
||||
|
||||
it('rejects a credit note before creating a payment journal entry', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
transactions: {
|
||||
data: {
|
||||
id: TX_ID,
|
||||
amount: 12500,
|
||||
date: '2026-05-12',
|
||||
currency: 'SEK',
|
||||
invoice_id: null,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
invoices: {
|
||||
data: {
|
||||
id: INV_ID,
|
||||
status: 'sent',
|
||||
document_type: 'invoice',
|
||||
total: -12500,
|
||||
credited_invoice_id: 'ffffffff-ffff-4fff-8fff-ffffffffffff',
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await matchInvoicePOST(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-invoice`,
|
||||
{ invoice_id: INV_ID },
|
||||
),
|
||||
txParams(TX_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect((await res.json()).error.code).toBe('MATCH_INVOICE_CREDIT_NOTE')
|
||||
expect(createInvPmtJE).not.toHaveBeenCalled()
|
||||
expect(createInvCashJE).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The v1 route threads resolveSettlementAccount(transaction.cash_account_id)
|
||||
// exactly like the dashboard route and the agent/MCP commit path; these
|
||||
// regression tests were missing here (flagged in triage on #987) even
|
||||
|
||||
@@ -180,6 +180,11 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
details: { documentType: docType },
|
||||
})
|
||||
}
|
||||
if (invoice.credited_invoice_id) {
|
||||
return v1ErrorResponseFromCode('MATCH_INVOICE_CREDIT_NOTE', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
}
|
||||
if (
|
||||
invoice.status !== 'sent' &&
|
||||
invoice.status !== 'overdue' &&
|
||||
|
||||
Reference in New Issue
Block a user