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:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user