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
@@ -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,