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:
@@ -27,6 +27,9 @@ verify_jwt = true
|
|||||||
[functions.create-google-meet-event]
|
[functions.create-google-meet-event]
|
||||||
verify_jwt = false
|
verify_jwt = false
|
||||||
|
|
||||||
|
[functions.handle-order-paid]
|
||||||
|
verify_jwt = false
|
||||||
|
|
||||||
[functions.send-consultation-reminder]
|
[functions.send-consultation-reminder]
|
||||||
verify_jwt = false
|
verify_jwt = false
|
||||||
|
|
||||||
|
|||||||
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,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
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 { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||||
import { SMTPClient } from "https://deno.land/x/denomailer@1.6.0/mod.ts";
|
|
||||||
|
|
||||||
const corsHeaders = {
|
const corsHeaders = {
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
@@ -21,203 +20,6 @@ interface PakasirWebhookPayload {
|
|||||||
completed_at?: string;
|
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, string>): 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<boolean> {
|
|
||||||
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<string, unknown>): Promise<boolean> {
|
|
||||||
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<string, string>,
|
|
||||||
webhookPayload: Record<string, unknown>
|
|
||||||
): Promise<void> {
|
|
||||||
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 = `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<style>
|
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
|
||||||
.header { background: #f8f9fa; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }
|
|
||||||
.content { background: #ffffff; padding: 30px; border: 1px solid #e9ecef; }
|
|
||||||
.footer { background: #f8f9fa; padding: 15px; text-align: center; font-size: 12px; color: #6c757d; border-radius: 0 0 8px 8px; }
|
|
||||||
a { color: #0066cc; }
|
|
||||||
.button { display: inline-block; padding: 12px 24px; background: #0066cc; color: white; text-decoration: none; border-radius: 6px; margin: 10px 0; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="content">
|
|
||||||
${body}
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
Email ini dikirim otomatis. Jangan membalas email ini.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
|
|
||||||
await sendEmail(
|
|
||||||
smtpSettings,
|
|
||||||
recipientEmail,
|
|
||||||
subject,
|
|
||||||
fullHtml,
|
|
||||||
brandFromName
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
serve(async (req) => {
|
serve(async (req) => {
|
||||||
// Handle CORS preflight
|
// Handle CORS preflight
|
||||||
if (req.method === "OPTIONS") {
|
if (req.method === "OPTIONS") {
|
||||||
@@ -263,7 +65,7 @@ serve(async (req) => {
|
|||||||
// Find the order by payment_reference or id
|
// Find the order by payment_reference or id
|
||||||
const { data: order, error: orderError } = await supabase
|
const { data: order, error: orderError } = await supabase
|
||||||
.from("orders")
|
.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}`)
|
.or(`payment_reference.eq.${payload.order_id},id.eq.${payload.order_id}`)
|
||||||
.single();
|
.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
|
const { error: updateError } = await supabase
|
||||||
.from("orders")
|
.from("orders")
|
||||||
.update({
|
.update({
|
||||||
@@ -304,221 +106,7 @@ serve(async (req) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[WEBHOOK] Order updated to paid:", order.id);
|
console.log("[WEBHOOK] Order updated to paid:", order.id, "- Trigger will handle the rest");
|
||||||
|
|
||||||
// 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<string, string> = {
|
|
||||||
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<string, unknown> = {
|
|
||||||
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<string, string> = {
|
|
||||||
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<string, unknown> = {
|
|
||||||
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<string, unknown> = {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(JSON.stringify({ success: true, order_id: order.id }), {
|
return new Response(JSON.stringify({ success: true, order_id: order.id }), {
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|||||||
93
supabase/migrations/20241223_payment_trigger.sql
Normal file
93
supabase/migrations/20241223_payment_trigger.sql
Normal file
@@ -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;
|
||||||
Reference in New Issue
Block a user