@@ -4,7 +4,7 @@ import { NextResponse } from "next/server";
44import { createClient as createAdminClient } from "@/lib/supabase/admin" ;
55
66const stripe = new Stripe ( process . env . STRIPE_SECRET_KEY ! , {
7- apiVersion : "2026-02-25 .clover" ,
7+ apiVersion : "2026-01-28 .clover" ,
88} ) ;
99
1010export async function POST ( req : Request ) {
@@ -41,44 +41,44 @@ export async function POST(req: Request) {
4141 }
4242
4343 try {
44- if ( event . type === "checkout.session.completed" || event . type === "checkout.session.async_payment_succeeded" ) {
44+ if (
45+ event . type === "checkout.session.completed" ||
46+ event . type === "checkout.session.async_payment_succeeded"
47+ ) {
4548 const session = event . data . object as Stripe . Checkout . Session ;
4649
47- // ✅ Verify payment actually succeeded
50+ // For checkout.session.completed with a delayed payment, payment_status may be
51+ // 'unpaid' — we log it but do NOT fulfill. async_payment_succeeded will fire later.
4852 if ( session . payment_status !== "paid" ) {
49- console . warn ( `[Stripe Webhook] Order ${ session . metadata ?. order_id } is unpaid. Waiting for async_payment_succeeded.` ) ;
50- // Don't throw error, just log it and return 200 so Stripe doesn't retry this specific event.
51- // We will catch async_payment_succeeded later.
52- await supabase . from ( "stripe_events" ) . upsert ( {
53- id : event . id ,
54- type : event . type ,
55- processed : true ,
56- error : "Payment not completed yet" ,
57- } , { onConflict : 'id' } ) ;
53+ console . warn (
54+ `[Stripe Webhook] Session ${ session . id } payment_status=${ session . payment_status } . Awaiting async_payment_succeeded.`
55+ ) ;
56+ await supabase . from ( "stripe_events" ) . upsert (
57+ { id : event . id , type : event . type , processed : true , error : "payment_pending" } ,
58+ { onConflict : "id" }
59+ ) ;
5860 return NextResponse . json ( { received : true , status : "pending_payment" } ) ;
5961 }
6062
6163 const orderId = session . metadata ?. order_id ;
62- if ( ! orderId ) throw new Error ( "Missing order_id" ) ;
64+ if ( ! orderId ) throw new Error ( "Missing order_id in session metadata " ) ;
6365
64- // ✅ FIXED SHIPPING — check both SDK formats
66+ // Resolve shipping address — handle all known SDK shapes
6567 const sessionAny = session as any ;
6668 const shipping =
67- sessionAny . shipping_details ?? sessionAny . collected_information ?. shipping_details ?? sessionAny . customer_details ?. address ?? null ;
68-
69+ sessionAny . shipping_details ??
70+ sessionAny . collected_information ?. shipping_details ??
71+ null ;
6972 const customer = session . customer_details ?? null ;
7073
71- // ✅ Safe parsing
74+ // Safe-parse cart items from metadata
7275 let cartItems : any [ ] = [ ] ;
7376 try {
74- cartItems = session . metadata ?. items
75- ? JSON . parse ( session . metadata . items )
76- : [ ] ;
77+ cartItems = session . metadata ?. items ? JSON . parse ( session . metadata . items ) : [ ] ;
7778 } catch {
7879 cartItems = [ ] ;
7980 }
8081
81- // ✅ Defensive data extraction
8282 const shippingAddress = {
8383 name : shipping ?. name || customer ?. name || "" ,
8484 line1 : shipping ?. address ?. line1 || "" ,
@@ -100,96 +100,144 @@ export async function POST(req: Request) {
100100 } ;
101101
102102 const chosenShipping = session . shipping_cost ?? null ;
103+ const customerName = shippingAddress . name || customer ?. name || "Customer" ;
103104
104- // ✅ Atomic order update
105+ // Atomic order update — writes all columns that now exist in the schema
105106 const { error : updateError } = await supabase
106107 . from ( "orders" )
107108 . update ( {
108109 status : "paid" ,
109110 fulfillment_status : "unfulfilled" ,
110111 customer_email : customer ?. email || "" ,
111- customer_name : shippingAddress . name || customer ?. name || "Customer" ,
112+ customer_name : customerName ,
112113 customer_phone : customer ?. phone || null ,
113114 shipping_address : shippingAddress ,
114115 billing_address : billingAddress ,
115116 amount_total : session . amount_total ? session . amount_total / 100 : 0 ,
117+ stripe_session_id : session . id ,
116118 metadata : {
117119 stripe_session_id : session . id ,
118120 shipping_cost_cents : chosenShipping ?. amount_total ?? 0 ,
119- shipping_label : ( session as any ) . shipping_details ?. dynamic_tax_locations ?. [ 0 ] ?? "selected_in_stripe" ,
120121 } ,
121122 } )
122123 . eq ( "id" , orderId ) ;
123124
124125 if ( updateError ) throw updateError ;
125126
126- // ✅ Insert items only if order is paid correctly
127+ // Insert order items — guarded against double-insert if both events fire
127128 if ( cartItems . length > 0 ) {
128- // First check if items already exist (if async_payment_succeeded fired after session.completed)
129- const { data : existingItems } = await supabase . from ( "order_items" ) . select ( "id" ) . eq ( "order_id" , orderId ) ;
129+ const { data : existingItems } = await supabase
130+ . from ( "order_items" )
131+ . select ( "id" )
132+ . eq ( "order_id" , orderId ) ;
133+
130134 if ( ! existingItems || existingItems . length === 0 ) {
131- const { error : itemsError } = await supabase
132- . from ( "order_items" )
133- . insert (
134- cartItems . map ( ( item ) => ( {
135- order_id : orderId ,
136- product_id : item . product_id ,
137- variant_id : item . variant_id || null ,
138- quantity : item . quantity ,
139- price : item . price ,
140- fulfilled_quantity : 0 ,
141- } ) )
142- ) ;
143-
144- if ( itemsError ) {
145- console . error ( "Items insert failed" , itemsError ) ;
146- } else {
147- for ( const item of cartItems ) {
148- if ( item . variant_id ) {
149- const { data : v } = await supabase . from ( 'product_variants' ) . select ( 'stock' ) . eq ( 'id' , item . variant_id ) . single ( ) ;
150- if ( v && v . stock !== undefined ) {
151- await supabase . from ( 'product_variants' ) . update ( { stock : Math . max ( 0 , v . stock - item . quantity ) } ) . eq ( 'id' , item . variant_id ) ;
152- }
153- } else if ( item . product_id ) {
154- const { data : p } = await supabase . from ( 'products' ) . select ( 'stock' ) . eq ( 'id' , item . product_id ) . single ( ) ;
155- if ( p && p . stock !== undefined ) {
156- await supabase . from ( 'products' ) . update ( { stock : Math . max ( 0 , p . stock - item . quantity ) } ) . eq ( 'id' , item . product_id ) ;
157- }
135+ const { error : itemsError } = await supabase . from ( "order_items" ) . insert (
136+ cartItems . map ( ( item ) => ( {
137+ order_id : orderId ,
138+ product_id : item . product_id ,
139+ variant_id : item . variant_id || null ,
140+ quantity : item . quantity ,
141+ price : item . price ,
142+ fulfilled_quantity : 0 ,
143+ } ) )
144+ ) ;
145+
146+ if ( itemsError ) {
147+ console . error ( "[Stripe Webhook] Items insert failed:" , itemsError ) ;
148+ } else {
149+ // Deduct stock
150+ for ( const item of cartItems ) {
151+ if ( item . variant_id ) {
152+ const { data : v } = await supabase
153+ . from ( "product_variants" )
154+ . select ( "stock" )
155+ . eq ( "id" , item . variant_id )
156+ . single ( ) ;
157+ if ( v && v . stock !== undefined ) {
158+ await supabase
159+ . from ( "product_variants" )
160+ . update ( { stock : Math . max ( 0 , v . stock - item . quantity ) } )
161+ . eq ( "id" , item . variant_id ) ;
162+ }
163+ } else if ( item . product_id ) {
164+ const { data : p } = await supabase
165+ . from ( "products" )
166+ . select ( "stock" )
167+ . eq ( "id" , item . product_id )
168+ . single ( ) ;
169+ if ( p && p . stock !== undefined ) {
170+ await supabase
171+ . from ( "products" )
172+ . update ( { stock : Math . max ( 0 , p . stock - item . quantity ) } )
173+ . eq ( "id" , item . product_id ) ;
158174 }
159175 }
160176 }
177+ }
161178 }
162179 }
163180
164- // ✅ Log AFTER success
165- await supabase . from ( "stripe_events" ) . upsert ( {
166- id : event . id ,
167- type : event . type ,
168- processed : true ,
169- } , { onConflict : 'id' } ) ;
181+ // Mark event processed
182+ await supabase . from ( "stripe_events" ) . upsert (
183+ { id : event . id , type : event . type , processed : true } ,
184+ { onConflict : "id" }
185+ ) ;
170186
171- // ✅ Async email (non-blocking)
187+ // Send confirmation email (non-blocking)
172188 if ( customer ?. email ) {
173- // Send order confirmation only down this path securely
174189 import ( "@/lib/utils/email" )
175190 . then ( ( { sendOrderConfirmationEmail } ) =>
176191 sendOrderConfirmationEmail ( {
177192 orderId,
178193 customerEmail : customer . email ! ,
179- customerName : customer . name || "Customer" ,
194+ customerName,
180195 totalAmount : ( session . amount_total || 0 ) / 100 ,
181196 items : cartItems ,
182197 } )
183198 )
184199 . catch ( ( ) => { } ) ;
185200 }
201+
202+ } else if ( event . type === "checkout.session.async_payment_failed" ) {
203+ // Delayed payment method definitively failed — mark order cancelled
204+ const session = event . data . object as Stripe . Checkout . Session ;
205+ const orderId = session . metadata ?. order_id ;
206+ if ( orderId ) {
207+ await supabase
208+ . from ( "orders" )
209+ . update ( { status : "cancelled" , fulfillment_status : "cancelled" } )
210+ . eq ( "id" , orderId ) ;
211+ console . warn ( `[Stripe Webhook] Async payment FAILED for order ${ orderId } . Marked cancelled.` ) ;
212+ }
213+ await supabase . from ( "stripe_events" ) . upsert (
214+ { id : event . id , type : event . type , processed : true } ,
215+ { onConflict : "id" }
216+ ) ;
217+
218+ } else if ( event . type === "checkout.session.expired" ) {
219+ // Session timed out — mark order cancelled so admin knows
220+ const session = event . data . object as Stripe . Checkout . Session ;
221+ const orderId = session . metadata ?. order_id ;
222+ if ( orderId ) {
223+ await supabase
224+ . from ( "orders" )
225+ . update ( { status : "cancelled" , fulfillment_status : "cancelled" } )
226+ . eq ( "id" , orderId )
227+ . eq ( "status" , "pending" ) ; // only cancel if still pending — don't overwrite a paid order
228+ console . info ( `[Stripe Webhook] Session expired for order ${ orderId } . Marked cancelled.` ) ;
229+ }
230+ await supabase . from ( "stripe_events" ) . upsert (
231+ { id : event . id , type : event . type , processed : true } ,
232+ { onConflict : "id" }
233+ ) ;
234+
186235 } else {
187- // log other events
188- await supabase . from ( "stripe_events" ) . upsert ( {
189- id : event . id ,
190- type : event . type ,
191- processed : true ,
192- } , { onConflict : 'id' } ) ;
236+ // All other events — just log
237+ await supabase . from ( "stripe_events" ) . upsert (
238+ { id : event . id , type : event . type , processed : true } ,
239+ { onConflict : "id" }
240+ ) ;
193241 }
194242
195243 return NextResponse . json ( { received : true } ) ;
0 commit comments