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
@@ -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
+18
View File
@@ -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 }
}