fix(invoices): roll back the header row when a recurring-schedule item replace fails (#1312)
* fix(invoices): roll back the header row when a recurring-schedule item replace fails
PATCH /api/invoices/recurring/[id] and the update_recurring_schedule commit
executor wrote the schedule header first, then replaced the items. An item
insert failure restored the items snapshot but left the header update
committed, so a combined edit half-applied: a new day_of_month or
default_dimensions stayed while the line edit was undone.
Both write paths now go through one shared helper,
lib/invoices/apply-recurring-schedule-update.ts, which snapshots the header
before writing it (only for a combined edit, the only case with something to
undo) and compensates it on any items failure. The rollback update is filtered
on the updated_at stamp our own write produced, so a concurrent writer (the
hourly cron, a second edit) wins instead of being clobbered from a stale
snapshot: audit finding C2 in lib/invoices/voucher-matching.ts.
A compensation that itself fails is no longer swallowed. The helper reports
itemsRestored / headerRestored, logs the unrecoverable rows and the intended
restore payload, and both call sites then return the new
INVOICE_RECURRING_UPDATE_PARTIAL registry entry, which tells the user in
Swedish that the schedule may be half-saved and to check fields and items
before retrying. A clean rollback keeps the PG-mapped error so a CHECK
violation still surfaces its specific message.
Also in the rewritten block:
- the items DELETE error is checked, so a failed delete no longer proceeds to
an insert that would duplicate every line;
- the 404 existence check moved above every write, so a PATCH with items for a
missing or cross-tenant id writes nothing;
- the items snapshot uses select('*') with id/created_at stripped on restore
(same idiom as replaceInvoiceItems), so a column added later is carried
through instead of silently dropped;
- NewRecurringScheduleDialog unwraps the nested { error: { message } } envelope
the route returns, which otherwise reached the toast as "[object Object]".
The cron's no-empty-items invariant holds on every failure path: the items are
either untouched, restored, or the failure is reported explicitly.
Fixes #1275
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(invoices): never write when the compensating snapshot is unavailable
Follow-up on the recurring-schedule rollback: the helper still performed two
writes it already knew it could not compensate.
- The header snapshot read now checks its error and a missing row, and the
header UPDATE is skipped entirely when either holds, so no header change is
committed that we already know can never be rolled back.
- An unreadable item snapshot now aborts BEFORE the delete (rolling the header
back) instead of deleting first and reporting itemsRestored: false, so the
cron invariant "a schedule always has items" holds on every failure path.
- That header read now runs whenever items are replaced and is scoped by
company_id, so it doubles as the ownership proof the schedule_id-only item
delete/insert lacks (the commit executor runs with RLS off). Stated in the
JSDoc as well.
- The item snapshot is paginated via fetchAllRows: a schedule with more than
1000 lines could otherwise restore partially while reporting a clean
rollback.
- The executor now returns errorCode INVOICE_RECURRING_UPDATE_PARTIAL,
surfaced as CommitResult.code and persisted as result_data.error_code, so a
staged-op caller can detect the partial state without substring-matching the
Swedish sentence.
- Route: details keys are camelCase throughout, and an item failure is logged
once, with the repair context kept on the partial path only.
Tests: the unreadable-snapshot branches are exercised (including the
previously unused itemsSnapshotError harness hook), and the test that pinned
"header written with no possibility of rollback" now asserts that nothing is
written at all.
Fixes #1275
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4a38fa30ed
commit
f3bf50d862
@@ -25,7 +25,9 @@ const chain: any = {
|
||||
|
||||
const mockSupabase = {
|
||||
auth: { getUser: vi.fn() },
|
||||
from: vi.fn(() => chain),
|
||||
// The table name is unused by the shared chain, but declared so a test can
|
||||
// install a table-aware implementation of its own.
|
||||
from: vi.fn((_table?: string) => chain),
|
||||
}
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
@@ -147,3 +149,160 @@ describe('PATCH /api/invoices/recurring/[id] reactivation', () => {
|
||||
expect(updatePayloads[0]).toEqual({ auto_send: true })
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* A combined edit (header fields + items) writes two tables, which PostgREST
|
||||
* cannot do atomically. Issue #1275: an item-insert failure used to leave the
|
||||
* header update committed. These tests pin the compensating rollback and the
|
||||
* partial-state error when a compensation itself fails.
|
||||
*/
|
||||
describe('PATCH /api/invoices/recurring/[id] combined edit rollback', () => {
|
||||
const SCHEDULES = 'recurring_invoice_schedules'
|
||||
const STAMP = '2026-07-30T09:00:00.000Z'
|
||||
const headerRow: Record<string, unknown> = {
|
||||
id: 's-1',
|
||||
name: 'Månadsavgift',
|
||||
day_of_month: 25,
|
||||
next_run_date: '2026-08-25',
|
||||
updated_at: '2026-07-01T00:00:00Z',
|
||||
}
|
||||
|
||||
const storedItem = {
|
||||
id: 'i-1',
|
||||
created_at: '2026-07-01T00:00:00Z',
|
||||
schedule_id: 's-1',
|
||||
sort_order: 0,
|
||||
description: 'Gammal rad',
|
||||
quantity: 1,
|
||||
unit: 'st',
|
||||
unit_price: 500,
|
||||
vat_rate: 25,
|
||||
dimensions: {},
|
||||
}
|
||||
|
||||
let itemsInsertErrors: (Record<string, unknown> | null)[] = []
|
||||
let itemsSnapshot: Record<string, unknown>[] = []
|
||||
let headerExists = true
|
||||
const itemsInserts: Record<string, unknown>[][] = []
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
updatePayloads.length = 0
|
||||
itemsInserts.length = 0
|
||||
itemsInsertErrors = []
|
||||
itemsSnapshot = []
|
||||
headerExists = true
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
|
||||
let headerUpdateCall = 0
|
||||
let itemsInsertCall = 0
|
||||
mockSupabase.from.mockImplementation((table?: string) => {
|
||||
if (table === SCHEDULES) {
|
||||
const selectChain: Record<string, unknown> = {
|
||||
eq: () => selectChain,
|
||||
single: () => Promise.resolve({ data: headerExists ? headerRow : null, error: null }),
|
||||
maybeSingle: () =>
|
||||
Promise.resolve({ data: headerExists ? headerRow : null, error: null }),
|
||||
}
|
||||
return {
|
||||
select: () => selectChain,
|
||||
update: (payload: Record<string, unknown>) => {
|
||||
updatePayloads.push(payload)
|
||||
const call = headerUpdateCall
|
||||
headerUpdateCall += 1
|
||||
const chain: Record<string, unknown> = {
|
||||
eq: () => chain,
|
||||
// The first update reads back updated_at; the rollback update
|
||||
// reads back the matched ids.
|
||||
select: () =>
|
||||
call === 0
|
||||
? { maybeSingle: () => Promise.resolve({ data: { updated_at: STAMP }, error: null }) }
|
||||
: Promise.resolve({ data: [{ id: 's-1' }], error: null }),
|
||||
}
|
||||
return chain
|
||||
},
|
||||
}
|
||||
}
|
||||
const itemsChain: Record<string, unknown> = {
|
||||
// The snapshot is read through fetchAllRows, hence .order().range().
|
||||
select: () => ({
|
||||
eq: () => ({
|
||||
order: () => ({
|
||||
range: () => Promise.resolve({ data: itemsSnapshot, error: null }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
delete: () => ({ eq: () => Promise.resolve({ error: null }) }),
|
||||
insert: (rows: Record<string, unknown>[]) => {
|
||||
itemsInserts.push(rows)
|
||||
const error = itemsInsertErrors[itemsInsertCall] ?? null
|
||||
itemsInsertCall += 1
|
||||
return Promise.resolve({ error })
|
||||
},
|
||||
}
|
||||
return itemsChain
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// clearAllMocks does not reset implementations, so hand the shared chain
|
||||
// back or the first describe breaks when the file order changes.
|
||||
mockSupabase.from.mockImplementation(() => chain)
|
||||
})
|
||||
|
||||
const items = [{ description: 'Rad A', quantity: 1, unit: 'st', unit_price: 1000 }]
|
||||
|
||||
it('restores the prior header fields when the items insert fails', async () => {
|
||||
itemsSnapshot = [storedItem]
|
||||
itemsInsertErrors = [{ message: 'check violation', code: '23514' }, null]
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
||||
await PATCH(patchReq({ name: 'Nytt namn', items }), params),
|
||||
)
|
||||
|
||||
expect(status).toBe(400)
|
||||
// Clean rollback keeps the PG-mapped error, not the partial-state one.
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
expect(updatePayloads).toHaveLength(2)
|
||||
expect(updatePayloads[1]).toEqual({ name: 'Månadsavgift' })
|
||||
// Items back too: the failed replace, then the snapshot restore.
|
||||
expect(itemsInserts).toHaveLength(2)
|
||||
expect(itemsInserts[1][0]).toMatchObject({ description: 'Gammal rad', schedule_id: 's-1' })
|
||||
})
|
||||
|
||||
it('returns the partial-state error when the items restore also fails', async () => {
|
||||
itemsSnapshot = [storedItem]
|
||||
itemsInsertErrors = [
|
||||
{ message: 'check violation', code: '23514' },
|
||||
{ message: 'restore boom' },
|
||||
]
|
||||
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; message: string }
|
||||
}>(await PATCH(patchReq({ name: 'Nytt namn', items }), params))
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error.code).toBe('INVOICE_RECURRING_UPDATE_PARTIAL')
|
||||
expect(body.error.message).toMatch(/halvsparat/)
|
||||
})
|
||||
|
||||
it('writes no header row for an item-only edit', async () => {
|
||||
const { status } = await parseJsonResponse(await PATCH(patchReq({ items }), params))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(updatePayloads).toHaveLength(0)
|
||||
expect(itemsInserts).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('404s before writing the header when the schedule does not exist', async () => {
|
||||
headerExists = false
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ type: string }>(
|
||||
await PATCH(patchReq({ name: 'Nytt namn', items }), params),
|
||||
)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.type).toBe('not_found')
|
||||
expect(updatePayloads).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse } from '@/lib/errors/get-structured-error'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { UpdateRecurringScheduleSchema } from '@/lib/api/schemas'
|
||||
import { applyRecurringScheduleUpdate } from '@/lib/invoices/apply-recurring-schedule-update'
|
||||
import {
|
||||
computeInitialRunDate,
|
||||
computeNextRunDate,
|
||||
@@ -162,22 +163,9 @@ export const PATCH = withRouteContext(
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updateRow).length > 0) {
|
||||
const { error: updateError } = await supabase
|
||||
.from('recurring_invoice_schedules')
|
||||
.update(updateRow)
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (updateError) {
|
||||
log.error('failed to update recurring schedule', updateError)
|
||||
return errorResponse(updateError, log, { requestId })
|
||||
}
|
||||
}
|
||||
|
||||
// Existence check BEFORE any write: with items in the payload the request
|
||||
// writes two tables, so a 404 must not leave a header update behind.
|
||||
if (items) {
|
||||
// Replace items wholesale. Cheaper than diffing for a small list and
|
||||
// matches how the UI form sends the full list back on every save.
|
||||
const { data: existing } = await supabase
|
||||
.from('recurring_invoice_schedules')
|
||||
.select('id')
|
||||
@@ -191,61 +179,51 @@ export const PATCH = withRouteContext(
|
||||
{ status: 404 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot existing rows so we can restore them if the insert fails.
|
||||
// Without this, a failed replace would leave the schedule with zero
|
||||
// items and every subsequent cron run would throw "schedule has no
|
||||
// items", silently skipping billing dates.
|
||||
const { data: previousItems } = await supabase
|
||||
.from('recurring_invoice_schedule_items')
|
||||
.select('sort_order, description, quantity, unit, unit_price, vat_rate, dimensions')
|
||||
.eq('schedule_id', id)
|
||||
// Items are replaced wholesale. Cheaper than diffing for a small list and
|
||||
// matches how the UI form sends the full list back on every save. Both
|
||||
// writes are compensated on failure inside the shared helper.
|
||||
const result = await applyRecurringScheduleUpdate(supabase, {
|
||||
scheduleId: id,
|
||||
companyId,
|
||||
fields: updateRow,
|
||||
items,
|
||||
log,
|
||||
})
|
||||
|
||||
await supabase
|
||||
.from('recurring_invoice_schedule_items')
|
||||
.delete()
|
||||
.eq('schedule_id', id)
|
||||
|
||||
const itemRows = items.map((item, idx) => ({
|
||||
schedule_id: id,
|
||||
sort_order: idx,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
vat_rate: item.vat_rate ?? null,
|
||||
dimensions: item.dimensions ?? {},
|
||||
}))
|
||||
const { error: itemsError } = await supabase
|
||||
.from('recurring_invoice_schedule_items')
|
||||
.insert(itemRows)
|
||||
if (itemsError) {
|
||||
log.error('failed to replace schedule items', itemsError)
|
||||
// Restore the snapshot so the schedule stays valid for the cron.
|
||||
if (previousItems && previousItems.length > 0) {
|
||||
const restoreRows = previousItems.map((row) => ({
|
||||
schedule_id: id,
|
||||
sort_order: row.sort_order,
|
||||
description: row.description,
|
||||
quantity: row.quantity,
|
||||
unit: row.unit,
|
||||
unit_price: row.unit_price,
|
||||
vat_rate: row.vat_rate,
|
||||
dimensions: row.dimensions ?? {},
|
||||
}))
|
||||
const { error: restoreError } = await supabase
|
||||
.from('recurring_invoice_schedule_items')
|
||||
.insert(restoreRows)
|
||||
if (restoreError) {
|
||||
log.error(
|
||||
'failed to restore schedule items after failed replace: schedule may be left empty',
|
||||
restoreError,
|
||||
{ scheduleId: id },
|
||||
)
|
||||
}
|
||||
}
|
||||
return errorResponse(itemsError, log, { requestId })
|
||||
if (!result.ok) {
|
||||
if (result.stage === 'header') {
|
||||
log.error('failed to update recurring schedule', result.error)
|
||||
return errorResponse(result.error, log, { requestId })
|
||||
}
|
||||
if (!result.itemsRestored || !result.headerRestored) {
|
||||
// A compensation did not apply, so the schedule may be half-saved: say
|
||||
// so instead of reporting a clean failure. Logged here with the repair
|
||||
// context (errorResponseFromCode only records the code itself), which
|
||||
// the clean-rollback path below does not need.
|
||||
log.error('recurring schedule update left a partial state', result.error, {
|
||||
scheduleId: id,
|
||||
stage: result.stage,
|
||||
itemsRestored: result.itemsRestored,
|
||||
headerRestored: result.headerRestored,
|
||||
})
|
||||
return errorResponseFromCode('INVOICE_RECURRING_UPDATE_PARTIAL', log, {
|
||||
requestId,
|
||||
// camelCase throughout, matching the pgCode key errorResponse itself
|
||||
// merges into details for Postgres failures.
|
||||
details: {
|
||||
pgCode: result.error.code,
|
||||
stage: result.stage,
|
||||
itemsRestored: result.itemsRestored,
|
||||
headerRestored: result.headerRestored,
|
||||
},
|
||||
})
|
||||
}
|
||||
// Clean rollback: keep the PG-mapped error so a CHECK violation still
|
||||
// surfaces its specific Swedish message. errorResponse logs it, so no
|
||||
// second log line here.
|
||||
return errorResponse(result.error, log, { requestId })
|
||||
}
|
||||
|
||||
const { data: complete } = await supabase
|
||||
|
||||
Reference in New Issue
Block a user