Skip to content

Commit 8216aa7

Browse files
(SP: 2) [Backend/CI/DX]: Unify order paymentStatus transitions under guard; add legacy coverage + tripwire; silence webhook contract stderr
1 parent b18caee commit 8216aa7

7 files changed

Lines changed: 828 additions & 46 deletions

File tree

frontend/lib/services/orders/checkout.ts

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
type OrderSummaryWithMinor,
1919
} from '@/lib/types/shop';
2020
import { coercePriceFromDb } from '@/db/queries/shop/orders';
21-
import { type PaymentStatus } from '@/lib/shop/payments';
21+
import { type PaymentProvider, type PaymentStatus } from '@/lib/shop/payments';
2222

2323
import {
2424
InsufficientStockError,
@@ -44,6 +44,7 @@ import {
4444

4545
import { getOrderById, getOrderByIdempotencyKey } from './summary';
4646
import { restockOrder } from './restock';
47+
import { guardedPaymentStatusUpdate } from './payment-state';
4748

4849
async function reconcileNoPaymentOrder(
4950
orderId: string
@@ -144,13 +145,26 @@ async function reconcileNoPaymentOrder(
144145
.set({
145146
status: 'PAID',
146147
inventoryStatus: 'reserved',
147-
paymentStatus: 'paid',
148148
failureCode: null,
149149
failureMessage: null,
150150
updatedAt: new Date(),
151151
})
152152
.where(eq(orders.id, orderId));
153153

154+
const payRes = await guardedPaymentStatusUpdate({
155+
orderId,
156+
paymentProvider: 'none',
157+
to: 'paid',
158+
source: 'checkout',
159+
});
160+
161+
if (!payRes.applied && payRes.reason !== 'ALREADY_IN_STATE') {
162+
throw new OrderStateInvalidError(
163+
'Order paymentStatus transition blocked after reservation.',
164+
{ orderId, details: { reason: payRes.reason, from: payRes.from } }
165+
);
166+
}
167+
154168
return getOrderById(orderId);
155169
} catch (e) {
156170
const failAt = new Date();
@@ -176,7 +190,6 @@ async function reconcileNoPaymentOrder(
176190
.set({
177191
status: 'INVENTORY_FAILED',
178192
inventoryStatus: 'released',
179-
paymentStatus: 'failed',
180193
failureCode: isOos ? 'OUT_OF_STOCK' : 'INTERNAL_ERROR',
181194
failureMessage: isOos
182195
? e.message
@@ -187,6 +200,20 @@ async function reconcileNoPaymentOrder(
187200
})
188201
.where(eq(orders.id, orderId));
189202

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') {
211+
logError(
212+
`[reconcileNoPaymentOrder] paymentStatus transition to failed blocked orderId=${orderId} reason=${payRes.reason}`,
213+
new Error('payment_transition_blocked')
214+
);
215+
}
216+
190217
throw e;
191218
}
192219
}
@@ -367,7 +394,8 @@ export async function createOrderWithItems({
367394
}): Promise<CheckoutResult> {
368395
const currency: Currency = resolveCurrencyFromLocale(locale);
369396
const paymentsEnabled = isPaymentsEnabled();
370-
const paymentProvider = paymentsEnabled ? 'stripe' : 'none';
397+
const paymentProvider: PaymentProvider = paymentsEnabled ? 'stripe' : 'none';
398+
371399
// IMPORTANT: DB CHECK requires provider=none => payment_status in ('paid','failed')
372400
const paymentStatus = paymentsEnabled ? 'requires_payment' : 'paid';
373401

@@ -678,12 +706,37 @@ export async function createOrderWithItems({
678706
.set({
679707
status: paymentsEnabled ? 'INVENTORY_RESERVED' : 'PAID',
680708
inventoryStatus: 'reserved',
681-
paymentStatus: paymentsEnabled ? 'pending' : 'paid',
682709
failureCode: null,
683710
failureMessage: null,
684711
updatedAt: new Date(),
685712
})
686713
.where(eq(orders.id, orderId));
714+
715+
const targetPaymentStatus: PaymentStatus = paymentsEnabled
716+
? 'pending'
717+
: 'paid';
718+
719+
const payRes = await guardedPaymentStatusUpdate({
720+
orderId,
721+
paymentProvider,
722+
to: targetPaymentStatus,
723+
source: 'checkout',
724+
});
725+
726+
if (!payRes.applied && payRes.reason !== 'ALREADY_IN_STATE') {
727+
throw new OrderStateInvalidError(
728+
'Order paymentStatus transition blocked after inventory reservation.',
729+
{
730+
orderId,
731+
details: {
732+
reason: payRes.reason,
733+
from: payRes.from,
734+
to: targetPaymentStatus,
735+
paymentProvider,
736+
},
737+
}
738+
);
739+
}
687740
} catch (e) {
688741
const failAt = new Date();
689742
await db
@@ -709,7 +762,6 @@ export async function createOrderWithItems({
709762
.set({
710763
status: 'INVENTORY_FAILED',
711764
inventoryStatus: 'released',
712-
paymentStatus: 'failed',
713765
failureCode: isOos ? 'OUT_OF_STOCK' : 'INTERNAL_ERROR',
714766
failureMessage: isOos
715767
? e.message
@@ -720,6 +772,20 @@ export async function createOrderWithItems({
720772
})
721773
.where(eq(orders.id, orderId));
722774

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') {
783+
logError(
784+
`[createOrderWithItems] paymentStatus transition to failed blocked orderId=${orderId} provider=${paymentProvider} reason=${payRes.reason}`,
785+
new Error('payment_transition_blocked')
786+
);
787+
}
788+
723789
throw e;
724790
}
725791

frontend/lib/services/orders/payment-intent.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { type OrderSummaryWithMinor } from '@/lib/types/shop';
88
import { InvalidPayloadError, OrderNotFoundError } from '../errors';
99
import { resolvePaymentProvider } from './_shared';
1010
import { getOrderItems, parseOrderSummary } from './summary';
11+
import { guardedPaymentStatusUpdate } from './payment-state';
1112

1213
export async function setOrderPaymentIntent({
1314
orderId,
@@ -52,19 +53,34 @@ export async function setOrderPaymentIntent({
5253
return parseOrderSummary(existing, items);
5354
}
5455

55-
const [updated] = await db
56-
.update(orders)
57-
.set({
56+
const res = await guardedPaymentStatusUpdate({
57+
orderId,
58+
paymentProvider: 'stripe',
59+
to: 'requires_payment',
60+
source: 'payment_intent',
61+
allowSameStateUpdate: true,
62+
set: {
5863
paymentIntentId,
59-
paymentStatus: 'requires_payment',
6064
updatedAt: new Date(),
61-
})
65+
},
66+
});
67+
68+
if (!res.applied) {
69+
// Keep error semantics consistent with previous validation rules.
70+
// This also guarantees we won't ever do failed/refunded -> requires_payment.
71+
throw new InvalidPayloadError(
72+
`Order payment intent update blocked (${res.reason}).`
73+
);
74+
}
75+
76+
const [updated] = await db
77+
.select()
78+
.from(orders)
6279
.where(eq(orders.id, orderId))
63-
.returning();
80+
.limit(1);
6481

65-
if (!updated) throw new Error('Failed to update order payment intent');
82+
if (!updated) throw new OrderNotFoundError('Order not found');
6683

6784
const items = await getOrderItems(orderId);
6885
return parseOrderSummary(updated, items);
6986
}
70-

frontend/lib/services/orders/refund.ts

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { eq } from 'drizzle-orm';
22

33
import { db } from '@/db';
44
import { orders } from '@/db/schema/shop';
5-
import { type PaymentStatus } from '@/lib/shop/payments';
65
import { type OrderSummaryWithMinor } from '@/lib/types/shop';
76
import { InvalidPayloadError, OrderNotFoundError } from '../errors';
87
import { resolvePaymentProvider } from './_shared';
@@ -32,17 +31,9 @@ export async function refundOrder(
3231
'Refunds are only supported for stripe orders.'
3332
);
3433
}
35-
36-
const refundableStatuses: PaymentStatus[] = ['paid'];
37-
if (!refundableStatuses.includes(order.paymentStatus as PaymentStatus)) {
38-
throw new InvalidPayloadError(
39-
'Order cannot be refunded from the current status.'
40-
);
41-
}
42-
4334
const res = await guardedPaymentStatusUpdate({
4435
orderId,
45-
paymentProvider: order.paymentProvider,
36+
paymentProvider: provider, // <-- замість order.paymentProvider
4637
to: 'refunded',
4738
source: 'admin',
4839
note: 'refundOrder()',

frontend/lib/services/orders/restock.ts

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ import { applyReleaseMove } from '../inventory';
55
import { db } from '@/db';
66
import { inventoryMoves, orders } from '@/db/schema/shop';
77
import { type PaymentStatus } from '@/lib/shop/payments';
8-
8+
import { guardedPaymentStatusUpdate } from './payment-state';
99
import { OrderNotFoundError, OrderStateInvalidError } from '../errors';
10+
import { resolvePaymentProvider } from './_shared';
1011

1112
export type RestockReason = 'failed' | 'refunded' | 'canceled' | 'stale';
1213
export type RestockOptions = {
@@ -63,6 +64,7 @@ export async function restockOrder(
6364
id: orders.id,
6465
paymentProvider: orders.paymentProvider,
6566
paymentStatus: orders.paymentStatus,
67+
paymentIntentId: orders.paymentIntentId,
6668
inventoryStatus: orders.inventoryStatus,
6769
stockRestored: orders.stockRestored,
6870
restockedAt: orders.restockedAt,
@@ -76,6 +78,8 @@ export async function restockOrder(
7678
if (!order) throw new OrderNotFoundError('Order not found');
7779

7880
const isNoPayment = order.paymentProvider === 'none';
81+
const provider = resolvePaymentProvider(order);
82+
const transitionSource = options?.alreadyClaimed ? 'janitor' : 'system';
7983

8084
// already released / legacy idempotency
8185
if (
@@ -106,12 +110,12 @@ export async function restockOrder(
106110
(reason === 'failed' || reason === 'canceled' || reason === 'stale')
107111
) {
108112
const now = new Date();
109-
await db
113+
114+
const [touched] = await db
110115
.update(orders)
111116
.set({
112117
status: 'INVENTORY_FAILED',
113118
inventoryStatus: 'released',
114-
paymentStatus: 'failed',
115119
failureCode: order.failureCode ?? 'STALE_ORPHAN',
116120
failureMessage:
117121
order.failureMessage ??
@@ -120,19 +124,33 @@ export async function restockOrder(
120124
restockedAt: now,
121125
updatedAt: now,
122126
})
123-
.where(eq(orders.id, orderId));
127+
.where(and(eq(orders.id, orderId), eq(orders.stockRestored, false)))
128+
.returning({ id: orders.id });
129+
130+
if (!touched) return;
131+
132+
// paymentStatus transition only via guard
133+
await guardedPaymentStatusUpdate({
134+
orderId,
135+
paymentProvider: provider,
136+
to: 'failed',
137+
source: transitionSource,
138+
// tie to this exact finalize marker (prevents races)
139+
extraWhere: eq(orders.restockedAt, now),
140+
});
141+
124142
return;
125143
}
126144

127145
// Stripe (or any non-none provider): stale orphan must become terminal
128146
if (reason === 'stale') {
129147
const now = new Date();
130-
await db
148+
149+
const [touched] = await db
131150
.update(orders)
132151
.set({
133152
status: 'INVENTORY_FAILED',
134153
inventoryStatus: 'released',
135-
paymentStatus: 'failed',
136154
failureCode: order.failureCode ?? 'STALE_ORPHAN',
137155
failureMessage:
138156
order.failureMessage ??
@@ -141,7 +159,20 @@ export async function restockOrder(
141159
restockedAt: now,
142160
updatedAt: now,
143161
})
144-
.where(eq(orders.id, orderId));
162+
.where(and(eq(orders.id, orderId), eq(orders.stockRestored, false)))
163+
.returning({ id: orders.id });
164+
165+
if (!touched) return;
166+
167+
// paymentStatus transition only via guard (provider from DB)
168+
await guardedPaymentStatusUpdate({
169+
orderId,
170+
paymentProvider: provider,
171+
to: 'failed',
172+
source: transitionSource,
173+
extraWhere: eq(orders.restockedAt, now),
174+
});
175+
145176
return;
146177
}
147178

@@ -195,19 +226,30 @@ export async function restockOrder(
195226
if (!finalized) return;
196227

197228
let normalizedStatus: PaymentStatus | undefined;
198-
if (reason === 'refunded') normalizedStatus = 'refunded';
229+
if (reason === 'refunded' && !isNoPayment) normalizedStatus = 'refunded';
199230
else if (reason === 'failed' || reason === 'canceled' || reason === 'stale')
200231
normalizedStatus = 'failed';
232+
201233
const shouldCancel = reason === 'canceled';
202234
const shouldFail = reason === 'failed' || reason === 'stale';
203235
await db
204236
.update(orders)
205237
.set({
206238
inventoryStatus: 'released',
207239
updatedAt: now,
208-
...(normalizedStatus ? { paymentStatus: normalizedStatus } : {}),
209240
...(shouldFail ? { status: 'INVENTORY_FAILED' } : {}),
210241
...(shouldCancel ? { status: 'CANCELED' } : {}),
211242
})
212243
.where(eq(orders.id, orderId));
244+
245+
if (normalizedStatus) {
246+
await guardedPaymentStatusUpdate({
247+
orderId,
248+
paymentProvider: provider,
249+
to: normalizedStatus,
250+
source: transitionSource,
251+
// bind to finalize-once marker
252+
extraWhere: eq(orders.restockedAt, finalizedAt),
253+
});
254+
}
213255
}

0 commit comments

Comments
 (0)