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
@@ -1437,5 +1437,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-09-01] Anon-callable SECURITY DEFINER writes: the guard shape `IF auth.uid() IS NOT NULL AND NOT EXISTS (membership)` is unsafe on its own. The anon JWT carries no `sub` claim, so auth.uid() is NULL for role anon too and the guard short-circuits into the trusted branch. It is defense in depth behind a REVOKE FROM PUBLIC, anon, never a substitute for one. Every new SECURITY DEFINER function ships with that REVOKE; tests/pg/definer-function-grants.pg.test.ts enforces it from a sweep rather than a hand list, because hand-listing is exactly how three guarded numbering RPCs were wrongly declared safe.
|
||||
[2026-09-01] public.create_invoice_with_items(jsonb,jsonb) is revoked, not dropped. It is prod-only, uncalled and already non-functional, so removing it is cleanup rather than security, and the revoke closes the hole in full. An irreversible schema deletion against production belongs in its own reviewable migration.
|
||||
[2026-09-01] SKV connector refresh classification fixed broker-side (skeptic refutation on PR #2103, found independently by two skeptics): the broker's /oauth/token catch-all had collapsed SKV's terminal dead-refresh-token dialects (404 id_not_found, 400 invalid_grant, "Refresh Token status is expired": the DOMINANT refresh outcome, per-flow tokens live 65 min) into the generic 502, so a connector instance could never classify ordinary session expiry: raw English 500s instead of the reconnect flow, staged filing ops consumed as non-recoverable, crons retrying raw forever. The broker now re-codes those dialects (refresh grant only, never the code exchange where invalid_grant means an expired one-shot code) as 401 CONNECTOR_SKV_REFRESH_DEAD, which the instance maps to SESSION_EXPIRED; the generic 502 remains raw so a transient SKV outage still never re-arms the reconnect banner (#1155). Same pass: the data proxy now forwards WWW-Authenticate + x-skv-*/x-amzn-*/x-api-* response headers (the instance's MISSING_SCOPE classification reads them; nothing secret rides in them), and the instance's gateway-refusal guidance is connector-aware (a self-host has no SKATTEVERKET_APIGW_CLIENT_ID or Utvecklarportalen access: point at /api/connector/status + support instead).
|
||||
[2026-09-01] WooCommerce failed orders: excluded 'failed' from order sync + remove-on-transition; kept 'cancelled' importing and 'trash' skip-only: cancelled is a real order some users want visible (asked in user reply), trash can be restored in wp-admin and may mirror a paid event. Removal deletes app-side with freeze guards incl. frozen-refund-child veto (parent_order_id cascades, table has no delete trigger).
|
||||
[2026-09-01] Skeptic BLOCK on woo failed-order removal fixed by: freeze guards repeated on the DELETE statement (TOCTOU), is_paid=false + legacy_transaction_id null guards, orderRemoves gated on !orderIsPaid. No BEFORE DELETE trigger/RPC: the is_paid guard makes the cascade race unreachable (refund children only exist under paid parents).
|
||||
[2026-09-01] EB claim guard, skeptic round (PR #2116): active-company standing state (enabled cash_accounts + enabled accounts on its live-ish rows) outranks sibling claims COMPANY-wide, not row-wide: a bank-list renewal arrives on a fresh row and must not switch a working feed off. pending_selection rows neither claim nor remember deselections (unconfirmed callback output; also stops fail-closed writes from poisoning later connects). Guard-disabled accounts are never mirrored from the callback (mirroring enabled:false can promote the seeded primary 1930 manual row and disable it under a foreign identity) and the selection save skips allocation+mirror for disabled never-mirrored accounts, so the no-slot-burned invariant holds end to end. Deselection carry got a picker note; enabling an account clears the guard flags. Legacy both-companies-enabled overlaps stay untouched (Swedish review advisory: prod sweep is a follow-up, not this PR).
|
||||
[2026-09-01] EB claim guard round 2 (skeptic re-verify): pending_selection rows are asymmetric, not excluded: their ENABLED accounts still claim (attach-created rows hold offered accounts with no cash rows until saved; excluding them reopened the attach-window double-booking), while their disabled flags stay out of deselection memory (unconfirmed callback output). Both fetchAllRows claim queries order('id'): unordered .range() pagination can silently skip rows at page boundaries, and a skipped row is a missed claim (fail-open).
|
||||
|
||||
@@ -279,6 +279,7 @@ describe('woocommerce extension routes', () => {
|
||||
inserted: 4,
|
||||
updated: 0,
|
||||
unchanged: 0,
|
||||
removed: 0,
|
||||
frozenFlagged: 0,
|
||||
crossMarked: 0,
|
||||
errors: 0,
|
||||
|
||||
@@ -14,9 +14,10 @@ vi.mock('../lib/api-client', () => ({
|
||||
|
||||
vi.mock('@/lib/webshop-orders/ingest', () => ({
|
||||
upsertWebshopOrders: vi.fn(),
|
||||
removeWebshopOrders: vi.fn(),
|
||||
}))
|
||||
|
||||
import { upsertWebshopOrders } from '@/lib/webshop-orders/ingest'
|
||||
import { removeWebshopOrders, upsertWebshopOrders } from '@/lib/webshop-orders/ingest'
|
||||
import type { WebshopOrderUpsert } from '@/lib/webshop-orders/types'
|
||||
import { encryptCredential } from '../lib/credentials'
|
||||
import {
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
mapRefundToWebshopRow,
|
||||
orderImports,
|
||||
orderIsPaid,
|
||||
orderRemoves,
|
||||
syncWooCommerceOrders,
|
||||
wooOrderExternalId,
|
||||
wooRefundExternalId,
|
||||
@@ -146,6 +148,7 @@ beforeEach(() => {
|
||||
listOrdersPage.mockResolvedValue([])
|
||||
listOrderRefunds.mockResolvedValue([])
|
||||
vi.mocked(upsertWebshopOrders).mockResolvedValue({ ...emptyUpsertResult })
|
||||
vi.mocked(removeWebshopOrders).mockResolvedValue({ removed: 0, errors: 0 })
|
||||
})
|
||||
|
||||
describe('frozen external_id formats', () => {
|
||||
@@ -176,12 +179,22 @@ describe('frozen external_id formats', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('orderImports / orderIsPaid', () => {
|
||||
it('every non-trash status imports, paid or not', () => {
|
||||
describe('orderImports / orderRemoves / orderIsPaid', () => {
|
||||
it('every status except trash and failed imports, paid or not', () => {
|
||||
expect(orderImports(makeOrder())).toBe(true)
|
||||
expect(orderImports(makeOrder({ status: 'pending', date_paid_gmt: null }))).toBe(true)
|
||||
expect(orderImports(makeOrder({ status: 'refunded' }))).toBe(true)
|
||||
expect(orderImports(makeOrder({ status: 'trash' }))).toBe(false)
|
||||
expect(orderImports(makeOrder({ status: 'failed', date_paid_gmt: null }))).toBe(false)
|
||||
})
|
||||
|
||||
it('only unpaid failed orders trigger removal of an existing row', () => {
|
||||
expect(orderRemoves(makeOrder({ status: 'failed', date_paid_gmt: null }))).toBe(true)
|
||||
// A failed order that was at some point paid keeps its row: money moved.
|
||||
expect(orderRemoves(makeOrder({ status: 'failed' }))).toBe(false)
|
||||
expect(orderRemoves(makeOrder({ status: 'trash', date_paid_gmt: null }))).toBe(false)
|
||||
expect(orderRemoves(makeOrder())).toBe(false)
|
||||
expect(orderRemoves(makeOrder({ status: 'cancelled' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('is_paid follows date_paid', () => {
|
||||
@@ -563,6 +576,57 @@ describe('syncWooCommerceOrders', () => {
|
||||
expect((rows as WebshopOrderUpsert[])[0]).toMatchObject({ is_paid: false })
|
||||
})
|
||||
|
||||
it('removes the existing row of a failed order instead of importing it', async () => {
|
||||
const { client, updates } = makeSupabaseMock()
|
||||
listOrdersPage.mockResolvedValueOnce([
|
||||
makeOrder({
|
||||
status: 'failed',
|
||||
date_paid_gmt: null,
|
||||
// A stray refunds stub must not trigger a refund fetch for a
|
||||
// non-importing order.
|
||||
refunds: [{ id: 9, reason: '', total: '-100.00' }],
|
||||
}),
|
||||
])
|
||||
vi.mocked(removeWebshopOrders).mockResolvedValueOnce({ removed: 1, errors: 0 })
|
||||
|
||||
const summary = await syncWooCommerceOrders(client, makeConnection())
|
||||
|
||||
expect(summary).toMatchObject({ fetched: 1, inserted: 0, removed: 1, errors: 0 })
|
||||
expect(upsertWebshopOrders).not.toHaveBeenCalled()
|
||||
expect(listOrderRefunds).not.toHaveBeenCalled()
|
||||
expect(removeWebshopOrders).toHaveBeenCalledWith(client, 'company-1', [
|
||||
'woo_shop.example.se_order_1042',
|
||||
])
|
||||
// The removal is complete work: the cursor advances normally.
|
||||
const cursors = cursorUpdates(updates)
|
||||
expect(cursors).toHaveLength(1)
|
||||
expect(cursors[0].values.last_order_synced_at).toBe('2026-08-01T09:05:00.000Z')
|
||||
})
|
||||
|
||||
it('neither imports nor removes a paid order the store reports as failed', async () => {
|
||||
const { client } = makeSupabaseMock()
|
||||
listOrdersPage.mockResolvedValueOnce([makeOrder({ status: 'failed' })])
|
||||
|
||||
const summary = await syncWooCommerceOrders(client, makeConnection())
|
||||
|
||||
expect(summary).toMatchObject({ fetched: 1, inserted: 0, removed: 0, errors: 0 })
|
||||
expect(upsertWebshopOrders).not.toHaveBeenCalled()
|
||||
expect(removeWebshopOrders).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('holds the cursor below a page whose removal reported errors', async () => {
|
||||
const { client, updates } = makeSupabaseMock()
|
||||
listOrdersPage.mockResolvedValueOnce([makeOrder({ status: 'failed', date_paid_gmt: null })])
|
||||
vi.mocked(removeWebshopOrders).mockResolvedValueOnce({ removed: 0, errors: 1 })
|
||||
|
||||
const summary = await syncWooCommerceOrders(client, makeConnection())
|
||||
|
||||
expect(summary.errors).toBe(1)
|
||||
const cursors = cursorUpdates(updates)
|
||||
expect(cursors).toHaveLength(1)
|
||||
expect(cursors[0].values.last_order_synced_at).toBe('2026-08-01T09:04:59.000Z')
|
||||
})
|
||||
|
||||
it('holds the cursor below an order whose refund fetch failed', async () => {
|
||||
const { client, updates } = makeSupabaseMock()
|
||||
const order = makeOrder({ refunds: [{ id: 77, reason: '', total: '-250.00' }] })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { upsertWebshopOrders } from '@/lib/webshop-orders/ingest'
|
||||
import { removeWebshopOrders, upsertWebshopOrders } from '@/lib/webshop-orders/ingest'
|
||||
import type { WebshopOrderUpsert } from '@/lib/webshop-orders/types'
|
||||
import { createLogger, type Logger } from '@/lib/logger'
|
||||
import { roundOre as round } from '@/lib/money'
|
||||
@@ -21,8 +21,9 @@ const defaultLog = createLogger('woocommerce/order-sync')
|
||||
* public.webshop_orders (the Orders page), replacing the earlier
|
||||
* transactions-inbox feed.
|
||||
*
|
||||
* Every non-trash order imports (including unpaid ones: the Orders page shows
|
||||
* order state and the invoice flow needs pre-payment orders), carrying the
|
||||
* Every order imports except trash and failed (including unpaid ones: the
|
||||
* Orders page shows order state and the invoice flow needs pre-payment
|
||||
* orders), carrying the
|
||||
* full booking underlag the wc/v3 payload already contains: customer billing
|
||||
* snapshot, payment method, per-rate VAT breakdown and line items. Refunds
|
||||
* are separate negative rows parented to their order. Nothing here books
|
||||
@@ -113,6 +114,8 @@ export interface WooCommerceSyncSummary {
|
||||
updated: number
|
||||
/** Re-polled rows with nothing new. */
|
||||
unchanged: number
|
||||
/** Rows deleted because the store now reports the order as failed. */
|
||||
removed: number
|
||||
/** Booked rows whose financials drifted remotely (flagged, not touched). */
|
||||
frozenFlagged: number
|
||||
/** Rows linked to a row the retired transactions feed already imported. */
|
||||
@@ -157,9 +160,26 @@ export function orderIsPaid(order: Pick<WooOrder, 'date_paid_gmt'>): boolean {
|
||||
return Boolean(order.date_paid_gmt)
|
||||
}
|
||||
|
||||
/** Every non-trash order imports; trash is the store's recycle bin. */
|
||||
/**
|
||||
* Which orders exist in the feed. Trash is the store's recycle bin; failed
|
||||
* is a checkout whose payment never went through, so it carries no money
|
||||
* event (user report: failed attempts flooded the Orders page as permanently
|
||||
* unbookable "Ej betald" rows).
|
||||
*/
|
||||
export function orderImports(order: Pick<WooOrder, 'status'>): boolean {
|
||||
return order.status !== 'trash'
|
||||
return order.status !== 'trash' && order.status !== 'failed'
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders whose already-imported rows should be REMOVED on re-poll: a pending
|
||||
* order that transitions to failed would otherwise sit stale forever. A
|
||||
* failed order that has a date_paid is NOT removed (skeptic finding): money
|
||||
* moved at some point (gateway void, admin bulk-edit mistake), and deleting
|
||||
* the row would hide a possibly real money event, the same reason trash
|
||||
* keeps its long-standing skip-only semantics.
|
||||
*/
|
||||
export function orderRemoves(order: Pick<WooOrder, 'status' | 'date_paid_gmt'>): boolean {
|
||||
return order.status === 'failed' && !orderIsPaid(order)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -467,6 +487,8 @@ export function mapRefundToWebshopRow(
|
||||
|
||||
interface PageRowsOutcome {
|
||||
rows: WebshopOrderUpsert[]
|
||||
/** external_ids of orders whose existing rows should be removed (failed). */
|
||||
removalExternalIds: string[]
|
||||
/**
|
||||
* date_modified (ms) of every order whose refund rows are incomplete this
|
||||
* run (fetch failed or skipped on deadline). The cursor must not advance
|
||||
@@ -486,14 +508,28 @@ async function buildPageRows(
|
||||
log: Logger,
|
||||
deadlineMs?: number,
|
||||
): Promise<PageRowsOutcome> {
|
||||
const outcome: PageRowsOutcome = { rows: [], incompleteModifiedMs: [], hitDeadline: false }
|
||||
const outcome: PageRowsOutcome = {
|
||||
rows: [],
|
||||
removalExternalIds: [],
|
||||
incompleteModifiedMs: [],
|
||||
hitDeadline: false,
|
||||
}
|
||||
|
||||
for (const order of orders) {
|
||||
if (orderRemoves(order)) {
|
||||
outcome.removalExternalIds.push(wooOrderExternalId(storeScope, order.id))
|
||||
}
|
||||
// Non-importing orders contribute nothing else; skipping here also keeps
|
||||
// their refunds out of the feed. Unreachable for trash in practice (the
|
||||
// list call asks for status=any, which excludes trash), and a failed
|
||||
// order's refund would parent to a row being removed: an unexplainable
|
||||
// negative either way.
|
||||
if (!orderImports(order)) continue
|
||||
// A corrupt total is counted and logged, never silently identical to a
|
||||
// zero-total order. Deliberately NOT held via the cursor: a permanently
|
||||
// corrupt total would stall the whole feed forever, where a skipped row
|
||||
// plus a loud error can be followed up.
|
||||
if (orderImports(order) && orderAmountUnparseable(order)) {
|
||||
if (orderAmountUnparseable(order)) {
|
||||
summary.errors += 1
|
||||
log.warn('unparseable order total; row skipped', {
|
||||
orderId: order.id,
|
||||
@@ -572,6 +608,7 @@ export async function syncWooCommerceOrders(
|
||||
inserted: 0,
|
||||
updated: 0,
|
||||
unchanged: 0,
|
||||
removed: 0,
|
||||
frozenFlagged: 0,
|
||||
crossMarked: 0,
|
||||
errors: 0,
|
||||
@@ -648,6 +685,20 @@ export async function syncWooCommerceOrders(
|
||||
failureFloorMs = Math.min(failureFloorMs, firstMs - 1000)
|
||||
}
|
||||
}
|
||||
if (page.removalExternalIds.length > 0) {
|
||||
const removal = await removeWebshopOrders(
|
||||
supabase,
|
||||
connection.company_id,
|
||||
page.removalExternalIds,
|
||||
)
|
||||
summary.removed += removal.removed
|
||||
summary.errors += removal.errors
|
||||
if (removal.errors > 0) {
|
||||
// Same retry contract as failed upserts: hold the cursor so the
|
||||
// next run re-lists this page and retries the removal.
|
||||
failureFloorMs = Math.min(failureFloorMs, firstMs - 1000)
|
||||
}
|
||||
}
|
||||
for (const ms of page.incompleteModifiedMs) {
|
||||
failureFloorMs = Math.min(failureFloorMs, ms - 1000)
|
||||
}
|
||||
|
||||
@@ -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