Skip to content

Commit 9d89d65

Browse files
(SP: 1) [Checkout] Harden contracts: PATCH price merge validation, cross-order PI guard, payable-state gating; cleanup dead code; add provider CHECK; inline ensureStripePI
1 parent a43ec61 commit 9d89d65

16 files changed

Lines changed: 334 additions & 143 deletions

frontend/app/[locale]/shop/checkout/payment/[orderId]/page.tsx

Lines changed: 0 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -163,54 +163,6 @@ export default async function PaymentPage(props: PaymentPageProps) {
163163
let clientSecret = resolveClientSecret(searchParams);
164164
const publishableKey = paymentsEnabled ? stripeEnv.publishableKey : null;
165165

166-
// Ensure we have a clientSecret even when URL doesn't include ?clientSecret=...
167-
// Source of truth for payment finality is webhook; this only initializes Elements.
168-
// if (
169-
// paymentsEnabled &&
170-
// publishableKey &&
171-
// (!clientSecret || !clientSecret.trim())
172-
// ) {
173-
// const existingPi = order.paymentIntentId?.trim() ?? '';
174-
// let phase:
175-
// | 'retrievePaymentIntent'
176-
// | 'createPaymentIntent'
177-
// | 'setOrderPaymentIntent'
178-
// | 'unknown' = 'unknown';
179-
180-
// try {
181-
// if (existingPi) {
182-
// phase = 'retrievePaymentIntent';
183-
// const retrieved = await retrievePaymentIntent(existingPi);
184-
// clientSecret = retrieved.clientSecret;
185-
// } else {
186-
// phase = 'createPaymentIntent';
187-
// const snapshot = await readStripePaymentIntentParams(order.id);
188-
// const created = await createPaymentIntent({
189-
// amount: snapshot.amountMinor,
190-
// currency: snapshot.currency,
191-
// orderId: order.id,
192-
// idempotencyKey: `pi:${order.id}`,
193-
// });
194-
195-
// phase = 'setOrderPaymentIntent';
196-
// await setOrderPaymentIntent({
197-
// orderId: order.id,
198-
// paymentIntentId: created.paymentIntentId,
199-
// });
200-
201-
// clientSecret = created.clientSecret;
202-
// }
203-
// } catch (error) {
204-
// logError('payment_page_failed', error, {
205-
// orderId: order.id,
206-
// existingPi,
207-
// phase,
208-
// });
209-
210-
// // Leave clientSecret empty -> UI shows "Payment cannot be initialized"
211-
// }
212-
// }
213-
214166
if (
215167
paymentsEnabled &&
216168
publishableKey &&

frontend/app/api/shop/admin/products/[id]/route.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,14 @@ import {
77
AdminUnauthorizedError,
88
requireAdminApi,
99
} from '@/lib/auth/admin';
10+
import {
11+
InvalidPayloadError,
12+
SlugConflictError,
13+
PriceConfigError,
14+
} from '@/lib/services/errors';
1015

1116
import { parseAdminProductForm } from '@/lib/admin/parseAdminProductForm';
1217
import { logError } from '@/lib/logging';
13-
import { InvalidPayloadError, SlugConflictError } from '@/lib/services/errors';
1418
import {
1519
deleteProduct,
1620
getAdminProductByIdWithPrices,
@@ -217,6 +221,19 @@ export async function PATCH(
217221
} catch (error) {
218222
logError('Failed to update product', error);
219223

224+
if (error instanceof PriceConfigError) {
225+
return NextResponse.json(
226+
{
227+
error: error.message,
228+
code: error.code, // PRICE_CONFIG_ERROR
229+
productId: error.productId,
230+
currency: error.currency,
231+
field: 'prices',
232+
},
233+
{ status: 400 }
234+
);
235+
}
236+
220237
if (error instanceof InvalidPayloadError) {
221238
const anyErr = error as any;
222239
return NextResponse.json(

frontend/app/api/shop/checkout/route.ts

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -292,26 +292,16 @@ export async function POST(request: NextRequest) {
292292
const stripePaymentFlow =
293293
paymentsEnabled && order.paymentProvider === 'stripe';
294294

295-
async function ensureStripePI(
296-
orderId: string,
297-
existingPaymentIntentId: string | null
298-
) {
299-
return await ensureStripePaymentIntentForOrder({
300-
orderId,
301-
existingPaymentIntentId,
302-
});
303-
}
304-
305295
// =========================
306296
// Existing order path
307297
// =========================
308298
if (!result.isNew) {
309299
if (stripePaymentFlow) {
310300
try {
311-
const ensured = await ensureStripePI(
312-
order.id,
313-
order.paymentIntentId ?? null
314-
);
301+
const ensured = await ensureStripePaymentIntentForOrder({
302+
orderId: order.id,
303+
existingPaymentIntentId: order.paymentIntentId ?? null,
304+
});
315305

316306
return buildCheckoutResponse({
317307
order: {
@@ -408,10 +398,10 @@ export async function POST(request: NextRequest) {
408398

409399
// Stripe new order: durable attempt layer (bounded + audited)
410400
try {
411-
const ensured = await ensureStripePI(
412-
order.id,
413-
order.paymentIntentId ?? null
414-
);
401+
const ensured = await ensureStripePaymentIntentForOrder({
402+
orderId: order.id,
403+
existingPaymentIntentId: order.paymentIntentId ?? null,
404+
});
415405

416406
return buildCheckoutResponse({
417407
order: {

frontend/drizzle/0001_add_payment_attempts.sql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ CREATE TABLE "payment_attempts" (
1212
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
1313
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
1414
"finalized_at" timestamp with time zone,
15+
CONSTRAINT "payment_attempts_provider_check" CHECK ("payment_attempts"."provider" in ('stripe')),
1516
CONSTRAINT "payment_attempts_status_check" CHECK ("payment_attempts"."status" in ('active','succeeded','failed','canceled')),
1617
CONSTRAINT "payment_attempts_attempt_number_check" CHECK ("payment_attempts"."attempt_number" >= 1)
1718
);

frontend/lib/psp/stripe.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,20 @@ type CreateRefundInput = {
2020
let _stripe: Stripe | null = null;
2121
let _stripeKey: string | null = null;
2222

23+
class StripePspError extends Error {
24+
readonly code: string;
25+
readonly cause: unknown;
26+
27+
constructor(code: string, cause: unknown) {
28+
super(code);
29+
this.name = 'StripePspError';
30+
this.code = code;
31+
this.cause = cause;
32+
}
33+
}
34+
2335
function withCause(code: string, cause: unknown): Error {
24-
const err = new Error(code);
25-
// Preserve original error for logs/diagnostics without relying on ES2022 ErrorOptions.
26-
(err as any).cause = cause;
27-
return err;
36+
return new StripePspError(code, cause);
2837
}
2938

3039
export async function createRefund({

frontend/lib/services/errors.ts

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,12 @@ export class OrderNotFoundError extends Error {
3535
// code = "SLUG_CONFLICT" as const
3636
// }
3737
export class InvalidPayloadError extends Error {
38-
code = 'INVALID_PAYLOAD' as const;
39-
constructor(message = 'Invalid payload') {
38+
code: string;
39+
40+
constructor(message = 'Invalid payload', opts?: { code?: string }) {
4041
super(message);
4142
this.name = 'InvalidPayloadError';
43+
this.code = opts?.code ?? 'INVALID_PAYLOAD';
4244
}
4345
}
4446

@@ -91,26 +93,27 @@ export class SlugConflictError extends Error {
9193
}
9294

9395
export class OrderStateInvalidError extends Error {
94-
code = 'ORDER_STATE_INVALID' as const;
95-
orderId?: string;
96-
field?: string;
97-
rawValue?: unknown;
98-
details?: Record<string, unknown>;
96+
readonly code = 'ORDER_STATE_INVALID' as const;
97+
98+
readonly orderId?: string;
99+
readonly field?: string;
100+
readonly rawValue?: unknown;
101+
readonly details?: unknown;
99102

100103
constructor(
101-
message = 'Order state is invalid.',
102-
options?: {
104+
message: string,
105+
opts?: {
103106
orderId?: string;
104107
field?: string;
105108
rawValue?: unknown;
106-
details?: Record<string, unknown>;
109+
details?: unknown;
107110
}
108111
) {
109112
super(message);
110113
this.name = 'OrderStateInvalidError';
111-
this.orderId = options?.orderId;
112-
this.field = options?.field;
113-
this.rawValue = options?.rawValue;
114-
this.details = options?.details;
114+
this.orderId = opts?.orderId;
115+
this.field = opts?.field;
116+
this.rawValue = opts?.rawValue;
117+
this.details = opts?.details;
115118
}
116119
}

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { readStripePaymentIntentParams } from '@/lib/services/orders/payment-int
88
import { createPaymentIntent, retrievePaymentIntent } from '@/lib/psp/stripe';
99
import { setOrderPaymentIntent } from '@/lib/services/orders';
1010
import { logError } from '@/lib/logging';
11+
import { OrderStateInvalidError } from '@/lib/services/errors';
1112

1213
export type PaymentProvider = 'stripe';
1314
export type PaymentAttemptStatus =
@@ -125,7 +126,27 @@ async function upsertBackfillAttemptForExistingPI(args: {
125126
)
126127
.limit(1);
127128

128-
if (found[0]) return found[0];
129+
const existingAttempt = found[0] ?? null;
130+
if (existingAttempt) {
131+
// Guard: never reuse attempt that belongs to a different order (cross-order PI reuse).
132+
if (existingAttempt.orderId === orderId) return existingAttempt;
133+
134+
// Fail-closed: PI is already linked to another order. Do NOT create a new attempt
135+
// that would bind this PI to a second order.
136+
throw new OrderStateInvalidError(
137+
'PaymentIntent is already associated with a different order.',
138+
{
139+
orderId,
140+
field: 'providerPaymentIntentId',
141+
rawValue: paymentIntentId,
142+
details: {
143+
provider,
144+
paymentIntentId,
145+
existingOrderId: existingAttempt.orderId,
146+
},
147+
}
148+
);
149+
}
129150

130151
const max = await getMaxAttemptNumber(orderId, provider);
131152
const next = max + 1;

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

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -110,23 +110,39 @@ export async function readStripePaymentIntentParams(orderId: string): Promise<{
110110
);
111111
}
112112

113+
// Payable-state gate: fail-closed for paid/failed/refunded/canceled/etc.
114+
const allowed: PaymentStatus[] = ['pending', 'requires_payment'];
115+
if (!allowed.includes(existing.paymentStatus as PaymentStatus)) {
116+
throw new OrderStateInvalidError(
117+
'Order is not payable; Stripe PaymentIntent initialization is not allowed in the current state.',
118+
{
119+
orderId,
120+
field: 'paymentStatus',
121+
rawValue: existing.paymentStatus,
122+
details: {
123+
allowed,
124+
provider,
125+
paymentIntentId: existing.paymentIntentId ?? null,
126+
},
127+
}
128+
);
129+
}
130+
113131
const amountMinor = existing.totalAmountMinor;
114132

115133
// Canonical money source = DB minor units. Fail-closed on invalid totals.
116134
if (!Number.isSafeInteger(amountMinor) || amountMinor <= 0) {
117-
const err = new OrderStateInvalidError(
118-
'Invalid order total for Stripe payment intent creation.'
135+
throw new OrderStateInvalidError(
136+
'Invalid order total for Stripe payment intent creation.',
137+
{
138+
orderId,
139+
field: 'totalAmountMinor',
140+
rawValue: amountMinor,
141+
details: {
142+
reason: 'Invalid order total for Stripe payment intent creation.',
143+
},
144+
}
119145
);
120-
121-
// attach diagnostics for API handler (keeps existing errorResponse shape)
122-
(err as any).orderId = orderId;
123-
(err as any).field = 'totalAmountMinor';
124-
(err as any).rawValue = amountMinor;
125-
(err as any).details = {
126-
reason: 'Invalid order total for Stripe payment intent creation.',
127-
};
128-
129-
throw err;
130146
}
131147

132148
return { amountMinor, currency: existing.currency };

frontend/lib/services/orders/refund.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,7 @@ import {
1111
} from './psp-metadata/refunds';
1212

1313
function invalid(code: string, message: string): InvalidPayloadError {
14-
const err = new InvalidPayloadError(message);
15-
(err as any).code = code;
16-
return err;
14+
return new InvalidPayloadError(message, { code });
1715
}
1816

1917
function makeRefundIdempotencyKey(

frontend/lib/services/products/mutations/update.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
// frontend/lib/services/products/mutations/update.ts
21
import { eq, sql } from 'drizzle-orm';
3-
42
import {
53
destroyProductImage,
64
uploadProductImageFromFile,
@@ -17,6 +15,7 @@ import { mapRowToProduct } from '../mapping';
1715
import { normalizeSlug } from '../slug';
1816
import {
1917
assertMoneyMinorInt,
18+
assertMergedPricesPolicy,
2019
enforceSaleBadgeRequiresOriginal,
2120
normalizePricesFromInput,
2221
validatePriceRows,
@@ -56,10 +55,14 @@ export async function updateProduct(
5655

5756
const prices = normalizePricesFromInput(input);
5857
if (prices.length) validatePriceRows(prices);
59-
// Enforce SALE invariant against FINAL state (DB rows + incoming upserts)
58+
6059
const finalBadge = (input as any).badge ?? existing.badge;
6160

62-
if (finalBadge === 'SALE') {
61+
// Enforce merged-state invariants (DB rows + incoming upserts)
62+
// - If prices are patched, validate merged currency policy (e.g. USD must exist)
63+
// - If final badge is SALE, enforce originalPrice for ALL currencies in merged state
64+
65+
if (prices.length || finalBadge === 'SALE') {
6366
const existingPriceRows = await db
6467
.select({
6568
currency: productPrices.currency,
@@ -92,7 +95,18 @@ export async function updateProduct(
9295
merged.set(p.currency, p);
9396
}
9497

95-
enforceSaleBadgeRequiresOriginal('SALE', Array.from(merged.values()));
98+
const mergedRows = Array.from(merged.values());
99+
100+
// Currency-set policy should be enforced on merged state ONLY when prices are patched.
101+
// This keeps PATCH semantics: partial prices payload is allowed, and policy is checked post-merge.
102+
if (prices.length) {
103+
assertMergedPricesPolicy(mergedRows, { productId: id, requireUsd: true });
104+
}
105+
106+
// SALE invariant must be enforced on merged state when badge is SALE (even if prices are not patched).
107+
if (finalBadge === 'SALE') {
108+
enforceSaleBadgeRequiresOriginal('SALE', mergedRows);
109+
}
96110
}
97111

98112
// Base fields update
@@ -138,7 +152,7 @@ export async function updateProduct(
138152
}
139153

140154
try {
141-
// 1) upsert prices
155+
// 1) upsert prices
142156
if (prices.length) {
143157
const upsertRows = prices.map(p => {
144158
const priceMinor = p.priceMinor;

0 commit comments

Comments
 (0)