Skip to content

Commit e97ddbe

Browse files
committed
fix(stripe): add missing db columns, fix webhook async handling, remove chatbase integration
1 parent 699348f commit e97ddbe

File tree

4 files changed

+127
-74
lines changed

4 files changed

+127
-74
lines changed

MASTER.sql

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,10 @@ ALTER TABLE public.orders
607607
ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
608608
ALTER TABLE public.orders
609609
ADD COLUMN IF NOT EXISTS shipping_label_url text;
610+
ALTER TABLE public.orders
611+
ADD COLUMN IF NOT EXISTS customer_name text;
612+
ALTER TABLE public.orders
613+
ADD COLUMN IF NOT EXISTS customer_phone text;
610614
-- Drop orphaned admin_audit_logs table (has RLS but no policies — not in schema)
611615
DROP TABLE IF EXISTS public.admin_audit_logs CASCADE;
612616
-- Sync legacy columns
@@ -667,8 +671,10 @@ CREATE TABLE IF NOT EXISTS public.order_items (
667671
SET NULL,
668672
quantity integer NOT NULL CHECK (quantity > 0),
669673
price numeric(10, 2) NOT NULL,
674+
fulfilled_quantity integer NOT NULL DEFAULT 0,
670675
created_at timestamptz NOT NULL DEFAULT now()
671676
);
677+
ALTER TABLE public.order_items ADD COLUMN IF NOT EXISTS fulfilled_quantity integer NOT NULL DEFAULT 0;
672678
ALTER TABLE public.order_items
673679
ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
674680
DO $$ BEGIN IF NOT EXISTS (
@@ -687,8 +693,12 @@ CREATE TABLE IF NOT EXISTS public.stripe_events (
687693
id text PRIMARY KEY,
688694
type text,
689695
data jsonb,
696+
processed boolean NOT NULL DEFAULT false,
697+
error text,
690698
created_at timestamptz NOT NULL DEFAULT now()
691699
);
700+
ALTER TABLE public.stripe_events ADD COLUMN IF NOT EXISTS processed boolean NOT NULL DEFAULT false;
701+
ALTER TABLE public.stripe_events ADD COLUMN IF NOT EXISTS error text;
692702
-- 1H. email_logs — idempotency log for sent emails (service_role only)
693703
CREATE TABLE IF NOT EXISTS public.email_logs (
694704
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),

app/api/webhook/stripe/route.ts

Lines changed: 117 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { NextResponse } from "next/server";
44
import { createClient as createAdminClient } from "@/lib/supabase/admin";
55

66
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
7-
apiVersion: "2026-02-25.clover",
7+
apiVersion: "2026-01-28.clover",
88
});
99

1010
export 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 SHIPPINGcheck both SDK formats
66+
// Resolve shipping addresshandle 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 });

app/layout.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,9 +167,6 @@ export default async function RootLayout({
167167
/>
168168
</CartProvider>
169169
<Analytics />
170-
<Script id="chatbase-loader" strategy="afterInteractive">
171-
{`(function(){if(!window.chatbase||window.chatbase("getState")!=="initialized"){window.chatbase=(...arguments)=>{if(!window.chatbase.q){window.chatbase.q=[]}window.chatbase.q.push(arguments)};window.chatbase=new Proxy(window.chatbase,{get(target,prop){if(prop==="q"){return target.q}return(...args)=>target(prop,...args)}})}const onLoad=function(){const script=document.createElement("script");const host="${process.env.NEXT_PUBLIC_CHATBASE_HOST || 'https://www.chatbase.co/'}";script.src=host.replace(/\\/$/, '') + '/embed.min.js';script.id="${process.env.NEXT_PUBLIC_CHATBOT_ID}";script.domain="www.chatbase.co";document.body.appendChild(script)};if(document.readyState==="complete"){onLoad()}else{window.addEventListener("load",onLoad)}})();`}
172-
</Script>
173170
</body>
174171
</html>
175172
);

lib/env.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,6 @@ const secondaryVars = [
2121
"SMTP_USER",
2222
"SMTP_PASS",
2323
"SMTP_FROM",
24-
"NEXT_PUBLIC_CHATBOT_ID",
25-
"NEXT_PUBLIC_CHATBASE_HOST",
2624
]
2725

2826
export function validateEnv() {

0 commit comments

Comments
 (0)