Refactor payment flow to use database triggers (Clean Architecture)
BREAKING CHANGE: Complete refactor of payment handling New Architecture: 1. pakasir-webhook (120 lines -> was 535 lines) - Only verifies signature and updates order status - Removed: SMTP, email templates, notification logic 2. Database Trigger (NEW) - Automatically fires when payment_status = 'paid' - Calls handle-order-paid edge function - Works for webhook AND manual admin updates 3. handle-order-paid (NEW edge function) - Grants user access for products - Creates Google Meet events for consulting - Sends notifications via send-email-v2 - Triggers webhooks Benefits: - Single Responsibility: Each function has one clear purpose - Trigger works for both webhook and manual admin actions - Easier to debug and maintain - Reusable notification system Migration required: Run 20241223_payment_trigger.sql 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
285
supabase/functions/handle-order-paid/index.ts
Normal file
285
supabase/functions/handle-order-paid/index.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
interface HandlePaidOrderRequest {
|
||||
order_id: string;
|
||||
user_id: string;
|
||||
total_amount: number;
|
||||
payment_method?: string;
|
||||
payment_provider?: string;
|
||||
}
|
||||
|
||||
serve(async (req: Request): Promise<Response> => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const body: HandlePaidOrderRequest = await req.json();
|
||||
const { order_id } = body;
|
||||
|
||||
console.log("[HANDLE-PAID] Processing paid order:", order_id);
|
||||
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
// Get full order details with items
|
||||
const { data: order, error: orderError } = await supabase
|
||||
.from("orders")
|
||||
.select(`
|
||||
*,
|
||||
profiles(email, full_name),
|
||||
order_items (
|
||||
product_id,
|
||||
product:products (title, type)
|
||||
)
|
||||
`)
|
||||
.eq("id", order_id)
|
||||
.single();
|
||||
|
||||
if (orderError || !order) {
|
||||
console.error("[HANDLE-PAID] Order not found:", order_id);
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: "Order not found" }),
|
||||
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
const userEmail = order.profiles?.email || "";
|
||||
const userName = order.profiles?.full_name || "Pelanggan";
|
||||
const orderItems = order.order_items as Array<{
|
||||
product_id: string;
|
||||
product: { title: string; type: string };
|
||||
}>;
|
||||
|
||||
// Check if this is a consulting order
|
||||
const hasConsulting = orderItems.some(item => item.product.type === "consulting");
|
||||
|
||||
if (hasConsulting) {
|
||||
console.log("[HANDLE-PAID] Consulting order detected, processing slots");
|
||||
|
||||
// Update consulting slots status
|
||||
await supabase
|
||||
.from("consulting_slots")
|
||||
.update({ status: "confirmed" })
|
||||
.eq("order_id", order_id);
|
||||
|
||||
// Create Google Meet events for each slot
|
||||
const { data: consultingSlots } = await supabase
|
||||
.from("consulting_slots")
|
||||
.select("*")
|
||||
.eq("order_id", order_id);
|
||||
|
||||
if (consultingSlots && consultingSlots.length > 0) {
|
||||
for (const slot of consultingSlots) {
|
||||
try {
|
||||
console.log("[HANDLE-PAID] Creating Google Meet for slot:", slot.id);
|
||||
|
||||
const topic = orderItems.find(i => i.product.type === "consulting")?.product.title || "Konsultasi 1-on-1";
|
||||
|
||||
const meetResponse = await fetch(
|
||||
`${supabaseUrl}/functions/v1/create-google-meet-event`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${Deno.env.get("SUPABASE_ANON_KEY")}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
slot_id: slot.id,
|
||||
date: slot.date,
|
||||
start_time: slot.start_time,
|
||||
end_time: slot.end_time,
|
||||
client_name: userName,
|
||||
client_email: userEmail,
|
||||
topic: topic,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (meetResponse.ok) {
|
||||
const meetData = await meetResponse.json();
|
||||
if (meetData.success) {
|
||||
console.log("[HANDLE-PAID] Meet created:", meetData.meet_link);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[HANDLE-PAID] Meet creation failed:", error);
|
||||
// Don't fail the entire process
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh slots to get meet_link
|
||||
const { data: updatedSlots } = await supabase
|
||||
.from("consulting_slots")
|
||||
.select("*")
|
||||
.eq("order_id", order_id);
|
||||
|
||||
const slots = (updatedSlots || []) as Array<{
|
||||
date: string;
|
||||
start_time: string;
|
||||
meet_link?: string;
|
||||
}>;
|
||||
|
||||
// Send consulting notification
|
||||
await sendNotification(supabase, "consulting_scheduled", userEmail, {
|
||||
nama: userName,
|
||||
email: userEmail,
|
||||
order_id: order_id.substring(0, 8),
|
||||
tanggal_pesanan: new Date().toLocaleDateString("id-ID"),
|
||||
total: `Rp ${order.total_amount.toLocaleString("id-ID")}`,
|
||||
metode_pembayaran: order.payment_method || "Unknown",
|
||||
tanggal_konsultasi: slots[0]?.date || "",
|
||||
jam_konsultasi: slots.map(s => s.start_time.substring(0, 5)).join(", "),
|
||||
link_meet: slots[0]?.meet_link || "Akan dikirim terpisah",
|
||||
}, {
|
||||
event: "consulting_scheduled",
|
||||
order_id,
|
||||
user_id: order.user_id,
|
||||
user_email: userEmail,
|
||||
user_name: userName,
|
||||
total_amount: order.total_amount,
|
||||
payment_method: order.payment_method,
|
||||
slots: updatedSlots,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Regular product order - grant access
|
||||
console.log("[HANDLE-PAID] Regular product order, granting access");
|
||||
|
||||
for (const item of orderItems) {
|
||||
// Check if access already exists
|
||||
const { data: existingAccess } = await supabase
|
||||
.from("user_access")
|
||||
.select("id")
|
||||
.eq("user_id", order.user_id)
|
||||
.eq("product_id", item.product_id)
|
||||
.maybeSingle();
|
||||
|
||||
if (!existingAccess) {
|
||||
await supabase
|
||||
.from("user_access")
|
||||
.insert({
|
||||
user_id: order.user_id,
|
||||
product_id: item.product_id,
|
||||
});
|
||||
console.log("[HANDLE-PAID] Access granted for product:", item.product_id);
|
||||
}
|
||||
}
|
||||
|
||||
const productTitles = orderItems.map(i => i.product.title);
|
||||
|
||||
// Send payment success notification
|
||||
await sendNotification(supabase, "payment_success", userEmail, {
|
||||
nama: userName,
|
||||
email: userEmail,
|
||||
order_id: order_id.substring(0, 8),
|
||||
tanggal_pesanan: new Date().toLocaleDateString("id-ID"),
|
||||
total: `Rp ${order.total_amount.toLocaleString("id-ID")}`,
|
||||
metode_pembayaran: order.payment_method || "Unknown",
|
||||
produk: productTitles.join(", "),
|
||||
link_akses: `${Deno.env.get("SITE_URL") || ""}/access`,
|
||||
}, {
|
||||
event: "payment_success",
|
||||
order_id,
|
||||
user_id: order.user_id,
|
||||
user_email: userEmail,
|
||||
user_name: userName,
|
||||
total_amount: order.total_amount,
|
||||
payment_method: order.payment_method,
|
||||
products: productTitles,
|
||||
});
|
||||
|
||||
// Send access granted notification
|
||||
await sendNotification(supabase, "access_granted", userEmail, {
|
||||
nama: userName,
|
||||
produk: productTitles.join(", "),
|
||||
}, {
|
||||
event: "access_granted",
|
||||
order_id,
|
||||
user_id: order.user_id,
|
||||
user_email: userEmail,
|
||||
user_name: userName,
|
||||
products: productTitles,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, order_id }),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("[HANDLE-PAID] Error:", error);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: error.message || "Internal server error"
|
||||
}),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function to send notification
|
||||
async function sendNotification(
|
||||
supabase: any,
|
||||
templateKey: string,
|
||||
shortcodeData: Record<string, string>,
|
||||
webhookPayload: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
console.log("[HANDLE-PAID] Sending notification:", templateKey);
|
||||
|
||||
// Fetch template
|
||||
const { data: template } = await supabase
|
||||
.from("notification_templates")
|
||||
.select("*")
|
||||
.eq("key", templateKey)
|
||||
.single();
|
||||
|
||||
if (!template) {
|
||||
console.log("[HANDLE-PAID] Template not found:", templateKey);
|
||||
return;
|
||||
}
|
||||
|
||||
// Send webhook if configured
|
||||
if (template.webhook_url) {
|
||||
try {
|
||||
await fetch(template.webhook_url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(webhookPayload),
|
||||
});
|
||||
console.log("[HANDLE-PAID] Webhook sent to:", template.webhook_url);
|
||||
} catch (error) {
|
||||
console.error("[HANDLE-PAID] Webhook failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Skip email if template is inactive
|
||||
if (!template.is_active) {
|
||||
console.log("[HANDLE-PAID] Template inactive, skipping email");
|
||||
return;
|
||||
}
|
||||
|
||||
// Send email via Mailketing
|
||||
await fetch(`${Deno.env.get("SUPABASE_URL")}/functions/v1/send-email-v2`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${Deno.env.get("SUPABASE_ANON_KEY")}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
to: shortcodeData.email,
|
||||
subject: template.email_subject,
|
||||
html: template.email_body_html,
|
||||
shortcodeData,
|
||||
}),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user