204 lines
7.1 KiB
TypeScript
204 lines
7.1 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { createLogger } from '@/lib/logger'
|
|
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
|
import { getUnusedVoucherAllocation } from '@/lib/bookkeeping/errors'
|
|
|
|
const log = createLogger('cancel-orphaned-entry')
|
|
|
|
/**
|
|
* Document a single voucher number as an explained break in the
|
|
* verifikationsnummerserie (BFNAR 2013:2: the series must be unbroken, and any
|
|
* break needs a documented reason; an undocumented one blocks year-end
|
|
* closing in `checkYearEndReadiness`).
|
|
*
|
|
* A stranded voucher occupies exactly one number, so the gap is the closed
|
|
* range [voucherNumber, voucherNumber]. Every reader keys explanations on
|
|
* `voucher_series:gap_start:gap_end` (the /bookkeeping voucher-gaps view, the
|
|
* `gnubok_list_voucher_gaps` MCP tool, the year-end readiness check), so a
|
|
* single-number gap MUST be written with gap_start === gap_end or it is
|
|
* invisible to all of them.
|
|
*
|
|
* Columns are exactly the table's: company_id, user_id, fiscal_period_id,
|
|
* voucher_series, gap_start, gap_end, explanation. All are NOT NULL.
|
|
*
|
|
* Never throws. Every caller is already on a decided error path (the CAS
|
|
* conflict response is correct for the client and must not be replaced by a
|
|
* 500), so a failure is logged at error level with the full payload an
|
|
* operator needs to file the row by hand. It is logged, never swallowed.
|
|
*
|
|
* @returns true when the gap is documented (or already was), false otherwise.
|
|
*/
|
|
export async function recordVoucherGapExplanation(
|
|
supabase: SupabaseClient,
|
|
params: {
|
|
companyId: string
|
|
userId: string
|
|
fiscalPeriodId: string
|
|
voucherSeries: string
|
|
voucherNumber: number
|
|
explanation: string
|
|
},
|
|
): Promise<boolean> {
|
|
const payload = {
|
|
company_id: params.companyId,
|
|
user_id: params.userId,
|
|
fiscal_period_id: params.fiscalPeriodId,
|
|
voucher_series: params.voucherSeries,
|
|
gap_start: params.voucherNumber,
|
|
gap_end: params.voucherNumber,
|
|
explanation: params.explanation,
|
|
}
|
|
|
|
try {
|
|
const { error } = await supabase.from('voucher_gap_explanations').insert(payload)
|
|
|
|
if (error) {
|
|
// 23505 = the exact gap is already documented (unique on
|
|
// company_id, fiscal_period_id, voucher_series, gap_start, gap_end).
|
|
// A retry hitting an existing row is the desired end state, and the
|
|
// stored explanation (possibly a human's) wins.
|
|
if ((error as { code?: string }).code === '23505') return true
|
|
|
|
log.error('failed to record voucher gap explanation (gap stays undocumented)', error, payload)
|
|
return false
|
|
}
|
|
return true
|
|
} catch (err) {
|
|
log.error(
|
|
'unexpected failure recording voucher gap explanation (gap stays undocumented)',
|
|
err as Error,
|
|
payload,
|
|
)
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Storno a posted journal entry that could not be linked to its transaction.
|
|
*
|
|
* The bookkeeping engine posts entries before the transaction CAS runs. When
|
|
* that CAS definitively fails, the entry is immutable and must be reversed,
|
|
* never edited or cancelled in place. Compensation is best-effort so the
|
|
* caller can preserve the original conflict response.
|
|
*/
|
|
export async function reverseOrphanedJournalEntry(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
userId: string,
|
|
journalEntryId: string,
|
|
gapExplanation: string,
|
|
): Promise<void> {
|
|
let unusedVoucher: ReturnType<typeof getUnusedVoucherAllocation> = null
|
|
try {
|
|
await reverseEntry(supabase, companyId, userId, journalEntryId)
|
|
return
|
|
} catch (reverseError) {
|
|
unusedVoucher = getUnusedVoucherAllocation(reverseError)
|
|
log.error('failed to storno orphaned journal entry', reverseError as Error, {
|
|
companyId,
|
|
journalEntryId,
|
|
unusedVoucher,
|
|
})
|
|
}
|
|
|
|
// The original posted voucher is live accounting evidence, never a gap.
|
|
// Only the engine can identify an exact reversal number that its durable
|
|
// sequence allocated before a reversal row existed.
|
|
if (!unusedVoucher) return
|
|
|
|
await recordVoucherGapExplanation(supabase, {
|
|
companyId,
|
|
userId,
|
|
fiscalPeriodId: unusedVoucher.fiscalPeriodId,
|
|
voucherSeries: unusedVoucher.voucherSeries,
|
|
voucherNumber: unusedVoucher.voucherNumber,
|
|
explanation: gapExplanation,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Compensation for the payment-flow CAS guard: a payment voucher was posted,
|
|
* but the invoice row was settled by a concurrent request between our read
|
|
* and write, so the voucher belongs to no payment. Cancel it and document
|
|
* the voucher-number gap (BFNAR 2013:2 requires gaps to be explained).
|
|
*
|
|
* Mirrors the inline compensation the mark-paid route has always had; the
|
|
* match routes previously returned MATCH_SI_NOT_OPEN and left the voucher
|
|
* orphaned in the ledger.
|
|
*
|
|
* Best-effort by design: the CAS conflict response is already correct for
|
|
* the caller, so failures here are logged loudly rather than thrown.
|
|
*/
|
|
export async function cancelOrphanedPaymentEntry(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
userId: string,
|
|
journalEntryId: string,
|
|
explanation: string,
|
|
): Promise<void> {
|
|
try {
|
|
const { data: orphan, error: fetchError } = await supabase
|
|
.from('journal_entries')
|
|
.select('fiscal_period_id, voucher_series, voucher_number')
|
|
.eq('id', journalEntryId)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
if (fetchError) {
|
|
log.error('failed to load orphaned payment voucher for cancellation', fetchError, {
|
|
companyId,
|
|
journalEntryId,
|
|
})
|
|
}
|
|
|
|
// Recovery breadcrumb BEFORE mutating: the cancel and the gap insert are
|
|
// separate statements, so a crash between them would leave a cancelled
|
|
// voucher with no gap explanation (BFNAR 2013:2 requires one). This line
|
|
// carries everything an operator needs to write it manually.
|
|
if (orphan) {
|
|
log.info('cancelling orphaned payment voucher', {
|
|
companyId,
|
|
journalEntryId,
|
|
voucherSeries: orphan.voucher_series || 'A',
|
|
voucherNumber: orphan.voucher_number,
|
|
fiscalPeriodId: orphan.fiscal_period_id,
|
|
explanation,
|
|
})
|
|
}
|
|
|
|
const { error: cancelError } = await supabase
|
|
.from('journal_entries')
|
|
.update({ status: 'cancelled' })
|
|
.eq('id', journalEntryId)
|
|
.eq('company_id', companyId)
|
|
|
|
if (cancelError) {
|
|
log.error('failed to cancel orphaned payment voucher (manual cleanup needed)', cancelError, {
|
|
companyId,
|
|
journalEntryId,
|
|
})
|
|
return
|
|
}
|
|
|
|
if (orphan) {
|
|
await recordVoucherGapExplanation(supabase, {
|
|
companyId,
|
|
userId,
|
|
fiscalPeriodId: orphan.fiscal_period_id,
|
|
voucherSeries: orphan.voucher_series || 'A',
|
|
voucherNumber: orphan.voucher_number,
|
|
explanation,
|
|
})
|
|
}
|
|
} catch (err) {
|
|
// Hard never-throw guarantee: the caller is about to return the correct
|
|
// CAS-conflict response, and an unexpected rejection here (network blip,
|
|
// driver error) must not replace it with a 500. The orphan stays posted
|
|
// and visible; the breadcrumb above covers manual recovery.
|
|
log.error('unexpected failure while cancelling orphaned payment voucher', err as Error, {
|
|
companyId,
|
|
journalEntryId,
|
|
})
|
|
}
|
|
}
|