fix(webshop-orders): stop syncing failed WooCommerce orders, remove stale unpaid rows (#2119)
* fix(webshop-orders): stop importing failed WooCommerce orders, remove stale rows on failed transition Failed checkouts carry no money event but imported as permanently unbookable 'Ej betald' rows (user report). orderImports() now excludes 'failed' alongside 'trash', and a re-polled order that transitioned to failed deletes its existing row via removeWebshopOrders(), which enforces the freeze boundary app-side: frozen rows are never deleted, and a parent with a frozen refund child is spared because parent_order_id cascades. Removal failures hold the sync cursor like upsert failures do. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014hQZLCdT56j8nAyoQtAs2C * fix(webshop-orders): make failed-order removal race-safe per skeptic findings Repeat every guard on the DELETE statement itself, not only the candidate select: a row booked/marked/invoiced between the two round trips must survive (TOCTOU refutation). Never remove paid rows (orderRemoves gated on !orderIsPaid plus is_paid=false on both statements): money moved at some point, and paid parents are the only rows that can carry refund children, which also closes the cascade race without a DB trigger. Spare cross-marked rows (legacy_transaction_id): the order may be booked via the retired transactions feed without any freeze column set. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014hQZLCdT56j8nAyoQtAs2C * fix(webshop-orders): audit-log successful failed-order removals Compliance swarm finding (ISO A.8.10): the hard delete logged only its failure path. Every successful removal batch now logs companyId, deleted row ids and the requested external_ids, the only deletion record for pre-bokforing rows that carry no behandlingshistorik. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014hQZLCdT56j8nAyoQtAs2C --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
fa69174aa0
commit
a57a8d968b
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { upsertWebshopOrders } from '../ingest'
|
||||
import { removeWebshopOrders, upsertWebshopOrders } from '../ingest'
|
||||
import type { WebshopOrderUpsert } from '../types'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
@@ -313,3 +313,100 @@ describe('upsertWebshopOrders', () => {
|
||||
expect(result.firstError?.message).toBe('boom')
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeWebshopOrders', () => {
|
||||
let mock: ReturnType<typeof createQueuedMockSupabase>
|
||||
const supabase = () => mock.supabase as unknown as SupabaseClient
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mock = createQueuedMockSupabase()
|
||||
})
|
||||
|
||||
it('deletes unfrozen rows and reports the count', async () => {
|
||||
mock.enqueueMany([
|
||||
{ data: [{ id: 'row-1' }, { id: 'row-2' }] }, // unfrozen candidates
|
||||
{ data: [] }, // no frozen refund children
|
||||
{ data: [{ id: 'row-1' }, { id: 'row-2' }] }, // delete
|
||||
])
|
||||
|
||||
const result = await removeWebshopOrders(supabase(), COMPANY, [
|
||||
'woo_butik.example.se_order_1001',
|
||||
'woo_butik.example.se_order_1002',
|
||||
])
|
||||
|
||||
expect(result).toEqual({ removed: 2, errors: 0 })
|
||||
expect(mock.findCall('webshop_orders', 'delete')).toBeDefined()
|
||||
// The freeze guards run on the candidate select AND are repeated on the
|
||||
// delete statement itself: a row frozen between the two round trips must
|
||||
// survive (TOCTOU skeptic finding).
|
||||
const guardColumns = [
|
||||
'journal_entry_id',
|
||||
'invoice_id',
|
||||
'manually_booked_at',
|
||||
'legacy_transaction_id',
|
||||
]
|
||||
expect(mock.findCalls('webshop_orders', 'is').map((args) => args[0])).toEqual([
|
||||
...guardColumns,
|
||||
...guardColumns,
|
||||
])
|
||||
// Paid rows are never deleted, on both statements.
|
||||
expect(
|
||||
mock.findCalls('webshop_orders', 'eq').filter((args) => args[0] === 'is_paid'),
|
||||
).toEqual([
|
||||
['is_paid', false],
|
||||
['is_paid', false],
|
||||
])
|
||||
})
|
||||
|
||||
it('does nothing when no unfrozen row matches', async () => {
|
||||
mock.enqueueMany([{ data: [] }])
|
||||
|
||||
const result = await removeWebshopOrders(supabase(), COMPANY, [
|
||||
'woo_butik.example.se_order_1001',
|
||||
])
|
||||
|
||||
expect(result).toEqual({ removed: 0, errors: 0 })
|
||||
expect(mock.findCall('webshop_orders', 'delete')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('spares a parent whose refund child is frozen (delete would cascade)', async () => {
|
||||
mock.enqueueMany([
|
||||
{ data: [{ id: 'row-1' }, { id: 'row-2' }] },
|
||||
{ data: [{ parent_order_id: 'row-1' }] }, // row-1 has a booked refund
|
||||
{ data: [{ id: 'row-2' }] },
|
||||
])
|
||||
|
||||
const result = await removeWebshopOrders(supabase(), COMPANY, [
|
||||
'woo_butik.example.se_order_1001',
|
||||
'woo_butik.example.se_order_1002',
|
||||
])
|
||||
|
||||
expect(result).toEqual({ removed: 1, errors: 0 })
|
||||
const deleteIn = mock
|
||||
.findCalls('webshop_orders', 'in')
|
||||
.find((args) => args[0] === 'id')
|
||||
expect(deleteIn?.[1]).toEqual(['row-2'])
|
||||
})
|
||||
|
||||
it('surfaces delete errors without throwing', async () => {
|
||||
mock.enqueueMany([
|
||||
{ data: [{ id: 'row-1' }] },
|
||||
{ data: [] },
|
||||
{ data: null, error: { message: 'boom', code: '500' } },
|
||||
])
|
||||
|
||||
const result = await removeWebshopOrders(supabase(), COMPANY, [
|
||||
'woo_butik.example.se_order_1001',
|
||||
])
|
||||
|
||||
expect(result.removed).toBe(0)
|
||||
expect(result.errors).toBe(1)
|
||||
expect(result.firstError?.message).toBe('boom')
|
||||
})
|
||||
|
||||
it('returns immediately on an empty id list', async () => {
|
||||
const result = await removeWebshopOrders(supabase(), COMPANY, [])
|
||||
expect(result).toEqual({ removed: 0, errors: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,11 @@ import { fetchExchangeRate } from '@/lib/currency/riksbanken'
|
||||
import { roundOre as round } from '@/lib/money'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import type { Currency, WebshopOrder } from '@/types'
|
||||
import type { WebshopOrderUpsert, WebshopOrderUpsertResult } from './types'
|
||||
import type {
|
||||
WebshopOrderRemovalResult,
|
||||
WebshopOrderUpsert,
|
||||
WebshopOrderUpsertResult,
|
||||
} from './types'
|
||||
|
||||
const log = createLogger('webshop-orders/ingest')
|
||||
|
||||
@@ -452,3 +456,115 @@ export async function upsertWebshopOrders(
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete rows the store no longer considers money events (WooCommerce
|
||||
* 'failed' payment attempts: user report, failed checkouts sat forever as
|
||||
* unbookable "Ej betald" rows). The freeze boundary applies to deletes as it
|
||||
* does to updates, application-side here because the table has no
|
||||
* delete-protection trigger:
|
||||
*
|
||||
* - a frozen row (booked / invoiced / manually marked) is never deleted;
|
||||
* - a paid row is never deleted: money moved at some point, and a paid row
|
||||
* is also the only kind that can have refund children (parent_order_id
|
||||
* cascades, so deleting one could take a booked refund row with it);
|
||||
* - a cross-marked row (legacy_transaction_id) is never deleted: the order
|
||||
* may be booked via the retired transactions feed without any freeze
|
||||
* column set on this row;
|
||||
* - every guard is repeated ON THE DELETE STATEMENT itself, not only on the
|
||||
* candidate select (skeptic finding): a row booked between the select and
|
||||
* the delete must survive, exactly like the conditional booking claim in
|
||||
* book-order.ts. The frozen-child veto select below is defense in depth
|
||||
* on top of the is_paid guard.
|
||||
*/
|
||||
export async function removeWebshopOrders(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
externalIds: string[],
|
||||
): Promise<WebshopOrderRemovalResult> {
|
||||
const result: WebshopOrderRemovalResult = { removed: 0, errors: 0 }
|
||||
if (externalIds.length === 0) return result
|
||||
|
||||
const recordError = (err: unknown) => {
|
||||
result.errors += 1
|
||||
if (!result.firstError) {
|
||||
const anyErr = err as { message?: string; code?: string | null }
|
||||
result.firstError = {
|
||||
message: anyErr?.message ?? String(err),
|
||||
code: anyErr?.code ?? null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const batch of chunk(externalIds, CHUNK_SIZE)) {
|
||||
const { data: candidates, error: candidateError } = await supabase
|
||||
.from('webshop_orders')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.in('external_id', batch)
|
||||
.eq('is_paid', false)
|
||||
.is('journal_entry_id', null)
|
||||
.is('invoice_id', null)
|
||||
.is('manually_booked_at', null)
|
||||
.is('legacy_transaction_id', null)
|
||||
if (candidateError) {
|
||||
recordError(candidateError)
|
||||
continue
|
||||
}
|
||||
const candidateIds = ((candidates ?? []) as Array<{ id: string }>).map((r) => r.id)
|
||||
if (candidateIds.length === 0) continue
|
||||
|
||||
const { data: frozenChildren, error: childError } = await supabase
|
||||
.from('webshop_orders')
|
||||
.select('parent_order_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('parent_order_id', candidateIds)
|
||||
.or('journal_entry_id.not.is.null,invoice_id.not.is.null,manually_booked_at.not.is.null')
|
||||
if (childError) {
|
||||
recordError(childError)
|
||||
continue
|
||||
}
|
||||
const vetoedParents = new Set(
|
||||
((frozenChildren ?? []) as Array<{ parent_order_id: string }>).map(
|
||||
(r) => r.parent_order_id,
|
||||
),
|
||||
)
|
||||
const deletableIds = candidateIds.filter((id) => !vetoedParents.has(id))
|
||||
if (deletableIds.length === 0) continue
|
||||
|
||||
const { data: deletedRows, error: deleteError } = await supabase
|
||||
.from('webshop_orders')
|
||||
.delete()
|
||||
.eq('company_id', companyId)
|
||||
.in('id', deletableIds)
|
||||
.eq('is_paid', false)
|
||||
.is('journal_entry_id', null)
|
||||
.is('invoice_id', null)
|
||||
.is('manually_booked_at', null)
|
||||
.is('legacy_transaction_id', null)
|
||||
.select('id')
|
||||
if (deleteError) {
|
||||
recordError(deleteError)
|
||||
log.warn('webshop order removal batch failed', {
|
||||
companyId,
|
||||
batchSize: deletableIds.length,
|
||||
code: (deleteError as { code?: string }).code,
|
||||
})
|
||||
} else {
|
||||
const deletedIds = ((deletedRows ?? []) as Array<{ id: string }>).map((r) => r.id)
|
||||
result.removed += deletedIds.length
|
||||
if (deletedIds.length > 0) {
|
||||
// Hard delete of financial staging rows: the log is the only record
|
||||
// of what was removed and by which process (compliance finding, ISO
|
||||
// A.8.10), since pre-bokföring rows carry no behandlingshistorik.
|
||||
log.info('webshop order rows removed (store reports order failed)', {
|
||||
companyId,
|
||||
deletedIds,
|
||||
requestedExternalIds: batch,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -46,6 +46,14 @@ export interface WebshopOrderUpsert {
|
||||
refunded_total: number
|
||||
}
|
||||
|
||||
export interface WebshopOrderRemovalResult {
|
||||
/** Rows deleted (unfrozen order rows; unfrozen refund children cascade). */
|
||||
removed: number
|
||||
errors: number
|
||||
/** First error encountered, surfaced for the sync summary/logs. */
|
||||
firstError?: { message: string; code?: string | null }
|
||||
}
|
||||
|
||||
export interface WebshopOrderUpsertResult {
|
||||
inserted: number
|
||||
updated: number
|
||||
|
||||
Reference in New Issue
Block a user