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