feat: concurrency guards, account validation, and reversal side-effects (#247)
* feat: add concurrency guards, account validation, and reversal side-effects to bookkeeping engine Prevent double-booking via CAS guards on mark-paid and categorize routes (409 on conflict), make payment GL entries blocking (AP/AR must match GL), validate account resolution in engine, and auto-sync invoice status on payment reversal. Adds journal_entry.reversed event type. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address Greptile review — company_id filter, voucher gaps, status restore - Add missing company_id filter on supplier-invoice CAS update (defense in depth) - Add voucher_gap_explanations insert on CAS-cancelled entries in both mark-paid routes (BFNAR 2013:2 compliance, matching categorize route pattern) - Fix reversal status restore: check due_date to determine overdue vs sent/approved instead of always reverting to sent/approved - Rename shadowed reversedLines variable to originalLines (P2 clarity) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: derive reversal payment amount from payments table, not GL lines The reversal GL entry is already a line-by-line mirror per BFL 5 kap 5§. For the business-level invoice sync, use the payment record amount from supplier_invoice_payments / invoice_payments instead of inspecting GL account numbers — works identically for kontantmetod and faktureringsmetod without needing to know which accounts were used. Also adds company_id filter on all reversal sync queries (defense in depth). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: allow reversal of partially_paid customer invoices Widen the status filter from .eq('status', 'paid') to .in('status', ['paid', 'partially_paid']) so that reversing a partial payment GL entry correctly updates the invoice state. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
d484c341a4
commit
e46654ab25
@@ -125,8 +125,8 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
enqueue({ data: invoice, error: null })
|
||||
// Fetch company settings (now before update due to journal-first ordering)
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
// Update invoice status
|
||||
enqueue({ data: null, error: null })
|
||||
// Update invoice status (CAS guard: returns matched row)
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
|
||||
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
|
||||
@@ -166,7 +166,8 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
// Update invoice status (CAS guard: returns matched row)
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
|
||||
mockCreateInvoiceCashEntry.mockResolvedValue({ id: 'je-2' })
|
||||
|
||||
@@ -213,8 +214,8 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
enqueue({ data: invoice, error: null })
|
||||
// Fetch company settings (before update — journal-first ordering)
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
// Update invoice status
|
||||
enqueue({ data: null, error: null })
|
||||
// Update invoice status (CAS guard: returns matched row)
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
|
||||
mockFindFiscalPeriod.mockResolvedValue('fp-1')
|
||||
mockCreateJournalEntry.mockResolvedValue({ id: 'je-custom' })
|
||||
@@ -314,7 +315,8 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
// Update invoice status (CAS guard: returns matched row)
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
|
||||
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-auto' })
|
||||
|
||||
|
||||
@@ -168,8 +168,8 @@ export async function POST(
|
||||
}
|
||||
}
|
||||
|
||||
// Update status to paid — only after journal entry succeeds
|
||||
const { error: updateError } = await supabase
|
||||
// Update status to paid (CAS guard: only if still in payable status)
|
||||
const { data: updateResult, error: updateError } = await supabase
|
||||
.from('invoices')
|
||||
.update({
|
||||
status: 'paid',
|
||||
@@ -178,11 +178,44 @@ export async function POST(
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.in('status', ['sent', 'overdue'])
|
||||
.select('id')
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: 'Kunde inte uppdatera status' }, { status: 500 })
|
||||
}
|
||||
|
||||
// CAS guard: status changed between our read and write
|
||||
if (!updateResult || updateResult.length === 0) {
|
||||
if (journalEntryId) {
|
||||
const { data: orphan } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('fiscal_period_id, voucher_series, voucher_number')
|
||||
.eq('id', journalEntryId)
|
||||
.single()
|
||||
|
||||
await supabase
|
||||
.from('journal_entries')
|
||||
.update({ status: 'cancelled' })
|
||||
.eq('id', journalEntryId)
|
||||
|
||||
if (orphan) {
|
||||
await supabase.from('voucher_gap_explanations').insert({
|
||||
company_id: companyId,
|
||||
fiscal_period_id: orphan.fiscal_period_id,
|
||||
voucher_series: orphan.voucher_series || 'A',
|
||||
gap_number: orphan.voucher_number,
|
||||
explanation: 'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd',
|
||||
created_by: user.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'Fakturan har redan betalats av en annan förfrågan' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
status: 'paid',
|
||||
|
||||
@@ -116,8 +116,8 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => {
|
||||
|
||||
mockCreateSupplierInvoicePaymentEntry.mockResolvedValue({ id: 'je-1' })
|
||||
|
||||
// Update invoice
|
||||
enqueue({ data: null, error: null })
|
||||
// Update invoice (CAS guard: returns matched row)
|
||||
enqueue({ data: [{ id: 'si-1' }], error: null })
|
||||
// Record payment
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
@@ -160,7 +160,8 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => {
|
||||
|
||||
mockCreateSupplierInvoicePaymentEntry.mockResolvedValue({ id: 'je-2' })
|
||||
|
||||
enqueue({ data: null, error: null })
|
||||
// Update invoice (CAS guard: returns matched row)
|
||||
enqueue({ data: [{ id: 'si-1' }], error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', {
|
||||
@@ -215,7 +216,8 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => {
|
||||
|
||||
mockCreateSupplierInvoiceCashEntry.mockResolvedValue({ id: 'je-3' })
|
||||
|
||||
enqueue({ data: null, error: null })
|
||||
// Update invoice (CAS guard: returns matched row)
|
||||
enqueue({ data: [{ id: 'si-1' }], error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', {
|
||||
@@ -234,7 +236,7 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => {
|
||||
expect(mockCreateSupplierInvoicePaymentEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns success when journal entry creation fails (non-blocking)', async () => {
|
||||
it('returns 500 when journal entry creation fails (blocking — GL must succeed for payment)', async () => {
|
||||
const supplier = makeSupplier()
|
||||
const invoice = makeSupplierInvoice({
|
||||
id: 'si-1',
|
||||
@@ -251,22 +253,15 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => {
|
||||
|
||||
mockCreateSupplierInvoicePaymentEntry.mockRejectedValue(new Error('Period locked'))
|
||||
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'si-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
journal_entry_id: null
|
||||
}>(response)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.journal_entry_id).toBeNull()
|
||||
expect(status).toBe(500)
|
||||
expect(body.error).toBe('Kunde inte bokföra betalningen')
|
||||
})
|
||||
|
||||
it('emits supplier_invoice.paid event', async () => {
|
||||
@@ -284,7 +279,8 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => {
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual' }, error: null })
|
||||
mockCreateSupplierInvoicePaymentEntry.mockResolvedValue({ id: 'je-1' })
|
||||
enqueue({ data: null, error: null })
|
||||
// Update invoice (CAS guard: returns matched row)
|
||||
enqueue({ data: [{ id: 'si-1' }], error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const emitSpy = vi.spyOn(eventBus, 'emit')
|
||||
|
||||
@@ -99,6 +99,10 @@ export async function POST(
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create payment journal entry:', err)
|
||||
return NextResponse.json(
|
||||
{ error: 'Kunde inte bokföra betalningen' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Calculate new remaining amount
|
||||
@@ -107,8 +111,8 @@ export async function POST(
|
||||
const isFullyPaid = newRemaining <= 0
|
||||
const newStatus = isFullyPaid ? 'paid' : 'partially_paid'
|
||||
|
||||
// Update invoice
|
||||
const { error: updateError } = await supabase
|
||||
// Update invoice (CAS guard: only if status hasn't changed since we read it)
|
||||
const { data: updateResult, error: updateError } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.update({
|
||||
status: newStatus,
|
||||
@@ -118,11 +122,45 @@ export async function POST(
|
||||
payment_journal_entry_id: journalEntryId,
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.in('status', ['registered', 'approved', 'partially_paid', 'overdue'])
|
||||
.select('id')
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: updateError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// CAS guard: status changed between our read and write
|
||||
if (!updateResult || updateResult.length === 0) {
|
||||
if (journalEntryId) {
|
||||
const { data: orphan } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('fiscal_period_id, voucher_series, voucher_number')
|
||||
.eq('id', journalEntryId)
|
||||
.single()
|
||||
|
||||
await supabase
|
||||
.from('journal_entries')
|
||||
.update({ status: 'cancelled' })
|
||||
.eq('id', journalEntryId)
|
||||
|
||||
if (orphan) {
|
||||
await supabase.from('voucher_gap_explanations').insert({
|
||||
company_id: companyId,
|
||||
fiscal_period_id: orphan.fiscal_period_id,
|
||||
voucher_series: orphan.voucher_series || 'A',
|
||||
gap_number: orphan.voucher_number,
|
||||
explanation: 'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd',
|
||||
created_by: user.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'Fakturan har redan betalats av en annan förfrågan' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// Record payment
|
||||
const { error: paymentError } = await supabase
|
||||
.from('supplier_invoice_payments')
|
||||
|
||||
@@ -146,8 +146,8 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
mockSaveUserMappingRule.mockResolvedValue(undefined)
|
||||
|
||||
// Update transaction
|
||||
enqueue({ data: null, error: null })
|
||||
// Update transaction (CAS guard: returns matched row)
|
||||
enqueue({ data: [{ id: 'tx-1' }], error: null })
|
||||
|
||||
const emitSpy = vi.spyOn(eventBus, 'emit')
|
||||
|
||||
@@ -284,8 +284,8 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
|
||||
mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
|
||||
// Update transaction
|
||||
enqueue({ data: null, error: null })
|
||||
// Update transaction (CAS guard: returns matched row)
|
||||
enqueue({ data: [{ id: 'tx-1' }], error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/categorize', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -380,8 +380,8 @@ export async function POST(
|
||||
}
|
||||
}
|
||||
|
||||
// Update the transaction
|
||||
const { error: updateError } = await supabase
|
||||
// Update the transaction (CAS guard: only set journal_entry_id if still null)
|
||||
const { data: updateResult, error: updateError } = await supabase
|
||||
.from('transactions')
|
||||
.update({
|
||||
is_business,
|
||||
@@ -389,6 +389,8 @@ export async function POST(
|
||||
journal_entry_id: journalEntryId,
|
||||
})
|
||||
.eq('id', id)
|
||||
.is('journal_entry_id', null)
|
||||
.select('id')
|
||||
|
||||
if (updateError) {
|
||||
console.error('Failed to update transaction:', updateError)
|
||||
@@ -398,6 +400,37 @@ export async function POST(
|
||||
)
|
||||
}
|
||||
|
||||
// CAS guard: another request already set journal_entry_id
|
||||
if ((!updateResult || updateResult.length === 0) && journalEntryId) {
|
||||
// Cancel the orphaned journal entry and document the voucher gap
|
||||
const { data: orphan } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('fiscal_period_id, voucher_series, voucher_number')
|
||||
.eq('id', journalEntryId)
|
||||
.single()
|
||||
|
||||
await supabase
|
||||
.from('journal_entries')
|
||||
.update({ status: 'cancelled' })
|
||||
.eq('id', journalEntryId)
|
||||
|
||||
if (orphan) {
|
||||
await supabase.from('voucher_gap_explanations').insert({
|
||||
company_id: companyId,
|
||||
fiscal_period_id: orphan.fiscal_period_id,
|
||||
voucher_series: orphan.voucher_series || 'A',
|
||||
gap_number: orphan.voucher_number,
|
||||
explanation: 'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd',
|
||||
created_by: user.id,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Transaction was already categorized by another request' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'transaction.categorized',
|
||||
payload: {
|
||||
|
||||
Reference in New Issue
Block a user