Skip to content

Commit 19a770d

Browse files
OneTwo3DOneTwo3D IMS
andauthored
fix(sales): route TrackShip delivery poll through status-transition path (closes audit-C2) (#193)
* fix(sales): route TrackShip delivery poll through the status-transition path Audit finding C2 (onetwo3d-ims-erl0): checkDeliveryStatus marked orders DELIVERED via a raw db.salesOrder.update — no transition guard, no side effects. A delivery detected by the cron skipped everything the manual DELIVERED path does (status_changed activity log, WooCommerce status push, cache revalidation), and could overwrite an order that had moved to CANCELLED/REFUNDED between the SHIPPED query and the write. Now routed through applySalesOrderStatusTransition with the internal bypass token (cron has no session, so the permission check is skipped but the guard + side effects run). The state machine rejects DELIVERED from a non-deliverable status, so a since-cancelled order is no longer silently overwritten — the cron logs delivery_status_skipped and moves on. Extracted the delivery-marking into markOrderDelivered with injected transition + log deps so the routing and skip-on-reject behaviour are unit-tested without the DB or external API (2 tests). workflows.md documents that cron-driven DELIVERED runs the same guard + side effects as manual. Closes onetwo3d-ims-erl0. * fix(sales): TrackShip delivery uses skipPermissionCheck, keeping the state guard (review hardening) Codex adversarial pass found the original C2 fix did NOT actually protect against the stale-state race it claimed to. The internal bypass token makes validateManualSalesOrderStatusTransition short-circuit { success: true }, skipping BOTH the permission check AND the state-machine guard — so a SHIPPED→CANCELLED race between the poll's query and the write would still overwrite CANCELLED with DELIVERED, and the docs claim was false. Root fix: added a narrower skipPermissionCheck option to applySalesOrderStatusTransition that skips ONLY the permission check; the state-machine guard (pre-lock AND under-lock via updateSalesOrderStatusUnderLock) still runs. The WooCommerce status-sync path keeps the bypass token (skip-both) unchanged — it legitimately forces mapped statuses and already handles rejection. TrackShip now uses skipPermissionCheck, so a since-cancelled/refunded order is genuinely rejected under the lock and logged as delivery_status_skipped. Also from the review: - Hoisted the applySalesOrderStatusTransition dynamic import above the per-order loop (was re-evaluated each delivered order). - Wrapped the per-order mark in try/catch so one order's failure (e.g. a transient log write) no longer aborts the whole cron batch. - Dropped the cast in the test; deps.transition type now matches the real signature (extra/options optional), so a future signature drift is caught. Docs corrected: the cron skips permission only, not the state guard. 2/2 helper tests, 93/93 sales-domain tests. --------- Co-authored-by: OneTwo3D IMS <dev@onetwo3d.com>
1 parent e94b8c1 commit 19a770d

4 files changed

Lines changed: 157 additions & 17 deletions

File tree

‎app/actions/sales.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1224,11 +1224,17 @@ export async function applySalesOrderStatusTransition(
12241224
id: string,
12251225
targetStatus: SoStatus,
12261226
extra?: { trackingNumber?: string; shipFromWarehouseId?: string },
1227-
options?: { pushStatusToWooCommerce?: boolean; internalBypassToken?: symbol },
1227+
options?: { pushStatusToWooCommerce?: boolean; internalBypassToken?: symbol; skipPermissionCheck?: boolean },
12281228
): Promise<{ success: boolean; error?: string }> {
12291229
try {
1230+
// internalBypassToken skips BOTH the permission check and the state-machine
1231+
// guard — used by external systems (WooCommerce) that may legitimately force
1232+
// a mapped status. skipPermissionCheck is narrower: it skips ONLY the
1233+
// permission check (for sessionless internal callers such as the delivery
1234+
// cron) while the state-machine guard still runs, so a stale transition
1235+
// (e.g. an order cancelled after the poll's SHIPPED query) is still rejected.
12301236
const bypassPermission = options?.internalBypassToken === INTERNAL_STATUS_TRANSITION_BYPASS
1231-
if (!bypassPermission) {
1237+
if (!bypassPermission && !options?.skipPermissionCheck) {
12321238
await requirePermission('sales.process')
12331239
}
12341240
const so = await db.salesOrder.findUnique({

‎docs/workflows.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,16 @@ rules.
3131
| `REFUNDED` | None | Terminal. |
3232
| `SHIPPED` | `COMPLETED`, `DELIVERED`, `PARTIALLY_REFUNDED`, `REFUNDED` | - |
3333

34+
The `DELIVERED` transition can be driven automatically by delivery-tracking
35+
polling (TrackShip or the active shopping connector, via `/api/cron/delivery-status`).
36+
That cron path runs the **same** transition guard and side effects as a manual
37+
delivery — it does not write the status directly. Because the cron has no user
38+
session it skips only the permission check (not the state-machine guard, unlike
39+
the WooCommerce status-sync path), so if an order has moved out of a deliverable
40+
state (e.g. cancelled or refunded after dispatch) between the poll's SHIPPED
41+
query and the under-lock write, the guard rejects the change and the cron logs a
42+
`delivery_status_skipped` warning instead of forcing it.
43+
3444
### Shipments
3545

3646
| Status | Allowed next statuses | Notes |

‎lib/trackship.ts‎

Lines changed: 78 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,70 @@ export async function queryTrackShip(apiKey: string, trackingNumber: string, car
7171
}
7272
}
7373

74+
// ---------------------------------------------------------------------------
75+
// Mark a delivered order
76+
// ---------------------------------------------------------------------------
77+
78+
type DeliveredTarget = { id: string; externalOrderNumber: string | null; source: string }
79+
type MarkDeliveredDeps = {
80+
transition: (
81+
id: string,
82+
targetStatus: 'DELIVERED',
83+
extra?: { trackingNumber?: string; shipFromWarehouseId?: string },
84+
options?: { skipPermissionCheck?: boolean },
85+
) => Promise<{ success: boolean; error?: string }>
86+
log: typeof logActivity
87+
}
88+
89+
/**
90+
* Mark an order DELIVERED from a delivery-tracking poll.
91+
*
92+
* Routes through the real status-transition path rather than a raw status
93+
* write (audit C2): the transition re-validates the state machine under a lock
94+
* — so an order that moved to CANCELLED / REFUNDED / etc. between the SHIPPED
95+
* query and now is rejected, not silently overwritten — and runs the same side
96+
* effects a manual DELIVERED transition does (status_changed log, WooCommerce
97+
* status push, cache revalidation). The cron has no session, so it passes
98+
* skipPermissionCheck to skip ONLY the sales.process permission check; the
99+
* state-machine guard still runs (unlike the WooCommerce bypass token, which
100+
* skips both). Dependency-injected so the routing + skip-on-reject behaviour
101+
* is unit-testable without the DB or external API.
102+
*/
103+
export async function markOrderDelivered(
104+
target: DeliveredTarget,
105+
deps: MarkDeliveredDeps,
106+
): Promise<{ delivered: boolean }> {
107+
const result = await deps.transition(target.id, 'DELIVERED', undefined, {
108+
skipPermissionCheck: true,
109+
})
110+
if (result.success) {
111+
await deps.log({
112+
entityType: 'SALES_ORDER',
113+
entityId: target.id,
114+
action: 'delivered',
115+
tag: 'sales',
116+
level: 'INFO',
117+
description: `Order ${target.externalOrderNumber} marked as delivered via ${target.source}`,
118+
metadata: { orderNumber: target.externalOrderNumber, source: target.source },
119+
resolveUser: false,
120+
})
121+
return { delivered: true }
122+
}
123+
// No longer in a state that can transition to DELIVERED (e.g. cancelled or
124+
// refunded after dispatch). Log and skip rather than forcing it.
125+
await deps.log({
126+
entityType: 'SALES_ORDER',
127+
entityId: target.id,
128+
action: 'delivery_status_skipped',
129+
tag: 'sales',
130+
level: 'WARNING',
131+
description: `Skipped marking order ${target.externalOrderNumber} delivered via ${target.source}: ${result.error ?? 'transition rejected'}`,
132+
metadata: { orderNumber: target.externalOrderNumber, source: target.source, error: result.error ?? null },
133+
resolveUser: false,
134+
})
135+
return { delivered: false }
136+
}
137+
74138
// Check delivery status for all shipped orders
75139
// ---------------------------------------------------------------------------
76140

@@ -106,6 +170,9 @@ export async function checkDeliveryStatus(): Promise<{ checked: number; delivere
106170
let checked = 0
107171
let delivered = 0
108172

173+
// Hoisted: the transition module is loaded once, not per delivered order.
174+
const { applySalesOrderStatusTransition } = await import('@/app/actions/sales')
175+
109176
for (const order of shippedOrders) {
110177
let isDelivered = false
111178

@@ -135,22 +202,18 @@ export async function checkDeliveryStatus(): Promise<{ checked: number; delivere
135202
}
136203

137204
if (isDelivered) {
138-
await db.salesOrder.update({
139-
where: { id: order.id },
140-
data: { status: 'DELIVERED' },
141-
})
142-
delivered++
143205
const resolvedSource = useShoppingConnector ? 'shopping_connector' : 'trackship'
144-
await logActivity({
145-
entityType: 'SALES_ORDER',
146-
entityId: order.id,
147-
action: 'delivered',
148-
tag: 'sales',
149-
level: 'INFO',
150-
description: `Order ${order.externalOrderNumber} marked as delivered via ${resolvedSource}`,
151-
metadata: { orderNumber: order.externalOrderNumber, source: resolvedSource },
152-
resolveUser: false,
153-
})
206+
try {
207+
const result = await markOrderDelivered(
208+
{ id: order.id, externalOrderNumber: order.externalOrderNumber, source: resolvedSource },
209+
{ transition: applySalesOrderStatusTransition, log: logActivity },
210+
)
211+
if (result.delivered) delivered++
212+
} catch (error) {
213+
// Isolate per-order failures (e.g. a transient DB error while logging)
214+
// so one bad order does not abort delivery processing for the rest.
215+
console.error(`[delivery-status] failed to mark order ${order.id} delivered:`, error)
216+
}
154217
}
155218
}
156219

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import assert from 'node:assert/strict'
2+
import { test } from 'node:test'
3+
import { markOrderDelivered } from '@/lib/trackship'
4+
5+
type TransitionCall = {
6+
id: string
7+
targetStatus: string
8+
extra?: { trackingNumber?: string; shipFromWarehouseId?: string }
9+
options?: { skipPermissionCheck?: boolean }
10+
}
11+
12+
function makeDeps(transitionResult: { success: boolean; error?: string }) {
13+
const calls: { transition: TransitionCall[]; logs: Array<Record<string, unknown>> } = { transition: [], logs: [] }
14+
const deps = {
15+
transition: async (
16+
id: string,
17+
targetStatus: 'DELIVERED',
18+
extra?: { trackingNumber?: string; shipFromWarehouseId?: string },
19+
options?: { skipPermissionCheck?: boolean },
20+
) => {
21+
calls.transition.push({ id, targetStatus, extra, options })
22+
return transitionResult
23+
},
24+
log: async (entry: Record<string, unknown>) => { calls.logs.push(entry) },
25+
}
26+
return { deps, calls }
27+
}
28+
29+
test('routes delivery through the transition path skipping only permission (not a raw write)', async () => {
30+
const { deps, calls } = makeDeps({ success: true })
31+
const result = await markOrderDelivered(
32+
{ id: 'so-1', externalOrderNumber: 'WC-100', source: 'trackship' },
33+
deps,
34+
)
35+
assert.equal(result.delivered, true)
36+
assert.equal(calls.transition.length, 1)
37+
const call = calls.transition[0]
38+
assert.equal(call.id, 'so-1')
39+
assert.equal(call.targetStatus, 'DELIVERED')
40+
// skipPermissionCheck skips only the permission gate — the state-machine
41+
// guard still runs, so a since-cancelled order is rejected (success:false).
42+
assert.equal(call.options?.skipPermissionCheck, true)
43+
// Logs the successful delivery.
44+
assert.equal(calls.logs.length, 1)
45+
assert.equal(calls.logs[0].action, 'delivered')
46+
})
47+
48+
test('skips and warns when the transition is rejected (order no longer SHIPPED)', async () => {
49+
const { deps, calls } = makeDeps({ success: false, error: 'Cannot move from CANCELLED to DELIVERED' })
50+
const result = await markOrderDelivered(
51+
{ id: 'so-2', externalOrderNumber: 'WC-200', source: 'shopping_connector' },
52+
deps,
53+
)
54+
// Not counted as delivered, and no forced write — only a warning log.
55+
assert.equal(result.delivered, false)
56+
assert.equal(calls.transition.length, 1)
57+
assert.equal(calls.logs.length, 1)
58+
assert.equal(calls.logs[0].action, 'delivery_status_skipped')
59+
assert.equal(calls.logs[0].level, 'WARNING')
60+
assert.match(String(calls.logs[0].description), /Cannot move from CANCELLED to DELIVERED/)
61+
})

0 commit comments

Comments
 (0)