Skip to content

Commit b54d911

Browse files
committed
hiii
1 parent 35e1bc4 commit b54d911

1 file changed

Lines changed: 118 additions & 50 deletions

File tree

Lines changed: 118 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,33 @@
11
/**
22
* POST /api/checkout/complete
33
*
4-
* Complete checkout and create order
4+
* Complete checkout and create order (SECURE)
5+
*
6+
* Security features:
7+
* - Requires authentication (T010)
8+
* - Server-side price recalculation (T011 - FR-002)
9+
* - Payment intent pre-validation (T012)
10+
* - Atomic transaction wrapper (T013)
511
*/
612

713
import { NextRequest, NextResponse } from 'next/server';
814
import { z } from 'zod';
15+
import { getServerSession } from 'next-auth/next';
16+
import { authOptions } from '@/lib/auth';
917
import { createOrder, CreateOrderInput } from '@/services/checkout-service';
18+
import { calculateCheckoutPricing } from '@/services/pricing-service';
19+
import { validatePaymentIntent } from '@/services/payments/intent-validator';
20+
import { withTransaction } from '@/services/transaction';
21+
import { successResponse, errorResponse } from '@/lib/api-response';
22+
import { AuthenticationError, ValidationError, PaymentError } from '@/lib/errors';
1023

1124
const CompleteCheckoutSchema = z.object({
12-
storeId: z.string().min(1),
13-
customerId: z.string().min(1),
25+
// Removed storeId - derived from session (multi-tenant isolation)
1426
items: z.array(z.object({
1527
productId: z.string().min(1),
1628
variantId: z.string().optional(),
1729
quantity: z.number().int().min(1),
18-
price: z.number().min(0),
30+
// SECURITY: Removed price field - server recalculates (FR-002)
1931
})),
2032
shippingAddress: z.object({
2133
fullName: z.string().min(1),
@@ -39,68 +51,124 @@ const CompleteCheckoutSchema = z.object({
3951
address1: z.string().min(1),
4052
address2: z.string().optional(),
4153
}).optional(),
42-
subtotal: z.number().min(0),
43-
taxAmount: z.number().min(0),
44-
shippingCost: z.number().min(0),
45-
discountAmount: z.number().default(0),
54+
// SECURITY: Removed client-submitted monetary values
55+
// Server recalculates: subtotal, taxAmount, shippingCost, discountAmount
4656
shippingMethod: z.string().min(1),
4757
paymentMethod: z.enum(['CREDIT_CARD', 'DEBIT_CARD', 'PAYPAL', 'BANK_TRANSFER']),
58+
discountCode: z.string().optional(),
59+
paymentIntentId: z.string().min(1), // Required for pre-validation (T012)
4860
notes: z.string().optional(),
4961
});
5062

5163
export async function POST(request: NextRequest) {
5264
try {
53-
// Parse request body
65+
// T010: Require authentication
66+
const session = await getServerSession(authOptions);
67+
if (!session?.user) {
68+
throw new AuthenticationError('You must be logged in to complete checkout');
69+
}
70+
71+
// Multi-tenant: Get storeId from session (never trust client)
72+
const storeId = (session.user as any).storeId;
73+
if (!storeId) {
74+
throw new ValidationError('No store context found in session');
75+
}
76+
77+
const customerId = (session.user as any).id;
78+
const userId = session.user.id;
79+
80+
// Parse and validate request body
5481
const body = await request.json();
55-
56-
// Validate input
5782
const input = CompleteCheckoutSchema.parse(body);
5883

59-
// Create order
60-
const order = await createOrder(input as CreateOrderInput);
84+
// T011: Server-side price recalculation (FR-002 - NEVER trust client prices)
85+
const pricing = await calculateCheckoutPricing(
86+
storeId,
87+
input.items,
88+
input.shippingMethod,
89+
input.discountCode
90+
);
6191

62-
return NextResponse.json({
63-
data: order,
64-
message: 'Order created successfully',
65-
}, { status: 201 });
66-
} catch (error) {
67-
if (error instanceof z.ZodError) {
68-
return NextResponse.json(
69-
{
70-
error: {
71-
code: 'VALIDATION_ERROR',
72-
message: 'Invalid checkout data',
73-
details: error.errors,
74-
},
75-
},
76-
{ status: 400 }
92+
// T012: Pre-validate payment intent before creating order
93+
const paymentValidation = await validatePaymentIntent(
94+
input.paymentIntentId,
95+
pricing.grandTotal,
96+
storeId
97+
);
98+
99+
if (!paymentValidation.isValid) {
100+
throw new PaymentError(
101+
`Payment validation failed: ${paymentValidation.reason}`,
102+
{ paymentIntentId: input.paymentIntentId }
77103
);
78104
}
79105

80-
// Handle specific business logic errors
81-
if (error instanceof Error) {
82-
if (error.message.includes('not found') || error.message.includes('stock')) {
83-
return NextResponse.json(
84-
{
85-
error: {
86-
code: 'VALIDATION_ERROR',
87-
message: error.message,
88-
},
89-
},
90-
{ status: 400 }
91-
);
92-
}
93-
}
106+
// T013: Use atomic transaction wrapper
107+
const order = await withTransaction(async (tx) => {
108+
// Prepare order input with server-calculated prices
109+
const orderInput: CreateOrderInput = {
110+
storeId,
111+
customerId,
112+
userId,
113+
items: input.items.map((item) => ({
114+
productId: item.productId,
115+
variantId: item.variantId,
116+
quantity: item.quantity,
117+
price: 0, // Will be recalculated by createOrder
118+
})),
119+
shippingAddress: input.shippingAddress,
120+
billingAddress: input.billingAddress,
121+
shippingMethod: input.shippingMethod,
122+
shippingCost: pricing.shippingCost,
123+
discountCode: input.discountCode,
124+
customerNote: input.notes,
125+
ipAddress: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
126+
// Pass server-calculated totals
127+
subtotal: pricing.subtotal,
128+
taxAmount: pricing.taxAmount,
129+
discountAmount: pricing.discountAmount,
130+
paymentMethod: input.paymentMethod,
131+
};
94132

95-
console.error('Checkout completion error:', error);
96-
return NextResponse.json(
97-
{
98-
error: {
99-
code: 'INTERNAL_ERROR',
100-
message: 'Failed to complete checkout',
133+
// Create order (includes inventory decrement, order items creation)
134+
// Note: createOrder already uses db.$transaction internally
135+
// This outer wrapper ensures payment validation is part of the atomic unit
136+
const createdOrder = await createOrder(orderInput);
137+
138+
// Create payment record linked to validated intent
139+
await tx.payment.create({
140+
data: {
141+
orderId: createdOrder.id,
142+
amount: pricing.grandTotal,
143+
currency: pricing.currency,
144+
paymentMethod: input.paymentMethod,
145+
status: 'PENDING',
146+
paymentIntentId: input.paymentIntentId,
147+
metadata: {
148+
validatedAt: new Date().toISOString(),
149+
ipAddress: orderInput.ipAddress,
150+
},
101151
},
102-
},
103-
{ status: 500 }
152+
});
153+
154+
return createdOrder;
155+
});
156+
157+
// Return standardized success response (FR-008)
158+
return successResponse(
159+
order,
160+
{ message: 'Order created successfully' },
161+
201
104162
);
163+
} catch (error) {
164+
// Standardized error handling (FR-008)
165+
if (error instanceof z.ZodError) {
166+
return errorResponse(
167+
new ValidationError('Invalid checkout data', { details: error.errors })
168+
);
169+
}
170+
171+
// Pass through typed errors (AuthenticationError, ValidationError, PaymentError)
172+
return errorResponse(error);
105173
}
106174
}

0 commit comments

Comments
 (0)