From 01579ac2999591f2733da2a9de3fb40b8a00736a Mon Sep 17 00:00:00 2001 From: dwindown Date: Tue, 23 Dec 2025 16:59:13 +0700 Subject: [PATCH] Refactor payment flow to use database triggers (Clean Architecture) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- supabase/config.toml | 3 + supabase/functions/handle-order-paid/index.ts | 285 ++++++++++++ supabase/functions/pakasir-webhook/index.ts | 422 +----------------- .../migrations/20241223_payment_trigger.sql | 93 ++++ 4 files changed, 386 insertions(+), 417 deletions(-) create mode 100644 supabase/functions/handle-order-paid/index.ts create mode 100644 supabase/migrations/20241223_payment_trigger.sql diff --git a/supabase/config.toml b/supabase/config.toml index 7072df6..5a9e9f2 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -27,6 +27,9 @@ verify_jwt = true [functions.create-google-meet-event] verify_jwt = false +[functions.handle-order-paid] +verify_jwt = false + [functions.send-consultation-reminder] verify_jwt = false diff --git a/supabase/functions/handle-order-paid/index.ts b/supabase/functions/handle-order-paid/index.ts new file mode 100644 index 0000000..095cbef --- /dev/null +++ b/supabase/functions/handle-order-paid/index.ts @@ -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 => { + 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, + webhookPayload: Record +): Promise { + 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, + }), + }); +} diff --git a/supabase/functions/pakasir-webhook/index.ts b/supabase/functions/pakasir-webhook/index.ts index cc745a7..50c94e4 100644 --- a/supabase/functions/pakasir-webhook/index.ts +++ b/supabase/functions/pakasir-webhook/index.ts @@ -1,6 +1,5 @@ import { serve } from "https://deno.land/std@0.190.0/http/server.ts"; -import { createClient, SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2.49.1"; -import { SMTPClient } from "https://deno.land/x/denomailer@1.6.0/mod.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; const corsHeaders = { "Access-Control-Allow-Origin": "*", @@ -21,203 +20,6 @@ interface PakasirWebhookPayload { completed_at?: string; } -interface SmtpSettings { - smtp_host: string; - smtp_port: number; - smtp_username: string; - smtp_password: string; - smtp_from_name: string; - smtp_from_email: string; - smtp_use_tls: boolean; -} - -interface NotificationTemplate { - id: string; - key: string; - name: string; - is_active: boolean; - email_subject: string; - email_body_html: string; - webhook_url: string; -} - -// Replace shortcodes in template -function replaceShortcodes(template: string, data: Record): string { - let result = template || ""; - for (const [key, value] of Object.entries(data)) { - result = result.replace(new RegExp(`\\{${key}\\}`, 'g'), value || ''); - } - return result; -} - -// Send email via SMTP -async function sendEmail( - smtp: SmtpSettings, - to: string, - subject: string, - htmlBody: string, - brandFromName?: string -): Promise { - if (!smtp.smtp_host || !smtp.smtp_username || !smtp.smtp_password) { - console.log("[EMAIL] SMTP not configured, skipping email"); - return false; - } - - try { - const client = new SMTPClient({ - connection: { - hostname: smtp.smtp_host, - port: smtp.smtp_port, - tls: smtp.smtp_use_tls, - auth: { - username: smtp.smtp_username, - password: smtp.smtp_password, - }, - }, - }); - - const fromName = smtp.smtp_from_name || brandFromName || "Notification"; - const fromEmail = smtp.smtp_from_email || smtp.smtp_username; - - await client.send({ - from: `${fromName} <${fromEmail}>`, - to: to, - subject: subject, - content: "auto", - html: htmlBody, - }); - - await client.close(); - console.log("[EMAIL] Sent successfully to:", to); - return true; - } catch (error) { - console.error("[EMAIL] Failed to send:", error); - return false; - } -} - -// Send webhook notification -async function sendWebhook(url: string, payload: Record): Promise { - if (!url) return false; - - try { - const response = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - console.log("[WEBHOOK] Sent to:", url, "Status:", response.status); - return response.ok; - } catch (error) { - console.error("[WEBHOOK] Failed to send to:", url, error); - return false; - } -} - -// Process notification for a specific event -async function processNotification( - supabase: SupabaseClient, - templateKey: string, - recipientEmail: string, - shortcodeData: Record, - webhookPayload: Record -): Promise { - console.log(`[NOTIFICATION] Processing ${templateKey} for ${recipientEmail}`); - - // Fetch template - const { data: templateData } = await supabase - .from("notification_templates") - .select("*") - .eq("key", templateKey) - .single(); - - if (!templateData) { - console.log(`[NOTIFICATION] Template ${templateKey} not found`); - return; - } - - const template = templateData as NotificationTemplate; - - // ALWAYS send webhook if URL is configured (regardless of is_active) - if (template.webhook_url) { - await sendWebhook(template.webhook_url, webhookPayload); - - // Update last_payload_example - await supabase - .from("notification_templates") - .update({ last_payload_example: webhookPayload, updated_at: new Date().toISOString() }) - .eq("id", template.id); - } - - // Only send email if is_active is true - if (!template.is_active) { - console.log(`[NOTIFICATION] Template ${templateKey} is not active, skipping email`); - return; - } - - // Fetch SMTP settings - const { data: smtpData } = await supabase - .from("notification_settings") - .select("*") - .single(); - - if (!smtpData) { - console.log("[NOTIFICATION] SMTP settings not configured"); - return; - } - - const smtpSettings = smtpData as SmtpSettings; - - // Fetch brand settings for fallback from_name - const { data: platformData } = await supabase - .from("platform_settings") - .select("brand_email_from_name") - .single(); - - const brandFromName = (platformData as { brand_email_from_name?: string } | null)?.brand_email_from_name; - - // Replace shortcodes - const subject = replaceShortcodes(template.email_subject, shortcodeData); - const body = replaceShortcodes(template.email_body_html, shortcodeData); - - // Wrap body in email template - const fullHtml = ` - - - - - - - - -
-
- ${body} -
- -
- -`; - - await sendEmail( - smtpSettings, - recipientEmail, - subject, - fullHtml, - brandFromName - ); -} - serve(async (req) => { // Handle CORS preflight if (req.method === "OPTIONS") { @@ -227,7 +29,7 @@ serve(async (req) => { try { // Verify webhook signature if configured const signature = req.headers.get("x-pakasir-signature") || req.headers.get("x-callback-token") || ""; - + if (PAKASIR_WEBHOOK_SECRET && signature !== PAKASIR_WEBHOOK_SECRET) { console.error("[WEBHOOK] Invalid signature"); return new Response(JSON.stringify({ error: "Invalid signature" }), { @@ -263,7 +65,7 @@ serve(async (req) => { // Find the order by payment_reference or id const { data: order, error: orderError } = await supabase .from("orders") - .select("id, user_id, total_amount, payment_status") + .select("id, payment_status") .or(`payment_reference.eq.${payload.order_id},id.eq.${payload.order_id}`) .single(); @@ -284,7 +86,7 @@ serve(async (req) => { }); } - // Update order status + // Update order status - this will trigger the database trigger const { error: updateError } = await supabase .from("orders") .update({ @@ -304,221 +106,7 @@ serve(async (req) => { }); } - console.log("[WEBHOOK] Order updated to paid:", order.id); - - // Get user profile - const { data: profile } = await supabase - .from("profiles") - .select("full_name, email") - .eq("id", order.user_id) - .single(); - - const userEmail = (profile as { email?: string } | null)?.email || ""; - const userName = (profile as { full_name?: string } | null)?.full_name || "Pelanggan"; - - // Check for consulting slots linked to this order - const { data: consultingSlots } = await supabase - .from("consulting_slots") - .select("*") - .eq("order_id", order.id); - - if (consultingSlots && consultingSlots.length > 0) { - // This is a consulting order - update slot statuses - const { error: slotUpdateError } = await supabase - .from("consulting_slots") - .update({ status: "confirmed" }) - .eq("order_id", order.id); - - if (slotUpdateError) { - console.error("[WEBHOOK] Failed to update consulting slots:", slotUpdateError); - } else { - console.log("[WEBHOOK] Consulting slots confirmed for order:", order.id); - } - - // Create Google Meet events for each consulting slot - for (const slot of consultingSlots) { - try { - console.log("[WEBHOOK] Creating Google Meet event for slot:", slot.id); - - const { data: settings } = await supabase - .from("platform_settings") - .select("integration_google_calendar_id") - .single(); - - if (!settings?.integration_google_calendar_id) { - console.log("[WEBHOOK] Google Calendar not configured, skipping Meet creation"); - continue; - } - - // Get product info for the topic - const { data: orderItems } = await supabase - .from("order_items") - .select("product_id, products(name)") - .eq("order_id", order.id) - .limit(1); - - const topic = orderItems?.[0]?.products?.name || "Konsultasi 1-on-1"; - - const meetResponse = await fetch( - `${Deno.env.get("SUPABASE_URL")}/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("[WEBHOOK] Google Meet event created:", meetData.meet_link); - } else { - console.error("[WEBHOOK] Failed to create Meet event:", meetData.message); - } - } else { - console.error("[WEBHOOK] Meet creation API error:", meetResponse.status); - } - } catch (error) { - console.error("[WEBHOOK] Error creating Google Meet event:", error); - // Don't fail the webhook if Meet creation fails - } - } - - // Refresh slots to get updated meet_link - const { data: updatedSlots } = await supabase - .from("consulting_slots") - .select("*") - .eq("order_id", order.id); - - // Format consulting slot details for notification - const slots = (updatedSlots || consultingSlots) as Array<{ date: string; start_time: string; end_time: string; meet_link?: string }>; - - const shortcodeData: Record = { - 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: payload.payment_method || "Pakasir", - 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", - }; - - const webhookPayload: Record = { - event: "consulting_scheduled", - order_id: order.id, - user_id: order.user_id, - user_email: userEmail, - user_name: userName, - total_amount: order.total_amount, - payment_method: payload.payment_method, - slots: consultingSlots, - timestamp: new Date().toISOString(), - }; - - // Send consulting_scheduled notification - await processNotification(supabase, "consulting_scheduled", userEmail, shortcodeData, webhookPayload); - - } else { - // Regular product order - grant access - const { data: orderItems, error: itemsError } = await supabase - .from("order_items") - .select("product_id, product:products(title)") - .eq("order_id", order.id); - - if (itemsError) { - console.error("[WEBHOOK] Failed to fetch order items:", itemsError); - } - - const productTitles: string[] = []; - - // Grant user_access for each product - if (orderItems && orderItems.length > 0) { - for (const item of orderItems as Array<{ product_id: string; product: { title: string } | { title: string }[] | null }>) { - const productId = item.product_id; - const productData = Array.isArray(item.product) ? item.product[0] : item.product; - - // Check if access already exists - const { data: existingAccess } = await supabase - .from("user_access") - .select("id") - .eq("user_id", order.user_id) - .eq("product_id", productId) - .maybeSingle(); - - if (!existingAccess) { - const { error: accessError } = await supabase - .from("user_access") - .insert({ - user_id: order.user_id, - product_id: productId, - }); - - if (accessError) { - console.error("[WEBHOOK] Failed to grant access for product:", productId, accessError); - } else { - console.log("[WEBHOOK] Granted access for product:", productId); - } - } - - if (productData?.title) { - productTitles.push(productData.title); - } - } - } - - const shortcodeData: Record = { - 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: payload.payment_method || "Pakasir", - produk: productTitles.join(", "), - link_akses: `${Deno.env.get("SITE_URL") || ""}/access`, - }; - - const webhookPayload: Record = { - event: "payment_success", - order_id: order.id, - user_id: order.user_id, - user_email: userEmail, - user_name: userName, - total_amount: order.total_amount, - payment_method: payload.payment_method, - products: productTitles, - timestamp: new Date().toISOString(), - }; - - // Send payment_success notification - await processNotification(supabase, "payment_success", userEmail, shortcodeData, webhookPayload); - - // Also send access_granted notification - if (productTitles.length > 0) { - const accessWebhookPayload: Record = { - event: "access_granted", - order_id: order.id, - user_id: order.user_id, - user_email: userEmail, - user_name: userName, - products: productTitles, - timestamp: new Date().toISOString(), - }; - await processNotification(supabase, "access_granted", userEmail, shortcodeData, accessWebhookPayload); - } - } + console.log("[WEBHOOK] Order updated to paid:", order.id, "- Trigger will handle the rest"); return new Response(JSON.stringify({ success: true, order_id: order.id }), { status: 200, diff --git a/supabase/migrations/20241223_payment_trigger.sql b/supabase/migrations/20241223_payment_trigger.sql new file mode 100644 index 0000000..f202fd6 --- /dev/null +++ b/supabase/migrations/20241223_payment_trigger.sql @@ -0,0 +1,93 @@ +-- ============================================================================ +-- Payment Trigger Architecture +-- ============================================================================ +-- This refactors the payment flow to use database triggers instead of +-- handling everything in the webhook function. +-- +-- Flow: +-- 1. pakasir-webhook or admin updates order.payment_status = 'paid' +-- 2. Trigger fires -> calls handle_paid_order() function +-- 3. handle_paid_order() calls handle-order-paid edge function +-- 4. Edge function handles: access grants, notifications, meet links +-- ============================================================================ + +-- Enable pg_net extension for HTTP calls from PostgreSQL +CREATE EXTENSION IF NOT EXISTS pg_net; + +-- ============================================================================ +-- Function: handle_paid_order +-- Purpose: Called by trigger when order payment_status becomes 'paid' +-- Calls the edge function to handle all post-payment actions +-- ============================================================================ +CREATE OR REPLACE FUNCTION handle_paid_order() +RETURNS TRIGGER AS $$ +DECLARE + edge_function_url TEXT; + edge_function_response TEXT; + order_data JSON; +BEGIN + -- Only proceed if payment_status changed to 'paid' + IF (NEW.payment_status != 'paid' OR OLD.payment_status = 'paid') THEN + RETURN NEW; + END IF; + + -- Log the payment event + RAISE NOTICE 'Order % payment status changed to paid', NEW.id; + + -- Get the edge function URL from environment + edge_function_url := current_setting('app.base_url', true) || '/functions/v1/handle-order-paid'; + + -- Prepare order data + order_data := json_build_object( + 'order_id', NEW.id, + 'user_id', NEW.user_id, + 'total_amount', NEW.total_amount, + 'payment_method', NEW.payment_method, + 'payment_provider', NEW.payment_provider + ); + + -- Call the edge function asynchronously via pg_net + -- We use pg_net to avoid blocking the transaction + PERFORM net.http_post( + url := edge_function_url, + headers := json_build_object( + 'Content-Type', 'application/json', + 'Authorization', 'Bearer ' || current_setting('app.service_role_key', true) + ), + body := order_data + ); + + RAISE NOTICE 'Called handle-order-paid for order %', NEW.id; + + RETURN NEW; +EXCEPTION + WHEN OTHERS THEN + -- Log error but don't fail the transaction + RAISE WARNING 'Failed to call handle-order-paid for order %: %', NEW.id, SQLERRM; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- ============================================================================ +-- Trigger: on_order_paid +-- Purpose: Fires handle_paid_order() when order payment status changes +-- ============================================================================ +DROP TRIGGER IF EXISTS on_order_paid ON orders; + +CREATE TRIGGER on_order_paid + AFTER UPDATE ON orders + FOR EACH ROW + WHEN (NEW.payment_status = 'paid' AND OLD.payment_status IS DISTINCT FROM NEW.payment_status) + EXECUTE FUNCTION handle_paid_order(); + +-- ============================================================================ +-- Comments for documentation +-- ============================================================================ +COMMENT ON FUNCTION handle_paid_order() IS 'Triggered when order payment_status becomes "paid". Calls handle-order-paid edge function to handle access grants, notifications, and Meet link creation.'; +COMMENT ON TRIGGER on_order_paid ON orders IS 'Fires handle_paid_order() function when payment status changes to paid'; + +-- ============================================================================ +-- Grant necessary permissions +-- ============================================================================ +GRANT EXECUTE ON FUNCTION handle_paid_order() TO postgres; +GRANT USAGE ON SCHEMA net TO postgres;