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:
Jakob Wennberg
2026-04-15 15:23:08 +02:00
committed by GitHub
co-authored by Claude Opus 4.6
parent d484c341a4
commit e46654ab25
12 changed files with 285 additions and 36 deletions
@@ -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',
+35 -2
View File
@@ -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: {