Fix/supp ag fb (#1023)
* fix: prevent credit notes from entering payment flow * fix: persist and display customer personal numbers * feat: configure automatic invoice reminder days * fix: issue credit notes through send flow * chore: add repository agent guidance * feat(mcp): route tools across user companies * fix(articles): delete unused register entries * feat(invoices): improve issued invoice actions * feat(supplier-invoices): retain uploaded source documents * docs: record implementation decisions * feat: enhance customer personal number handling and validation - Updated CustomerForm to allow personal numbers in the format of "********-1234" for individual customers. - Added validation to ensure personal numbers are only accepted for individual customers in CreateCustomerSchema. - Implemented masking and encryption for personal numbers to enhance data protection. - Introduced new utility functions for masking and encrypting personal numbers. - Added database migration to enforce unique constraints on credit note relationships and prevent duplicate entries. - Enhanced error handling and logging for credit note issuance and invoice processing. - Updated tests to cover new credit note creation guards and personal number handling. * test: enhance list companies test with supabase query mocks
This commit is contained in:
@@ -111,10 +111,9 @@ export const PATCH = withRouteContext(
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
// DELETE soft-deactivates (active = false) rather than hard-deleting. Articles
|
||||
// are master data referenced by historical invoice lines via a (frozen) copy;
|
||||
// keeping the row preserves the register's audit trail and the article number.
|
||||
// Re-activate by PATCHing { active: true }.
|
||||
// Articles are master data, while invoice lines hold frozen copies of the
|
||||
// accounting values. An article may therefore be deleted only while no invoice
|
||||
// line references it. The preflight also covers draft invoices.
|
||||
export const DELETE = withRouteContext(
|
||||
'article.delete',
|
||||
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||||
@@ -122,28 +121,66 @@ export const DELETE = withRouteContext(
|
||||
const { user, supabase, companyId, log, requestId } = ctx
|
||||
const opLog = log.child({ articleId: id })
|
||||
|
||||
const { data, error } = await supabase
|
||||
const { error: articleError } = await supabase
|
||||
.from('articles')
|
||||
.update({ active: false })
|
||||
.select('id')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
if (articleError) {
|
||||
if (articleError.code === 'PGRST116') {
|
||||
return errorResponseFromCode('ARTICLE_NOT_FOUND', opLog, { requestId })
|
||||
}
|
||||
opLog.error('article deactivate failed', error)
|
||||
return errorResponseFromCode('ARTICLE_UPDATE_FAILED', opLog, {
|
||||
opLog.error('article lookup before delete failed', articleError)
|
||||
return errorResponseFromCode('ARTICLE_DELETE_FAILED', opLog, {
|
||||
requestId,
|
||||
details: { reason: error.message },
|
||||
details: { reason: articleError.message },
|
||||
})
|
||||
}
|
||||
|
||||
const { count: usageCount, error: usageError } = await supabase
|
||||
.from('invoice_items')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('article_id', id)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (usageError) {
|
||||
opLog.error('article usage check failed', usageError)
|
||||
return errorResponseFromCode('ARTICLE_DELETE_FAILED', opLog, {
|
||||
requestId,
|
||||
details: { reason: usageError.message },
|
||||
})
|
||||
}
|
||||
|
||||
if ((usageCount ?? 0) > 0) {
|
||||
return errorResponseFromCode('ARTICLE_IN_USE', opLog, { requestId })
|
||||
}
|
||||
|
||||
const { error: deleteError, count: deletedCount } = await supabase
|
||||
.from('articles')
|
||||
.delete({ count: 'exact' })
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (deleteError) {
|
||||
if (deleteError.code === '23503') {
|
||||
return errorResponseFromCode('ARTICLE_IN_USE', opLog, { requestId })
|
||||
}
|
||||
opLog.error('article delete failed', deleteError)
|
||||
return errorResponseFromCode('ARTICLE_DELETE_FAILED', opLog, {
|
||||
requestId,
|
||||
details: { reason: deleteError.message },
|
||||
})
|
||||
}
|
||||
|
||||
if (deletedCount === 0) {
|
||||
return errorResponseFromCode('ARTICLE_NOT_FOUND', opLog, { requestId })
|
||||
}
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'article.updated',
|
||||
payload: { article: data as Article, companyId: companyId!, userId: user.id },
|
||||
type: 'article.deleted',
|
||||
payload: { articleId: id, companyId, userId: user.id },
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* Tests for GET/PATCH/DELETE /api/articles/[id] (artikelregister).
|
||||
*
|
||||
* DELETE soft-deactivates (active = false) rather than hard-deleting, so the
|
||||
* article and its number survive for history. PATCH is a sparse update.
|
||||
* DELETE permanently removes only articles that have never been used on an
|
||||
* invoice line. PATCH is a sparse update.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createQueuedMockSupabase, createMockRequest, createMockRouteParams, parseJsonResponse } from '@/tests/helpers'
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
@@ -97,13 +99,69 @@ describe('GET/PATCH/DELETE /api/articles/[id]', () => {
|
||||
expect(body.error.code).toBe('ARTICLE_REVENUE_ACCOUNT_INVALID')
|
||||
})
|
||||
|
||||
it('DELETE soft-deactivates and returns success', async () => {
|
||||
enqueue({ data: { id: 'a1', active: false } })
|
||||
it('DELETE returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const response = await DELETE(createMockRequest('/api/articles/a1', { method: 'DELETE' }), createMockRouteParams({ id: 'a1' }))
|
||||
const response = await DELETE(
|
||||
createMockRequest('/api/articles/a1', { method: 'DELETE' }),
|
||||
createMockRouteParams({ id: 'a1' }),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('DELETE returns 404 when the article is not found', async () => {
|
||||
enqueue({ data: null, error: { code: 'PGRST116', message: 'not found' } })
|
||||
|
||||
const response = await DELETE(
|
||||
createMockRequest('/api/articles/a1', { method: 'DELETE' }),
|
||||
createMockRouteParams({ id: 'a1' }),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error.code).toBe('ARTICLE_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('DELETE rejects an article used on an invoice line', async () => {
|
||||
enqueue({ data: { id: 'a1' }, error: null })
|
||||
enqueue({ data: null, error: null, count: 1 })
|
||||
|
||||
const response = await DELETE(
|
||||
createMockRequest('/api/articles/a1', { method: 'DELETE' }),
|
||||
createMockRouteParams({ id: 'a1' }),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('ARTICLE_IN_USE')
|
||||
expect(supabase.from).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('DELETE permanently removes an unused article', async () => {
|
||||
enqueue({ data: { id: 'a1' }, error: null })
|
||||
enqueue({ data: null, error: null, count: 0 })
|
||||
enqueue({ data: null, error: null, count: 1 })
|
||||
|
||||
const emitSpy = vi.spyOn(eventBus, 'emit')
|
||||
const response = await DELETE(
|
||||
createMockRequest('/api/articles/a1', { method: 'DELETE' }),
|
||||
createMockRouteParams({ id: 'a1' }),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ success: boolean }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(supabase.from).toHaveBeenNthCalledWith(1, 'articles')
|
||||
expect(supabase.from).toHaveBeenNthCalledWith(2, 'invoice_items')
|
||||
expect(supabase.from).toHaveBeenNthCalledWith(3, 'articles')
|
||||
expect(emitSpy).toHaveBeenCalledWith({
|
||||
type: 'article.deleted',
|
||||
payload: { articleId: 'a1', companyId: 'company-1', userId: 'user-1' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user