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:
Mattsson
2026-07-15 15:53:15 +02:00
committed by GitHub
parent a558c75678
commit 072aedeaf9
116 changed files with 5708 additions and 669 deletions
+51 -14
View File
@@ -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 })
+63 -5
View File
@@ -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' },
})
})
})
+31 -2
View File
@@ -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)
})
})
+6 -3
View File
@@ -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 },
)
+52 -1
View File
@@ -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({
+21 -1
View File
@@ -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
+181 -40
View File
@@ -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 },
+20 -34
View File
@@ -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 () => {
+116 -31
View File
@@ -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 }
+33 -20
View File
@@ -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
View File
@@ -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 })
}
+82
View File
@@ -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
+16 -1
View File
@@ -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' })
+60
View File
@@ -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' &&