Skip to content

Commit 699348f

Browse files
committed
refactor: production-grade service layer architecture
- New /services/productService.ts — all product read logic, 5-min cache - New /services/orderService.ts — all order CRUD + Stripe refunds - New /services/checkoutService.ts — cart validation, pricing lock, Stripe session - New /types/product.ts — shared Product, ProductCard, CartItem types - Updated /types/order.ts — full OrderStatus/FulfillmentStatus unions - New /lib/api/errorHandler.ts — withErrorHandler wrapper + ApiError class - New /lib/api/rateLimit.ts — in-memory per-IP rate limiter - Refactored /api/products route.ts — thin controller (delegates to productService) - Refactored /api/products/[id] route.ts — thin controller - Refactored /api/checkout route.ts — thin controller + rate limiting - Refactored /api/orders route.ts — thin controller + auth guard - Build verified: Exit code 0, all routes intact
1 parent 2ae773c commit 699348f

File tree

11 files changed

+911
-444
lines changed

11 files changed

+911
-444
lines changed

app/api/checkout/route.ts

Lines changed: 22 additions & 260 deletions
Original file line numberDiff line numberDiff line change
@@ -1,264 +1,26 @@
1-
import { NextResponse } from "next/server";
2-
import Stripe from "stripe";
3-
import { createClient as createAdminClient } from "@/lib/supabase/admin";
4-
5-
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
6-
apiVersion: "2026-02-25.clover",
7-
});
8-
9-
// Countries where we ship internationally
10-
const INTL_COUNTRIES = [
11-
"CA","GB","AU","NZ","FR","DE","ES","IT","NL","BE","SE","NO","DK","FI",
12-
"JP","KR","SG","MY","PH","TH","VN","IN","AE","SA","MX","BR","ZA","PL","TR",
13-
] as Stripe.Checkout.SessionCreateParams.ShippingAddressCollection.AllowedCountry[];
14-
15-
const ALL_COUNTRIES = ["US", ...INTL_COUNTRIES] as Stripe.Checkout.SessionCreateParams.ShippingAddressCollection.AllowedCountry[];
16-
17-
// ─── Caching ──────────────────────────────────────────────────────────
18-
let cachedShippingConfig: any = null;
19-
let cacheTimestamp = 0;
20-
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
1+
import { NextResponse } from 'next/server';
2+
import { createCheckoutSession } from '@/services/checkoutService';
3+
import { checkoutLimiter } from '@/lib/api/rateLimit';
214

225
export async function POST(req: Request) {
23-
try {
24-
const { items } = await req.json();
25-
26-
if (!items || items.length === 0) {
27-
return NextResponse.json({ error: "Cart is empty" }, { status: 400 });
28-
}
29-
30-
const supabase = await createAdminClient();
31-
32-
// ─── Fetch & validate products ───────────────────────────────────────
33-
const itemIds = items.map((i: any) => i.productId);
34-
const { data: dbProducts } = await supabase
35-
.from("products")
36-
.select("id, title, base_price, sale_price, on_sale, stock, weight_oz")
37-
.in("id", itemIds);
38-
39-
if (!dbProducts || dbProducts.length === 0) {
40-
return NextResponse.json({ error: "Some products are invalid or unavailable" }, { status: 400 });
41-
}
42-
43-
const variantIds = items.filter((i: any) => i.variantId).map((i: any) => i.variantId);
44-
let dbVariants: any[] = [];
45-
if (variantIds.length > 0) {
46-
const { data } = await supabase
47-
.from("product_variants")
48-
.select("id, name, price_override, weight")
49-
.in("id", variantIds);
50-
dbVariants = data || [];
51-
}
52-
53-
// ─── Lock prices & weight server-side ───────────────────────────────
54-
const validatedItems = items.map((item: any) => {
55-
const product = dbProducts.find((p: any) => p.id === item.productId);
56-
if (!product) throw new Error("Invalid product");
57-
58-
if (product.stock < item.quantity) {
59-
throw new Error(`Insufficient stock for ${product.title}`);
60-
}
61-
62-
const variant = dbVariants.find((v: any) => v.id === item.variantId);
63-
64-
let price = product.base_price || 0;
65-
if (variant?.price_override != null) price = variant.price_override;
66-
if (product.on_sale && product.sale_price != null) price = product.sale_price;
67-
68-
const name = variant?.name ? `${product.title}${variant.name}` : product.title;
69-
70-
return {
71-
...item,
72-
name,
73-
price: Number(price),
74-
variant_weight_oz: variant?.weight ? Number(variant.weight) : null,
75-
product_weight_oz: product?.weight_oz ? Number(product.weight_oz) : null,
76-
};
77-
});
78-
79-
// ─── Compute weight & subtotal ───────────────────────────────────────
80-
const { calculateTotalWeightLb, calculateShippingRate } = await import("@/lib/utils/shippo");
81-
const totalWeightLb = calculateTotalWeightLb(validatedItems);
82-
const subtotal = validatedItems.reduce(
83-
(acc: number, item: any) => acc + item.price * item.quantity,
84-
0
85-
);
86-
87-
// ─── Load shipping config (cached) ──────────────────────────────────
88-
const now = Date.now();
89-
if (!cachedShippingConfig || (now - cacheTimestamp) > CACHE_TTL) {
90-
const { data: shippingConfig } = await supabase
91-
.from("site_settings")
92-
.select("setting_value")
93-
.eq("setting_key", "shipping_settings")
94-
.maybeSingle();
95-
96-
cachedShippingConfig = shippingConfig?.setting_value || {};
97-
cacheTimestamp = now;
98-
}
99-
100-
const cfg = cachedShippingConfig;
101-
const isFree = subtotal >= parseFloat(cfg.free_shipping_threshold ?? "100");
102-
103-
// Calculate all 4 options based on actual weight
104-
const stdRate = calculateShippingRate(totalWeightLb, subtotal, cfg, "standard");
105-
const expRate = calculateShippingRate(totalWeightLb, subtotal, cfg, "express");
106-
const intlStdRate = calculateShippingRate(totalWeightLb, subtotal, cfg, "intl_standard");
107-
const intlExpRate = calculateShippingRate(totalWeightLb, subtotal, cfg, "intl_express");
108-
109-
const shippingOptions: Stripe.Checkout.SessionCreateParams.ShippingOption[] = [
110-
// Option 1: US Domestic Standard (or FREE if threshold met)
111-
{
112-
shipping_rate_data: {
113-
type: "fixed_amount",
114-
fixed_amount: {
115-
amount: Math.round(stdRate.cost * 100),
116-
currency: "usd",
117-
},
118-
display_name: stdRate.name,
119-
delivery_estimate: {
120-
minimum: { unit: "business_day", value: stdRate.minDays },
121-
maximum: { unit: "business_day", value: stdRate.maxDays },
122-
},
123-
},
124-
},
125-
// Option 2: US Domestic Express
126-
{
127-
shipping_rate_data: {
128-
type: "fixed_amount",
129-
fixed_amount: {
130-
amount: Math.round(expRate.cost * 100),
131-
currency: "usd",
132-
},
133-
display_name: expRate.name,
134-
delivery_estimate: {
135-
minimum: { unit: "business_day", value: expRate.minDays },
136-
maximum: { unit: "business_day", value: expRate.maxDays },
137-
},
138-
},
139-
},
140-
// Option 3: International Standard
141-
{
142-
shipping_rate_data: {
143-
type: "fixed_amount",
144-
fixed_amount: {
145-
amount: Math.round(intlStdRate.cost * 100),
146-
currency: "usd",
147-
},
148-
display_name: intlStdRate.name,
149-
delivery_estimate: {
150-
minimum: { unit: "business_day", value: intlStdRate.minDays },
151-
maximum: { unit: "business_day", value: intlStdRate.maxDays },
152-
},
153-
},
154-
},
155-
// Option 4: International Express
156-
{
157-
shipping_rate_data: {
158-
type: "fixed_amount",
159-
fixed_amount: {
160-
amount: Math.round(intlExpRate.cost * 100),
161-
currency: "usd",
162-
},
163-
display_name: intlExpRate.name,
164-
delivery_estimate: {
165-
minimum: { unit: "business_day", value: intlExpRate.minDays },
166-
maximum: { unit: "business_day", value: intlExpRate.maxDays },
167-
},
168-
},
169-
},
170-
];
171-
172-
// ─── Create pending order (no email yet – webhook will fill it) ──────
173-
const estimatedTotal = subtotal + stdRate.cost; // worst case; webhook corrects it
174-
const { data: order, error: orderError } = await supabase
175-
.from("orders")
176-
.insert({
177-
status: "pending",
178-
customer_email: "pending@stripe",
179-
amount_total: estimatedTotal,
180-
shipping_address: null,
181-
metadata: {
182-
weight_lb: totalWeightLb.toFixed(3),
183-
subtotal: subtotal.toFixed(2),
184-
is_free_shipping: isFree,
185-
},
186-
})
187-
.select("id")
188-
.single();
189-
190-
if (orderError) {
191-
console.error("Order creation failed:", orderError);
192-
return NextResponse.json({ error: "Order creation failed" }, { status: 500 });
193-
}
194-
195-
// ─── Stripe line items ───────────────────────────────────────────────
196-
const lineItems = validatedItems.map((item: any) => ({
197-
price_data: {
198-
currency: "usd",
199-
product_data: {
200-
name: item.name,
201-
metadata: {
202-
product_id: item.productId,
203-
variant_id: item.variantId || "",
204-
},
205-
},
206-
unit_amount: Math.round(item.price * 100),
207-
},
208-
quantity: item.quantity,
209-
}));
210-
211-
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://dinacosmetic.store";
212-
213-
// ─── Create Stripe session ───────────────────────────────────────────
214-
const session = await stripe.checkout.sessions.create(
215-
{
216-
payment_method_types: ["card"],
217-
mode: "payment",
218-
line_items: lineItems,
219-
220-
// Customer enters email, name, shipping address ONCE inside Stripe
221-
customer_creation: "always",
222-
billing_address_collection: "auto",
223-
shipping_address_collection: {
224-
allowed_countries: ALL_COUNTRIES,
225-
},
226-
phone_number_collection: { enabled: true },
227-
228-
// Stripe shows ALL options — customer picks the one that fits their location
229-
shipping_options: shippingOptions,
230-
231-
success_url: `${SITE_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
232-
cancel_url: `${SITE_URL}/shop`,
233-
234-
automatic_tax: { enabled: true },
235-
expires_at: Math.floor(Date.now() / 1000) + 1800, // 30 min
236-
237-
metadata: {
238-
order_id: order.id,
239-
subtotal: subtotal.toFixed(2),
240-
weight_lb: totalWeightLb.toFixed(3),
241-
items: JSON.stringify(
242-
validatedItems.map((i: any) => ({
243-
product_id: i.productId,
244-
variant_id: i.variantId || null,
245-
quantity: i.quantity,
246-
price: i.price,
247-
}))
248-
),
249-
},
250-
},
251-
{
252-
idempotencyKey: `checkout_${order.id}`,
253-
}
254-
);
255-
256-
return NextResponse.json({ url: session.url });
257-
} catch (error: any) {
258-
console.error("Checkout error:", error);
259-
return NextResponse.json(
260-
{ error: error.message || "Internal error" },
261-
{ status: 500 }
262-
);
6+
// Rate limit: max 10 checkout attempts per IP per minute
7+
const { success } = checkoutLimiter.check(req);
8+
if (!success) {
9+
return NextResponse.json({ error: 'Too many requests. Please wait before trying again.' }, { status: 429 });
10+
}
11+
12+
try {
13+
const body = await req.json();
14+
const { items } = body;
15+
16+
if (!items || !Array.isArray(items) || items.length === 0) {
17+
return NextResponse.json({ error: 'Cart is empty' }, { status: 400 });
26318
}
19+
20+
const { url } = await createCheckoutSession(items);
21+
return NextResponse.json({ url });
22+
} catch (err: any) {
23+
console.error('[POST /api/checkout]', err.message);
24+
return NextResponse.json({ error: err.message || 'Internal error' }, { status: 500 });
25+
}
26426
}

app/api/orders/route.ts

Lines changed: 49 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,49 @@
1-
import { NextResponse } from 'next/server';
2-
import { createClient } from '@/lib/supabase/server';
3-
4-
export async function POST(request: Request) {
5-
try {
6-
const payload = await request.json();
7-
const { customer_name, email, phone, address, total_amount, items } = payload;
8-
9-
const supabase = await createClient();
10-
11-
// Basic insertion, since production orders generally verify against Stripe webhooks.
12-
// This endpoint supports the prompt's minimum flow requirement.
13-
const { data: order, error } = await supabase
14-
.from('orders')
15-
.insert({
16-
customer_email: email,
17-
amount_total: total_amount,
18-
status: 'pending'
19-
})
20-
.select()
21-
.single();
22-
23-
if (error) throw error;
24-
25-
return NextResponse.json({
26-
id: order.id,
27-
customer_name,
28-
email,
29-
phone,
30-
address,
31-
amount_total: order.amount_total,
32-
status: order.status,
33-
created_at: order.created_at
34-
});
35-
36-
} catch (error) {
37-
return NextResponse.json({ error: 'Failed to create order' }, { status: 500 });
38-
}
39-
}
1+
import { NextResponse } from 'next/server';
2+
import { createClient } from '@/lib/supabase/server';
3+
import { listOrders, getOrderById } from '@/services/orderService';
4+
5+
export async function GET(req: Request) {
6+
try {
7+
const supabase = await createClient();
8+
const { data: { user } } = await supabase.auth.getUser();
9+
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
10+
11+
// Check admin role
12+
const { data: profile } = await supabase.from('profiles').select('role').eq('id', user.id).single();
13+
if (profile?.role !== 'admin') return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
14+
15+
const { searchParams } = new URL(req.url);
16+
const orders = await listOrders({
17+
status: searchParams.get('status') ?? undefined,
18+
search: searchParams.get('q') ?? undefined,
19+
limit: Number(searchParams.get('limit') ?? 50),
20+
offset: Number(searchParams.get('offset') ?? 0),
21+
});
22+
23+
return NextResponse.json(orders);
24+
} catch (err: any) {
25+
console.error('[GET /api/orders]', err);
26+
return NextResponse.json({ error: 'Failed to fetch orders' }, { status: 500 });
27+
}
28+
}
29+
30+
// POST remains for any direct order creation needs (rare — Stripe webhook handles production flow)
31+
export async function POST(req: Request) {
32+
try {
33+
const { email, total_amount } = await req.json();
34+
if (!email) return NextResponse.json({ error: 'email is required' }, { status: 400 });
35+
36+
const supabase = await createClient();
37+
const { data: order, error } = await supabase
38+
.from('orders')
39+
.insert({ customer_email: email, amount_total: total_amount ?? 0, status: 'pending' })
40+
.select()
41+
.single();
42+
43+
if (error) throw error;
44+
return NextResponse.json(order, { status: 201 });
45+
} catch (err: any) {
46+
console.error('[POST /api/orders]', err);
47+
return NextResponse.json({ error: 'Failed to create order' }, { status: 500 });
48+
}
49+
}

0 commit comments

Comments
 (0)