fix(transactions): confirm and allow undo on a counterparty booking (#1292)

Booking via "Tidigare motparter" ended on a bare setExitingIds().add(id): the
row animated out of the inbox and that was the entire feedback. No "Bokförd"
toast, no Ångra, no unbooked-count decrement, and the id was never removed from
exitingIds again, so an undo would have restored the row's data while leaving it
filtered out of the list.

Extracted runCategorize's success tail into one finishBooking() rather than
copying the toast into the counterparty branch: the counterparty path was
already a second, thinner implementation of it, which is why it drifted. Both
paths and the counterparty activate-and-retry go through it now, pinned by a
parity test.

Also fixed on the way:

- handleTransactionBooked (manual booking dialog / voucher match) never
  decremented the unbooked count, so the header read one too high until the
  next refetch. It deliberately gets no Ångra: its `matched` branch links the
  transaction to a PRE-EXISTING verifikat, and /uncategorize storno-reverses
  whatever journal_entry_id the transaction points at.
- The 350ms animation timer re-applied the booked shape unconditionally, so an
  Ångra resolving inside that window left the client claiming a journal_entry_id
  the server had already storno-reversed. A completed undo now wins.
- finishBooking cleared processingId unconditionally; scoped to the finished id
  so it cannot wipe an unrelated row's spinner.

No migrations.
This commit is contained in:
Jakob Wennberg
2026-07-29 19:58:47 +02:00
committed by GitHub
parent 198d3092c7
commit 954ce873a8
3 changed files with 227 additions and 71 deletions
+127 -71
View File
@@ -918,6 +918,104 @@ export default function TransactionsPage() {
return runCategorize({ id, isBusiness, category, vatTreatment, accountOverride, templateId, inboxItemId, dimensions, confirmNoMatch: false })
}
/**
* The shared tail of a successful booking: exit animation, unbooked-count
* decrement, the outcome toast with its Ångra action, and the state patch
* that lands the row in its booked shape.
*
* Both booking paths run this. The counterparty-template path used to do
* only `setExitingIds().add(id)`, so a successful booking gave no
* confirmation, no undo and no count decrement, and the id was never removed
* from exitingIds again: an undo would have restored the row's data while
* leaving it filtered out of the inbox.
*/
function finishBooking(args: {
id: string
isBusiness: boolean
category?: TransactionCategory
journalEntryId?: string | null
journalEntryCreated?: boolean
journalEntryError?: string | null
}) {
const { id, isBusiness, category, journalEntryId, journalEntryCreated, journalEntryError } = args
// A completed Ångra must win over the delayed patch below. The undo has
// already storno-reversed the verifikat server-side, so re-applying the
// booked shape afterwards would show a journal_entry_id that no longer
// represents a live entry.
let undone = false
setExitingIds((prev) => new Set(prev).add(id))
setTotalUncategorizedCount((prev) => Math.max(0, (prev ?? 1) - 1))
if (journalEntryCreated) {
toast({
title: 'Bokförd',
action: (
<ToastAction altText="Ångra kategorisering" onClick={async () => {
try {
const undoRes = await fetch(`/api/transactions/${id}/uncategorize`, { method: 'POST' })
if (undoRes.ok) {
undone = true
setTransactions((prev) =>
prev.map((t) =>
t.id === id
? { ...t, is_business: null, category: null as unknown as TransactionCategory, journal_entry_id: null }
: t
)
)
setTotalUncategorizedCount((prev) => (prev ?? 0) + 1)
toast({ title: t('undone_title'), description: t('undone_description') })
} else {
const errData = await undoRes.json()
toast({
title: 'Kunde inte ångra',
description: getErrorMessage(errData, { context: 'transaction', statusCode: undoRes.status }),
variant: 'destructive',
})
}
} catch {
toast({ title: t('undo_failed_title'), description: t('undo_failed_description'), variant: 'destructive' })
}
}}>
Ångra
</ToastAction>
),
})
} else if (journalEntryError) {
toast({ title: 'Delvis bokförd', description: `Verifikation kunde inte skapas: ${journalEntryError}`, variant: 'destructive' })
} else {
toast({ title: t('partially_booked_title'), description: t('partially_booked_description') })
}
// Update transaction in state after a brief delay for animation. Clearing
// the id from exitingIds is what makes an Ångra later put the row back in
// the inbox instead of leaving it invisible.
setTimeout(() => {
if (!undone) {
setTransactions((prev) =>
prev.map((tx) =>
tx.id === id
? {
...tx,
is_business: isBusiness,
...(category ? { category } : {}),
...(journalEntryId ? { journal_entry_id: journalEntryId } : {}),
}
: tx
)
)
}
setExitingIds((prev) => {
const next = new Set(prev)
next.delete(id)
return next
})
// Only this row's spinner: the shared helper must not clear another
// transaction's in-flight state.
setProcessingId((prev) => (prev === id ? null : prev))
}, 350)
}
async function runCategorize(args: {
id: string
isBusiness: boolean
@@ -1151,66 +1249,14 @@ export default function TransactionsPage() {
return null
}
// Mark as exiting for animation, then update state
setExitingIds((prev) => new Set(prev).add(id))
setTotalUncategorizedCount((prev) => Math.max(0, (prev ?? 1) - 1))
if (result.journal_entry_created) {
toast({
title: 'Bokförd',
action: (
<ToastAction altText="Ångra kategorisering" onClick={async () => {
try {
const undoRes = await fetch(`/api/transactions/${id}/uncategorize`, { method: 'POST' })
if (undoRes.ok) {
setTransactions((prev) =>
prev.map((t) =>
t.id === id
? { ...t, is_business: null, category: null as unknown as TransactionCategory, journal_entry_id: null }
: t
)
)
setTotalUncategorizedCount((prev) => (prev ?? 0) + 1)
toast({ title: t('undone_title'), description: t('undone_description') })
} else {
const errData = await undoRes.json()
toast({
title: 'Kunde inte ångra',
description: getErrorMessage(errData, { context: 'transaction', statusCode: undoRes.status }),
variant: 'destructive',
})
}
} catch {
toast({ title: t('undo_failed_title'), description: t('undo_failed_description'), variant: 'destructive' })
}
}}>
Ångra
</ToastAction>
),
})
} else if (result.journal_entry_error) {
toast({ title: 'Delvis bokförd', description: `Verifikation kunde inte skapas: ${result.journal_entry_error}`, variant: 'destructive' })
} else {
toast({ title: t('partially_booked_title'), description: t('partially_booked_description') })
}
// Update transaction in state after a brief delay for animation
setExitingIds((prev) => new Set(prev).add(id))
setTimeout(() => {
setTransactions((prev) =>
prev.map((t) =>
t.id === id
? { ...t, is_business: isBusiness, category: result.category, journal_entry_id: result.journal_entry_id }
: t
)
)
setExitingIds((prev) => {
const next = new Set(prev)
next.delete(id)
return next
})
setProcessingId(null)
}, 350)
finishBooking({
id,
isBusiness,
category: result.category,
journalEntryId: result.journal_entry_id,
journalEntryCreated: result.journal_entry_created,
journalEntryError: result.journal_entry_error,
})
return result.journal_entry_id || null
} catch {
@@ -1901,6 +1947,10 @@ export default function TransactionsPage() {
matched?: boolean,
) {
setExitingIds((prev) => new Set(prev).add(transactionId))
// The row leaves the unbooked inbox either way (booked, or linked to an
// existing voucher), so the header count has to follow: every other path
// that removes a row decrements, this one did not.
setTotalUncategorizedCount((prev) => Math.max(0, (prev ?? 1) - 1))
setTimeout(() => {
setTransactions((prev) =>
prev.map((t) =>
@@ -2246,7 +2296,7 @@ export default function TransactionsPage() {
let journalEntryId: string | null
if (!templateId && quickReview?.template?.id && isCounterpartyTemplateId(quickReview.template.id)) {
const cpTemplateId = extractCounterpartyId(quickReview.template.id)
const cpCategorize = async (): Promise<{ ok: boolean; journalEntryId: string | null; result: { error?: { code?: string; account_numbers?: string[]; details?: { account_numbers?: string[] } }; journal_entry_id?: string | null }; status: number }> => {
const cpCategorize = async (): Promise<{ ok: boolean; journalEntryId: string | null; result: { error?: { code?: string; account_numbers?: string[]; details?: { account_numbers?: string[] } }; journal_entry_id?: string | null; journal_entry_created?: boolean; journal_entry_error?: string | null; category?: TransactionCategory }; status: number }> => {
const r = await fetch(`/api/transactions/${id}/categorize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -2303,15 +2353,14 @@ export default function TransactionsPage() {
// update conditionally writes the journal_entry_id when it's
// actually present.
if (retry.ok) {
setExitingIds((prev) => new Set(prev).add(id))
setTransactions((prev) =>
prev.map((t) =>
t.id === id
? { ...t, is_business: true, ...(retry.journalEntryId ? { journal_entry_id: retry.journalEntryId } : {}) }
: t
)
)
toast({ title: 'Bokförd' })
finishBooking({
id,
isBusiness: true,
category: retry.result?.category,
journalEntryId: retry.journalEntryId,
journalEntryCreated: retry.result?.journal_entry_created,
journalEntryError: retry.result?.journal_entry_error,
})
} else {
toast({ title: 'Kategorisering misslyckades', description: getErrorMessage(retry.result, { context: 'transaction', statusCode: retry.status }), variant: 'destructive' })
}
@@ -2332,7 +2381,14 @@ export default function TransactionsPage() {
setQuickReview(null)
return null
}
setExitingIds((prev) => new Set(prev).add(id))
finishBooking({
id,
isBusiness: true,
category: result?.category,
journalEntryId: cpJeId,
journalEntryCreated: result?.journal_entry_created,
journalEntryError: result?.journal_entry_error,
})
journalEntryId = cpJeId
} else {
journalEntryId = await handleCategorize(id, true, category, vatTreatment, accountOverride, templateId, undefined, dimensions)