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:
@@ -143,6 +143,38 @@ describe('commitPendingOperation: unlock_period', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('commitPendingOperation: credit-note issuance guard', () => {
|
||||
it('rejects mark_invoice_sent before the ordinary invoice executor can book it', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({
|
||||
data: makeInvoice({
|
||||
id: 'credit-1',
|
||||
status: 'draft',
|
||||
credited_invoice_id: 'invoice-1',
|
||||
}),
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: null, error: null }) // dispatcher's rejected update
|
||||
|
||||
const op = makePendingOp({
|
||||
operation_type: 'mark_invoice_sent',
|
||||
params: { invoice_id: 'credit-1' },
|
||||
})
|
||||
|
||||
const result = await commitPendingOperation(
|
||||
supabase as never,
|
||||
'user-1',
|
||||
'company-1',
|
||||
op,
|
||||
)
|
||||
|
||||
expect(result.status).toBe('rejected')
|
||||
expect(result.http_status).toBe(409)
|
||||
expect(result.error).toContain('Credit notes must be issued')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── post_annual_depreciation ───────────────────────────────────────
|
||||
|
||||
describe('commitPendingOperation: post_annual_depreciation', () => {
|
||||
|
||||
@@ -253,6 +253,49 @@ describe('commitPendingOperation: link_transaction_journal_entry', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a credit note before linking the transaction', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
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,
|
||||
})
|
||||
enqueue({ data: null, error: null }) // dispatcher's reject update
|
||||
|
||||
const op = makePendingOp({
|
||||
params: {
|
||||
transaction_id: TX_UUID,
|
||||
journal_entry_id: JE_UUID,
|
||||
invoice_id: INV_UUID,
|
||||
},
|
||||
})
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.http_status).toBe(400)
|
||||
expect(result.error).toBe('Credit notes cannot be recorded as paid.')
|
||||
})
|
||||
|
||||
it('returns 409 LINK_TX_INVOICE_RACE when optimistic lock loses', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
|
||||
@@ -67,6 +67,35 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('commitPendingOperation: mark_invoice_paid state + invoice.paid', () => {
|
||||
it('rejects credit notes before creating a payment journal entry', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'credit-1',
|
||||
invoice_number: 'KR-F-2026001',
|
||||
status: 'sent',
|
||||
total: -525,
|
||||
remaining_amount: -525,
|
||||
paid_amount: null,
|
||||
credited_invoice_id: 'inv-1',
|
||||
document_type: 'invoice',
|
||||
journal_entry_id: 'je-credit',
|
||||
customer: { name: 'Test AB' },
|
||||
},
|
||||
error: null,
|
||||
}) // invoice fetch
|
||||
enqueue({ data: null, error: null }) // dispatcher pending_operations update
|
||||
|
||||
const op = makePendingOp({ params: { invoice_id: 'credit-1', payment_date: '2026-03-30' } })
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('rejected')
|
||||
expect(result.http_status).toBe(409)
|
||||
expect(mockCreatePaymentEntry).not.toHaveBeenCalled()
|
||||
expect(mockCreateCashEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('zeroes remaining_amount and emits invoice.paid on full payment (issue #825)', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
|
||||
@@ -59,6 +59,42 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('commitPendingOperation: match_transaction_invoice settlement account resolution', () => {
|
||||
it('rejects a credit note before creating a payment journal entry', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'tx-1',
|
||||
company_id: 'company-1',
|
||||
amount: 12500,
|
||||
currency: 'SEK',
|
||||
date: '2026-05-12',
|
||||
invoice_id: null,
|
||||
journal_entry_id: null,
|
||||
},
|
||||
error: null,
|
||||
}) // transaction fetch
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'credit-1',
|
||||
invoice_number: 'KR-F-2026001',
|
||||
status: 'sent',
|
||||
total: -12500,
|
||||
credited_invoice_id: 'inv-1',
|
||||
},
|
||||
error: null,
|
||||
}) // invoice fetch
|
||||
enqueue({ data: null, error: null }) // dispatcher pending_operations update
|
||||
|
||||
const op = makePendingOp({ params: { transaction_id: 'tx-1', invoice_id: 'credit-1' } })
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('rejected')
|
||||
expect(result.http_status).toBe(409)
|
||||
expect(mockCreatePaymentEntry).not.toHaveBeenCalled()
|
||||
expect(mockCreateCashEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('credits the payment JE to the transaction\'s own linked cash account, not a hardcoded 1930', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
|
||||
@@ -905,6 +905,9 @@ async function commitMarkInvoicePaid(
|
||||
.single()
|
||||
|
||||
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
|
||||
if (invoice.credited_invoice_id) {
|
||||
return { error: 'Kreditfakturor kan inte markeras som betalda.', status: 409 }
|
||||
}
|
||||
if (invoice.status !== 'sent' && invoice.status !== 'overdue') {
|
||||
return { error: 'Invoice can only be marked as paid when status is "sent" or "overdue"', status: 409 }
|
||||
}
|
||||
@@ -1143,6 +1146,12 @@ async function commitSendInvoice(
|
||||
.single()
|
||||
|
||||
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
|
||||
if (invoice.credited_invoice_id) {
|
||||
return {
|
||||
error: 'Credit notes must be issued through the invoice send flow',
|
||||
status: 409,
|
||||
}
|
||||
}
|
||||
// partially_paid/credited imply the invoice was already issued too: the
|
||||
// status flip below would regress them to 'sent' (PR #666 review, ASVS V2.3).
|
||||
if (['sent', 'paid', 'overdue', 'partially_paid', 'credited'].includes(invoice.status)) {
|
||||
@@ -1312,6 +1321,12 @@ async function commitMarkInvoiceSent(
|
||||
.single()
|
||||
|
||||
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
|
||||
if (invoice.credited_invoice_id) {
|
||||
return {
|
||||
error: 'Credit notes must be issued through the invoice send flow',
|
||||
status: 409,
|
||||
}
|
||||
}
|
||||
if (invoice.status !== 'draft') return { error: 'Only draft invoices can be marked as sent', status: 409 }
|
||||
|
||||
try {
|
||||
@@ -1374,6 +1389,9 @@ async function commitMatchTransactionInvoice(
|
||||
.single()
|
||||
|
||||
if (invError || !invoice) return { error: 'Invoice not found', status: 404 }
|
||||
if (invoice.credited_invoice_id) {
|
||||
return { error: 'Kreditfakturor kan inte registreras som betalda.', status: 409 }
|
||||
}
|
||||
if (!['sent', 'overdue', 'partially_paid'].includes(invoice.status)) {
|
||||
return { error: 'Invoice is not in a matchable state', status: 409 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user