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
225export 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}
0 commit comments