Files
accounted/lib/transactions/ignore.ts
T
Jakob Wennberg dc07ca8872 feat(transactions): steer private marking in locked periods to ignore, with v1 and MCP ignore verbs (#1661) (#2031)
Decision (option a): a private marking stays a real booking (eget uttag/insattning), so it remains blocked in a locked or closed period; the legal escape for rows that are not affarshandelser is ignore. Private + locked now returns TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED with remediation naming the ignore paths instead of a bare PERIOD_LOCKED, on all four categorize surfaces and the bulk driver. New v1 POST/DELETE /transactions/{id}/ignore (isTransactionBooked-based 409, idempotent) and a staged MCP gnubok_ignore_transaction (+ accounted_ alias, search visibility to respect the tools/list payload ceiling) with operation_type ignore_transaction; the CHECK pair 20260831070000/070001 rebuilds the constraint from main's newest list plus the new value. Dashboard toast gains an Ignorera i stallet action. Closes #1661
2026-08-31 08:39:04 +01:00

128 lines
4.8 KiB
TypeScript

/**
* Shared core for "ignore a bank transaction" (issue #1661).
*
* Ignoring is the legal escape hatch for a bank row that is not an
* affärshändelse (a PSD2 ghost row, a duplicate from a reconnect, a transfer
* that never executed): it writes no verifikat, so BFL 5 kap. does not apply
* and a locked or closed period does not block it. A private marking, by
* contrast, IS a booking (eget uttag/insättning, or 2893 for an AB) and stays
* subject to the period lock.
*
* Three doors call this one function so they cannot drift:
* - the dashboard route (app/api/transactions/[id]/ignore)
* - the v1 REST verb (app/api/v1/.../transactions/[id]/ignore)
* - the MCP executor for the staged ignore_transaction operation
* (lib/pending-operations/commit.ts)
*
* "Already booked" is decided by isTransactionBooked(): a bulk-booked or
* multi-allocated row keeps transactions.journal_entry_id NULL and is anchored
* through transaction_voucher_links / invoice_payments /
* supplier_invoice_payments instead. A bare journal_entry_id check would let
* those rows be ignored while a verifikat still carries them.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { dbError } from '@/lib/errors/db-error'
import { isTransactionBooked } from '@/lib/transactions/is-booked'
export type TransactionIgnoreRefusalCode = 'TX_CATEGORIZE_TX_NOT_FOUND' | 'TX_IGNORE_ALREADY_BOOKED'
export type SetTransactionIgnoredOutcome =
| {
ok: true
transaction_id: string
is_ignored: boolean
/** false when the row was already in the requested state (idempotent no-op). */
changed: boolean
/** true when the caller asked for a dry run: nothing was written. */
dry_run: boolean
}
| {
ok: false
code: TransactionIgnoreRefusalCode
status: 404 | 409
}
interface TransactionIgnoreRow {
id: string
journal_entry_id: string | null
is_ignored: boolean | null
}
/**
* Set or clear the ignore flag on one bank transaction.
*
* - Unknown row in this company: `{ ok: false, code: 'TX_CATEGORIZE_TX_NOT_FOUND' }`.
* - `ignored = true` on a booked row (any of the three anchors):
* `{ ok: false, code: 'TX_IGNORE_ALREADY_BOOKED' }`. Restoring
* (`ignored = false`) needs no such check: the DB CHECK
* `transactions_is_ignored_no_journal_entry` already guarantees an ignored
* row is unbooked.
* - Same state as requested: `{ ok: true, changed: false }` (idempotent).
* - Database failures throw; callers map them through lib/errors.
*/
export async function setTransactionIgnored(
supabase: SupabaseClient,
companyId: string,
transactionId: string,
ignored: boolean,
options: { dryRun?: boolean } = {},
): Promise<SetTransactionIgnoredOutcome> {
const { data: tx, error: fetchError } = await supabase
.from('transactions')
.select('id, journal_entry_id, is_ignored')
.eq('id', transactionId)
.eq('company_id', companyId)
.maybeSingle<TransactionIgnoreRow>()
if (fetchError) throw dbError(fetchError, null)
if (!tx) return { ok: false, code: 'TX_CATEGORIZE_TX_NOT_FOUND', status: 404 }
if (ignored) {
let booked = tx.journal_entry_id != null
if (!booked) {
// The two junction shapes that keep journal_entry_id NULL on the row.
const [voucherLinks, invoicePayments, supplierPayments] = await Promise.all([
supabase
.from('transaction_voucher_links')
.select('transaction_id')
.eq('transaction_id', tx.id)
.limit(1),
supabase.from('invoice_payments').select('transaction_id').eq('transaction_id', tx.id).limit(1),
supabase
.from('supplier_invoice_payments')
.select('transaction_id')
.eq('transaction_id', tx.id)
.limit(1),
])
if (voucherLinks.error) throw dbError(voucherLinks.error, null)
if (invoicePayments.error) throw dbError(invoicePayments.error, null)
if (supplierPayments.error) throw dbError(supplierPayments.error, null)
booked = isTransactionBooked(
tx,
[...(invoicePayments.data ?? []), ...(supplierPayments.data ?? [])],
voucherLinks.data ?? [],
)
}
if (booked) return { ok: false, code: 'TX_IGNORE_ALREADY_BOOKED', status: 409 }
}
const alreadyInState = Boolean(tx.is_ignored) === ignored
if (alreadyInState || options.dryRun) {
return {
ok: true,
transaction_id: tx.id,
is_ignored: ignored,
changed: !alreadyInState,
dry_run: Boolean(options.dryRun),
}
}
const { error: updateError } = await supabase
.from('transactions')
.update({ is_ignored: ignored })
.eq('id', transactionId)
.eq('company_id', companyId)
if (updateError) throw dbError(updateError, null)
return { ok: true, transaction_id: tx.id, is_ignored: ignored, changed: true, dry_run: false }
}