Skip to content

Commit 5284ab3

Browse files
(SP: 1) [Backend] Enforce restock release invariants: fail-safe on release failure + regression test
1 parent e1f55a4 commit 5284ab3

4 files changed

Lines changed: 401 additions & 121 deletions

File tree

frontend/components/tests/CookieBanner.test.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
14
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
25
import { render, screen, act, fireEvent } from '@testing-library/react';
36
import { CookieBanner } from '@/components/shared/CookieBanner';

frontend/lib/services/orders/checkout.ts

Lines changed: 55 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { and, eq, inArray, ne, sql } from 'drizzle-orm';
22

3-
import { applyReserveMove, applyReleaseMove } from '../inventory';
3+
import { applyReserveMove } from '../inventory';
44
import { logError, logWarn } from '@/lib/logging';
55
import { isPaymentsEnabled } from '@/lib/env/stripe';
66
import { db } from '@/db';
@@ -58,6 +58,8 @@ async function reconcileNoPaymentOrder(
5858
inventoryStatus: orders.inventoryStatus,
5959
stockRestored: orders.stockRestored,
6060
restockedAt: orders.restockedAt,
61+
failureCode: orders.failureCode,
62+
failureMessage: orders.failureMessage,
6163
})
6264
.from(orders)
6365
.where(eq(orders.id, orderId))
@@ -89,6 +91,25 @@ async function reconcileNoPaymentOrder(
8991
return getOrderById(orderId);
9092
}
9193

94+
if (row.inventoryStatus === 'release_pending') {
95+
// Do not attempt to reserve again while release is pending.
96+
try {
97+
await restockOrder(orderId, {
98+
reason: 'failed',
99+
workerId: 'reconcileNoPaymentOrder',
100+
});
101+
} catch (restockErr) {
102+
logError(
103+
`[reconcileNoPaymentOrder] restock failed orderId=${orderId}`,
104+
restockErr
105+
);
106+
}
107+
108+
throw new InsufficientStockError(
109+
row.failureMessage ?? 'Order cannot be completed (release pending).'
110+
);
111+
}
112+
92113
// If it was already released/restocked - treat as failed.
93114
if (
94115
row.inventoryStatus === 'released' ||
@@ -168,49 +189,39 @@ async function reconcileNoPaymentOrder(
168189
return getOrderById(orderId);
169190
} catch (e) {
170191
const failAt = new Date();
192+
193+
// Mark as "release pending" only. Finalization must happen via restockOrder().
171194
await db
172195
.update(orders)
173196
.set({ inventoryStatus: 'release_pending', updatedAt: failAt })
174197
.where(eq(orders.id, orderId));
175198

176-
for (const item of itemsToReserve) {
177-
try {
178-
await applyReleaseMove(orderId, item.productId, item.quantity);
179-
} catch (releaseErr) {
180-
logError(
181-
`[reconcileNoPaymentOrder] release failed orderId=${orderId} productId=${item.productId} quantity=${item.quantity}`,
182-
releaseErr
183-
);
184-
}
185-
}
186-
187199
const isOos = e instanceof InsufficientStockError;
200+
188201
await db
189202
.update(orders)
190203
.set({
191204
status: 'INVENTORY_FAILED',
192-
inventoryStatus: 'released',
205+
inventoryStatus: 'release_pending',
193206
failureCode: isOos ? 'OUT_OF_STOCK' : 'INTERNAL_ERROR',
194207
failureMessage: isOos
195208
? e.message
196209
: 'Checkout failed after reservation attempt.',
197-
stockRestored: true,
198-
restockedAt: failAt,
210+
// IMPORTANT: do NOT set stockRestored/restockedAt here.
199211
updatedAt: failAt,
200212
})
201213
.where(eq(orders.id, orderId));
202214

203-
const payRes = await guardedPaymentStatusUpdate({
204-
orderId,
205-
paymentProvider: 'none',
206-
to: 'failed',
207-
source: 'checkout',
208-
});
209-
210-
if (!payRes.applied && payRes.reason !== 'ALREADY_IN_STATE') {
215+
try {
216+
await restockOrder(orderId, {
217+
reason: 'failed',
218+
workerId: 'reconcileNoPaymentOrder',
219+
});
220+
} catch (restockErr) {
221+
// If release fails, we must not lie in order state; leave it for sweeps/janitor.
211222
logError(
212-
`[reconcileNoPaymentOrder] paymentStatus transition to failed blocked orderId=${orderId} reason=${payRes.reason}`,
213-
new Error('payment_transition_blocked')
223+
`[reconcileNoPaymentOrder] restock failed orderId=${orderId}`,
224+
restockErr
214225
);
215226
}
216227

@@ -396,8 +407,10 @@ export async function createOrderWithItems({
396407
const paymentsEnabled = isPaymentsEnabled();
397408
const paymentProvider: PaymentProvider = paymentsEnabled ? 'stripe' : 'none';
398409

399-
// IMPORTANT: DB CHECK requires provider=none => payment_status in ('paid','failed')
400-
const paymentStatus = paymentsEnabled ? 'requires_payment' : 'paid';
410+
// paymentStatus is initialized here only; ALL transitions must go via guardedPaymentStatusUpdate.
411+
// IMPORTANT: DB CHECK requires provider='none' => payment_status in ('paid','failed')
412+
const initialPaymentStatus: PaymentStatus =
413+
paymentProvider === 'none' ? 'paid' : 'requires_payment';
401414

402415
const normalizedItems = mergeCheckoutItems(
403416
items
@@ -584,7 +597,7 @@ export async function createOrderWithItems({
584597
totalAmount: toDbMoney(orderTotalCents),
585598

586599
currency,
587-
paymentStatus,
600+
paymentStatus: initialPaymentStatus,
588601
paymentProvider,
589602
paymentIntentId: null,
590603

@@ -712,9 +725,8 @@ export async function createOrderWithItems({
712725
})
713726
.where(eq(orders.id, orderId));
714727

715-
const targetPaymentStatus: PaymentStatus = paymentsEnabled
716-
? 'pending'
717-
: 'paid';
728+
const targetPaymentStatus: PaymentStatus =
729+
paymentProvider === 'none' ? 'paid' : 'pending';
718730

719731
const payRes = await guardedPaymentStatusUpdate({
720732
orderId,
@@ -739,50 +751,37 @@ export async function createOrderWithItems({
739751
}
740752
} catch (e) {
741753
const failAt = new Date();
754+
742755
await db
743756
.update(orders)
744757
.set({ inventoryStatus: 'release_pending', updatedAt: failAt })
745758
.where(eq(orders.id, orderId));
746759

747-
// best-effort release
748-
for (const it of itemsToReserve) {
749-
try {
750-
await applyReleaseMove(orderId, it.productId, it.quantity);
751-
} catch (releaseErr) {
752-
logError(
753-
`[createOrderWithItems] release failed orderId=${orderId} productId=${it.productId} quantity=${it.quantity}`,
754-
releaseErr
755-
);
756-
}
757-
}
758-
759760
const isOos = e instanceof InsufficientStockError;
761+
760762
await db
761763
.update(orders)
762764
.set({
763765
status: 'INVENTORY_FAILED',
764-
inventoryStatus: 'released',
766+
inventoryStatus: 'release_pending',
765767
failureCode: isOos ? 'OUT_OF_STOCK' : 'INTERNAL_ERROR',
766768
failureMessage: isOos
767769
? e.message
768770
: 'Checkout failed after reservation attempt.',
769-
stockRestored: true,
770-
restockedAt: failAt,
771+
// IMPORTANT: do NOT set stockRestored/restockedAt here.
771772
updatedAt: failAt,
772773
})
773774
.where(eq(orders.id, orderId));
774775

775-
const payRes = await guardedPaymentStatusUpdate({
776-
orderId,
777-
paymentProvider,
778-
to: 'failed',
779-
source: 'checkout',
780-
});
781-
782-
if (!payRes.applied && payRes.reason !== 'ALREADY_IN_STATE') {
776+
try {
777+
await restockOrder(orderId, {
778+
reason: 'failed',
779+
workerId: 'createOrderWithItems',
780+
});
781+
} catch (restockErr) {
783782
logError(
784-
`[createOrderWithItems] paymentStatus transition to failed blocked orderId=${orderId} provider=${paymentProvider} reason=${payRes.reason}`,
785-
new Error('payment_transition_blocked')
783+
`[createOrderWithItems] restock failed orderId=${orderId}`,
784+
restockErr
786785
);
787786
}
788787

0 commit comments

Comments
 (0)