11import { and , eq , inArray , ne , sql } from 'drizzle-orm' ;
22
3- import { applyReserveMove , applyReleaseMove } from '../inventory' ;
3+ import { applyReserveMove } from '../inventory' ;
44import { logError , logWarn } from '@/lib/logging' ;
55import { isPaymentsEnabled } from '@/lib/env/stripe' ;
66import { 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