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
@@ -174,6 +174,15 @@ export async function createDraftEntry(
|
||||
// Resolve account IDs
|
||||
const accountIdMap = await resolveAccountIds(supabase, companyId, input.lines)
|
||||
|
||||
// Validate all account numbers resolved to IDs
|
||||
const allAccountNumbers = [...new Set(input.lines.map(l => l.account_number))]
|
||||
const missingAccounts = allAccountNumbers.filter(num => !accountIdMap.has(num))
|
||||
if (missingAccounts.length > 0) {
|
||||
throw new Error(
|
||||
`Account(s) not found in chart of accounts: ${missingAccounts.join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
// Insert journal entry header as draft (voucher_number = 0, will be assigned on commit)
|
||||
const { data: entry, error: entryError } = await supabase
|
||||
.from('journal_entries')
|
||||
@@ -350,6 +359,15 @@ export async function reverseEntry(
|
||||
// Resolve account IDs
|
||||
const accountIdMap = await resolveAccountIds(supabase, companyId, reversedLines)
|
||||
|
||||
// Validate all account numbers resolved to IDs
|
||||
const reversalAccountNumbers = [...new Set(reversedLines.map(l => l.account_number))]
|
||||
const missingReversalAccounts = reversalAccountNumbers.filter(num => !accountIdMap.has(num))
|
||||
if (missingReversalAccounts.length > 0) {
|
||||
throw new Error(
|
||||
`Account(s) not found in chart of accounts: ${missingReversalAccounts.join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
// Create reversal entry with reverses_id link
|
||||
const { data: reversalEntry, error: reversalError } = await supabase
|
||||
.from('journal_entries')
|
||||
@@ -417,6 +435,94 @@ export async function reverseEntry(
|
||||
throw new Error('Entry was already reversed by a concurrent operation')
|
||||
}
|
||||
|
||||
// If this was a payment entry, sync the linked invoice/supplier-invoice status
|
||||
const paymentSourceTypes = [
|
||||
'invoice_paid', 'invoice_cash_payment',
|
||||
'supplier_invoice_paid', 'supplier_invoice_cash_payment',
|
||||
]
|
||||
|
||||
if (paymentSourceTypes.includes(original.source_type) && original.source_id) {
|
||||
// The GL reversal is already handled above (line-by-line mirror of the original
|
||||
// verifikation per BFL 5 kap 5§). Here we sync the business-level invoice state.
|
||||
// Payment amounts come from the payments table, not from GL line inspection —
|
||||
// this works identically for kontantmetod and faktureringsmetod.
|
||||
const entryId = original.id
|
||||
|
||||
if (original.source_type.startsWith('supplier_invoice')) {
|
||||
const { data: payment } = await supabase
|
||||
.from('supplier_invoice_payments')
|
||||
.select('amount')
|
||||
.eq('journal_entry_id', entryId)
|
||||
.single()
|
||||
|
||||
const { data: supplierInvoice } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('paid_amount, total_amount, due_date')
|
||||
.eq('id', original.source_id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (supplierInvoice && payment) {
|
||||
const newPaidAmount = Math.round((supplierInvoice.paid_amount - payment.amount) * 100) / 100
|
||||
const newRemaining = Math.round((supplierInvoice.total_amount - Math.max(0, newPaidAmount)) * 100) / 100
|
||||
let newStatus: string
|
||||
if (newPaidAmount > 0) {
|
||||
newStatus = 'partially_paid'
|
||||
} else if (supplierInvoice.due_date && new Date(supplierInvoice.due_date) < new Date()) {
|
||||
newStatus = 'overdue'
|
||||
} else {
|
||||
newStatus = 'approved'
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from('supplier_invoices')
|
||||
.update({
|
||||
status: newStatus,
|
||||
paid_amount: Math.max(0, newPaidAmount),
|
||||
remaining_amount: newRemaining,
|
||||
paid_at: null,
|
||||
payment_journal_entry_id: null,
|
||||
})
|
||||
.eq('id', original.source_id)
|
||||
.eq('company_id', companyId)
|
||||
}
|
||||
} else {
|
||||
const { data: payment } = await supabase
|
||||
.from('invoice_payments')
|
||||
.select('amount')
|
||||
.eq('journal_entry_id', entryId)
|
||||
.single()
|
||||
|
||||
const { data: customerInvoice } = await supabase
|
||||
.from('invoices')
|
||||
.select('paid_amount, due_date')
|
||||
.eq('id', original.source_id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (customerInvoice) {
|
||||
const paymentAmount = payment?.amount ?? customerInvoice.paid_amount
|
||||
const newPaidAmount = Math.round((customerInvoice.paid_amount - paymentAmount) * 100) / 100
|
||||
const revertStatus = newPaidAmount > 0
|
||||
? 'partially_paid'
|
||||
: customerInvoice.due_date && new Date(customerInvoice.due_date) < new Date()
|
||||
? 'overdue'
|
||||
: 'sent'
|
||||
|
||||
await supabase
|
||||
.from('invoices')
|
||||
.update({
|
||||
status: revertStatus,
|
||||
paid_at: null,
|
||||
paid_amount: Math.max(0, newPaidAmount),
|
||||
})
|
||||
.eq('id', original.source_id)
|
||||
.eq('company_id', companyId)
|
||||
.in('status', ['paid', 'partially_paid'])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch complete reversal entry with lines
|
||||
const { data: completeEntry } = await supabase
|
||||
.from('journal_entries')
|
||||
@@ -431,5 +537,10 @@ export async function reverseEntry(
|
||||
payload: { entry: result, userId, companyId },
|
||||
})
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'journal_entry.reversed',
|
||||
payload: { originalEntry: original as JournalEntry, reversalEntry: result, userId, companyId },
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ describe('correctEntry', () => {
|
||||
{ data: reversalEntry, error: null }, // 1: insert reversal
|
||||
{ data: null, error: null }, // 2: insert reversal lines
|
||||
{ data: null, error: null }, // 3: post reversal
|
||||
{ data: [], error: null }, // 4: accounts
|
||||
{ data: [{ id: 'acc-5420', account_number: '5420' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 4: accounts
|
||||
{ data: null, error: { message: 'DB error' } }, // 5: insert corrected FAILS
|
||||
{ data: null, error: null }, // 6: cancelEntry reversal update
|
||||
{ data: null, error: null }, // 7: cancelEntry reversal lines delete
|
||||
|
||||
@@ -174,6 +174,14 @@ export async function correctEntry(
|
||||
accountIdMap.set(account.account_number, account.id)
|
||||
}
|
||||
|
||||
// Validate all account numbers resolved to IDs
|
||||
const missingAccounts = accountNumbers.filter(num => !accountIdMap.has(num))
|
||||
if (missingAccounts.length > 0) {
|
||||
throw new Error(
|
||||
`Account(s) not found in chart of accounts: ${missingAccounts.join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
const { data: newEntry, error: correctedError } = await supabase
|
||||
.from('journal_entries')
|
||||
.insert({
|
||||
|
||||
@@ -11,6 +11,7 @@ const log = createLogger('event-log')
|
||||
*/
|
||||
const PERSISTED_EVENT_TYPES: CoreEventType[] = [
|
||||
'journal_entry.committed',
|
||||
'journal_entry.reversed',
|
||||
'journal_entry.corrected',
|
||||
'document.uploaded',
|
||||
'document.accessed',
|
||||
@@ -67,6 +68,15 @@ function extractEntityId(payload: Record<string, unknown>): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
// For journal_entry.reversed: use the reversal entry's ID
|
||||
if ('reversalEntry' in payload) {
|
||||
const reversalEntry = payload.reversalEntry
|
||||
if (reversalEntry && typeof reversalEntry === 'object' && 'id' in reversalEntry) {
|
||||
const id = (reversalEntry as Record<string, unknown>).id
|
||||
if (typeof id === 'string') return id
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export type CoreEvent =
|
||||
| { type: 'journal_entry.drafted'; payload: { entry: JournalEntry; userId: string; companyId: string } }
|
||||
| { type: 'journal_entry.committed'; payload: { entry: JournalEntry; userId: string; companyId: string } }
|
||||
| { type: 'journal_entry.corrected'; payload: { original: JournalEntry; storno: JournalEntry; corrected: JournalEntry; userId: string; companyId: string } }
|
||||
| { type: 'journal_entry.reversed'; payload: { originalEntry: JournalEntry; reversalEntry: JournalEntry; userId: string; companyId: string } }
|
||||
| { type: 'journal_entry.deleted'; payload: { entryId: string; voucherSeries: string; voucherNumber: number; userId: string; companyId: string } }
|
||||
// Documents
|
||||
| { type: 'document.uploaded'; payload: { document: DocumentAttachment; userId: string; companyId: string } }
|
||||
|
||||
Reference in New Issue
Block a user