Skill/fix (#355)

* fix: ensure customer email addresses are anonymized and not displayed in tickets

* feat: add uncredit functionality for supplier invoices

- Implemented the ability to uncredit supplier invoices, restoring the original invoice status and freeing up the invoice number.
- Added confirmation dialog for uncrediting actions.
- Updated the supplier invoice detail page to show an "Undo Credit" button for credited invoices.
- Enhanced the new supplier invoice page to handle conflicts when a duplicate invoice number is detected, allowing users to uncredit the existing invoice.
- Created API endpoint for uncrediting invoices, including handling of journal entries and invoice status updates.
- Added tests for the uncredit functionality to ensure proper behavior and error handling.

* feat: implement soft-delete for credited invoices and add reversed status

* fix: update uncredit logic to handle registration journal entries and improve user feedback

* fix: retain no-op migration stub for history alignment with future index changes
This commit is contained in:
Mattsson
2026-04-23 11:57:12 +02:00
committed by GitHub
parent 02f94ef631
commit e137a9f452
13 changed files with 1051 additions and 28 deletions
+18 -2
View File
@@ -22,7 +22,9 @@ export async function GET(
const { data: invoice, error } = await supabase
.from('supplier_invoices')
.select('*, supplier:suppliers(*), items:supplier_invoice_items(*), payments:supplier_invoice_payments(*)')
.select(
'*, supplier:suppliers(*), items:supplier_invoice_items(*), payments:supplier_invoice_payments(*), credited_original:supplier_invoices!credited_invoice_id(id, supplier_invoice_number, arrival_number)'
)
.eq('id', id)
.eq('company_id', companyId)
.single()
@@ -111,7 +113,7 @@ export async function DELETE(
// Only allow deleting registered invoices without journal entries
const { data: existing } = await supabase
.from('supplier_invoices')
.select('status, registration_journal_entry_id')
.select('status, registration_journal_entry_id, is_credit_note')
.eq('id', id)
.eq('company_id', companyId)
.single()
@@ -120,6 +122,20 @@ export async function DELETE(
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
// Block direct deletion of credit notes — deleting just the row would orphan the
// posted reversal JE and silently break momsdeklaration. The user must instead
// run "Ångra kreditering" on the original, which storno-reverses the JE and
// restores the original's status atomically.
if (existing.is_credit_note) {
return NextResponse.json(
{
error:
'Kreditfakturor kan inte tas bort direkt. Gå till originalfakturan och välj "Ångra kreditering" för att frigöra numret och återställa bokföringen.',
},
{ status: 400 }
)
}
if (existing.status !== 'registered') {
return NextResponse.json(
{ error: 'Kan bara ta bort registrerade fakturor' },
@@ -0,0 +1,338 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createMockRouteParams,
createQueuedMockSupabase,
makeSupplierInvoice,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
const mockReverseEntry = vi.fn()
vi.mock('@/lib/bookkeeping/engine', () => ({
reverseEntry: (...args: unknown[]) => mockReverseEntry(...args),
}))
import { eventBus } from '@/lib/events'
import { POST } from '../route'
describe('POST /api/supplier-invoices/[id]/uncredit', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const request = createMockRequest('/api/supplier-invoices/inv-1/uncredit', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns 404 when original invoice not found', async () => {
enqueue({ data: null, error: { message: 'not found' } })
const request = createMockRequest('/api/supplier-invoices/inv-1/uncredit', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(404)
expect(body.error).toBe('Not found')
})
it('idempotently returns 200 when invoice is not credited', async () => {
const original = makeSupplierInvoice({ id: 'inv-1', status: 'approved', payments: [] })
enqueue({ data: original, error: null })
const request = createMockRequest('/api/supplier-invoices/inv-1/uncredit', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ data: { status: string } }>(response)
expect(status).toBe(200)
expect(body.data.status).toBe('approved')
expect(mockReverseEntry).not.toHaveBeenCalled()
})
it('reverses credit JE, soft-deletes credit row, and restores original to approved when no payments', async () => {
const original = makeSupplierInvoice({
id: 'inv-1',
status: 'credited',
total: 10000,
remaining_amount: 0,
due_date: '2099-12-31',
registration_journal_entry_id: 'je-reg-1',
payments: [],
})
// Fetch original
enqueue({ data: original, error: null })
// Find active (non-reversed) credit row
enqueue({
data: { id: 'credit-1', registration_journal_entry_id: 'je-credit' },
error: null,
})
mockReverseEntry.mockResolvedValue({ id: 'je-reversal' })
// Mark credit row reversed (soft-delete)
enqueue({ data: null, error: null })
// Update original
enqueue({
data: { ...original, status: 'approved', remaining_amount: 10000 },
error: null,
})
const request = createMockRequest('/api/supplier-invoices/inv-1/uncredit', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{
data: { status: string; remaining_amount: number }
reversal_entry_id: string
}>(response)
expect(status).toBe(200)
expect(body.data.status).toBe('approved')
expect(body.data.remaining_amount).toBe(10000)
expect(body.reversal_entry_id).toBe('je-reversal')
expect(mockReverseEntry).toHaveBeenCalledWith(
mockSupabase,
'company-1',
'user-1',
'je-credit'
)
})
it('restores to paid when full payments exist', async () => {
const original = makeSupplierInvoice({
id: 'inv-1',
status: 'credited',
total: 10000,
remaining_amount: 0,
payments: [
{
id: 'p-1',
supplier_invoice_id: 'inv-1',
payment_date: '2024-06-15',
amount: 10000,
currency: 'SEK',
exchange_rate: null,
exchange_rate_difference: 0,
journal_entry_id: 'je-pay',
transaction_id: null,
notes: null,
created_at: '2024-06-15T00:00:00Z',
},
],
})
enqueue({ data: original, error: null })
enqueue({
data: { id: 'credit-1', registration_journal_entry_id: 'je-credit' },
error: null,
})
mockReverseEntry.mockResolvedValue({ id: 'je-reversal' })
enqueue({ data: null, error: null })
enqueue({
data: { ...original, status: 'paid', remaining_amount: 0 },
error: null,
})
const request = createMockRequest('/api/supplier-invoices/inv-1/uncredit', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ data: { status: string } }>(response)
expect(status).toBe(200)
expect(body.data.status).toBe('paid')
})
it('handles cash method (credit row without registration_journal_entry_id) — skips reverseEntry and restores to registered', async () => {
// Pure cash-method: neither the original nor the credit row have a
// registration JE. The original must not be restored to 'approved' —
// that would assert a verifikation that never existed (sambandskravet,
// BFL 4 kap 2§). 'registered' is the correct state.
const original = makeSupplierInvoice({
id: 'inv-1',
status: 'credited',
total: 5000,
remaining_amount: 0,
due_date: '2099-12-31',
registration_journal_entry_id: null,
payments: [],
})
enqueue({ data: original, error: null })
enqueue({
data: { id: 'credit-1', registration_journal_entry_id: null },
error: null,
})
enqueue({ data: null, error: null })
enqueue({
data: { ...original, status: 'registered', remaining_amount: 5000 },
error: null,
})
const request = createMockRequest('/api/supplier-invoices/inv-1/uncredit', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{
data: { status: string }
reversal_entry_id: string | null
}>(response)
expect(status).toBe(200)
expect(body.data.status).toBe('registered')
expect(mockReverseEntry).not.toHaveBeenCalled()
expect(body.reversal_entry_id).toBeNull()
})
it('continues cleanup when JE was already manually reversed', async () => {
const original = makeSupplierInvoice({
id: 'inv-1',
status: 'credited',
total: 5000,
remaining_amount: 0,
registration_journal_entry_id: 'je-reg-1',
payments: [],
})
enqueue({ data: original, error: null })
enqueue({
data: { id: 'credit-1', registration_journal_entry_id: 'je-credit' },
error: null,
})
mockReverseEntry.mockRejectedValue(new Error('Can only reverse posted entries'))
enqueue({ data: null, error: null })
enqueue({
data: { ...original, status: 'approved', remaining_amount: 5000 },
error: null,
})
const request = createMockRequest('/api/supplier-invoices/inv-1/uncredit', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
})
it('returns 400 with Swedish message when reverseEntry hits a locked period', async () => {
const original = makeSupplierInvoice({
id: 'inv-1',
status: 'credited',
total: 5000,
remaining_amount: 0,
payments: [],
})
enqueue({ data: original, error: null })
enqueue({
data: { id: 'credit-1', registration_journal_entry_id: 'je-credit' },
error: null,
})
mockReverseEntry.mockRejectedValue(
new Error('Cannot create entry in locked/closed fiscal period')
)
const request = createMockRequest('/api/supplier-invoices/inv-1/uncredit', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toMatch(/låst|stängd/i)
})
it('restores original even when no active credit row is found (credit already reversed)', async () => {
const original = makeSupplierInvoice({
id: 'inv-1',
status: 'credited',
total: 5000,
remaining_amount: 0,
due_date: '2099-12-31',
registration_journal_entry_id: 'je-reg-1',
payments: [],
})
enqueue({ data: original, error: null })
// Find credit row filters out status='reversed' — nothing comes back
enqueue({ data: null, error: null })
enqueue({
data: { ...original, status: 'approved', remaining_amount: 5000 },
error: null,
})
const request = createMockRequest('/api/supplier-invoices/inv-1/uncredit', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{
data: { status: string }
reversal_entry_id: string | null
}>(response)
expect(status).toBe(200)
expect(body.data.status).toBe('approved')
expect(body.reversal_entry_id).toBeNull()
expect(mockReverseEntry).not.toHaveBeenCalled()
})
it('emits supplier_invoice.uncredited event', async () => {
const original = makeSupplierInvoice({
id: 'inv-1',
status: 'credited',
total: 5000,
remaining_amount: 0,
due_date: '2099-12-31',
registration_journal_entry_id: 'je-reg-1',
payments: [],
})
enqueue({ data: original, error: null })
enqueue({
data: { id: 'credit-1', registration_journal_entry_id: 'je-credit' },
error: null,
})
mockReverseEntry.mockResolvedValue({ id: 'je-reversal' })
enqueue({ data: null, error: null })
enqueue({
data: { ...original, status: 'approved', remaining_amount: 5000 },
error: null,
})
const emitSpy = vi.spyOn(eventBus, 'emit')
const request = createMockRequest('/api/supplier-invoices/inv-1/uncredit', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(emitSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'supplier_invoice.uncredited',
payload: expect.objectContaining({
reversedCreditNoteId: 'credit-1',
reversalEntryId: 'je-reversal',
userId: 'user-1',
}),
})
)
})
})
@@ -0,0 +1,175 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import type { SupplierInvoice, SupplierInvoicePayment } from '@/types'
ensureInitialized()
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
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)
const { data: original, error: fetchError } = await supabase
.from('supplier_invoices')
.select('*, payments:supplier_invoice_payments(*)')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (fetchError || !original) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
// Idempotent no-op: an already-uncredited or never-credited invoice just returns the row.
// Friendlier than a 409 — the client retry path can blindly call this without checking first.
if (original.status !== 'credited') {
return NextResponse.json({ data: original })
}
// Filter out already-reversed credits — re-crediting the same original after a prior
// uncredit creates a second credit row, so we may find multiple historical matches.
const { data: creditNote } = await supabase
.from('supplier_invoices')
.select('id, registration_journal_entry_id')
.eq('company_id', companyId)
.eq('credited_invoice_id', id)
.eq('is_credit_note', true)
.neq('status', 'reversed')
.maybeSingle()
let reversalEntryId: string | null = null
if (creditNote?.registration_journal_entry_id) {
try {
const reversal = await reverseEntry(
supabase,
companyId,
user.id,
creditNote.registration_journal_entry_id
)
reversalEntryId = reversal.id
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
// Already reversed (manually or by another concurrent uncredit) — fine, continue.
if (
/Can only reverse posted entries/i.test(msg) ||
/already reversed by a concurrent operation/i.test(msg)
) {
// proceed to row cleanup
} else if (err instanceof AccountsNotInChartError) {
return accountsNotInChartResponse(err)
} else {
// Period lock and similar trigger errors — surface a clear Swedish message
// so the user knows WHY the action failed (per project's error-UX guidelines).
return NextResponse.json(
{ error: getErrorMessage(err, { context: 'supplier_invoice' }) },
{ status: 400 }
)
}
}
}
if (creditNote) {
// Soft-delete: mark the credit row 'reversed' and stamp reversed_at. BFL 7 kap
// requires räkenskapsinformation to be preserved for 7 years; BFL 5 kap 7§ wants
// an unbroken ankomstnummer series; sambandskravet requires the posted JE to
// remain traceable back to its business-layer row. A hard-delete would break all
// three. The row and its items are kept; the partial unique index excludes
// status='reversed' so re-crediting is still possible.
const { error: reverseMarkError } = await supabase
.from('supplier_invoices')
.update({ status: 'reversed', reversed_at: new Date().toISOString() })
.eq('id', creditNote.id)
.eq('company_id', companyId)
if (reverseMarkError) {
return NextResponse.json(
{ error: getErrorMessage(reverseMarkError, { context: 'supplier_invoice' }) },
{ status: 500 }
)
}
}
// Recompute original status from payments. The credit had reduced remaining_amount
// to 0 and bumped status to 'credited' — undo both based on what's actually paid.
const payments = (original.payments as SupplierInvoicePayment[]) || []
const paidSum = payments.reduce((sum, p) => sum + (p.amount || 0), 0)
const total = original.total || 0
const remaining = Math.round((total - paidSum) * 100) / 100
let newStatus: SupplierInvoice['status']
if (paidSum >= total && total > 0) {
newStatus = 'paid'
} else if (paidSum > 0) {
newStatus = 'partially_paid'
} else if (original.due_date && new Date(original.due_date) < new Date()) {
newStatus = 'overdue'
} else if (original.registration_journal_entry_id) {
// Posted verifikation exists -> safe to restore to 'approved'.
newStatus = 'approved'
} else {
// No registration JE on the original (cash method, or an inconsistent row
// that somehow reached 'credited' without a booking). Restoring to
// 'approved' would yield an approved invoice with no verifikation, which
// violates sambandskravet (BFL 4 kap 2§). Fall back to 'registered'.
newStatus = 'registered'
}
const { data: restored, error: updateError } = await supabase
.from('supplier_invoices')
.update({
status: newStatus,
remaining_amount: remaining,
})
.eq('id', id)
.eq('company_id', companyId)
.select()
.single()
if (updateError || !restored) {
return NextResponse.json(
{ error: getErrorMessage(updateError, { context: 'supplier_invoice' }) },
{ status: 500 }
)
}
try {
await eventBus.emit({
type: 'supplier_invoice.uncredited',
payload: {
supplierInvoice: restored as SupplierInvoice,
reversedCreditNoteId: creditNote?.id ?? '',
reversalEntryId,
userId: user.id,
companyId,
},
})
} catch {
// Non-blocking
}
return NextResponse.json({
data: restored,
reversal_entry_id: reversalEntryId,
})
}
@@ -301,4 +301,163 @@ describe('POST /api/supplier-invoices', () => {
expect(status).toBe(500)
expect(body.error).toBe('Items insert failed')
})
it('returns 409 with credit chain on duplicate supplier_invoice_number for credited original', async () => {
const supplier = makeSupplier({ id: VALID_UUID })
// Fetch supplier
enqueue({ data: supplier, error: null })
// RPC get_next_arrival_number
enqueue({ data: 8 })
// Insert invoice → unique-index violation
enqueue({
data: null,
error: {
code: '23505',
message:
'duplicate key value violates unique constraint "idx_supplier_invoices_company_supplier_number"',
},
})
// Lookup existing row
enqueue({
data: {
id: 'existing-1',
supplier_invoice_number: 'LF-DUP',
status: 'credited',
},
error: null,
})
// Lookup credit note for the credited original
enqueue({ data: { id: 'credit-1' }, error: null })
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
supplier_invoice_number: 'LF-DUP',
invoice_date: '2024-06-01',
due_date: '2024-07-01',
items: [{ description: 'Test', quantity: 1, unit_price: 1000, account_number: '4010' }],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{
error: string
message: string
existing: { id: string; supplier_invoice_number: string; status: string; credit_note_id: string }
}>(response)
expect(status).toBe(409)
expect(body.error).toBe('duplicate_supplier_invoice_number')
expect(body.message).toMatch(/krediterad/i)
expect(body.existing).toEqual({
id: 'existing-1',
supplier_invoice_number: 'LF-DUP',
status: 'credited',
credit_note_id: 'credit-1',
})
})
it('returns 409 without credit_note_id when existing invoice is not credited', async () => {
const supplier = makeSupplier({ id: VALID_UUID })
enqueue({ data: supplier, error: null })
enqueue({ data: 9 })
enqueue({
data: null,
error: {
code: '23505',
message:
'duplicate key value violates unique constraint "idx_supplier_invoices_company_supplier_number"',
},
})
enqueue({
data: {
id: 'existing-2',
supplier_invoice_number: 'LF-DUP-2',
status: 'approved',
},
error: null,
})
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
supplier_invoice_number: 'LF-DUP-2',
invoice_date: '2024-06-01',
due_date: '2024-07-01',
items: [{ description: 'Test', quantity: 1, unit_price: 1000, account_number: '4010' }],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{
error: string
message: string
existing: { id: string; status: string; credit_note_id: string | null }
}>(response)
expect(status).toBe(409)
expect(body.error).toBe('duplicate_supplier_invoice_number')
expect(body.existing.status).toBe('approved')
expect(body.existing.credit_note_id).toBeNull()
})
it('returns generic 409 when existing row lookup races to nothing', async () => {
const supplier = makeSupplier({ id: VALID_UUID })
enqueue({ data: supplier, error: null })
enqueue({ data: 10 })
enqueue({
data: null,
error: {
code: '23505',
message:
'duplicate key value violates unique constraint "idx_supplier_invoices_company_supplier_number"',
},
})
// Lookup returns null — the row was deleted between the failing insert and our fetch
enqueue({ data: null, error: null })
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
supplier_invoice_number: 'LF-RACE',
invoice_date: '2024-06-01',
due_date: '2024-07-01',
items: [{ description: 'Test', quantity: 1, unit_price: 1000, account_number: '4010' }],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: string; message: string; existing?: unknown }>(response)
expect(status).toBe(409)
expect(body.error).toBe('duplicate_supplier_invoice_number')
expect(body.existing).toBeUndefined()
})
it('falls through to 500 for non-23505 insert errors', async () => {
const supplier = makeSupplier({ id: VALID_UUID })
enqueue({ data: supplier, error: null })
enqueue({ data: 11 })
enqueue({ data: null, error: { code: '23502', message: 'NOT NULL violation' } })
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
supplier_invoice_number: 'LF-OTHER',
invoice_date: '2024-06-01',
due_date: '2024-07-01',
items: [{ description: 'Test', quantity: 1, unit_price: 1000, account_number: '4010' }],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(500)
expect(body.error).toBe('NOT NULL violation')
})
})
+70
View File
@@ -147,6 +147,76 @@ export async function POST(request: Request) {
.single()
if (invoiceError || !invoice) {
// Translate the unique-index violation on (company_id, supplier_id, supplier_invoice_number)
// into a structured 409 so the UI can offer to undo the credit chain rather than
// leaving the user stuck on a generic 500. Other DB errors keep the existing 500 path.
const pgErr = invoiceError as { code?: string; message?: string } | null
const isDuplicateNumber =
pgErr?.code === '23505' &&
(pgErr.message || '').includes('idx_supplier_invoices_company_supplier_number')
if (isDuplicateNumber) {
const { data: existing } = await supabase
.from('supplier_invoices')
.select('id, supplier_invoice_number, status')
.eq('company_id', companyId)
.eq('supplier_id', body.supplier_id)
.eq('supplier_invoice_number', body.supplier_invoice_number)
.maybeSingle()
if (!existing) {
// Race: row vanished between the failing insert and our lookup. Stay defensive.
return NextResponse.json(
{
error: 'duplicate_supplier_invoice_number',
message: `Det finns redan en faktura med nummer ${body.supplier_invoice_number} från denna leverantör.`,
},
{ status: 409 }
)
}
let creditNoteId: string | null = null
if (existing.status === 'credited') {
const { data: creditNote } = await supabase
.from('supplier_invoices')
.select('id')
.eq('company_id', companyId)
.eq('credited_invoice_id', existing.id)
.eq('is_credit_note', true)
.maybeSingle()
creditNoteId = creditNote?.id ?? null
}
const statusLabels: Record<string, string> = {
registered: 'registrerad',
approved: 'godkänd',
paid: 'betald',
partially_paid: 'delbetald',
overdue: 'förfallen',
disputed: 'tvist',
credited: 'krediterad',
}
const statusLabel = statusLabels[existing.status] || existing.status
const message =
existing.status === 'credited'
? `Det finns redan en faktura med nummer ${existing.supplier_invoice_number} från denna leverantör (krediterad). Du kan ångra krediteringen för att frigöra numret, eller använda ett annat nummer.`
: `Det finns redan en faktura med nummer ${existing.supplier_invoice_number} från denna leverantör (status: ${statusLabel}). Använd ett annat nummer.`
return NextResponse.json(
{
error: 'duplicate_supplier_invoice_number',
message,
existing: {
id: existing.id,
supplier_invoice_number: existing.supplier_invoice_number,
status: existing.status,
credit_note_id: creditNoteId,
},
},
{ status: 409 }
)
}
return NextResponse.json({ error: invoiceError?.message || 'Failed to create invoice' }, { status: 500 })
}